diff --git a/.gitignore b/.gitignore index 8adaed0..34cf5f6 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,4 @@ coverage/ *.bak *.tmp .TemporaryItems/ +/dashboard/backend/uploads \ No newline at end of file diff --git a/dashboard/backend/src/app.ts b/dashboard/backend/src/app.ts index f832d1e..4233fc7 100644 --- a/dashboard/backend/src/app.ts +++ b/dashboard/backend/src/app.ts @@ -5,9 +5,11 @@ import './config/loadEnv.js'; import express from 'express'; import cors from 'cors'; +import path from 'path'; import logger from './config/logger.js'; import routes from './routes/index.js'; import { bodyLimit } from './middleware/bodyLimit.js'; +import { wwwSnakeCase } from './middleware/snakeCase.js'; import { ensureBootstrapSuperAdmin } from './services/userBootstrap.js'; const app = express(); @@ -18,6 +20,12 @@ app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(bodyLimit); +// 静态文件服务:素材库上传目录 +app.use('/uploads', express.static(path.resolve(process.cwd(), 'uploads'))); + +// 官网 CMS 响应字段名统一转为 snake_case(Drizzle 返回 camelCase,前端约定 snake_case) +app.use(wwwSnakeCase); + // 根路径(无需登录) app.get('/', (_req, res) => { res.json({ diff --git a/dashboard/backend/src/middleware/snakeCase.ts b/dashboard/backend/src/middleware/snakeCase.ts new file mode 100644 index 0000000..4a13622 --- /dev/null +++ b/dashboard/backend/src/middleware/snakeCase.ts @@ -0,0 +1,40 @@ +import type { Request, Response, NextFunction } from 'express'; + +/** + * 官网 CMS 响应字段名转换中间件 + * + * Drizzle ORM 查询返回的行对象使用 schema 中定义的 camelCase 属性名(如 fileType、fileSize), + * 而前端与 API 约定统一使用 snake_case(如 file_type、file_size,与数据库列名一致)。 + * 该中间件拦截 /api/www/ 路由的 res.json 响应,递归地将对象键从 camelCase 转为 snake_case。 + * + * 说明: + * - 仅转换对象键,不改动字符串/数值等基本类型值。 + * - JSON 列(config、form_data、options_json 等)由前端以 snake_case 写入,转换对其为无操作。 + * - Date 实例原样保留,交由 JSON.stringify 序列化为 ISO 字符串。 + */ + +function toSnake(key: string): string { + return key.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`); +} + +function convertToSnake(value: unknown): unknown { + if (value === null || value === undefined) return value; + if (value instanceof Date) return value; + if (Array.isArray(value)) return value.map(convertToSnake); + if (typeof value === 'object') { + const out: Record = {}; + for (const key of Object.keys(value as Record)) { + out[toSnake(key)] = convertToSnake((value as Record)[key]); + } + return out; + } + return value; +} + +export function wwwSnakeCase(req: Request, res: Response, next: NextFunction) { + if (req.path.startsWith('/api/www') || req.path.startsWith('/api/site')) { + const originalJson = res.json.bind(res); + res.json = (body: unknown) => originalJson(convertToSnake(body)); + } + return next(); +} diff --git a/dashboard/backend/src/routes/index.ts b/dashboard/backend/src/routes/index.ts index 730ef9f..b096749 100644 --- a/dashboard/backend/src/routes/index.ts +++ b/dashboard/backend/src/routes/index.ts @@ -8,6 +8,7 @@ import otaTargetDeviceRouter from './otaTargetDevice.js'; import shareCodeLogsRouter from './shareCodeLogs.js'; import usersRouter from './users.js'; import dashboardRouter from './dashboard.js'; +import siteRouter from './site.js'; import { wwwGlobalRouter, wwwNavRouter, @@ -22,6 +23,10 @@ import { } from './www/index.js'; const routes: Router[] = [ + // 官网前台公开只读接口(免鉴权) + // 注意:必须注册在最前面。其他路由文件使用无路径 router.use(authMiddleware), + // 且通过 app.use(router) 挂载于 /,会拦截一切进入该路由器的请求(含不匹配的)。 + siteRouter, authRouter, brandsRouter, modelsRouter, diff --git a/dashboard/backend/src/routes/site.ts b/dashboard/backend/src/routes/site.ts new file mode 100644 index 0000000..9d1586a --- /dev/null +++ b/dashboard/backend/src/routes/site.ts @@ -0,0 +1,154 @@ +import { Router, type Request, type Response } from 'express'; +import { eq, and } from 'drizzle-orm'; +import { db } from '../config/database.js'; +import { + wwwSiteSettings, + wwwFooterSettings, + wwwSocialLinks, + wwwSeoDefaults, + wwwNavItems, + wwwNavAppearance, + wwwSectionBlocks, + wwwSpecGroups, + wwwSpecItems, + wwwI18nEntries, + wwwProductSeries, + wwwProducts, +} from '../schemas/index.js'; +import logger from '../config/logger.js'; +import { ApiResponse } from '../utils/response.js'; + +/** + * 官网前台公开只读接口(供 Nuxt 官网消费) + * + * 与 dashboard 管理端接口(/api/www/*,需 JWT 鉴权)完全隔离: + * - 无需登录,公开访问 + * - 仅提供 GET 只读能力 + * - 仅返回对前台可见的内容(is_visible=1、已发布等) + */ +const router = Router(); + +// ===== 全局配置:站点信息 + 页脚 + 社交链接 + SEO 默认值 ===== +// GET /api/site/global +router.get('/api/site/global', async (_req: Request, res: Response) => { + try { + const [site] = await db.select().from(wwwSiteSettings).limit(1); + const [footer] = await db.select().from(wwwFooterSettings).limit(1); + const social = await db.select().from(wwwSocialLinks).orderBy(wwwSocialLinks.sortOrder); + const [seo] = await db.select().from(wwwSeoDefaults).limit(1); + + res.json(ApiResponse.success({ + site: site ?? null, + footer: footer ?? null, + social: social ?? [], + seo: seo ?? null, + })); + } catch (e: unknown) { + logger.error(`[site/global] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取全局配置失败')); + } +}); + +// ===== 导航:可见入口 + 外观 ===== +// GET /api/site/nav +router.get('/api/site/nav', async (_req: Request, res: Response) => { + try { + const items = await db.select().from(wwwNavItems) + .where(eq(wwwNavItems.isVisible, 1)) + .orderBy(wwwNavItems.sortOrder); + const [appearance] = await db.select().from(wwwNavAppearance).limit(1); + + res.json(ApiResponse.success({ + items: items ?? [], + appearance: appearance ?? null, + })); + } catch (e: unknown) { + logger.error(`[site/nav] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取导航配置失败')); + } +}); + +// ===== Section 区块:按页面类型返回可见区块(含参数规格子数据) ===== +// GET /api/site/sections?page_type=home&product_id=1 +router.get('/api/site/sections', async (req: Request, res: Response) => { + try { + const pageType = req.query.page_type as string; + const productId = req.query.product_id ? Number(req.query.product_id) : undefined; + + if (!pageType) { + res.json(ApiResponse.error('page_type 参数必填')); + return; + } + + const conditions = [ + eq(wwwSectionBlocks.pageType, pageType), + eq(wwwSectionBlocks.isVisible, 1), + ]; + if (productId) conditions.push(eq(wwwSectionBlocks.productId, productId)); + + const sections = await db.select().from(wwwSectionBlocks) + .where(and(...conditions)) + .orderBy(wwwSectionBlocks.sortOrder); + + // 为 spec_table 布局的区块内联参数分组数据,避免前端 N+1 请求 + const items = await Promise.all(sections.map(async (section) => { + if (section.layout !== 'spec_table') return section; + + const groups = await db.select().from(wwwSpecGroups) + .where(eq(wwwSpecGroups.sectionBlockId, section.id)) + .orderBy(wwwSpecGroups.sortOrder); + + const specGroups = await Promise.all(groups.map(async (g) => { + const specItems = await db.select().from(wwwSpecItems) + .where(eq(wwwSpecItems.specGroupId, g.id)) + .orderBy(wwwSpecItems.sortOrder); + return { ...g, items: specItems }; + })); + + return { ...section, specGroups }; + })); + + res.json(ApiResponse.success({ items, total: items.length })); + } catch (e: unknown) { + logger.error(`[site/sections] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取 Section 列表失败')); + } +}); + +// ===== i18n 词条:返回全部词条(前台用于动态翻译 UI 文案) ===== +// GET /api/site/i18n +router.get('/api/site/i18n', async (_req: Request, res: Response) => { + try { + const items = await db.select().from(wwwI18nEntries); + res.json(ApiResponse.success({ items: items ?? [], total: items.length })); + } catch (e: unknown) { + logger.error(`[site/i18n] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取词条列表失败')); + } +}); + +// ===== 产品:可见系列及其下可见产品(供导航 Mega Menu 与产品页使用) ===== +// GET /api/site/products +router.get('/api/site/products', async (_req: Request, res: Response) => { + try { + const series = await db.select().from(wwwProductSeries) + .where(eq(wwwProductSeries.isVisible, 1)) + .orderBy(wwwProductSeries.sortOrder); + + const products = await db.select().from(wwwProducts) + .where(eq(wwwProducts.isVisible, 1)) + .orderBy(wwwProducts.sortOrder); + + const seriesWithProducts = series.map((s) => ({ + ...s, + products: products.filter((p) => p.seriesId === s.id), + })); + + res.json(ApiResponse.success({ series: seriesWithProducts, total: seriesWithProducts.length })); + } catch (e: unknown) { + logger.error(`[site/products] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取产品列表失败')); + } +}); + +export default router; diff --git a/dashboard/frontend/src/service/api/www-media.ts b/dashboard/frontend/src/service/api/www-media.ts index 9feef03..070129e 100644 --- a/dashboard/frontend/src/service/api/www-media.ts +++ b/dashboard/frontend/src/service/api/www-media.ts @@ -13,6 +13,7 @@ export function fetchUploadMedia(file: File, onProgress?: (percent: number) => v url: '/www/media/upload', method: 'post', data: formData, + headers: { 'Content-Type': 'multipart/form-data' }, onUploadProgress: (e: { loaded: number; total?: number }) => { if (onProgress && e.total) { onProgress(Math.round((e.loaded / e.total) * 100)); diff --git a/dashboard/frontend/src/views/www/contact/settings/index.vue b/dashboard/frontend/src/views/www/contact/settings/index.vue index f6a7478..369f28a 100644 --- a/dashboard/frontend/src/views/www/contact/settings/index.vue +++ b/dashboard/frontend/src/views/www/contact/settings/index.vue @@ -71,7 +71,7 @@ const columns: DataTableColumns = [ width: 60, render(row) { return h(NSwitch, { - value: row.is_required, + value: Boolean(row.is_required), onUpdateValue: (val: boolean) => handleToggleRequired(row, val) }); } @@ -130,7 +130,7 @@ function handleEditField(row: Api.Www.FormField) { fieldFormData.name_zh = row.name_zh; fieldFormData.name_en = row.name_en; fieldFormData.field_type = row.field_type as 'text' | 'email' | 'phone' | 'textarea' | 'select'; - fieldFormData.is_required = row.is_required; + fieldFormData.is_required = Boolean(row.is_required); fieldFormData.placeholder_zh = row.placeholder_zh; fieldFormData.placeholder_en = row.placeholder_en; fieldFormData.options_json = row.options_json || ''; diff --git a/dashboard/frontend/src/views/www/media/index.vue b/dashboard/frontend/src/views/www/media/index.vue index 35639f5..c4b00eb 100644 --- a/dashboard/frontend/src/views/www/media/index.vue +++ b/dashboard/frontend/src/views/www/media/index.vue @@ -5,6 +5,7 @@ import { fetchDeleteMediaItem, fetchGetMedia, fetchGetMediaTags, + fetchUpdateMediaItem, fetchUploadMedia } from '@/service/api'; @@ -53,6 +54,114 @@ function categoryLabel(val: string) { return categoryOptions.find(o => o.value === val)?.label || val; } +// ===== 预览 ===== +const previewVisible = ref(false); +const previewRow = ref(null); + +// 预览类型:图片 / 视频 / PDF 可在线预览,其余(Office 等)仅提供下载 +type PreviewKind = 'image' | 'video' | 'pdf' | 'unsupported'; +const previewKind = computed(() => { + const row = previewRow.value; + if (!row) return 'unsupported'; + if (row.file_type === 'image') return 'image'; + if (row.file_type === 'video') return 'video'; + const ext = row.file_path.split('.').pop()?.toLowerCase() || ''; + if (ext === 'pdf' || row.mime_type === 'application/pdf') return 'pdf'; + return 'unsupported'; +}); + +// 相对路径(用于页面内 img/video/iframe 加载,/uploads 已由 vite/nginx 代理) +const previewUrl = computed(() => previewRow.value?.file_path || ''); +// 完整可访问 URL(用于复制链接) +const previewFullUrl = computed(() => (previewRow.value ? window.location.origin + previewRow.value.file_path : '')); + +function handlePreview(row: Api.Www.MediaItem) { + previewRow.value = row; + previewVisible.value = true; +} + +// ===== 复制链接 ===== +async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // 非安全上下文(http)下 clipboard API 不可用,降级为 execCommand + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(ta); + return ok; + } +} + +async function handleCopyLink(row: Api.Www.MediaItem) { + const url = window.location.origin + row.file_path; + const ok = await copyText(url); + if (ok) window.$message?.success('链接已复制到剪贴板'); + else window.$message?.error('复制失败,请手动复制'); +} + +async function copyPreviewLink() { + if (!previewRow.value) return; + await handleCopyLink(previewRow.value); +} + +// ===== 编辑(重命名 + 分类 + 标签)===== +const editVisible = ref(false); +const editLoading = ref(false); +const editForm = reactive({ + id: 0, + filename: '', + category: 'other', + tags: [] as string[] +}); + +const tagSelectOptions = computed(() => tagOptions.value.map(t => ({ label: t.tag_name, value: t.tag_name }))); + +function handleEdit(row: Api.Www.MediaItem) { + editForm.id = row.id; + editForm.filename = row.filename; + editForm.category = row.category; + editForm.tags = [...(row.tags || [])]; + editVisible.value = true; +} + +async function handleEditSave() { + if (!editForm.filename?.trim()) { + window.$message?.warning('文件名不能为空'); + return; + } + editLoading.value = true; + const { error } = await fetchUpdateMediaItem(editForm.id, { + filename: editForm.filename.trim(), + category: editForm.category, + tags: editForm.tags + }); + editLoading.value = false; + if (!error) { + window.$message?.success('保存成功'); + editVisible.value = false; + loadData(); + loadTags(); // 可能新建了标签,刷新下拉选项 + } +} + +// ===== 行点击自动复制链接 ===== +function handleRowClick(row: Api.Www.MediaItem) { + handleCopyLink(row); +} + +const rowProps = (row: Api.Www.MediaItem) => ({ + style: 'cursor: pointer;', + onClick: () => handleRowClick(row) +}); + +// ===== 表格列 ===== const columns = computed>(() => [ { title: 'ID', key: 'id', width: 60 }, { @@ -60,14 +169,15 @@ const columns = computed>(() => [ key: 'thumbnail_url', width: 80, render(row) { - if (row.file_type === 'image' && (row.thumbnail_url || row.file_path)) { - return h('img', { - src: row.thumbnail_url || row.file_path, - alt: row.filename, - style: 'width: 48px; height: 48px; object-fit: cover; border-radius: 4px;' - }); - } - return h(NTag, { size: 'small' }, { default: () => row.file_type }); + const content + = row.file_type === 'image' && (row.thumbnail_url || row.file_path) + ? h('img', { + src: row.thumbnail_url || row.file_path, + alt: row.filename, + style: 'width: 48px; height: 48px; object-fit: cover; border-radius: 4px;' + }) + : h(NTag, { size: 'small' }, { default: () => row.file_type }); + return h('div', { style: 'cursor: pointer;', onClick: (e: Event) => { e.stopPropagation(); handlePreview(row); } }, [content]); } }, { title: '文件名', key: 'filename', ellipsis: { tooltip: true } }, @@ -95,14 +205,15 @@ const columns = computed>(() => [ { title: '操作', key: 'actions', - width: 100, + width: 130, fixed: 'right', render(row) { - return h(NButton, { - size: 'small', - type: 'error', - onClick: () => handleDelete(row) - }, { default: () => '删除' }); + return h(NSpace, { size: 8, onClick: (e: Event) => e.stopPropagation() }, { + default: () => [ + h(NButton, { type: 'warning', size: 'small', onClick: () => handleEdit(row) }, { default: () => '编辑' }), + h(NButton, { type: 'error', size: 'small', onClick: () => handleDelete(row) }, { default: () => '删除' }) + ] + }); } } ]); @@ -191,6 +302,7 @@ onMounted(() => { - +
{
+ + + +
+ +
+ +
+ + +
+
+ + +