官网开发 0810-v2
This commit is contained in:
@@ -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;
|
||||
Generated
+6
-3
@@ -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)
|
||||
|
||||
|
||||
@@ -9,3 +9,5 @@ allowBuilds:
|
||||
shamefullyHoist: true
|
||||
ignoreWorkspaceRootCheck: true
|
||||
linkWorkspacePackages: true
|
||||
patchedDependencies:
|
||||
vdirs@0.1.8: patches/vdirs@0.1.8.patch
|
||||
|
||||
+2
@@ -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">
|
||||
<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">
|
||||
<template #icon><SvgIcon icon="mdi:content-save" /></template>
|
||||
保存
|
||||
</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">
|
||||
<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">
|
||||
<template #icon><SvgIcon icon="mdi:content-save" /></template>
|
||||
保存
|
||||
</NButton>
|
||||
</div>
|
||||
</NCard>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS `www_site_settings` (
|
||||
`favicon_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Favicon 素材路径',
|
||||
`logo_dark_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Logo 深色版(浅色区块上用)',
|
||||
`logo_light_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Logo 浅色版(深色区块上用)',
|
||||
`enabled_locales` VARCHAR(20) NOT NULL DEFAULT 'zh,en' COMMENT '启用的语言(逗号分隔):zh,en',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
|
||||
+3
-3
@@ -105,11 +105,11 @@ CREATE TABLE IF NOT EXISTS `www_support_settings` (
|
||||
`hero_subtitle_en` VARCHAR(200) NOT NULL DEFAULT '' COMMENT 'Hero 副标题(英文)',
|
||||
`hero_bg_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Hero 背景图',
|
||||
-- Section 1: 常见问题分类
|
||||
`s1_faq_categories` JSON COMMENT 'FAQ 分类 JSON: [{icon, name_zh, name_en, desc_zh, desc_en}]',
|
||||
`s1_faq_categories` JSON COMMENT 'FAQ 分类 JSON: [{title_zh, title_en, items: [{question_zh, question_en, answer_zh, answer_en}]}]',
|
||||
-- Section 2: 下载中心
|
||||
`s2_downloads` JSON COMMENT '下载资源 JSON: [{name_zh, name_en, file_url, version}]',
|
||||
`s2_downloads` JSON COMMENT '下载资源 JSON: [{name_zh, name_en, file_url, file_size}]',
|
||||
-- Section 3: 联系方式
|
||||
`s3_contact` JSON COMMENT '联系信息 JSON: [{title_zh, title_en, info_zh, info_en, image_url}]',
|
||||
`s3_contact` JSON COMMENT '联系信息 JSON: [{label_zh, label_en, value_zh, value_en}]',
|
||||
-- Section 4: CTA
|
||||
`s4_cta_zh` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'CTA 按钮(中文)',
|
||||
`s4_cta_en` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'CTA 按钮(英文)',
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- 产品 id=2 参数规格区块(www_section_blocks.id=43)「音频参数」分组(www_spec_groups.id=9)参数项录入
|
||||
-- 英文按产品资料原文录入,中文为对应原文;多行参数值以换行分隔;sort_order 按展示顺序递增
|
||||
INSERT INTO www_spec_items (spec_group_id, name_zh, name_en, value_zh, value_en, sort_order) VALUES
|
||||
(9, '播放和DAC解码', 'Play & DAC',
|
||||
'支持立体声 DSD512,PCM 768KHz 32位',
|
||||
'support DSD512,PCM 768KHz 32-bit', 0),
|
||||
(9, '蓝牙音频输入', 'Bluetooth audio input',
|
||||
'蓝牙 BT5.0,支持 SBC/AAC',
|
||||
'BT5.0,support AAC/SBC', 1),
|
||||
(9, 'USB-B音频输入', 'USB-B audio input',
|
||||
'支持 Windows (10, 11),Android,IOS。支持立体声 DSD512,PCM 768KHz 32位',
|
||||
'support Windows(10, 11),Android,IOS。support DSD512,PCM 768KHz 32-bit', 2),
|
||||
(9, '光纤/同轴音频输入', 'Optical/Coaxial audio input',
|
||||
'支持立体声 PCM 192KHz 24位,DoP64',
|
||||
'support PCM 192KHz 24-bit,DoP64', 3),
|
||||
(9, 'HDMI ARC', 'HDMI ARC',
|
||||
'支持立体声 PCM 192KHz 24位',
|
||||
'support PCM 192KHz 24-bit', 4),
|
||||
(9, '模拟前级音频输入', 'Analog input',
|
||||
'RCA(非平衡),最大增益 +10dB',
|
||||
'RCA (unbalance),max gain +10dB', 5),
|
||||
(9, '模拟音频输出接口', 'Analog audio output interface',
|
||||
'XLR(平衡)+ RCA(非平衡)+ 低音炮输出',
|
||||
'XLR(balanced)+RCA(unbalanced)+subwoofer output', 6),
|
||||
(9, 'XLR输出音频特性', 'XLR Audio Output',
|
||||
'输出电平:4.2Vrms@0dBFS\n频率响应:20Hz~20KHz (±0.25dB)\n动态范围:> 128dB\n信噪比:>128dB\nTHD+N:<0.000096% (-120.3dB) @ 不计权\n串扰:>-117dB',
|
||||
'output level 4.2Vrms@0dBFS\nFrequency Response:20Hz~20KHz(±0.25dB)\nDynamic Range:> 128dB\nSignal-to-Noise Ratio(SNR):>128dB\nTHD+N:<0.000096%(-120.3dB)@ Unweighted\nCrosstalk:>-117dB', 7),
|
||||
(9, 'RCA输出音频特性', 'RCA Audio Output',
|
||||
'输出电平:2.1Vrms@0dBFS\n频率响应:20Hz~20KHz (±0.25dB)\n动态范围:> 125dB\n信噪比:>125dB\nTHD+N:<0.00010% (-119dB) @ 不计权\n串扰:>-114dB',
|
||||
'output level 2.1Vrms@0dBFS\nFrequency Response:20Hz~20KHz(±0.25dB)\nDynamic Range:> 125dB\nSignal-to-Noise Ratio(SNR):>125dB\nTHD+N:<0.00010%(-119dB)@ Unweighted\nCrosstalk:>-114dB', 8),
|
||||
(9, '耳机输出音频特性', 'Headphone Audio Output',
|
||||
'输出电平:4-15Vrms@0dBFS\n频率响应:20Hz~20KHz (±0.25dB)\n动态范围:> 128dB\n信噪比:>128dB\nTHD+N:<0.00016% (-116dB) @ 不计权',
|
||||
'output level 4-15Vrms@0dBFS\nFrequency Response:20Hz~20KHz(±0.25dB)\nDynamic Range:> 128dB\nSignal-to-Noise Ratio(SNR):>128dB\nTHD+N:<0.00016%(-116dB)@ Unweighted', 9);
|
||||
@@ -0,0 +1,48 @@
|
||||
-- 产品 id=2 参数规格区块(www_section_blocks.id=43)「输入」分组(www_spec_groups.id=10)参数项录入
|
||||
-- 中英文按产品资料录入;同一参数中英文不一致时以英文值为准(含拼写勘误:TIRGGER→TRIGGER、去除中文多余字符)
|
||||
INSERT INTO www_spec_items (spec_group_id, name_zh, name_en, value_zh, value_en, sort_order) VALUES
|
||||
(10, 'USB-A Port', 'USB-A Port',
|
||||
'仅限更新固件用',
|
||||
'For Firmware Updates Only', 0),
|
||||
(10, 'USB-B Port', 'USB-B Port',
|
||||
'USB Audio系统兼容性: Windows (10, 11),Android,IOS系统 最高支持立体声 DSD512,PCM 768KHz/32Bit',
|
||||
'USB Audio System Compatibility: Windows (10, 11), Android, iOS; Supports up to Stereo DSD512, PCM 768kHz/32-Bit', 1),
|
||||
(10, 'USB Type-C', 'USB Type-C',
|
||||
'USB Audio系统兼容性: Windows (10, 11),Android,IOS系统 最高支持立体声 DSD512,PCM 768KHz/32Bit',
|
||||
'USB Audio System Compatibility: Windows (10, 11), Android, iOS; Supports up to Stereo DSD512, PCM 768kHz/32-Bit', 2),
|
||||
(10, '模拟单端(输入)', 'Analog (unbalanced input)',
|
||||
'RAC*2(非平衡),最大增益 +10dB',
|
||||
'RAC*2 (unbalanced),Max Gain +10dB', 3),
|
||||
(10, '光纤', 'Optical',
|
||||
'最高支持立体声 PCM 192KHz 24Bit、DoP64',
|
||||
'Support up to PCM 192KHz 24-bit,DoP64', 4),
|
||||
(10, '同轴', 'Coaxial',
|
||||
'RAC*1',
|
||||
'RAC*1', 5),
|
||||
(10, '蓝牙接收5.0', 'Bluetooth input',
|
||||
'蓝牙BT5.0,支持SBC/AAC',
|
||||
'BT5.0,support AAC/SBC', 6),
|
||||
(10, 'HDMI ARC', 'HDMI ARC',
|
||||
'最高支持立体声PCM 192KHz 24Bit',
|
||||
'support PCM 192KHz 24-bit', 7),
|
||||
(10, 'TRIGGER接口', 'TRIGGER Connection',
|
||||
'标准3.5mm耳机插座(TRIGGER IN接口*1+TRIGGER OUT接口*1)',
|
||||
'3.5mm Headphone Jack (TRIGGER IN ×1 + TRIGGER OUT ×1)', 8),
|
||||
(10, 'WiFi', 'WiFi',
|
||||
'WiFi 2.4G / 5G',
|
||||
'WiFi 2.4G / 5G', 9),
|
||||
(10, '音量旋钮', 'Volume Knob',
|
||||
'编码器旋钮',
|
||||
'Encoder Knob', 10),
|
||||
(10, '开机按钮', 'Power Button',
|
||||
'单按静音,长按待机',
|
||||
'Single Press: Mute, Long Press: Standby', 11),
|
||||
(10, '电源开关', 'Power Switch',
|
||||
'船形开关',
|
||||
'Rocker Switch', 12),
|
||||
(10, '电源', 'Power Supply',
|
||||
'AC 100-240V~50/60Hz',
|
||||
'AC 100-240V~50/60Hz', 13),
|
||||
(10, '遥控器', 'Controller',
|
||||
'红外遥控器12键',
|
||||
'12-Button Infrared Remote Control', 14);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 产品 id=2 参数规格区块(www_section_blocks.id=43)「输出」分组(www_spec_groups.id=11)参数项录入
|
||||
-- 中英文按产品资料录入;勘误:分组英文标题 Ouput→Output;英文参数名 Unbalalced→Unbalanced
|
||||
UPDATE www_spec_groups SET title_en = 'Output' WHERE id = 11 AND title_en = 'Ouput';
|
||||
|
||||
INSERT INTO www_spec_items (spec_group_id, name_zh, name_en, value_zh, value_en, sort_order) VALUES
|
||||
(11, '平衡线路输出接口', 'Balanced Circuit Output',
|
||||
'XLR3平衡插座',
|
||||
'XLR3 Balanced Socket', 0),
|
||||
(11, '单端线路输出接口', 'Unbalanced Circuit Output',
|
||||
'RCA插座*2',
|
||||
'RCA*2', 1),
|
||||
(11, '低音炮输出接口', 'Subwoofer Output',
|
||||
'RCA插座*2',
|
||||
'RCA*2', 2),
|
||||
(11, '平衡耳机输出接口', 'Balanced Headphone Output',
|
||||
'标准4.4mm插座*1+XLR4平衡插座*1',
|
||||
'4.4mm Socket ×1 + XLR4 Balanced Socket ×1', 3),
|
||||
(11, '单端耳机输出接口', 'Unbalanced Headphone Output',
|
||||
'标准6.35mm 插座',
|
||||
'6.35mm Socket', 4);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- x13: 站点信息新增「启用的多语言」配置
|
||||
-- 逗号分隔的语言代码(zh,en),决定 www 前台展示哪些语言;
|
||||
-- 仅选一个语言时,前台隐藏顶部导航的语言切换按钮。
|
||||
ALTER TABLE `www_site_settings`
|
||||
ADD COLUMN `enabled_locales` VARCHAR(20) NOT NULL DEFAULT 'zh,en' COMMENT '启用的语言(逗号分隔):zh,en' AFTER `logo_light_url`;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 产品 id=2 参数规格区块(www_section_blocks.id=43)「基本规格」分组(www_spec_groups.id=8)参数项录入
|
||||
-- 英文按产品资料原文录入,中文为对应翻译;sort_order 按展示顺序递增
|
||||
INSERT INTO www_spec_items (spec_group_id, name_zh, name_en, value_zh, value_en, sort_order) VALUES
|
||||
(8, '型号', 'Model', 'X9', 'X9', 0),
|
||||
(8, '显示屏', 'Display', '4 英寸 TFT 全贴合触摸屏(960×400)', '4-inch TFT Fully Laminated Touchscreen (960X400)', 1),
|
||||
(8, '硬件方案', 'Hardware solution','DAC:AK4191EQ+AK4499EX;蓝牙解码:QCC5125', 'DAC: AK4191EQ+AK4499EX; Bluetooth decoding: QCC5125', 2),
|
||||
(8, '音频处理器', 'Audio processor', 'AKM 7739 DSP', 'AKM 7739 DSP', 3),
|
||||
(8, '运放芯片', 'Op-Amp chip', 'OPA1612', 'OPA1612', 4),
|
||||
(8, '尺寸', 'dimension', '宽:300mm × 长:206mm × 高:65mm', 'Width: 300mm * Length: 206mm * Height: 65mm', 5),
|
||||
(8, '电源', 'Power supply', '超低噪声线性电源', 'Ultra-Low Noise Linear Power Supply', 6),
|
||||
(8, '额定功率', 'Rated Power', '25W', '25W', 7),
|
||||
(8, '包装清单', 'Packing list', '电源线 ×1、遥控器 ×1、USB-B 线缆 ×1、用户手册 ×1', 'Power Cable ×1, Remote Controller ×1, USB-B Cable×1, User Manual ×1', 8),
|
||||
(8, '颜色', 'Color', '银色', 'Silver', 9),
|
||||
(8, '重量', 'Weight', '3.72kg', '3.72kg', 10),
|
||||
(8, '操作方式', 'Operation method', '触摸屏、红外 + 蓝牙遥控、Luxsin App(兼容 Android 和 iOS)', 'Touchscreen, Infrared + Bluetooth Remote Control, Luxsin App (compatible with Android and iOS)', 11);
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB |
@@ -43,7 +43,7 @@ const wechatHover = ref(false);
|
||||
<a v-if="footer?.contact_phone" :href="`tel:${footer.contact_phone}`" class="hover:text-primary transition-colors">
|
||||
{{ footer.contact_phone }}
|
||||
</a>
|
||||
<a v-if="footer?.contact_email" :href="`mailto:${footer.contact_email}`" class="hover:text-primary transition-colors">
|
||||
<a v-if="footer?.contact_email" :href="`mailto:${footer.contact_email}`" class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
{{ footer.contact_email }}
|
||||
</a>
|
||||
</div>
|
||||
@@ -92,7 +92,7 @@ const wechatHover = ref(false);
|
||||
{{ footer?.copyright_text || t('footer.copyright', { year: currentYear }) }}
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-16px text-xs text-gray-400 dark:text-gray-500">
|
||||
<span v-if="footer?.icp_number">{{ footer.icp_number }}</span>
|
||||
<a v-if="footer?.icp_number" href="https://beian.miit.gov.cn" target="_blank" rel="noopener noreferrer" class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">{{ footer.icp_number }}</a>
|
||||
<span v-if="footer?.police_number">{{ footer.police_number }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,32 @@ const appStore = useAppStore();
|
||||
|
||||
const { navItems, navAppearance, productSeries, site } = storeToRefs(siteStore);
|
||||
|
||||
// ===== 多语言:按站点配置决定可切换的语言 =====
|
||||
// 启用的语言代码;未配置时默认全部语言
|
||||
const enabledLocaleCodes = computed<string[]>(() => {
|
||||
const all: string[] = (unref(locales) || []).map(l => l.code);
|
||||
const raw = site.value?.enabled_locales;
|
||||
if (!raw) return all;
|
||||
const list = raw.split(',').map(s => s.trim()).filter(c => all.includes(c));
|
||||
return list.length ? list : all;
|
||||
});
|
||||
|
||||
// 实际展示的切换按钮 = i18n locales ∩ 启用列表
|
||||
const activeLocales = computed(() => (unref(locales) || []).filter(l => enabledLocaleCodes.value.includes(l.code)));
|
||||
|
||||
// 只启用一种语言时不显示切换按钮
|
||||
const showLocaleSwitch = computed(() => activeLocales.value.length > 1);
|
||||
|
||||
// 当前语言被停用(如只启用中文时访问 /en/*):跳转到首个启用语言的对应页面
|
||||
watch(
|
||||
[enabledLocaleCodes, () => locale.value],
|
||||
([codes]) => {
|
||||
if (!site.value || codes.includes(locale.value)) return;
|
||||
const target = codes[0];
|
||||
if (target) navigateTo(switchLocalePath(target as typeof locale.value));
|
||||
}
|
||||
);
|
||||
|
||||
// 滚动状态:滚动后强制毛玻璃背景 + 深色文字
|
||||
const { y: scrollY } = useWindowScroll();
|
||||
const isScrolled = computed(() => scrollY.value > 24);
|
||||
@@ -186,9 +212,9 @@ function closeMobile() {
|
||||
|
||||
<!-- 右侧:语言切换 + 移动端汉堡 -->
|
||||
<div class="flex items-center gap-12px">
|
||||
<div class="hidden md:flex items-center gap-4px">
|
||||
<div v-if="showLocaleSwitch" class="hidden md:flex items-center gap-4px">
|
||||
<NuxtLink
|
||||
v-for="loc in locales"
|
||||
v-for="loc in activeLocales"
|
||||
:key="loc.code"
|
||||
:to="switchLocalePath(loc.code)"
|
||||
class="px-8px py-4px rounded text-sm transition-colors"
|
||||
@@ -230,9 +256,9 @@ function closeMobile() {
|
||||
{{ siteStore.navLabel(item) }}
|
||||
</NuxtLink>
|
||||
|
||||
<div class="flex items-center gap-8px mt-12px px-12px">
|
||||
<div v-if="showLocaleSwitch" class="flex items-center gap-8px mt-12px px-12px">
|
||||
<NuxtLink
|
||||
v-for="loc in locales"
|
||||
v-for="loc in activeLocales"
|
||||
:key="loc.code"
|
||||
:to="switchLocalePath(loc.code)"
|
||||
class="px-12px py-6px rounded text-sm transition-colors"
|
||||
|
||||
@@ -110,7 +110,7 @@ function onLeave(el: Element, done: () => void) {
|
||||
<span class="text-sm font-semibold w-20% shrink-0" :class="isDark ? 'text-gray-200' : 'text-gray-800 dark:text-gray-200'">
|
||||
{{ isEn ? item.name_en : item.name_zh }}
|
||||
</span>
|
||||
<span class="flex-1 text-sm text-left" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'">
|
||||
<span class="flex-1 text-sm text-left whitespace-pre-line" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'">
|
||||
{{ isEn ? item.value_en : item.value_zh }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -127,7 +127,7 @@ function onLeave(el: Element, done: () => void) {
|
||||
<span class="text-sm font-semibold w-20% shrink-0" :class="isDark ? 'text-gray-200' : 'text-gray-800 dark:text-gray-200'">
|
||||
{{ isEn ? item.name_en : item.name_zh }}
|
||||
</span>
|
||||
<span class="flex-1 text-sm text-left" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'">
|
||||
<span class="flex-1 text-sm text-left whitespace-pre-line" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'">
|
||||
{{ isEn ? item.value_en : item.value_zh }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -63,10 +63,10 @@ const hasS3 = computed(() => Boolean(s3Title.value || s3Cta.value));
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- S1:图文介绍 -->
|
||||
<!-- S1:图文介绍(上文下图) -->
|
||||
<section v-if="hasS1" class="section-container py-64px md:py-96px">
|
||||
<div class="grid md:grid-cols-2 gap-40px md:gap-64px items-center">
|
||||
<div class="flex flex-col gap-20px order-2 md:order-1">
|
||||
<div class="flex flex-col gap-40px md:gap-48px">
|
||||
<div class="mx-auto max-w-800px flex flex-col gap-20px">
|
||||
<h2 v-if="s1Title" class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white">{{ s1Title }}</h2>
|
||||
<!-- 富文本(dashboard 端为 HTML 内容) -->
|
||||
<div v-if="s1Content" class="text-base md:text-lg text-gray-600 dark:text-gray-300 leading-relaxed" v-html="s1Content" />
|
||||
@@ -79,11 +79,11 @@ const hasS3 = computed(() => Boolean(s3Title.value || s3Cta.value));
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="config?.s1_image_url" class="order-1 md:order-2">
|
||||
<div v-if="config?.s1_image_url">
|
||||
<img
|
||||
:src="config.s1_image_url"
|
||||
:alt="s1Title"
|
||||
class="w-full rounded-16px shadow-lg object-cover aspect-[4/3]"
|
||||
class="w-full rounded-16px shadow-lg object-cover aspect-[16/9]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface SiteSettings {
|
||||
favicon_url: string;
|
||||
logo_dark_url: string;
|
||||
logo_light_url: string;
|
||||
/** 启用的语言(逗号分隔):zh,en */
|
||||
enabled_locales?: string;
|
||||
}
|
||||
|
||||
export interface FooterSettings {
|
||||
|
||||
Reference in New Issue
Block a user