From 816281bf2dd677760e7f6fdf2720797ebb2a2274 Mon Sep 17 00:00:00 2001 From: eafonyang Date: Tue, 4 Aug 2026 19:18:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=98=E7=BD=91=E5=BC=80=E5=8F=91=200804?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dashboard/backend/src/routes/www/media.ts | 86 ++- .../frontend/src/service/api/www-media.ts | 13 +- .../views/www/components/MediaSelectInput.vue | 164 ++++++ .../views/www/components/SectionManager.vue | 520 ++++++++++++++++++ .../src/views/www/global/basic/index.vue | 7 +- .../src/views/www/global/seo/index.vue | 3 +- .../frontend/src/views/www/media/index.vue | 166 +++++- .../src/views/www/nav/items/index.vue | 3 +- .../src/views/www/news/articles/index.vue | 3 +- .../frontend/src/views/www/page-seo/index.vue | 3 +- .../src/views/www/page/home/index.vue | 453 +-------------- .../src/views/www/page/news-list/index.vue | 3 +- .../src/views/www/page/product-list/index.vue | 3 +- .../src/views/www/product/list/index.vue | 41 +- .../src/views/www/product/series/index.vue | 3 +- www/app/app.vue | 14 +- www/app/components/AppHeader.vue | 66 ++- .../components/section/SectionRenderer.vue | 49 +- .../section/layouts/LayoutFeatureGrid.vue | 2 +- .../section/layouts/LayoutFullBanner.vue | 2 +- .../section/layouts/LayoutGallery.vue | 2 +- .../section/layouts/LayoutHeroSplit.vue | 2 +- .../section/layouts/LayoutScrollNarrative.vue | 2 +- .../section/layouts/LayoutSpecTable.vue | 2 +- .../section/layouts/LayoutSplitContent.vue | 38 +- .../section/layouts/LayoutStats.vue | 2 +- .../section/layouts/LayoutVideoShowcase.vue | 2 +- www/app/composables/useRequest.ts | 5 +- www/app/composables/useSectionContent.ts | 6 +- www/app/utils/resolveAssetUrl.ts | 49 ++ www/nuxt.config.ts | 6 +- 31 files changed, 1177 insertions(+), 543 deletions(-) create mode 100644 dashboard/frontend/src/views/www/components/MediaSelectInput.vue create mode 100644 dashboard/frontend/src/views/www/components/SectionManager.vue create mode 100644 www/app/utils/resolveAssetUrl.ts diff --git a/dashboard/backend/src/routes/www/media.ts b/dashboard/backend/src/routes/www/media.ts index 2b7252c..eed7f42 100644 --- a/dashboard/backend/src/routes/www/media.ts +++ b/dashboard/backend/src/routes/www/media.ts @@ -17,16 +17,33 @@ const UPLOAD_DIR = path.resolve(process.cwd(), 'uploads'); if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true }); const storage = multer.diskStorage({ - destination: (_req, _file, cb) => { - const now = new Date(); - const subDir = path.join(UPLOAD_DIR, `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}`); - fs.mkdirSync(subDir, { recursive: true }); - cb(null, subDir); + destination: (req, _file, cb) => { + const body = req.body as Record; + const customDir = body.dir?.trim(); + if (customDir) { + const dirPath = path.join(UPLOAD_DIR, customDir); + fs.mkdirSync(dirPath, { recursive: true }); + cb(null, dirPath); + } else { + const now = new Date(); + const subDir = path.join(UPLOAD_DIR, `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}`); + fs.mkdirSync(subDir, { recursive: true }); + cb(null, subDir); + } }, - filename: (_req, file, cb) => { + filename: (req, file, cb) => { const ext = path.extname(file.originalname); - const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`; - cb(null, name); + const body = req.body as Record; + // auto_name 默认 true(不传或传 "true" 均视为自动生成) + const autoName = body.auto_name !== 'false'; + if (autoName) { + cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`); + } else { + const customName = (body.custom_filename || 'file').trim(); + // 去除用户可能误输入的扩展名 + const cleanName = path.basename(customName, path.extname(customName)); + cb(null, `${cleanName}${ext}`); + } }, }); @@ -41,6 +58,22 @@ function getFileType(mimetype: string): string { return 'file'; } +/** 彻底删除素材:DB 记录 + 标签关联 + 磁盘文件 */ +async function deleteMediaCompletely(row: typeof wwwMedia.$inferSelect) { + await db.delete(wwwMediaTagMap).where(eq(wwwMediaTagMap.mediaId, row.id)); + await db.delete(wwwMedia).where(eq(wwwMedia.id, row.id)); + // 删除磁盘文件:file_path 形如 /uploads/xxx,拼接到 UPLOAD_DIR 并做路径安全校验 + const rel = row.filePath.replace(/^\/uploads\//, ''); + const abs = path.normalize(path.join(UPLOAD_DIR, rel)); + if (abs.startsWith(UPLOAD_DIR + path.sep) && fs.existsSync(abs)) { + try { + fs.unlinkSync(abs); + } catch (err) { + logger.error(`[www/media] 删除磁盘文件失败 ${abs}: ${err instanceof Error ? err.message : err}`); + } + } +} + // ===== 6. 素材库 ===== // GET /api/www/media?category=product&tag=xxx&keyword=xxx&file_type=image @@ -117,8 +150,10 @@ router.post('/api/www/media/upload', upload.single('file'), async (req: Request, const category = (req.body.category as string) || 'other'; const relativePath = `/uploads/${path.relative(UPLOAD_DIR, file.path)}`; + // 显示名(不含扩展名),DB 中不存扩展名 + const displayName = path.parse(file.filename).name; const result = await db.insert(wwwMedia).values({ - filename: file.originalname, + filename: displayName, filePath: relativePath, fileType, mimeType: file.mimetype, @@ -184,6 +219,35 @@ router.post('/api/www/media/upload-batch', upload.array('files', 20), async (req } }); +// DELETE /api/www/media/batch(批量物理删除,跳过被引用的素材;放在 :id 前面避免路由冲突) +router.delete('/api/www/media/batch', async (req: Request, res: Response) => { + try { + const ids = req.body.ids as number[]; + if (!Array.isArray(ids) || ids.length === 0) { + res.json(ApiResponse.error('ids 为必填项')); + return; + } + const deleted: number[] = []; + const skipped: number[] = []; + for (const id of ids) { + const [existing] = await db.select().from(wwwMedia).where(eq(wwwMedia.id, id)); + if (!existing || existing.refCount > 0) { + skipped.push(id); + continue; + } + await deleteMediaCompletely(existing); + deleted.push(id); + } + res.json(ApiResponse.success( + { deleted, skipped }, + `成功删除 ${deleted.length} 个素材${skipped.length ? `,跳过 ${skipped.length} 个(不存在或被引用)` : ''}` + )); + } catch (e: unknown) { + logger.error(`[www/media/batch] DELETE error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('批量删除失败')); + } +}); + // GET /api/www/media/tags(放在 :id 前面避免路由冲突) router.get('/api/www/media/tags', async (_req: Request, res: Response) => { try { @@ -319,7 +383,7 @@ router.put('/api/www/media/:id', async (req: Request, res: Response) => { } }); -// DELETE /api/www/media/:id(软删除) +// DELETE /api/www/media/:id(物理删除:DB 记录 + 磁盘文件) router.delete('/api/www/media/:id', async (req: Request, res: Response) => { try { const id = Number(req.params.id); @@ -334,7 +398,7 @@ router.delete('/api/www/media/:id', async (req: Request, res: Response) => { return; } - await db.update(wwwMedia).set({ deletedAt: new Date() }).where(eq(wwwMedia.id, id)); + await deleteMediaCompletely(existing); res.json(ApiResponse.success(null, '删除成功')); } catch (e: unknown) { logger.error(`[www/media/:id] DELETE error: ${e instanceof Error ? e.message : e}`); diff --git a/dashboard/frontend/src/service/api/www-media.ts b/dashboard/frontend/src/service/api/www-media.ts index 070129e..13a1d05 100644 --- a/dashboard/frontend/src/service/api/www-media.ts +++ b/dashboard/frontend/src/service/api/www-media.ts @@ -6,8 +6,15 @@ export function fetchGetMedia(params?: PageParams & { category?: string; tag?: s return request>({ url: '/www/media', method: 'get', params }); } -export function fetchUploadMedia(file: File, onProgress?: (percent: number) => void) { +export function fetchUploadMedia(file: File, params?: { dir?: string; custom_filename?: string; auto_name?: boolean; category?: string; tags?: string }, onProgress?: (percent: number) => void) { const formData = new FormData(); + // 文本字段必须在 file 之前 append:multer 按接收顺序流式解析, + // 若 file 在前,destination/filename 回调触发时 req.body 尚未填充,自定义目录/文件名会丢失 + formData.append('auto_name', String(params?.auto_name ?? true)); + if (params?.dir) formData.append('dir', params.dir); + if (params?.custom_filename) formData.append('custom_filename', params.custom_filename); + if (params?.category) formData.append('category', params.category); + if (params?.tags) formData.append('tags', params.tags); formData.append('file', file); return request({ url: '/www/media/upload', @@ -34,6 +41,10 @@ export function fetchDeleteMediaItem(id: number) { return request({ url: `/www/media/${id}`, method: 'delete' }); } +export function fetchBatchDeleteMedia(ids: number[]) { + return request<{ deleted: number[]; skipped: number[] }>({ url: '/www/media/batch', method: 'delete', data: { ids } }); +} + // ===== 素材标签 ===== export function fetchGetMediaTags() { return request({ url: '/www/media/tags', method: 'get' }); diff --git a/dashboard/frontend/src/views/www/components/MediaSelectInput.vue b/dashboard/frontend/src/views/www/components/MediaSelectInput.vue new file mode 100644 index 0000000..4d89e89 --- /dev/null +++ b/dashboard/frontend/src/views/www/components/MediaSelectInput.vue @@ -0,0 +1,164 @@ + + + diff --git a/dashboard/frontend/src/views/www/components/SectionManager.vue b/dashboard/frontend/src/views/www/components/SectionManager.vue new file mode 100644 index 0000000..1da4638 --- /dev/null +++ b/dashboard/frontend/src/views/www/components/SectionManager.vue @@ -0,0 +1,520 @@ + + + + + diff --git a/dashboard/frontend/src/views/www/global/basic/index.vue b/dashboard/frontend/src/views/www/global/basic/index.vue index 12be7ae..acfa018 100644 --- a/dashboard/frontend/src/views/www/global/basic/index.vue +++ b/dashboard/frontend/src/views/www/global/basic/index.vue @@ -1,6 +1,7 @@ - - diff --git a/dashboard/frontend/src/views/www/page/news-list/index.vue b/dashboard/frontend/src/views/www/page/news-list/index.vue index 4eb4a39..69e8e77 100644 --- a/dashboard/frontend/src/views/www/page/news-list/index.vue +++ b/dashboard/frontend/src/views/www/page/news-list/index.vue @@ -1,5 +1,6 @@ + + + + + diff --git a/dashboard/frontend/src/views/www/product/series/index.vue b/dashboard/frontend/src/views/www/product/series/index.vue index 25c080e..1b32fbe 100644 --- a/dashboard/frontend/src/views/www/product/series/index.vue +++ b/dashboard/frontend/src/views/www/product/series/index.vue @@ -1,6 +1,7 @@ diff --git a/www/app/components/AppHeader.vue b/www/app/components/AppHeader.vue index 56f1e2e..314d77e 100644 --- a/www/app/components/AppHeader.vue +++ b/www/app/components/AppHeader.vue @@ -1,5 +1,5 @@