From c53fa0e292372629eda5752ace5ccd6c2c7d30da Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Mon, 12 Oct 2026 19:29:03 +0800 Subject: [PATCH] feat: add super-admin product detail generator --- .dockerignore | 11 + .gitignore | 4 + .impeccable/design.json | 41 +- DESIGN.md | 31 + Dockerfile | 21 + .../assets/[assetId]/route.ts | 18 + .../detail-generator/brand-profiles/route.ts | 12 + .../detail-generator/capabilities/route.ts | 9 + app/api/detail-generator/jobs/[id]/route.ts | 6 + .../images/[source]/[sourceId]/route.ts | 15 + app/api/detail-generator/materials/route.ts | 5 + .../projects/[id]/analyze/route.ts | 5 + .../projects/[id]/approve/route.ts | 5 + .../projects/[id]/assets/route.ts | 5 + .../projects/[id]/exports/route.ts | 5 + .../projects/[id]/generate/route.ts | 5 + .../projects/[id]/materials/route.ts | 5 + .../detail-generator/projects/[id]/route.ts | 10 + .../projects/[id]/screens/[screenId]/route.ts | 7 + app/api/detail-generator/route.ts | 7 + app/assets/detail-generator/page.tsx | 23 + .../DepartmentShell.detail-generator.test.tsx | 37 + .../department-dashboard/DepartmentShell.tsx | 21 +- .../detail-generator/DetailCanvas.test.tsx | 150 ++ components/detail-generator/DetailCanvas.tsx | 173 ++ .../DetailGeneratorWorkspace.test.tsx | 185 ++ .../DetailGeneratorWorkspace.tsx | 309 ++++ .../detail-generator/DetailOutlineEditor.tsx | 19 + .../detail-generator/DetailProductForm.tsx | 88 + .../detail-generator/DetailScreenEditor.tsx | 53 + components/detail-generator/client.ts | 35 + .../useUnsavedDetailGuard.test.tsx | 128 ++ .../useUnsavedDetailGuard.tsx | 133 ++ docs/detail-generator.md | 68 + docs/生产数据库安全增量迁移SOP-2026-08-12.md | 17 +- instrumentation.ts | 3 + .../detail-generator-backend.test.ts | 171 ++ lib/__tests__/detail-generator-jobs.test.ts | 88 + .../detail-generator-materials.test.ts | 46 + .../detail-generator-service.test.ts | 156 ++ .../detail-generator-storage.test.ts | 40 + lib/detail-generator/access.ts | 51 + lib/detail-generator/brand-profiles.ts | 31 + lib/detail-generator/fonts.ts | 35 + lib/detail-generator/jobs.ts | 156 ++ lib/detail-generator/layout.ts | 38 + lib/detail-generator/materials.ts | 104 ++ lib/detail-generator/provider.ts | 147 ++ lib/detail-generator/render.ts | 54 + lib/detail-generator/repository.ts | 215 +++ lib/detail-generator/runtime.ts | 20 + lib/detail-generator/service.ts | 260 +++ lib/detail-generator/storage.ts | 57 + lib/detail-generator/types.ts | 113 ++ lib/detail-generator/validation.ts | 93 + next.config.js | 4 + package-lock.json | 341 +++- package.json | 4 + .../20261012_detail_generator/migration.sql | 62 + prisma/schema.prisma | 73 + .../fonts/NotoSansSC-Bold.otf | Bin 0 -> 8543168 bytes .../fonts/NotoSansSC-Regular.otf | Bin 0 -> 8331336 bytes public/detail-generator/fonts/OFL.txt | 92 + public/detail-generator/fonts/README.md | 15 + scripts/detail-generator-migration.test.mjs | 19 + scripts/git-incremental-deploy.ps1 | 129 +- scripts/git-incremental-deploy.test.mjs | 81 +- scripts/safe-production-migrate.mjs | 18 + scripts/safe-production-migrate.test.mjs | 17 +- scripts/verify-detail-generator.ts | 228 +++ workers/detail-renderer/.gitignore | 1 + workers/detail-renderer/README.md | 13 + workers/detail-renderer/cli.mjs | 17 + workers/detail-renderer/package-lock.json | 1551 +++++++++++++++++ workers/detail-renderer/package.json | 14 + workers/detail-renderer/render.mjs | 173 ++ workers/detail-renderer/render.test.mjs | 154 ++ 77 files changed, 6498 insertions(+), 52 deletions(-) create mode 100644 app/api/detail-generator/assets/[assetId]/route.ts create mode 100644 app/api/detail-generator/brand-profiles/route.ts create mode 100644 app/api/detail-generator/capabilities/route.ts create mode 100644 app/api/detail-generator/jobs/[id]/route.ts create mode 100644 app/api/detail-generator/materials/[materialId]/images/[source]/[sourceId]/route.ts create mode 100644 app/api/detail-generator/materials/route.ts create mode 100644 app/api/detail-generator/projects/[id]/analyze/route.ts create mode 100644 app/api/detail-generator/projects/[id]/approve/route.ts create mode 100644 app/api/detail-generator/projects/[id]/assets/route.ts create mode 100644 app/api/detail-generator/projects/[id]/exports/route.ts create mode 100644 app/api/detail-generator/projects/[id]/generate/route.ts create mode 100644 app/api/detail-generator/projects/[id]/materials/route.ts create mode 100644 app/api/detail-generator/projects/[id]/route.ts create mode 100644 app/api/detail-generator/projects/[id]/screens/[screenId]/route.ts create mode 100644 app/api/detail-generator/route.ts create mode 100644 app/assets/detail-generator/page.tsx create mode 100644 components/department-dashboard/DepartmentShell.detail-generator.test.tsx create mode 100644 components/detail-generator/DetailCanvas.test.tsx create mode 100644 components/detail-generator/DetailCanvas.tsx create mode 100644 components/detail-generator/DetailGeneratorWorkspace.test.tsx create mode 100644 components/detail-generator/DetailGeneratorWorkspace.tsx create mode 100644 components/detail-generator/DetailOutlineEditor.tsx create mode 100644 components/detail-generator/DetailProductForm.tsx create mode 100644 components/detail-generator/DetailScreenEditor.tsx create mode 100644 components/detail-generator/client.ts create mode 100644 components/detail-generator/useUnsavedDetailGuard.test.tsx create mode 100644 components/detail-generator/useUnsavedDetailGuard.tsx create mode 100644 docs/detail-generator.md create mode 100644 lib/__tests__/detail-generator-backend.test.ts create mode 100644 lib/__tests__/detail-generator-jobs.test.ts create mode 100644 lib/__tests__/detail-generator-materials.test.ts create mode 100644 lib/__tests__/detail-generator-service.test.ts create mode 100644 lib/__tests__/detail-generator-storage.test.ts create mode 100644 lib/detail-generator/access.ts create mode 100644 lib/detail-generator/brand-profiles.ts create mode 100644 lib/detail-generator/fonts.ts create mode 100644 lib/detail-generator/jobs.ts create mode 100644 lib/detail-generator/layout.ts create mode 100644 lib/detail-generator/materials.ts create mode 100644 lib/detail-generator/provider.ts create mode 100644 lib/detail-generator/render.ts create mode 100644 lib/detail-generator/repository.ts create mode 100644 lib/detail-generator/runtime.ts create mode 100644 lib/detail-generator/service.ts create mode 100644 lib/detail-generator/storage.ts create mode 100644 lib/detail-generator/types.ts create mode 100644 lib/detail-generator/validation.ts create mode 100644 prisma/migrations/20261012_detail_generator/migration.sql create mode 100644 public/detail-generator/fonts/NotoSansSC-Bold.otf create mode 100644 public/detail-generator/fonts/NotoSansSC-Regular.otf create mode 100644 public/detail-generator/fonts/OFL.txt create mode 100644 public/detail-generator/fonts/README.md create mode 100644 scripts/detail-generator-migration.test.mjs create mode 100644 scripts/verify-detail-generator.ts create mode 100644 workers/detail-renderer/.gitignore create mode 100644 workers/detail-renderer/README.md create mode 100644 workers/detail-renderer/cli.mjs create mode 100644 workers/detail-renderer/package-lock.json create mode 100644 workers/detail-renderer/package.json create mode 100644 workers/detail-renderer/render.mjs create mode 100644 workers/detail-renderer/render.test.mjs diff --git a/.dockerignore b/.dockerignore index 220ba23..3eed0c9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -215,3 +215,14 @@ temp_*.sql !prisma/migrations/ !prisma/migrations/** !data/team-function-initial-content.json + +# Private generated data never belongs to a build context. +/detail-generator-private +/detail-generator-private/** +/.private +/.private/** +/workers/detail-renderer/node_modules +/workers/detail-renderer/node_modules/** +!workers/detail-renderer/package.json +!workers/detail-renderer/package-lock.json +!public/detail-generator/fonts/OFL.txt diff --git a/.gitignore b/.gitignore index 1a68018..aa0dd54 100644 --- a/.gitignore +++ b/.gitignore @@ -179,3 +179,7 @@ Relay私密接管清单* /scripts/migrate-standard-model-white-bg-webp.js /scripts/recover-selected-video-preview*.ts /scripts/verify-p1-1-fix.ts + +# Private detail-generator projects and disposable acceptance output +/detail-generator-private/ +/output/detail-generator/ diff --git a/.impeccable/design.json b/.impeccable/design.json index af44b54..30a4dba 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -1,6 +1,6 @@ { "schemaVersion": 2, - "generatedAt": "2026-08-19T00:00:00+08:00", + "generatedAt": "2026-10-12T10:15:30Z", "title": "Design System: 品牌中心部门看板", "extensions": { "colorMeta": { @@ -348,6 +348,21 @@ "#176fc1", "#0d4d8c" ] + }, + "detail-field-border": { + "role": "neutral", + "displayName": "详情页字段与次要按钮边框", + "canonical": "#cfd8e3" + }, + "detail-control-ink": { + "role": "neutral", + "displayName": "详情页次要操作文字", + "canonical": "#344054" + }, + "detail-primary-hover": { + "role": "primary", + "displayName": "详情页主要操作悬停蓝", + "canonical": "#0964de" } }, "typographyMeta": { @@ -472,6 +487,30 @@ "type": "navigation", "description": "品牌中心范围外普通用户使用的中性素材库顶栏;只保留页面定位和退出登录,不提供品牌中心入口。", "projection": "
素材资产库
" + }, + { + "name": "Detail Generator Field", + "kind": "input", + "refersTo": "detail-field", + "description": "仅记录详情页资料、大纲与文案编辑复用的 detailFieldClass;44px 最小高度、8px 圆角与明确的蓝色聚焦环。", + "html": "", + "css": ".ds-detail-field-label{display:grid;gap:6px;color:#101828;font:500 14px/20px 'Space Grotesk','Noto Sans SC',ui-sans-serif,system-ui,sans-serif}.ds-detail-field{min-height:44px;width:100%;border:1px solid #cfd8e3;border-radius:8px;background:#fff;padding:8px 12px;color:#101828;font-family:inherit;font-size:14px;line-height:20px;font-weight:inherit}.ds-detail-field::placeholder{color:#66758b}.ds-detail-field:focus-visible{outline:none;box-shadow:0 0 0 2px #1677ff}.ds-detail-field:disabled{background:#f8fafc;color:#66758b}" + }, + { + "name": "Detail Generator Secondary Button", + "kind": "button", + "refersTo": "detail-button-secondary", + "description": "详情页复用的 detailButtonClass,承载保存、上传、返回和图片替换等次要操作;不替换其他页面按钮。", + "html": "", + "css": ".ds-detail-button-secondary{display:inline-flex;min-height:44px;flex-shrink:0;align-items:center;justify-content:center;gap:8px;white-space:nowrap;border:1px solid #cfd8e3;border-radius:8px;background:#fff;padding:0 12px;color:#344054;font:500 14px/20px 'Space Grotesk','Noto Sans SC',ui-sans-serif,system-ui,sans-serif;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s}.ds-detail-button-secondary:hover{background:#f8fafc}.ds-detail-button-secondary:focus-visible{outline:none;box-shadow:0 0 0 2px #fff,0 0 0 4px #1677ff}.ds-detail-button-secondary:disabled{cursor:not-allowed;opacity:.5}" + }, + { + "name": "Detail Generator Primary Button", + "kind": "button", + "refersTo": "detail-button-primary", + "description": "详情页复用的 detailPrimaryClass,用于分析、确认生成、继续生成与导出;保持既有 Action Blue,悬停变体仅限该控件。", + "html": "", + "css": ".ds-detail-button-primary{display:inline-flex;min-height:44px;flex-shrink:0;align-items:center;justify-content:center;gap:8px;white-space:nowrap;border:0;border-radius:8px;background:#1677ff;padding:0 16px;color:#fff;font:600 14px/20px 'Space Grotesk','Noto Sans SC',ui-sans-serif,system-ui,sans-serif;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s}.ds-detail-button-primary:hover{background:#0964de}.ds-detail-button-primary:focus-visible{outline:none;box-shadow:0 0 0 2px #fff,0 0 0 4px #1677ff}.ds-detail-button-primary:disabled{cursor:not-allowed;opacity:.5}" } ], "narrative": { diff --git a/DESIGN.md b/DESIGN.md index 9e68570..ff54dfa 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -25,6 +25,9 @@ colors: workflow-inactive-description: "#667085" workflow-hover: "#eef2f6" workflow-focus: "#5a9ce6" + detail-field-border: "#cfd8e3" + detail-control-ink: "#344054" + detail-primary-hover: "#0964de" typography: display: fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" @@ -109,6 +112,26 @@ components: rounded: "{rounded.md}" padding: "10px 12px" width: "232px" + detail-field: + backgroundColor: "{colors.surface}" + textColor: "{colors.text-primary}" + rounded: "{rounded.md}" + padding: "8px 12px" + detail-button-secondary: + backgroundColor: "{colors.surface}" + textColor: "{colors.detail-control-ink}" + rounded: "{rounded.md}" + padding: "0 12px" + detail-button-primary: + backgroundColor: "{colors.action-blue}" + textColor: "{colors.surface}" + rounded: "{rounded.md}" + padding: "0 16px" + detail-button-primary-hover: + backgroundColor: "{colors.detail-primary-hover}" + textColor: "{colors.surface}" + rounded: "{rounded.md}" + padding: "0 16px" --- # Design System: 品牌中心部门看板 @@ -319,6 +342,14 @@ components: - **Responsive:** 1280px 以下转为两列;640px 以下加组与人物身份同一行,部门组和个人组在下方换行。长名称与多组允许行高自然增加。 - **Interaction:** 个人组提供具备人员/组名的移除按钮,独立加组选择器保留原提交行为;部门继承来源仅展示。完整部门路径保留在悬停提示及辅助语义中,错误与成功状态使用 alert/status。 +### Detail Generator Controls + +- **Scope:** `components/detail-generator/DetailProductForm.tsx` 导出的 `detailFieldClass`、`detailButtonClass` 和 `detailPrimaryClass`,在资料、大纲与单屏编辑器中复用;`detail-*` token 只记录这组已实现控件,不替换其他页面的字段或按钮。 +- **Fields:** 最小高度 44px、8px 圆角、白底、`#cfd8e3` 边框,14px 文字与 `8px 12px` 内边距;placeholder 使用 `#66758b`。聚焦显示 2px `#1677ff` 环;禁用时为 `#f8fafc` 底与 `#66758b` 文字。长文案使用可纵向调整的 textarea。 +- **Secondary Button:** 最小高度 44px、8px 圆角、白底、`#cfd8e3` 边框、`#344054` 文字,左右 12px 内边距;hover 为 `#f8fafc`。 +- **Primary Button:** 最小高度 44px、8px 圆角、`#1677ff` 底与白字,左右 16px 内边距;hover 为 `#0964de`。两种按钮的 focus-visible 均为 2px 蓝环与 2px 白色间隔,disabled 降为 50% 不透明度并显示不可用指针。 +- **Surface Contract:** 详情页的 Operate 模式、分阶段构图、750px 画布、编辑范围、响应式与验收边界记录在 `.impeccable/surfaces/app-assets-detail-generator-page-tsx.md`,不提升为全项目规则。 + ## Do's and Don'ts ### Do: diff --git a/Dockerfile b/Dockerfile index cf90517..c0a2a30 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,21 @@ ARG ALPINE_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/alpine # Local/CI builds remain possible without Git metadata. Production deployment # supplies the exact SHA and rejects this sentinel before any container starts. ARG VCS_REF=0000000000000000000000000000000000000000 +ARG DETAIL_RENDERER_IMAGE=node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 FROM ${BASE_IMAGE} AS base +# Fabric's Node renderer has a separate dependency tree and Node runtime. +# The platform's existing Canvas 2 installation remains independent. +FROM ${DETAIL_RENDERER_IMAGE} AS detail-renderer-deps +ARG ALPINE_MIRROR +RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories && \ + apk add --no-cache python3 make g++ cairo-dev jpeg-dev pango-dev giflib-dev +WORKDIR /opt/detail-renderer +COPY workers/detail-renderer/package.json workers/detail-renderer/package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm CXXFLAGS="-include cstdint" npm_config_build_from_source=canvas npm ci --no-audit --no-fund +COPY workers/detail-renderer/ ./ +RUN node -e "require('canvas').createCanvas(1,1); console.log('isolated Canvas smoke passed')" + # 安装依赖阶段 FROM base AS deps ARG ALPINE_MIRROR @@ -200,6 +213,10 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/app ./app COPY --from=builder --chown=root:root /app/public ./public COPY --from=builder --chown=root:root /app/DEPLOYMENT_COMMIT /app/DEPLOYMENT_COMMIT +COPY --from=detail-renderer-deps --chown=root:root /opt/detail-renderer /opt/detail-renderer +COPY --from=detail-renderer-deps --chown=root:root /usr/local/bin/node /opt/detail-renderer/bin/node +ENV DETAIL_RENDERER_NODE=/opt/detail-renderer/bin/node +ENV DETAIL_RENDERER_ENTRY=/opt/detail-renderer/cli.mjs # 只复制运行时必需的 node_modules COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma @@ -243,6 +260,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260915_add_brand_ COPY --from=builder --chown=root:root /app/prisma/migrations/20260918_add_performance_conversion_sync ./prisma/migrations/20260918_add_performance_conversion_sync COPY --from=builder --chown=root:root /app/prisma/migrations/20260923_add_certificate_report_metadata ./prisma/migrations/20260923_add_certificate_report_metadata COPY --from=builder --chown=root:root /app/prisma/migrations/20260924_add_certificate_tested_on ./prisma/migrations/20260924_add_certificate_tested_on +COPY --from=builder --chown=root:root /app/prisma/migrations/20261012_detail_generator ./prisma/migrations/20261012_detail_generator COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs @@ -251,6 +269,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-surv # 应用代码归 root 所有,运行账号只能写上传目录和 Next.js 运行时缓存; # 即使上层文件路径校验再次出现缺陷,也不能覆盖 server.js 或业务源码。 RUN mkdir -p /app/public /app/uploads && \ + mkdir -p /app/detail-generator-private && \ mkdir -p /app/scripts && \ mkdir -p /app/.next/cache && \ mkdir -p /app/uploads/temp && \ @@ -260,6 +279,8 @@ RUN mkdir -p /app/public /app/uploads && \ chown -R root:root /app && \ chmod -R a+rX /app/public /app/prisma/migrations /app/scripts && \ chown -R nextjs:nodejs /app/.next/cache /app/uploads && \ + chown nextjs:nodejs /app/detail-generator-private && \ + chmod 0700 /app/detail-generator-private && \ chmod 0750 /app/uploads /app/uploads/temp /app/uploads/assembled /app/uploads/materials /app/uploads/cleanup && \ test "$(cat /app/DEPLOYMENT_COMMIT)" = "$VCS_REF" && \ chmod 0444 /app/DEPLOYMENT_COMMIT && \ diff --git a/app/api/detail-generator/assets/[assetId]/route.ts b/app/api/detail-generator/assets/[assetId]/route.ts new file mode 100644 index 0000000..c4741a7 --- /dev/null +++ b/app/api/detail-generator/assets/[assetId]/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server' +import { detailGeneratorErrorResponse, requireDetailGeneratorUser } from '@/lib/detail-generator/access' +import { findDetailAsset } from '@/lib/detail-generator/repository' +import { readDetailFile } from '@/lib/detail-generator/storage' +import { assertDetailId } from '@/lib/detail-generator/validation' +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export async function GET(_request: Request, { params }: { params: { assetId: string } }) { + try { + const ownerId = await requireDetailGeneratorUser() + const asset = await findDetailAsset(assertDetailId(params.assetId), ownerId) + const buffer = await readDetailFile(asset.storageKey) + return new NextResponse(new Uint8Array(buffer), { headers: { + 'Content-Type': asset.mimeType, 'Content-Length': String(buffer.length), 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff', + 'Content-Disposition': `${asset.mimeType.startsWith('image/') && asset.kind !== 'export' ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(asset.name)}`, + } }) + } catch (error) { return detailGeneratorErrorResponse(error) } +} diff --git a/app/api/detail-generator/brand-profiles/route.ts b/app/api/detail-generator/brand-profiles/route.ts new file mode 100644 index 0000000..5941bb2 --- /dev/null +++ b/app/api/detail-generator/brand-profiles/route.ts @@ -0,0 +1,12 @@ +import { detailGeneratorRoute } from '@/lib/detail-generator/access' +import { listDetailBrandProfiles, saveDetailBrandProfile } from '@/lib/detail-generator/brand-profiles' +import { parseDetailJson } from '@/lib/detail-generator/validation' +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export function GET() { return detailGeneratorRoute(async () => ({ profiles: await listDetailBrandProfiles() })) } +export function PUT(request: Request) { + return detailGeneratorRoute(async () => { + const input = await parseDetailJson(request) + return { profile: await saveDetailBrandProfile(input.profile, input.revision) } + }) +} diff --git a/app/api/detail-generator/capabilities/route.ts b/app/api/detail-generator/capabilities/route.ts new file mode 100644 index 0000000..33570dd --- /dev/null +++ b/app/api/detail-generator/capabilities/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server' +import { requireDetailGeneratorUser } from '@/lib/detail-generator/access' +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export async function GET() { + let canUse = false + try { await requireDetailGeneratorUser(); canUse = true } catch { canUse = false } + return NextResponse.json({ canUse }, { headers: { 'Cache-Control': 'private, no-store' } }) +} diff --git a/app/api/detail-generator/jobs/[id]/route.ts b/app/api/detail-generator/jobs/[id]/route.ts new file mode 100644 index 0000000..7a5c965 --- /dev/null +++ b/app/api/detail-generator/jobs/[id]/route.ts @@ -0,0 +1,6 @@ +import { detailGeneratorRoute } from '@/lib/detail-generator/access' +import { detailPublicJob, findDetailJob } from '@/lib/detail-generator/repository' +import { assertDetailId } from '@/lib/detail-generator/validation' +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export async function GET(_request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(async userId => ({ job: detailPublicJob(await findDetailJob(assertDetailId(params.id), userId)) })) } diff --git a/app/api/detail-generator/materials/[materialId]/images/[source]/[sourceId]/route.ts b/app/api/detail-generator/materials/[materialId]/images/[source]/[sourceId]/route.ts new file mode 100644 index 0000000..e7acc1e --- /dev/null +++ b/app/api/detail-generator/materials/[materialId]/images/[source]/[sourceId]/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server' +import sharp from 'sharp' +import { detailGeneratorErrorResponse, requireDetailGeneratorUser } from '@/lib/detail-generator/access' +import { readDetailMaterialImage } from '@/lib/detail-generator/materials' +import { assertDetailId } from '@/lib/detail-generator/validation' +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export async function GET(_request: Request, { params }: { params: { materialId: string; source: string; sourceId: string } }) { + try { + const ownerId = await requireDetailGeneratorUser() + const image = await readDetailMaterialImage(ownerId, assertDetailId(params.materialId), params.source, assertDetailId(params.sourceId)) + const buffer = await sharp(image.buffer, { limitInputPixels: 40_000_000 }).rotate().resize(400, 400, { fit: 'inside', withoutEnlargement: true }).png().toBuffer() + return new NextResponse(new Uint8Array(buffer), { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff' } }) + } catch (error) { return detailGeneratorErrorResponse(error) } +} diff --git a/app/api/detail-generator/materials/route.ts b/app/api/detail-generator/materials/route.ts new file mode 100644 index 0000000..5ad54de --- /dev/null +++ b/app/api/detail-generator/materials/route.ts @@ -0,0 +1,5 @@ +import { detailGeneratorRoute } from '@/lib/detail-generator/access' +import { findDetailMaterialMatches } from '@/lib/detail-generator/materials' +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export async function GET(request: Request) { return detailGeneratorRoute(async userId => { const query = new URL(request.url).searchParams; return { matches: await findDetailMaterialMatches(userId, query.get('identifierType'), query.get('identifier')) } }) } diff --git a/app/api/detail-generator/projects/[id]/analyze/route.ts b/app/api/detail-generator/projects/[id]/analyze/route.ts new file mode 100644 index 0000000..c155c6a --- /dev/null +++ b/app/api/detail-generator/projects/[id]/analyze/route.ts @@ -0,0 +1,5 @@ +import { detailGeneratorRoute } from '@/lib/detail-generator/access' +import { createDetailAction } from '@/lib/detail-generator/service' +import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation' +export const runtime = 'nodejs' +export async function POST(request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(async userId => ({ job: await createDetailAction(userId, assertDetailId(params.id), 'analyze', await parseDetailJson(request)) })) } diff --git a/app/api/detail-generator/projects/[id]/approve/route.ts b/app/api/detail-generator/projects/[id]/approve/route.ts new file mode 100644 index 0000000..0cc1eaf --- /dev/null +++ b/app/api/detail-generator/projects/[id]/approve/route.ts @@ -0,0 +1,5 @@ +import { detailGeneratorRoute } from '@/lib/detail-generator/access' +import { approveDetailAnalysis } from '@/lib/detail-generator/service' +import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation' +export const runtime = 'nodejs' +export async function POST(request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(async userId => ({ project: await approveDetailAnalysis(userId, assertDetailId(params.id), await parseDetailJson(request)) })) } diff --git a/app/api/detail-generator/projects/[id]/assets/route.ts b/app/api/detail-generator/projects/[id]/assets/route.ts new file mode 100644 index 0000000..3979cae --- /dev/null +++ b/app/api/detail-generator/projects/[id]/assets/route.ts @@ -0,0 +1,5 @@ +import { detailGeneratorRoute } from '@/lib/detail-generator/access' +import { uploadDetailAsset } from '@/lib/detail-generator/service' +import { assertDetailId } from '@/lib/detail-generator/validation' +export const runtime = 'nodejs' +export async function POST(request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(userId => uploadDetailAsset(userId, assertDetailId(params.id), request)) } diff --git a/app/api/detail-generator/projects/[id]/exports/route.ts b/app/api/detail-generator/projects/[id]/exports/route.ts new file mode 100644 index 0000000..c75cbdd --- /dev/null +++ b/app/api/detail-generator/projects/[id]/exports/route.ts @@ -0,0 +1,5 @@ +import { detailGeneratorRoute } from '@/lib/detail-generator/access' +import { createDetailAction } from '@/lib/detail-generator/service' +import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation' +export const runtime = 'nodejs' +export async function POST(request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(async userId => ({ job: await createDetailAction(userId, assertDetailId(params.id), 'export', await parseDetailJson(request)) })) } diff --git a/app/api/detail-generator/projects/[id]/generate/route.ts b/app/api/detail-generator/projects/[id]/generate/route.ts new file mode 100644 index 0000000..40b571f --- /dev/null +++ b/app/api/detail-generator/projects/[id]/generate/route.ts @@ -0,0 +1,5 @@ +import { detailGeneratorRoute } from '@/lib/detail-generator/access' +import { createDetailAction } from '@/lib/detail-generator/service' +import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation' +export const runtime = 'nodejs' +export async function POST(request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(async userId => ({ job: await createDetailAction(userId, assertDetailId(params.id), 'generate', await parseDetailJson(request)) })) } diff --git a/app/api/detail-generator/projects/[id]/materials/route.ts b/app/api/detail-generator/projects/[id]/materials/route.ts new file mode 100644 index 0000000..ff251b0 --- /dev/null +++ b/app/api/detail-generator/projects/[id]/materials/route.ts @@ -0,0 +1,5 @@ +import { detailGeneratorRoute } from '@/lib/detail-generator/access' +import { importDetailMaterial } from '@/lib/detail-generator/service' +import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation' +export const runtime = 'nodejs' +export async function POST(request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(async userId => importDetailMaterial(userId, assertDetailId(params.id), await parseDetailJson(request))) } diff --git a/app/api/detail-generator/projects/[id]/route.ts b/app/api/detail-generator/projects/[id]/route.ts new file mode 100644 index 0000000..1f5655d --- /dev/null +++ b/app/api/detail-generator/projects/[id]/route.ts @@ -0,0 +1,10 @@ +import { detailGeneratorRoute } from '@/lib/detail-generator/access' +import { editDetailProject, getDetailProject } from '@/lib/detail-generator/service' +import { deleteDetailProject } from '@/lib/detail-generator/repository' +import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation' +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +type Context = { params: { id: string } } +export async function GET(_request: Request, { params }: Context) { return detailGeneratorRoute(userId => getDetailProject(userId, assertDetailId(params.id))) } +export async function PATCH(request: Request, { params }: Context) { return detailGeneratorRoute(async userId => ({ project: await editDetailProject(userId, assertDetailId(params.id), await parseDetailJson(request)) })) } +export async function DELETE(_request: Request, { params }: Context) { return detailGeneratorRoute(async userId => { await deleteDetailProject(assertDetailId(params.id), userId); return { deleted: true } }) } diff --git a/app/api/detail-generator/projects/[id]/screens/[screenId]/route.ts b/app/api/detail-generator/projects/[id]/screens/[screenId]/route.ts new file mode 100644 index 0000000..019ec48 --- /dev/null +++ b/app/api/detail-generator/projects/[id]/screens/[screenId]/route.ts @@ -0,0 +1,7 @@ +import { detailGeneratorRoute } from '@/lib/detail-generator/access' +import { createDetailAction, editDetailScreen } from '@/lib/detail-generator/service' +import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation' +export const runtime = 'nodejs' +type Context = { params: { id: string; screenId: string } } +export async function PATCH(request: Request, { params }: Context) { return detailGeneratorRoute(async userId => ({ project: await editDetailScreen(userId, assertDetailId(params.id), assertDetailId(params.screenId), await parseDetailJson(request)) })) } +export async function POST(request: Request, { params }: Context) { return detailGeneratorRoute(async userId => ({ job: await createDetailAction(userId, assertDetailId(params.id), 'regenerate', await parseDetailJson(request), assertDetailId(params.screenId)) })) } diff --git a/app/api/detail-generator/route.ts b/app/api/detail-generator/route.ts new file mode 100644 index 0000000..562dff4 --- /dev/null +++ b/app/api/detail-generator/route.ts @@ -0,0 +1,7 @@ +import { detailGeneratorRoute } from '@/lib/detail-generator/access' +import { getDetailHome, newDetailProject } from '@/lib/detail-generator/service' +import { parseDetailJson } from '@/lib/detail-generator/validation' +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export async function GET() { return detailGeneratorRoute(userId => getDetailHome(userId)) } +export async function POST(request: Request) { return detailGeneratorRoute(async userId => ({ project: await newDetailProject(userId, (await parseDetailJson(request)).document) })) } diff --git a/app/assets/detail-generator/page.tsx b/app/assets/detail-generator/page.tsx new file mode 100644 index 0000000..32c7971 --- /dev/null +++ b/app/assets/detail-generator/page.tsx @@ -0,0 +1,23 @@ +import { getServerSession } from 'next-auth' +import { notFound, redirect } from 'next/navigation' +import { authOptions } from '@/lib/auth' +import { assertDetailGeneratorAccess, DetailGeneratorError } from '@/lib/detail-generator/access' +import MaterialLibraryAccessShell from '@/components/material-library/MaterialLibraryAccessShell' +import DetailGeneratorWorkspace from '@/components/detail-generator/DetailGeneratorWorkspace' + +export const dynamic = 'force-dynamic' +export const metadata = { title: '详情页生成 | 品牌中心' } + +export default async function DetailGeneratorPage() { + const session = await getServerSession(authOptions) + if (!session?.user?.id) redirect('/login') + try { await assertDetailGeneratorAccess(session) } catch (error) { + if (error instanceof DetailGeneratorError && error.status === 403) notFound() + throw error + } + return ( + + + + ) +} diff --git a/components/department-dashboard/DepartmentShell.detail-generator.test.tsx b/components/department-dashboard/DepartmentShell.detail-generator.test.tsx new file mode 100644 index 0000000..5dc94f7 --- /dev/null +++ b/components/department-dashboard/DepartmentShell.detail-generator.test.tsx @@ -0,0 +1,37 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import DepartmentShell from './DepartmentShell' + +jest.mock('./DepartmentAccountMenu', () => ({ __esModule: true, default: () => null })) +jest.mock('./DepartmentHeader', () => { + const React = require('react') + return { __esModule: true, default: React.forwardRef(function Header(_props: unknown, ref: unknown) { return }) } +}) + +beforeEach(() => { + window.history.replaceState(null, '', '/workspaces/dashboard/core-metrics') + window.matchMedia = jest.fn().mockReturnValue({ matches: false, addEventListener: jest.fn(), removeEventListener: jest.fn() }) +}) + +test.each([false, undefined])('服务端能力为 %s 时不显示新功能入口', async canUse => { + global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ canUse }) }) + render(

内容

) + await waitFor(() => expect(global.fetch).toHaveBeenCalled()) + fireEvent.click(screen.getByRole('button', { name: '资产与AI自动化' })) + expect(screen.queryByRole('link', { name: /详情页生成/ })).not.toBeInTheDocument() +}) + +test('只有数据库能力响应明确为 true 才显示详情页生成入口', async () => { + global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ canUse: true }) }) + render(

内容

) + fireEvent.click(screen.getByRole('button', { name: '资产与AI自动化' })) + expect(await screen.findByRole('link', { name: /详情页生成/ })).toHaveAttribute('href', '/assets/detail-generator') + expect(global.fetch).toHaveBeenCalledWith('/api/detail-generator/capabilities', expect.objectContaining({ cache: 'no-store' })) +}) + +test('权限请求失败保持入口隐藏', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('offline')) + render(

内容

) + await waitFor(() => expect(global.fetch).toHaveBeenCalled()) + fireEvent.click(screen.getByRole('button', { name: '资产与AI自动化' })) + expect(screen.queryByRole('link', { name: /详情页生成/ })).not.toBeInTheDocument() +}) diff --git a/components/department-dashboard/DepartmentShell.tsx b/components/department-dashboard/DepartmentShell.tsx index 6c66737..d810315 100644 --- a/components/department-dashboard/DepartmentShell.tsx +++ b/components/department-dashboard/DepartmentShell.tsx @@ -22,6 +22,7 @@ import { TrendingUp, UsersRound, Workflow, + WandSparkles, X, type LucideIcon, } from 'lucide-react' @@ -60,6 +61,7 @@ function readPathname() { } function getPageTitle(pathname: string) { + if (pathname.startsWith('/assets/detail-generator')) return '详情页生成' if (pathname === '/') return '部门总览' for (const section of WORKSPACE_SECTIONS) { const item = section.items.find((candidate) => pathname.startsWith(candidate.href)) @@ -73,16 +75,29 @@ export default function DepartmentShell({ children }: { children: ReactNode }) { const [navigationCollapsed, setNavigationCollapsed] = useState(false) const [mobileNavigationOpen, setMobileNavigationOpen] = useState(false) const [isMobileViewport, setIsMobileViewport] = useState(false) + const [canUseDetailGenerator, setCanUseDetailGenerator] = useState(false) const navigationTriggerRef = useRef(null) const mobileDialogRef = useRef(null) const contentRootRef = useRef(null) - const activeSection = WORKSPACE_SECTIONS.find((section) => section.items.some((item) => pathname.startsWith(item.href))) + const navigationSections = WORKSPACE_SECTIONS.map((section) => section.key === 'assets' && canUseDetailGenerator + ? { ...section, items: [...section.items, { key: 'detail-generator', title: '详情页生成', href: '/assets/detail-generator', status: 'connected' as const }] } + : section) + const activeSection = navigationSections.find((section) => section.items.some((item) => pathname.startsWith(item.href))) const [expandedSections, setExpandedSections] = useState(activeSection ? [activeSection.key] : ['dashboard']) useEffect(() => { setPathname(readPathname()) }, []) + useEffect(() => { + const controller = new AbortController() + fetch('/api/detail-generator/capabilities', { cache: 'no-store', signal: controller.signal }) + .then((response) => response.ok ? response.json() : null) + .then((capability) => setCanUseDetailGenerator(capability?.canUse === true)) + .catch(() => setCanUseDetailGenerator(false)) + return () => controller.abort() + }, []) + useEffect(() => { if (typeof window.matchMedia !== 'function') return const media = window.matchMedia('(max-width: 1023px)') @@ -175,7 +190,7 @@ export default function DepartmentShell({ children }: { children: ReactNode }) {