mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix: refine system update workflow
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -28,7 +28,7 @@ use super::{
|
||||
update_settings,
|
||||
},
|
||||
system::routes::{
|
||||
check_updates, perform_update, restart, rollback, update_event_stream, update_status,
|
||||
perform_update, restart, rollback, update_detail, update_event_stream, update_status,
|
||||
version,
|
||||
},
|
||||
};
|
||||
@@ -49,7 +49,7 @@ pub fn router() -> Router<AppState> {
|
||||
post(regenerate_admin_api_key),
|
||||
)
|
||||
.route("/api/admin/system/version", get(version))
|
||||
.route("/api/admin/system/check-updates", get(check_updates))
|
||||
.route("/api/admin/system/update-detail", get(update_detail))
|
||||
.route("/api/admin/system/update-events", get(update_event_stream))
|
||||
.route("/api/admin/system/update", post(perform_update))
|
||||
.route("/api/admin/system/update-status", get(update_status))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! 管理端系统路由入口。
|
||||
|
||||
pub(crate) use super::updater::{
|
||||
check_updates, perform_update, restart, rollback, update_event_stream, update_status, version,
|
||||
perform_update, restart, rollback, update_detail, update_event_stream, update_status, version,
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ use archive::{extract_release_archive, replace_release_files, rollback_release_u
|
||||
mod release;
|
||||
use release::{
|
||||
check_latest_release, fetch_latest_release, select_release_archive, update_info_from_release,
|
||||
GitHubRelease,
|
||||
GitHubRelease, UpdateInfoData,
|
||||
};
|
||||
|
||||
const APP_BINARY_NAME: &str = "codex-proxy-rs";
|
||||
@@ -58,8 +58,8 @@ static UPDATE_EVENT_SENDER: OnceLock<broadcast::Sender<SystemUpdateEvent>> = Onc
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CheckUpdateQuery {
|
||||
force: Option<bool>,
|
||||
pub(crate) struct UpdateDetailQuery {
|
||||
refresh: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -71,6 +71,10 @@ struct VersionData {
|
||||
deployment_mode: String,
|
||||
deployment_mode_label: String,
|
||||
update_channel: String,
|
||||
latest_version: String,
|
||||
has_update: bool,
|
||||
update_cached: bool,
|
||||
update_warning: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -123,19 +127,20 @@ struct SystemUpdateConfig {
|
||||
/// `GET /api/admin/system/version`
|
||||
pub(crate) async fn version(_auth: AdminAuth) -> Result<impl IntoResponse, AdminError> {
|
||||
let config = SystemUpdateConfig::from_env();
|
||||
let update_info = check_latest_release(&config, false).await;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(config.version_data()),
|
||||
AdminEnvelope::ok(config.version_data(&update_info)),
|
||||
))
|
||||
}
|
||||
|
||||
/// `GET /api/admin/system/check-updates`
|
||||
pub(crate) async fn check_updates(
|
||||
/// `GET /api/admin/system/update-detail`
|
||||
pub(crate) async fn update_detail(
|
||||
_auth: AdminAuth,
|
||||
Query(query): Query<CheckUpdateQuery>,
|
||||
Query(query): Query<UpdateDetailQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let config = SystemUpdateConfig::from_env();
|
||||
let info = check_latest_release(&config, query.force.unwrap_or(false)).await;
|
||||
let info = check_latest_release(&config, query.refresh.unwrap_or(false)).await;
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(info)))
|
||||
}
|
||||
|
||||
@@ -388,7 +393,7 @@ impl SystemUpdateConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn version_data(&self) -> VersionData {
|
||||
fn version_data(&self, update_info: &UpdateInfoData) -> VersionData {
|
||||
VersionData {
|
||||
version: self.version.clone(),
|
||||
git_sha: self.git_sha.clone(),
|
||||
@@ -396,6 +401,10 @@ impl SystemUpdateConfig {
|
||||
deployment_mode: self.deployment_mode.clone(),
|
||||
deployment_mode_label: deployment_mode_label(&self.deployment_mode).to_string(),
|
||||
update_channel: self.update_channel.clone(),
|
||||
latest_version: update_info.latest_version.clone(),
|
||||
has_update: update_info.has_update,
|
||||
update_cached: update_info.cached,
|
||||
update_warning: update_info.warning.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const ASSET_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
|
||||
const CONTENT_SECURITY_POLICY: &str = concat!(
|
||||
"default-src 'self'; ",
|
||||
"script-src 'self'; ",
|
||||
"style-src 'self'; ",
|
||||
"style-src 'self' 'unsafe-inline'; ",
|
||||
"img-src 'self' data:; ",
|
||||
"font-src 'self' data:; ",
|
||||
"connect-src 'self'; ",
|
||||
|
||||
@@ -46,7 +46,10 @@ const VERSION_NEW: &str = "0.2.0";
|
||||
#[tokio::test]
|
||||
async fn version_should_return_backend_build_metadata() {
|
||||
let _guard = SYSTEM_ENV_LOCK.lock().await;
|
||||
set_system_update_env("zyycn/codex-proxy-rs-version-route", "http://127.0.0.1:9");
|
||||
let repository = "zyycn/codex-proxy-rs-version-route";
|
||||
let github = MockServer::start().await;
|
||||
mount_latest_release(&github, repository, "0.2.0").await;
|
||||
set_system_update_env(repository, &github.uri());
|
||||
std::env::set_var("CPR_GIT_SHA", "version-test-sha");
|
||||
std::env::set_var("CPR_BUILD_TIME", "2026-07-01T00:00:00Z");
|
||||
let (app, _dir) = admin_system_test_app("system-version.sqlite").await;
|
||||
@@ -72,22 +75,26 @@ async fn version_should_return_backend_build_metadata() {
|
||||
&& body["data"]["deploymentMode"] == "docker"
|
||||
&& body["data"]["deploymentModeLabel"] == "Docker"
|
||||
&& body["data"]["updateChannel"] == "stable"
|
||||
&& body["data"]["latestVersion"] == VERSION_NEW
|
||||
&& body["data"]["hasUpdate"] == true
|
||||
&& body["data"]["updateCached"] == false
|
||||
&& body["data"]["updateWarning"].is_null()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_updates_should_use_cached_release_when_not_forced() {
|
||||
async fn update_detail_should_use_cached_release_when_not_refreshed() {
|
||||
let _guard = SYSTEM_ENV_LOCK.lock().await;
|
||||
let repository = "zyycn/codex-proxy-rs-cache-route";
|
||||
let success_server = MockServer::start().await;
|
||||
mount_latest_release(&success_server, repository, "0.2.0").await;
|
||||
set_system_update_env(repository, &success_server.uri());
|
||||
let (app, _dir) = admin_system_test_app("system-cache.sqlite").await;
|
||||
let initial = get_check_updates(&app, true, "req_system_cache_initial").await;
|
||||
let initial = get_update_detail(&app, true, "req_system_cache_initial").await;
|
||||
|
||||
let failing_server = MockServer::start().await;
|
||||
set_system_update_env(repository, &failing_server.uri());
|
||||
let cached = get_check_updates(&app, false, "req_system_cache_cached").await;
|
||||
let cached = get_update_detail(&app, false, "req_system_cache_cached").await;
|
||||
|
||||
assert!(
|
||||
initial["hasUpdate"] == true
|
||||
@@ -97,14 +104,14 @@ async fn check_updates_should_use_cached_release_when_not_forced() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_updates_should_fallback_to_cache_when_forced_fetch_fails() {
|
||||
async fn update_detail_should_fallback_to_cache_when_refresh_fetch_fails() {
|
||||
let _guard = SYSTEM_ENV_LOCK.lock().await;
|
||||
let repository = "zyycn/codex-proxy-rs-cache-forced-route";
|
||||
let success_server = MockServer::start().await;
|
||||
mount_latest_release(&success_server, repository, "0.3.0").await;
|
||||
set_system_update_env(repository, &success_server.uri());
|
||||
let (app, _dir) = admin_system_test_app("system-cache-forced.sqlite").await;
|
||||
let initial = get_check_updates(&app, true, "req_system_cache_forced_initial").await;
|
||||
let initial = get_update_detail(&app, true, "req_system_cache_forced_initial").await;
|
||||
|
||||
let failing_server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
@@ -113,7 +120,7 @@ async fn check_updates_should_fallback_to_cache_when_forced_fetch_fails() {
|
||||
.mount(&failing_server)
|
||||
.await;
|
||||
set_system_update_env(repository, &failing_server.uri());
|
||||
let fallback = get_check_updates(&app, true, "req_system_cache_forced_fallback").await;
|
||||
let fallback = get_update_detail(&app, true, "req_system_cache_forced_fallback").await;
|
||||
|
||||
assert!(
|
||||
initial["hasUpdate"] == true
|
||||
@@ -124,7 +131,7 @@ async fn check_updates_should_fallback_to_cache_when_forced_fetch_fails() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_updates_should_report_source_build_as_unsupported() {
|
||||
async fn update_detail_should_report_source_build_as_unsupported() {
|
||||
let _guard = SYSTEM_ENV_LOCK.lock().await;
|
||||
let repository = "zyycn/codex-proxy-rs-source-route";
|
||||
let github = MockServer::start().await;
|
||||
@@ -133,7 +140,7 @@ async fn check_updates_should_report_source_build_as_unsupported() {
|
||||
std::env::set_var("CPR_BUILD_TYPE", "source");
|
||||
let (app, _dir) = admin_system_test_app("system-source-build.sqlite").await;
|
||||
|
||||
let info = get_check_updates(&app, true, "req_system_source_build").await;
|
||||
let info = get_update_detail(&app, true, "req_system_source_build").await;
|
||||
|
||||
assert!(
|
||||
info["currentVersion"] == VERSION_OLD
|
||||
@@ -147,7 +154,7 @@ async fn check_updates_should_report_source_build_as_unsupported() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_updates_should_reject_untrusted_github_api_base() {
|
||||
async fn update_detail_should_reject_untrusted_github_api_base() {
|
||||
let _guard = SYSTEM_ENV_LOCK.lock().await;
|
||||
set_system_update_env(
|
||||
"zyycn/codex-proxy-rs-untrusted-api-base-route",
|
||||
@@ -155,7 +162,7 @@ async fn check_updates_should_reject_untrusted_github_api_base() {
|
||||
);
|
||||
let (app, _dir) = admin_system_test_app("system-untrusted-api-base.sqlite").await;
|
||||
|
||||
let info = get_check_updates(&app, true, "req_system_untrusted_api_base").await;
|
||||
let info = get_update_detail(&app, true, "req_system_untrusted_api_base").await;
|
||||
|
||||
assert!(
|
||||
info["hasUpdate"] == false
|
||||
@@ -763,13 +770,13 @@ async fn update_events_should_open_authenticated_sse_stream() {
|
||||
assert!(response.status() == StatusCode::OK && content_type.starts_with("text/event-stream"));
|
||||
}
|
||||
|
||||
async fn get_check_updates(app: &axum::Router, force: bool, request_id: &str) -> Value {
|
||||
async fn get_update_detail(app: &axum::Router, refresh: bool, request_id: &str) -> Value {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!("/api/admin/system/check-updates?force={force}"))
|
||||
.uri(format!("/api/admin/system/update-detail?refresh={refresh}"))
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", request_id)
|
||||
.body(Body::empty())
|
||||
|
||||
@@ -66,7 +66,7 @@ async fn server_router_should_serve_frontend_assets_without_shadowing_api_routes
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
csp.contains("style-src 'self';") && !csp.contains("unsafe-inline"),
|
||||
csp.contains("script-src 'self';") && csp.contains("style-src 'self' 'unsafe-inline';"),
|
||||
"unexpected content security policy: {csp}"
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -180,7 +180,7 @@ GHCR 镜像发布 `linux/amd64` 和 `linux/arm64` 多平台 manifest。
|
||||
核心接口:
|
||||
|
||||
- `GET /api/admin/system/version`
|
||||
- `GET /api/admin/system/check-updates`
|
||||
- `GET /api/admin/system/update-detail`
|
||||
- `GET /api/admin/system/update-events`
|
||||
- `GET /api/admin/system/update-status`
|
||||
- `POST /api/admin/system/update`
|
||||
|
||||
@@ -10,6 +10,10 @@ export interface SystemVersion {
|
||||
deploymentMode: string
|
||||
deploymentModeLabel: string
|
||||
updateChannel: string
|
||||
latestVersion: string
|
||||
hasUpdate: boolean
|
||||
updateCached: boolean
|
||||
updateWarning?: string
|
||||
}
|
||||
|
||||
export interface SystemUpdateInfo {
|
||||
@@ -49,11 +53,11 @@ export function getSystemVersion() {
|
||||
})
|
||||
}
|
||||
|
||||
export function checkSystemUpdates(force = false) {
|
||||
export function getSystemUpdateDetail(refresh = false) {
|
||||
return request<SystemUpdateInfo>({
|
||||
url: '/api/admin/system/check-updates',
|
||||
url: '/api/admin/system/update-detail',
|
||||
method: 'GET',
|
||||
params: { force },
|
||||
params: { refresh },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import {
|
||||
SYSTEM_UPDATE_EVENTS_URL,
|
||||
checkSystemUpdates,
|
||||
getSystemVersion,
|
||||
getSystemUpdateDetail,
|
||||
performSystemUpdate,
|
||||
restartSystem,
|
||||
type SystemUpdateInfo,
|
||||
@@ -38,9 +38,11 @@ const updateStreamError = shallowRef('')
|
||||
const updateEventSource = shallowRef<EventSource | null>(null)
|
||||
|
||||
let restartTimer: number | undefined
|
||||
let loadVersionPromise: Promise<SystemVersion> | undefined
|
||||
let loadSystemPromise: Promise<void> | undefined
|
||||
const maxUpdateLogs = 200
|
||||
|
||||
const hasUpdate = computed(() => Boolean(updateInfo.value?.hasUpdate))
|
||||
const hasUpdate = computed(() => Boolean(updateInfo.value?.hasUpdate ?? version.value?.hasUpdate))
|
||||
const isReleaseBuild = computed(() => updateInfo.value?.buildType === 'release')
|
||||
const canUpdate = computed(
|
||||
() =>
|
||||
@@ -121,30 +123,47 @@ function disconnectUpdateEvents() {
|
||||
updateStreaming.value = false
|
||||
}
|
||||
|
||||
async function loadSystem(force = false) {
|
||||
if (loading.value) return
|
||||
async function loadSystem(refresh = false) {
|
||||
if (loadSystemPromise) return loadSystemPromise
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const [versionData, updateData] = await Promise.all([
|
||||
getSystemVersion(),
|
||||
checkSystemUpdates(force),
|
||||
])
|
||||
version.value = versionData
|
||||
loadSystemPromise = (async () => {
|
||||
const updateData = await getSystemUpdateDetail(refresh)
|
||||
updateInfo.value = updateData
|
||||
if (!version.value) {
|
||||
version.value = await getSystemVersion()
|
||||
}
|
||||
loadedOnce.value = true
|
||||
})()
|
||||
|
||||
try {
|
||||
await loadSystemPromise
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadSystemPromise = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUpdates(force = true) {
|
||||
async function loadVersion() {
|
||||
if (loadVersionPromise) return loadVersionPromise
|
||||
|
||||
loadVersionPromise = getSystemVersion()
|
||||
try {
|
||||
const versionData = await loadVersionPromise
|
||||
version.value = versionData
|
||||
return versionData
|
||||
} finally {
|
||||
loadVersionPromise = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUpdates(refresh = true) {
|
||||
if (checking.value) return updateInfo.value
|
||||
|
||||
checking.value = true
|
||||
resetUpdateResult()
|
||||
try {
|
||||
updateInfo.value = await checkSystemUpdates(force)
|
||||
updateInfo.value = await getSystemUpdateDetail(refresh)
|
||||
if (!version.value) {
|
||||
version.value = await getSystemVersion()
|
||||
}
|
||||
@@ -268,6 +287,7 @@ export function useSystemUpdate() {
|
||||
hasUpdate,
|
||||
isReleaseBuild,
|
||||
canUpdate,
|
||||
loadVersion,
|
||||
loadSystem,
|
||||
checkUpdates,
|
||||
updateNow,
|
||||
|
||||
@@ -33,7 +33,7 @@ const uiStore = useUiStore()
|
||||
const { effectiveTheme } = storeToRefs(uiStore)
|
||||
const { toggleTheme } = uiStore
|
||||
const preferredMotion = usePreferredReducedMotion()
|
||||
const { version, hasUpdate, loadSystem } = useSystemUpdate()
|
||||
const { version, hasUpdate, loadedOnce, loadVersion, loadSystem } = useSystemUpdate()
|
||||
|
||||
const navItems = [
|
||||
{ label: '概览', icon: LayoutDashboard, path: '/' },
|
||||
@@ -52,6 +52,24 @@ function navigate(path: string) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
const systemUpdateOpening = shallowRef(false)
|
||||
|
||||
async function openSystemUpdate() {
|
||||
if (systemUpdateOpen.value || systemUpdateOpening.value) return
|
||||
|
||||
systemUpdateOpening.value = true
|
||||
try {
|
||||
if (!loadedOnce.value) {
|
||||
await loadSystem(false)
|
||||
}
|
||||
} catch {
|
||||
// 弹窗打开后由弹窗内的加载逻辑提示失败原因。
|
||||
} finally {
|
||||
systemUpdateOpening.value = false
|
||||
systemUpdateOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
@@ -77,7 +95,9 @@ const systemUpdateOpen = shallowRef(false)
|
||||
const themeToggleLabel = computed(() =>
|
||||
effectiveTheme.value === 'dark' ? '切换浅色模式' : '切换暗黑模式',
|
||||
)
|
||||
const versionLabel = computed(() => `v${version.value?.version || '...'}`)
|
||||
const versionText = computed(() => version.value?.version.trim() ?? '')
|
||||
const hasVersionLabel = computed(() => versionText.value.length > 0)
|
||||
const versionLabel = computed(() => `v${versionText.value}`)
|
||||
const updateButtonLabel = computed(() =>
|
||||
hasUpdate.value ? '发现新版本,打开系统更新' : '打开系统更新',
|
||||
)
|
||||
@@ -200,7 +220,7 @@ onMounted(() => {
|
||||
})
|
||||
}
|
||||
animateSidebarLabels(Boolean(props.collapsed))
|
||||
void loadSystem(false).catch(() => undefined)
|
||||
void loadVersion().catch(() => undefined)
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -250,15 +270,18 @@ watch(
|
||||
Rust build
|
||||
</span>
|
||||
<button
|
||||
v-if="hasVersionLabel"
|
||||
type="button"
|
||||
class="inline-flex h-4.5 min-w-0 items-center gap-1 rounded-(--cp-input-radius-small) border-0 px-1.5 font-mono text-[10px] leading-none font-[720] cursor-pointer transition-colors"
|
||||
:class="
|
||||
:class="[
|
||||
hasUpdate
|
||||
? 'bg-(--cp-success-bg) text-(--cp-success-text) hover:bg-(--cp-success-bg-hover)'
|
||||
: 'bg-(--cp-bg-subtle) text-(--cp-text-muted) hover:bg-(--cp-bg-muted) hover:text-(--cp-text-secondary)'
|
||||
"
|
||||
: 'bg-(--cp-bg-subtle) text-(--cp-text-muted) hover:bg-(--cp-bg-muted) hover:text-(--cp-text-secondary)',
|
||||
systemUpdateOpening ? 'cursor-wait opacity-70' : '',
|
||||
]"
|
||||
:title="updateButtonLabel"
|
||||
@click="systemUpdateOpen = true"
|
||||
:disabled="systemUpdateOpening"
|
||||
@click="openSystemUpdate"
|
||||
>
|
||||
<span>{{ versionLabel }}</span>
|
||||
<ArrowUpCircle v-if="hasUpdate" class="size-3 shrink-0 text-(--cp-success)" />
|
||||
@@ -309,12 +332,13 @@ watch(
|
||||
|
||||
<div class="flex items-center" :class="collapsed ? 'grid gap-1' : 'gap-1'">
|
||||
<BaseButton
|
||||
v-if="collapsed && hasUpdate"
|
||||
v-if="collapsed"
|
||||
icon-only
|
||||
:variant="hasUpdate ? 'success' : 'ghost'"
|
||||
size="default"
|
||||
:label="updateButtonLabel"
|
||||
@click="systemUpdateOpen = true"
|
||||
:loading="systemUpdateOpening"
|
||||
@click="openSystemUpdate"
|
||||
>
|
||||
<ArrowUpCircle :size="19" />
|
||||
</BaseButton>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@lucide/vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseEmpty from '@/components/base/BaseEmpty.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
@@ -124,9 +125,6 @@ const updateLogRows = computed(() =>
|
||||
})),
|
||||
)
|
||||
|
||||
const showUpdateProgress = computed(
|
||||
() => hasUpdate.value || updating.value || updateSuccess.value || updateLogRows.value.length > 0,
|
||||
)
|
||||
const renderedReleaseNotes = computed(() => renderMarkdown(updateInfo.value?.notes))
|
||||
|
||||
const streamStatusLabel = computed(() => {
|
||||
@@ -289,9 +287,21 @@ onUnmounted(() => {
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="showUpdateProgress"
|
||||
class="overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-subtle)"
|
||||
v-if="renderedReleaseNotes"
|
||||
class="grid gap-2 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="m-0 text-[13px] font-[760] text-(--cp-text-primary)">发布说明</p>
|
||||
<span class="font-mono text-[11px] font-[650] text-(--cp-text-muted)">
|
||||
{{ displayValue(updateInfo?.latestVersion) }}
|
||||
</span>
|
||||
</div>
|
||||
<BaseScrollbar class="-mx-4" max-height="160px" view-class="px-4 pr-5" track-inset="none">
|
||||
<div class="release-notes" v-html="renderedReleaseNotes" />
|
||||
</BaseScrollbar>
|
||||
</section>
|
||||
|
||||
<section class="overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-subtle)">
|
||||
<header class="flex items-center justify-between gap-3 px-4 pt-3.5 pb-2.5">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Terminal class="size-4 shrink-0 text-(--cp-success)" />
|
||||
@@ -309,8 +319,13 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<BaseScrollbar ref="updateLogScrollbar" height="260px" view-class="min-h-full px-4 pb-4">
|
||||
<div v-if="updateLogRows.length" class="grid gap-2">
|
||||
<BaseScrollbar
|
||||
v-if="updateLogRows.length"
|
||||
ref="updateLogScrollbar"
|
||||
height="260px"
|
||||
view-class="min-h-full px-4 pb-4"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<div
|
||||
v-for="log in updateLogRows"
|
||||
:key="log.id"
|
||||
@@ -328,25 +343,10 @@ onUnmounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="m-0 py-8 text-center text-[12px] font-[650] text-(--cp-text-muted)">
|
||||
暂无进度
|
||||
</p>
|
||||
</BaseScrollbar>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="renderedReleaseNotes"
|
||||
class="grid gap-2 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="m-0 text-[13px] font-[760] text-(--cp-text-primary)">发布说明</p>
|
||||
<span class="font-mono text-[11px] font-[650] text-(--cp-text-muted)">
|
||||
{{ displayValue(updateInfo?.latestVersion) }}
|
||||
</span>
|
||||
<div v-else class="grid h-30 place-items-center px-4 pb-4">
|
||||
<BaseEmpty title="暂无进度" :icon="Terminal" compact plain />
|
||||
</div>
|
||||
<BaseScrollbar class="-mx-4" max-height="160px" view-class="px-4 pr-5" track-inset="none">
|
||||
<div class="release-notes" v-html="renderedReleaseNotes" />
|
||||
</BaseScrollbar>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import { Marked, Renderer } from 'marked'
|
||||
|
||||
const renderer = new Renderer()
|
||||
const defaultLinkRenderer = renderer.link.bind(renderer)
|
||||
|
||||
renderer.link = (token) => {
|
||||
const html = defaultLinkRenderer(token)
|
||||
return html.replace('<a ', '<a target="_blank" rel="noreferrer" ')
|
||||
}
|
||||
import { Marked } from 'marked'
|
||||
|
||||
const marked = new Marked({
|
||||
async: false,
|
||||
breaks: false,
|
||||
gfm: true,
|
||||
renderer,
|
||||
})
|
||||
|
||||
export function renderMarkdown(source?: string | null) {
|
||||
const value = source?.trim()
|
||||
if (!value) return ''
|
||||
|
||||
return DOMPurify.sanitize(marked.parse(value, { async: false }), {
|
||||
const html = marked
|
||||
.parse(value, { async: false })
|
||||
.replaceAll('<a ', '<a target="_blank" rel="noreferrer" ')
|
||||
|
||||
return DOMPurify.sanitize(html, {
|
||||
ADD_ATTR: ['target', 'rel'],
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user