diff --git a/.gitignore b/.gitignore index 46f6770..9f7d411 100644 --- a/.gitignore +++ b/.gitignore @@ -96,6 +96,10 @@ missing-video-files*.json /.claude/worktrees/ /.playwright-mcp/ /.performance-survey-audits/ +/outputs/department-dashboard-qa/ +/.impeccable/screenshots/ +/pnpm-lock.yaml +/pnpm-workspace.yaml # Temporary investigation/probe artifacts /tmp/ @@ -124,4 +128,3 @@ backups/ # DSH 视觉工具产物目录(本地生成,不进 git) .dsh-vision-toolkit/ - diff --git a/.impeccable/design.json b/.impeccable/design.json new file mode 100644 index 0000000..f5a5cef --- /dev/null +++ b/.impeccable/design.json @@ -0,0 +1,531 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-08-19T00:00:00+08:00", + "title": "Design System: 品牌中心部门看板", + "extensions": { + "colorMeta": { + "nav-rail": { + "role": "neutral", + "displayName": "深海军蓝导航轨", + "canonical": "#06192d", + "tonalRamp": [ + "#f1f5f9", + "#dbe4ed", + "#aebdcd", + "#7f93a8", + "#526b83", + "#2b435b", + "#142b42", + "#06192d" + ] + }, + "action-blue": { + "role": "primary", + "displayName": "当前项高识别蓝", + "canonical": "#1677ff", + "tonalRamp": [ + "#eff6ff", + "#dbeafe", + "#bfdbfe", + "#93c5fd", + "#60a5fa", + "#3b82f6", + "#1677ff", + "#0958d9" + ] + }, + "canvas": { + "role": "neutral", + "displayName": "冷灰工作画布", + "canonical": "#f2f4f7", + "tonalRamp": [ + "#ffffff", + "#fafbfc", + "#f8fafc", + "#f2f4f7", + "#e9edf2", + "#dfe4ea", + "#cfd6df", + "#98a2b3" + ] + }, + "surface": { + "role": "neutral", + "displayName": "白色数据面板", + "canonical": "#ffffff", + "tonalRamp": [ + "#ffffff", + "#fcfcfd", + "#f9fafb", + "#f2f4f7", + "#e4e7ec", + "#d0d5dd", + "#98a2b3", + "#667085" + ] + }, + "surface-soft": { + "role": "neutral", + "displayName": "次级交互面", + "canonical": "#f8fafc", + "tonalRamp": [ + "#ffffff", + "#f8fafc", + "#f1f5f9", + "#e2e8f0", + "#cbd5e1", + "#94a3b8", + "#64748b", + "#334155" + ] + }, + "border": { + "role": "neutral", + "displayName": "数据面板边界", + "canonical": "#e2e8f0", + "tonalRamp": [ + "#ffffff", + "#f8fafc", + "#f1f5f9", + "#e2e8f0", + "#cbd5e1", + "#94a3b8", + "#64748b", + "#334155" + ] + }, + "text-primary": { + "role": "neutral", + "displayName": "主要文字", + "canonical": "#101828", + "tonalRamp": [ + "#f2f4f7", + "#d0d5dd", + "#98a2b3", + "#667085", + "#475467", + "#344054", + "#1d2939", + "#101828" + ] + }, + "text-secondary": { + "role": "neutral", + "displayName": "辅助文字", + "canonical": "#66758b", + "tonalRamp": [ + "#f8fafc", + "#e9eef5", + "#d7e0ea", + "#b6c4d4", + "#8fa2b8", + "#66758b", + "#4f6077", + "#344054" + ] + }, + "nav-secondary": { + "role": "neutral", + "displayName": "导航辅助文字", + "canonical": "#aebdcd", + "tonalRamp": [ + "#f5f8fb", + "#e4ebf2", + "#cfdae5", + "#aebdcd", + "#8aa0b6", + "#647b92", + "#425a72", + "#243c54" + ] + }, + "connected": { + "role": "success", + "displayName": "真实已接入状态", + "canonical": "#31d0aa", + "tonalRamp": [ + "#ecfdf8", + "#d1fae5", + "#a7f3d0", + "#6ee7b7", + "#31d0aa", + "#0e9f78", + "#087052", + "#064e3b" + ] + }, + "danger": { + "role": "danger", + "displayName": "错误与风险", + "canonical": "#b42318", + "tonalRamp": [ + "#fff1f0", + "#fee4e2", + "#fecdca", + "#fda29b", + "#f97066", + "#d92d20", + "#b42318", + "#7a271a" + ] + }, + "delivery-yellow": { + "role": "warning", + "displayName": "交付率黄色区间", + "canonical": "#a88a00", + "tonalRamp": [ + "#fffceb", + "#fff7cc", + "#ffec99", + "#ffdd66", + "#e9c436", + "#c4a516", + "#a88a00", + "#725400" + ] + }, + "business-orange": { + "role": "secondary", + "displayName": "业务经营识别色", + "canonical": "#9a4d08", + "tonalRamp": [ + "#fff4e7", + "#ffe2bd", + "#ffc982", + "#f3a94f", + "#d9821c", + "#b6630b", + "#9a4d08", + "#663004" + ] + }, + "assets-green": { + "role": "secondary", + "displayName": "资产与自动化识别色", + "canonical": "#087052", + "tonalRamp": [ + "#e9f8f2", + "#ccefe2", + "#9fe0ca", + "#63c4a4", + "#2ea481", + "#0f8563", + "#087052", + "#064b39" + ] + }, + "organization-gold": { + "role": "secondary", + "displayName": "组织管理识别色", + "canonical": "#725400", + "tonalRamp": [ + "#fff8d9", + "#ffed9a", + "#f8dc58", + "#d9bb22", + "#b99408", + "#8f7000", + "#725400", + "#4f3a00" + ] + }, + "governance-blue": { + "role": "secondary", + "displayName": "治理与设置识别色", + "canonical": "#214ba7", + "tonalRamp": [ + "#edf3ff", + "#d6e4ff", + "#adc8ff", + "#85a5ff", + "#597ef7", + "#3569df", + "#214ba7", + "#15347a" + ] + }, + "workflow-active": { + "role": "tertiary", + "displayName": "工作流选中底", + "canonical": "#e6f4ff", + "tonalRamp": [ + "#f7fcff", + "#eef9ff", + "#e6f4ff", + "#bae0ff", + "#91caff", + "#69b1ff", + "#4096ff", + "#1677ff" + ] + }, + "workflow-active-title": { + "role": "primary", + "displayName": "工作流选中标题", + "canonical": "#0050cb", + "tonalRamp": [ + "#e6f4ff", + "#bae0ff", + "#69b1ff", + "#1677ff", + "#0958d9", + "#0050cb", + "#003eb3", + "#002c8c" + ] + }, + "workflow-active-description": { + "role": "tertiary", + "displayName": "工作流选中说明", + "canonical": "#315f91", + "tonalRamp": [ + "#edf6ff", + "#d6e9fb", + "#b5d4ee", + "#8fb8dc", + "#699ac5", + "#4c7daf", + "#315f91", + "#20436c" + ] + }, + "workflow-inactive-title": { + "role": "neutral", + "displayName": "工作流未选标题", + "canonical": "#4f6077", + "tonalRamp": [ + "#f8fafc", + "#e9eef5", + "#d7e0ea", + "#b6c4d4", + "#8fa2b8", + "#66758b", + "#4f6077", + "#344054" + ] + }, + "workflow-inactive-description": { + "role": "neutral", + "displayName": "工作流未选说明", + "canonical": "#667085", + "tonalRamp": [ + "#fcfcfd", + "#f2f4f7", + "#e4e7ec", + "#d0d5dd", + "#98a2b3", + "#667085", + "#475467", + "#344054" + ] + }, + "workflow-hover": { + "role": "neutral", + "displayName": "工作流未选悬停", + "canonical": "#eef2f6", + "tonalRamp": [ + "#ffffff", + "#f8fafc", + "#f4f6f8", + "#eef2f6", + "#e4e9ef", + "#d5dde6", + "#b8c4d2", + "#7f93a8" + ] + }, + "workflow-focus": { + "role": "tertiary", + "displayName": "工作流焦点环", + "canonical": "#5a9ce6", + "tonalRamp": [ + "#eff7ff", + "#dbeeff", + "#b8dcff", + "#8fc4f5", + "#5a9ce6", + "#2f7fcf", + "#176fc1", + "#0d4d8c" + ] + } + }, + "typographyMeta": { + "display": { + "displayName": "页面主标题", + "use": "首页与关键二级页标题" + }, + "headline": { + "displayName": "数据页标题", + "use": "指标与明细页标题" + }, + "title": { + "displayName": "面板标题", + "use": "职责域与数据分区" + }, + "body": { + "displayName": "正文", + "use": "页面说明和数据解释" + }, + "label": { + "displayName": "操作标签", + "use": "导航、按钮、表头和状态" + } + }, + "shadows": [ + { + "name": "panel-low", + "value": "0 6px 18px rgba(15, 23, 42, 0.05)", + "purpose": "需要与冷灰画布分离的主要白色面板" + }, + { + "name": "menu", + "value": "0 14px 38px rgba(15, 23, 42, 0.16)", + "purpose": "账户菜单、移动抽屉和可拖动榜单" + } + ], + "motion": [ + { + "name": "state", + "value": "200ms ease", + "purpose": "导航折叠、选中和轻量悬停反馈" + } + ], + "breakpoints": [ + { + "name": "sm", + "value": "640px" + }, + { + "name": "lg", + "value": "1024px" + }, + { + "name": "2xl", + "value": "1536px" + } + ] + }, + "components": [ + { + "name": "Department Navigation Active", + "type": "navigation", + "description": "二级页面在深色导航轨中的唯一当前位置;16px 语义图标与文字一起变为白色。", + "projection": "业务趋势" + }, + { + "name": "Data Panel", + "type": "card", + "description": "边框优先的白色矩形数据工作面。", + "projection": "

核心指标

真实、示例与待接入状态均清晰标注。

" + }, + { + "name": "Primary Button", + "type": "button", + "description": "高识别蓝色主要行动按钮。", + "projection": "" + }, + { + "name": "Search Field", + "type": "input", + "description": "白底细边框的数据筛选字段。", + "projection": "" + }, + { + "name": "Connected Status", + "type": "chip", + "description": "只有真实接入业务才使用绿色状态。", + "projection": "实时读取" + }, + { + "name": "Workspace Row", + "type": "custom", + "description": "不可点击的大职责域和可点击的小项入口。", + "projection": "

中心仪表盘

掌握经营与交付全貌

" + }, + { + "name": "Sidebar Account Menu", + "type": "navigation", + "description": "账户入口固定在侧栏底部,菜单向上展开。", + "projection": "
" + }, + { + "name": "Mobile Navigation Drawer", + "type": "navigation", + "description": "1024px 以下使用的完整模态导航抽屉。", + "projection": "
品牌中心
" + }, + { + "name": "Automation Workflow Split Workbench", + "type": "navigation", + "description": "Department Shell 内的 232px 二级业务栏与同源真实操作面;移动端菜单改为顶部两列,不渲染标题或接入数量区。", + "projection": "
绩效考核调研

同源真实业务操作面

" + } + ], + "narrative": { + "northStar": "深色导航轨数据工作台", + "overview": "品牌中心是一套固定导航、快速切换与逐层下钻的内部运营界面。深海军蓝导航轨提供稳定坐标,白色操作顶栏承载全局控制,冷灰画布上以矩形白色工作面板组织真实数据、示例状态与待接入框架。\n\n它保留品牌中心自己的五个职责域、22 个小项入口,以及素材库与自动化工作流两项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。首页是工作地图,不是营销门户;子页是数据工作面,不用巨型漂浮英雄卡重复标题。", + "keyCharacteristics": [ + "272px 固定深色导航轨,桌面可折叠为 80px。", + "72px 白色顶栏与 `#f2f4f7` 冷灰画布组成稳定后台骨架;账户入口固定在侧栏底部。", + "`#1677ff` 只用于当前项、主要行动和明确焦点。", + "白色面板以 8–12px 圆角、细边框和克制阴影呈现。", + "移动端导航是完整的模态抽屉;内容状态始终诚实标注。", + "自动化工作流在 Department Shell 内使用 232px 二级业务栏与同源真实操作面,移动端改为顶部两列菜单。" + ], + "rules": [ + { + "name": "The Current Location Rule", + "body": "进入二级页面后只保留一个最强的 `#1677ff` 导航选中态;首页不设置独立“部门总览”导航项,职责域色不得与当前项竞争。", + "section": "colors" + }, + { + "name": "The Truth Color Rule", + "body": "绿色只表示真实已接入,示例值和待接入状态不得伪装成成功。", + "section": "colors" + }, + { + "name": "The Embedded Business Truth Rule", + "body": "二级栏只切换既有真实业务操作面;选中态、URL 与 iframe 内容必须同步,不能用入口卡片或示例数据替代。", + "section": "colors" + }, + { + "name": "The Dense Tool Rule", + "body": "顶栏和导航保持 12–14px 紧凑标签;页面标题才使用 30–36px,不把每个面板都做成英雄区。", + "section": "typography" + }, + { + "name": "The Two-Level Workflow Rule", + "body": "自动化工作流的二级栏只包含两个业务菜单,不添加标题、数量、说明头或入口卡;右侧始终是当前菜单对应的真实操作面。", + "section": "layout" + }, + { + "name": "The Top-Level Return Rule", + "body": "嵌入业务的根路径返回必须跳出 iframe 到顶层浏览上下文,禁止在操作面内再次嵌入品牌中心首页。", + "section": "layout" + }, + { + "name": "The Border-First Rule", + "body": "默认先用 `#e2e8f0` 细边框建立结构;不要用超过 20px 的主表面圆角与大阴影制造漂浮岛。", + "section": "elevation" + } + ], + "dos": [ + "**Do** 在首页、框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell。", + "**Do** 保持 5 个职责域、22 个小项、20 个框架入口,以及素材库和自动化工作流两项已接入业务的产品真相。", + "**Do** 明确区分真实数据、示例值、公式待接入与空数据。", + "**Do** 在桌面与 390px 移动宽度验证导航、焦点和横向溢出。", + "**Do** 让自动化工作流的菜单、`?workflow=` 和右侧真实业务操作面始终同步。", + "**Do** 让嵌入业务的根路径返回以 `target=\"_top\"` 跳出 iframe。" + ], + "donts": [ + "**Don't** 恢复旧版漂浮岛视觉、门户式白色英雄卡或超过 20px 的主表面圆角。", + "**Don't** 让职责域标题本身成为导航;只有下方小项链接进入二级页面。", + "**Don't** 复制参考图中的京东品牌、店铺指标、水印或虚构业务数据。", + "**Don't** 让移动抽屉键盘焦点穿透到底层页面。", + "**Don't** 在自动化工作流二级栏顶部显示标题、接入数量、统计区或独立说明区。", + "**Don't** 用入口卡片、复制页面或假数据替代 `/performance-survey` 与 `/work-review` 的真实操作面。", + "**Don't** 让 `href=\"/\"` 在 iframe 内打开并嵌套品牌中心首页。" + ] + } +} diff --git a/DESIGN.md b/DESIGN.md index a3e16d6..2f466288 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,65 +1,281 @@ -# DESIGN.md — 由壹点·品牌中心(看板首页) +--- +name: "品牌中心部门看板" +description: "以深色部门导航轨组织业务、资产、组织与治理工作的内部数据工作台。" +colors: + nav-rail: "#06192d" + action-blue: "#1677ff" + canvas: "#f2f4f7" + surface: "#ffffff" + surface-soft: "#f8fafc" + border: "#e2e8f0" + text-primary: "#101828" + text-secondary: "#66758b" + nav-secondary: "#aebdcd" + connected: "#31d0aa" + danger: "#b42318" + delivery-yellow: "#a88a00" + business-orange: "#9a4d08" + assets-green: "#087052" + organization-gold: "#725400" + governance-blue: "#214ba7" + workflow-active: "#e6f4ff" + workflow-active-title: "#0050cb" + workflow-active-description: "#315f91" + workflow-inactive-title: "#4f6077" + workflow-inactive-description: "#667085" + workflow-hover: "#eef2f6" + workflow-focus: "#5a9ce6" +typography: + display: + fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" + fontSize: "36px" + fontWeight: 700 + lineHeight: 1.2 + letterSpacing: "-0.03em" + headline: + fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" + fontSize: "30px" + fontWeight: 700 + lineHeight: 1.25 + letterSpacing: "-0.02em" + title: + fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" + fontSize: "20px" + fontWeight: 700 + lineHeight: 1.4 + body: + fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" + fontSize: "16px" + fontWeight: 400 + lineHeight: 1.75 + label: + fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" + fontSize: "14px" + fontWeight: 600 + lineHeight: 1.4 +rounded: + sm: "4px" + md: "8px" + lg: "12px" + full: "9999px" +spacing: + xs: "4px" + sm: "8px" + md: "12px" + lg: "16px" + xl: "24px" + xxl: "32px" +components: + navigation-active: + backgroundColor: "{colors.action-blue}" + textColor: "{colors.surface}" + rounded: "{rounded.md}" + padding: "12px" + panel: + backgroundColor: "{colors.surface}" + textColor: "{colors.text-primary}" + rounded: "{rounded.lg}" + padding: "24px" + button-primary: + backgroundColor: "{colors.action-blue}" + textColor: "{colors.surface}" + rounded: "{rounded.md}" + padding: "12px 16px" + field: + backgroundColor: "{colors.surface}" + textColor: "{colors.text-primary}" + rounded: "{rounded.md}" + padding: "12px 16px" + status-connected: + backgroundColor: "{colors.connected}" + textColor: "{colors.nav-rail}" + rounded: "{rounded.full}" + size: "6px" + workflow-secondary-nav: + backgroundColor: "{colors.surface-soft}" + textColor: "{colors.workflow-inactive-title}" + rounded: "{rounded.md}" + padding: "16px" + width: "232px" + workflow-secondary-item-active: + backgroundColor: "{colors.workflow-active}" + textColor: "{colors.workflow-active-title}" + rounded: "{rounded.md}" + padding: "10px 12px" + height: "58px" +--- - +# Design System: 品牌中心部门看板 -> 记录自建成代码,非先行规范。方向:用户裁定的 Zen Minimal × Future Organic 融合(2026-08-15)。 -> **全站浅色单主题**:深色主题已废弃(2026-08-15 用户裁定),`darkMode: 'class'` 且不挂 `.dark`,所有 `dark:` 变体失效;新代码禁止再写 `dark:` 类。遗留 `dark:` 类 inert 保留,随逐页统一清理。 +## Overview -## World +**Creative North Star: "深色导航轨数据工作台"** -「白皮书」:整页是一张编辑排印的品牌白皮书——白纸黑字、发丝线、超大留白;五个板块排成作品集式编号索引;唯一的色彩氛围是右上角一枚缓慢呼吸的柔紫光晕。安静、克制、有底气。 +品牌中心是一套固定导航、快速切换与逐层下钻的内部运营界面。深海军蓝导航轨提供稳定坐标,白色操作顶栏承载全局控制,冷灰画布上以矩形白色工作面板组织真实数据、示例状态与待接入框架。 -## Color +它保留品牌中心自己的五个职责域、22 个小项入口,以及素材库与自动化工作流两项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。首页是工作地图,不是营销门户;子页是数据工作面,不用巨型漂浮英雄卡重复标题。 -| 令牌 | 值 | 用途 | -|---|---|---| -| paper | `#fafafa` | 页面底 | -| surface | `#ffffff` | 白卡、悬停行底 | -| ink | `#1a1c1d` | 标题、正文主色、主按钮 | -| body | `#6b7075` | 次级正文(≥4.5:1) | -| faint | `#86898e` | 注脚/微标注/规划态文字(≥4.5:1) | -| ghost-title | `#8f9398` | 规划中板块标题(大字 ≥3:1) | -| hairline | `#e7e8ea` | 全部分割线与卡片边 | -| accent | `#6e79c8` / 深链 `#5a66b8` | 柔光光晕 / 页脚链接 | -| accent-wash | `#eef0fa` | 光晕最浅层、::selection | -| signal-live | `#157a4b` | 已上线状态点 | -| signal-building | `#6e79c8` | 建设中状态点 | -| signal-planned | `#9aa0a8` | 规划中状态点 | +**Key Characteristics:** -无阴影(平面化);层级靠留白、字重与发丝线。**logo 双版本**:浅底表面用 `loginLogoUrl`(深蓝+橙图案),黑底表面用 `headerLogoUrl`(亮色图案)。 +- 272px 固定深色导航轨,桌面可折叠为 80px。 +- 72px 白色顶栏与 `#f2f4f7` 冷灰画布组成稳定后台骨架;账户入口固定在侧栏底部。 +- `#1677ff` 只用于当前项、主要行动和明确焦点。 +- 白色面板以 8–12px 圆角、细边框和克制阴影呈现。 +- 移动端导航是完整的模态抽屉;内容状态始终诚实标注。 +- 自动化工作流在 Department Shell 内使用 232px 二级业务栏与同源真实操作面,移动端改为顶部两列菜单。 -## Type +## Colors -- 全站默认字栈:`Space Grotesk` → `Noto Sans SC`。拉丁字母和数字走 Space Grotesk,汉字走思源黑体,同一段字里自动切换。 -- 中文标题:Noto Sans SC(`font-sans` / `font-display`),600 字重、`-0.03em` 紧字距;首页巨标 `clamp(60px, 9vw, 132px)` -- 工程标注/编号/英文:Space Grotesk(字栈首位 + `font-en`),11–13px、大写、`0.14em` 字距 -- 正文:同上字栈,13–16px、行高 1.7;不再用系统默认黑体 -- 自托管:`@fontsource/noto-sans-sc`(400/500/600/700/900)、`@fontsource/space-grotesk`(400/500/600/700) +深色导航和高识别蓝色建立方向感,冷灰中性色保持高密度数据的可读性;职责域色只做局部识别,不铺满主画布。 + +### Primary + +- **Action Blue** (`#1677ff`): 当前导航项、主要按钮、焦点环和关键链接。 +- **Deep Navy Rail** (`#06192d`): 桌面固定导航和移动抽屉。 + +### Secondary + +- **Business Orange** (`#9a4d08`), **Assets Green** (`#087052`), **Organization Gold** (`#725400`), **Governance Blue** (`#214ba7`): 五个职责域的局部图标、摘要和软色块。 +- **Connected Mint** (`#31d0aa`): 已接入业务入口的小圆点状态。 + +### Neutral + +- **Canvas Gray** (`#f2f4f7`): 主工作区背景。 +- **Panel White** (`#ffffff`): 顶栏、面板、表格和字段。 +- **Soft Surface** (`#f8fafc`): 面板内部的次级行或骨架。 +- **Divider** (`#e2e8f0`): 面板、行、表格与字段边界。 +- **Primary Ink** (`#101828`) / **Secondary Ink** (`#66758b`): 标题、正文和辅助信息。 +- **Workflow Active** (`#e6f4ff`, `#0050cb`, `#315f91`): 自动化工作流二级栏的选中底、标题与说明。 +- **Workflow Inactive** (`#4f6077`, `#667085`, `#eef2f6`): 未选标题、说明与 hover;焦点环固定为 `#5a9ce6`。 + +### Named Rules + +**The Current Location Rule.** 进入二级页面后只保留一个最强的 `#1677ff` 导航选中态;首页不设置独立“部门总览”导航项,职责域色不得与当前项竞争。 + +**The Truth Color Rule.** 绿色只表示真实已接入,示例值和待接入状态不得伪装成成功。 + +**The Embedded Business Truth Rule.** 二级栏只切换既有真实业务操作面;选中态、URL 与 iframe 内容必须同步,不能用入口卡片或示例数据替代。 + +## Typography + +**Display Font:** System UI sans-serif + +**Body Font:** System UI sans-serif + +**Character:** 紧凑、直接、适合中文运营后台;依靠字重和间距建立层级,不使用装饰性字体。 + +### Hierarchy + +- **Display** (700, 36px, 1.2): 首页与关键二级页标题;移动端降至约 30px。 +- **Headline** (700, 30px, 1.25): 指标与明细页标题。 +- **Title** (700, 20px, 1.4): 工作板块、数据分区和面板标题。 +- **Body** (400, 16px, 1.75): 页面说明,建议控制在约 70ch 内。 +- **Label** (600, 14px, 1.4): 导航、表头、按钮和状态标签。 + +### Named Rules + +**The Dense Tool Rule.** 顶栏和导航保持 12–14px 紧凑标签;页面标题才使用 30–36px,不把每个面板都做成英雄区。 ## Layout -- 顶栏:56px 高,logo + 「由壹点·品牌中心」+ 板块导航 + 用户区;发丝线下边 -- 容器:`max-w-[1280px] px-6`;板块间距 64–96px -- 编号索引:五行,网格 `[64px_1fr_160px_120px_32px]`(移动端 `[44px_1fr_auto]`,状态点保留) -- 已上线板块:两张 12px 圆角白卡(`p-8/10`)承载入口按钮 +桌面端采用固定侧栏工作台:导航轨宽 272px,可折叠为 80px;右侧顶栏高 72px;主内容最大宽度 1600px,使用 16/24/32px 响应式内边距。首页标题后直接进入五条纵向职责域,每条职责域下的小项在桌面端同一横排,窄屏改为一至两列。 + +1024px 以下隐藏固定侧栏,以左侧模态抽屉替代。抽屉宽度为 `min(86vw, 304px)`,打开时锁定页面滚动、底层内容 `inert`、焦点进入并约束在导航内,支持 Escape 关闭并恢复触发器焦点。390px 页面不得产生全局横向溢出。 + +自动化工作流在 Department Shell 内容区内再分两层:桌面左侧为 232px 白 / 雾灰二级栏,右侧为 `minmax(0, 1fr)` 的同源实际业务操作面;移动端二级栏移动到内容顶部,以两列菜单呈现。二级栏永久不渲染“自动化工作流”标题、接入数量或独立统计区,只显示“绩效考核调研”“作品过稿看板”两个菜单。`?workflow=performance-survey|work-review` 持久化当前选择并与嵌入内容同步。 + +右侧通过同源 iframe 直接嵌入 `/performance-survey` 与 `/work-review`,保留原业务的真实数据、权限和交互。嵌入业务中所有 `href="/"` 的跨应用返回必须使用 `target="_top"`,确保返回品牌中心根页面而不是在 iframe 内再次嵌套首页。 + +**The Two-Level Workflow Rule.** 自动化工作流的二级栏只包含两个业务菜单,不添加标题、数量、说明头或入口卡;右侧始终是当前菜单对应的真实操作面。 + +**The Top-Level Return Rule.** 嵌入业务的根路径返回必须跳出 iframe 到顶层浏览上下文,禁止在操作面内再次嵌入品牌中心首页。 + +## Elevation & Depth + +系统以边框和底色分层为主,阴影只提供轻微离地感。顶栏与静态数据面板无需强阴影;首页职责域、指标组和悬浮菜单可使用低强度环境阴影。 + +### Shadow Vocabulary + +- **Panel Low** (`0 6px 18px rgba(15, 23, 42, 0.05)`): 仅用于需要与画布分离的主要白色面板。 +- **Menu** (`0 14px 38px rgba(15, 23, 42, 0.16)`): 账户菜单、移动浮层和可拖动榜单。 + +### Named Rules + +**The Border-First Rule.** 默认先用 `#e2e8f0` 细边框建立结构;不要用超过 20px 的主表面圆角与大阴影制造漂浮岛。 + +## Shapes + +主表面使用 8–12px 圆角:8px 用于字段、导航项和小面板,12px 用于职责域和大数据面板。固定侧栏和顶栏已提供位置上下文,子页面不重复渲染面包屑。圆形只用于状态点、头像和明确图标容器。不得恢复 22–28px 的门户式主表面。 ## Components -- 索引行:已上线 = 整行可点 Link,悬停白底+箭头浮现+编号变紫;规划中 = 灰行不可点 -- 按钮:主 = 墨色实心 8px 圆角;次 = 1px 墨边白底,hover 反色 -- 状态:7px 圆点 + 12px 文字(移动端不隐藏) -- 焦点:`ring-2 ring-[#1a1c1d]/70 ring-offset-2`,全站交互元素必带 +### Buttons -## Motion +- **Shape:** 8–12px 圆角,最小触控高度 40–44px。 +- **Primary:** `#1677ff` 背景和白字;hover 变深,focus-visible 使用清晰蓝色环。 +- **Secondary:** 白底、`#e2e8f0` 边框和深色文字。 -GSAP(`useGSAP` + `matchMedia('(prefers-reduced-motion: no-preference)')`):巨字升起 → 光晕淡入 → 索引行 stagger 0.08 → 注脚淡入;光晕 5s 纯 opacity 呼吸循环。全部 0.4–1.2s,信息效率优先(用户既定标准:略丰富但克制)。 +### Chips -## Surfaces +- **Style:** 8px 或全圆角;状态文案必须同时说明“实时读取”“示例值”或“待接入”,不能只靠颜色。 -::selection = `#eef0fa`;链接 `underline-offset-4`;光晕容器 `overflow-hidden` 防移动端横向溢出。 +### Cards / Containers -## Scope Notes +- **Corner Style:** 主要面板 12px,内部分区 8px。 +- **Background:** 白色;次级行可用 `#f8fafc`。 +- **Shadow Strategy:** 边框优先,阴影克制。 +- **Internal Padding:** 20–32px,随屏宽收紧。 -- **2026-08-17 用户裁定:本次是比较重大的升级,视觉语言需要全面升级。** 后续页面不得再做「旧壳换色 / 只改角标和按钮」的局部换皮;信息架构可保留,版式、组件、密度、动效应按本世界重做。 -- 组织架构页(`/organization`)仍为旧「云采石场」语言(BoardShell),待按本世界全面对齐 -- 素材库(`/materials`)顶栏已是看板语言;列表、筛选、卡片仍是旧素材库骨架,需按本世界全面升级,而不是只改色板 +### Inputs / Fields + +- **Style:** 白底、8px 圆角、`#e2e8f0` 边框。 +- **Focus:** `#1677ff` 边框或 2px focus ring。 +- **Error / Disabled:** 红色错误与降低不透明度并用,保留文字解释。 + +### Navigation + +- 深色轨默认文字为 `#aebdcd`,hover 使用低透明白色,当前项使用 `#1677ff` + 白字。 +- 侧栏从五个职责域直接开始,不显示独立“部门总览”入口;首页标题仍由白色顶栏提供页面定位。 +- 账户入口固定在侧栏最下方并向上展开菜单;顶栏只保留页面定位与导航折叠控制。 +- 子页面标题直接承接首屏内容,不在标题上方增加重复的面包屑面板。 +- 22 个小项在侧栏使用 16px 语义线性图标;选中项图标随文字切为白色,页面标题区不重复摆放同一导航图标。 +- 大职责域按钮只展开/收起;小项链接承担导航,并以 `aria-current="page"` 标识当前位置。 +- 移动抽屉使用 `role="dialog"`、`aria-modal="true"` 和完整键盘焦点管理。 + +### Automation Workflow Split Workbench + +- **Desktop Structure:** 深色主导航右侧是 232px 白 / 雾灰二级栏,再右侧是同源真实操作面;整体填满 `calc(100dvh - 72px)`,不生成欢迎页或入口卡片。 +- **Menus:** 只显示“绩效考核调研”和“作品过稿看板”。选中项使用 `#e6f4ff` 底、`#0050cb` 标题、`#1677ff` 图标和 `#315f91` 说明;未选项使用 `#4f6077` 标题、`#667085` 说明,hover 为 `#eef2f6`,active hover 不得覆盖选中底。 +- **Focus:** 两个菜单最小高度 58px,8px 圆角,焦点环为 `#5a9ce6`;当前项同时设置 `aria-current="page"`。 +- **Mobile:** 同一菜单移动到操作面顶部并变为两列;桌面说明在窄屏隐藏,顺序和 URL 语义不变。 +- **Embedding:** `/performance-survey` 与 `/work-review` 直接保留各自真实数据、权限、工具栏和交互;`?workflow=` 记录选择。所有跨应用根路径返回使用 `target="_top"`。 +- **Permanent Omission:** 二级栏顶部永久不显示“自动化工作流”、接入数量、统计区或解释区。 + +### Workspace Row + +首页每个职责域是不可点击的白色分组面板;域标题、摘要和说明置于上方,小项链接置于下方横排。五域色只用于图标软底、摘要与箭头,不改变统一白色数据面板材质。 + +### Data Status Placement + +所有子页面从页面标题直接进入核心内容,不渲染独立的“数据接入说明”“数据来源说明”或“计算说明”面板。真实读取、示例值、待接入、读取失败和公式状态由对应卡片或数据模块自身承担;实际业务操作帮助不属于此类,仍应保留在需要的位置。 + +## Do's and Don'ts + +### Do: + +- **Do** 在首页、框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell。 +- **Do** 保持 5 个职责域、22 个小项、20 个框架入口,以及素材库和自动化工作流两项已接入业务的产品真相。 +- **Do** 明确区分真实数据、示例值、公式待接入与空数据。 +- **Do** 在桌面与 390px 移动宽度验证导航、焦点和横向溢出。 +- **Do** 让自动化工作流的菜单、`?workflow=` 和右侧真实业务操作面始终同步。 +- **Do** 让嵌入业务的根路径返回以 `target="_top"` 跳出 iframe。 + +### Don't: + +- **Don't** 恢复旧版漂浮岛视觉、门户式白色英雄卡或超过 20px 的主表面圆角。 +- **Don't** 让职责域标题本身成为导航;只有下方小项链接进入二级页面。 +- **Don't** 复制参考图中的京东品牌、店铺指标、水印或虚构业务数据。 +- **Don't** 让移动抽屉键盘焦点穿透到底层页面。 +- **Don't** 在自动化工作流二级栏顶部显示标题、接入数量、统计区或独立说明区。 +- **Don't** 用入口卡片、复制页面或假数据替代 `/performance-survey` 与 `/work-review` 的真实操作面。 +- **Don't** 让 `href="/"` 在 iframe 内打开并嵌套品牌中心首页。 diff --git a/Dockerfile b/Dockerfile index 81bbb5d..d0511e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,20 @@ RUN --mount=type=cache,target=/root/.npm \ node -e "const sharp=require('sharp'); sharp(Buffer.alloc(4),{raw:{width:1,height:1,channels:4}}).png().toBuffer().then(()=>console.log('sharp dependency smoke passed')).catch((error)=>{console.error(error);process.exit(1)})" && \ echo "Dependencies installed ($(du -sh /app/node_modules | cut -f1))" +# 本地开发阶段:复用依赖层,并补齐项目运行时会调用的媒体处理工具。 +# 源码由 Compose 绑定挂载,避免每次修改代码都重新构建镜像。 +FROM deps AS dev +ARG ALPINE_MIRROR +RUN apk add --no-cache ffmpeg openssl poppler-utils + +ENV NODE_ENV=development +ENV NEXT_TELEMETRY_DISABLED=1 +ENV HOSTNAME=0.0.0.0 + +EXPOSE 3000 + +CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0"] + # 构建阶段 FROM base AS builder ARG ALPINE_MIRROR diff --git a/PRODUCT.md b/PRODUCT.md index 68087fb..51ddd87 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -6,63 +6,45 @@ web -## Stack - -Next.js 14 (App Router) + React 18 + TypeScript + TailwindCSS + PostgreSQL(Prisma)。既有代码库已定栈。 - ## Users -- **系统管理员**:维护平台人员与组织数据,需要看到全量真实组织架构、人员归属、职级与汇报线。 -- **部门负责人**:查看本部门组织与人员情况(本部门范围内)。 -- 用户为内部员工(非程序员),界面需日常语言、少术语、可直接读。 +品牌中心内部成员、部门负责人和管理者。他们需要从统一入口定位业务、资产、组织与治理相关工作,并按自身权限进入具体工具。 ## Product Purpose -把「由壹点3D素材库」逐步改造为「品牌中心部门看板」(对内品牌:**由壹点·品牌中心**,隶属由壹点软件集团)。第一阶段(2026-08-15)已完成:登录落点切换为看板首页 `/`,素材库整体迁至 `/materials`,组织架构上移为顶层 `/organization`(旧路径 `/admin/org` 服务端跳转)。 - -## Positioning - -以钉钉唯一值为唯一人员身份判定依据,统一接入公司 HR 报表库(非钉钉通讯录),人员/组织数据全平台一致;考核调研与素材库共用同一份权威人员主档,业务特殊数据隔离在各自专属表。素材库为在产系统,改造期间上传/下载/分享/权限链路不动。 +“品牌中心部门看板”是品牌中心的一级工作门户,用五个业务域组织部门工作入口:中心仪表盘、业务经营、资产与AI自动化、组织管理、治理与设置。当前先建立信息架构和导航框架,底层业务数据后续接入。 ## Operating Context -- 人员权威数据源:公司 SQL Server 报表库(`rpt` schema),每日 02:00(Asia/Shanghai)自动同步组织架构→在职状态→离职联动。 -- 只有「开过素材库账号 + 已建立钉钉绑定」的人员进入展示/统计体系(约 503 人);无账号的仓储/物流等约 1200 人不纳入。 -- 在职判定用规范化 `employmentState`(ACTIVE/RESIGNED/UNKNOWN,UNKNOWN 视为在职),不用关键词匹配。 +产品运行在内部 Web 环境中,既有“由壹点3D素材”平台已经在线运行。本地开发阶段必须与线上数据和部署链路隔离;代码只有在用户明确指示后才允许提交或部署。 ## Capabilities and Constraints -- 看板首页 `/`:五大板块编号索引(01 创意资产库全员可进 / 02 组织管理仅管理员 / 03-05 规划中如实标灰),入场"对焦"动效,悬停物理重量语言。 -- 创意资产库 `/materials`:原素材库首页,**业务功能不动**;顶栏已统一为看板语言。2026-08-17 起按「重大升级」处理:视觉语言要全面对齐看板世界,禁止只改色板/圆角的局部换皮。 -- 组织架构页 `/organization`:组织树 + 人员列表,展示 M/P 等级、人员归属(部门路径)、直属主管、在职状态。 -- 纯展示(只读),不改数据。 -- 权限:系统管理员看全部;部门负责人看本部门。 -- 离职人员默认隐藏,可切换「含离职」。 -- 无绑定账号(无钉钉 ID,约 15 人)与部门未确定人员(约 57 人)单独分组,不消失、不硬塞部门。 -- 数据缺口如实展示:直属主管覆盖率低(约 158/503)、部分人员无 M/P 等级。 +- 一级门户根路径为 `/`。 +- “由壹点3D素材”作为“资产与AI自动化”下的二级页面保留完整现有能力。 +- “自动化工作流”作为“资产与AI自动化”下的业务入口,集中链接现有的绩效考核调研与作品过稿看板。 +- 除素材资产库、自动化工作流和已接入的中心指标外,其余板块现阶段只展示可理解、可扩展的模块框架,不虚构业务数据。 +- 沿用项目现有 Next.js、NextAuth、Tailwind CSS 和权限体系。 ## Brand Commitments -- 平台名:由壹点·品牌中心(隶属由壹点软件集团;素材库模块沿用「由壹点3D素材」之名)。 -- **首页视觉方向(2026-08-15 用户裁定):Zen Minimal × Future Organic 融合**——白纸(#fafafa)黑字(#1a1c1d)的瑞士编辑排印、发丝线、超大留白、编号索引列表(作品集语言)、右上角唯一柔紫光晕(#6e79c8 系)缓慢呼吸;平面化无阴影,黑实心按钮 8px 圆角。方向源自用户在 Stitch 自生成的候选 3/4 + 作品集文件夹两份 DESIGN.md(Monochrome/Studio Minimalist)。此前的「云采石场」语言仅保留在组织架构页(BoardShell),后续按新方向对齐。 -- **logo 双版本规则**:黑底表面(素材库旧顶栏)用 `headerLogoUrl`(亮色图案);浅底表面(看板首页、BoardShell)用 `loginLogoUrl`(深蓝+橙图案)。两者内容不同,不可混用。 -- **全站浅色单主题(2026-08-15 裁定)**:深色主题废弃,`tailwind darkMode: 'class'` 且不挂 `.dark`;新代码禁止 `dark:` 类。 -- 动效标准:略丰富但克制(用户既定),GSAP + matchMedia(prefers-reduced-motion) + fromTo + clearProps,200-500ms,信息效率优先。 +- 产品主标题使用“品牌中心部门看板”。 +- 信息架构以用户提供的五分支部门工作地图为依据,使用克制、清晰的业务语言。 +- 保留“由壹点3D素材”现有名称和功能语义。 ## Evidence on Hand -- 思维导图:`资料/品牌中心部门看板(初版).jpg`(5 大板块规划)。 -- 交接文档:`docs/人员架构界面切换-交接文档-2026-08-14.md`。 -- 数据盘点:`scripts/diagnose-people-architecture.ts`(只读,可重复跑)。 +- 用户提供的五板块思维导图截图。 +- 当前仓库内已运行的素材库页面、登录、权限和后台功能。 +- 暂无用于一级门户的真实业务指标,界面不得编造数值或经营结论。 ## Product Principles -1. 人员身份只认钉钉唯一值;姓名/邮箱仅人工排错,不自动建绑定。 -2. 只展示「有账号+绑定」的人,无账号的仓储/物流等不纳入统计。 -3. 在职判定用规范化 `employmentState`,UNKNOWN 视为在职(宁可多留)。 -4. 数据如实展示,不造假:主管/等级缺失就显示"未记录"。 -5. 归属与署名分离:素材归属与作者署名对不上通常是正确的,不"修正"。 +- 先让入口与职责边界清楚,再接入数据。 +- 一级门户负责导航与全局理解,二级页面负责完成具体任务。 +- 保持线上业务稳定,本地验证充分后再由用户决定是否提交和部署。 +- 对不可用能力诚实标注状态,不用假数据制造完成感。 ## Accessibility & Inclusion -内部后台工具,目标用户为内部员工;遵循基础可访问性(语义化、键盘可操作、对比度达标)。 +门户的核心导航必须支持键盘操作、清晰焦点、语义化标签,并在桌面端与移动端保持可读和可用。 diff --git a/README.md b/README.md index be90666..8c2b1a7 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,51 @@ docker compose up -d http://localhost:3000 ``` +登录后的一级入口为“品牌中心部门看板”,当前页面路由如下: + +```text +品牌中心部门看板:http://localhost:3000/ +小项二级框架示例:http://localhost:3000/workspaces/dashboard/core-metrics +由壹点3D素材:http://localhost:3000/assets/material-library +``` + +### 无本机 Node 环境时使用 Docker 开发 + +仓库提供一套隔离的 Docker 开发环境,包含 Node.js 22、PostgreSQL、Redis、MinIO 和支持源码热更新的 Next.js。它使用独立数据卷,不连接生产数据库或生产对象存储。 + +```bash +docker compose \ + --env-file .env.docker-test.local \ + -f docker-compose.local-test.yml \ + -f docker-compose.dev.yml \ + up -d --build +``` + +首次启动会自动执行 `prisma generate` 和 `prisma db push`。启动完成后访问: + +```text +应用:http://localhost:4321 +MinIO 控制台:http://localhost:59001 +``` + +查看应用日志或停止环境: + +```bash +docker compose \ + --env-file .env.docker-test.local \ + -f docker-compose.local-test.yml \ + -f docker-compose.dev.yml \ + logs -f app + +docker compose \ + --env-file .env.docker-test.local \ + -f docker-compose.local-test.yml \ + -f docker-compose.dev.yml \ + down +``` + +`down` 会保留本地数据库和上传文件;只有显式增加 `--volumes` 才会清除本地开发数据。 + ## 常用脚本 ```bash diff --git a/app/api/department-dashboard/core-metrics/first-pass/route.ts b/app/api/department-dashboard/core-metrics/first-pass/route.ts new file mode 100644 index 0000000..a4b2eff --- /dev/null +++ b/app/api/department-dashboard/core-metrics/first-pass/route.ts @@ -0,0 +1,37 @@ +import { getServerSession } from 'next-auth' +import { NextResponse } from 'next/server' +import { authOptions } from '@/lib/auth' +import { PerformanceSurveyApiError } from '@/lib/performance-survey-service' +import { + WorkReviewApiError, + getFirstPassReviewSummary, +} from '@/lib/work-review-service' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const PRIVATE_HEADERS = { + 'Cache-Control': 'private, no-store, max-age=0', +} + +export async function GET() { + try { + const session = await getServerSession(authOptions) + const data = await getFirstPassReviewSummary(session) + return NextResponse.json({ success: true, data }, { headers: PRIVATE_HEADERS }) + } catch (error) { + if (error instanceof WorkReviewApiError || error instanceof PerformanceSurveyApiError) { + return NextResponse.json( + { success: false, error: error.message }, + { status: error.status, headers: PRIVATE_HEADERS }, + ) + } + + console.error('[department-dashboard] 首轮通过指标读取失败:', error) + return NextResponse.json( + { success: false, error: '首轮通过指标读取失败,请稍后重试' }, + { status: 500, headers: PRIVATE_HEADERS }, + ) + } +} diff --git a/app/assets/material-library/layout.tsx b/app/assets/material-library/layout.tsx new file mode 100644 index 0000000..7fa9c52 --- /dev/null +++ b/app/assets/material-library/layout.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: '由壹点3D素材 | 品牌中心部门看板', + description: '品牌中心资产与AI自动化素材库', +} + +export default function MaterialLibraryLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return children +} diff --git a/app/assets/material-library/page.tsx b/app/assets/material-library/page.tsx new file mode 100644 index 0000000..424c608 --- /dev/null +++ b/app/assets/material-library/page.tsx @@ -0,0 +1,1639 @@ +'use client' + +import { useEffect, useState, useRef, useCallback, useDeferredValue, useMemo } from 'react' +import { useSession } from 'next-auth/react' +import { useRouter, usePathname } from 'next/navigation' +import Link from 'next/link' +import UploadModal from '@/components/UploadModal' +import RankingPanel from '@/components/RankingPanel' +import DepartmentShell from '@/components/department-dashboard/DepartmentShell' +import AIChatWidget from '@/components/AIChatWidget' +import VideoPlayerModal from '@/components/VideoPlayerModal' +import ToastBubble from '@/components/ToastBubble' +import { useToast } from '@/hooks/useToast' +import { parseDownloadError } from '@/lib/download-errors' +import { validateAndTriggerDownload } from '@/lib/client-download' +import { parseApiError, handleError, parseFetchError } from '@/lib/error-handler' +import { normalizeVideoMaterialType, isVideoMaterialType as isVideoMaterialTypeValue } from '@/lib/video-material-types' +import { buildPublicMaterialShareUrl } from '@/lib/public-app-url' +import { normalizeMaterialPreviewUrl, pickFirstMaterialPreviewUrl } from '@/lib/material-preview-url' +// 首页拆分组件 +import { SearchBar, FilterPanel, LoadMoreTrigger, MaterialGrid } from '@/components/home' + +interface Material { + id: string + userId: string + previewUrl: string + productModel: string + categoryName: string + sceneName: string + platformName?: string // 平台分类 + productCategoryName?: string // 品类分类 + materialType: string + isAiGenerated?: boolean + taskDetailSourceType?: string | null + renderer: string + authorName: string + zipFileName: string + zipFileSize: string | number + downloadCount: number + likeCount: number + isLiked: boolean + isFavorited: boolean // 收藏状态 + createdAt: string + displayImages?: string[] // 展示图片数组(详情页/模特类型) + videoSourceUrl?: string | null // 原始视频URL + videoPreview540Url?: string | null // 720p预览视频URL(字段名保持540Url以兼容数据库) + videoPreviewClipUrl?: string | null // 视频片段URL + videoCoverUrl?: string | null // 视频封面URL + videoProcessingStatus?: string | null // 视频处理状态 + videoPreviewBlocked?: boolean + videoPreviewUnavailableMessage?: string | null + videoPreviewRemainingDays?: number | null + videoDuration?: number | null // 视频时长(秒) + transcodeLastError?: string | null // 转码错误信息 + transcodeAttempts?: number | null // 转码尝试次数 + transcodeProgress?: number | null // 转码进度百分比 + transcodeStage?: string | null // 转码阶段:preview/clip/cover + transcodeQueueWaitingIndex?: number | null // 队列等待索引 + transcodeQueueWaitingTotal?: number | null // 队列等待总数 + transcodeQueueProcessingIds?: string[] // 正在处理的ID列表 + transcodeQueueMaxConcurrent?: number | null // 最大并发数 + user: { + username: string + avatar?: string | null + } +} + +interface FacetItem { + id: string + name: string + count: number +} + +interface MonthFacetItem { + value: string + label: string + count: number +} + +interface FacetTotals { + materialType: number + category: number + scene: number + platform: number + productCategory: number + month: number +} + +const EMPTY_FACET_TOTALS: FacetTotals = { + materialType: 0, + category: 0, + scene: 0, + platform: 0, + productCategory: 0, + month: 0, +} + +export default function MaterialLibraryPage() { + const { data: session, status } = useSession() + const router = useRouter() + const pathname = usePathname() + const [materials, setMaterials] = useState([]) + const [loading, setLoading] = useState(true) + const [showUploadModal, setShowUploadModal] = useState(false) + const [searchKeyword, setSearchKeyword] = useState('') + const [selectedCategory, setSelectedCategory] = useState('') + const [selectedScene, setSelectedScene] = useState('') + const [selectedMaterialType, setSelectedMaterialType] = useState('') // 素材类型筛选,默认展示全部 + // const [selectedMaterialType, setSelectedMaterialType] = useState('3D场景') // 素材类型筛选,默认展示3D场景(已注释,改为默认展示全部) + const [selectedPlatform, setSelectedPlatform] = useState('') // 平台分类筛选 + const [selectedProductCategory, setSelectedProductCategory] = useState('') // 品类筛选 + const [categories, setCategories] = useState([]) + const [scenes, setScenes] = useState([]) + const [platforms, setPlatforms] = useState([]) + const [productCategories, setProductCategories] = useState([]) + const [isFilterCollapsed, setIsFilterCollapsed] = useState(true) // 筛选区域折叠状态,默认折叠 + // 素材类型列表(已分类排序) + const [materialTypes, setMaterialTypes] = useState<{ value: string; label: string; count?: number }[]>([]) + const [materialTypeCounts, setMaterialTypeCounts] = useState>({}) + const [isTransitionLoading, setIsTransitionLoading] = useState(false) + const [downloadingId, setDownloadingId] = useState(null) + const [downloadProgress, setDownloadProgress] = useState(0) + const [selectedMonth, setSelectedMonth] = useState('') + const [months, setMonths] = useState([]) + const [sortBy, setSortBy] = useState('createdAt') // createdAt | downloadCount + const [sortOrder, setSortOrder] = useState<'desc' | 'asc'>('desc') // 排序方向:desc 倒序(默认)| asc 正序 + const [previewImage, setPreviewImage] = useState<{ url: string; title: string } | null>(null) + const [showVideoModal, setShowVideoModal] = useState(false) + const [videoSrc, setVideoSrc] = useState(null) + const transcodePollTimers = useRef>(new Map()) + const transcodeStartTimes = useRef>(new Map()) + const transcodePollingFlags = useRef>(new Map()) // 标记是否正在轮询 + const fetchMaterialsRef = useRef(null) // 存储fetchMaterials函数引用 + const materialsRequestIdRef = useRef(0) + const materialsAbortRef = useRef(null) + const facetsRequestIdRef = useRef(0) + const facetsAbortRef = useRef(null) + + // ✅ 清理转码Map的函数(直接操作Map,避免循环依赖) + const cleanupTranscodeMaps = useCallback(() => { + const now = Date.now() + const MAX_AGE = 30 * 60 * 1000 // 30分钟 + let cleanedCount = 0 + + // 清理超过30分钟的条目 + transcodeStartTimes.current.forEach((startTime, materialId) => { + if (now - startTime > MAX_AGE) { + const timer = transcodePollTimers.current.get(materialId) + if (timer) { + clearTimeout(timer) + transcodePollTimers.current.delete(materialId) + } + transcodePollingFlags.current.delete(materialId) + transcodeStartTimes.current.delete(materialId) + cleanedCount++ + } + }) + + // 清理不在当前素材列表中的条目 + const currentMaterialIds = new Set(materials.map(m => m.id)) + transcodePollTimers.current.forEach((timer, materialId) => { + if (!currentMaterialIds.has(materialId)) { + clearTimeout(timer) + transcodePollTimers.current.delete(materialId) + transcodePollingFlags.current.delete(materialId) + transcodeStartTimes.current.delete(materialId) + cleanedCount++ + } + }) + + if (cleanedCount > 0) { + console.log(`[TranscodePoll] Cleaned up ${cleanedCount} old entries. Current size: ${transcodePollTimers.current.size}`) + } + }, [materials]) + + // ✅ 定期清理转码Map(每10分钟) + useEffect(() => { + const interval = setInterval(() => { + if (transcodePollTimers.current.size > 0) { + console.log(`[TranscodePoll] Periodic cleanup check. Current size: ${transcodePollTimers.current.size}`) + cleanupTranscodeMaps() + } + }, 10 * 60 * 1000) // 10分钟 + + return () => clearInterval(interval) + }, [cleanupTranscodeMaps]) + + // 音频播放状态 + const audioRef = useRef(null) + const [playingAudioId, setPlayingAudioId] = useState(null) + const [audioProgress, setAudioProgress] = useState(0) // 0-100 + const [audioDuration, setAudioDuration] = useState(0) // 音频总时长(秒) + const [audioCurrentTime, setAudioCurrentTime] = useState(0) // 当前播放时间(秒) + const [audioElement, setAudioElement] = useState(null) // 用于AudioVisualizer + const [currentMaterialsTotalCount, setCurrentMaterialsTotalCount] = useState(0) + const [facetTotals, setFacetTotals] = useState(EMPTY_FACET_TOTALS) + // 分页+懒加载相关状态 + const [displayedCount, setDisplayedCount] = useState(24) // 当前显示的素材数量 + const [currentPage, setCurrentPage] = useState(1) // 当前加载到第几页 + const [hasMore, setHasMore] = useState(true) // 是否还有更多数据 + const [isLoadingMore, setIsLoadingMore] = useState(false) // 是否正在加载更多 + const pageSize = 20 + const [shareUrl, setShareUrl] = useState(null) + const [showShareModal, setShowShareModal] = useState(false) + const [showShareNotification, setShowShareNotification] = useState(false) + const [themeColor, setThemeColor] = useState('#1f2937') // 主题颜色 + const [favoriteNotification, setFavoriteNotification] = useState(null) // 收藏操作提示 + const { toast, showToast } = useToast() // 统一的Toast提示 + // 实时搜索:使用 useDeferredValue 实现非阻塞搜索 + const deferredSearchKeyword = useDeferredValue(searchKeyword) + const isSearchPending = searchKeyword !== deferredSearchKeyword + + const materialTypesWithCounts = useMemo( + () => + materialTypes.map((type) => ({ + ...type, + count: materialTypeCounts[type.value] || 0, + })), + [materialTypes, materialTypeCounts], + ) + + const buildFilterParams = useCallback( + (options: { includeSort?: boolean; includePaging?: boolean; page?: number } = {}) => { + const { includeSort = true, includePaging = false, page = 1 } = options + const params = new URLSearchParams() + const searchValue = deferredSearchKeyword.trim() + + if (searchValue) params.append('search', searchValue) + if (selectedCategory) params.append('category', selectedCategory) + if (selectedScene) params.append('scene', selectedScene) + if (selectedMaterialType) params.append('materialType', selectedMaterialType) + if (selectedPlatform) params.append('platform', selectedPlatform) + if (selectedProductCategory) params.append('productCategory', selectedProductCategory) + if (selectedMonth) params.append('month', selectedMonth) + if (includeSort) { + if (sortBy) params.append('sortBy', sortBy) + if (sortOrder) params.append('sortOrder', sortOrder) + } + if (includePaging) { + params.append('page', page.toString()) + params.append('pageSize', pageSize.toString()) + } + + return params + }, + [ + deferredSearchKeyword, + selectedCategory, + selectedScene, + selectedMaterialType, + selectedPlatform, + selectedProductCategory, + selectedMonth, + sortBy, + sortOrder, + pageSize, + ], + ) + + // 实时搜索处理函数 - 输入立即更新,搜索由 useDeferredValue 控制 + const handleSearchChange = useCallback((value: string) => { + setSearchKeyword(value) + }, []) + + const handleCategoryChange = useCallback((value: string) => { + if (value !== selectedCategory) { + setSelectedScene('') + } + setSelectedCategory(value) + }, [selectedCategory]) + + // 移除页面卸载时保存状态的逻辑(避免滚动问题) + + useEffect(() => { + if (status === 'unauthenticated') { + router.push('/login') + } + }, [status, router]) + + // 从URL参数恢复筛选状态(在组件初始化时执行一次) + useEffect(() => { + if (typeof window === 'undefined') return + + const params = new URLSearchParams(window.location.search) + const materialType = params.get('materialType') + const category = params.get('category') + const scene = params.get('scene') + const platform = params.get('platform') + const productCategory = params.get('productCategory') + const month = params.get('month') + const search = params.get('search') + const sort = params.get('sortBy') + const sortOrderParam = params.get('sortOrder') + + if (materialType) setSelectedMaterialType(normalizeVideoMaterialType(materialType)) + if (category) setSelectedCategory(category) + if (scene) setSelectedScene(scene) + if (platform) setSelectedPlatform(platform) + if (productCategory) setSelectedProductCategory(productCategory) + if (month) setSelectedMonth(month) + if (search) setSearchKeyword(search) + if (sort) setSortBy(sort) + if (sortOrderParam === 'asc' || sortOrderParam === 'desc') setSortOrder(sortOrderParam) + }, []) + + // 同步筛选状态到URL参数 + useEffect(() => { + if (typeof window === 'undefined') return + + const params = new URLSearchParams() + const searchValue = deferredSearchKeyword.trim() + if (searchValue) params.set('search', searchValue) + if (selectedMaterialType) params.set('materialType', selectedMaterialType) // 只有选择了素材类型才添加参数 + if (selectedCategory) params.set('category', selectedCategory) + if (selectedScene) params.set('scene', selectedScene) + if (selectedPlatform) params.set('platform', selectedPlatform) + if (selectedProductCategory) params.set('productCategory', selectedProductCategory) + if (selectedMonth) params.set('month', selectedMonth) + if (sortBy && sortBy !== 'createdAt') params.set('sortBy', sortBy) + if (sortOrder && sortOrder !== 'desc') params.set('sortOrder', sortOrder) + + const newUrl = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname + window.history.replaceState({}, '', newUrl) + }, [deferredSearchKeyword, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder]) + + useEffect(() => { + fetchThemeColor() + }, []) + + // 加载当前用户可见的素材类型 + useEffect(() => { + const loadViewableTypes = async () => { + if (status !== 'authenticated') return + try { + const mapMaterialType = (t: string) => { + // 映射老类型到新类型 + if (t === '3DMAX') return '3D场景' + if (t === '产品全案') return '任务-详情' + if (t === '详情页') return '详情模板' + return normalizeVideoMaterialType(t) + } + const res = await fetch('/api/materials/viewable-types') + if (res.ok) { + const data = await res.json() + const types = (data.materialTypes || []) as string[] + const normalizeTypes = (arr: string[]) => + Array.from( + new Set( + arr + .map(mapMaterialType) + .filter((t) => t && t !== '全部') + ) + ) + const source = normalizeTypes(types) + const finalList = source.map((t) => ({ + value: t, + label: t, + })) + const hasSelected = finalList.some((t) => t.value === selectedMaterialType) + // const preferredDefault = '3D场景' // 已注释,改为默认展示全部 + // const hasPreferred = finalList.some((t) => t.value === preferredDefault) + if (!hasSelected && selectedMaterialType) { // 如果当前选中的类型不在列表中,且有选中值,则重置为空(全部) + setSelectedMaterialType('') + } + const mapped = finalList + setMaterialTypes(mapped) + } else { + setMaterialTypes([]) + if (selectedMaterialType) { + setSelectedMaterialType('') + } + } + } catch (error) { + console.error('获取可见素材类型失败:', error) + setMaterialTypes([]) + if (selectedMaterialType) { + setSelectedMaterialType('') + } + } + } + loadViewableTypes() + }, [status]) + + // 恢复滚动位置和页面状态(从详情页返回时) + // 使用 pathname 作为依赖,当路由变为首页时触发恢复 + const hasRestoredRef = useRef(false) + useEffect(() => { + if (typeof window === 'undefined') return + // 只在首页路径时执行 + if (pathname !== '/assets/material-library') return + // 避免重复恢复 + if (hasRestoredRef.current) return + + try { + const savedScrollPosition = sessionStorage.getItem('home_scroll_position') + const savedCache = sessionStorage.getItem('home_materials_cache') + + if (savedCache) { + const cache = JSON.parse(savedCache) + const cacheAge = Date.now() - (cache.timestamp || 0) + const CACHE_EXPIRY = 5 * 60 * 1000 // 5分钟 + const cachedMaterials = Array.isArray(cache.materials) ? cache.materials : [] + + // 如果缓存未过期,恢复状态 + if (cacheAge < CACHE_EXPIRY && cachedMaterials.length > 0) { + // 恢复筛选条件 + if (cache.selectedCategory) setSelectedCategory(cache.selectedCategory) + if (cache.selectedScene) setSelectedScene(cache.selectedScene) + if (cache.selectedMaterialType) setSelectedMaterialType(normalizeVideoMaterialType(cache.selectedMaterialType)) + if (cache.selectedPlatform) setSelectedPlatform(cache.selectedPlatform) + if (cache.selectedProductCategory) setSelectedProductCategory(cache.selectedProductCategory) + if (cache.selectedMonth) setSelectedMonth(cache.selectedMonth) + if (cache.sortBy) setSortBy(cache.sortBy) + if (cache.sortOrder) setSortOrder(cache.sortOrder) + if (cache.searchKeyword) setSearchKeyword(cache.searchKeyword) + + // 恢复素材数据和显示数量 + if (cachedMaterials.length > 0) { + setMaterials(cachedMaterials) + setDisplayedCount(cache.displayedCount || cachedMaterials.length) + setCurrentPage(cache.currentPage || 1) + } + + // 立即清除loading状态(使用缓存时不需要加载) + setLoading(false) + + // 恢复滚动位置(延迟执行,确保内容已渲染) + if (savedScrollPosition) { + // 等待内容渲染完成后再恢复滚动位置 + const restoreScroll = () => { + const scrollY = parseInt(savedScrollPosition, 10) + if (scrollY > 0) { + // 多次尝试恢复,确保内容已完全渲染 + let attempts = 0 + const tryScroll = () => { + attempts++ + window.scrollTo({ + top: scrollY, + behavior: 'auto' + }) + // 如果滚动位置不对,且尝试次数少于5次,继续尝试 + if (Math.abs(window.scrollY - scrollY) > 10 && attempts < 5) { + setTimeout(tryScroll, 100) + } else { + // 清除已恢复的缓存 + sessionStorage.removeItem('home_scroll_position') + sessionStorage.removeItem('home_materials_cache') + hasRestoredRef.current = true + } + } + // 使用多个 requestAnimationFrame 确保DOM已渲染 + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setTimeout(tryScroll, 50) + }) + }) + } else { + // 没有滚动位置,直接清除缓存 + sessionStorage.removeItem('home_scroll_position') + sessionStorage.removeItem('home_materials_cache') + hasRestoredRef.current = true + } + } + restoreScroll() + } else { + // 没有滚动位置,直接清除缓存 + sessionStorage.removeItem('home_scroll_position') + sessionStorage.removeItem('home_materials_cache') + hasRestoredRef.current = true + } + + return // 使用缓存,不重新获取数据 + } else { + // 缓存过期,清除 + sessionStorage.removeItem('home_scroll_position') + sessionStorage.removeItem('home_materials_cache') + // 缓存过期时,不设置 hasRestoredRef,让正常的数据获取逻辑执行 + } + } else if (savedScrollPosition) { + // 只有滚动位置,没有缓存数据,恢复滚动位置(但不阻止数据获取) + const scrollY = parseInt(savedScrollPosition, 10) + if (scrollY > 0) { + let attempts = 0 + const tryScroll = () => { + attempts++ + window.scrollTo({ + top: scrollY, + behavior: 'auto' + }) + // 如果滚动位置不对,且尝试次数少于5次,继续尝试 + if (Math.abs(window.scrollY - scrollY) > 10 && attempts < 5) { + setTimeout(tryScroll, 100) + } else { + sessionStorage.removeItem('home_scroll_position') + // 不设置 hasRestoredRef,让数据正常获取 + } + } + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setTimeout(tryScroll, 50) + }) + }) + } else { + sessionStorage.removeItem('home_scroll_position') + // 不设置 hasRestoredRef,让数据正常获取 + } + } + // 如果没有缓存也没有滚动位置,不设置 hasRestoredRef,让正常的数据获取逻辑执行 + } catch (e) { + console.warn('[Home] Failed to restore state from sessionStorage:', e) + sessionStorage.removeItem('home_scroll_position') + sessionStorage.removeItem('home_materials_cache') + // 出错时,不设置 hasRestoredRef,让正常的数据获取逻辑执行 + } + }, [pathname]) // 当路由变化到首页时执行 + + // 当从其他页面返回首页时,重置恢复标记 + useEffect(() => { + if (pathname === '/assets/material-library') { + // 延迟重置,确保恢复逻辑已执行 + const timer = setTimeout(() => { + hasRestoredRef.current = false + }, 1000) + return () => clearTimeout(timer) + } + }, [pathname]) + + // 当筛选条件变化时,重新获取素材列表(延迟执行,避免初始化时重复调用) + useEffect(() => { + // 只有在已登录状态下才获取素材 + if (status !== 'authenticated') return + + // 如果已从缓存恢复,且恢复标记还在,不重新获取 + // 注意:这里不删除缓存,因为恢复逻辑会自己处理 + if (hasRestoredRef.current) { + // 恢复完成后,清除恢复标记,允许后续筛选变化时正常获取 + const timer = setTimeout(() => { + hasRestoredRef.current = false + }, 2000) // 给恢复逻辑足够的时间 + return () => clearTimeout(timer) + } + + // 延迟执行,确保状态已恢复 + const timer = setTimeout(() => { + fetchMaterials(1, false) + }, 100) + return () => clearTimeout(timer) + }, [status, deferredSearchKeyword, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder]) + + // 获取主题颜色 + const fetchThemeColor = async () => { + try { + const response = await fetch('/api/system-settings') + if (response.ok) { + const data = await response.json() + setThemeColor(data.headerBgColor || '#1f2937') + } + } catch (error) { + console.error('获取主题颜色失败:', error) + } + } + + // 移除筛选条件保存逻辑(避免滚动问题) + + // 获取素材列表(支持分页和追加模式) + const fetchMaterials = useCallback( + async (page = 1, append = false) => { + const requestId = ++materialsRequestIdRef.current + materialsAbortRef.current?.abort() + const controller = new AbortController() + materialsAbortRef.current = controller + + try { + // 修复:只在首次加载(无搜索词且无结果)时显示全屏加载 + // 搜索时只使用 isTransitionLoading,避免整个页面被替换导致 SearchBar 丢失焦点 + const isInitialLoad = !append && page === 1 && materials.length === 0 && !deferredSearchKeyword + if (isInitialLoad) { + setLoading(true) + } else if (!append) { + setIsTransitionLoading(true) + } + if (!append) setHasMore(true) + + const params = buildFilterParams({ includePaging: true, page }) + + const url = `/api/materials${params.toString() ? '?' + params.toString() : ''}` + const response = await fetch(url, { signal: controller.signal }) + if (!response.ok) { + throw new Error(`素材加载失败 (${response.status})`) + } + if (requestId === materialsRequestIdRef.current) { + const data = await response.json() + // 清理素材 ID,并为预览图补齐统一 fallback + const materialsList = ((data.materials || data) as Material[]) + .flatMap((e): Material[] => { + if (!e.id || typeof e.id !== 'string') return [] + const trimmedId = e.id.trim() + if (!trimmedId) return [] + + const displayImages = (Array.isArray(e.displayImages) ? e.displayImages : []) + .map(normalizeMaterialPreviewUrl) + .filter((url): url is string => Boolean(url)) + const videoSourceUrl = normalizeMaterialPreviewUrl(e.videoSourceUrl) + const videoPreview540Url = normalizeMaterialPreviewUrl(e.videoPreview540Url) + const videoPreviewClipUrl = normalizeMaterialPreviewUrl(e.videoPreviewClipUrl) + const videoCoverUrl = normalizeMaterialPreviewUrl(e.videoCoverUrl) + // 只允许非空字符串进入预览 URL,避免历史异常布尔值挡住后续合法地址。 + const previewUrl = pickFirstMaterialPreviewUrl( + e.previewUrl, + videoCoverUrl, + ...displayImages, + ) || '' + + return [{ + ...e, + id: trimmedId, + previewUrl, + displayImages, + videoSourceUrl, + videoPreview540Url, + videoPreviewClipUrl, + videoCoverUrl, + }] + }) + + let appendedItems: Material[] = materialsList + if (append) { + setMaterials((prev) => { + const existingIds = new Set(prev.map((item) => item.id)) + const deduped = materialsList.filter((item) => !existingIds.has(item.id)) + appendedItems = deduped + if (deduped.length === 0) return prev + return [...prev, ...deduped] + }) + } else { + setMaterials(materialsList) + setDisplayedCount(Math.min(24, materialsList.length)) + } + + const appendedCount = append ? appendedItems.length : materialsList.length + + setCurrentPage(page) + const totalPages = data.totalPages || 1 + setHasMore(appendedCount > 0 && page < totalPages) + setCurrentMaterialsTotalCount(data.total ?? data.totalCount ?? materialsList.length) + + // 对需要转码的视频素材启动轮询 + appendedItems.forEach((material) => { + if (isVideoMaterial(material.materialType) && material.videoSourceUrl) { + startTranscodePoll(material, 0) + } + }) + + return appendedCount + } + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') return 0 + console.error('Error fetching materials:', error) + if (requestId === materialsRequestIdRef.current) { + showToast(error instanceof Error ? error.message : '素材加载失败', { variant: 'error' }) + } + } finally { + if (!append && requestId === materialsRequestIdRef.current) { + setLoading(false) + setIsTransitionLoading(false) + } + if (materialsAbortRef.current === controller) materialsAbortRef.current = null + } + return 0 + }, + [materials.length, buildFilterParams, showToast] + ) + + // 更新fetchMaterialsRef + useEffect(() => { + fetchMaterialsRef.current = fetchMaterials + }, [fetchMaterials]) + + // 判断是否为视频类型素材 + const isVideoMaterial = useCallback((materialType: string): boolean => { + return isVideoMaterialTypeValue(materialType) + }, []) + + // 停止转码轮询 + const stopTranscodePoll = useCallback((materialId: string) => { + const timer = transcodePollTimers.current.get(materialId) + if (timer) { + clearTimeout(timer) + transcodePollTimers.current.delete(materialId) + } + transcodePollingFlags.current.delete(materialId) + transcodeStartTimes.current.delete(materialId) + }, []) + + // 启动转码轮询 + const startTranscodePoll = useCallback((material: Material, delay: number = 5000) => { + if (!isVideoMaterial(material.materialType)) return + if (!material.id || typeof material.id !== 'string') { + console.error('[TranscodePoll] Invalid material ID (empty or not string):', material.id) + return + } + + const materialId = material.id.trim() + if (!materialId || materialId.length < 10 || materialId.length > 100) { + console.error('[TranscodePoll] Invalid material ID format:', material.id, 'Length:', materialId.length) + return + } + + // 如果已完成,停止轮询 + if (material.videoProcessingStatus === 'done') { + stopTranscodePoll(materialId) + return + } + + // 如果已经在轮询,不重复启动 + if (transcodePollingFlags.current.get(materialId)) { + return + } + + const poll = async () => { + try { + const response = await fetch(`/api/materials/transcode?materialId=${encodeURIComponent(materialId)}`) + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + console.error(`[TranscodePoll] GET request failed for material ${materialId}:`, response.status, errorData) + if (response.status === 400) { + stopTranscodePoll(materialId) + return + } + throw new Error(`status ${response.status}: ${errorData.error || 'Unknown error'}`) + } + + const data = await response.json() + const isProcessing = data.queue?.processingIds?.includes(materialId) ?? false + + // 记录开始处理时间 + if (isProcessing && !transcodeStartTimes.current.get(materialId)) { + transcodeStartTimes.current.set(materialId, Date.now()) + } + if (!isProcessing && transcodeStartTimes.current.get(materialId)) { + transcodeStartTimes.current.delete(materialId) + } + + // 更新素材状态 + setMaterials((prev) => + prev.map((m) => + (m.id?.trim() || m.id) === materialId || m.id === material.id + ? { + ...m, + videoProcessingStatus: data.status, + // 状态接口只有 has* 布尔标志,真实 URL 由完成后的列表刷新取得。 + videoPreview540Url: normalizeMaterialPreviewUrl(m.videoPreview540Url), + videoPreviewClipUrl: normalizeMaterialPreviewUrl(m.videoPreviewClipUrl), + videoCoverUrl: normalizeMaterialPreviewUrl(m.videoCoverUrl), + videoDuration: data.duration ?? m.videoDuration, + transcodeAttempts: data.attempts ?? m.transcodeAttempts, + transcodeProgress: data.progress ?? m.transcodeProgress, + transcodeStage: data.stage ?? m.transcodeStage, + transcodeQueueWaitingIndex: data.queue?.waitingIndex ?? null, + transcodeQueueWaitingTotal: data.queue?.waitingTotal ?? null, + transcodeQueueProcessingIds: data.queue?.processingIds ?? [], + transcodeQueueMaxConcurrent: data.queue?.maxConcurrent ?? m.transcodeQueueMaxConcurrent, + transcodeLastError: data.lastError ?? m.transcodeLastError, + } + : m + ) + ) + + // 如果已完成但缺少预览URL,刷新列表 + if (data.status === 'done' && !material.videoPreviewClipUrl && !material.videoPreview540Url) { + console.log('[TranscodePoll] Transcode done but missing preview URLs, refreshing material list...') + fetchMaterialsRef.current?.(1, false) + } + + // 检查队列是否为空且状态为processing(可能是异常情况,尝试重试) + const queueEmpty = (data.queue?.processingIds?.length ?? 0) === 0 && (data.queue?.waitingTotal ?? 0) === 0 + if (data.status === 'processing' && queueEmpty) { + const retryCount = transcodeStartTimes.current.get(materialId) ? 1 : 0 + if (retryCount < 2) { + // 静默重试 + try { + await fetch('/api/materials/transcode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ materialId, forceRetry: true }), + }) + } catch (e) { + // 静默失败 + } + } + } + + // 根据状态决定是否继续轮询 + if (data.status === 'done') { + stopTranscodePoll(materialId) + setTimeout(() => { + fetchMaterialsRef.current?.(1, false) + }, 500) + } else if (data.status === 'failed') { + stopTranscodePoll(materialId) + } else { + // 继续轮询,5秒后再次检查 + const timer = setTimeout(poll, 5000) + transcodePollTimers.current.set(materialId, timer) + } + } catch (error) { + console.error(`[TranscodePoll] Poll error for material ${materialId}:`, error) + // 出错后8秒重试 + const timer = setTimeout(poll, 8000) + transcodePollTimers.current.set(materialId, timer) + } + } + + // 标记为正在轮询 + transcodePollingFlags.current.set(materialId, true) + + // 延迟启动轮询 + const timer = setTimeout(poll, delay) + transcodePollTimers.current.set(materialId, timer) + }, [stopTranscodePoll]) + + // 组件卸载时清理所有转码轮询 + useEffect(() => { + return () => { + transcodePollTimers.current.forEach((timer) => { + clearTimeout(timer) + }) + transcodePollTimers.current.clear() + transcodePollingFlags.current.clear() + transcodeStartTimes.current.clear() + } + }, []) + + // 音频播放控制 + const handleAudioPlay = useCallback((materialId: string, audioUrl: string) => { + console.log('[Home] handleAudioPlay called:', { materialId, audioUrl }) + + if (!audioUrl) { + console.error('[Home] No audio URL provided for material:', materialId) + showToast('音频文件不存在', { variant: 'error' }) + return + } + + // 防止重复调用:如果正在加载或播放同一个音频,直接返回 + if (playingAudioId === materialId && audioRef.current) { + // 如果点击的是当前正在播放的音频,则暂停 + if (audioRef.current && !audioRef.current.paused) { + audioRef.current.pause() + audioRef.current = null + setAudioElement(null) + setPlayingAudioId(null) + setAudioProgress(0) + setAudioDuration(0) + setAudioCurrentTime(0) + return + } + } + + // 如果正在加载另一个音频,先停止并清理 + if (audioRef.current && playingAudioId !== materialId) { + // 清理所有事件监听器 + const oldAudio = audioRef.current + oldAudio.pause() + oldAudio.src = '' + oldAudio.load() + audioRef.current = null + } + + // 清理旧的 audio 元素引用,确保 AudioVisualizer 能正确切换到新的音频 + setAudioElement(null) + + // 重置状态,避免进度条闪烁 + setAudioProgress(0) + setAudioDuration(0) + setAudioCurrentTime(0) + + // 创建新的音频元素 + const audio = new Audio(audioUrl) + audio.crossOrigin = 'anonymous' // 允许跨域访问(用于AudioVisualizer) + audio.preload = 'auto' // 预加载音频 + audioRef.current = audio + + // 监听加载错误 + const handleError = (e: Event) => { + const error = audio.error + console.error('[Home] Audio error:', { + code: error?.code, + message: error?.message, + audioUrl, + materialId + }) + showToast(`音频加载失败: ${error?.message || '未知错误'}`, { variant: 'error' }) + setPlayingAudioId(null) + setAudioProgress(0) + setAudioDuration(0) + setAudioCurrentTime(0) + if (audioRef.current === audio) { + audioRef.current = null + setAudioElement(null) + } + audio.removeEventListener('error', handleError) + } + audio.addEventListener('error', handleError) + + // 监听元数据加载完成(获取时长) + const handleLoadedMetadata = () => { + console.log('[Home] Audio metadata loaded, duration:', audio.duration) + if (audioRef.current === audio) { + setAudioDuration(audio.duration || 0) + } + } + audio.addEventListener('loadedmetadata', handleLoadedMetadata) + + // 监听可以播放(使用canplay事件,有足够数据就开始播放,减少延迟) + const handleCanPlay = () => { + // 检查是否仍然是当前音频(防止切换音频时的竞态条件) + if (audioRef.current !== audio) { + audio.removeEventListener('canplay', handleCanPlay) + return + } + + console.log('[Home] Audio can play:', audioUrl) + audio.play().then(() => { + // 再次检查是否仍然是当前音频 + if (audioRef.current !== audio) { + audio.pause() + return + } + console.log('[Home] Audio playback started:', audioUrl) + setAudioElement(audio) // 设置audio元素供AudioVisualizer使用 + setPlayingAudioId(materialId) + }).catch((error) => { + console.error('[Home] Audio play failed:', error, 'URL:', audioUrl) + showToast(`播放失败: ${error.message || '未知错误'}`, { variant: 'error' }) + if (audioRef.current === audio) { + setPlayingAudioId(null) + audioRef.current = null + setAudioElement(null) + } + }) + audio.removeEventListener('canplay', handleCanPlay) + } + audio.addEventListener('canplay', handleCanPlay) + + // 如果音频已经可以播放,立即播放 + if (audio.readyState >= 2) { + handleCanPlay() + } + + // 监听播放进度 + const handleTimeUpdate = () => { + // 检查是否仍然是当前音频 + if (audioRef.current !== audio) { + // 如果不是当前音频,移除监听器 + audio.removeEventListener('timeupdate', handleTimeUpdate) + return + } + // 只有当音频有时长且正在播放时才更新进度 + if (audio.duration && !audio.paused) { + setAudioProgress((audio.currentTime / audio.duration) * 100) + setAudioCurrentTime(audio.currentTime) + } + } + audio.addEventListener('timeupdate', handleTimeUpdate) + + // 监听播放结束 + const handleEnded = () => { + if (audioRef.current === audio) { + setPlayingAudioId(null) + setAudioProgress(0) + setAudioDuration(0) + setAudioCurrentTime(0) + audioRef.current = null + setAudioElement(null) + } + audio.removeEventListener('ended', handleEnded) + } + audio.addEventListener('ended', handleEnded) + + // 开始加载音频 + audio.load() + }, [playingAudioId, showToast]) + + // 组件卸载时清理音频 + useEffect(() => { + return () => { + if (audioRef.current) { + audioRef.current.pause() + audioRef.current = null + } + } + }, []) + + // 分页+懒加载函数(完全参考flnxi_sucai实现) + const handleLoadMore = useCallback(async () => { + if (isLoadingMore) return + + // 如果当前显示的数量小于已加载的素材数量,直接增加显示数量 + if (displayedCount < materials.length) { + setIsLoadingMore(true) + setDisplayedCount((prev) => Math.min(prev + 24, materials.length)) + setIsLoadingMore(false) + return + } + + // 如果没有更多数据,不再加载 + if (!hasMore) return + + // 加载下一页数据 + setIsLoadingMore(true) + const appended = await fetchMaterials(currentPage + 1, true) + if (appended > 0) { + setDisplayedCount((prev) => Math.min(prev + 24, materials.length + appended)) + } + setIsLoadingMore(false) + }, [isLoadingMore, displayedCount, materials.length, hasMore, currentPage, fetchMaterials]) + + // IntersectionObserver 逻辑已移至 LoadMoreTrigger 组件 + + const fetchFacets = useCallback(async () => { + const requestId = ++facetsRequestIdRef.current + facetsAbortRef.current?.abort() + const controller = new AbortController() + facetsAbortRef.current = controller + + try { + const params = buildFilterParams({ includeSort: false, includePaging: false }) + const url = `/api/materials/facets${params.toString() ? '?' + params.toString() : ''}` + const response = await fetch(url, { signal: controller.signal }) + if (!response.ok) throw new Error(`筛选数据加载失败 (${response.status})`) + if (requestId === facetsRequestIdRef.current) { + const data = await response.json() + setCategories(data.categories || []) + setScenes(data.scenes || []) + setPlatforms(data.platforms || []) + setProductCategories(data.productCategories || []) + setMonths(data.months || []) + setFacetTotals({ ...EMPTY_FACET_TOTALS, ...(data.totals || {}) }) + setMaterialTypeCounts( + Object.fromEntries( + ((data.materialTypes || []) as FacetItem[]).map((item) => [item.name, item.count]), + ), + ) + } + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') return + console.error('Error fetching material facets:', error) + if (requestId === facetsRequestIdRef.current) { + showToast(error instanceof Error ? error.message : '筛选数据加载失败', { variant: 'error' }) + } + } finally { + if (facetsAbortRef.current === controller) facetsAbortRef.current = null + } + }, [buildFilterParams, showToast]) + + useEffect(() => () => { + materialsAbortRef.current?.abort() + facetsAbortRef.current?.abort() + }, []) + + useEffect(() => { + if (status !== 'authenticated') return + fetchFacets() + }, [status, fetchFacets]) + + // 根据素材类型获取角标背景颜色 + const getMaterialTypeBadgeColor = (materialType: string): string => { + // 判断是否为视频类型 + const isVideoType = isVideoMaterial(materialType) + + if (materialType === '3D场景' || materialType === '3DMAX') { + return 'bg-red-500' + } else if (isVideoType) { + return 'bg-blue-600' + } else if (materialType === '音乐') { + return 'bg-purple-600' + } else if (materialType === 'C4D模型') { + return 'bg-blue-500' + } else if (materialType === '详情页' || materialType === '详情模板' || materialType === '矢量文件') { + return 'bg-amber-500' + } else { + return 'bg-gray-700' + } + } + + // 格式化文件大小 + const formatFileSize = (bytes: string | number): string => { + const size = typeof bytes === 'string' ? parseInt(bytes) : bytes + if (size === 0) return '未知' + const units = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(size) / Math.log(1024)) + return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}` + } + + // 分享功能 + const handleShare = async (materialId: string) => { + const url = buildPublicMaterialShareUrl(materialId) + setShareUrl(url) + try { + await navigator.clipboard.writeText(url) + setShowShareNotification(true) + setTimeout(() => setShowShareNotification(false), 2000) + } catch (error) { + setShowShareModal(true) + } + } + + const copyShareUrl = async () => { + if (!shareUrl) return + + try { + await navigator.clipboard.writeText(shareUrl) + setShowShareNotification(true) + setTimeout(() => setShowShareNotification(false), 2000) + setShowShareModal(false) + } catch (error) { + console.error('复制失败,尝试降级方案:', error) + // 降级方案:使用传统方法复制 + const textarea = document.createElement('textarea') + textarea.value = shareUrl + textarea.style.position = 'fixed' + textarea.style.left = '-999999px' + textarea.style.top = '-999999px' + document.body.appendChild(textarea) + textarea.focus() + textarea.select() + try { + const successful = document.execCommand('copy') + if (successful) { + setShowShareNotification(true) + setTimeout(() => setShowShareNotification(false), 2000) + setShowShareModal(false) + } else { + showToast('复制失败,请手动复制', { variant: 'error' }) + } + } catch (err) { + console.error('降级复制也失败:', err) + showToast('复制失败,请手动复制', { variant: 'error' }) + } + document.body.removeChild(textarea) + } + } + + const handleDownloadByDirectLink = (id: string) => { + const runDownload = async () => { + if (downloadingId === id) { + return + } + + setDownloadingId(id) + setDownloadProgress(10) + + try { + const downloadUrl = `/api/materials/download?id=${encodeURIComponent(id)}` + await validateAndTriggerDownload(downloadUrl) + setDownloadProgress(100) + setMaterials(prev => + prev.map(item => + item.id === id ? { ...item, downloadCount: (item.downloadCount || 0) + 1 } : item + ) + ) + } catch (error) { + const { message } = parseDownloadError(error) + showToast(message, { variant: 'error' }) + } finally { + setTimeout(() => { + setDownloadingId(null) + setDownloadProgress(0) + }, 300) + } + } + + void runDownload() + } + + const handleLike = async (id: string) => { + try { + const response = await fetch('/api/materials/like', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ materialId: id }), + }) + + if (response.ok) { + // 刷新列表以更新点赞状态 + fetchMaterials() + } else { + const error = await parseFetchError(response) + showToast(error.message, { variant: 'error' }) + } + } catch (error) { + console.error('Like error:', error) + handleError(error, showToast) + } + } + + // 收藏/取消收藏 + const handleFavorite = async (id: string) => { + if (!session?.user) { + setFavoriteNotification('请先登录') + setTimeout(() => setFavoriteNotification(null), 2000) + return + } + try { + const response = await fetch('/api/materials/favorite', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ materialId: id }), + }) + + if (!response.ok) { + const error = await parseFetchError(response) + setFavoriteNotification(error.message) + setTimeout(() => setFavoriteNotification(null), 2000) + return + } + + const result = await response.json() + // 更新本地状态 + setMaterials(prev => prev.map(m => + m.id === id ? { ...m, isFavorited: result.isFavorited } : m + )) + setFavoriteNotification(result.isFavorited ? '收藏成功' : '取消收藏成功') + setTimeout(() => setFavoriteNotification(null), 2000) + } catch (error) { + console.error('收藏操作失败:', error) + const { message } = parseApiError(error) + setFavoriteNotification(message) + setTimeout(() => setFavoriteNotification(null), 2000) + } + } + + // 排序切换(点击同一字段切换正/倒序,点击不同字段重置为倒序) + const handleSortChange = (field: string) => { + if (sortBy === field) { + setSortOrder(prev => prev === 'desc' ? 'asc' : 'desc') + } else { + setSortBy(field) + setSortOrder('desc') + } + } + + // 编辑素材 + const handleEdit = (material: Material) => { + router.push(`/edit/${material.id}`) + } + + // 素材点击处理(保存状态并跳转) + const handleMaterialClick = (material: Material) => { + // 保存滚动位置和页面状态(异步,不阻塞跳转) + try { + sessionStorage.setItem('home_scroll_position', window.scrollY.toString()) + sessionStorage.setItem('home_materials_cache', JSON.stringify({ + materials: materials, + displayedCount: displayedCount, + currentPage: currentPage, + selectedCategory: selectedCategory, + selectedScene: selectedScene, + selectedMaterialType: selectedMaterialType, + selectedPlatform: selectedPlatform, + selectedProductCategory: selectedProductCategory, + selectedMonth: selectedMonth, + sortBy: sortBy, + sortOrder: sortOrder, + searchKeyword: searchKeyword, + timestamp: Date.now(), + })) + } catch (e) { + console.warn('[Home] Failed to save state to sessionStorage:', e) + } + + // 立即跳转,不等待任何操作(使用window.location.href确保立即跳转) + window.location.href = `/share/${material.id}` + } + + // 重试转码 + const handleRetryTranscode = async (materialId: string) => { + try { + const response = await fetch('/api/materials/transcode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ materialId, forceRetry: true }), + }) + if (response.ok) { + // 刷新素材列表 + fetchMaterials(1, false) + } else { + const error = await parseFetchError(response) + showToast(error.message, { variant: 'error' }) + } + } catch (error) { + console.error('Retry transcode error:', error) + handleError(error, showToast) + } + } + + // 删除素材 + const handleDelete = async (materialId: string) => { + if (!confirm('确定要删除这个素材吗?素材将移至回收站。')) return + + try { + const response = await fetch(`/api/materials?id=${materialId}`, { + method: 'DELETE', + }) + + if (response.ok) { + // 从本地状态移除 + setMaterials(prev => prev.filter(m => m.id !== materialId)) + showToast('删除成功', { variant: 'success' }) + } else { + const error = await parseFetchError(response) + showToast(error.message, { variant: 'error' }) + } + } catch (error) { + console.error('Error deleting material:', error) + handleError(error, showToast) + } + } + + if (status === 'loading' || loading) { + return ( +
+
加载中...
+
+ ) + } + + if (!session) { + return null + } + + return ( + +
+ + {/* 收藏操作提示气泡 */} + {favoriteNotification && ( +
+ {favoriteNotification} +
+ )} + + {/* 内网切换提示已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转 */} + + + +
+ {/* Header Section */} +
+
+ +
+ + {/* Search Bar - 实时搜索 */} + + + {/* 筛选面板组件 */} + + + {/* Active Filters Display */} + {(searchKeyword || selectedMaterialType || selectedCategory || selectedScene || selectedPlatform || selectedProductCategory || selectedMonth) && ( +
+
+ 当前筛选: + {searchKeyword && ( + + 搜索: {searchKeyword} + + + )} + {selectedMaterialType && ( + + 素材类型: {selectedMaterialType} + + + )} + {selectedCategory && ( + + 店铺: {selectedCategory} + + + )} + {selectedScene && ( + + 场景类别: {selectedScene} + + + )} + {selectedPlatform && ( + + 平台分类: {selectedPlatform} + + + )} + {selectedProductCategory && ( + + 产品类别: {selectedProductCategory} + + + )} + {selectedMonth && ( + + 月份: {months.find(m => m.value === selectedMonth)?.label || selectedMonth} + + + )} + +
+
+ )} +
+ + {/* Materials Grid - 使用 MaterialGrid 组件 */} + + + {/* 分页+懒加载 */} + {materials.length > 0 && !loading && ( + + )} +
+ + {/* Upload Modal */} + {showUploadModal && ( + setShowUploadModal(false)} + onSuccess={() => { + setShowUploadModal(false) + // 延迟刷新列表,确保数据库事务全部提交完成(修复批量上传后素材显示不全的问题) + // 延迟1秒,给数据库足够的时间完成所有事务提交 + setTimeout(() => { + fetchMaterials() + }, 1000) + fetchFacets() + }} + /> + )} + + {/* Image Preview Modal */} + {previewImage && ( +
setPreviewImage(null)} + > +
+ +
+ {previewImage.title} e.stopPropagation()} + /> +

{previewImage.title}

+
+
+
+ )} + + {/* Share Notification */} + {showShareNotification && ( + + )} + + {/* 统一的Toast提示 */} + {toast && toast.show && ( + + )} + + {/* Share Modal */} + {showShareModal && shareUrl && ( +
setShowShareModal(false)} + > +
e.stopPropagation()} + > +

分享链接

+
+ {shareUrl} +
+
+ + +
+
+
+ )} + + {/* AI聊天助手 */} + +
+
+ ) +} diff --git a/app/edit/[id]/page.tsx b/app/edit/[id]/page.tsx index bc3643c..1281328 100644 --- a/app/edit/[id]/page.tsx +++ b/app/edit/[id]/page.tsx @@ -562,7 +562,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) setInitialFormSignature(currentFormSignature) setSuccess('素材信息更新成功') setTimeout(() => { - router.push('/materials') + router.push('/assets/material-library') }, 1200) } catch (submitError: any) { console.error('更新失败:', submitError) @@ -611,7 +611,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })