官网开发 0810-v2

This commit is contained in:
eafonyang
2026-08-10 18:50:14 +08:00
parent 48510739a7
commit d41f77b49c
23 changed files with 875 additions and 60 deletions
+12 -1
View File
@@ -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',
});
}
@@ -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`),
});
@@ -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;
+6 -3
View File
@@ -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)
+2
View File
@@ -9,3 +9,5 @@ allowBuilds:
shamefullyHoist: true
ignoreWorkspaceRootCheck: true
linkWorkspacePackages: true
patchedDependencies:
vdirs@0.1.8: patches/vdirs@0.1.8.patch
+2
View File
@@ -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;
}
@@ -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);
<NFormItem label="Logo(浅色版)" path="logo_light_url">
<MediaSelectInput v-model:value="formData.logo_light_url" file-type="image" input-class="w-400px" placeholder="深色区块上用的 Logo 路径" />
</NFormItem>
<NFormItem label="多语言" path="enabled_locales">
<div class="w-400px">
<NSelect v-model:value="formData.enabled_locales" multiple :options="localeOptions" placeholder="选择网站展示的语言" />
<p class="mt-4px text-12px text-gray-400">可多选;仅选一种语言时,官网顶部不再显示语言切换按钮</p>
</div>
</NFormItem>
</NForm>
<div class="flex justify-end">
<NButton type="primary" :loading="saving" @click="handleSave">
@@ -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);
<MediaSelectInput v-model:value="formData.cover_url" file-type="image" placeholder="素材路径" />
</NFormItem>
<NFormItem label="正文(中)">
<NInput v-model:value="formData.content_zh" type="textarea" :autosize="{ minRows: 6, maxRows: 15 }" placeholder="支持 HTML" />
<RichTextEditor v-model:value="formData.content_zh" />
</NFormItem>
<NFormItem label="正文(英)">
<NInput v-model:value="formData.content_en" type="textarea" :autosize="{ minRows: 6, maxRows: 15 }" placeholder="支持 HTML" />
<RichTextEditor v-model:value="formData.content_en" />
</NFormItem>
<NFormItem label="状态">
<NSelect v-model:value="formData.status" :options="statusOptions" />
</NFormItem>
<NFormItem label="发布时间">
<NDatePicker v-model:formatted-value="formData.published_at" type="datetime" value-format="yyyy-MM-dd'T'HH:mm:ssZ" clearable class="w-300px" />
<NDatePicker v-model:value="formData.published_at" type="datetime" clearable class="w-300px" />
</NFormItem>
</NForm>
<template #footer>
@@ -1,27 +1,90 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { NButton, NCard, NForm, NFormItem, NInput, NSpace } from 'naive-ui';
import MediaSelectInput from '../../components/MediaSelectInput.vue';
import RichTextEditor from '../../components/RichTextEditor.vue';
import { fetchGetAboutSettings, fetchUpdateAboutSettings } from '@/service/api';
defineOptions({ name: 'WwwPageAbout' });
// S2 核心优势条目(存 json 列 s2_features)
interface FeatureRow {
title_zh: string;
title_en: string;
desc_zh: string;
desc_en: string;
}
const loading = ref(false);
const saving = ref(false);
const formData = reactive<Record<string, unknown>>({});
const formData = reactive({
hero_title_zh: '',
hero_title_en: '',
hero_subtitle_zh: '',
hero_subtitle_en: '',
hero_bg_url: '',
s1_title_zh: '',
s1_title_en: '',
s1_content_zh: '',
s1_content_en: '',
s1_image_url: '',
s1_cta_zh: '',
s1_cta_en: '',
s1_cta_url: '',
s2_title_zh: '',
s2_title_en: '',
s3_title_zh: '',
s3_title_en: '',
s3_cta_zh: '',
s3_cta_en: '',
s3_cta_url: ''
});
const features = ref<FeatureRow[]>([]);
function emptyFeature(): FeatureRow {
return { title_zh: '', title_en: '', desc_zh: '', desc_en: '' };
}
// JSON 列兼容:后端可能返回数组或 JSON 字符串
function parseJsonArray<T>(value: unknown): T[] {
if (Array.isArray(value)) return value as T[];
if (typeof value === 'string' && value.trim()) {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return parsed as T[];
} catch {
/* 忽略坏数据 */
}
}
return [];
}
function str(v: unknown): string {
return typeof v === 'string' ? v : v == null ? '' : String(v);
}
async function loadData() {
loading.value = true;
const { data, error } = await fetchGetAboutSettings();
loading.value = false;
if (!error && data) {
Object.assign(formData, data);
delete formData.id;
if (error || !data) return;
const row = data as Record<string, unknown>;
for (const key of Object.keys(formData) as Array<keyof typeof formData>) {
formData[key] = str(row[key]);
}
features.value = parseJsonArray<Partial<FeatureRow>>(row.s2_features).map(f => ({
title_zh: str(f.title_zh),
title_en: str(f.title_en),
desc_zh: str(f.desc_zh),
desc_en: str(f.desc_en)
}));
}
async function handleSave() {
saving.value = true;
const { error } = await fetchUpdateAboutSettings({ ...formData });
const { error } = await fetchUpdateAboutSettings({ ...formData, s2_features: features.value });
saving.value = false;
if (!error) {
window.$message?.success('保存成功');
@@ -32,13 +95,131 @@ onMounted(loadData);
</script>
<template>
<NCard :bordered="false" class="card-wrapper" title="关于我们" size="small" :loading="loading">
<NEmpty description="关于我们页面配置为动态 JSON 结构,请使用 JSON 编辑器编辑" />
<div class="mt-16px flex justify-end">
<NButton type="primary" :loading="saving" @click="handleSave">
<template #icon><SvgIcon icon="mdi:content-save" /></template>
保存
</NButton>
</div>
</NCard>
<NSpace vertical :size="16">
<!-- 页头 Hero -->
<NCard :bordered="false" class="card-wrapper" title="页头 Hero" size="small" :loading="loading">
<template #header-extra>
<NButton type="primary" :loading="saving" @click="handleSave">
保存
</NButton>
</template>
<NForm :model="formData" label-placement="left" label-width="110">
<NFormItem label="标题(中)">
<NInput v-model:value="formData.hero_title_zh" maxlength="100" class="w-300px" />
</NFormItem>
<NFormItem label="标题(英)">
<NInput v-model:value="formData.hero_title_en" maxlength="100" class="w-300px" />
</NFormItem>
<NFormItem label="副标题(中)">
<NInput v-model:value="formData.hero_subtitle_zh" maxlength="200" class="w-500px" />
</NFormItem>
<NFormItem label="副标题(英)">
<NInput v-model:value="formData.hero_subtitle_en" maxlength="200" class="w-500px" />
</NFormItem>
<NFormItem label="背景图">
<MediaSelectInput v-model:value="formData.hero_bg_url" file-type="image" placeholder="留空使用默认背景" input-class="w-500px" />
</NFormItem>
</NForm>
</NCard>
<!-- S1 品牌介绍 -->
<NCard :bordered="false" class="card-wrapper" title="S1 · 品牌介绍(左文右图)" size="small" :loading="loading">
<NForm :model="formData" label-placement="left" label-width="110">
<NFormItem label="标题(中)">
<NInput v-model:value="formData.s1_title_zh" maxlength="200" class="w-400px" />
</NFormItem>
<NFormItem label="标题(英)">
<NInput v-model:value="formData.s1_title_en" maxlength="200" class="w-400px" />
</NFormItem>
<NFormItem label="正文(中)">
<RichTextEditor v-model:value="formData.s1_content_zh" placeholder="品牌介绍正文(HTML)" />
</NFormItem>
<NFormItem label="正文(英)">
<RichTextEditor v-model:value="formData.s1_content_en" placeholder="Brand introduction (HTML)" />
</NFormItem>
<NFormItem label="配图">
<MediaSelectInput v-model:value="formData.s1_image_url" file-type="image" placeholder="右侧配图,留空不显示" input-class="w-500px" />
</NFormItem>
<NFormItem label="按钮文案(中)">
<NInput v-model:value="formData.s1_cta_zh" maxlength="100" class="w-200px" placeholder="留空不显示按钮" />
</NFormItem>
<NFormItem label="按钮文案(英)">
<NInput v-model:value="formData.s1_cta_en" maxlength="100" class="w-200px" />
</NFormItem>
<NFormItem label="按钮链接">
<NInput v-model:value="formData.s1_cta_url" maxlength="500" class="w-400px" placeholder="如 /contact,默认 /contact" />
</NFormItem>
</NForm>
</NCard>
<!-- S2 核心优势 -->
<NCard :bordered="false" class="card-wrapper" title="S2 · 核心优势(特性卡片)" size="small" :loading="loading">
<NForm :model="formData" label-placement="left" label-width="110">
<NFormItem label="标题(中)">
<NInput v-model:value="formData.s2_title_zh" maxlength="200" class="w-400px" />
</NFormItem>
<NFormItem label="标题(英)">
<NInput v-model:value="formData.s2_title_en" maxlength="200" class="w-400px" />
</NFormItem>
</NForm>
<NSpace vertical :size="12">
<div
v-for="(f, idx) in features"
:key="idx"
class="rounded-8px border border-gray-200 dark:border-dark-600 p-16px"
>
<div class="mb-8px flex items-center justify-between">
<span class="text-13px font-medium opacity-70">特性 {{ idx + 1 }}</span>
<NButton text type="error" size="small" @click="features.splice(idx, 1)">
删除
</NButton>
</div>
<NForm label-placement="left" label-width="110">
<NFormItem label="标题(中)">
<NInput v-model:value="f.title_zh" maxlength="100" class="w-300px" />
</NFormItem>
<NFormItem label="标题(英)">
<NInput v-model:value="f.title_en" maxlength="100" class="w-300px" />
</NFormItem>
<NFormItem label="描述(中)">
<NInput v-model:value="f.desc_zh" type="textarea" :autosize="{ minRows: 2, maxRows: 5 }" />
</NFormItem>
<NFormItem label="描述(英)">
<NInput v-model:value="f.desc_en" type="textarea" :autosize="{ minRows: 2, maxRows: 5 }" />
</NFormItem>
</NForm>
</div>
<NButton dashed @click="features.push(emptyFeature())">
+ 添加特性
</NButton>
</NSpace>
</NCard>
<!-- S3 底部 CTA -->
<NCard :bordered="false" class="card-wrapper" title="S3 · 底部 CTA" size="small" :loading="loading">
<NForm :model="formData" label-placement="left" label-width="110">
<NFormItem label="标题(中)">
<NInput v-model:value="formData.s3_title_zh" maxlength="200" class="w-400px" />
</NFormItem>
<NFormItem label="标题(英)">
<NInput v-model:value="formData.s3_title_en" maxlength="200" class="w-400px" />
</NFormItem>
<NFormItem label="按钮文案(中)">
<NInput v-model:value="formData.s3_cta_zh" maxlength="100" class="w-200px" placeholder="留空不显示按钮" />
</NFormItem>
<NFormItem label="按钮文案(英)">
<NInput v-model:value="formData.s3_cta_en" maxlength="100" class="w-200px" />
</NFormItem>
<NFormItem label="按钮链接">
<NInput v-model:value="formData.s3_cta_url" maxlength="500" class="w-400px" placeholder="如 /contact,默认 /contact" />
</NFormItem>
</NForm>
<div class="flex justify-end">
<NButton type="primary" :loading="saving" @click="handleSave">
保存
</NButton>
</div>
</NCard>
</NSpace>
</template>
@@ -1,27 +1,131 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { NButton, NCard, NForm, NFormItem, NInput, NSpace } from 'naive-ui';
import MediaSelectInput from '../../components/MediaSelectInput.vue';
import { fetchGetSupportSettings, fetchUpdateSupportSettings } from '@/service/api';
defineOptions({ name: 'WwwPageSupport' });
// JSON 列子结构(与 www 端 types/site.ts 保持一致)
interface FaqItemRow {
question_zh: string;
question_en: string;
answer_zh: string;
answer_en: string;
}
interface FaqCategoryRow {
title_zh: string;
title_en: string;
items: FaqItemRow[];
}
interface DownloadRow {
name_zh: string;
name_en: string;
file_url: string;
file_size: string;
}
interface ContactRow {
label_zh: string;
label_en: string;
value_zh: string;
value_en: string;
}
const loading = ref(false);
const saving = ref(false);
const formData = reactive<Record<string, unknown>>({});
const formData = reactive({
hero_title_zh: '',
hero_title_en: '',
hero_subtitle_zh: '',
hero_subtitle_en: '',
hero_bg_url: '',
s4_cta_zh: '',
s4_cta_en: '',
s4_cta_url: ''
});
const faqCategories = ref<FaqCategoryRow[]>([]);
const downloads = ref<DownloadRow[]>([]);
const contacts = ref<ContactRow[]>([]);
function emptyFaqItem(): FaqItemRow {
return { question_zh: '', question_en: '', answer_zh: '', answer_en: '' };
}
function emptyCategory(): FaqCategoryRow {
return { title_zh: '', title_en: '', items: [emptyFaqItem()] };
}
function emptyDownload(): DownloadRow {
return { name_zh: '', name_en: '', file_url: '', file_size: '' };
}
function emptyContact(): ContactRow {
return { label_zh: '', label_en: '', value_zh: '', value_en: '' };
}
// JSON 列兼容:后端可能返回数组或 JSON 字符串
function parseJsonArray<T>(value: unknown): T[] {
if (Array.isArray(value)) return value as T[];
if (typeof value === 'string' && value.trim()) {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return parsed as T[];
} catch {
/* 忽略坏数据 */
}
}
return [];
}
function str(v: unknown): string {
return typeof v === 'string' ? v : v == null ? '' : String(v);
}
async function loadData() {
loading.value = true;
const { data, error } = await fetchGetSupportSettings();
loading.value = false;
if (!error && data) {
Object.assign(formData, data);
delete formData.id;
if (error || !data) return;
const row = data as Record<string, unknown>;
for (const key of Object.keys(formData) as Array<keyof typeof formData>) {
formData[key] = str(row[key]);
}
// 兼容旧种子结构 {icon, name_zh, name_en, desc_zh, desc_en} → 新结构 title_*,保存时自动迁移
faqCategories.value = parseJsonArray<Record<string, unknown>>(row.s1_faq_categories).map(c => ({
title_zh: str(c.title_zh || c.name_zh),
title_en: str(c.title_en || c.name_en),
items: parseJsonArray<Partial<FaqItemRow>>(c.items).map(it => ({
question_zh: str(it.question_zh),
question_en: str(it.question_en),
answer_zh: str(it.answer_zh),
answer_en: str(it.answer_en)
}))
}));
downloads.value = parseJsonArray<Partial<DownloadRow>>(row.s2_downloads).map(d => ({
name_zh: str(d.name_zh),
name_en: str(d.name_en),
file_url: str(d.file_url),
file_size: str(d.file_size)
}));
// 兼容旧种子结构 {title_zh, title_en, info_zh, info_en} → 新结构 label_*/value_*,保存时自动迁移
contacts.value = parseJsonArray<Record<string, unknown>>(row.s3_contact).map(c => ({
label_zh: str(c.label_zh || c.title_zh),
label_en: str(c.label_en || c.title_en),
value_zh: str(c.value_zh || c.info_zh),
value_en: str(c.value_en || c.info_en)
}));
}
async function handleSave() {
saving.value = true;
const { error } = await fetchUpdateSupportSettings({ ...formData });
const { error } = await fetchUpdateSupportSettings({
...formData,
s1_faq_categories: faqCategories.value,
s2_downloads: downloads.value,
s3_contact: contacts.value
});
saving.value = false;
if (!error) {
window.$message?.success('保存成功');
@@ -32,13 +136,182 @@ onMounted(loadData);
</script>
<template>
<NCard :bordered="false" class="card-wrapper" title="技术支持" size="small" :loading="loading">
<NEmpty description="技术支持页面配置为动态 JSON 结构,请使用 JSON 编辑器编辑" />
<div class="mt-16px flex justify-end">
<NButton type="primary" :loading="saving" @click="handleSave">
<template #icon><SvgIcon icon="mdi:content-save" /></template>
保存
</NButton>
</div>
</NCard>
<NSpace vertical :size="16">
<!-- 页头 Hero -->
<NCard :bordered="false" class="card-wrapper" title="页头 Hero" size="small" :loading="loading">
<template #header-extra>
<NButton type="primary" :loading="saving" @click="handleSave">
保存
</NButton>
</template>
<NForm :model="formData" label-placement="left" label-width="110">
<NFormItem label="标题(中)">
<NInput v-model:value="formData.hero_title_zh" maxlength="100" class="w-300px" />
</NFormItem>
<NFormItem label="标题(英)">
<NInput v-model:value="formData.hero_title_en" maxlength="100" class="w-300px" />
</NFormItem>
<NFormItem label="副标题(中)">
<NInput v-model:value="formData.hero_subtitle_zh" maxlength="200" class="w-500px" />
</NFormItem>
<NFormItem label="副标题(英)">
<NInput v-model:value="formData.hero_subtitle_en" maxlength="200" class="w-500px" />
</NFormItem>
<NFormItem label="背景图">
<MediaSelectInput v-model:value="formData.hero_bg_url" file-type="image" placeholder="留空使用默认背景" input-class="w-500px" />
</NFormItem>
</NForm>
</NCard>
<!-- S1 常见问题 -->
<NCard :bordered="false" class="card-wrapper" title="S1 · 常见问题(FAQ 分类 → 问答)" size="small" :loading="loading">
<NSpace vertical :size="16">
<div
v-for="(cat, cIdx) in faqCategories"
:key="cIdx"
class="rounded-8px border border-gray-200 dark:border-dark-600 p-16px"
>
<div class="mb-12px flex items-center justify-between">
<span class="text-13px font-medium opacity-70">分类 {{ cIdx + 1 }}</span>
<NButton text type="error" size="small" @click="faqCategories.splice(cIdx, 1)">
删除分类
</NButton>
</div>
<NForm label-placement="left" label-width="110">
<NFormItem label="分类标题(中)">
<NInput v-model:value="cat.title_zh" maxlength="100" class="w-300px" />
</NFormItem>
<NFormItem label="分类标题(英)">
<NInput v-model:value="cat.title_en" maxlength="100" class="w-300px" />
</NFormItem>
</NForm>
<NSpace vertical :size="12" class="mt-8px">
<div
v-for="(item, iIdx) in cat.items"
:key="iIdx"
class="rounded-8px bg-gray-50 dark:bg-black/10 p-12px"
>
<div class="mb-8px flex items-center justify-between">
<span class="text-12px opacity-60">问答 {{ iIdx + 1 }}</span>
<NButton text type="error" size="small" @click="cat.items.splice(iIdx, 1)">
删除
</NButton>
</div>
<NForm label-placement="left" label-width="110">
<NFormItem label="问题(中)">
<NInput v-model:value="item.question_zh" maxlength="200" />
</NFormItem>
<NFormItem label="问题(英)">
<NInput v-model:value="item.question_en" maxlength="200" />
</NFormItem>
<NFormItem label="答案(中)">
<NInput v-model:value="item.answer_zh" type="textarea" :autosize="{ minRows: 2, maxRows: 6 }" />
</NFormItem>
<NFormItem label="答案(英)">
<NInput v-model:value="item.answer_en" type="textarea" :autosize="{ minRows: 2, maxRows: 6 }" />
</NFormItem>
</NForm>
</div>
<NButton size="small" dashed @click="cat.items.push(emptyFaqItem())">
+ 添加问答
</NButton>
</NSpace>
</div>
<NButton dashed @click="faqCategories.push(emptyCategory())">
+ 添加分类
</NButton>
</NSpace>
</NCard>
<!-- S2 资料下载 -->
<NCard :bordered="false" class="card-wrapper" title="S2 · 资料下载" size="small" :loading="loading">
<NSpace vertical :size="12">
<div
v-for="(d, idx) in downloads"
:key="idx"
class="rounded-8px border border-gray-200 dark:border-dark-600 p-16px"
>
<div class="mb-8px flex items-center justify-between">
<span class="text-13px font-medium opacity-70">资源 {{ idx + 1 }}</span>
<NButton text type="error" size="small" @click="downloads.splice(idx, 1)">
删除
</NButton>
</div>
<NForm label-placement="left" label-width="110">
<NFormItem label="名称(中)">
<NInput v-model:value="d.name_zh" maxlength="100" class="w-300px" />
</NFormItem>
<NFormItem label="名称(英)">
<NInput v-model:value="d.name_en" maxlength="100" class="w-300px" />
</NFormItem>
<NFormItem label="文件">
<MediaSelectInput v-model:value="d.file_url" file-type="file" placeholder="从素材库选择下载文件" />
</NFormItem>
<NFormItem label="文件大小">
<NInput v-model:value="d.file_size" maxlength="50" class="w-150px" placeholder="如 2.4 MB" />
</NFormItem>
</NForm>
</div>
<NButton dashed @click="downloads.push(emptyDownload())">
+ 添加资源
</NButton>
</NSpace>
</NCard>
<!-- S3 联系方式 -->
<NCard :bordered="false" class="card-wrapper" title="S3 · 联系方式" size="small" :loading="loading">
<NSpace vertical :size="12">
<div
v-for="(c, idx) in contacts"
:key="idx"
class="rounded-8px border border-gray-200 dark:border-dark-600 p-16px"
>
<div class="mb-8px flex items-center justify-between">
<span class="text-13px font-medium opacity-70">条目 {{ idx + 1 }}</span>
<NButton text type="error" size="small" @click="contacts.splice(idx, 1)">
删除
</NButton>
</div>
<NForm label-placement="left" label-width="110">
<NFormItem label="标签(中)">
<NInput v-model:value="c.label_zh" maxlength="100" class="w-250px" placeholder="如:技术支持邮箱" />
</NFormItem>
<NFormItem label="标签(英)">
<NInput v-model:value="c.label_en" maxlength="100" class="w-250px" />
</NFormItem>
<NFormItem label="内容(中)">
<NInput v-model:value="c.value_zh" maxlength="200" class="w-400px" />
</NFormItem>
<NFormItem label="内容(英)">
<NInput v-model:value="c.value_en" maxlength="200" class="w-400px" />
</NFormItem>
</NForm>
</div>
<NButton dashed @click="contacts.push(emptyContact())">
+ 添加条目
</NButton>
</NSpace>
</NCard>
<!-- S4 底部 CTA -->
<NCard :bordered="false" class="card-wrapper" title="S4 · 底部 CTA" size="small" :loading="loading">
<NForm :model="formData" label-placement="left" label-width="110">
<NFormItem label="按钮文案(中)">
<NInput v-model:value="formData.s4_cta_zh" maxlength="100" class="w-200px" placeholder="留空不显示按钮" />
</NFormItem>
<NFormItem label="按钮文案(英)">
<NInput v-model:value="formData.s4_cta_en" maxlength="100" class="w-200px" />
</NFormItem>
<NFormItem label="按钮链接">
<NInput v-model:value="formData.s4_cta_url" maxlength="500" class="w-400px" placeholder="如 /contact,默认 /contact" />
</NFormItem>
</NForm>
<div class="flex justify-end">
<NButton type="primary" :loading="saving" @click="handleSave">
保存
</NButton>
</div>
</NCard>
</NSpace>
</template>