feat: 上线品牌中心部门看板

This commit is contained in:
江林铖 2026-08-19 12:01:48 +08:00
parent 23083c2ef5
commit 92ed481314
56 changed files with 6113 additions and 619 deletions

5
.gitignore vendored
View File

@ -96,6 +96,10 @@ missing-video-files*.json
/.claude/worktrees/ /.claude/worktrees/
/.playwright-mcp/ /.playwright-mcp/
/.performance-survey-audits/ /.performance-survey-audits/
/outputs/department-dashboard-qa/
/.impeccable/screenshots/
/pnpm-lock.yaml
/pnpm-workspace.yaml
# Temporary investigation/probe artifacts # Temporary investigation/probe artifacts
/tmp/ /tmp/
@ -124,4 +128,3 @@ backups/
# DSH 视觉工具产物目录(本地生成,不进 git) # DSH 视觉工具产物目录(本地生成,不进 git)
.dsh-vision-toolkit/ .dsh-vision-toolkit/

531
.impeccable/design.json Normal file
View File

@ -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": "<a class=\"ds-nav-active\" href=\"#\" aria-current=\"page\"><svg aria-hidden=\"true\" viewBox=\"0 0 24 24\"><path d=\"M3 17l5-5 4 4 7-8\"/><path d=\"M15 8h4v4\"/></svg><span>业务趋势</span></a><style>.ds-nav-active{display:flex;min-height:44px;align-items:center;gap:10px;border-radius:8px;background:#1677ff;color:#fff;padding:0 12px;font:600 14px/1.4 ui-sans-serif,system-ui;text-decoration:none}.ds-nav-active svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.ds-nav-active:focus-visible{outline:2px solid #69a9ff;outline-offset:2px}</style>"
},
{
"name": "Data Panel",
"type": "card",
"description": "边框优先的白色矩形数据工作面。",
"projection": "<section class=\"ds-panel\"><h2>核心指标</h2><p>真实、示例与待接入状态均清晰标注。</p></section><style>.ds-panel{border:1px solid #e2e8f0;border-radius:12px;background:#fff;padding:24px;color:#101828;box-shadow:0 6px 18px rgba(15,23,42,.05)}.ds-panel h2{margin:0;font:700 20px/1.4 ui-sans-serif,system-ui}.ds-panel p{margin:8px 0 0;color:#66758b;font:400 16px/1.75 ui-sans-serif,system-ui}</style>"
},
{
"name": "Primary Button",
"type": "button",
"description": "高识别蓝色主要行动按钮。",
"projection": "<button class=\"ds-button-primary\" type=\"button\">查看明细</button><style>.ds-button-primary{min-height:44px;border:0;border-radius:8px;background:#1677ff;color:#fff;padding:0 16px;font:600 14px/1 ui-sans-serif,system-ui;cursor:pointer}.ds-button-primary:hover{background:#0958d9}.ds-button-primary:focus-visible{outline:2px solid #1677ff;outline-offset:3px}</style>"
},
{
"name": "Search Field",
"type": "input",
"description": "白底细边框的数据筛选字段。",
"projection": "<label class=\"ds-field-label\">搜索<input class=\"ds-field\" type=\"search\" placeholder=\"输入关键词\"></label><style>.ds-field-label{display:grid;gap:6px;color:#344054;font:600 14px/1.4 ui-sans-serif,system-ui}.ds-field{min-height:44px;border:1px solid #e2e8f0;border-radius:8px;background:#fff;padding:0 14px;color:#101828;font:400 16px/1.4 ui-sans-serif,system-ui}.ds-field:focus{outline:2px solid #1677ff;outline-offset:1px}</style>"
},
{
"name": "Connected Status",
"type": "chip",
"description": "只有真实接入业务才使用绿色状态。",
"projection": "<span class=\"ds-status-connected\"><i aria-hidden=\"true\"></i>实时读取</span><style>.ds-status-connected{display:inline-flex;align-items:center;gap:7px;border-radius:9999px;background:#ecfdf8;padding:5px 9px;color:#087052;font:600 13px/1.3 ui-sans-serif,system-ui}.ds-status-connected i{width:7px;height:7px;border-radius:50%;background:#31d0aa}</style>"
},
{
"name": "Workspace Row",
"type": "custom",
"description": "不可点击的大职责域和可点击的小项入口。",
"projection": "<section class=\"ds-workspace\"><header><h2>中心仪表盘</h2><p>掌握经营与交付全貌</p></header><nav aria-label=\"中心仪表盘\"><a href=\"#\">核心指标 →</a><a href=\"#\">业务趋势 →</a></nav></section><style>.ds-workspace{border:1px solid #e2e8f0;border-radius:12px;background:#fff;padding:24px;color:#101828}.ds-workspace h2{margin:0;font:700 20px/1.4 ui-sans-serif,system-ui}.ds-workspace p{margin:4px 0 16px;color:#b42318;font:600 14px/1.4 ui-sans-serif,system-ui}.ds-workspace nav{display:flex;border:1px solid #e2e8f0;border-radius:8px;overflow:hidden}.ds-workspace a{flex:1;padding:14px;color:#101828;text-decoration:none;font:600 14px/1.4 ui-sans-serif,system-ui}.ds-workspace a+a{border-left:1px solid #e2e8f0}.ds-workspace a:focus-visible{outline:2px solid #1677ff;outline-offset:-2px}</style>"
},
{
"name": "Sidebar Account Menu",
"type": "navigation",
"description": "账户入口固定在侧栏底部,菜单向上展开。",
"projection": "<div class=\"ds-account\"><button aria-haspopup=\"menu\"><b>本</b><span>本地超级管理员<small>账户与退出</small></span></button></div><style>.ds-account{border-top:1px solid rgba(255,255,255,.1);background:#06192d;padding:12px 16px}.ds-account button{display:flex;min-height:48px;width:100%;align-items:center;gap:12px;border:0;border-radius:8px;background:transparent;color:#dbe4ed;text-align:left}.ds-account b{display:grid;width:36px;height:36px;place-items:center;border-radius:8px;background:#1677ff;color:#fff}.ds-account span{font:600 14px/1.4 ui-sans-serif,system-ui}.ds-account small{display:block;color:#7f93a8;font-weight:400}.ds-account button:focus-visible{outline:2px solid #69a9ff}</style>"
},
{
"name": "Mobile Navigation Drawer",
"type": "navigation",
"description": "1024px 以下使用的完整模态导航抽屉。",
"projection": "<div class=\"ds-drawer\" role=\"dialog\" aria-modal=\"true\" aria-label=\"品牌中心移动导航\"><strong>品牌中心</strong><button type=\"button\" aria-label=\"关闭部门导航\">×</button><nav><a href=\"#\">中心仪表盘</a><a href=\"#\">业务经营</a></nav></div><style>.ds-drawer{width:min(86vw,304px);min-height:360px;background:#06192d;padding:20px;color:#fff;font-family:ui-sans-serif,system-ui}.ds-drawer>button{float:right;border:0;background:transparent;color:#fff;font-size:24px}.ds-drawer nav{display:grid;gap:8px;margin-top:24px}.ds-drawer a{min-height:44px;border-radius:8px;padding:12px;color:#aebdcd;text-decoration:none}.ds-drawer a:focus-visible{outline:2px solid #69a9ff}</style>"
},
{
"name": "Automation Workflow Split Workbench",
"type": "navigation",
"description": "Department Shell 内的 232px 二级业务栏与同源真实操作面;移动端菜单改为顶部两列,不渲染标题或接入数量区。",
"projection": "<section class=\"ds-workflow\"><nav class=\"ds-workflow-nav\" aria-label=\"自动化工作流二级导航\"><button class=\"ds-workflow-item ds-workflow-active\" type=\"button\" aria-current=\"page\"><svg aria-hidden=\"true\" viewBox=\"0 0 24 24\"><path d=\"M9 11l2 2 4-4\"/><path d=\"M5 3h14v18H5z\"/></svg><span><b>绩效考核调研</b><small>考核问卷、填报与结果管理</small></span></button><button class=\"ds-workflow-item ds-workflow-inactive\" type=\"button\"><svg aria-hidden=\"true\" viewBox=\"0 0 24 24\"><path d=\"M6 3h9l3 3v15H6z\"/><path d=\"M9 12h6M9 16h6\"/></svg><span><b>作品过稿看板</b><small>作品提交、评审与退修流转</small></span></button></nav><section class=\"ds-workflow-live\" aria-label=\"绩效考核调研操作区\"><strong>绩效考核调研</strong><p>同源真实业务操作面</p></section></section><style>.ds-workflow{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:360px;background:#fff;font-family:ui-sans-serif,system-ui}.ds-workflow-nav{display:grid;align-content:start;gap:4px;border-right:1px solid #e2e8f0;background:#f8fafc;padding:24px 16px}.ds-workflow-item{display:flex;min-width:0;min-height:58px;align-items:center;gap:12px;border:0;border-radius:8px;padding:10px 12px;text-align:left;transition:background .2s,color .2s}.ds-workflow-item svg{width:16px;height:16px;flex:none;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.ds-workflow-item span{min-width:0}.ds-workflow-item b,.ds-workflow-item small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ds-workflow-item b{font-size:14px}.ds-workflow-item small{margin-top:2px;font-size:11px}.ds-workflow-active{background:#e6f4ff;color:#0050cb}.ds-workflow-active svg{color:#1677ff}.ds-workflow-active small{color:#315f91}.ds-workflow-active:hover{background:#e6f4ff}.ds-workflow-inactive{background:transparent;color:#4f6077}.ds-workflow-inactive svg{color:#087052}.ds-workflow-inactive small{color:#667085}.ds-workflow-inactive:hover{background:#eef2f6;color:#101828}.ds-workflow-item:focus-visible{outline:2px solid #5a9ce6;outline-offset:0}.ds-workflow-live{min-width:0;background:#f2f4f7;padding:24px;color:#101828}.ds-workflow-live strong{font-size:20px}.ds-workflow-live p{margin:8px 0 0;color:#66758b;font-size:14px}@media(max-width:1023px){.ds-workflow{grid-template-columns:1fr}.ds-workflow-nav{grid-template-columns:repeat(2,minmax(0,1fr));border-right:0;border-bottom:1px solid #e2e8f0;padding:16px}.ds-workflow-item small{display:none}}</style>"
}
],
"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 内打开并嵌套品牌中心首页。"
]
}
}

304
DESIGN.md
View File

@ -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"
---
<!-- impeccable:design-schema 1 --> # Design System: 品牌中心部门看板
> 记录自建成代码,非先行规范。方向:用户裁定的 Zen Minimal × Future Organic 融合(2026-08-15)。 ## Overview
> **全站浅色单主题**:深色主题已废弃(2026-08-15 用户裁定),`darkMode: 'class'` 且不挂 `.dark`,所有 `dark:` 变体失效;新代码禁止再写 `dark:` 类。遗留 `dark:` 类 inert 保留,随逐页统一清理。
## World **Creative North Star: "深色导航轨数据工作台"**
「白皮书」:整页是一张编辑排印的品牌白皮书——白纸黑字、发丝线、超大留白;五个板块排成作品集式编号索引;唯一的色彩氛围是右上角一枚缓慢呼吸的柔紫光晕。安静、克制、有底气。 品牌中心是一套固定导航、快速切换与逐层下钻的内部运营界面。深海军蓝导航轨提供稳定坐标,白色操作顶栏承载全局控制,冷灰画布上以矩形白色工作面板组织真实数据、示例状态与待接入框架。
## Color 它保留品牌中心自己的五个职责域、22 个小项入口,以及素材库与自动化工作流两项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。首页是工作地图,不是营销门户;子页是数据工作面,不用巨型漂浮英雄卡重复标题。
| 令牌 | 值 | 用途 | **Key Characteristics:**
|---|---|---|
| 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` | 规划中状态点 |
无阴影(平面化);层级靠留白、字重与发丝线。**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` 字距 ### Primary
- 正文:同上字栈,13–16px、行高 1.7;不再用系统默认黑体
- 自托管:`@fontsource/noto-sans-sc`(400/500/600/700/900)、`@fontsource/space-grotesk`(400/500/600/700) - **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 ## Layout
- 顶栏:56px 高,logo + 「由壹点·品牌中心」+ 板块导航 + 用户区;发丝线下边 桌面端采用固定侧栏工作台:导航轨宽 272px,可折叠为 80px;右侧顶栏高 72px;主内容最大宽度 1600px,使用 16/24/32px 响应式内边距。首页标题后直接进入五条纵向职责域,每条职责域下的小项在桌面端同一横排,窄屏改为一至两列。
- 容器:`max-w-[1280px] px-6`;板块间距 64–96px
- 编号索引:五行,网格 `[64px_1fr_160px_120px_32px]`(移动端 `[44px_1fr_auto]`,状态点保留) 1024px 以下隐藏固定侧栏,以左侧模态抽屉替代。抽屉宽度为 `min(86vw, 304px)`,打开时锁定页面滚动、底层内容 `inert`、焦点进入并约束在导航内,支持 Escape 关闭并恢复触发器焦点。390px 页面不得产生全局横向溢出。
- 已上线板块:两张 12px 圆角白卡(`p-8/10`)承载入口按钮
自动化工作流在 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 ## Components
- 索引行:已上线 = 整行可点 Link,悬停白底+箭头浮现+编号变紫;规划中 = 灰行不可点 ### Buttons
- 按钮:主 = 墨色实心 8px 圆角;次 = 1px 墨边白底,hover 反色
- 状态:7px 圆点 + 12px 文字(移动端不隐藏)
- 焦点:`ring-2 ring-[#1a1c1d]/70 ring-offset-2`,全站交互元素必带
## 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 用户裁定:本次是比较重大的升级,视觉语言需要全面升级。** 后续页面不得再做「旧壳换色 / 只改角标和按钮」的局部换皮;信息架构可保留,版式、组件、密度、动效应按本世界重做。 ### Inputs / Fields
- 组织架构页(`/organization`)仍为旧「云采石场」语言(BoardShell),待按本世界全面对齐
- 素材库(`/materials`)顶栏已是看板语言;列表、筛选、卡片仍是旧素材库骨架,需按本世界全面升级,而不是只改色板 - **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 内打开并嵌套品牌中心首页。

View File

@ -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)})" && \ 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))" 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 FROM base AS builder
ARG ALPINE_MIRROR ARG ALPINE_MIRROR

View File

@ -6,63 +6,45 @@
web web
## Stack
Next.js 14 (App Router) + React 18 + TypeScript + TailwindCSS + PostgreSQL(Prisma)。既有代码库已定栈。
## Users ## Users
- **系统管理员**:维护平台人员与组织数据,需要看到全量真实组织架构、人员归属、职级与汇报线。 品牌中心内部成员、部门负责人和管理者。他们需要从统一入口定位业务、资产、组织与治理相关工作,并按自身权限进入具体工具。
- **部门负责人**:查看本部门组织与人员情况(本部门范围内)。
- 用户为内部员工(非程序员),界面需日常语言、少术语、可直接读。
## Product Purpose ## Product Purpose
把「由壹点3D素材库」逐步改造为「品牌中心部门看板」(对内品牌:**由壹点·品牌中心**,隶属由壹点软件集团)。第一阶段(2026-08-15)已完成:登录落点切换为看板首页 `/`,素材库整体迁至 `/materials`,组织架构上移为顶层 `/organization`(旧路径 `/admin/org` 服务端跳转)。 “品牌中心部门看板”是品牌中心的一级工作门户,用五个业务域组织部门工作入口:中心仪表盘、业务经营、资产与AI自动化、组织管理、治理与设置。当前先建立信息架构和导航框架,底层业务数据后续接入。
## Positioning
以钉钉唯一值为唯一人员身份判定依据,统一接入公司 HR 报表库(非钉钉通讯录),人员/组织数据全平台一致;考核调研与素材库共用同一份权威人员主档,业务特殊数据隔离在各自专属表。素材库为在产系统,改造期间上传/下载/分享/权限链路不动。
## Operating Context ## Operating Context
- 人员权威数据源:公司 SQL Server 报表库(`rpt` schema),每日 02:00(Asia/Shanghai)自动同步组织架构→在职状态→离职联动。 产品运行在内部 Web 环境中,既有“由壹点3D素材”平台已经在线运行。本地开发阶段必须与线上数据和部署链路隔离;代码只有在用户明确指示后才允许提交或部署。
- 只有「开过素材库账号 + 已建立钉钉绑定」的人员进入展示/统计体系(约 503 人);无账号的仓储/物流等约 1200 人不纳入。
- 在职判定用规范化 `employmentState`(ACTIVE/RESIGNED/UNKNOWN,UNKNOWN 视为在职),不用关键词匹配。
## Capabilities and Constraints ## Capabilities and Constraints
- 看板首页 `/`:五大板块编号索引(01 创意资产库全员可进 / 02 组织管理仅管理员 / 03-05 规划中如实标灰),入场"对焦"动效,悬停物理重量语言。 - 一级门户根路径为 `/`。
- 创意资产库 `/materials`:原素材库首页,**业务功能不动**;顶栏已统一为看板语言。2026-08-17 起按「重大升级」处理:视觉语言要全面对齐看板世界,禁止只改色板/圆角的局部换皮。 - “由壹点3D素材”作为“资产与AI自动化”下的二级页面保留完整现有能力。
- 组织架构页 `/organization`:组织树 + 人员列表,展示 M/P 等级、人员归属(部门路径)、直属主管、在职状态。 - “自动化工作流”作为“资产与AI自动化”下的业务入口,集中链接现有的绩效考核调研与作品过稿看板。
- 纯展示(只读),不改数据。 - 除素材资产库、自动化工作流和已接入的中心指标外,其余板块现阶段只展示可理解、可扩展的模块框架,不虚构业务数据。
- 权限:系统管理员看全部;部门负责人看本部门。 - 沿用项目现有 Next.js、NextAuth、Tailwind CSS 和权限体系。
- 离职人员默认隐藏,可切换「含离职」。
- 无绑定账号(无钉钉 ID,约 15 人)与部门未确定人员(约 57 人)单独分组,不消失、不硬塞部门。
- 数据缺口如实展示:直属主管覆盖率低(约 158/503)、部分人员无 M/P 等级。
## Brand Commitments ## 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`(深蓝+橙图案)。两者内容不同,不可混用。 - 保留“由壹点3D素材”现有名称和功能语义。
- **全站浅色单主题(2026-08-15 裁定)**:深色主题废弃,`tailwind darkMode: 'class'` 且不挂 `.dark`;新代码禁止 `dark:` 类。
- 动效标准:略丰富但克制(用户既定),GSAP + matchMedia(prefers-reduced-motion) + fromTo + clearProps,200-500ms,信息效率优先。
## Evidence on Hand ## Evidence on Hand
- 思维导图:`资料/品牌中心部门看板(初版).jpg`(5 大板块规划)。 - 用户提供的五板块思维导图截图。
- 交接文档:`docs/人员架构界面切换-交接文档-2026-08-14.md`。 - 当前仓库内已运行的素材库页面、登录、权限和后台功能。
- 数据盘点:`scripts/diagnose-people-architecture.ts`(只读,可重复跑)。 - 暂无用于一级门户的真实业务指标,界面不得编造数值或经营结论。
## Product Principles ## Product Principles
1. 人员身份只认钉钉唯一值;姓名/邮箱仅人工排错,不自动建绑定。 - 先让入口与职责边界清楚,再接入数据。
2. 只展示「有账号+绑定」的人,无账号的仓储/物流等不纳入统计。 - 一级门户负责导航与全局理解,二级页面负责完成具体任务。
3. 在职判定用规范化 `employmentState`,UNKNOWN 视为在职(宁可多留)。 - 保持线上业务稳定,本地验证充分后再由用户决定是否提交和部署。
4. 数据如实展示,不造假:主管/等级缺失就显示"未记录"。 - 对不可用能力诚实标注状态,不用假数据制造完成感。
5. 归属与署名分离:素材归属与作者署名对不上通常是正确的,不"修正"。
## Accessibility & Inclusion ## Accessibility & Inclusion
内部后台工具,目标用户为内部员工;遵循基础可访问性(语义化、键盘可操作、对比度达标)。 门户的核心导航必须支持键盘操作、清晰焦点、语义化标签,并在桌面端与移动端保持可读和可用。

View File

@ -243,6 +243,51 @@ docker compose up -d
http://localhost:3000 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 ```bash

View File

@ -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 },
)
}
}

View File

@ -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
}

File diff suppressed because it is too large Load Diff

View File

@ -562,7 +562,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
setInitialFormSignature(currentFormSignature) setInitialFormSignature(currentFormSignature)
setSuccess('素材信息更新成功') setSuccess('素材信息更新成功')
setTimeout(() => { setTimeout(() => {
router.push('/materials') router.push('/assets/material-library')
}, 1200) }, 1200)
} catch (submitError: any) { } catch (submitError: any) {
console.error('更新失败:', submitError) console.error('更新失败:', submitError)
@ -611,7 +611,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
<div className="mt-6 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"> <div className="mt-6 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button <button
type="button" type="button"
onClick={() => router.push('/materials')} onClick={() => router.push('/assets/material-library')}
className="min-h-11 rounded-apple border border-gray-300 px-5 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700" className="min-h-11 rounded-apple border border-gray-300 px-5 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
> >
返回素材库 返回素材库

View File

@ -2,17 +2,17 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* 全站浅色单主题(2026-08-15 用户裁定):深色媒体查询已移除,dark: 变体已停用(tailwind darkMode:'class' 且不挂 .dark) */
:root { :root {
--foreground-rgb: 0, 0, 0; --foreground-rgb: 0, 0, 0;
--background-start-rgb: 250, 250, 250; --background-start-rgb: 250, 250, 250;
--background-end-rgb: 255, 255, 255; --background-end-rgb: 255, 255, 255;
} }
@layer base { @media (prefers-color-scheme: dark) {
html, :root {
body { --foreground-rgb: 255, 255, 255;
font-family: "Space Grotesk", "Noto Sans SC", ui-sans-serif, system-ui, sans-serif; --background-start-rgb: 18, 18, 18;
--background-end-rgb: 0, 0, 0;
} }
} }
@ -63,6 +63,53 @@ body {
cursor: not-allowed; cursor: not-allowed;
} }
/* 品牌中心门户内的素材库固定使用浅色运营工作台,不跟随系统深色媒体查询。 */
@media (prefers-color-scheme: dark) {
.department-material-light .dark\:bg-gray-900 {
background-color: #edf3fa !important;
}
.department-material-light :is(.dark\:bg-gray-800, .dark\:bg-gray-950, .dark\:bg-gray-300) {
background-color: #ffffff !important;
}
.department-material-light .dark\:bg-gray-800\/50 {
background-color: #f7f9fc !important;
}
.department-material-light :is(.dark\:text-white, .dark\:text-gray-800) {
color: #101828 !important;
}
.department-material-light .dark\:text-gray-100 {
color: #1b2738 !important;
}
.department-material-light :is(.dark\:text-gray-300, .dark\:text-slate-200) {
color: #4f6077 !important;
}
.department-material-light :is(.dark\:text-gray-400, .dark\:text-slate-400) {
color: #66758b !important;
}
.department-material-light :is(.dark\:border-gray-600, .dark\:border-gray-700, .dark\:border-gray-800, .dark\:border-slate-700) {
border-color: #dce5ef !important;
}
.department-material-light .dark\:placeholder\:text-gray-500::placeholder {
color: #8a98aa !important;
}
.department-material-light :is(.dark\:hover\:bg-gray-700:hover, .dark\:hover\:bg-gray-900:hover) {
background-color: #f1f6fc !important;
}
.department-material-light :is(.dark\:hover\:text-gray-200:hover, .dark\:hover\:text-gray-300:hover, .dark\:hover\:text-white:hover) {
color: #176fc1 !important;
}
}
/* 淡入动画 */ /* 淡入动画 */
@keyframes fadeIn { @keyframes fadeIn {

View File

@ -1,13 +1,4 @@
import type { Metadata, Viewport } from 'next' import type { Metadata, Viewport } from 'next'
import '@fontsource/noto-sans-sc/400.css'
import '@fontsource/noto-sans-sc/500.css'
import '@fontsource/noto-sans-sc/600.css'
import '@fontsource/noto-sans-sc/700.css'
import '@fontsource/noto-sans-sc/900.css'
import '@fontsource/space-grotesk/400.css'
import '@fontsource/space-grotesk/500.css'
import '@fontsource/space-grotesk/600.css'
import '@fontsource/space-grotesk/700.css'
import './globals.css' import './globals.css'
import Providers from './providers' import Providers from './providers'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
@ -35,13 +26,10 @@ async function getSystemSettings() {
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const settings = await getSystemSettings() const settings = await getSystemSettings()
const siteTitle = !settings?.siteName || settings.siteName === '由壹点3D素材'
? '由壹点·品牌中心'
: settings.siteName
return { return {
title: siteTitle, title: settings?.siteName || '由壹点3D素材',
description: '品牌中心部门看板与创意资产协作平台', description: '3D素材分享平台',
icons: { icons: {
icon: '/api/favicon', icon: '/api/favicon',
shortcut: '/api/favicon', shortcut: '/api/favicon',
@ -66,7 +54,20 @@ export default function RootLayout({
{/* 注意:frame-ancestors 只能通过 HTTP 响应头设置,不能通过 meta 标签 */} {/* 注意:frame-ancestors 只能通过 HTTP 响应头设置,不能通过 meta 标签 */}
<meta httpEquiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: http: https: ws: wss:; img-src 'self' 'unsafe-inline' blob: data: http: https:; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https://g.alicdn.com; style-src 'self' 'unsafe-inline'; connect-src 'self' blob: data: http: https: ws: wss:; media-src 'self' blob: data: http: https:; font-src 'self' data: http: https:; object-src 'none'; base-uri 'self'; form-action 'self';" /> <meta httpEquiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: http: https: ws: wss:; img-src 'self' 'unsafe-inline' blob: data: http: https:; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https://g.alicdn.com; style-src 'self' 'unsafe-inline'; connect-src 'self' blob: data: http: https: ws: wss:; media-src 'self' blob: data: http: https:; font-src 'self' data: http: https:; object-src 'none'; base-uri 'self'; form-action 'self';" />
</head> </head>
<body className="antialiased"> <body className="font-sans antialiased">
<template
data-design-contract="BRAND-CENTER-DARK-RAIL-OPS-V4"
dangerouslySetInnerHTML={{
__html: `<!--
THESIS: 品牌中心是一套固定导航、快速切换与逐层下钻的部门数据工作台,不是漂浮卡片式门户。
OWN-WORLD: 深海军蓝部门导航轨、白色操作顶栏、冷灰数据画布、矩形白色工作面板与高识别蓝色选中态组成同一套运营界面。
STORY: 用户从左侧五个职责域直接切换二级页面,在主画布中查看框架、真实指标、品牌健康度或既有素材业务。
FIRST VIEWPORT: 272px 部门导航与 72px 操作顶栏建立稳定坐标,页面标题和第一块核心工作面板无需经过门户式英雄卡即可看到。
FORM: Operate 模式的固定侧栏数据工作台;用户参考图精确塑形;seed key BRAND-CENTER-DARK-RAIL-OPS-V4。
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
-->`,
}}
/>
<Providers> <Providers>
{children} {children}
</Providers> </Providers>

View File

@ -1,330 +1,11 @@
/* import type { Metadata } from 'next'
* 方向契约(用户裁定方向:Zen Minimal × Future Organic 融合 · 2026-08-15) import BrandCenterDashboard from '@/components/department-dashboard/BrandCenterDashboard'
* THESIS: 看板首页是一张"编辑页"——白纸、黑字、发丝线、超大留白;七个板块排成一列
* 编号索引(作品集列表语言),悬停整行浮现;唯一的柔光来自一枚有机渐变光晕。
* 拒绝卡片阵列、拒绝重色块、拒绝装饰;安静得像一本品牌中心的白皮书。
* OWN-WORLD: 白 #fafafa/#fff、墨 #1a1c1d、正文灰 #6b7075、注脚灰 #86898e、发丝线 #e7e8ea、
* 柔粉色场(唯一彩色氛围)+ 微型大写字距标注、
* 黑实心按钮 8px 圆角、无阴影平面化;全部文字对比过 WCAG(≥4.5:1 / 大字 ≥3:1)。
* STORY: 打开即读:巨字「品牌中心」+ 一句矿脉定位;视线沿编号索引下移——
* 01 资产、02 组织、03 考核、04 过稿是活的(可点,行变黑);后续板块仍在生长。
* 四块已上线板块以克制的白卡提供入口;页脚是跟踪字距的微型注脚。
* FIRST VIEWPORT: 极简顶栏(logo+由壹点·品牌中心)→ 超大 600 字重「品牌中心」+ 定位语 →
* 右侧一枚柔粉渐变色场压角 → 下方七条发丝线分隔的编号索引行。
* FORM: 用户在 Stitch 自生成的候选 3(Zen Minimal)与 4(Future Organic)融合,
* 参考作品集文件夹两份 DESIGN.md(Monochrome/Studio Minimalist)裁定。
* FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
*/
'use client' export const metadata: Metadata = {
title: '品牌中心部门看板',
import { useEffect, useRef } from 'react' description: '品牌中心部门业务、资产、组织与治理工作入口',
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
import MaterialsHeader from '@/components/board/MaterialsHeader'
import BrandFluidField from '@/components/board/BrandFluidField'
gsap.registerPlugin(useGSAP)
const ADMIN_ROLES = ['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN']
function StatusDot({ tone }: { tone: 'live' | 'building' | 'planned' }) {
const tones = { live: 'bg-[#157a4b]', building: 'bg-[#6e79c8]', planned: 'bg-[#9aa0a8]' } as const
return <span aria-hidden="true" className={`inline-block h-[7px] w-[7px] shrink-0 rounded-full ${tones[tone]}`} />
} }
const labelCls = 'text-[11px] uppercase tracking-[0.14em] text-[#86898e]' export default function BrandCenterDashboardPage() {
const focusRing = 'focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa]' return <BrandCenterDashboard />
/** 会话加载中的骨架行(防止管理员首帧闪灰) */
function SkeletonIndex() {
return (
<div className="border-t border-[#e7e8ea]">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 border-b border-[#e7e8ea] py-9 md:gap-8">
<div className="h-3 w-8 shrink-0 animate-pulse bg-[#eceef0]" />
<div className="min-w-0 flex-1 space-y-2.5">
<div className="h-5 w-44 animate-pulse bg-[#eceef0]" />
<div className="h-3 w-72 max-w-full animate-pulse bg-[#f2f3f5]" />
</div>
</div>
))}
</div>
)
}
export default function BoardHomePage() {
const { data: session, status } = useSession()
const router = useRouter()
const pageRef = useRef<HTMLDivElement>(null)
const sessionLoading = status === 'loading'
const sessionUser = session?.user as { role?: string } | undefined
const isAdmin = ADMIN_ROLES.includes(sessionUser?.role || '')
useEffect(() => {
if (status === 'unauthenticated') {
router.replace('/login')
}
}, [status, router])
const indexRows = [
{
no: '01',
title: '创意资产库',
en: 'Creative Assets',
tone: 'live' as const,
status: '运行中',
live: true,
href: '/materials',
desc: '素材浏览、上传、分享与下载——品牌中心的创意资产都在这里,全部功能照常使用。',
},
{
no: '02',
title: '组织管理',
en: 'People & Org',
tone: 'live' as const,
status: '已上线',
live: isAdmin,
href: '/organization',
desc: '人员主档以钉钉唯一值绑定 HR 花名册,组织树与在职状态每日 02:00 自动同步。',
},
{
no: '03',
title: '绩效考核',
en: 'Performance',
tone: 'live' as const,
status: '已上线',
live: true,
href: '/performance-survey',
desc: '个人考核、公共看板与调研管理——任务进度和结果在同一处收敛。',
},
{
no: '04',
title: '作品过稿看板',
en: 'Work Review',
tone: 'live' as const,
status: '已上线',
live: true,
href: '/work-review',
desc: '作品提交、版本迭代与评审意见——让创意协作从上传后继续推进。',
},
{
no: '05',
title: '中心仪表盘',
en: 'Dashboard',
tone: 'building' as const,
status: '建设中',
live: false,
desc: '将接入:核心指标 · 业务趋势 · 六品牌健康度 · 团队产能 · 风险预警',
},
{
no: '06',
title: '业务经营',
en: 'Operations',
tone: 'planned' as const,
status: '规划中',
live: false,
desc: '将包含:新品上新 · 品牌塑造 · 创意生产 · 业绩转化 · 项目协同',
},
{
no: '07',
title: '治理与设置',
en: 'Governance',
tone: 'planned' as const,
status: '规划中',
live: false,
desc: '将包含:权限角色 · 数据口径 · 编辑能力 · 通知机制',
},
]
useGSAP(() => {
if (sessionLoading || typeof window.matchMedia !== 'function') return
if (
!pageRef.current?.querySelector('[data-hero-title]') ||
!pageRef.current.querySelector('[data-row-line]') ||
!pageRef.current.querySelector('[data-row]') ||
!pageRef.current.querySelector('[data-foot]')
) {
return
}
const motion = gsap.matchMedia()
motion.add('(prefers-reduced-motion: no-preference)', () => {
const tl = gsap.timeline({ defaults: { ease: 'expo.out' } })
tl
// 主标题:对焦揭幕——虚焦+宽字距 → 聚焦收拢(重、慢,配 quint 级缓动)
.fromTo(
'[data-hero-title]',
{ filter: 'blur(16px)', opacity: 0, letterSpacing: '0.16em' },
{
filter: 'blur(0px)',
opacity: 1,
letterSpacing: '-0.03em',
duration: 1.15,
ease: 'power4.out',
clearProps: 'filter,opacity,letterSpacing',
},
)
// 定位语
.fromTo(
'[data-hero]',
{ y: 12, autoAlpha: 0 },
{ y: 0, autoAlpha: 1, duration: 0.55, clearProps: 'opacity,visibility,transform' },
'-=0.85',
)
// 图纸先行:每行的发丝线依次画出,内容再落位
.fromTo(
'[data-row-line]',
{ scaleX: 0 },
{ scaleX: 1, duration: 0.6, stagger: 0.07 },
'-=0.55',
)
.fromTo(
'[data-row]',
{ y: 14, autoAlpha: 0 },
{ y: 0, autoAlpha: 1, duration: 0.55, stagger: 0.07, clearProps: 'opacity,visibility,transform' },
'-=0.5',
)
.fromTo(
'[data-foot]',
{ autoAlpha: 0 },
{ autoAlpha: 1, duration: 0.6, clearProps: 'opacity,visibility' },
'-=0.35',
)
})
return () => motion.revert()
}, { scope: pageRef, dependencies: [sessionLoading] })
return (
// direction: zen-minimal × future-organic fusion (user-curated) — THESIS: 白皮书式的编号索引,悬停浮现
<div ref={pageRef} className="board-home min-h-screen bg-[#fafafa] text-[#1a1c1d]">
<style>{`.board-home ::selection { background: #eef0fa; color: #1a1c1d; }`}</style>
{/* ===== 平台顶栏(首页模式:品牌 + 五大板块导航 + 工具 + 用户菜单) ===== */}
<MaterialsHeader session={session} />
<main className="mx-auto max-w-[1280px] px-6">
{/* ===== 编辑页首:巨字 + 柔光 ===== */}
<section className="relative isolate overflow-hidden pb-16 pt-20 md:pb-24 md:pt-28">
{/* 动态品牌色场:来自 Future Organic,只在首屏承担一次有色氛围 */}
<BrandFluidField />
<div className="relative z-10">
{/* 巨型标题:入场对焦揭幕(GSAP blur+tracking),悬停字距呼吸(CSS) */}
<h1
data-hero-title
className="group cursor-default text-[clamp(60px,9vw,132px)] font-semibold leading-[1.04] tracking-[-0.03em] transition-[letter-spacing] duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:tracking-[0.035em]"
>
品牌中心
</h1>
<p data-hero className="mt-6 max-w-xl text-[15px] leading-relaxed text-[#6b7075] md:text-[16px]">
品牌的矿脉,从人到资产到经营,统一开采。
</p>
</div>
</section>
{/* ===== 编号索引(作品集列表语言;加载中骨架化防闪灰) ===== */}
{sessionLoading ? (
<SkeletonIndex />
) : (
<section aria-label="板块索引" className="border-t border-[#e7e8ea]">
{indexRows.map((row) =>
row.live ? (
<Link
key={row.no}
data-row
href={row.href ?? '#'}
className={`group relative grid grid-cols-[44px_1fr_auto] items-baseline gap-4 rounded-sm py-7 transition-colors duration-[350ms] ease-[cubic-bezier(0.22,1,0.36,1)] hover:bg-white md:grid-cols-[64px_1fr_160px_120px_32px] md:gap-8 md:py-9 ${focusRing}`}
>
{/* 基线发丝线:入场画出,悬停加深 */}
<span
data-row-line
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-px origin-left bg-[#e7e8ea] transition-colors duration-[350ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:bg-[#d9dcdf]"
/>
{/* 悬停定位线:从左划出 */}
<span
aria-hidden="true"
className="absolute inset-x-0 bottom-[-1px] h-[2px] origin-left scale-x-0 bg-[#1a1c1d] transition-transform duration-[350ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-x-100"
/>
<span className="font-en text-[13px] tabular-nums text-[#86898e] transition-all duration-[350ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:translate-x-1 group-hover:text-[#5a66b8]">
{row.no}
</span>
<div className="min-w-0">
<h2 className="text-[24px] font-semibold tracking-tight transition-transform duration-[350ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:translate-x-1.5 md:text-[30px]">
{row.title}
</h2>
<p className="mt-1.5 text-[13px] leading-relaxed text-[#6b7075] transition-all duration-[350ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:translate-x-1 group-hover:text-[#4a4f55]">
{row.desc}
</p>
</div>
<span className={`font-en hidden text-[11px] uppercase tracking-[0.14em] text-[#86898e] transition-colors duration-[350ms] group-hover:text-[#6b7075] md:inline`}>
{row.en}
</span>
<span className="flex items-center gap-2">
<StatusDot tone={row.tone} />
<span className="text-[12px] text-[#6b7075]">{row.status}</span>
</span>
<span
aria-hidden="true"
className="hidden -translate-x-2 text-[18px] text-[#1a1c1d] opacity-0 transition-all duration-[350ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:translate-x-0 group-hover:opacity-100 group-focus-visible:translate-x-0 group-focus-visible:opacity-100 md:inline"
>
→
</span>
</Link>
) : (
<div
key={row.no}
data-row
aria-disabled="true"
className="relative grid grid-cols-[44px_1fr_auto] items-baseline gap-4 py-7 md:grid-cols-[64px_1fr_160px_120px_32px] md:gap-8 md:py-9"
>
<span
data-row-line
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-px origin-left bg-[#e7e8ea]"
/>
<span className="font-en text-[13px] tabular-nums text-[#86898e]">{row.no}</span>
<div className="min-w-0">
<h2 className="text-[24px] font-medium tracking-tight text-[#8f9398] md:text-[30px]">
{row.title}
</h2>
<p className="mt-1.5 text-[13px] leading-relaxed text-[#86898e]">{row.desc}</p>
</div>
<span className="font-en hidden text-[11px] uppercase tracking-[0.14em] text-[#86898e] md:inline">
{row.en}
</span>
<span className="flex items-center gap-2">
<StatusDot tone={row.tone} />
<span className="text-[12px] text-[#86898e]">{row.status}</span>
</span>
<span aria-hidden="true" className="hidden text-[18px] text-transparent md:inline">
·
</span>
</div>
),
)}
</section>
)}
{/* ===== 微型注脚 ===== */}
<footer data-foot className="flex flex-wrap items-center gap-x-4 gap-y-2 border-t border-[#e7e8ea] py-10">
<span className="font-en text-[10px] uppercase tracking-[0.2em] text-[#86898e]">Ground</span>
<span className="text-[12px] text-[#6b7075]">
本看板由「由壹点3D素材库」改造而来,素材库现有功能不受影响。
</span>
<Link
href="/materials"
className={`rounded-sm text-[12px] font-medium text-[#5a66b8] underline-offset-4 hover:underline ${focusRing}`}
>
直达创意资产库 →
</Link>
</footer>
</main>
</div>
)
} }

View File

@ -1,7 +1,5 @@
'use client' 'use client'
// LEGACY_READ_ONLY: 旧域名仅用于展示历史考核素材。
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
@ -19,8 +17,6 @@ import { groupVisualScoresByEvaluator } from '@/lib/performance-survey-result-gr
import { PerformanceSurveyAdminConfig } from '@/app/performance-survey/PerformanceSurveyAdminConfig' import { PerformanceSurveyAdminConfig } from '@/app/performance-survey/PerformanceSurveyAdminConfig'
import { PerformanceSurveyExportDialog } from '@/app/performance-survey/PerformanceSurveyExportDialog' import { PerformanceSurveyExportDialog } from '@/app/performance-survey/PerformanceSurveyExportDialog'
import { useConfirmActionDialog } from '@/components/ConfirmActionDialog' import { useConfirmActionDialog } from '@/components/ConfirmActionDialog'
import BrandLogo from '@/components/BrandLogo'
import { DEFAULT_LOGIN_LOGO_URL } from '@/lib/brand-assets'
import { import {
choosePerformanceSurveyExportDestination, choosePerformanceSurveyExportDestination,
sanitizePerformanceSurveyExportFileName, sanitizePerformanceSurveyExportFileName,
@ -154,8 +150,6 @@ const DEFAULT_VISUAL_CASE_COUNT = 3
const ALL_POSITION_GROUPS_FILTER = 'all' const ALL_POSITION_GROUPS_FILTER = 'all'
const PERFORMANCE_SURVEY_IMAGE_PROXY_HOSTS = new Set([ const PERFORMANCE_SURVEY_IMAGE_PROXY_HOSTS = new Set([
's3.jiansun.vip', 's3.jiansun.vip',
's3.yydd.ai',
's3.1080p.hk',
]) ])
type PerformanceSurveyDashboardTab = 'personal' | 'public' | 'manage' type PerformanceSurveyDashboardTab = 'personal' | 'public' | 'manage'
@ -602,10 +596,6 @@ export default function PerformanceSurveyPage() {
setDingtalkSessionChecked(true) setDingtalkSessionChecked(true)
}, []) }, [])
useEffect(() => {
document.title = '由壹点·品牌中心 · 绩效考核'
}, [])
useEffect(() => { useEffect(() => {
const handleExpiredSession = () => { const handleExpiredSession = () => {
setHasDingtalkPerformanceSession(false) setHasDingtalkPerformanceSession(false)
@ -710,18 +700,10 @@ function PerformanceSurveyMobileHeader({ session }: { session: any }) {
: '' : ''
return ( return (
<header className="sticky top-0 z-30 border-b border-[#e7e8ea] bg-[#fafafa] text-[#1a1c1d]"> <header className="sticky top-0 z-30 border-b border-[#eeeeee] bg-[#2f3131] text-white">
<div className="mx-auto flex h-14 w-full max-w-[1440px] items-center justify-between gap-4 px-4 sm:px-6"> <div className="mx-auto flex h-14 w-full max-w-[1440px] items-center justify-between gap-4 px-4 sm:px-6">
<div className="flex min-w-0 items-center gap-2.5"> <div className="flex min-w-0 items-center gap-3">
<Link <div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-white/15 bg-[#f4f4f4] text-[12px] font-semibold text-[#0050cb]">
href="/"
aria-label="返回品牌中心首页"
className="flex shrink-0 items-center rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa]"
>
<BrandLogo src={DEFAULT_LOGIN_LOGO_URL} fallbackSrc={DEFAULT_LOGIN_LOGO_URL} alt="" width={28} height={28} unoptimized className="h-7 w-7 rounded-md object-cover" />
</Link>
<span aria-hidden="true" className="text-[12px] text-[#d4d6da]">/</span>
<div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-[#e7e8ea] bg-[#e9ebef] text-[12px] font-semibold text-[#1a1c1d]">
{avatar ? ( {avatar ? (
<Image <Image
src={avatar} src={avatar}
@ -735,17 +717,15 @@ function PerformanceSurveyMobileHeader({ session }: { session: any }) {
getSurveyInitials(name) getSurveyInitials(name)
)} )}
</div> </div>
<div className="min-w-0"> <div className="truncate text-[16px] font-bold tracking-tight">考核调研</div>
<div className="truncate text-[15px] font-semibold tracking-[-0.02em]">绩效考核</div>
<div className="font-en mt-0.5 truncate text-[10px] uppercase tracking-[0.14em] text-[#86898e]">Performance</div>
</div>
</div> </div>
<Link <Link
href="/materials" href="/"
className="shrink-0 text-[12px] font-semibold text-[#6b7075] transition-colors hover:text-[#1a1c1d]" target="_top"
aria-label="返回创意资产库" className="shrink-0 text-[12px] font-semibold text-white/85 transition-colors hover:text-white"
aria-label="返回品牌中心首页"
> >
创意资产库 返回品牌中心
</Link> </Link>
</div> </div>
</header> </header>
@ -932,18 +912,10 @@ function PerformanceSurveyDashboardHeader({
tabItems: Array<{ id: PerformanceSurveyDashboardTab; label: string }> tabItems: Array<{ id: PerformanceSurveyDashboardTab; label: string }>
}) { }) {
return ( return (
<header className="border-b border-[#e7e8ea] bg-[#fafafa] text-[#1a1c1d]"> <header className="bg-[#1f2937] text-white shadow-sm">
<div className="container mx-auto grid min-h-[60px] max-w-7xl grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-3 py-3 sm:grid-cols-[minmax(280px,1fr)_auto_minmax(280px,1fr)] sm:px-4"> <div className="container mx-auto grid min-h-[60px] max-w-7xl grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-3 py-3 sm:grid-cols-[minmax(220px,1fr)_auto_minmax(220px,1fr)] sm:px-4">
<div className="flex min-w-0 items-center gap-2.5"> <div className="flex min-w-0 items-center gap-3">
<Link <div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-white/15 bg-white/15 text-white">
href="/"
aria-label="返回品牌中心首页"
className="flex shrink-0 items-center rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa]"
>
<BrandLogo src={DEFAULT_LOGIN_LOGO_URL} fallbackSrc={DEFAULT_LOGIN_LOGO_URL} alt="" width={28} height={28} unoptimized className="h-7 w-7 rounded-md object-cover" />
</Link>
<span aria-hidden="true" className="text-[12px] text-[#d4d6da]">/</span>
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-[#e7e8ea] bg-[#e9ebef] text-[#1a1c1d]">
{avatar ? ( {avatar ? (
<Image <Image
src={avatar} src={avatar}
@ -958,8 +930,8 @@ function PerformanceSurveyDashboardHeader({
)} )}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-[15px] font-semibold tracking-[-0.02em]">绩效考核</div> <div className="truncate text-[16px] font-bold">考核调研</div>
<div className="truncate text-[11px] font-medium text-[#6b7075]" title={displayName}>{displayName}</div> <div className="truncate text-[11px] font-medium text-white/70" title={displayName}>{displayName}</div>
</div> </div>
</div> </div>
<nav className="hidden h-full items-center justify-center gap-9 sm:flex"> <nav className="hidden h-full items-center justify-center gap-9 sm:flex">
@ -973,8 +945,8 @@ function PerformanceSurveyDashboardHeader({
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
className={`h-full border-b-2 px-1 text-[13px] font-bold transition-colors ${ className={`h-full border-b-2 px-1 text-[13px] font-bold transition-colors ${
active active
? 'border-[#1a1c1d] text-[#1a1c1d]' ? 'border-white text-white'
: 'border-transparent text-[#6b7075] hover:text-[#1a1c1d]' : 'border-transparent text-[#b3c5ff] hover:text-white'
}`} }`}
> >
{item.label} {item.label}
@ -983,18 +955,20 @@ function PerformanceSurveyDashboardHeader({
})} })}
</nav> </nav>
<Link <Link
href="/materials" href="/"
className="hidden justify-self-end text-[13px] font-semibold text-[#6b7075] transition-colors hover:text-[#1a1c1d] sm:block" target="_top"
aria-label="返回创意资产库" className="hidden justify-self-end text-[13px] font-semibold text-white/90 transition-colors hover:text-white sm:block"
aria-label="返回品牌中心首页"
> >
创意资产库 返回品牌中心
</Link> </Link>
<Link <Link
href="/materials" href="/"
className="justify-self-end text-[12px] font-semibold text-[#6b7075] transition-colors hover:text-[#1a1c1d] sm:hidden" target="_top"
aria-label="返回创意资产库" className="justify-self-end text-[12px] font-semibold text-[#b3c5ff] transition-colors hover:text-white sm:hidden"
aria-label="返回品牌中心首页"
> >
返回 返回品牌中心
</Link> </Link>
</div> </div>
</header> </header>
@ -5831,7 +5805,8 @@ function VisualCaseSurvey({
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<Link <Link
href="/materials" href="/"
target="_top"
className="rounded-[12px] border border-[#dbe5f2] bg-white px-3 py-2.5 text-[13px] font-semibold text-[#101828]" className="rounded-[12px] border border-[#dbe5f2] bg-white px-3 py-2.5 text-[13px] font-semibold text-[#101828]"
> >
素材库 素材库
@ -6943,10 +6918,11 @@ function SurveyUnavailablePanel({
返回个人看板 返回个人看板
</Link> </Link>
<Link <Link
href="/materials" href="/"
target="_top"
className="inline-flex min-h-11 items-center justify-center rounded-md border border-[#dbe5f2] bg-white px-5 text-sm font-semibold text-[#101828]" className="inline-flex min-h-11 items-center justify-center rounded-md border border-[#dbe5f2] bg-white px-5 text-sm font-semibold text-[#101828]"
> >
返回素材库 返回品牌中心
</Link> </Link>
</div> </div>
</div> </div>

View File

@ -8,7 +8,7 @@ import Image from 'next/image'
import { format } from 'date-fns' import { format } from 'date-fns'
import gsap from 'gsap' import gsap from 'gsap'
import { CalendarClock, CheckCircle2, RefreshCw, ShieldCheck } from 'lucide-react' import { CalendarClock, CheckCircle2, RefreshCw, ShieldCheck } from 'lucide-react'
import MaterialsHeader from '@/components/board/MaterialsHeader' import Header from '@/components/Header'
import VideoPlayerModal from '@/components/VideoPlayerModal' import VideoPlayerModal from '@/components/VideoPlayerModal'
import SkuList from '@/components/SkuList' import SkuList from '@/components/SkuList'
import SkuEditModal from '@/components/SkuEditModal' import SkuEditModal from '@/components/SkuEditModal'
@ -281,9 +281,8 @@ function VideoShareWaitPanel({ checking, onCheckAgain, waitState }: VideoShareWa
</section> </section>
) )
} }
export default function SharePage() { export default function SharePage() {
const { data: session, status } = useSession() const { data: session } = useSession()
const router = useRouter() const router = useRouter()
const params = useParams() const params = useParams()
const materialId = params?.id as string const materialId = params?.id as string
@ -543,13 +542,13 @@ export default function SharePage() {
? parseApiError(errorPayload) ? parseApiError(errorPayload)
: await parseFetchError(response) : await parseFetchError(response)
showToast(error.message, { variant: 'error' }) showToast(error.message, { variant: 'error' })
router.push('/materials') router.push('/assets/material-library')
} }
} catch (error) { } catch (error) {
console.error('Error fetching material:', error) console.error('Error fetching material:', error)
const { message } = parseApiError(error) const { message } = parseApiError(error)
showToast(message, { variant: 'error' }) showToast(message, { variant: 'error' })
router.push('/materials') router.push('/assets/material-library')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -900,7 +899,7 @@ export default function SharePage() {
sessionStorage.removeItem('home_materials_cache') sessionStorage.removeItem('home_materials_cache')
showToast('素材已移至回收站', { variant: 'success' }) showToast('素材已移至回收站', { variant: 'success' })
window.setTimeout(() => router.replace('/materials'), 500) window.setTimeout(() => router.replace('/assets/material-library'), 500)
} finally { } finally {
setDetailActionBusy(null) setDetailActionBusy(null)
} }
@ -923,7 +922,7 @@ export default function SharePage() {
if (videoShareWait) { if (videoShareWait) {
return ( return (
<div className="min-h-screen bg-[#fafafa] text-[#1a1c1d]"> <div className="min-h-screen bg-[#fafafa] text-[#1a1c1d]">
<MaterialsHeader session={session} sectionLabel="创意资产库" /> <Header session={session} />
<main className="mx-auto flex min-h-[calc(100vh-5rem)] max-w-[1280px] items-start justify-center px-6 py-8 sm:items-center sm:py-12"> <main className="mx-auto flex min-h-[calc(100vh-5rem)] max-w-[1280px] items-start justify-center px-6 py-8 sm:items-center sm:py-12">
<VideoShareWaitPanel <VideoShareWaitPanel
waitState={videoShareWait} waitState={videoShareWait}
@ -934,11 +933,10 @@ export default function SharePage() {
</div> </div>
) )
} }
if (!material) { if (!material) {
return ( return (
<div className="min-h-screen bg-[#fafafa] text-[#1a1c1d]"> <div className="min-h-screen bg-[#fafafa] text-[#1a1c1d]">
<MaterialsHeader session={session} sectionLabel="创意资产库" /> <Header session={session} />
<main className="mx-auto max-w-[1280px] px-6 py-16"> <main className="mx-auto max-w-[1280px] px-6 py-16">
<div className="text-center"> <div className="text-center">
<p className="text-[15px] text-[#86898e]">素材不存在</p> <p className="text-[15px] text-[#86898e]">素材不存在</p>
@ -965,7 +963,7 @@ export default function SharePage() {
return ( return (
<div ref={pageRef} className="min-h-screen bg-[#fafafa] text-[#1a1c1d]"> <div ref={pageRef} className="min-h-screen bg-[#fafafa] text-[#1a1c1d]">
<MaterialsHeader session={session} sectionLabel="创意资产库" /> <Header session={session} />
<main className="mx-auto max-w-[1280px] px-6 py-6 sm:py-8"> <main className="mx-auto max-w-[1280px] px-6 py-6 sm:py-8">
<MaterialDetailHeader <MaterialDetailHeader
title={materialDisplayTitle || material.productModel || material.materialType} title={materialDisplayTitle || material.productModel || material.materialType}
@ -983,7 +981,7 @@ export default function SharePage() {
router.back() router.back()
return return
} }
router.replace('/materials') router.replace('/assets/material-library')
}} }}
onFavorite={() => void handleFavoriteMaterial()} onFavorite={() => void handleFavoriteMaterial()}
onShare={() => void handleShareMaterial()} onShare={() => void handleShareMaterial()}
@ -1575,4 +1573,3 @@ export default function SharePage() {
</div> </div>
) )
} }

View File

@ -0,0 +1,22 @@
'use client'
import Link from 'next/link'
import DepartmentShell from '@/components/department-dashboard/DepartmentShell'
export default function CoreMetricError({ reset }: { error: Error; reset: () => void }) {
return (
<DepartmentShell>
<main className="flex px-4 py-10 sm:px-6 lg:px-8">
<div className="mx-auto w-full max-w-xl rounded-xl border border-[#e2e8f0] bg-white p-6 shadow-[0_6px_18px_rgba(15,23,42,0.05)] sm:p-8">
<p className="text-sm font-bold text-[#b4231c]">指标明细暂时无法加载</p>
<h1 className="mt-3 text-2xl font-bold">数据读取出现异常</h1>
<p className="mt-3 text-sm leading-6 text-slate-600">请重新加载;若问题持续,可先返回核心指标总览。</p>
<div className="mt-6 flex flex-wrap gap-3">
<button onClick={reset} className="min-h-11 rounded-xl bg-[#247fd3] px-4 text-sm font-bold text-white transition-colors hover:bg-[#176fc1] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#78aee9]">重新加载</button>
<Link href="/workspaces/dashboard/core-metrics" className="inline-flex min-h-11 items-center rounded-xl bg-[#edf5ff] px-4 text-sm font-bold text-[#176fc1] transition-colors hover:bg-[#dcecff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#78aee9]">返回核心指标</Link>
</div>
</div>
</main>
</DepartmentShell>
)
}

View File

@ -0,0 +1,20 @@
import DepartmentShell from '@/components/department-dashboard/DepartmentShell'
export default function CoreMetricLoading() {
return (
<DepartmentShell>
<div className="animate-pulse px-4 text-slate-950 sm:px-6 lg:px-8" aria-label="正在加载指标明细" aria-busy="true">
<div className="mx-auto max-w-[1600px] py-7">
<div className="h-16 rounded-lg border border-[#e2e8f0] bg-white" />
<div className="mt-5 rounded-xl border border-[#e2e8f0] bg-white p-6 sm:p-8">
<div className="h-12 w-12 rounded-xl bg-[#e7eef7]" />
<div className="mt-6 h-12 max-w-sm rounded-xl bg-[#e7eef7]" />
<div className="mt-4 h-6 max-w-2xl rounded-xl bg-[#edf3fa]" />
</div>
<div className="mt-5 h-36 rounded-xl border border-[#e2e8f0] bg-white" />
<div className="mt-5 h-80 rounded-xl border border-[#e2e8f0] bg-white" />
</div>
</div>
</DepartmentShell>
)
}

View File

@ -0,0 +1,17 @@
import Link from 'next/link'
import DepartmentShell from '@/components/department-dashboard/DepartmentShell'
export default function CoreMetricNotFound() {
return (
<DepartmentShell>
<main className="flex px-4 py-10 sm:px-6 lg:px-8">
<div className="mx-auto w-full max-w-xl rounded-xl border border-[#e2e8f0] bg-white p-6 shadow-[0_6px_18px_rgba(15,23,42,0.05)] sm:p-8">
<p className="text-sm font-bold text-[#b4231c]">页面不存在</p>
<h1 className="mt-3 text-2xl font-bold">未找到这个核心指标</h1>
<p className="mt-3 text-sm leading-6 text-slate-600">请返回核心指标总览选择有效入口。</p>
<Link href="/workspaces/dashboard/core-metrics" className="mt-6 inline-flex min-h-11 items-center rounded-xl bg-[#247fd3] px-4 text-sm font-bold text-white transition-colors hover:bg-[#176fc1] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#78aee9]">返回核心指标</Link>
</div>
</main>
</DepartmentShell>
)
}

View File

@ -0,0 +1,48 @@
import type { Metadata } from 'next'
import { getServerSession } from 'next-auth'
import { notFound } from 'next/navigation'
import CoreMetricDetail from '@/components/department-dashboard/CoreMetricDetail'
import { getCoreMetric } from '@/components/department-dashboard/core-metrics'
import { authOptions } from '@/lib/auth'
import {
getFirstPassReviewProjects,
getOngoingWorkReviewProjects,
} from '@/lib/work-review-service'
type CoreMetricPageProps = {
params: { section: string; item: string; metric: string }
searchParams?: { page?: string; q?: string }
}
export const dynamic = 'force-dynamic'
export function generateMetadata({ params }: CoreMetricPageProps): Metadata {
const metric = getCoreMetric(params.metric)
if (params.section !== 'dashboard' || params.item !== 'core-metrics' || !metric) return {}
return {
title: `${metric.label} | 核心指标 | 品牌中心部门看板`,
description: metric.detailDescription,
}
}
export default async function CoreMetricPage({ params, searchParams }: CoreMetricPageProps) {
const metric = getCoreMetric(params.metric)
if (params.section !== 'dashboard' || params.item !== 'core-metrics' || !metric) notFound()
const page = Math.max(1, Number(searchParams?.page || 1) || 1)
const query = String(searchParams?.q || '').trim().slice(0, 100)
if (metric.key === 'ongoing-projects') {
const session = await getServerSession(authOptions)
const projectPage = await getOngoingWorkReviewProjects(session, { page, pageSize: 20, query })
return <CoreMetricDetail metric={metric} query={query} projectPage={projectPage} />
}
if (metric.key === 'first-pass-rate') {
const session = await getServerSession(authOptions)
const firstPassPage = await getFirstPassReviewProjects(session, { page, pageSize: 20, query })
return <CoreMetricDetail metric={metric} query={query} firstPassPage={firstPassPage} />
}
return <CoreMetricDetail metric={metric} />
}

View File

@ -0,0 +1,81 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import AutomationWorkflowsDashboard from '@/components/department-dashboard/AutomationWorkflowsDashboard'
import BrandHealthDashboard from '@/components/department-dashboard/BrandHealthDashboard'
import BusinessTrendsDashboard from '@/components/department-dashboard/BusinessTrendsDashboard'
import CoreMetricsDashboard from '@/components/department-dashboard/CoreMetricsDashboard'
import WorkspacePlaceholder from '@/components/department-dashboard/WorkspacePlaceholder'
import {
getFrameworkWorkspaceParams,
getWorkspaceItem,
} from '@/components/department-dashboard/workspace-navigation'
type WorkspaceItemPageProps = {
params: {
section: string
item: string
}
}
export function generateStaticParams() {
return getFrameworkWorkspaceParams()
}
export function generateMetadata({ params }: WorkspaceItemPageProps): Metadata {
const workspace = getWorkspaceItem(params.section, params.item)
if (!workspace) return {}
if (workspace.section.key === 'assets' && workspace.item.key === 'automation-workflows') {
return {
title: '自动化工作流 | 资产与AI自动化 | 品牌中心部门看板',
description: '集中进入绩效考核调研与作品过稿看板。',
}
}
if (workspace.item.status !== 'framework') return {}
return {
title: `${workspace.item.title} | ${workspace.section.title} | 品牌中心部门看板`,
description: workspace.section.key === 'dashboard' && workspace.item.key === 'brand-health'
? '六个品牌的健康度评分维度与权重框架。'
: workspace.section.key === 'dashboard' && workspace.item.key === 'business-trends'
? '六个品牌的近八周计划、实际完成量、项目结构与达成率框架。'
: `${workspace.item.title}二级页面框架。`,
}
}
export default function WorkspaceItemPage({ params }: WorkspaceItemPageProps) {
const workspace = getWorkspaceItem(params.section, params.item)
if (!workspace) {
notFound()
}
if (workspace.section.key === 'assets' && workspace.item.key === 'automation-workflows') {
return <AutomationWorkflowsDashboard />
}
if (workspace.item.status !== 'framework') notFound()
if (workspace.section.key === 'dashboard' && workspace.item.key === 'core-metrics') {
return <CoreMetricsDashboard />
}
if (workspace.section.key === 'dashboard' && workspace.item.key === 'brand-health') {
return <BrandHealthDashboard />
}
if (workspace.section.key === 'dashboard' && workspace.item.key === 'business-trends') {
return <BusinessTrendsDashboard />
}
return (
<WorkspacePlaceholder
title={workspace.item.title}
description={`“${workspace.item.title}”属于“${workspace.section.title}”板块,当前先建立独立二级页面框架。`}
accent={workspace.section.accent}
accentSoft={workspace.section.accentSoft}
accentText={workspace.section.accentText}
/>
)
}

View File

@ -5,20 +5,21 @@ import Link from 'next/link'
import { signOut } from 'next-auth/react' import { signOut } from 'next-auth/react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Session } from 'next-auth' import { Session } from 'next-auth'
import { useState, useRef, useEffect, type MouseEvent as ReactMouseEvent } from 'react' import { useState, useRef, useEffect } from 'react'
import { initializeNetwork, setupNetworkChangeDetection, addNetworkChangeListener } from '@/lib/network' import { initializeNetwork, setupNetworkChangeDetection, addNetworkChangeListener } from '@/lib/network'
import UpdateLogModal, { useUpdateLog, showUpdateLog } from '@/components/UpdateLogModal' import UpdateLogModal, { useUpdateLog, showUpdateLog } from '@/components/UpdateLogModal'
import MaintenanceBanner from '@/components/MaintenanceBanner' import MaintenanceBanner from '@/components/MaintenanceBanner'
import HeaderTaobaoSearch from '@/components/HeaderTaobaoSearch' import HeaderTaobaoSearch from '@/components/HeaderTaobaoSearch'
import BrandLogo from '@/components/BrandLogo' import { formatHumanSpeed, formatTransferProgressSummary } from '@/lib/human-readable-transfer'
import SystemPerformanceCard, { type SystemPerformanceData } from '@/components/board/SystemPerformanceCard'
import { DEFAULT_HEADER_LOGO_URL } from '@/lib/brand-assets'
interface HeaderProps { interface HeaderProps {
session: Session | null session: Session | null
appearance?: 'default' | 'light'
} }
export default function Header({ session }: HeaderProps) { const DEFAULT_HEADER_LOGO_URL = '/api/favicon'
export default function Header({ session, appearance = 'default' }: HeaderProps) {
const router = useRouter() const router = useRouter()
const role = session ? String((session.user as any)?.role || '') : '' const role = session ? String((session.user as any)?.role || '') : ''
const isAdmin = ['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN'].includes(role) const isAdmin = ['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN'].includes(role)
@ -34,7 +35,13 @@ export default function Header({ session }: HeaderProps) {
const [showPerfCard, setShowPerfCard] = useState(false) const [showPerfCard, setShowPerfCard] = useState(false)
const [perfLoading, setPerfLoading] = useState(false) const [perfLoading, setPerfLoading] = useState(false)
const [perfError, setPerfError] = useState<string | null>(null) const [perfError, setPerfError] = useState<string | null>(null)
const [perfData, setPerfData] = useState<SystemPerformanceData | null>(null) const [perfData, setPerfData] = useState<{
cpu?: { usage: number; loadAverage: number[] }
memory?: { used: number; total: number; percentage: number }
network?: { uploadSpeed: number; downloadSpeed: number }
uploadQueue?: { total: number; processing: any[]; waiting: any[] }
transcodeQueue?: { total: number; maxConcurrent: number; processing: any[]; waiting: any[] }
} | null>(null)
const perfHoverTimerRef = useRef<number | null>(null) const perfHoverTimerRef = useRef<number | null>(null)
const perfHideTimerRef = useRef<number | null>(null) const perfHideTimerRef = useRef<number | null>(null)
const perfIntervalRef = useRef<number | null>(null) const perfIntervalRef = useRef<number | null>(null)
@ -46,6 +53,7 @@ export default function Header({ session }: HeaderProps) {
const [headerBgColor, setHeaderBgColor] = useState('#1f2937') const [headerBgColor, setHeaderBgColor] = useState('#1f2937')
const [headerLogoUrl, setHeaderLogoUrl] = useState(DEFAULT_HEADER_LOGO_URL) const [headerLogoUrl, setHeaderLogoUrl] = useState(DEFAULT_HEADER_LOGO_URL)
const [siteName, setSiteName] = useState('由壹点3D素材') const [siteName, setSiteName] = useState('由壹点3D素材')
const isLight = appearance === 'light'
// 加载系统设置 // 加载系统设置
useEffect(() => { useEffect(() => {
@ -136,9 +144,9 @@ export default function Header({ session }: HeaderProps) {
return return
} }
const fetchPerf = async (isInitial: boolean) => { const fetchPerf = async () => {
try { try {
if (isInitial) setPerfLoading(true) setPerfLoading(true)
setPerfError(null) setPerfError(null)
const res = await fetch('/api/performance/quick') const res = await fetch('/api/performance/quick')
if (!res.ok) { if (!res.ok) {
@ -148,16 +156,16 @@ export default function Header({ session }: HeaderProps) {
setPerfData(data) setPerfData(data)
} catch (err) { } catch (err) {
console.error('加载性能数据失败:', err) console.error('加载性能数据失败:', err)
setPerfError('刚才没读到') setPerfError('加载失败')
} finally { } finally {
setPerfLoading(false) setPerfLoading(false)
} }
} }
void fetchPerf(true) // 立即拉一次
perfIntervalRef.current = window.setInterval(() => { fetchPerf()
void fetchPerf(false) // 每 2 秒轮询一次
}, 2000) perfIntervalRef.current = window.setInterval(fetchPerf, 2000)
return () => { return () => {
clearIntervalSafe() clearIntervalSafe()
@ -204,9 +212,14 @@ export default function Header({ session }: HeaderProps) {
return name?.charAt(0).toUpperCase() || 'U' return name?.charAt(0).toUpperCase() || 'U'
} }
const handleLogoHomeClick = (event: ReactMouseEvent<HTMLAnchorElement>) => { const formatAgo = (ms: number): string => {
event.preventDefault() if (!Number.isFinite(ms) || ms < 0) return '--'
window.location.assign('/') const sec = Math.floor(ms / 1000)
if (sec < 60) return `${sec}s`
const min = Math.floor(sec / 60)
if (min < 60) return `${min}min`
const hr = Math.floor(min / 60)
return `${hr}h${min % 60}min`
} }
return ( return (
@ -216,24 +229,26 @@ export default function Header({ session }: HeaderProps) {
{/* 网络切换横幅已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转 */} {/* 网络切换横幅已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转 */}
<header <header
className="sticky top-0 z-50 shadow-sm" data-appearance={appearance}
style={{ backgroundColor: headerBgColor }} className={isLight
? 'sticky top-0 z-50 mx-3 rounded-b-[26px] bg-white text-[#101828] shadow-[0_12px_34px_rgba(74,103,140,0.09)]'
: 'sticky top-0 z-50 shadow-sm'}
style={isLight ? undefined : { backgroundColor: headerBgColor }}
> >
<div className="container mx-auto max-w-7xl px-3 py-3 sm:px-4 sm:py-4"> <div className="container mx-auto max-w-7xl px-3 py-3 sm:px-4 sm:py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{/* Logo */} {/* Logo */}
<Link href="/" onClick={handleLogoHomeClick} className="flex min-w-0 items-center space-x-2 sm:space-x-3"> <Link href="/assets/material-library" className="flex min-w-0 items-center space-x-2 sm:space-x-3">
<BrandLogo <Image
src={headerLogoUrl} src={headerLogoUrl}
fallbackSrc={DEFAULT_HEADER_LOGO_URL} alt={siteName}
alt=""
width={36} width={36}
height={36} height={36}
unoptimized unoptimized
className="h-9 w-9 rounded-lg sm:h-10 sm:w-10" className="h-9 w-9 rounded-lg sm:h-10 sm:w-10"
/> />
<span className="max-w-[11rem] truncate text-base font-semibold text-white sm:max-w-none sm:text-2xl"> <span className={`max-w-[11rem] truncate text-base font-semibold sm:max-w-none sm:text-2xl ${isLight ? 'text-[#101828]' : 'text-white'}`}>
{siteName} {siteName}
</span> </span>
</Link> </Link>
@ -255,9 +270,13 @@ export default function Header({ session }: HeaderProps) {
> >
<div <div
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${
isInternalNetwork isLight
? 'bg-green-500/10 text-green-400 border border-green-500/20' ? isInternalNetwork
: 'bg-blue-500/10 text-blue-400 border border-blue-500/20' ? 'border border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border border-blue-200 bg-blue-50 text-blue-700'
: isInternalNetwork
? 'bg-green-500/10 text-green-400 border border-green-500/20'
: 'bg-blue-500/10 text-blue-400 border border-blue-500/20'
}`} }`}
> >
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
@ -266,8 +285,201 @@ export default function Header({ session }: HeaderProps) {
<span>{isInternalNetwork ? '内网' : '公网'}</span> <span>{isInternalNetwork ? '内网' : '公网'}</span>
</div> </div>
{/* 性能监控悬停卡片(优化版) */}
{canViewPerformance && showPerfCard && ( {canViewPerformance && showPerfCard && (
<SystemPerformanceCard data={perfData} loading={perfLoading} error={perfError} /> <div className="absolute right-0 mt-2 w-96 bg-gray-900 text-gray-100 rounded-lg shadow-xl border border-gray-700 p-4 z-50">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold text-gray-300">系统性能</span>
{perfLoading && (
<span className="text-xs text-gray-400">加载中...</span>
)}
{perfError && !perfLoading && (
<span className="text-xs text-red-400">{perfError}</span>
)}
</div>
{/* CPU / 内存 / 网络 */}
<div className="space-y-2 mb-3">
{/* CPU */}
<div>
<div className="flex justify-between text-sm text-gray-300 mb-1">
<span>CPU</span>
<span>{perfData?.cpu?.usage ?? '--'}%</span>
</div>
<div className="w-full h-2 rounded-full bg-gray-800 overflow-hidden">
<div
className="h-full rounded-full bg-emerald-500"
style={{ width: `${Math.min(Math.max(perfData?.cpu?.usage ?? 0, 0), 100)}%` }}
/>
</div>
<div className="flex justify-between text-xs text-gray-500 mt-0.5">
<span>1/5/15 分钟负载</span>
<span>
{(perfData?.cpu?.loadAverage || []).map(v => v.toFixed(2)).join(' / ') || '--'}
</span>
</div>
</div>
{/* 内存 */}
<div>
<div className="flex justify-between text-sm text-gray-300 mb-1">
<span>内存</span>
<span>
{perfData?.memory
? `${perfData.memory.used} / ${perfData.memory.total} MB (${perfData.memory.percentage}%)`
: '--'}
</span>
</div>
<div className="w-full h-2 rounded-full bg-gray-800 overflow-hidden">
<div
className="h-full rounded-full bg-sky-500"
style={{
width: `${Math.min(Math.max(perfData?.memory?.percentage ?? 0, 0), 100)}%`,
}}
/>
</div>
</div>
{/* 服务器整体网络流量 */}
<div>
<div className="flex justify-between text-sm text-gray-300 mb-1">
<span>服务器网络</span>
<span className="text-xs text-gray-500">全站实时</span>
</div>
<div className="space-y-1">
<div className="flex justify-between text-xs text-gray-400">
<span>入站</span>
<span>
{perfData?.network?.uploadSpeed
? formatHumanSpeed(perfData.network.uploadSpeed)
: '--'}
</span>
</div>
<div className="flex justify-between text-xs text-gray-400">
<span>出站</span>
<span>
{perfData?.network?.downloadSpeed
? formatHumanSpeed(perfData.network.downloadSpeed)
: '--'}
</span>
</div>
<div className="text-[11px] text-gray-500">
服务器整体流量,非当前浏览器测速
</div>
</div>
</div>
</div>
{/* 上传队列 / 转码队列 详细信息 */}
<div className="border-t border-gray-800 pt-3 mt-2 space-y-3">
{/* 上传队列 */}
<div>
<div className="flex justify-between text-sm text-gray-300 mb-2">
<span>上传队列</span>
<span>
{perfData?.uploadQueue
? `处理中 ${perfData.uploadQueue.processing?.length || 0} · 等待 ${perfData.uploadQueue.waiting?.length || 0}`
: '--'}
</span>
</div>
{perfData?.uploadQueue?.processing && perfData.uploadQueue.processing.length > 0 && (
<div className="space-y-1.5 max-h-32 overflow-y-auto">
{perfData.uploadQueue.processing.map((task: any, idx: number) => {
const uploadedBytes =
task.uploadedBytes || ((task.fileSize || 0) * ((task.progress || 0) / 100))
const speedText = task.currentSpeed
? formatTransferProgressSummary({
speedBytesPerSecond: task.currentSpeed,
loadedBytes: uploadedBytes,
totalBytes: task.fileSize,
})
: '速度: --'
return (
<div key={task.uploadId || idx} className="text-xs text-gray-400 bg-gray-800/50 rounded px-2.5 py-1.5">
<div className="truncate font-medium">{task.fileName || '未知文件'}</div>
<div className="flex justify-between mt-1 text-xs">
<span>{task.username || '未知用户'}</span>
<span>{task.materialType || '未知类型'}</span>
</div>
<div className="mt-1 text-xs">
<div>进度: {task.progress ?? 0}%</div>
<div>{speedText}</div>
</div>
{task.isActive === false && (
<div className="text-xs text-amber-400 mt-1">
无活动: {formatAgo(task.inactiveMs)}
</div>
)}
</div>
)
})}
</div>
)}
{perfData?.uploadQueue?.waiting && perfData.uploadQueue.waiting.length > 0 && (
<div className="space-y-1.5 max-h-32 overflow-y-auto mt-2">
{perfData.uploadQueue.waiting.map((task: any, idx: number) => (
<div key={task.uploadId || idx} className="text-xs text-gray-500 bg-gray-800/30 rounded px-2.5 py-1.5">
<div className="truncate font-medium">{task.fileName || '未知文件'}</div>
<div className="flex justify-between mt-1 text-xs">
<span>{task.username || '未知用户'}</span>
<span>{task.materialType || '未知类型'}</span>
</div>
<div className="text-xs text-gray-500 mt-1">等待中</div>
</div>
))}
</div>
)}
</div>
{/* 转码队列 */}
<div>
<div className="flex justify-between text-sm text-gray-300 mb-2">
<span>转码队列</span>
<span>
{perfData?.transcodeQueue
? `处理中 ${perfData.transcodeQueue.processing?.length || 0} / ${perfData.transcodeQueue.maxConcurrent || 0} · 等待 ${perfData.transcodeQueue.waiting?.length || 0}`
: '--'}
</span>
</div>
{perfData?.transcodeQueue?.processing && perfData.transcodeQueue.processing.length > 0 && (
<div className="space-y-1.5 max-h-32 overflow-y-auto">
{perfData.transcodeQueue.processing.map((task: any, idx: number) => {
const stageMap: Record<string, string> = {
preview: '预览视频',
clip: '片段生成',
cover: '封面提取',
}
return (
<div key={task.materialId || idx} className="text-xs text-gray-400 bg-gray-800/50 rounded px-2.5 py-1.5">
<div className="truncate font-medium">{task.fileName || '未知文件'}</div>
<div className="flex justify-between mt-1 text-xs">
<span>{task.materialType || '未知类型'} · {task.stage ? stageMap[task.stage] || task.stage : '处理中'}</span>
<span>{task.progress ?? 0}%</span>
</div>
<div className="text-xs text-gray-500 mt-1">上传人: {task.authorName || '未知用户'}</div>
</div>
)
})}
</div>
)}
{perfData?.transcodeQueue?.waiting && perfData.transcodeQueue.waiting.length > 0 && (
<div className="space-y-1.5 max-h-32 overflow-y-auto mt-2">
{perfData.transcodeQueue.waiting.map((task: any, idx: number) => (
<div key={task.materialId || idx} className="text-xs text-gray-500 bg-gray-800/30 rounded px-2.5 py-1.5">
<div className="truncate font-medium">{task.fileName || '未知文件'}</div>
<div className="flex justify-between mt-1 text-xs">
<span>{task.materialType || '未知类型'}</span>
<span>队列 #{task.queuePosition || idx + 1}</span>
</div>
<div className="text-xs text-gray-500 mt-1">上传人: {task.authorName || '未知用户'}</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)} )}
</div> </div>
)} )}
@ -279,7 +491,7 @@ export default function Header({ session }: HeaderProps) {
onClick={() => setShowDropdown(!showDropdown)} onClick={() => setShowDropdown(!showDropdown)}
aria-label="打开用户菜单" aria-label="打开用户菜单"
aria-expanded={showDropdown} aria-expanded={showDropdown}
className="flex items-center p-1.5 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-900 transition-all" className={`flex items-center rounded-lg p-1.5 transition-all ${isLight ? 'hover:bg-[#f1f6fc]' : 'hover:bg-gray-800 dark:hover:bg-gray-900'}`}
> >
{/* 圆形头像 */} {/* 圆形头像 */}
{(session.user as any)?.avatar ? ( {(session.user as any)?.avatar ? (
@ -292,7 +504,7 @@ export default function Header({ session }: HeaderProps) {
unoptimized unoptimized
/> />
) : ( ) : (
<div className="w-8 h-8 rounded-full bg-white dark:bg-gray-300 border-2 border-gray-700 dark:border-gray-600 flex items-center justify-center text-gray-900 dark:text-gray-800 font-semibold text-base shadow-lg hover:border-gray-500 dark:hover:border-gray-400 transition-all"> <div className={`flex h-8 w-8 items-center justify-center rounded-full border-2 bg-white text-base font-semibold text-gray-900 transition-all ${isLight ? 'border-[#dce7f3] shadow-[0_4px_12px_rgba(67,93,126,0.08)] hover:border-[#9ec3ec]' : 'border-gray-700 shadow-lg hover:border-gray-500 dark:border-gray-600 dark:bg-gray-300 dark:text-gray-800 dark:hover:border-gray-400'}`}>
{getInitials(session.user?.name || '')} {getInitials(session.user?.name || '')}
</div> </div>
)} )}
@ -319,10 +531,10 @@ export default function Header({ session }: HeaderProps) {
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg> </svg>
<span>我的档案</span> <span>个人中心</span>
</Link> </Link>
{/* 绩效考核 */} {/* 考核调研 */}
<Link <Link
href="/performance-survey" href="/performance-survey"
onClick={() => setShowDropdown(false)} onClick={() => setShowDropdown(false)}
@ -400,7 +612,7 @@ export default function Header({ session }: HeaderProps) {
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/> />
</svg> </svg>
<span>治理与设置</span> <span>系统设置</span>
</Link> </Link>
)} )}

View File

@ -18,8 +18,14 @@ interface Position {
y: number y: number
} }
export default function RankingPanel() { type RankingPanelProps = {
const [isVisible, setIsVisible] = useState(true) appearance?: 'dark' | 'light'
safeLeft?: number
dockRight?: boolean
}
export default function RankingPanel({ appearance = 'dark', safeLeft = 0, dockRight = false }: RankingPanelProps) {
const [isVisible, setIsVisible] = useState(!dockRight)
const [currentTab, setCurrentTab] = useState<'contributors' | 'downloads' | 'likes'>('contributors') const [currentTab, setCurrentTab] = useState<'contributors' | 'downloads' | 'likes'>('contributors')
const [rankings, setRankings] = useState<RankingData>({ const [rankings, setRankings] = useState<RankingData>({
contributors: [], contributors: [],
@ -42,9 +48,14 @@ export default function RankingPanel() {
const [isButtonDragging, setIsButtonDragging] = useState(false) const [isButtonDragging, setIsButtonDragging] = useState(false)
const [buttonDragOffset, setButtonDragOffset] = useState<Position>({ x: 0, y: 0 }) const [buttonDragOffset, setButtonDragOffset] = useState<Position>({ x: 0, y: 0 })
const clickStartRef = useRef<{ x: number; y: number; time: number } | null>(null) const clickStartRef = useRef<{ x: number; y: number; time: number } | null>(null)
const isLight = appearance === 'light'
// 从localStorage恢复状态 // 从localStorage恢复状态
useEffect(() => { useEffect(() => {
if (dockRight) {
setIsVisible(false)
return
}
try { try {
const saved = localStorage.getItem('ranking_panel_state') const saved = localStorage.getItem('ranking_panel_state')
if (saved) { if (saved) {
@ -65,7 +76,13 @@ export default function RankingPanel() {
console.error('恢复榜单面板状态失败:', error) console.error('恢复榜单面板状态失败:', error)
setIsVisible(false) setIsVisible(false)
} }
}, []) }, [dockRight])
useEffect(() => {
const minimumX = window.innerWidth >= 1024 ? safeLeft : 0
setPosition((current) => current.x < minimumX ? { ...current, x: minimumX + 16 } : current)
setButtonPosition((current) => current.x < minimumX ? { ...current, x: minimumX + 16 } : current)
}, [safeLeft])
// 保存状态到localStorage // 保存状态到localStorage
const saveState = () => { const saveState = () => {
@ -142,7 +159,7 @@ export default function RankingPanel() {
const maxY = window.innerHeight - 400 const maxY = window.innerHeight - 400
setPosition({ setPosition({
x: Math.max(0, Math.min(newX, maxX)), x: Math.max(window.innerWidth >= 1024 ? safeLeft : 0, Math.min(newX, maxX)),
y: Math.max(0, Math.min(newY, maxY)) y: Math.max(0, Math.min(newY, maxY))
}) })
} }
@ -161,7 +178,7 @@ export default function RankingPanel() {
document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp) document.removeEventListener('mouseup', handleMouseUp)
} }
}, [isDragging, dragOffset]) }, [isDragging, dragOffset, safeLeft])
// 缩略按钮拖动处理 // 缩略按钮拖动处理
const handleButtonMouseDown = (e: React.MouseEvent) => { const handleButtonMouseDown = (e: React.MouseEvent) => {
@ -192,7 +209,7 @@ export default function RankingPanel() {
const maxY = window.innerHeight - 48 const maxY = window.innerHeight - 48
setButtonPosition({ setButtonPosition({
x: Math.max(0, Math.min(newX, maxX)), x: Math.max(window.innerWidth >= 1024 ? safeLeft : 0, Math.min(newX, maxX)),
y: Math.max(0, Math.min(newY, maxY)) y: Math.max(0, Math.min(newY, maxY))
}) })
} }
@ -211,7 +228,7 @@ export default function RankingPanel() {
document.removeEventListener('mousemove', handleButtonMouseMove) document.removeEventListener('mousemove', handleButtonMouseMove)
document.removeEventListener('mouseup', handleButtonMouseUp) document.removeEventListener('mouseup', handleButtonMouseUp)
} }
}, [isButtonDragging, buttonDragOffset]) }, [isButtonDragging, buttonDragOffset, safeLeft])
const handleClose = () => { const handleClose = () => {
setIsVisible(false) setIsVisible(false)
@ -290,11 +307,12 @@ export default function RankingPanel() {
clickStartRef.current = null clickStartRef.current = null
} }
}} }}
className="fixed z-40 flex h-12 w-12 items-center justify-center rounded-full border border-[#e7e8ea] bg-white shadow-[0_12px_32px_-12px_rgba(26,28,29,0.28)] transition-transform hover:scale-110" className={`fixed z-40 flex h-12 w-12 items-center justify-center rounded-full transition-transform hover:scale-110 ${dockRight ? 'bottom-24 right-6' : ''} ${isLight ? 'bg-white shadow-[0_10px_28px_rgba(67,93,126,0.16)]' : 'border border-gray-700 bg-gray-900 shadow-2xl dark:border-gray-800 dark:bg-gray-950'}`}
style={{ left: `${buttonPosition.x}px`, top: `${buttonPosition.y}px`, cursor: isButtonDragging ? 'grabbing' : 'grab' }} style={dockRight ? undefined : { left: `${buttonPosition.x}px`, top: `${buttonPosition.y}px`, cursor: isButtonDragging ? 'grabbing' : 'grab' }}
title="打开榜单面板(可拖动)" title="打开榜单面板(可拖动)"
aria-label="打开榜单面板"
> >
<svg className="w-6 h-6 text-yellow-400 pointer-events-none" fill="currentColor" viewBox="0 0 20 20"> <svg className={`pointer-events-none h-6 w-6 ${isLight ? 'text-[#247fd3]' : 'text-yellow-400'}`} fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /> <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg> </svg>
</button> </button>
@ -304,15 +322,15 @@ export default function RankingPanel() {
return ( return (
<div <div
ref={panelRef} ref={panelRef}
className="fixed z-40 h-[400px] w-[150px] overflow-hidden rounded-xl border border-[#e7e8ea] bg-white shadow-[0_24px_60px_-24px_rgba(26,28,29,0.3)]" className={`fixed z-40 h-[400px] w-[150px] overflow-hidden rounded-2xl ${dockRight ? 'right-6 top-24' : ''} ${isLight ? 'bg-white text-[#101828] shadow-[0_14px_38px_rgba(67,93,126,0.16)]' : 'border border-gray-700 bg-gray-900 shadow-2xl backdrop-blur-sm dark:border-gray-800 dark:bg-gray-950'}`}
style={{ left: `${position.x}px`, top: `${position.y}px`, cursor: isDragging ? 'grabbing' : 'grab' }} style={dockRight ? undefined : { left: `${position.x}px`, top: `${position.y}px`, cursor: isDragging ? 'grabbing' : 'grab' }}
> >
{/* 拖拽区域 */} {/* 拖拽区域 */}
<div <div
onMouseDown={handleMouseDown} onMouseDown={dockRight ? undefined : handleMouseDown}
className="cursor-grab border-b border-[#e7e8ea] bg-[#fafafa] px-3 py-3 text-center active:cursor-grabbing" className={`${dockRight ? '' : 'cursor-grab active:cursor-grabbing'} border-b px-3 py-3 text-center ${isLight ? 'border-[#e2e9f2] bg-[#f1f6fc]' : 'border-gray-700 bg-gradient-to-r from-gray-800 to-gray-900'}`}
> >
<h3 className="text-sm font-semibold leading-tight text-[#1a1c1d]"> <h3 className={`text-sm font-bold leading-tight ${isLight ? 'text-[#1b2738]' : 'text-white'}`}>
{getTabTitle()} {getTabTitle()}
</h3> </h3>
</div> </div>
@ -320,32 +338,38 @@ export default function RankingPanel() {
{/* 关闭按钮 */} {/* 关闭按钮 */}
<button <button
onClick={handleClose} onClick={handleClose}
className="absolute top-1.5 right-1.5 z-10 flex h-4 w-4 items-center justify-center rounded-full border border-[#e7e8ea] bg-white transition-colors hover:bg-[#e7e8ea]" className={`absolute right-1.5 top-1.5 z-10 flex h-4 w-4 items-center justify-center rounded-full transition-colors ${isLight ? 'bg-white hover:bg-[#e5eef8]' : 'bg-gray-800 hover:bg-gray-700'}`}
aria-label="关闭" aria-label="关闭"
> >
<svg className="h-2 w-2 text-[#6b7075]" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={`h-2 w-2 ${isLight ? 'text-[#66758b]' : 'text-gray-400 hover:text-white'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
{/* 指示器 */} {/* 指示器 */}
<div className="flex justify-center gap-1 border-b border-[#e7e8ea] bg-[#fafafa] py-2"> <div className={`flex justify-center gap-1 border-b py-2 ${isLight ? 'border-[#e2e9f2] bg-[#f8fafd]' : 'border-gray-700 bg-gray-800'}`}>
<button <button
onClick={() => setCurrentTab('contributors')} onClick={() => setCurrentTab('contributors')}
className={`h-1.5 w-1.5 rounded-full transition-all ${ aria-label="查看贡献榜"
currentTab === 'contributors' ? 'w-4 bg-[#1a1c1d]' : 'bg-[#d4d6da]' aria-pressed={currentTab === 'contributors'}
className={`w-1.5 h-1.5 rounded-full transition-all ${
currentTab === 'contributors' ? `${isLight ? 'bg-[#247fd3]' : 'bg-white'} w-4` : `${isLight ? 'bg-[#c7d8eb]' : 'bg-gray-600'}`
}`} }`}
/> />
<button <button
onClick={() => setCurrentTab('downloads')} onClick={() => setCurrentTab('downloads')}
className={`h-1.5 w-1.5 rounded-full transition-all ${ aria-label="查看下载榜"
currentTab === 'downloads' ? 'w-4 bg-[#1a1c1d]' : 'bg-[#d4d6da]' aria-pressed={currentTab === 'downloads'}
className={`w-1.5 h-1.5 rounded-full transition-all ${
currentTab === 'downloads' ? `${isLight ? 'bg-[#247fd3]' : 'bg-white'} w-4` : `${isLight ? 'bg-[#c7d8eb]' : 'bg-gray-600'}`
}`} }`}
/> />
<button <button
onClick={() => setCurrentTab('likes')} onClick={() => setCurrentTab('likes')}
className={`h-1.5 w-1.5 rounded-full transition-all ${ aria-label="查看点赞榜"
currentTab === 'likes' ? 'w-4 bg-[#1a1c1d]' : 'bg-[#d4d6da]' aria-pressed={currentTab === 'likes'}
className={`w-1.5 h-1.5 rounded-full transition-all ${
currentTab === 'likes' ? `${isLight ? 'bg-[#247fd3]' : 'bg-white'} w-4` : `${isLight ? 'bg-[#c7d8eb]' : 'bg-gray-600'}`
}`} }`}
/> />
</div> </div>
@ -354,28 +378,32 @@ export default function RankingPanel() {
<div className="p-3 overflow-y-auto" style={{ height: 'calc(100% - 80px)' }}> <div className="p-3 overflow-y-auto" style={{ height: 'calc(100% - 80px)' }}>
{loading ? ( {loading ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<div className="text-xs text-[#86898e]">加载中...</div> <div className={`text-xs ${isLight ? 'text-[#738198]' : 'text-gray-400'}`}>加载中...</div>
</div> </div>
) : getCurrentData().length === 0 ? ( ) : getCurrentData().length === 0 ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<div className="text-xs text-center text-[#86898e]">暂无数据</div> <div className={`text-center text-xs ${isLight ? 'text-[#738198]' : 'text-gray-500'}`}>暂无数据</div>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{getCurrentData().map((user, index) => ( {getCurrentData().map((user, index) => (
<div <div
key={index} key={index}
className={`flex items-center gap-2 rounded-lg border p-2 transition-all ${ className={`flex items-center gap-2 p-2 rounded-lg transition-all ${
index < 3 index < 3
? 'border-[#f0dfb8] bg-[#fdf6e9]' ? isLight
: 'border-[#e7e8ea] bg-[#fafafa]' ? 'bg-amber-50'
: 'bg-gradient-to-r from-yellow-900/30 to-orange-900/30 border border-yellow-600/30'
: isLight
? 'bg-[#f7f9fc]'
: 'bg-gray-800/50 border border-gray-700'
}`} }`}
> >
<div className="flex-shrink-0 w-6 flex items-center justify-center"> <div className="flex-shrink-0 w-6 flex items-center justify-center">
{index < 3 ? ( {index < 3 ? (
getMedalIcon(index) getMedalIcon(index)
) : ( ) : (
<span className="text-xs font-medium text-[#86898e]"> <span className="text-xs font-medium text-gray-400">
{index + 1} {index + 1}
</span> </span>
)} )}
@ -383,7 +411,7 @@ export default function RankingPanel() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div <div
className={`text-xs font-medium truncate ${ className={`text-xs font-medium truncate ${
index < 3 ? 'text-[#a0671c]' : 'text-[#1a1c1d]' isLight ? 'text-[#1b2738]' : index < 3 ? 'text-yellow-400' : 'text-gray-200'
}`} }`}
> >
{user.username} {user.username}
@ -391,7 +419,7 @@ export default function RankingPanel() {
</div> </div>
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<span className={`text-xs font-bold ${ <span className={`text-xs font-bold ${
index < 3 ? 'text-[#a0671c]' : 'text-[#6b7075]' isLight ? 'text-[#176fc1]' : index < 3 ? 'text-yellow-400' : 'text-gray-400'
}`}> }`}>
{user.count} {user.count}
</span> </span>

View File

@ -0,0 +1,41 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import AutomationWorkflowsDashboard from './AutomationWorkflowsDashboard'
const replace = jest.fn()
jest.mock('next-auth/react', () => ({
useSession: () => ({ data: { user: { name: '本地超级管理员' } } }),
signOut: jest.fn(),
}))
jest.mock('next/navigation', () => ({
useRouter: () => ({ replace }),
useSearchParams: () => new URLSearchParams(),
}))
describe('AutomationWorkflowsDashboard', () => {
beforeEach(() => replace.mockClear())
it('通过二级边栏切换右侧已有业务操作界面', () => {
render(<AutomationWorkflowsDashboard />)
expect(screen.queryByText('2 项业务已接入')).not.toBeInTheDocument()
const secondaryNavigation = screen.getByRole('navigation', { name: '自动化工作流二级导航' })
const performanceSurvey = within(secondaryNavigation).getByRole('button', { name: /绩效考核调研/ })
const workReview = within(secondaryNavigation).getByRole('button', { name: /作品过稿看板/ })
expect(performanceSurvey).toHaveAttribute('aria-current', 'page')
expect(performanceSurvey).toHaveClass('bg-[#e6f4ff]', 'text-[#0050cb]')
expect(workReview).toHaveClass('hover:bg-[#eef2f6]')
expect(screen.getByTitle('绩效考核调研操作界面')).toHaveAttribute('src', '/performance-survey')
fireEvent.click(workReview)
expect(workReview).toHaveAttribute('aria-current', 'page')
expect(screen.getByTitle('作品过稿看板操作界面')).toHaveAttribute('src', '/work-review')
expect(replace).toHaveBeenCalledWith(
'/workspaces/assets/automation-workflows?workflow=work-review',
{ scroll: false },
)
})
})

View File

@ -0,0 +1,101 @@
'use client'
import {
ClipboardCheck,
FileCheck2,
type LucideIcon,
} from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useState } from 'react'
import DepartmentShell from './DepartmentShell'
type AutomationWorkflowKey = 'performance-survey' | 'work-review'
type AutomationWorkflow = {
key: AutomationWorkflowKey
title: string
description: string
href: string
icon: LucideIcon
}
const AUTOMATION_WORKFLOWS: AutomationWorkflow[] = [
{
key: 'performance-survey',
title: '绩效考核调研',
description: '考核问卷、填报与结果管理',
href: '/performance-survey',
icon: ClipboardCheck,
},
{
key: 'work-review',
title: '作品过稿看板',
description: '作品提交、评审与退修流转',
href: '/work-review',
icon: FileCheck2,
},
]
function normalizeWorkflow(value: string | null): AutomationWorkflowKey {
return value === 'work-review' ? 'work-review' : 'performance-survey'
}
export default function AutomationWorkflowsDashboard() {
const router = useRouter()
const searchParams = useSearchParams()
const requestedWorkflow = normalizeWorkflow(searchParams.get('workflow'))
const [activeWorkflowKey, setActiveWorkflowKey] = useState<AutomationWorkflowKey>(requestedWorkflow)
const activeWorkflow = AUTOMATION_WORKFLOWS.find((workflow) => workflow.key === activeWorkflowKey) || AUTOMATION_WORKFLOWS[0]
useEffect(() => {
setActiveWorkflowKey(requestedWorkflow)
}, [requestedWorkflow])
const selectWorkflow = (workflow: AutomationWorkflow) => {
if (workflow.key === activeWorkflowKey) return
setActiveWorkflowKey(workflow.key)
router.replace(`/workspaces/assets/automation-workflows?workflow=${workflow.key}`, { scroll: false })
}
return (
<DepartmentShell>
<main className="flex h-[calc(100dvh-72px)] min-h-[560px] flex-col overflow-hidden bg-white lg:grid lg:grid-cols-[232px_minmax(0,1fr)]">
<aside className="shrink-0 border-b border-[#e2e8f0] bg-[#f8fafc] px-4 py-4 lg:min-h-0 lg:overflow-y-auto lg:border-b-0 lg:border-r lg:px-4 lg:py-6">
<nav aria-label="自动化工作流二级导航" className="grid grid-cols-2 gap-2 lg:grid-cols-1 lg:gap-1">
{AUTOMATION_WORKFLOWS.map((workflow) => {
const Icon = workflow.icon
const active = workflow.key === activeWorkflowKey
return (
<button
key={workflow.key}
type="button"
aria-current={active ? 'page' : undefined}
onClick={() => selectWorkflow(workflow)}
className={`group flex min-h-[58px] min-w-0 items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5a9ce6] ${active ? 'bg-[#e6f4ff] text-[#0050cb]' : 'text-[#4f6077] hover:bg-[#eef2f6] hover:text-[#101828]'}`}
>
<Icon className={`h-4 w-4 shrink-0 ${active ? 'text-[#1677ff]' : 'text-[#087052]'}`} aria-hidden="true" />
<span className="min-w-0">
<span className="block truncate text-sm font-bold">{workflow.title}</span>
<span className={`mt-0.5 hidden truncate text-[11px] lg:block ${active ? 'text-[#315f91]' : 'text-[#667085]'}`}>
{workflow.description}
</span>
</span>
</button>
)
})}
</nav>
</aside>
<section aria-label={`${activeWorkflow.title}操作区`} className="relative min-h-0 min-w-0 flex-1 bg-[#f2f4f7]">
<iframe
key={activeWorkflow.key}
src={activeWorkflow.href}
title={`${activeWorkflow.title}操作界面`}
className="h-full w-full border-0 bg-white"
/>
</section>
</main>
</DepartmentShell>
)
}

View File

@ -0,0 +1,33 @@
import { render, screen, within } from '@testing-library/react'
import BrandCenterDashboard from './BrandCenterDashboard'
jest.mock('next-auth/react', () => ({
useSession: () => ({
status: 'authenticated',
data: { user: { name: '本地超级管理员', email: 'superadmin@local.test' } },
}),
signOut: jest.fn(),
}))
describe('BrandCenterDashboard', () => {
it('大板块不可点击,小项分别进入对应二级页面', () => {
render(<BrandCenterDashboard />)
const workspaces = screen.getByRole('region', { name: '五个工作板块' })
const dashboard = within(workspaces).getByRole('article', { name: '中心仪表盘' })
const assets = within(workspaces).getByRole('article', { name: '资产与AI自动化' })
expect(screen.getByRole('heading', { level: 1, name: '品牌中心部门看板' })).toBeInTheDocument()
expect(within(workspaces).queryByRole('link', { name: '中心仪表盘' })).not.toBeInTheDocument()
expect(within(dashboard).getByRole('link', { name: /核心指标/ })).toHaveAttribute('href', '/workspaces/dashboard/core-metrics')
expect(within(dashboard).getByRole('link', { name: /业务趋势/ })).toHaveAttribute('href', '/workspaces/dashboard/business-trends')
expect(within(assets).getByRole('link', { name: /素材资产库/ })).toHaveAttribute('href', '/assets/material-library')
expect(within(assets).getByRole('link', { name: /自动化工作流/ })).toHaveAttribute('href', '/workspaces/assets/automation-workflows')
expect(within(assets).getByText('2 项工作流已接入')).toBeInTheDocument()
expect(within(workspaces).getAllByRole('link')).toHaveLength(22)
expect(screen.queryByText('选择板块内的小项进入对应二级页面。')).not.toBeInTheDocument()
expect(screen.queryByText('22 个二级入口 · 1 个业务页面已接入')).not.toBeInTheDocument()
expect(screen.queryByRole('navigation', { name: '工作板块快速导航' })).not.toBeInTheDocument()
expect(screen.queryByText('当前选择')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,118 @@
'use client'
import Link from 'next/link'
import { useSession } from 'next-auth/react'
import type { CSSProperties } from 'react'
import {
ArrowRight,
Bot,
BriefcaseBusiness,
Gauge,
Settings2,
UsersRound,
type LucideIcon,
} from 'lucide-react'
import DepartmentShell from './DepartmentShell'
import {
WORKSPACE_SECTIONS,
type WorkspaceSection,
type WorkspaceSectionKey,
} from './workspace-navigation'
const SECTION_ICONS: Record<WorkspaceSectionKey, LucideIcon> = {
dashboard: Gauge,
business: BriefcaseBusiness,
assets: Bot,
organization: UsersRound,
governance: Settings2,
}
function WorkspaceRow({ section }: { section: WorkspaceSection }) {
const Icon = SECTION_ICONS[section.key]
return (
<article
id={`workspace-${section.key}`}
aria-labelledby={`workspace-${section.key}-title`}
className="overflow-hidden rounded-xl border border-[#e2e8f0] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.06)]"
>
<div className="p-5 sm:p-7">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sm:gap-8">
<div className="flex min-w-0 items-start gap-4">
<span
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl"
style={{ backgroundColor: section.accentSoft, color: section.accentText }}
>
<Icon className="h-5 w-5" aria-hidden="true" />
</span>
<div className="min-w-0">
<h2 id={`workspace-${section.key}-title`} className="text-xl font-bold tracking-[-0.02em] text-[#101828]">{section.title}</h2>
<p className="mt-1 text-sm font-semibold" style={{ color: section.accentText }}>{section.summary}</p>
</div>
</div>
<p className="max-w-2xl text-sm leading-6 text-[#66758b] sm:text-right">{section.description}</p>
</div>
<nav
aria-label={`${section.title}二级页面`}
className="mt-6 grid grid-cols-1 gap-px overflow-hidden rounded-lg bg-[#e2e9f2] sm:grid-cols-2 lg:[grid-template-columns:repeat(var(--workspace-item-count),minmax(0,1fr))]"
style={{ '--workspace-item-count': section.items.length } as CSSProperties}
>
{section.items.map((item) => (
<Link
key={item.key}
href={item.href}
className="group flex min-h-[78px] min-w-0 items-center justify-between gap-3 bg-[#f7f9fc] px-4 py-3 text-left transition-[background-color,box-shadow] duration-200 hover:relative hover:z-10 hover:bg-white hover:shadow-[0_8px_22px_rgba(67,93,126,0.10)] focus-visible:relative focus-visible:z-20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#5a9ce6]"
>
<span className="min-w-0">
<span className="block text-sm font-bold leading-5 text-[#1b2738]">{item.title}</span>
<span className="mt-1 block text-xs leading-4 text-[#738198]">
{item.status === 'connected' ? item.productName : '二级框架'}
</span>
</span>
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl transition-transform duration-200 group-hover:translate-x-0.5"
style={{ backgroundColor: section.accentSoft, color: section.accentText }}
>
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</span>
</Link>
))}
</nav>
</div>
</article>
)
}
export default function BrandCenterDashboard() {
const { status } = useSession()
if (status === 'loading') {
return (
<div className="flex min-h-screen items-center justify-center bg-[#edf3fa] text-[#66758b]">
正在进入品牌中心部门看板…
</div>
)
}
return (
<DepartmentShell>
<main className="mx-auto max-w-[1600px] px-4 pb-12 pt-7 sm:px-6 sm:pb-16 lg:px-8">
<div className="border-b border-[#dfe4ea] pb-6">
<h1 className="max-w-4xl text-balance text-3xl font-bold tracking-[-0.03em] text-[#101828] sm:text-4xl">
品牌中心部门看板
</h1>
<p className="mt-3 max-w-3xl text-base leading-7 text-[#66758b] sm:text-lg">
从五个工作板块进入对应能力页面。当前先建立部门工作地图,业务数据将在后续阶段接入。
</p>
</div>
<section aria-label="五个工作板块" className="mt-6">
<div className="grid gap-5">
{WORKSPACE_SECTIONS.map((section) => <WorkspaceRow key={section.key} section={section} />)}
</div>
</section>
</main>
</DepartmentShell>
)
}

View File

@ -0,0 +1,34 @@
import { render, screen, within } from '@testing-library/react'
import BrandHealthDashboard from './BrandHealthDashboard'
import {
BRAND_HEALTH_BRANDS,
BRAND_HEALTH_DIMENSIONS,
} from './brand-health'
jest.mock('next-auth/react', () => ({
useSession: () => ({ data: { user: { name: '本地超级管理员' } } }),
signOut: jest.fn(),
}))
describe('BrandHealthDashboard', () => {
it('为六个品牌展示统一的五项健康度框架且不虚构分数', () => {
render(<BrandHealthDashboard />)
expect(screen.getByRole('heading', { level: 1, name: '六品牌健康度' })).toBeInTheDocument()
expect(screen.queryByRole('complementary', { name: '健康度计算说明' })).not.toBeInTheDocument()
expect(screen.getAllByText('数据源待接入')).toHaveLength(6)
for (const brand of BRAND_HEALTH_BRANDS) {
const brandModule = screen.getByRole('article', { name: brand.name })
expect(within(brandModule).getByText(`${brand.team}负责品牌`)).toBeInTheDocument()
expect(within(brandModule).getByLabelText(`${brand.name}品牌健康分待接入`)).toHaveTextContent('—')
for (const dimension of BRAND_HEALTH_DIMENSIONS) {
expect(within(brandModule).getByText(dimension.label)).toBeInTheDocument()
expect(within(brandModule).getByText(`权重 ${dimension.weight}%`)).toBeInTheDocument()
}
}
expect(screen.getByRole('link', { name: /返回部门看板/ })).toHaveAttribute('href', '/')
})
})

View File

@ -0,0 +1,105 @@
import Link from 'next/link'
import { ArrowLeft, HeartPulse } from 'lucide-react'
import DepartmentShell from './DepartmentShell'
import {
BRAND_HEALTH_BRANDS,
BRAND_HEALTH_DIMENSIONS,
type BrandHealthBrand,
} from './brand-health'
function BrandHealthRow({ brand }: { brand: BrandHealthBrand }) {
return (
<article
aria-labelledby={`brand-health-${brand.key}`}
className="overflow-hidden rounded-xl border border-[#e2e8f0] bg-white shadow-[0_6px_18px_rgba(15,23,42,0.05)]"
>
<div className="h-1 bg-[#5a9ce6]" aria-hidden="true" />
<div className="p-5 sm:p-7">
<header className="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-4">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#edf5ff] text-[#176fc1]">
<HeartPulse className="h-5 w-5" aria-hidden="true" />
</span>
<div className="min-w-0">
<h2 id={`brand-health-${brand.key}`} className="text-xl font-bold tracking-[-0.02em] text-[#101828] sm:text-2xl">
{brand.name}
</h2>
<p className="mt-1 text-sm font-semibold text-[#176fc1]">{brand.team}负责品牌</p>
</div>
</div>
<div className="flex items-center justify-between gap-5 border-t border-[#e2e9f2] pt-4 sm:justify-end sm:border-l sm:border-t-0 sm:pl-6 sm:pt-0">
<div>
<p className="text-xs font-bold text-[#738198]">品牌健康分</p>
<p className="mt-1 text-3xl font-bold tabular-nums tracking-[-0.03em] text-[#101828]" aria-label={`${brand.name}品牌健康分待接入`}>—</p>
</div>
<span className="inline-flex min-h-8 items-center rounded-lg bg-[#f1f6fc] px-3 text-xs font-bold text-[#5f7189]">
数据源待接入
</span>
</div>
</header>
<dl
aria-label={`${brand.name}五项健康度数据`}
className="mt-5 grid gap-px overflow-hidden rounded-2xl bg-[#e2e9f2] sm:grid-cols-2 lg:grid-cols-5"
>
{BRAND_HEALTH_DIMENSIONS.map((dimension) => (
<div
key={dimension.key}
className="bg-[#f8fafd] p-4"
>
<dt className="text-sm font-bold text-[#1b2738]">{dimension.label}</dt>
<dd className="mt-4 flex items-end justify-between gap-3">
<span className="text-2xl font-bold tabular-nums text-slate-400" aria-label={`${dimension.label}当前分数待接入`}>—</span>
<span className="rounded-md bg-white px-2 py-1 text-xs font-bold tabular-nums text-[#5f7189]">
权重 {dimension.weight}%
</span>
</dd>
<p className="mt-3 text-xs text-slate-500">当前分数待接入</p>
</div>
))}
</dl>
</div>
</article>
)
}
export default function BrandHealthDashboard() {
return (
<DepartmentShell>
<main className="mx-auto max-w-[1600px] px-4 pb-16 pt-7 sm:px-6 lg:px-8">
<header className="border-b border-[#dfe4ea] pb-6">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-[#edf5ff] text-[#176fc1]">
<HeartPulse className="h-6 w-6" aria-hidden="true" />
</div>
<h1 className="mt-6 text-balance text-3xl font-bold tracking-[-0.03em] sm:text-4xl">六品牌健康度</h1>
<p className="mt-3 max-w-3xl text-base leading-7 text-[#66758b] sm:text-lg">
按创意团队归属查看六个品牌的统一健康度框架。当前先展示五项评分维度与权重,实际分数将在业务数据源接入后显示。
</p>
</header>
<section aria-labelledby="brand-health-list-title" className="mt-6">
<div className="mb-4 flex flex-wrap items-end justify-between gap-3 rounded-lg border border-[#e2e8f0] bg-white px-6 py-5">
<div>
<h2 id="brand-health-list-title" className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">六个品牌模块</h2>
<p className="mt-1 text-sm text-[#66758b]">创意A:朴美、索菲亚、顾家;创意B:欧意朗、卓禾、亿家达。</p>
</div>
<p className="rounded-full bg-[#f1f6fc] px-4 py-2 text-sm font-semibold text-[#5f7189]">6 个品牌 · 5 项健康维度</p>
</div>
<div className="grid gap-4">
{BRAND_HEALTH_BRANDS.map((brand) => <BrandHealthRow key={brand.key} brand={brand} />)}
</div>
</section>
<Link
href="/"
className="mt-10 inline-flex min-h-11 items-center gap-2 rounded-xl bg-[#247fd3] px-4 text-sm font-bold text-white transition-colors hover:bg-[#176fc1] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#78aee9] focus-visible:ring-offset-2"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
返回部门看板
</Link>
</main>
</DepartmentShell>
)
}

View File

@ -0,0 +1,35 @@
import { render, screen, within } from '@testing-library/react'
import BusinessTrendsDashboard from './BusinessTrendsDashboard'
import {
BUSINESS_TREND_BRANDS,
BUSINESS_TREND_STAGES,
} from './business-trends'
jest.mock('next-auth/react', () => ({
useSession: () => ({ data: { user: { name: '本地超级管理员' } } }),
signOut: jest.fn(),
}))
describe('BusinessTrendsDashboard', () => {
it('shows six brand trend bands with eight-week comparison, project stages and enlarged example attainment', () => {
render(<BusinessTrendsDashboard />)
expect(screen.getByRole('heading', { level: 1, name: '业务趋势' })).toBeInTheDocument()
expect(screen.queryByRole('complementary', { name: '业务趋势数据说明' })).not.toBeInTheDocument()
expect(screen.getAllByText('示例值 · 数据源待接入')).toHaveLength(6)
for (const brand of BUSINESS_TREND_BRANDS) {
const brandBand = screen.getByRole('article', { name: brand.name })
expect(within(brandBand).getByRole('img', { name: `${brand.name}近8周计划与实际完成量对比` })).toBeInTheDocument()
expect(within(brandBand).getByText(`${brand.team}负责品牌`)).toBeInTheDocument()
expect(within(brandBand).getByLabelText(new RegExp(`${brand.name}示例达成率`))).toBeInTheDocument()
const projectStructure = within(brandBand).getByRole('region', { name: `${brand.name}项目结构` })
for (const stage of BUSINESS_TREND_STAGES) {
expect(within(projectStructure).getByText(stage.label)).toBeInTheDocument()
}
}
expect(screen.getByRole('link', { name: /返回部门看板/ })).toHaveAttribute('href', '/')
})
})

View File

@ -0,0 +1,161 @@
import Link from 'next/link'
import { ArrowLeft, GitBranch, TrendingUp } from 'lucide-react'
import DepartmentShell from './DepartmentShell'
import {
BUSINESS_TREND_BRANDS,
BUSINESS_TREND_STAGES,
BUSINESS_TREND_WEEK_LABELS,
summarizeBusinessTrend,
type BusinessTrendBrand,
} from './business-trends'
function formatAttainmentRate(rate: number | null) {
return rate === null ? '—' : `${rate.toFixed(1)}%`
}
function BusinessTrendBand({ brand }: { brand: BusinessTrendBrand }) {
const summary = summarizeBusinessTrend(brand)
const chartMax = Math.max(...brand.planned, ...brand.actual, 1)
return (
<article
aria-labelledby={`business-trend-${brand.key}`}
className="overflow-hidden rounded-xl border border-[#e2e8f0] bg-white shadow-[0_6px_18px_rgba(15,23,42,0.05)]"
>
<div className="h-1 bg-[#5a9ce6]" aria-hidden="true" />
<header className="flex flex-col gap-4 px-5 py-5 sm:flex-row sm:items-center sm:justify-between sm:px-7 sm:py-6">
<div className="flex min-w-0 items-center gap-4">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#edf5ff] text-[#176fc1]">
<TrendingUp className="h-5 w-5" aria-hidden="true" />
</span>
<div className="min-w-0">
<h2 id={`business-trend-${brand.key}`} className="text-xl font-bold tracking-[-0.02em] text-[#101828] sm:text-2xl">
{brand.name}
</h2>
<p className="mt-1 text-sm font-semibold text-[#176fc1]">{brand.team}负责品牌</p>
</div>
</div>
<span className="inline-flex min-h-8 items-center self-start rounded-lg bg-[#f1f6fc] px-3 text-xs font-bold text-[#5f7189] sm:self-center">
示例值 · 数据源待接入
</span>
</header>
<div className="border-t border-[#e2e9f2] lg:grid lg:grid-cols-[minmax(0,1fr)_320px]">
<figure className="min-w-0 px-5 py-6 sm:px-7 sm:py-7">
<figcaption className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h3 className="text-base font-bold text-[#1b2738]">近 8 周计划 vs 实际</h3>
<p className="mt-1 text-sm text-[#66758b]">W1 至 W8 由早到近,接入后替换为真实周次。</p>
</div>
<div className="flex items-center gap-4 text-xs font-semibold text-[#5f7189]" aria-label="图例">
<span className="inline-flex items-center gap-2"><span className="h-2.5 w-2.5 rounded-sm bg-[#dce5ef]" aria-hidden="true" />计划</span>
<span className="inline-flex items-center gap-2"><span className="h-2.5 w-2.5 rounded-sm bg-[#247fd3]" aria-hidden="true" />实际</span>
</div>
</figcaption>
<div
role="img"
aria-label={`${brand.name}近8周计划与实际完成量对比`}
className="mt-6 grid grid-cols-8 gap-1.5 sm:gap-3"
>
{BUSINESS_TREND_WEEK_LABELS.map((week, index) => {
const planned = brand.planned[index] ?? 0
const actual = brand.actual[index] ?? 0
const plannedHeight = Math.max(12, Math.round((planned / chartMax) * 100))
const actualHeight = Math.max(12, Math.round((actual / chartMax) * 100))
return (
<div key={week} className="flex min-w-0 flex-col items-center">
<div className="flex h-28 items-end gap-1 sm:h-32 sm:gap-1.5" aria-hidden="true">
<span className="w-2.5 rounded-t-sm bg-[#dce5ef] sm:w-3.5" style={{ height: `${plannedHeight}%` }} />
<span className="w-2.5 rounded-t-sm bg-[#247fd3] sm:w-3.5" style={{ height: `${actualHeight}%` }} />
</div>
<p className="mt-2 text-xs font-bold text-[#4f6077]">{week}</p>
<p className="mt-0.5 text-xs tabular-nums text-[#738198]" aria-label={`${week}实际${actual}计划${planned}`}>
{actual}/{planned}
</p>
</div>
)
})}
</div>
<p className="mt-4 text-xs text-[#738198]">柱下数字为实际 / 计划,当前均为结构示例值。</p>
</figure>
<section aria-label={`${brand.name}项目结构`} className="border-t border-[#e2e9f2] lg:border-l lg:border-t-0">
<div className="flex items-center gap-3 px-5 py-4 sm:px-7 lg:px-6">
<GitBranch className="h-4 w-4 text-[#176fc1]" aria-hidden="true" />
<div>
<h3 className="text-base font-bold text-[#1b2738]">项目结构</h3>
<p className="mt-0.5 text-xs text-[#738198]">当前阶段项目数 · 示例值</p>
</div>
</div>
<dl className="grid grid-cols-2 gap-px border-t border-[#e2e9f2] bg-[#e2e9f2] sm:grid-cols-5 lg:grid-cols-1">
{BUSINESS_TREND_STAGES.map((stage) => (
<div key={stage.key} className="flex min-h-14 items-center justify-between gap-3 bg-[#f8fafd] px-5 py-3 last:col-span-2 sm:last:col-span-1 lg:px-6">
<dt className="text-sm font-semibold text-[#4f6077]">{stage.label}</dt>
<dd className="text-lg font-bold tabular-nums text-[#101828]">{brand.stages[stage.key]}</dd>
</div>
))}
</dl>
</section>
</div>
<footer className="grid gap-px border-t border-[#e2e9f2] bg-[#e2e9f2] sm:grid-cols-[1fr_1fr_1.5fr]">
<div className="bg-[#f8fafd] px-5 py-4 sm:px-7">
<p className="text-xs font-semibold text-[#738198]">近 8 周计划总量</p>
<p className="mt-1 text-2xl font-bold tabular-nums text-[#101828]">{summary.plannedTotal}</p>
</div>
<div className="bg-[#f8fafd] px-5 py-4 sm:px-7">
<p className="text-xs font-semibold text-[#738198]">实际完成总量</p>
<p className="mt-1 text-2xl font-bold tabular-nums text-[#101828]">{summary.actualTotal}</p>
</div>
<div className="flex items-end justify-between gap-4 bg-[#edf5ff] px-5 py-4 sm:px-7">
<div>
<p className="text-xs font-bold text-[#176fc1]">示例达成率</p>
<p className="mt-1 text-xs text-[#66758b]">实际完成量 ÷ 计划数量</p>
</div>
<p className="text-4xl font-bold tabular-nums tracking-[-0.03em] text-[#101828] sm:text-5xl" aria-label={`${brand.name}示例达成率${formatAttainmentRate(summary.attainmentRate)}`}>
{formatAttainmentRate(summary.attainmentRate)}
</p>
</div>
</footer>
</article>
)
}
export default function BusinessTrendsDashboard() {
return (
<DepartmentShell>
<main className="mx-auto max-w-[1600px] px-4 pb-16 pt-7 sm:px-6 lg:px-8">
<header className="border-b border-[#dfe4ea] pb-6">
<h1 className="text-balance text-3xl font-bold tracking-[-0.03em] sm:text-4xl">业务趋势</h1>
<p className="mt-3 max-w-3xl text-base leading-7 text-[#66758b] sm:text-lg">
按六个品牌查看近 8 周计划与实际完成量,并从 Brief、设计、拍摄、后期、待上线五个阶段理解当前项目结构。
</p>
</header>
<section aria-labelledby="business-trend-list-title" className="mt-6">
<div className="mb-4 flex flex-wrap items-end justify-between gap-3 rounded-lg border border-[#e2e8f0] bg-white px-6 py-5">
<div>
<h2 id="business-trend-list-title" className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">六品牌近 8 周交付趋势</h2>
<p className="mt-1 text-sm text-[#66758b]">创意A:朴美、索菲亚、顾家;创意B:欧意朗、卓禾、亿家达。</p>
</div>
<p className="rounded-full bg-[#f1f6fc] px-4 py-2 text-sm font-semibold text-[#5f7189]">6 个品牌 · 8 周 · 5 个阶段</p>
</div>
<div className="grid gap-4">
{BUSINESS_TREND_BRANDS.map((brand) => <BusinessTrendBand key={brand.key} brand={brand} />)}
</div>
</section>
<Link
href="/"
className="mt-10 inline-flex min-h-11 items-center gap-2 rounded-xl bg-[#247fd3] px-4 text-sm font-bold text-white transition-colors hover:bg-[#176fc1] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#78aee9] focus-visible:ring-offset-2"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
返回部门看板
</Link>
</main>
</DepartmentShell>
)
}

View File

@ -0,0 +1,125 @@
import { render, screen } from '@testing-library/react'
import CoreMetricDetail from './CoreMetricDetail'
import { getCoreMetric } from './core-metrics'
jest.mock('next-auth/react', () => ({
useSession: () => ({ data: { user: { name: '本地超级管理员' } } }),
signOut: jest.fn(),
}))
describe('CoreMetricDetail', () => {
it('展示真实进行中项目列表与数据口径', () => {
const metric = getCoreMetric('ongoing-projects')!
render(
<CoreMetricDetail
metric={metric}
projectPage={{
items: [{
id: 'project-1',
title: '夏季主视觉过稿',
description: null,
status: 'pending_review',
workDate: '2026-08-10T00:00:00.000Z',
latestVersion: 2,
submitterName: '林一',
managerName: '陈主管',
finalReviewerName: '元泽',
updatedAt: '2026-08-14T02:30:00.000Z',
}],
pagination: { page: 1, pageSize: 20, total: 1, totalPages: 1 },
}}
/>,
)
expect(screen.getByRole('heading', { level: 1, name: '进行中项目' })).toBeInTheDocument()
expect(screen.getAllByText('夏季主视觉过稿')).toHaveLength(2)
expect(screen.getAllByText('评审中').length).toBeGreaterThan(0)
expect(screen.getByText(/数据来自作品过稿项目库/)).toBeInTheDocument()
expect(screen.getByRole('link', { name: /返回核心指标/ })).toHaveAttribute('href', '/workspaces/dashboard/core-metrics')
})
it('未接入的指标只展示诚实的明细框架', () => {
render(<CoreMetricDetail metric={getCoreMetric('risks')!} />)
expect(screen.getByRole('heading', { level: 1, name: '风险' })).toBeInTheDocument()
expect(screen.getByText(/当前不展示虚构记录/)).toBeInTheDocument()
expect(screen.getByText('数据源待接入')).toBeInTheDocument()
})
it('在按时交付明细中只用黄色数值呈现阈值状态', () => {
render(<CoreMetricDetail metric={getCoreMetric('on-time-delivery')!} />)
expect(screen.getByLabelText('按时交付示例值94.6%,黄色区间90%–95%')).toHaveClass('text-[#a88a00]')
expect(screen.queryByText('黄色区间 · 90%–95%')).not.toBeInTheDocument()
})
it('展示首轮通过真实公式与每个作品的 V1 审核结果', () => {
render(
<CoreMetricDetail
metric={getCoreMetric('first-pass-rate')!}
firstPassPage={{
summary: {
totalUploads: 3,
firstPassApproved: 1,
rate: 33.3,
statusBreakdown: { approved: 1, revisionRequired: 1, pendingReview: 1, unknown: 0 },
readAt: '2026-08-14T02:00:00.000Z',
},
items: [
{
id: 'approved-v1',
title: '已通过作品',
currentStatus: 'approved',
latestVersion: 1,
submitterName: '林一',
managerName: '陈主管',
finalReviewerName: '元泽',
workDate: '2026-08-10T00:00:00.000Z',
updatedAt: '2026-08-14T02:30:00.000Z',
firstVersionStatus: 'approved',
firstVersionSubmittedAt: '2026-08-10T02:00:00.000Z',
firstVersionCompletedAt: '2026-08-11T02:00:00.000Z',
},
{
id: 'revision-v1',
title: '首轮退修作品',
currentStatus: 'revision_required',
latestVersion: 1,
submitterName: '林二',
managerName: '陈主管',
finalReviewerName: '元泽',
workDate: '2026-08-11T00:00:00.000Z',
updatedAt: '2026-08-14T02:30:00.000Z',
firstVersionStatus: 'revision_required',
firstVersionSubmittedAt: '2026-08-11T02:00:00.000Z',
firstVersionCompletedAt: '2026-08-12T02:00:00.000Z',
},
{
id: 'pending-v1',
title: '评审中作品',
currentStatus: 'pending_review',
latestVersion: 1,
submitterName: '林三',
managerName: '陈主管',
finalReviewerName: '元泽',
workDate: '2026-08-12T00:00:00.000Z',
updatedAt: '2026-08-14T02:30:00.000Z',
firstVersionStatus: 'pending_review',
firstVersionSubmittedAt: '2026-08-12T02:00:00.000Z',
firstVersionCompletedAt: null,
},
],
pagination: { page: 1, pageSize: 20, total: 3, totalPages: 1 },
}}
/>,
)
expect(screen.getByRole('heading', { level: 1, name: '首轮通过' })).toBeInTheDocument()
expect(screen.getByText('首轮通过率 = V1 审核通过作品数 ÷ 全部上传作品数 × 100%')).toBeInTheDocument()
expect(screen.getByText('当前口径:1 ÷ 3 = 33.3%')).toBeInTheDocument()
expect(screen.getAllByText('首轮通过').length).toBeGreaterThan(0)
expect(screen.getAllByText('首轮未通过').length).toBeGreaterThan(0)
expect(screen.getAllByText('首轮评审中 · 仅计入分母').length).toBeGreaterThan(0)
expect(screen.getByRole('link', { name: /打开作品过稿数据源/ })).toHaveAttribute('href', 'https://sc.jiansun.vip/work-review')
})
})

View File

@ -0,0 +1,416 @@
import Link from 'next/link'
import {
ArrowLeft,
ArrowRight,
CalendarDays,
CheckCircle2,
Clock3,
Database,
ExternalLink,
RotateCcw,
Search,
} from 'lucide-react'
import type {
FirstPassReviewProjectListItem,
FirstPassReviewProjectPage,
OngoingWorkReviewProjectListItem,
OngoingWorkReviewProjectPage,
} from '@/lib/work-review-service'
import DepartmentShell from './DepartmentShell'
import OnTimeDeliveryRate from './OnTimeDeliveryRate'
import type { CoreMetric } from './core-metrics'
type CoreMetricDetailProps = {
metric: CoreMetric
query?: string
projectPage?: OngoingWorkReviewProjectPage
firstPassPage?: FirstPassReviewProjectPage
}
const STATUS_LABELS: Record<OngoingWorkReviewProjectListItem['status'], string> = {
pending_review: '评审中',
revision_required: '待修改',
}
function formatDate(value: string, includeTime = false) {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
...(includeTime ? { hour: '2-digit', minute: '2-digit', hour12: false } : {}),
timeZone: 'Asia/Shanghai',
}).format(new Date(value))
}
function buildPageHref(metricKey: string, page: number, query?: string) {
const search = new URLSearchParams()
if (page > 1) search.set('page', String(page))
if (query) search.set('q', query)
const suffix = search.toString()
return `/workspaces/dashboard/core-metrics/${metricKey}${suffix ? `?${suffix}` : ''}`
}
function formatRate(rate: number | null) {
if (rate === null) return '—'
return `${Number.isInteger(rate) ? rate.toFixed(0) : rate.toFixed(1)}%`
}
function StatusBadge({ status }: { status: OngoingWorkReviewProjectListItem['status'] }) {
const isRevision = status === 'revision_required'
return (
<span className={`inline-flex min-h-7 items-center rounded-lg px-2.5 text-xs font-bold ${isRevision ? 'bg-amber-50 text-amber-800' : 'bg-blue-50 text-blue-700'}`}>
{STATUS_LABELS[status]}
</span>
)
}
function ProjectList({ metric, query, projectPage }: Required<Pick<CoreMetricDetailProps, 'metric' | 'projectPage'>> & Pick<CoreMetricDetailProps, 'query'>) {
const { items, pagination } = projectPage
return (
<section aria-labelledby="project-list-title" className="mt-10">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 id="project-list-title" className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">进行中项目明细</h2>
<p className="mt-1 text-sm leading-6 text-slate-600">
数据来自作品过稿项目库;“评审中”和“待修改”均计入进行中。
</p>
</div>
<p className="text-sm font-semibold tabular-nums text-slate-600">共 {pagination.total} 个项目</p>
</div>
<form className="mt-5 flex max-w-xl gap-2" action={`/workspaces/dashboard/core-metrics/${metric.key}`} method="get" role="search">
<label htmlFor="project-query" className="sr-only">搜索项目、提交人或负责人</label>
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" aria-hidden="true" />
<input
id="project-query"
name="q"
type="search"
defaultValue={query}
placeholder="搜索项目、提交人或负责人"
className="min-h-11 w-full rounded-xl border border-slate-300 bg-white py-2 pl-9 pr-3 text-sm text-slate-950 outline-none transition-colors placeholder:text-slate-400 focus:border-slate-500 focus:ring-2 focus:ring-slate-300"
/>
</div>
<button type="submit" className="min-h-11 shrink-0 rounded-xl bg-[#247fd3] px-4 text-sm font-bold text-white transition-colors hover:bg-[#176fc1] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#78aee9] focus-visible:ring-offset-2">
查询
</button>
</form>
{items.length === 0 ? (
<div role="status" className="mt-6 rounded-lg border border-[#e2e8f0] bg-white px-5 py-14 text-center">
<Database className="mx-auto h-8 w-8 text-slate-400" aria-hidden="true" />
<h3 className="mt-4 text-base font-bold text-slate-900">
{query ? '没有匹配的进行中项目' : '当前没有进行中项目'}
</h3>
<p className="mx-auto mt-2 max-w-xl text-sm leading-6 text-slate-600">
{query
? '请调整关键词后重新查询。'
: '数据库中暂时没有“评审中”或“待修改”的作品过稿项目;有项目进入这些状态后会自动显示在这里。'}
</p>
</div>
) : (
<>
<div className="mt-6 hidden overflow-hidden rounded-lg border border-[#e2e8f0] bg-white md:block">
<table className="w-full table-fixed text-left text-sm">
<caption className="sr-only">进行中项目数据表</caption>
<thead className="bg-slate-50 text-xs font-bold text-slate-500">
<tr>
<th scope="col" className="w-[30%] px-5 py-3.5">项目</th>
<th scope="col" className="w-[12%] px-4 py-3.5">状态</th>
<th scope="col" className="w-[13%] px-4 py-3.5">提交人</th>
<th scope="col" className="w-[13%] px-4 py-3.5">负责人</th>
<th scope="col" className="w-[14%] px-4 py-3.5">作品日期</th>
<th scope="col" className="w-[18%] px-5 py-3.5">最近更新</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{items.map((project) => (
<tr key={project.id} className="align-top transition-colors hover:bg-slate-50">
<td className="px-5 py-4">
<p className="truncate font-bold text-slate-950">{project.title}</p>
<p className="mt-1 text-xs text-slate-500">第 {project.latestVersion} 版 · 终审 {project.finalReviewerName}</p>
</td>
<td className="px-4 py-4"><StatusBadge status={project.status} /></td>
<td className="truncate px-4 py-4 font-medium text-slate-700">{project.submitterName}</td>
<td className="truncate px-4 py-4 font-medium text-slate-700">{project.managerName}</td>
<td className="px-4 py-4 tabular-nums text-slate-600">{formatDate(project.workDate)}</td>
<td className="px-5 py-4 tabular-nums text-slate-600">{formatDate(project.updatedAt, true)}</td>
</tr>
))}
</tbody>
</table>
</div>
<ul className="mt-6 divide-y divide-slate-200 overflow-hidden rounded-lg border border-[#e2e8f0] bg-white md:hidden" aria-label="进行中项目列表">
{items.map((project) => (
<li key={project.id} className="p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate font-bold text-slate-950">{project.title}</h3>
<p className="mt-1 text-xs text-slate-500">第 {project.latestVersion} 版 · 终审 {project.finalReviewerName}</p>
</div>
<StatusBadge status={project.status} />
</div>
<dl className="mt-4 grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div><dt className="text-xs text-slate-500">提交人</dt><dd className="mt-1 font-semibold text-slate-800">{project.submitterName}</dd></div>
<div><dt className="text-xs text-slate-500">负责人</dt><dd className="mt-1 font-semibold text-slate-800">{project.managerName}</dd></div>
<div><dt className="text-xs text-slate-500">作品日期</dt><dd className="mt-1 tabular-nums text-slate-700">{formatDate(project.workDate)}</dd></div>
<div><dt className="text-xs text-slate-500">最近更新</dt><dd className="mt-1 tabular-nums text-slate-700">{formatDate(project.updatedAt, true)}</dd></div>
</dl>
</li>
))}
</ul>
</>
)}
{pagination.totalPages > 1 && (
<nav aria-label="进行中项目分页" className="mt-5 flex items-center justify-between gap-4 text-sm">
<p className="text-slate-500">第 {pagination.page} / {pagination.totalPages} 页</p>
<div className="flex gap-2">
{pagination.page > 1 && <Link className="inline-flex min-h-10 items-center rounded-xl bg-white px-3 font-bold text-slate-700 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400" href={buildPageHref(metric.key, pagination.page - 1, query)}>上一页</Link>}
{pagination.page < pagination.totalPages && <Link className="inline-flex min-h-10 items-center rounded-xl bg-white px-3 font-bold text-slate-700 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400" href={buildPageHref(metric.key, pagination.page + 1, query)}>下一页</Link>}
</div>
</nav>
)}
</section>
)
}
const CURRENT_REVIEW_STATUS_LABELS: Record<string, string> = {
approved: '当前已通过',
pending_review: '当前评审中',
revision_required: '当前待修改',
}
function FirstPassStatusBadge({ status }: { status: FirstPassReviewProjectListItem['firstVersionStatus'] }) {
if (status === 'approved') {
return (
<span className="inline-flex min-h-7 items-center gap-1.5 rounded-lg bg-emerald-50 px-2.5 text-xs font-bold text-emerald-800">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />首轮通过
</span>
)
}
if (status === 'revision_required') {
return (
<span className="inline-flex min-h-7 items-center gap-1.5 rounded-lg bg-amber-50 px-2.5 text-xs font-bold text-amber-800">
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />首轮未通过
</span>
)
}
if (status === 'pending_review') {
return (
<span className="inline-flex min-h-7 items-center gap-1.5 rounded-lg bg-blue-50 px-2.5 text-xs font-bold text-blue-700">
<Clock3 className="h-3.5 w-3.5" aria-hidden="true" />首轮评审中 · 仅计入分母
</span>
)
}
return (
<span className="inline-flex min-h-7 items-center rounded-lg bg-slate-100 px-2.5 text-xs font-bold text-slate-600">
状态待核对 · 仅计入分母
</span>
)
}
function FirstPassProjectList({ metric, query, firstPassPage }: Required<Pick<CoreMetricDetailProps, 'metric' | 'firstPassPage'>> & Pick<CoreMetricDetailProps, 'query'>) {
const { items, pagination, summary } = firstPassPage
return (
<section aria-labelledby="first-pass-list-title" className="mt-10">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 id="first-pass-list-title" className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">V1 首轮审核明细</h2>
<p className="mt-1 text-sm leading-6 text-slate-600">
每个作品只取 V1 审核结果;评审中、首轮未通过和待核对状态均属于已上传作品。
</p>
</div>
<a
href="https://sc.jiansun.vip/work-review"
target="_blank"
rel="noreferrer"
className="inline-flex min-h-10 items-center gap-2 self-start rounded-xl bg-white px-3 text-sm font-bold text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 lg:self-auto"
>
打开作品过稿数据源<ExternalLink className="h-4 w-4" aria-hidden="true" />
</a>
</div>
<div className="mt-5 rounded-lg border border-[#e2e8f0] bg-white px-5 py-4">
<p className="text-xs font-bold text-slate-500">统计公式</p>
<p className="mt-1 text-sm font-bold text-slate-900">
首轮通过率 = V1 审核通过作品数 ÷ 全部上传作品数 × 100%
</p>
<p className="mt-2 text-xs tabular-nums text-slate-500">
当前口径:{summary.firstPassApproved} ÷ {summary.totalUploads} = {formatRate(summary.rate)}
</p>
</div>
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<form className="flex max-w-xl flex-1 gap-2" action={`/workspaces/dashboard/core-metrics/${metric.key}`} method="get" role="search">
<label htmlFor="first-pass-query" className="sr-only">搜索作品、提交人或负责人</label>
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" aria-hidden="true" />
<input
id="first-pass-query"
name="q"
type="search"
defaultValue={query}
placeholder="搜索作品、提交人或负责人"
className="min-h-11 w-full rounded-xl border border-slate-300 bg-white py-2 pl-9 pr-3 text-sm text-slate-950 outline-none transition-colors placeholder:text-slate-400 focus:border-slate-500 focus:ring-2 focus:ring-slate-300"
/>
</div>
<button type="submit" className="min-h-11 shrink-0 rounded-xl bg-[#247fd3] px-4 text-sm font-bold text-white transition-colors hover:bg-[#176fc1] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#78aee9] focus-visible:ring-offset-2">
查询
</button>
</form>
<p className="text-sm font-semibold tabular-nums text-slate-600">共 {pagination.total} 个作品</p>
</div>
{items.length === 0 ? (
<div role="status" className="mt-6 rounded-lg border border-[#e2e8f0] bg-white px-5 py-14 text-center">
<Database className="mx-auto h-8 w-8 text-slate-400" aria-hidden="true" />
<h3 className="mt-4 text-base font-bold text-slate-900">
{query ? '没有匹配的作品' : '当前没有可统计的 V1 作品'}
</h3>
<p className="mx-auto mt-2 max-w-xl text-sm leading-6 text-slate-600">
{query ? '请调整关键词后重新查询。' : '作品上传至作品过稿后,本页会读取 V1 审核状态并自动纳入统计。'}
</p>
</div>
) : (
<>
<div className="mt-6 hidden overflow-hidden rounded-lg border border-[#e2e8f0] bg-white md:block">
<table className="w-full table-fixed text-left text-sm">
<caption className="sr-only">V1 首轮审核数据表</caption>
<thead className="bg-slate-50 text-xs font-bold text-slate-500">
<tr>
<th scope="col" className="w-[28%] px-5 py-3.5">作品</th>
<th scope="col" className="w-[20%] px-4 py-3.5">首轮结果</th>
<th scope="col" className="w-[13%] px-4 py-3.5">当前状态</th>
<th scope="col" className="w-[12%] px-4 py-3.5">提交人</th>
<th scope="col" className="w-[14%] px-4 py-3.5">V1 上传</th>
<th scope="col" className="w-[13%] px-5 py-3.5">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{items.map((project) => (
<tr key={project.id} className="align-top transition-colors hover:bg-slate-50">
<td className="px-5 py-4">
<p className="truncate font-bold text-slate-950">{project.title}</p>
<p className="mt-1 text-xs text-slate-500">当前第 {project.latestVersion} 版 · 负责人 {project.managerName}</p>
</td>
<td className="px-4 py-4"><FirstPassStatusBadge status={project.firstVersionStatus} /></td>
<td className="px-4 py-4 font-medium text-slate-700">{CURRENT_REVIEW_STATUS_LABELS[project.currentStatus] || '当前状态待核对'}</td>
<td className="truncate px-4 py-4 font-medium text-slate-700">{project.submitterName}</td>
<td className="px-4 py-4 tabular-nums text-slate-600">{formatDate(project.firstVersionSubmittedAt, true)}</td>
<td className="px-5 py-4">
<Link href={`/work-review?project=${encodeURIComponent(project.id)}`} className="inline-flex min-h-9 items-center gap-1 rounded-lg px-2 font-bold text-[#b4231c] hover:bg-[#fff0ee] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#d92d20]">
查看过稿记录<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
<ul className="mt-6 divide-y divide-slate-200 overflow-hidden rounded-lg border border-[#e2e8f0] bg-white md:hidden" aria-label="V1 首轮审核列表">
{items.map((project) => (
<li key={project.id} className="p-5">
<h3 className="font-bold text-slate-950">{project.title}</h3>
<div className="mt-3"><FirstPassStatusBadge status={project.firstVersionStatus} /></div>
<dl className="mt-4 grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div><dt className="text-xs text-slate-500">当前状态</dt><dd className="mt-1 font-semibold text-slate-800">{CURRENT_REVIEW_STATUS_LABELS[project.currentStatus] || '待核对'}</dd></div>
<div><dt className="text-xs text-slate-500">当前版本</dt><dd className="mt-1 font-semibold tabular-nums text-slate-800">V{project.latestVersion}</dd></div>
<div><dt className="text-xs text-slate-500">提交人</dt><dd className="mt-1 font-semibold text-slate-800">{project.submitterName}</dd></div>
<div><dt className="text-xs text-slate-500">V1 上传</dt><dd className="mt-1 tabular-nums text-slate-700">{formatDate(project.firstVersionSubmittedAt, true)}</dd></div>
</dl>
<Link href={`/work-review?project=${encodeURIComponent(project.id)}`} className="mt-4 inline-flex min-h-10 items-center gap-1 rounded-xl bg-slate-100 px-3 text-sm font-bold text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400">
查看过稿记录<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</li>
))}
</ul>
</>
)}
{pagination.totalPages > 1 && (
<nav aria-label="V1 首轮审核分页" className="mt-5 flex items-center justify-between gap-4 text-sm">
<p className="text-slate-500">第 {pagination.page} / {pagination.totalPages} 页</p>
<div className="flex gap-2">
{pagination.page > 1 && <Link className="inline-flex min-h-10 items-center rounded-xl bg-white px-3 font-bold text-slate-700 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400" href={buildPageHref(metric.key, pagination.page - 1, query)}>上一页</Link>}
{pagination.page < pagination.totalPages && <Link className="inline-flex min-h-10 items-center rounded-xl bg-white px-3 font-bold text-slate-700 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400" href={buildPageHref(metric.key, pagination.page + 1, query)}>下一页</Link>}
</div>
</nav>
)}
</section>
)
}
function PendingDataSource({ metric }: { metric: CoreMetric }) {
return (
<section aria-labelledby="detail-fields-title" className="mt-10">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 id="detail-fields-title" className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">明细框架</h2>
<p className="mt-1 text-sm leading-6 text-slate-600">底层业务表与公式尚未接入,当前不展示虚构记录。</p>
</div>
<span className="rounded-lg bg-amber-50 px-3 py-2 text-xs font-bold text-amber-800">数据源待接入</span>
</div>
<div role="status" className="mt-6 overflow-hidden rounded-lg border border-[#e2e8f0] bg-white">
<div className="border-b border-slate-200 px-5 py-4">
<h3 className="font-bold text-slate-950">计划展示字段</h3>
</div>
<div className="flex flex-wrap gap-2 p-5">
{metric.plannedFields.map((field) => <span key={field} className="rounded-lg bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-700">{field}</span>)}
</div>
<p className="border-t border-slate-200 px-5 py-8 text-center text-sm leading-6 text-slate-500">
数据源、字段映射和统计公式确认后,本页将自动列出对应业务明细。
</p>
</div>
</section>
)
}
export default function CoreMetricDetail({ metric, query, projectPage, firstPassPage }: CoreMetricDetailProps) {
const Icon = metric.icon
const hasRealProjectSource = metric.key === 'ongoing-projects' && projectPage
const hasFirstPassSource = metric.key === 'first-pass-rate' && firstPassPage
const isOnTimeDelivery = metric.key === 'on-time-delivery'
const hasRealSource = hasRealProjectSource || hasFirstPassSource
return (
<DepartmentShell>
<main className="mx-auto max-w-[1600px] px-4 pb-16 pt-7 sm:px-6 lg:px-8">
<header className="border-b border-[#dfe4ea] pb-6">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-[#edf5ff] text-[#176fc1]"><Icon className="h-6 w-6" aria-hidden="true" /></div>
<h1 className="mt-6 text-balance text-3xl font-bold tracking-[-0.03em] sm:text-4xl">{metric.label}</h1>
<p className="mt-3 max-w-3xl text-base leading-7 text-[#66758b] sm:text-lg">{metric.detailDescription}</p>
</header>
<section aria-label="指标口径" className="mt-6 grid overflow-hidden rounded-lg border border-[#e2e8f0] bg-white sm:grid-cols-3">
<div className="border-b border-slate-200 p-5 sm:border-b-0 sm:border-r">
<p className="text-xs font-bold text-slate-500">{hasFirstPassSource ? '当前首轮通过率' : hasRealProjectSource ? '当前项目数' : '总览示例值'}</p>
{isOnTimeDelivery && metric.exampleNumericValue !== undefined
? <OnTimeDeliveryRate rate={metric.exampleNumericValue} contextLabel="按时交付示例值" size="summary" />
: <p className="mt-2 text-2xl font-bold tabular-nums">{hasFirstPassSource ? formatRate(firstPassPage.summary.rate) : hasRealProjectSource ? projectPage.pagination.total : metric.exampleValue}</p>}
</div>
<div className="border-b border-slate-200 p-5 sm:border-b-0 sm:border-r">
<p className="text-xs font-bold text-slate-500">明细数据</p>
<p className="mt-2 text-base font-bold">{hasFirstPassSource ? `V1通过 ${firstPassPage.summary.firstPassApproved} / 全部 ${firstPassPage.summary.totalUploads}` : hasRealProjectSource ? `${projectPage.pagination.total} 条真实记录` : '等待业务数据源'}</p>
</div>
<div className="p-5"><p className="text-xs font-bold text-slate-500">同步状态</p><p className="mt-2 inline-flex items-center gap-2 text-base font-bold"><CalendarDays className="h-4 w-4 text-slate-500" aria-hidden="true" />{hasRealSource ? '打开页面时读取' : '每日同步待接入'}</p></div>
</section>
{hasRealProjectSource
? <ProjectList metric={metric} query={query} projectPage={projectPage} />
: hasFirstPassSource
? <FirstPassProjectList metric={metric} query={query} firstPassPage={firstPassPage} />
: <PendingDataSource metric={metric} />}
<div className="mt-10 flex flex-wrap gap-3">
<Link href="/workspaces/dashboard/core-metrics" className="inline-flex min-h-11 items-center gap-2 rounded-xl bg-[#247fd3] px-4 text-sm font-bold text-white transition-colors hover:bg-[#176fc1] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#78aee9] focus-visible:ring-offset-2"><ArrowLeft className="h-4 w-4" aria-hidden="true" />返回核心指标</Link>
<Link href="/" className="inline-flex min-h-11 items-center gap-2 rounded-xl bg-white px-4 text-sm font-bold text-[#4f6077] shadow-[0_8px_22px_rgba(67,93,126,0.08)] transition-colors hover:bg-[#f7f9fc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#78aee9]">返回部门看板<ArrowRight className="h-4 w-4" aria-hidden="true" /></Link>
</div>
</main>
</DepartmentShell>
)
}

View File

@ -0,0 +1,73 @@
import { render, screen, within } from '@testing-library/react'
import CoreMetricsDashboard from './CoreMetricsDashboard'
jest.mock('next-auth/react', () => ({
useSession: () => ({ data: { user: { name: '本地超级管理员' } } }),
signOut: jest.fn(),
}))
describe('CoreMetricsDashboard', () => {
beforeEach(() => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
data: {
totalUploads: 30,
firstPassApproved: 11,
rate: 36.7,
statusBreakdown: { approved: 11, revisionRequired: 7, pendingReview: 10, unknown: 2 },
readAt: '2026-08-14T02:00:00.000Z',
},
}),
}) as jest.Mock
})
afterEach(() => {
jest.restoreAllMocks()
})
it('展示真实首轮通过率与其余五项诚实的示例状态', async () => {
render(<CoreMetricsDashboard />)
expect(screen.getByRole('heading', { level: 1, name: '核心指标' })).toBeInTheDocument()
expect(screen.queryByRole('complementary', { name: '数据接入说明' })).not.toBeInTheDocument()
expect(await screen.findByText('36.7%')).toBeInTheDocument()
expect(screen.getByText('V1通过 11 / 全部 30')).toBeInTheDocument()
expect(screen.getByText('实时读取')).toBeInTheDocument()
expect(screen.getAllByText('示例值')).toHaveLength(5)
for (const label of ['进行中项目', '本月上新', '按时交付', '首轮通过', '团队负荷', '风险']) {
expect(screen.getByRole('heading', { level: 3, name: label })).toBeInTheDocument()
}
for (const value of ['42', '18/24', '94.6%', '36.7%', '87%', '4项']) {
expect(screen.getByText(value)).toBeInTheDocument()
}
expect(screen.getAllByText('公式待接入')).toHaveLength(5)
expect(screen.getByText('口径已定义')).toBeInTheDocument()
expect(screen.getAllByText('查看明细')).toHaveLength(6)
const onTimeDelivery = screen.getByRole('link', { name: '查看按时交付明细' })
expect(within(onTimeDelivery).getByLabelText('按时交付示例值94.6%,黄色区间90%–95%')).toHaveClass('text-[#a88a00]')
expect(within(onTimeDelivery).queryByText('黄色区间 · 90%–95%')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: '查看进行中项目明细' })).toHaveAttribute(
'href',
'/workspaces/dashboard/core-metrics/ongoing-projects',
)
expect(screen.getByRole('link', { name: '查看风险明细' })).toHaveAttribute(
'href',
'/workspaces/dashboard/core-metrics/risks',
)
expect(screen.getByRole('link', { name: /返回部门看板/ })).toHaveAttribute('href', '/')
})
it('数据接口失败时不保留虚构百分比', async () => {
global.fetch = jest.fn().mockResolvedValue({ ok: false, json: async () => ({ success: false }) }) as jest.Mock
render(<CoreMetricsDashboard />)
expect(await screen.findByText('读取失败')).toBeInTheDocument()
expect(screen.queryByText('82.3%')).not.toBeInTheDocument()
expect(screen.getByText('数据未加载')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,155 @@
'use client'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import {
ArrowLeft,
ArrowRight,
CalendarClock,
} from 'lucide-react'
import type { FirstPassReviewSummary } from '@/lib/work-review-metrics'
import DepartmentShell from './DepartmentShell'
import { CORE_METRICS, getCoreMetricHref } from './core-metrics'
import OnTimeDeliveryRate from './OnTimeDeliveryRate'
type FirstPassState =
| { status: 'loading'; data: null }
| { status: 'success'; data: FirstPassReviewSummary }
| { status: 'error'; data: null }
function formatRate(rate: number | null) {
if (rate === null) return '—'
return `${Number.isInteger(rate) ? rate.toFixed(0) : rate.toFixed(1)}%`
}
export default function CoreMetricsDashboard() {
const [firstPass, setFirstPass] = useState<FirstPassState>({ status: 'loading', data: null })
useEffect(() => {
const controller = new AbortController()
async function loadFirstPassSummary() {
try {
const response = await fetch('/api/department-dashboard/core-metrics/first-pass', {
cache: 'no-store',
signal: controller.signal,
})
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success || !payload.data) throw new Error('指标读取失败')
setFirstPass({ status: 'success', data: payload.data })
} catch (error) {
if ((error as Error).name !== 'AbortError') setFirstPass({ status: 'error', data: null })
}
}
void loadFirstPassSummary()
return () => controller.abort()
}, [])
return (
<DepartmentShell>
<main className="mx-auto max-w-[1600px] px-4 pb-16 pt-7 sm:px-6 lg:px-8">
<header className="border-b border-[#dfe4ea] pb-6">
<h1 id="core-metrics-title" className="max-w-4xl text-balance text-3xl font-bold tracking-[-0.03em] sm:text-4xl">
核心指标
</h1>
<p className="mt-3 max-w-3xl text-base leading-7 text-[#66758b] sm:text-lg">
集中查看项目进度、交付质量、团队负荷与风险状态,为部门日常经营提供统一入口。
</p>
</header>
<section aria-labelledby="metrics-heading" className="mt-6">
<div className="mb-0 flex flex-wrap items-end justify-between gap-3 rounded-t-xl border border-b-0 border-[#e2e8f0] bg-white px-6 py-5">
<div>
<h2 id="metrics-heading" className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">六项核心经营指标</h2>
<p className="mt-1 text-sm text-[#66758b]">每个分区已预留独立公式与同步状态。</p>
</div>
<p className="rounded-full bg-[#f1f6fc] px-4 py-2 text-sm font-semibold text-[#5f7189]">6 个指标分区</p>
</div>
<div
aria-describedby="metrics-disclaimer"
className="relative grid gap-px overflow-hidden rounded-b-xl border border-[#e2e8f0] bg-[#e2e9f2] pt-1 shadow-[0_6px_18px_rgba(15,23,42,0.05)] sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6"
>
<span className="absolute inset-x-0 top-0 h-1 bg-[#5a9ce6]" aria-hidden="true" />
{CORE_METRICS.map((metric) => {
const Icon = metric.icon
const isFirstPass = metric.key === 'first-pass-rate'
const isOnTimeDelivery = metric.key === 'on-time-delivery'
const badge = isFirstPass
? firstPass.status === 'success' ? '实时读取' : firstPass.status === 'error' ? '读取失败' : '正在读取'
: '示例值'
const value = isFirstPass
? firstPass.status === 'success' ? formatRate(firstPass.data.rate) : '—'
: metric.exampleValue
return (
<Link
key={metric.key}
href={getCoreMetricHref(metric.key)}
aria-label={`查看${metric.label}明细`}
className="group flex min-h-48 flex-col bg-[#f8fafd] p-5 transition-colors hover:bg-white focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#5a9ce6] sm:min-h-56 sm:p-6"
>
<div className="flex items-start justify-between gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#edf5ff] text-[#176fc1]">
<Icon className="h-5 w-5" aria-hidden="true" />
</span>
<span className="rounded-lg bg-white px-2.5 py-1 text-xs font-bold text-[#738198] shadow-[0_4px_12px_rgba(67,93,126,0.06)]">
{badge}
</span>
</div>
<h3 className="mt-6 text-base font-bold text-[#4f6077]">{metric.label}</h3>
<div aria-live={isFirstPass ? 'polite' : undefined} aria-atomic={isFirstPass ? 'true' : undefined}>
{isOnTimeDelivery && metric.exampleNumericValue !== undefined
? <OnTimeDeliveryRate rate={metric.exampleNumericValue} contextLabel={`${metric.label}示例值`} />
: (
<p className="mt-2 text-3xl font-bold tracking-[-0.03em] text-[#101828] sm:text-4xl" aria-label={`${metric.label}${isFirstPass ? '当前值' : '示例值'}${value}`}>
{value}
</p>
)}
{isFirstPass && firstPass.status === 'success' && (
<p className="mt-2 text-xs font-semibold tabular-nums text-slate-500">
V1通过 {firstPass.data.firstPassApproved} / 全部 {firstPass.data.totalUploads}
</p>
)}
{isFirstPass && firstPass.status === 'loading' && (
<p className="mt-2 text-xs font-semibold text-slate-500">正在连接作品过稿</p>
)}
</div>
<p className="mt-3 text-sm leading-6 text-[#66758b]">{metric.description}</p>
<div className="mt-auto flex flex-wrap items-center justify-between gap-x-4 gap-y-2 pt-6 text-xs font-semibold">
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-slate-500">
<span className="inline-flex items-center gap-1.5">
<CalendarClock className="h-3.5 w-3.5" aria-hidden="true" />
{isFirstPass ? (firstPass.status === 'error' ? '刷新页面重试' : '打开页面时读取') : '每日同步'}
</span>
<span aria-hidden="true">·</span>
<span>{isFirstPass ? (firstPass.status === 'error' ? '数据未加载' : '口径已定义') : '公式待接入'}</span>
</span>
<span className="inline-flex items-center gap-1 text-[#176fc1]">
查看明细
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" aria-hidden="true" />
</span>
</div>
</Link>
)
})}
</div>
<p id="metrics-disclaimer" className="mt-3 text-xs leading-5 text-[#66758b]">
“首轮通过”已读取作品过稿 V1 审核结果;其余示例值将在对应数据源与公式确认后替换。
</p>
</section>
<Link
href="/"
className="mt-10 inline-flex min-h-11 items-center gap-2 rounded-xl bg-[#247fd3] px-4 text-sm font-bold text-white transition-colors hover:bg-[#176fc1] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#78aee9] focus-visible:ring-offset-2"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
返回部门看板
</Link>
</main>
</DepartmentShell>
)
}

View File

@ -0,0 +1,94 @@
'use client'
import Link from 'next/link'
import { signOut, useSession } from 'next-auth/react'
import { ChevronDown, LogOut, UserRound } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
type DepartmentAccountMenuProps = {
collapsed: boolean
onNavigate?: () => void
}
export default function DepartmentAccountMenu({ collapsed, onNavigate }: DepartmentAccountMenuProps) {
const { data: session } = useSession()
const [open, setOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const userName = session?.user?.name || session?.user?.email || '当前用户'
const initials = userName.slice(0, 1).toUpperCase()
useEffect(() => {
const closeMenu = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) setOpen(false)
}
const closeWithEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || !open) return
event.stopPropagation()
setOpen(false)
triggerRef.current?.focus()
}
document.addEventListener('mousedown', closeMenu)
document.addEventListener('keydown', closeWithEscape)
return () => {
document.removeEventListener('mousedown', closeMenu)
document.removeEventListener('keydown', closeWithEscape)
}
}, [open])
return (
<div ref={menuRef} className="relative">
{open && (
<div
role="menu"
className={`absolute bottom-full z-50 mb-2 w-56 rounded-lg bg-white p-2 text-[#344054] shadow-[0_14px_38px_rgba(15,23,42,0.22)] ring-1 ring-[#e2e8f0] ${collapsed ? 'left-0' : 'inset-x-0 w-auto'}`}
>
<Link
href="/profile"
role="menuitem"
onClick={() => {
setOpen(false)
onNavigate?.()
}}
className="flex min-h-11 items-center gap-3 rounded-lg px-3 text-sm font-medium hover:bg-[#f2f5f8] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
>
<UserRound className="h-4 w-4" aria-hidden="true" />
个人资料
</Link>
<button
type="button"
role="menuitem"
onClick={() => signOut({ callbackUrl: '/login' })}
className="flex min-h-11 w-full items-center gap-3 rounded-lg px-3 text-left text-sm font-medium text-[#b42318] hover:bg-[#fff1f0] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f97066]"
>
<LogOut className="h-4 w-4" aria-hidden="true" />
退出登录
</button>
</div>
)}
<button
ref={triggerRef}
type="button"
aria-expanded={open}
aria-haspopup="menu"
aria-label="打开账户菜单"
title={collapsed ? userName : undefined}
onClick={() => setOpen((current) => !current)}
className={`flex min-h-12 w-full items-center rounded-lg text-left text-[#dbe4ed] transition-colors hover:bg-white/[0.08] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#69a9ff] ${collapsed ? 'justify-center px-2' : 'gap-3 px-2'}`}
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[#1677ff] text-sm font-bold text-white">{initials}</span>
{!collapsed && (
<>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">{userName}</span>
<span className="mt-0.5 block text-xs text-[#7f93a8]">账户与退出</span>
</span>
<ChevronDown className={`h-4 w-4 shrink-0 text-[#7f93a8] transition-transform ${open ? 'rotate-180' : ''}`} aria-hidden="true" />
</>
)}
</button>
</div>
)
}

View File

@ -0,0 +1,57 @@
'use client'
import Link from 'next/link'
import { Building2, Menu, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
import { forwardRef } from 'react'
type DepartmentHeaderProps = {
currentTitle: string
navigationCollapsed: boolean
navigationExpanded: boolean
navigationControls: string
onToggleNavigation: () => void
}
const DepartmentHeader = forwardRef<HTMLButtonElement, DepartmentHeaderProps>(function DepartmentHeader({
currentTitle,
navigationCollapsed,
navigationExpanded,
navigationControls,
onToggleNavigation,
}, navigationTriggerRef) {
return (
<header className="sticky top-0 z-30 flex h-[72px] items-center border-b border-[#e5e9ef] bg-white px-4 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<button
ref={navigationTriggerRef}
type="button"
aria-label="切换部门导航"
aria-expanded={navigationExpanded}
aria-controls={navigationControls}
onClick={onToggleNavigation}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-[#344054] transition-colors hover:bg-[#f2f5f8] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
>
<Menu className="h-5 w-5 lg:hidden" aria-hidden="true" />
{navigationCollapsed
? <PanelLeftOpen className="hidden h-5 w-5 lg:block" aria-hidden="true" />
: <PanelLeftClose className="hidden h-5 w-5 lg:block" aria-hidden="true" />}
</button>
<Link href="/" className="flex items-center gap-2 lg:hidden">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-[#eaf3ff] text-[#1677ff]">
<Building2 className="h-5 w-5" aria-hidden="true" />
</span>
<span className="hidden text-sm font-bold text-[#101828] sm:block">品牌中心</span>
</Link>
<div className="hidden min-w-0 lg:block">
<p className="truncate text-sm font-semibold text-[#101828]">{currentTitle}</p>
<p className="mt-0.5 text-xs text-[#98a2b3]">品牌中心部门工作台</p>
</div>
</div>
</header>
)
})
export default DepartmentHeader

View File

@ -0,0 +1,287 @@
'use client'
import Link from 'next/link'
import {
BadgeCheck,
BellRing,
Blocks,
BookOpen,
Bot,
BriefcaseBusiness,
Building2,
ChartColumn,
ChartNoAxesCombined,
ChevronDown,
ClipboardCheck,
Database,
Eye,
Gauge,
GaugeCircle,
HeartPulse,
LibraryBig,
Network,
Palette,
Rocket,
Settings2,
ShieldCheck,
SlidersHorizontal,
TrendingUp,
UsersRound,
Workflow,
X,
type LucideIcon,
} from 'lucide-react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import DepartmentAccountMenu from './DepartmentAccountMenu'
import DepartmentHeader from './DepartmentHeader'
import { WORKSPACE_SECTIONS, type WorkspaceSectionKey } from './workspace-navigation'
const SECTION_ICONS: Record<WorkspaceSectionKey, LucideIcon> = {
dashboard: Gauge,
business: BriefcaseBusiness,
assets: Bot,
organization: UsersRound,
governance: Settings2,
}
const ITEM_ICONS: Record<string, LucideIcon> = {
'core-metrics': GaugeCircle,
'business-trends': TrendingUp,
'brand-health': HeartPulse,
'team-capacity': UsersRound,
'management-focus': Eye,
'product-launch': Rocket,
'brand-building': BadgeCheck,
'creative-production': Palette,
'performance-conversion': ChartNoAxesCombined,
'project-collaboration': Network,
'material-library': LibraryBig,
'brand-knowledge': BookOpen,
'automation-workflows': Workflow,
'value-metrics': ChartColumn,
'people-structure': Network,
'team-functions': Blocks,
'performance-review': ClipboardCheck,
'people-workload': UsersRound,
'roles-permissions': ShieldCheck,
'data-definitions': Database,
'editing-capabilities': SlidersHorizontal,
notifications: BellRing,
}
function readPathname() {
return typeof window === 'undefined' ? '/' : window.location.pathname
}
function getPageTitle(pathname: string) {
if (pathname === '/') return '部门总览'
for (const section of WORKSPACE_SECTIONS) {
const item = section.items.find((candidate) => pathname.startsWith(candidate.href))
if (item) return item.title
}
return '品牌中心部门看板'
}
export default function DepartmentShell({ children }: { children: ReactNode }) {
const [pathname, setPathname] = useState('/')
const [navigationCollapsed, setNavigationCollapsed] = useState(false)
const [mobileNavigationOpen, setMobileNavigationOpen] = useState(false)
const [isMobileViewport, setIsMobileViewport] = useState(false)
const navigationTriggerRef = useRef<HTMLButtonElement>(null)
const mobileDialogRef = useRef<HTMLDivElement>(null)
const contentRootRef = useRef<HTMLDivElement>(null)
const activeSection = WORKSPACE_SECTIONS.find((section) => section.items.some((item) => pathname.startsWith(item.href)))
const [expandedSections, setExpandedSections] = useState<WorkspaceSectionKey[]>(activeSection ? [activeSection.key] : ['dashboard'])
useEffect(() => {
setPathname(readPathname())
}, [])
useEffect(() => {
if (typeof window.matchMedia !== 'function') return
const media = window.matchMedia('(max-width: 1023px)')
const syncViewport = () => {
setIsMobileViewport(media.matches)
if (!media.matches) setMobileNavigationOpen(false)
}
syncViewport()
media.addEventListener('change', syncViewport)
return () => media.removeEventListener('change', syncViewport)
}, [])
useEffect(() => {
if (!mobileNavigationOpen) return
const dialog = mobileDialogRef.current
const contentRoot = contentRootRef.current
const navigationTrigger = navigationTriggerRef.current
const previousOverflow = document.body.style.overflow
contentRoot?.setAttribute('inert', '')
document.body.style.overflow = 'hidden'
const focusableSelector = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
const focusables = dialog ? Array.from(dialog.querySelectorAll<HTMLElement>(focusableSelector)) : []
;(dialog?.querySelector<HTMLElement>('[aria-current="page"]') || focusables[0])?.focus()
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
setMobileNavigationOpen(false)
return
}
if (event.key !== 'Tab' || focusables.length === 0) return
const first = focusables[0]
const last = focusables[focusables.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = previousOverflow
contentRoot?.removeAttribute('inert')
navigationTrigger?.focus()
}
}, [mobileNavigationOpen])
useEffect(() => {
if (activeSection && !expandedSections.includes(activeSection.key)) {
setExpandedSections((current) => [...current, activeSection.key])
}
}, [activeSection, expandedSections])
const toggleSection = (key: WorkspaceSectionKey) => {
setExpandedSections((current) => current.includes(key)
? current.filter((candidate) => candidate !== key)
: [...current, key])
}
const toggleNavigation = () => {
if (typeof window.matchMedia === 'function' && window.matchMedia('(max-width: 1023px)').matches) {
setMobileNavigationOpen((current) => !current)
} else {
setNavigationCollapsed((current) => !current)
}
}
const sidebar = (
<aside aria-label="品牌中心主导航" className="flex h-full min-h-0 flex-col bg-[#06192d] text-white">
<div className={`flex h-[72px] shrink-0 items-center border-b border-white/10 ${navigationCollapsed ? 'justify-center px-3' : 'gap-3 px-5'}`}>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[#1677ff] text-white">
<Building2 className="h-5 w-5" aria-hidden="true" />
</span>
{!navigationCollapsed && <span className="truncate text-lg font-bold tracking-[-0.02em]">品牌中心</span>}
<button
type="button"
aria-label="关闭部门导航"
onClick={() => setMobileNavigationOpen(false)}
className="ml-auto flex h-9 w-9 items-center justify-center rounded-lg text-white/70 hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#69a9ff] lg:hidden"
>
<X className="h-5 w-5" aria-hidden="true" />
</button>
</div>
<nav aria-label="部门工作区" className="min-h-0 flex-1 overflow-y-auto px-3 py-4">
<div className="space-y-1">
{WORKSPACE_SECTIONS.map((section) => {
const Icon = SECTION_ICONS[section.key]
const expanded = expandedSections.includes(section.key)
const sectionActive = activeSection?.key === section.key
return (
<div key={section.key}>
<button
type="button"
aria-expanded={expanded}
aria-controls={`department-nav-${section.key}`}
title={navigationCollapsed ? section.title : undefined}
onClick={() => navigationCollapsed ? setNavigationCollapsed(false) : toggleSection(section.key)}
className={`flex min-h-11 w-full items-center rounded-lg text-left text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#69a9ff] ${navigationCollapsed ? 'justify-center px-2' : 'gap-3 px-3'} ${sectionActive ? 'text-white' : 'text-[#aebdcd] hover:bg-white/[0.08] hover:text-white'}`}
>
<Icon className="h-5 w-5 shrink-0" aria-hidden="true" />
{!navigationCollapsed && (
<>
<span className="min-w-0 flex-1 truncate">{section.title}</span>
<ChevronDown className={`h-4 w-4 shrink-0 transition-transform ${expanded ? 'rotate-180' : ''}`} aria-hidden="true" />
</>
)}
</button>
{!navigationCollapsed && expanded && (
<div id={`department-nav-${section.key}`} className="ml-5 mt-1 space-y-1 border-l border-white/10 pl-3">
{section.items.map((item) => {
const active = pathname.startsWith(item.href)
const ItemIcon = ITEM_ICONS[item.key] || Icon
return (
<Link
key={item.key}
href={item.href}
onClick={() => setMobileNavigationOpen(false)}
aria-current={active ? 'page' : undefined}
className={`flex min-h-10 items-center gap-2.5 rounded-lg px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#69a9ff] ${active ? 'bg-[#1677ff] font-semibold text-white' : 'text-[#9fb0c2] hover:bg-white/[0.08] hover:text-white'}`}
>
<ItemIcon className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{item.title}</span>
{item.status === 'connected' && <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[#31d0aa]" aria-label="已接入" />}
</Link>
)
})}
</div>
)}
</div>
)
})}
</div>
</nav>
<div className={`shrink-0 border-t border-white/10 py-3 ${navigationCollapsed ? 'px-3' : 'px-4'}`}>
<DepartmentAccountMenu
collapsed={navigationCollapsed}
onNavigate={() => setMobileNavigationOpen(false)}
/>
</div>
</aside>
)
return (
<div className="min-h-screen bg-[#f2f4f7] text-[#101828]">
<div id="department-desktop-navigation" className={`fixed inset-y-0 left-0 z-40 hidden transition-[width] duration-200 lg:block ${navigationCollapsed ? 'w-20' : 'w-[272px]'}`}>
{sidebar}
</div>
{mobileNavigationOpen && (
<>
<div aria-hidden="true" className="fixed inset-0 z-40 bg-[#06192d]/55 backdrop-blur-[2px] lg:hidden" onClick={() => setMobileNavigationOpen(false)} />
<div
ref={mobileDialogRef}
id="department-mobile-navigation"
role="dialog"
aria-modal="true"
aria-label="品牌中心移动导航"
className="fixed inset-y-0 left-0 z-50 w-[min(86vw,304px)] lg:hidden"
>
{sidebar}
</div>
</>
)}
<div ref={contentRootRef} className={`min-h-screen transition-[padding] duration-200 ${navigationCollapsed ? 'lg:pl-20' : 'lg:pl-[272px]'}`}>
<DepartmentHeader
ref={navigationTriggerRef}
currentTitle={getPageTitle(pathname)}
navigationCollapsed={navigationCollapsed}
navigationExpanded={isMobileViewport ? mobileNavigationOpen : !navigationCollapsed}
navigationControls={isMobileViewport ? 'department-mobile-navigation' : 'department-desktop-navigation'}
onToggleNavigation={toggleNavigation}
/>
{children}
</div>
</div>
)
}

View File

@ -0,0 +1,54 @@
import { getOnTimeDeliveryBand, type OnTimeDeliveryBand } from './core-metrics'
type OnTimeDeliveryRateProps = {
rate: number
contextLabel: string
size?: 'card' | 'summary'
}
const BAND_PRESENTATION: Record<OnTimeDeliveryBand, {
label: string
range: string
textClassName: string
}> = {
green: {
label: '绿色区间',
range: '>95%',
textClassName: 'text-[#067647]',
},
yellow: {
label: '黄色区间',
range: '90%–95%',
textClassName: 'text-[#a88a00]',
},
red: {
label: '红色区间',
range: '<90%',
textClassName: 'text-[#b42318]',
},
}
function formatRate(rate: number) {
return `${Number.isInteger(rate) ? rate.toFixed(0) : rate.toFixed(1)}%`
}
export default function OnTimeDeliveryRate({
rate,
contextLabel,
size = 'card',
}: OnTimeDeliveryRateProps) {
const band = getOnTimeDeliveryBand(rate)
const presentation = BAND_PRESENTATION[band]
const value = formatRate(rate)
return (
<div>
<p
className={`mt-2 font-bold tabular-nums tracking-[-0.03em] ${size === 'card' ? 'text-3xl sm:text-4xl' : 'text-2xl'} ${presentation.textClassName}`}
aria-label={`${contextLabel}${value},${presentation.label}${presentation.range}`}
>
{value}
</p>
</div>
)
}

View File

@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react'
import WorkspacePlaceholder from './WorkspacePlaceholder'
jest.mock('next-auth/react', () => ({
useSession: () => ({ data: { user: { name: '本地超级管理员' } } }),
signOut: jest.fn(),
}))
describe('WorkspacePlaceholder', () => {
it('清楚标注二级框架状态并提供返回入口', () => {
render(
<WorkspacePlaceholder
title="业务经营"
description="围绕品牌、新品、创意与转化,组织跨团队经营工作。"
capabilities={['新品上新', '品牌塑造']}
accent="#e98722"
accentSoft="#fff4e7"
accentText="#9a4d08"
/>
)
expect(screen.getByRole('heading', { level: 1, name: '业务经营' })).toBeInTheDocument()
expect(screen.getByText('二级页面框架已建立')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /返回部门看板/ })).toHaveAttribute('href', '/')
})
it('直接显示小项内容,不重复渲染面包屑', () => {
render(
<WorkspacePlaceholder
title="核心指标"
description="当前先建立独立二级页面框架。"
accent="#e73b32"
accentSoft="#fff0ee"
accentText="#b4231c"
/>
)
expect(screen.queryByRole('navigation', { name: '面包屑' })).not.toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: '核心指标' })).toBeInTheDocument()
})
})

View File

@ -0,0 +1,59 @@
import Link from 'next/link'
import { ArrowLeft, Layers3 } from 'lucide-react'
import DepartmentShell from './DepartmentShell'
type WorkspacePlaceholderProps = {
title: string
description: string
capabilities?: string[]
accent: string
accentSoft: string
accentText: string
}
export default function WorkspacePlaceholder({
title,
description,
capabilities = [],
accentSoft,
accentText,
}: WorkspacePlaceholderProps) {
return (
<DepartmentShell>
<main className="mx-auto max-w-[1600px] px-4 pb-16 pt-7 sm:px-6 lg:px-8">
<section className="rounded-xl border border-[#e2e8f0] bg-white p-6 shadow-[0_6px_18px_rgba(15,23,42,0.05)] sm:p-8">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl" style={{ backgroundColor: accentSoft, color: accentText }}>
<Layers3 className="h-6 w-6" aria-hidden="true" />
</div>
<h1 className="mt-6 text-balance text-3xl font-bold tracking-[-0.03em] sm:text-4xl">{title}</h1>
<p className="mt-3 max-w-3xl text-base leading-7 text-[#66758b] sm:text-lg">{description}</p>
<div className="mt-8 rounded-lg border border-[#e5e9ef] bg-[#f8fafc] p-5 sm:p-6">
<h2 className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">二级页面框架已建立</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-[#66758b]">
当前仅提供页面结构与能力范围,业务数据和具体操作将在后续阶段接入。
</p>
{capabilities.length > 0 && (
<div className="mt-6 flex flex-wrap gap-2" aria-label={`${title}规划能力`}>
{capabilities.map((capability) => (
<span key={capability} className="rounded-xl bg-white px-3 py-2 text-sm font-semibold text-[#4f6077] shadow-[0_5px_16px_rgba(67,93,126,0.07)]">
{capability}
</span>
))}
</div>
)}
<Link
href="/"
className="mt-8 inline-flex min-h-11 items-center gap-2 rounded-xl bg-[#247fd3] px-4 text-sm font-bold text-white transition-colors hover:bg-[#176fc1] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#78aee9] focus-visible:ring-offset-2"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
返回部门看板
</Link>
</div>
</section>
</main>
</DepartmentShell>
)
}

View File

@ -0,0 +1,27 @@
import {
BRAND_HEALTH_BRANDS,
BRAND_HEALTH_DIMENSIONS,
BRAND_HEALTH_FORMULA,
} from './brand-health'
describe('brand health configuration', () => {
it('contains six unique brands split evenly across the two creative teams', () => {
expect(BRAND_HEALTH_BRANDS).toHaveLength(6)
expect(new Set(BRAND_HEALTH_BRANDS.map((brand) => brand.key)).size).toBe(6)
expect(BRAND_HEALTH_BRANDS.filter((brand) => brand.team === '创意A')).toHaveLength(3)
expect(BRAND_HEALTH_BRANDS.filter((brand) => brand.team === '创意B')).toHaveLength(3)
})
it('uses the five confirmed health dimensions and a 100 percent total weight', () => {
expect(BRAND_HEALTH_DIMENSIONS.map((dimension) => dimension.label)).toEqual([
'商业表现',
'品牌一致性',
'内容资产',
'用户反馈',
'创新',
])
expect(BRAND_HEALTH_DIMENSIONS.reduce((total, dimension) => total + dimension.weight, 0)).toBe(100)
expect(BRAND_HEALTH_FORMULA).toContain('商业表现×35%')
expect(BRAND_HEALTH_FORMULA).toContain('创新×5%')
})
})

View File

@ -0,0 +1,34 @@
export type BrandTeam = '创意A' | '创意B'
export type BrandHealthBrand = {
key: string
name: string
team: BrandTeam
}
export type BrandHealthDimension = {
key: string
label: string
weight: number
}
export const BRAND_HEALTH_BRANDS: BrandHealthBrand[] = [
{ key: 'pumei', name: '朴美', team: '创意A' },
{ key: 'sophia', name: '索菲亚', team: '创意A' },
{ key: 'kukahome', name: '顾家', team: '创意A' },
{ key: 'ouyilang', name: '欧意朗', team: '创意B' },
{ key: 'zhuohe', name: '卓禾', team: '创意B' },
{ key: 'yijiada', name: '亿家达', team: '创意B' },
]
export const BRAND_HEALTH_DIMENSIONS: BrandHealthDimension[] = [
{ key: 'commercial-performance', label: '商业表现', weight: 35 },
{ key: 'brand-consistency', label: '品牌一致性', weight: 25 },
{ key: 'content-assets', label: '内容资产', weight: 20 },
{ key: 'user-feedback', label: '用户反馈', weight: 15 },
{ key: 'innovation', label: '创新', weight: 5 },
]
export const BRAND_HEALTH_FORMULA = BRAND_HEALTH_DIMENSIONS
.map((dimension) => `${dimension.label}×${dimension.weight}%`)
.join(' + ')

View File

@ -0,0 +1,36 @@
import {
BUSINESS_TREND_BRANDS,
BUSINESS_TREND_STAGES,
BUSINESS_TREND_WEEK_LABELS,
summarizeBusinessTrend,
} from './business-trends'
describe('business trends configuration', () => {
it('contains six brands, eight weeks and the confirmed five project stages', () => {
expect(BUSINESS_TREND_BRANDS).toHaveLength(6)
expect(new Set(BUSINESS_TREND_BRANDS.map((brand) => brand.key)).size).toBe(6)
expect(BUSINESS_TREND_BRANDS.filter((brand) => brand.team === '创意A')).toHaveLength(3)
expect(BUSINESS_TREND_BRANDS.filter((brand) => brand.team === '创意B')).toHaveLength(3)
expect(BUSINESS_TREND_WEEK_LABELS).toHaveLength(8)
expect(BUSINESS_TREND_STAGES.map((stage) => stage.label)).toEqual([
'Brief',
'设计',
'拍摄',
'后期',
'待上线',
])
for (const brand of BUSINESS_TREND_BRANDS) {
expect(brand.planned).toHaveLength(8)
expect(brand.actual).toHaveLength(8)
}
})
it('calculates attainment from actual completed total divided by planned total', () => {
expect(summarizeBusinessTrend(BUSINESS_TREND_BRANDS[0])).toEqual({
plannedTotal: 44,
actualTotal: 40,
attainmentRate: 90.9,
})
})
})

View File

@ -0,0 +1,98 @@
import type { BrandTeam } from './brand-health'
export const BUSINESS_TREND_WEEK_LABELS = [
'W1',
'W2',
'W3',
'W4',
'W5',
'W6',
'W7',
'W8',
] as const
export const BUSINESS_TREND_STAGES = [
{ key: 'brief', label: 'Brief' },
{ key: 'design', label: '设计' },
{ key: 'shooting', label: '拍摄' },
{ key: 'post', label: '后期' },
{ key: 'pending-launch', label: '待上线' },
] as const
export type BusinessTrendStageKey = (typeof BUSINESS_TREND_STAGES)[number]['key']
export type BusinessTrendBrand = {
key: string
name: string
team: BrandTeam
planned: number[]
actual: number[]
stages: Record<BusinessTrendStageKey, number>
}
export type BusinessTrendSummary = {
plannedTotal: number
actualTotal: number
attainmentRate: number | null
}
export const BUSINESS_TREND_BRANDS: BusinessTrendBrand[] = [
{
key: 'pumei',
name: '朴美',
team: '创意A',
planned: [4, 5, 5, 6, 6, 5, 7, 6],
actual: [3, 5, 4, 6, 5, 5, 6, 6],
stages: { brief: 6, design: 8, shooting: 5, post: 7, 'pending-launch': 4 },
},
{
key: 'sophia',
name: '索菲亚',
team: '创意A',
planned: [5, 5, 6, 6, 7, 6, 7, 7],
actual: [5, 4, 6, 5, 6, 6, 7, 6],
stages: { brief: 7, design: 9, shooting: 6, post: 8, 'pending-launch': 5 },
},
{
key: 'kukahome',
name: '顾家',
team: '创意A',
planned: [3, 4, 4, 5, 5, 5, 6, 6],
actual: [3, 3, 4, 4, 5, 4, 6, 5],
stages: { brief: 5, design: 7, shooting: 4, post: 6, 'pending-launch': 3 },
},
{
key: 'ouyilang',
name: '欧意朗',
team: '创意B',
planned: [4, 4, 5, 5, 5, 6, 6, 6],
actual: [4, 4, 5, 5, 4, 6, 5, 6],
stages: { brief: 6, design: 7, shooting: 5, post: 7, 'pending-launch': 5 },
},
{
key: 'zhuohe',
name: '卓禾',
team: '创意B',
planned: [3, 3, 4, 4, 5, 5, 5, 6],
actual: [2, 3, 4, 4, 4, 5, 5, 5],
stages: { brief: 4, design: 6, shooting: 4, post: 5, 'pending-launch': 3 },
},
{
key: 'yijiada',
name: '亿家达',
team: '创意B',
planned: [4, 5, 4, 5, 6, 6, 7, 7],
actual: [4, 5, 4, 5, 5, 6, 7, 6],
stages: { brief: 6, design: 8, shooting: 6, post: 8, 'pending-launch': 4 },
},
]
export function summarizeBusinessTrend(brand: BusinessTrendBrand): BusinessTrendSummary {
const plannedTotal = brand.planned.reduce((total, value) => total + value, 0)
const actualTotal = brand.actual.reduce((total, value) => total + value, 0)
const attainmentRate = plannedTotal === 0
? null
: Math.round((actualTotal / plannedTotal) * 1000) / 10
return { plannedTotal, actualTotal, attainmentRate }
}

View File

@ -0,0 +1,25 @@
import {
CORE_METRICS,
getOnTimeDeliveryBand,
getCoreMetric,
getCoreMetricHref,
getCoreMetricParams,
} from './core-metrics'
describe('core metrics registry', () => {
it('提供六个唯一且可解析的指标入口', () => {
expect(CORE_METRICS).toHaveLength(6)
expect(new Set(CORE_METRICS.map((metric) => metric.key)).size).toBe(6)
expect(getCoreMetric('ongoing-projects')?.label).toBe('进行中项目')
expect(getCoreMetric('unknown')).toBeNull()
expect(getCoreMetricHref('monthly-launches')).toBe('/workspaces/dashboard/core-metrics/monthly-launches')
expect(getCoreMetricParams()).toHaveLength(6)
})
it('按确认阈值给按时交付率分配绿色、黄色与红色区间', () => {
expect(getOnTimeDeliveryBand(95.1)).toBe('green')
expect(getOnTimeDeliveryBand(95)).toBe('yellow')
expect(getOnTimeDeliveryBand(90)).toBe('yellow')
expect(getOnTimeDeliveryBand(89.9)).toBe('red')
})
})

View File

@ -0,0 +1,110 @@
import {
BadgeCheck,
BriefcaseBusiness,
Clock3,
PackagePlus,
TriangleAlert,
UsersRound,
type LucideIcon,
} from 'lucide-react'
export type CoreMetricKey =
| 'ongoing-projects'
| 'monthly-launches'
| 'on-time-delivery'
| 'first-pass-rate'
| 'team-workload'
| 'risks'
export type CoreMetric = {
key: CoreMetricKey
label: string
exampleValue: string
exampleNumericValue?: number
description: string
icon: LucideIcon
detailDescription: string
plannedFields: string[]
}
export const CORE_METRICS: CoreMetric[] = [
{
key: 'ongoing-projects',
label: '进行中项目',
exampleValue: '42',
description: '当前处于进行中状态的项目总量。',
icon: BriefcaseBusiness,
detailDescription: '展示当前仍在流转中的项目,并按最近更新时间排序。',
plannedFields: ['项目名称', '当前状态', '提交人', '负责人', '作品日期', '最近更新'],
},
{
key: 'monthly-launches',
label: '本月上新',
exampleValue: '18/24',
description: '本月已上新数量与计划目标的对照。',
icon: PackagePlus,
detailDescription: '用于查看本月上新计划、已完成项目与当前节点。',
plannedFields: ['商品或项目', '品牌', '计划日期', '当前节点', '负责人'],
},
{
key: 'on-time-delivery',
label: '按时交付',
exampleValue: '94.6%',
exampleNumericValue: 94.6,
description: '在约定节点内完成交付的项目占比。',
icon: Clock3,
detailDescription: '用于核对计划交付时间、实际交付时间与时间偏差。',
plannedFields: ['项目', '计划时间', '实际时间', '时间偏差', '结果'],
},
{
key: 'first-pass-rate',
label: '首轮通过',
exampleValue: '82.3%',
description: 'V1版审核通过作品数占全部上传作品数的比例。',
icon: BadgeCheck,
detailDescription: '查看作品过稿中每个作品的 V1 审核结果,以及首轮通过率的真实统计口径。',
plannedFields: ['交付内容', '评审人', '首轮结果', '修改轮次', '评审时间'],
},
{
key: 'team-workload',
label: '团队负荷',
exampleValue: '87%',
description: '当前任务负荷相对可用产能的占比。',
icon: UsersRound,
detailDescription: '用于对照团队可用产能、已分配任务与负荷风险。',
plannedFields: ['团队或人员', '可用产能', '已分配', '负荷率', '风险状态'],
},
{
key: 'risks',
label: '风险',
exampleValue: '4项',
description: '当前需要重点跟进的风险事项数量。',
icon: TriangleAlert,
detailDescription: '用于集中查看风险事项、责任人、截止时间与处理进度。',
plannedFields: ['风险事项', '风险等级', '负责人', '截止时间', '处理状态'],
},
]
export function getCoreMetric(key: string): CoreMetric | null {
return CORE_METRICS.find((metric) => metric.key === key) || null
}
export function getCoreMetricHref(key: CoreMetricKey): string {
return `/workspaces/dashboard/core-metrics/${key}`
}
export function getCoreMetricParams() {
return CORE_METRICS.map((metric) => ({
section: 'dashboard',
item: 'core-metrics',
metric: metric.key,
}))
}
export type OnTimeDeliveryBand = 'green' | 'yellow' | 'red'
export function getOnTimeDeliveryBand(rate: number): OnTimeDeliveryBand {
if (rate > 95) return 'green'
if (rate >= 90) return 'yellow'
return 'red'
}

View File

@ -0,0 +1,19 @@
import {
getFrameworkWorkspaceParams,
getWorkspaceItem,
WORKSPACE_SECTIONS,
} from './workspace-navigation'
describe('workspace navigation', () => {
it('为22个小项提供唯一入口,并为未接入项生成独立框架页', () => {
const items = WORKSPACE_SECTIONS.flatMap((section) => section.items)
const hrefs = items.map((item) => item.href)
expect(items).toHaveLength(22)
expect(new Set(hrefs).size).toBe(22)
expect(getFrameworkWorkspaceParams()).toHaveLength(20)
expect(getWorkspaceItem('dashboard', 'core-metrics')?.item.title).toBe('核心指标')
expect(getWorkspaceItem('assets', 'material-library')?.item.status).toBe('connected')
expect(getWorkspaceItem('assets', 'automation-workflows')?.item.status).toBe('connected')
})
})

View File

@ -0,0 +1,114 @@
export type WorkspaceSectionKey = 'dashboard' | 'business' | 'assets' | 'organization' | 'governance'
export type WorkspaceItem = {
key: string
title: string
href: string
status: 'connected' | 'framework'
productName?: string
}
export type WorkspaceSection = {
key: WorkspaceSectionKey
title: string
description: string
summary: string
accent: string
accentSoft: string
accentText: string
items: WorkspaceItem[]
}
export const WORKSPACE_SECTIONS: WorkspaceSection[] = [
{
key: 'dashboard',
title: '中心仪表盘',
description: '聚合指标、趋势和团队运行状态,为管理者提供统一观察面。',
summary: '掌握经营与交付全貌',
accent: '#e73b32',
accentSoft: '#fff0ee',
accentText: '#b4231c',
items: [
{ key: 'core-metrics', title: '核心指标', href: '/workspaces/dashboard/core-metrics', status: 'framework' },
{ key: 'business-trends', title: '业务趋势', href: '/workspaces/dashboard/business-trends', status: 'framework' },
{ key: 'brand-health', title: '六品牌健康度', href: '/workspaces/dashboard/brand-health', status: 'framework' },
{ key: 'team-capacity', title: '团队产能', href: '/workspaces/dashboard/team-capacity', status: 'framework' },
{ key: 'management-focus', title: '管理者关注', href: '/workspaces/dashboard/management-focus', status: 'framework' },
],
},
{
key: 'business',
title: '业务经营',
description: '围绕品牌、新品、创意与转化,组织跨团队经营工作。',
summary: '推动品牌与项目向前',
accent: '#e98722',
accentSoft: '#fff4e7',
accentText: '#9a4d08',
items: [
{ key: 'product-launch', title: '新品上新', href: '/workspaces/business/product-launch', status: 'framework' },
{ key: 'brand-building', title: '品牌塑造', href: '/workspaces/business/brand-building', status: 'framework' },
{ key: 'creative-production', title: '创意生产', href: '/workspaces/business/creative-production', status: 'framework' },
{ key: 'performance-conversion', title: '业绩转化', href: '/workspaces/business/performance-conversion', status: 'framework' },
{ key: 'project-collaboration', title: '项目协同', href: '/workspaces/business/project-collaboration', status: 'framework' },
],
},
{
key: 'assets',
title: '资产与AI自动化',
description: '沉淀内容资产与品牌知识,把重复生产环节交给自动化流程。',
summary: '让资产可复用、流程可自动化',
accent: '#0f9f72',
accentSoft: '#e9f8f2',
accentText: '#087052',
items: [
{ key: 'material-library', title: '素材资产库', href: '/assets/material-library', status: 'connected', productName: '由壹点3D素材' },
{ key: 'brand-knowledge', title: '品牌知识库', href: '/workspaces/assets/brand-knowledge', status: 'framework' },
{ key: 'automation-workflows', title: '自动化工作流', href: '/workspaces/assets/automation-workflows', status: 'connected', productName: '2 项工作流已接入' },
{ key: 'value-metrics', title: '价值指标', href: '/workspaces/assets/value-metrics', status: 'framework' },
],
},
{
key: 'organization',
title: '组织管理',
description: '明确人员结构、团队职责和绩效协作关系,建立组织视图。',
summary: '看清组织、职责与负荷',
accent: '#c99300',
accentSoft: '#fff8d9',
accentText: '#725400',
items: [
{ key: 'people-structure', title: '人员架构', href: '/workspaces/organization/people-structure', status: 'framework' },
{ key: 'team-functions', title: '团队职能', href: '/workspaces/organization/team-functions', status: 'framework' },
{ key: 'performance-review', title: '绩效考核', href: '/workspaces/organization/performance-review', status: 'framework' },
{ key: 'people-workload', title: '人员与负荷', href: '/workspaces/organization/people-workload', status: 'framework' },
],
},
{
key: 'governance',
title: '治理与设置',
description: '统一权限、数据口径和通知机制,支撑可控、可追溯的协作。',
summary: '建立部门级工作规则',
accent: '#3569df',
accentSoft: '#edf3ff',
accentText: '#214ba7',
items: [
{ key: 'roles-permissions', title: '权限角色', href: '/workspaces/governance/roles-permissions', status: 'framework' },
{ key: 'data-definitions', title: '数据口径', href: '/workspaces/governance/data-definitions', status: 'framework' },
{ key: 'editing-capabilities', title: '编辑能力', href: '/workspaces/governance/editing-capabilities', status: 'framework' },
{ key: 'notifications', title: '通知机制', href: '/workspaces/governance/notifications', status: 'framework' },
],
},
]
export function getWorkspaceItem(sectionKey: string, itemKey: string) {
const section = WORKSPACE_SECTIONS.find((candidate) => candidate.key === sectionKey)
const item = section?.items.find((candidate) => candidate.key === itemKey)
return section && item ? { section, item } : null
}
export function getFrameworkWorkspaceParams() {
return WORKSPACE_SECTIONS.flatMap((section) =>
section.items
.filter((item) => item.status === 'framework')
.map((item) => ({ section: section.key, item: item.key }))
)
}

View File

@ -2,9 +2,6 @@
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { useEffect } from 'react'
import BrandLogo from '@/components/BrandLogo'
import { DEFAULT_LOGIN_LOGO_URL } from '@/lib/brand-assets'
export function WorkReviewHeader({ export function WorkReviewHeader({
name, name,
@ -29,58 +26,34 @@ export function WorkReviewHeader({
}) { }) {
const initials = name.trim().slice(0, 2) || '我' const initials = name.trim().slice(0, 2) || '我'
useEffect(() => {
document.title = '由壹点·品牌中心 · 作品过稿看板'
}, [])
return ( return (
<header className="sticky top-0 z-30 border-b border-[#e7e8ea] bg-[#fafafa]"> <header className="sticky top-0 z-30 bg-[#1f2937] text-white shadow-sm">
<div className={`mx-auto flex min-h-[64px] w-full flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3 sm:flex-nowrap sm:px-6 ${wide ? 'max-w-[1600px]' : 'max-w-[1280px]'}`}> <div className={`mx-auto flex min-h-[64px] w-full flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3 sm:flex-nowrap sm:px-6 ${wide ? 'max-w-[1600px]' : 'max-w-[1280px]'}`}>
<div className="flex min-w-0 items-center gap-2.5"> <div className="flex min-w-0 items-center gap-3">
<Link <div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-white/15 bg-white/15 text-[12px] font-bold">
href="/" {avatar ? (
aria-label="返回品牌中心首页" <Image
className="flex shrink-0 items-center gap-2 rounded-sm text-[13px] font-semibold tracking-tight text-[#1a1c1d] transition-opacity hover:opacity-70 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa]" src={avatar}
> alt={name}
<BrandLogo width={36}
src={DEFAULT_LOGIN_LOGO_URL} height={36}
fallbackSrc={DEFAULT_LOGIN_LOGO_URL} unoptimized
alt="" className="h-full w-full object-cover"
width={28} />
height={28} ) : initials}
unoptimized </div>
className="h-7 w-7 rounded-md object-cover"
/>
<span className="hidden lg:inline">由壹点·品牌中心</span>
</Link>
<span aria-hidden="true" className="text-[12px] text-[#d4d6da]">/</span>
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-[15px] font-semibold tracking-[-0.02em] text-[#1a1c1d]">作品过稿看板</div> <div className="truncate text-[16px] font-bold">作品过稿看板</div>
<div className="font-en mt-0.5 truncate text-[10px] uppercase tracking-[0.14em] text-[#86898e]">Review board</div> <div className="truncate text-[11px] font-medium text-white/65">{name}</div>
</div> </div>
</div> </div>
<div className={`flex shrink-0 items-center gap-2 ${secondaryAction ? 'w-full sm:w-auto' : ''}`}> <div className={`flex shrink-0 items-center gap-2 ${secondaryAction ? 'w-full sm:w-auto' : ''}`}>
<div className="hidden items-center gap-2 border-r border-[#e7e8ea] pr-3 sm:flex">
<div className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full border border-[#e7e8ea] bg-[#e9ebef] text-[10px] font-bold text-[#1a1c1d]">
{avatar ? (
<Image
src={avatar}
alt={name}
width={28}
height={28}
unoptimized
className="h-full w-full object-cover"
/>
) : initials}
</div>
<span className="max-w-24 truncate text-[12px] text-[#6b7075]">{name}</span>
</div>
{secondaryAction && ( {secondaryAction && (
<button <button
type="button" type="button"
onClick={secondaryAction.onClick} onClick={secondaryAction.onClick}
className="inline-flex min-h-10 flex-1 items-center justify-center rounded-lg bg-[#1a1c1d] px-3 text-[12px] font-bold text-white transition-all hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa] sm:min-h-9 sm:flex-none sm:px-4 sm:text-[13px]" className="inline-flex min-h-10 flex-1 items-center justify-center rounded bg-[#0050cb] px-3 text-[12px] font-bold text-white transition-colors hover:bg-[#003fa3] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[#1f2937] sm:min-h-9 sm:flex-none sm:px-4 sm:text-[13px]"
> >
<span className="mr-1 text-[17px] font-light leading-none" aria-hidden="true"> <span className="mr-1 text-[17px] font-light leading-none" aria-hidden="true">
{secondaryAction.icon === 'add' ? '+' : '←'} {secondaryAction.icon === 'add' ? '+' : '←'}
@ -92,10 +65,10 @@ export function WorkReviewHeader({
<button <button
type="button" type="button"
onClick={primaryAction.onClick} onClick={primaryAction.onClick}
className={`inline-flex items-center justify-center rounded-lg px-3 text-[12px] font-bold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa] sm:min-h-9 sm:px-4 sm:text-[13px] ${ className={`inline-flex items-center justify-center rounded px-3 text-[12px] font-bold text-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[#1f2937] sm:min-h-9 sm:px-4 sm:text-[13px] ${
secondaryAction secondaryAction
? 'min-h-10 flex-1 border border-[#1a1c1d] bg-white text-[#1a1c1d] hover:bg-[#1a1c1d] hover:text-white sm:flex-none' ? 'min-h-10 flex-1 border border-white/25 bg-white/10 hover:bg-white/15 sm:flex-none'
: 'min-h-9 bg-[#1a1c1d] text-white hover:opacity-80' : 'min-h-9 bg-[#0050cb] hover:bg-[#003fa3]'
}`} }`}
> >
<span className="mr-1 text-[17px] font-light leading-none" aria-hidden="true"> <span className="mr-1 text-[17px] font-light leading-none" aria-hidden="true">
@ -105,10 +78,11 @@ export function WorkReviewHeader({
</button> </button>
)} )}
<Link <Link
href="/materials" href="/"
className="inline-flex min-h-10 items-center justify-center rounded-lg px-2 text-[12px] font-semibold text-[#6b7075] transition-colors hover:bg-[#e9ebef] hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/60 sm:min-h-9 sm:px-3 sm:text-[13px]" target="_top"
className="inline-flex min-h-10 items-center justify-center rounded px-2 text-[12px] font-semibold text-white/80 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white sm:min-h-9 sm:px-3 sm:text-[13px]"
> >
创意资产库 返回品牌中心
</Link> </Link>
</div> </div>
</div> </div>

28
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,28 @@
name: gongxiangp2-local-dev
# 叠加在 docker-compose.local-test.yml 上使用。
# 基础文件提供隔离的 PostgreSQL、Redis、MinIO;此文件把应用切换为热更新开发模式。
services:
app:
build:
target: dev
args:
BASE_IMAGE: docker.m.daocloud.io/library/node:22-alpine
image: gongxiangp2-local-dev:latest
environment:
NODE_ENV: development
WATCHPACK_POLLING: "true"
command:
- /bin/sh
- -c
- npm run prisma:generate && npm run prisma:push && npm run dev -- --hostname 0.0.0.0
volumes:
- .:/app
- local-dev-node-modules:/app/node_modules
- local-dev-next:/app/.next
- local-dev-uploads:/app/uploads
volumes:
local-dev-node-modules:
local-dev-next:
local-dev-uploads:

View File

@ -0,0 +1,30 @@
import { buildDepartmentDashboardEmployeeWhere } from '@/lib/department-dashboard-scope'
describe('department dashboard scope', () => {
it('matches only the exact department subtree and memberships', () => {
const where = buildDepartmentDashboardEmployeeWhere(' 集团 / 品牌中心 ')
expect(where).toEqual({
OR: [
{ departmentPath: '集团/品牌中心' },
{ departmentPath: { startsWith: '集团/品牌中心/' } },
{
departmentMemberships: {
some: {
OR: [
{ departmentPath: '集团/品牌中心' },
{ departmentPath: { startsWith: '集团/品牌中心/' } },
],
},
},
},
],
})
})
it('fails closed when no reliable department path is available', () => {
expect(buildDepartmentDashboardEmployeeWhere(null)).toEqual({
id: '__department_dashboard_scope_unavailable__',
})
})
})

View File

@ -0,0 +1,36 @@
import {
buildFirstPassReviewSummary,
normalizeFirstPassReviewStatus,
} from '@/lib/work-review-metrics'
describe('work-review metrics', () => {
it('uses every uploaded V1 in the denominator and only approved V1 in the numerator', () => {
const summary = buildFirstPassReviewSummary([
{ status: 'approved', count: 11 },
{ status: 'revision_required', count: 7 },
{ status: 'pending_review', count: 10 },
{ status: 'legacy_state', count: 2 },
], new Date('2026-08-14T02:00:00.000Z'))
expect(summary).toEqual({
totalUploads: 30,
firstPassApproved: 11,
rate: 36.7,
statusBreakdown: {
approved: 11,
revisionRequired: 7,
pendingReview: 10,
unknown: 2,
},
readAt: '2026-08-14T02:00:00.000Z',
})
})
it('returns a null rate when no V1 work has been uploaded', () => {
expect(buildFirstPassReviewSummary([], new Date('2026-08-14T02:00:00.000Z')).rate).toBeNull()
})
it('maps unrecognised database states to the explicit unknown bucket', () => {
expect(normalizeFirstPassReviewStatus('archived')).toBe('unknown')
})
})

View File

@ -0,0 +1,37 @@
const UNAVAILABLE_DEPARTMENT_SCOPE_ID = '__department_dashboard_scope_unavailable__'
function normalizeDepartmentPath(value: unknown) {
return String(value ?? '')
.split('/')
.map((part) => part.trim())
.filter(Boolean)
.join('/')
}
/**
* 部门看板只按登录人的明确部门路径取数。
*
* 不使用部门名称兜底,避免不同组织下的同名部门被合并;没有可靠路径时返回一个
* 不可能命中的员工 ID,使指标安全地显示为空,而不是扩大到个人可见或全公司范围。
*/
export function buildDepartmentDashboardEmployeeWhere(scopeDepartmentPath: unknown) {
const departmentPath = normalizeDepartmentPath(scopeDepartmentPath)
if (!departmentPath) return { id: UNAVAILABLE_DEPARTMENT_SCOPE_ID }
return {
OR: [
{ departmentPath },
{ departmentPath: { startsWith: `${departmentPath}/` } },
{
departmentMemberships: {
some: {
OR: [
{ departmentPath },
{ departmentPath: { startsWith: `${departmentPath}/` } },
],
},
},
},
],
}
}

View File

@ -0,0 +1,65 @@
export type FirstPassReviewStatus = 'approved' | 'revision_required' | 'pending_review' | 'unknown'
export type FirstPassReviewStatusCount = {
status: string
count: number
}
export type FirstPassReviewSummary = {
totalUploads: number
firstPassApproved: number
rate: number | null
statusBreakdown: {
approved: number
revisionRequired: number
pendingReview: number
unknown: number
}
readAt: string
}
export function normalizeFirstPassReviewStatus(status: string): FirstPassReviewStatus {
if (status === 'approved' || status === 'revision_required' || status === 'pending_review') {
return status
}
return 'unknown'
}
/**
* 首轮通过率 = V1 审核通过作品数 / 全部 V1 上传作品数。
*
* 评审中、首轮未通过以及未知历史状态都属于已上传作品,因此必须保留在分母中。
*/
export function buildFirstPassReviewSummary(
rows: FirstPassReviewStatusCount[],
readAt = new Date(),
): FirstPassReviewSummary {
const statusBreakdown = {
approved: 0,
revisionRequired: 0,
pendingReview: 0,
unknown: 0,
}
for (const row of rows) {
const count = Math.max(0, Number(row.count) || 0)
const normalizedStatus = normalizeFirstPassReviewStatus(row.status)
if (normalizedStatus === 'approved') statusBreakdown.approved += count
else if (normalizedStatus === 'revision_required') statusBreakdown.revisionRequired += count
else if (normalizedStatus === 'pending_review') statusBreakdown.pendingReview += count
else statusBreakdown.unknown += count
}
const totalUploads = Object.values(statusBreakdown).reduce((total, count) => total + count, 0)
const firstPassApproved = statusBreakdown.approved
return {
totalUploads,
firstPassApproved,
rate: totalUploads === 0
? null
: Math.round((firstPassApproved / totalUploads) * 1000) / 10,
statusBreakdown,
readAt: readAt.toISOString(),
}
}

View File

@ -55,6 +55,13 @@ import {
validateWorkReviewAnnotations, validateWorkReviewAnnotations,
type NormalizedWorkReviewAnnotation, type NormalizedWorkReviewAnnotation,
} from '@/lib/work-review-policy' } from '@/lib/work-review-policy'
import {
buildFirstPassReviewSummary,
normalizeFirstPassReviewStatus,
type FirstPassReviewStatus,
type FirstPassReviewSummary,
} from '@/lib/work-review-metrics'
import { buildDepartmentDashboardEmployeeWhere } from '@/lib/department-dashboard-scope'
const REVIEW_ROLES = WORK_REVIEW_REVIEW_ROLES const REVIEW_ROLES = WORK_REVIEW_REVIEW_ROLES
@ -542,6 +549,249 @@ export async function getWorkReviewBoard(session: Session | null, params?: URLSe
} }
} }
export type OngoingWorkReviewProjectListItem = {
id: string
title: string
description: string | null
status: 'pending_review' | 'revision_required'
workDate: string
latestVersion: number
submitterName: string
managerName: string
finalReviewerName: string
updatedAt: string
}
export type OngoingWorkReviewProjectPage = {
items: OngoingWorkReviewProjectListItem[]
pagination: {
page: number
pageSize: number
total: number
totalPages: number
}
}
export type FirstPassReviewProjectListItem = {
id: string
title: string
currentStatus: string
latestVersion: number
submitterName: string
managerName: string
finalReviewerName: string
workDate: string
updatedAt: string
firstVersionStatus: FirstPassReviewStatus
firstVersionSubmittedAt: string
firstVersionCompletedAt: string | null
}
export type FirstPassReviewProjectPage = {
summary: FirstPassReviewSummary
items: FirstPassReviewProjectListItem[]
pagination: {
page: number
pageSize: number
total: number
totalPages: number
}
}
function getDepartmentDashboardProjectWhere(ctx: Awaited<ReturnType<typeof getContext>>) {
return {
submitterEmployee: {
is: buildDepartmentDashboardEmployeeWhere(ctx.scopeDepartmentPath),
},
}
}
function getFirstPassReviewWhere(projectWhere: any) {
return {
version: 1,
project: { is: projectWhere },
}
}
async function getFirstPassReviewSummaryForProjectWhere(
projectWhere: any,
): Promise<FirstPassReviewSummary> {
const rows = await prisma.workReviewVersion.groupBy({
by: ['status'],
where: getFirstPassReviewWhere(projectWhere),
_count: { _all: true },
})
return buildFirstPassReviewSummary(rows.map((row) => ({
status: row.status,
count: row._count._all,
})))
}
/**
* 部门看板“首轮通过”的真实统计口径。
*
* 分子仅包含 V1 状态为 approved 的作品,分母包含当前账号明确部门路径范围内的
* 全部 V1 上传作品。评审中、首轮未通过和未知历史状态都会保留在分母中。
*/
export async function getFirstPassReviewSummary(
session: Session | null,
): Promise<FirstPassReviewSummary> {
const ctx = await getContext(session)
return getFirstPassReviewSummaryForProjectWhere(getDepartmentDashboardProjectWhere(ctx))
}
export async function getFirstPassReviewProjects(
session: Session | null,
params?: { page?: number; pageSize?: number; query?: string },
): Promise<FirstPassReviewProjectPage> {
const ctx = await getContext(session)
const page = Math.max(1, Number(params?.page || 1) || 1)
const pageSize = Math.min(50, Math.max(1, Number(params?.pageSize || 20) || 20))
const query = clean(params?.query)
const filters: any[] = [
getDepartmentDashboardProjectWhere(ctx),
{ versions: { some: { version: 1 } } },
]
if (query) {
filters.push({
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ submitterName: { contains: query, mode: 'insensitive' } },
{ managerName: { contains: query, mode: 'insensitive' } },
],
})
}
const where = { AND: filters }
const [summary, total, projects] = await Promise.all([
getFirstPassReviewSummaryForProjectWhere(where),
prisma.workReviewProject.count({ where }),
prisma.workReviewProject.findMany({
where,
select: {
id: true,
title: true,
status: true,
latestVersion: true,
submitterName: true,
managerName: true,
finalReviewerName: true,
workDate: true,
updatedAt: true,
versions: {
where: { version: 1 },
select: {
status: true,
submittedAt: true,
completedAt: true,
},
take: 1,
},
},
orderBy: [{ updatedAt: 'desc' }, { workDate: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
])
return {
summary,
items: projects.map((project) => {
const firstVersion = project.versions[0]
return {
id: project.id,
title: project.title,
currentStatus: project.status,
latestVersion: project.latestVersion,
submitterName: project.submitterName,
managerName: project.managerName,
finalReviewerName: project.finalReviewerName,
workDate: project.workDate.toISOString(),
updatedAt: project.updatedAt.toISOString(),
firstVersionStatus: normalizeFirstPassReviewStatus(firstVersion.status),
firstVersionSubmittedAt: firstVersion.submittedAt.toISOString(),
firstVersionCompletedAt: firstVersion.completedAt?.toISOString() || null,
}
}),
pagination: {
page,
pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
}
}
/**
* 部门看板“进行中项目”的真实数据口径。
*
* 当前仓库唯一的项目实体是作品过稿项目,因此这里仅统计仍在评审或待修改的
* WorkReviewProject,并严格限制在登录人的明确部门路径范围内,避免跨部门展示项目。
*/
export async function getOngoingWorkReviewProjects(
session: Session | null,
params?: { page?: number; pageSize?: number; query?: string },
): Promise<OngoingWorkReviewProjectPage> {
const ctx = await getContext(session)
const page = Math.max(1, Number(params?.page || 1) || 1)
const pageSize = Math.min(50, Math.max(1, Number(params?.pageSize || 20) || 20))
const query = clean(params?.query)
const filters: any[] = [
getDepartmentDashboardProjectWhere(ctx),
{ status: { in: [VERSION_STATUS.PENDING_REVIEW, VERSION_STATUS.REVISION_REQUIRED] } },
]
if (query) {
filters.push({
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ submitterName: { contains: query, mode: 'insensitive' } },
{ managerName: { contains: query, mode: 'insensitive' } },
],
})
}
const where = { AND: filters }
const [total, projects] = await prisma.$transaction([
prisma.workReviewProject.count({ where }),
prisma.workReviewProject.findMany({
where,
select: {
id: true,
title: true,
description: true,
status: true,
workDate: true,
latestVersion: true,
submitterName: true,
managerName: true,
finalReviewerName: true,
updatedAt: true,
},
orderBy: [{ updatedAt: 'desc' }, { workDate: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
])
return {
items: projects.map((project) => ({
...project,
status: project.status as OngoingWorkReviewProjectListItem['status'],
workDate: project.workDate.toISOString(),
updatedAt: project.updatedAt.toISOString(),
})),
pagination: {
page,
pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
}
}
function getStoredWorkReviewAssetUrls(value: unknown) { function getStoredWorkReviewAssetUrls(value: unknown) {
const payload = serializeWorkReviewImagePayload(value) const payload = serializeWorkReviewImagePayload(value)
const groups = [...payload.imageGroups, ...payload.referenceImageGroups] const groups = [...payload.imageGroups, ...payload.referenceImageGroups]

View File

@ -9,6 +9,15 @@ import {
const API_CORS_ALLOWED_HEADERS = const API_CORS_ALLOWED_HEADERS =
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization, Cookie' 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization, Cookie'
const DEPARTMENT_PAGE_PREFIXES = [
'/assets/material-library',
'/workspaces',
]
function isDepartmentPage(pathname: string): boolean {
return pathname === '/' || DEPARTMENT_PAGE_PREFIXES.some((prefix) => pathname.startsWith(prefix))
}
function getAllowedCorsOrigins(request: NextRequest): Set<string> { function getAllowedCorsOrigins(request: NextRequest): Set<string> {
const values = [ const values = [
request.nextUrl.origin, request.nextUrl.origin,
@ -80,7 +89,7 @@ export async function middleware(request: NextRequest) {
// /materials(素材库)与 /organization(组织管理)与首页同级,需登录; // /materials(素材库)与 /organization(组织管理)与首页同级,需登录;
// /organization 沿用管理员角色门(与 /admin 一致) // /organization 沿用管理员角色门(与 /admin 一致)
if ( if (
pathname === '/' || isDepartmentPage(pathname) ||
pathname === '/materials' || pathname === '/materials' ||
pathname.startsWith('/admin') || pathname.startsWith('/admin') ||
pathname.startsWith('/organization') pathname.startsWith('/organization')
@ -151,7 +160,7 @@ export async function middleware(request: NextRequest) {
// 对于 HTML 页面,根据是否有新版本决定缓存策略 // 对于 HTML 页面,根据是否有新版本决定缓存策略
if ( if (
pathname === '/' || isDepartmentPage(pathname) ||
pathname === '/materials' || pathname === '/materials' ||
pathname.startsWith('/admin') || pathname.startsWith('/admin') ||
pathname.startsWith('/organization') || pathname.startsWith('/organization') ||
@ -160,9 +169,9 @@ export async function middleware(request: NextRequest) {
isAuthEntryPage || isAuthEntryPage ||
pathname.endsWith('.html') pathname.endsWith('.html')
) { ) {
if (isAuthEntryPage || shouldDisableCache) { if (isDepartmentPage(pathname) || pathname.startsWith('/admin') || isAuthEntryPage || shouldDisableCache) {
// 有新版本:禁用缓存,强制浏览器获取最新代码 // 认证页面或有新版本时禁用缓存,避免用户范围数据被共享缓存
response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0') response.headers.set('Cache-Control', 'private, no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0')
response.headers.set('Pragma', 'no-cache') response.headers.set('Pragma', 'no-cache')
response.headers.set('Expires', '0') response.headers.set('Expires', '0')
} else { } else {
@ -188,6 +197,8 @@ export async function middleware(request: NextRequest) {
export const config = { export const config = {
matcher: [ matcher: [
'/', '/',
'/assets/material-library/:path*',
'/workspaces/:path*',
'/login', '/login',
'/materials', '/materials',
'/organization/:path*', '/organization/:path*',