diff --git a/dashboard/backend/src/routes/www/global.ts b/dashboard/backend/src/routes/www/global.ts index 6102b7a..990438c 100644 --- a/dashboard/backend/src/routes/www/global.ts +++ b/dashboard/backend/src/routes/www/global.ts @@ -29,15 +29,25 @@ router.get('/api/www/global/site', async (_req: Request, res: Response) => { // PUT /api/www/global/site router.put('/api/www/global/site', async (req: Request, res: Response) => { try { - const { site_title, favicon_url, logo_dark_url, logo_light_url } = req.body; + const { site_title, favicon_url, logo_dark_url, logo_light_url, enabled_locales } = req.body; const [existing] = await db.select().from(wwwSiteSettings).limit(1); + // 多语言:前端可能传数组或逗号分隔字符串,白名单过滤后落库;空值保留原配置 + let localesValue: string | undefined; + if (enabled_locales !== undefined) { + const list = (Array.isArray(enabled_locales) ? enabled_locales : String(enabled_locales).split(',')) + .map((v: unknown) => String(v).trim()) + .filter(v => v === 'zh' || v === 'en'); + if (list.length > 0) localesValue = [...new Set(list)].join(','); + } + if (existing) { await db.update(wwwSiteSettings).set({ siteTitle: site_title ?? existing.siteTitle, faviconUrl: favicon_url ?? existing.faviconUrl, logoDarkUrl: logo_dark_url ?? existing.logoDarkUrl, logoLightUrl: logo_light_url ?? existing.logoLightUrl, + enabledLocales: localesValue ?? existing.enabledLocales, }).where(eq(wwwSiteSettings.id, existing.id)); } else { await db.insert(wwwSiteSettings).values({ @@ -45,6 +55,7 @@ router.put('/api/www/global/site', async (req: Request, res: Response) => { faviconUrl: favicon_url || '', logoDarkUrl: logo_dark_url || '', logoLightUrl: logo_light_url || '', + enabledLocales: localesValue || 'zh,en', }); } diff --git a/dashboard/backend/src/schemas/www/globalSettings.ts b/dashboard/backend/src/schemas/www/globalSettings.ts index 75036d1..78e0cdb 100644 --- a/dashboard/backend/src/schemas/www/globalSettings.ts +++ b/dashboard/backend/src/schemas/www/globalSettings.ts @@ -7,6 +7,7 @@ export const wwwSiteSettings = mysqlTable('www_site_settings', { faviconUrl: varchar('favicon_url', { length: 500 }).notNull().default(''), logoDarkUrl: varchar('logo_dark_url', { length: 500 }).notNull().default(''), logoLightUrl: varchar('logo_light_url', { length: 500 }).notNull().default(''), + enabledLocales: varchar('enabled_locales', { length: 20 }).notNull().default('zh,en'), updatedAt: datetime('updated_at').notNull().default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), }); diff --git a/dashboard/frontend/patches/vdirs@0.1.8.patch b/dashboard/frontend/patches/vdirs@0.1.8.patch new file mode 100644 index 0000000..40066ad --- /dev/null +++ b/dashboard/frontend/patches/vdirs@0.1.8.patch @@ -0,0 +1,158 @@ +diff --git a/es/clickoutside.js b/es/clickoutside.js +index 3dcb41588ad00463f58be7c972882bc9d49c0f06..eef77f163c02a525f2810ab9294cb4179c3bac97 100644 +--- a/es/clickoutside.js ++++ b/es/clickoutside.js +@@ -13,6 +13,12 @@ const clickoutside = { + } + }, + updated(el, { value, modifiers }) { ++ // patched: `updated` may fire without `mounted` (teleport remount), initialize lazily ++ if (!el[ctxKey]) { ++ el[ctxKey] = { ++ handler: undefined ++ }; ++ } + const ctx = el[ctxKey]; + if (typeof value === 'function') { + if (ctx.handler) { +@@ -43,6 +49,8 @@ const clickoutside = { + } + }, + unmounted(el, { modifiers }) { ++ if (!el[ctxKey]) ++ return; + const { handler } = el[ctxKey]; + if (handler) { + off('clickoutside', el, handler, { +diff --git a/es/mousemoveoutside.js b/es/mousemoveoutside.js +index f15be455deb61f8af83abc285b46f7931fd1cb06..2f2c6ffe9c33ace8e23dc3c51d75420dc563e95e 100644 +--- a/es/mousemoveoutside.js ++++ b/es/mousemoveoutside.js +@@ -11,6 +11,12 @@ const mousemoveoutside = { + } + }, + updated(el, { value }) { ++ // patched: `updated` may fire without `mounted` (teleport remount), initialize lazily ++ if (!el[ctxKey]) { ++ el[ctxKey] = { ++ handler: undefined ++ }; ++ } + const ctx = el[ctxKey]; + if (typeof value === 'function') { + if (ctx.handler) { +@@ -33,6 +39,8 @@ const mousemoveoutside = { + } + }, + unmounted(el) { ++ if (!el[ctxKey]) ++ return; + const { handler } = el[ctxKey]; + if (handler) { + off('mousemoveoutside', el, handler); +diff --git a/es/zindexable/index.js b/es/zindexable/index.js +index 8066b8c39003c8b09419586b125c5b866eb5c788..b7680bedb5823c2464305081164284c189a426b1 100644 +--- a/es/zindexable/index.js ++++ b/es/zindexable/index.js +@@ -17,6 +17,13 @@ const zindexable = { + updated(el, bindings) { + const { value = {} } = bindings; + const { zIndex, enabled } = value; ++ // patched: `updated` may fire without `mounted` (teleport remount), initialize lazily ++ if (!el[ctx]) { ++ el[ctx] = { ++ enabled: false, ++ initialized: false ++ }; ++ } + const cachedEnabled = el[ctx].enabled; + if (enabled && !cachedEnabled) { + zIndexManager.ensureZIndex(el, zIndex); +@@ -25,7 +32,7 @@ const zindexable = { + el[ctx].enabled = !!enabled; + }, + unmounted(el, bindings) { +- if (!el[ctx].initialized) ++ if (!el[ctx] || !el[ctx].initialized) + return; + const { value = {} } = bindings; + const { zIndex } = value; +diff --git a/lib/clickoutside.js b/lib/clickoutside.js +index 300e65614d1789e1ee477460b58b88aab94b6588..af39ce4e8b5deac36fac9ea8560aba676e852a54 100644 +--- a/lib/clickoutside.js ++++ b/lib/clickoutside.js +@@ -15,6 +15,12 @@ const clickoutside = { + } + }, + updated(el, { value, modifiers }) { ++ // patched: `updated` may fire without `mounted` (teleport remount), initialize lazily ++ if (!el[ctxKey]) { ++ el[ctxKey] = { ++ handler: undefined ++ }; ++ } + const ctx = el[ctxKey]; + if (typeof value === 'function') { + if (ctx.handler) { +@@ -45,6 +51,8 @@ const clickoutside = { + } + }, + unmounted(el, { modifiers }) { ++ if (!el[ctxKey]) ++ return; + const { handler } = el[ctxKey]; + if (handler) { + (0, evtd_1.off)('clickoutside', el, handler, { +diff --git a/lib/mousemoveoutside.js b/lib/mousemoveoutside.js +index daf5afa22dd0a715c91bf81703161878ffbfb6fd..1608823f4257d924e870710bf55a295255948d74 100644 +--- a/lib/mousemoveoutside.js ++++ b/lib/mousemoveoutside.js +@@ -13,6 +13,12 @@ const mousemoveoutside = { + } + }, + updated(el, { value }) { ++ // patched: `updated` may fire without `mounted` (teleport remount), initialize lazily ++ if (!el[ctxKey]) { ++ el[ctxKey] = { ++ handler: undefined ++ }; ++ } + const ctx = el[ctxKey]; + if (typeof value === 'function') { + if (ctx.handler) { +@@ -35,6 +41,8 @@ const mousemoveoutside = { + } + }, + unmounted(el) { ++ if (!el[ctxKey]) ++ return; + const { handler } = el[ctxKey]; + if (handler) { + (0, evtd_1.off)('mousemoveoutside', el, handler); +diff --git a/lib/zindexable/index.js b/lib/zindexable/index.js +index ac608e9c13376a0e21f9fea8802c1d7bfb9d1c58..9305912d112a91dc0f6eb354bb2aaa95b3351dca 100644 +--- a/lib/zindexable/index.js ++++ b/lib/zindexable/index.js +@@ -19,6 +19,13 @@ const zindexable = { + updated(el, bindings) { + const { value = {} } = bindings; + const { zIndex, enabled } = value; ++ // patched: `updated` may fire without `mounted` (teleport remount), initialize lazily ++ if (!el[ctx]) { ++ el[ctx] = { ++ enabled: false, ++ initialized: false ++ }; ++ } + const cachedEnabled = el[ctx].enabled; + if (enabled && !cachedEnabled) { + z_index_manager_1.default.ensureZIndex(el, zIndex); +@@ -27,7 +34,7 @@ const zindexable = { + el[ctx].enabled = !!enabled; + }, + unmounted(el, bindings) { +- if (!el[ctx].initialized) ++ if (!el[ctx] || !el[ctx].initialized) + return; + const { value = {} } = bindings; + const { zIndex } = value; diff --git a/dashboard/frontend/pnpm-lock.yaml b/dashboard/frontend/pnpm-lock.yaml index d3c4fe3..278388c 100644 --- a/dashboard/frontend/pnpm-lock.yaml +++ b/dashboard/frontend/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + vdirs@0.1.8: d2c61ceb63f1b1d07b884b02d6d92e59099989b7f5a59a39301adad995e046b5 + importers: .: @@ -7516,7 +7519,7 @@ snapshots: lodash-es: 4.18.1 seemly: 0.3.10 treemate: 0.3.11 - vdirs: 0.1.8(vue@3.5.34(typescript@6.0.3)) + vdirs: 0.1.8(patch_hash=d2c61ceb63f1b1d07b884b02d6d92e59099989b7f5a59a39301adad995e046b5)(vue@3.5.34(typescript@6.0.3)) vooks: 0.2.12(vue@3.5.34(typescript@6.0.3)) vue: 3.5.34(typescript@6.0.3) vueuc: 0.4.65(vue@3.5.34(typescript@6.0.3)) @@ -8555,7 +8558,7 @@ snapshots: vary@1.1.2: {} - vdirs@0.1.8(vue@3.5.34(typescript@6.0.3)): + vdirs@0.1.8(patch_hash=d2c61ceb63f1b1d07b884b02d6d92e59099989b7f5a59a39301adad995e046b5)(vue@3.5.34(typescript@6.0.3)): dependencies: evtd: 0.2.4 vue: 3.5.34(typescript@6.0.3) @@ -8742,7 +8745,7 @@ snapshots: css-render: 0.15.14 evtd: 0.2.4 seemly: 0.3.10 - vdirs: 0.1.8(vue@3.5.34(typescript@6.0.3)) + vdirs: 0.1.8(patch_hash=d2c61ceb63f1b1d07b884b02d6d92e59099989b7f5a59a39301adad995e046b5)(vue@3.5.34(typescript@6.0.3)) vooks: 0.2.12(vue@3.5.34(typescript@6.0.3)) vue: 3.5.34(typescript@6.0.3) diff --git a/dashboard/frontend/pnpm-workspace.yaml b/dashboard/frontend/pnpm-workspace.yaml index 68c2949..e147270 100644 --- a/dashboard/frontend/pnpm-workspace.yaml +++ b/dashboard/frontend/pnpm-workspace.yaml @@ -9,3 +9,5 @@ allowBuilds: shamefullyHoist: true ignoreWorkspaceRootCheck: true linkWorkspacePackages: true +patchedDependencies: + vdirs@0.1.8: patches/vdirs@0.1.8.patch diff --git a/dashboard/frontend/src/typings/api/www.d.ts b/dashboard/frontend/src/typings/api/www.d.ts index 7123f4d..5cd5935 100644 --- a/dashboard/frontend/src/typings/api/www.d.ts +++ b/dashboard/frontend/src/typings/api/www.d.ts @@ -7,6 +7,8 @@ declare namespace Api { favicon_url: string; logo_dark_url: string; logo_light_url: string; + /** 启用的语言(逗号分隔):zh,en */ + enabled_locales: string; updated_at?: string; } diff --git a/dashboard/frontend/src/views/www/global/basic/index.vue b/dashboard/frontend/src/views/www/global/basic/index.vue index acfa018..4a1f98f 100644 --- a/dashboard/frontend/src/views/www/global/basic/index.vue +++ b/dashboard/frontend/src/views/www/global/basic/index.vue @@ -14,11 +14,23 @@ const formData = reactive({ site_title: '', favicon_url: '', logo_dark_url: '', - logo_light_url: '' + logo_light_url: '', + enabled_locales: ['zh', 'en'] as string[] }); +const localeOptions = [ + { label: '中文', value: 'zh' }, + { label: '英文', value: 'en' } +]; + const rules: FormRules = { - site_title: [{ required: true, message: '请输入站点标题', trigger: ['blur', 'input'] }] + site_title: [{ required: true, message: '请输入站点标题', trigger: ['blur', 'input'] }], + enabled_locales: [ + { + validator: (_rule, value: string[]) => (value.length > 0 ? true : new Error('至少选择一种语言')), + trigger: ['blur', 'change'] + } + ] }; async function loadData() { @@ -30,13 +42,16 @@ async function loadData() { formData.favicon_url = data.favicon_url || ''; formData.logo_dark_url = data.logo_dark_url || ''; formData.logo_light_url = data.logo_light_url || ''; + formData.enabled_locales = (data.enabled_locales || 'zh,en').split(',').filter(v => v === 'zh' || v === 'en'); } } async function handleSave() { await formRef.value?.validate(); saving.value = true; - const { error } = await fetchUpdateSiteSettings({ ...formData }); + // 多语言以逗号分隔字符串落库 + const { enabled_locales, ...rest } = formData; + const { error } = await fetchUpdateSiteSettings({ ...rest, enabled_locales: enabled_locales.join(',') }); saving.value = false; if (!error) { window.$message?.success('保存成功'); @@ -61,6 +76,12 @@ onMounted(loadData); + +
+ +

可多选;仅选一种语言时,官网顶部不再显示语言切换按钮

+
+
diff --git a/dashboard/frontend/src/views/www/news/articles/index.vue b/dashboard/frontend/src/views/www/news/articles/index.vue index e83b64e..51bcd40 100644 --- a/dashboard/frontend/src/views/www/news/articles/index.vue +++ b/dashboard/frontend/src/views/www/news/articles/index.vue @@ -2,10 +2,12 @@ import { computed, h, onMounted, reactive, ref } from 'vue'; import { NButton, NSpace, NTag, type DataTableColumns, type FormInst, type FormRules } from 'naive-ui'; import MediaSelectInput from '../../components/MediaSelectInput.vue'; +import RichTextEditor from '../../components/RichTextEditor.vue'; import { fetchCreateNewsArticle, fetchDeleteNewsArticle, fetchGetNews, + fetchGetNewsArticle, fetchUpdateNewsArticle } from '@/service/api'; @@ -41,7 +43,7 @@ const formData = reactive({ content_zh: '', content_en: '', status: 'draft' as string, - published_at: null as string | null + published_at: null as number | null }); const rules: FormRules = { @@ -140,8 +142,9 @@ function handleAdd() { dialogVisible.value = true; } -function handleEdit(row: Api.Www.NewsArticle) { +async function handleEdit(row: Api.Www.NewsArticle) { dialogTitle.value = '编辑文章'; + // 列表接口不返回正文,先回填列表字段,再拉详情补全,避免保存时把正文覆盖为空 formData.id = row.id; formData.title_zh = row.title_zh; formData.title_en = row.title_en; @@ -149,11 +152,19 @@ function handleEdit(row: Api.Www.NewsArticle) { formData.summary_zh = row.summary_zh; formData.summary_en = row.summary_en; formData.cover_url = row.cover_url; - formData.content_zh = row.content_zh; - formData.content_en = row.content_en; + formData.content_zh = ''; + formData.content_en = ''; formData.status = row.status; - formData.published_at = row.published_at; + formData.published_at = row.published_at ? new Date(row.published_at).getTime() : null; dialogVisible.value = true; + + const { data, error } = await fetchGetNewsArticle(row.id); + if (error || !data || data.id !== formData.id) return; + formData.summary_zh = data.summary_zh ?? formData.summary_zh; + formData.summary_en = data.summary_en ?? formData.summary_en; + formData.cover_url = data.cover_url ?? formData.cover_url; + formData.content_zh = data.content_zh || ''; + formData.content_en = data.content_en || ''; } function handleDelete(row: Api.Www.NewsArticle) { @@ -175,7 +186,9 @@ function handleDelete(row: Api.Www.NewsArticle) { async function handleSubmit() { await formRef.value?.validate(); submitLoading.value = true; - const { id, ...payload } = formData; + const { id, published_at, ...rest } = formData; + // 时间戳 → ISO 字符串(UTC),与后端 DATETIME 存储格式对齐 + const payload = { ...rest, published_at: published_at ? new Date(published_at).toISOString() : null }; const { error } = id ? await fetchUpdateNewsArticle(id, payload) : await fetchCreateNewsArticle(payload); @@ -277,16 +290,16 @@ onMounted(loadData); - + - + - +