feat: 上线品牌中心部门看板
This commit is contained in:
parent
23083c2ef5
commit
92ed481314
5
.gitignore
vendored
5
.gitignore
vendored
@ -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
531
.impeccable/design.json
Normal 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
304
DESIGN.md
@ -1,65 +1,281 @@
|
|||||||
# DESIGN.md — 由壹点·品牌中心(看板首页)
|
---
|
||||||
|
name: "品牌中心部门看板"
|
||||||
|
description: "以深色部门导航轨组织业务、资产、组织与治理工作的内部数据工作台。"
|
||||||
|
colors:
|
||||||
|
nav-rail: "#06192d"
|
||||||
|
action-blue: "#1677ff"
|
||||||
|
canvas: "#f2f4f7"
|
||||||
|
surface: "#ffffff"
|
||||||
|
surface-soft: "#f8fafc"
|
||||||
|
border: "#e2e8f0"
|
||||||
|
text-primary: "#101828"
|
||||||
|
text-secondary: "#66758b"
|
||||||
|
nav-secondary: "#aebdcd"
|
||||||
|
connected: "#31d0aa"
|
||||||
|
danger: "#b42318"
|
||||||
|
delivery-yellow: "#a88a00"
|
||||||
|
business-orange: "#9a4d08"
|
||||||
|
assets-green: "#087052"
|
||||||
|
organization-gold: "#725400"
|
||||||
|
governance-blue: "#214ba7"
|
||||||
|
workflow-active: "#e6f4ff"
|
||||||
|
workflow-active-title: "#0050cb"
|
||||||
|
workflow-active-description: "#315f91"
|
||||||
|
workflow-inactive-title: "#4f6077"
|
||||||
|
workflow-inactive-description: "#667085"
|
||||||
|
workflow-hover: "#eef2f6"
|
||||||
|
workflow-focus: "#5a9ce6"
|
||||||
|
typography:
|
||||||
|
display:
|
||||||
|
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
|
||||||
|
fontSize: "36px"
|
||||||
|
fontWeight: 700
|
||||||
|
lineHeight: 1.2
|
||||||
|
letterSpacing: "-0.03em"
|
||||||
|
headline:
|
||||||
|
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
|
||||||
|
fontSize: "30px"
|
||||||
|
fontWeight: 700
|
||||||
|
lineHeight: 1.25
|
||||||
|
letterSpacing: "-0.02em"
|
||||||
|
title:
|
||||||
|
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
|
||||||
|
fontSize: "20px"
|
||||||
|
fontWeight: 700
|
||||||
|
lineHeight: 1.4
|
||||||
|
body:
|
||||||
|
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
|
||||||
|
fontSize: "16px"
|
||||||
|
fontWeight: 400
|
||||||
|
lineHeight: 1.75
|
||||||
|
label:
|
||||||
|
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
|
||||||
|
fontSize: "14px"
|
||||||
|
fontWeight: 600
|
||||||
|
lineHeight: 1.4
|
||||||
|
rounded:
|
||||||
|
sm: "4px"
|
||||||
|
md: "8px"
|
||||||
|
lg: "12px"
|
||||||
|
full: "9999px"
|
||||||
|
spacing:
|
||||||
|
xs: "4px"
|
||||||
|
sm: "8px"
|
||||||
|
md: "12px"
|
||||||
|
lg: "16px"
|
||||||
|
xl: "24px"
|
||||||
|
xxl: "32px"
|
||||||
|
components:
|
||||||
|
navigation-active:
|
||||||
|
backgroundColor: "{colors.action-blue}"
|
||||||
|
textColor: "{colors.surface}"
|
||||||
|
rounded: "{rounded.md}"
|
||||||
|
padding: "12px"
|
||||||
|
panel:
|
||||||
|
backgroundColor: "{colors.surface}"
|
||||||
|
textColor: "{colors.text-primary}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: "24px"
|
||||||
|
button-primary:
|
||||||
|
backgroundColor: "{colors.action-blue}"
|
||||||
|
textColor: "{colors.surface}"
|
||||||
|
rounded: "{rounded.md}"
|
||||||
|
padding: "12px 16px"
|
||||||
|
field:
|
||||||
|
backgroundColor: "{colors.surface}"
|
||||||
|
textColor: "{colors.text-primary}"
|
||||||
|
rounded: "{rounded.md}"
|
||||||
|
padding: "12px 16px"
|
||||||
|
status-connected:
|
||||||
|
backgroundColor: "{colors.connected}"
|
||||||
|
textColor: "{colors.nav-rail}"
|
||||||
|
rounded: "{rounded.full}"
|
||||||
|
size: "6px"
|
||||||
|
workflow-secondary-nav:
|
||||||
|
backgroundColor: "{colors.surface-soft}"
|
||||||
|
textColor: "{colors.workflow-inactive-title}"
|
||||||
|
rounded: "{rounded.md}"
|
||||||
|
padding: "16px"
|
||||||
|
width: "232px"
|
||||||
|
workflow-secondary-item-active:
|
||||||
|
backgroundColor: "{colors.workflow-active}"
|
||||||
|
textColor: "{colors.workflow-active-title}"
|
||||||
|
rounded: "{rounded.md}"
|
||||||
|
padding: "10px 12px"
|
||||||
|
height: "58px"
|
||||||
|
---
|
||||||
|
|
||||||
<!-- 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 内打开并嵌套品牌中心首页。
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@ -31,6 +31,20 @@ RUN --mount=type=cache,target=/root/.npm \
|
|||||||
node -e "const sharp=require('sharp'); sharp(Buffer.alloc(4),{raw:{width:1,height:1,channels:4}}).png().toBuffer().then(()=>console.log('sharp dependency smoke passed')).catch((error)=>{console.error(error);process.exit(1)})" && \
|
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
|
||||||
|
|||||||
56
PRODUCT.md
56
PRODUCT.md
@ -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
|
||||||
|
|
||||||
内部后台工具,目标用户为内部员工;遵循基础可访问性(语义化、键盘可操作、对比度达标)。
|
门户的核心导航必须支持键盘操作、清晰焦点、语义化标签,并在桌面端与移动端保持可读和可用。
|
||||||
|
|||||||
45
README.md
45
README.md
@ -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
|
||||||
|
|||||||
@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/assets/material-library/layout.tsx
Normal file
14
app/assets/material-library/layout.tsx
Normal 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
|
||||||
|
}
|
||||||
1639
app/assets/material-library/page.tsx
Normal file
1639
app/assets/material-library/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||||
>
|
>
|
||||||
返回素材库
|
返回素材库
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
333
app/page.tsx
333
app/page.tsx
@ -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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
app/workspaces/[section]/[item]/[metric]/error.tsx
Normal file
22
app/workspaces/[section]/[item]/[metric]/error.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
app/workspaces/[section]/[item]/[metric]/loading.tsx
Normal file
20
app/workspaces/[section]/[item]/[metric]/loading.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
app/workspaces/[section]/[item]/[metric]/not-found.tsx
Normal file
17
app/workspaces/[section]/[item]/[metric]/not-found.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
app/workspaces/[section]/[item]/[metric]/page.tsx
Normal file
48
app/workspaces/[section]/[item]/[metric]/page.tsx
Normal 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} />
|
||||||
|
}
|
||||||
81
app/workspaces/[section]/[item]/page.tsx
Normal file
81
app/workspaces/[section]/[item]/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
@ -217,23 +230,25 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
101
components/department-dashboard/AutomationWorkflowsDashboard.tsx
Normal file
101
components/department-dashboard/AutomationWorkflowsDashboard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
118
components/department-dashboard/BrandCenterDashboard.tsx
Normal file
118
components/department-dashboard/BrandCenterDashboard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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', '/')
|
||||||
|
})
|
||||||
|
})
|
||||||
105
components/department-dashboard/BrandHealthDashboard.tsx
Normal file
105
components/department-dashboard/BrandHealthDashboard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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', '/')
|
||||||
|
})
|
||||||
|
})
|
||||||
161
components/department-dashboard/BusinessTrendsDashboard.tsx
Normal file
161
components/department-dashboard/BusinessTrendsDashboard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
125
components/department-dashboard/CoreMetricDetail.test.tsx
Normal file
125
components/department-dashboard/CoreMetricDetail.test.tsx
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
416
components/department-dashboard/CoreMetricDetail.tsx
Normal file
416
components/department-dashboard/CoreMetricDetail.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
155
components/department-dashboard/CoreMetricsDashboard.tsx
Normal file
155
components/department-dashboard/CoreMetricsDashboard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
components/department-dashboard/DepartmentAccountMenu.tsx
Normal file
94
components/department-dashboard/DepartmentAccountMenu.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
components/department-dashboard/DepartmentHeader.tsx
Normal file
57
components/department-dashboard/DepartmentHeader.tsx
Normal 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
|
||||||
287
components/department-dashboard/DepartmentShell.tsx
Normal file
287
components/department-dashboard/DepartmentShell.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
components/department-dashboard/OnTimeDeliveryRate.tsx
Normal file
54
components/department-dashboard/OnTimeDeliveryRate.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
59
components/department-dashboard/WorkspacePlaceholder.tsx
Normal file
59
components/department-dashboard/WorkspacePlaceholder.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
components/department-dashboard/brand-health.test.ts
Normal file
27
components/department-dashboard/brand-health.test.ts
Normal 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%')
|
||||||
|
})
|
||||||
|
})
|
||||||
34
components/department-dashboard/brand-health.ts
Normal file
34
components/department-dashboard/brand-health.ts
Normal 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(' + ')
|
||||||
36
components/department-dashboard/business-trends.test.ts
Normal file
36
components/department-dashboard/business-trends.test.ts
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
98
components/department-dashboard/business-trends.ts
Normal file
98
components/department-dashboard/business-trends.ts
Normal 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 }
|
||||||
|
}
|
||||||
25
components/department-dashboard/core-metrics.test.ts
Normal file
25
components/department-dashboard/core-metrics.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
110
components/department-dashboard/core-metrics.ts
Normal file
110
components/department-dashboard/core-metrics.ts
Normal 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'
|
||||||
|
}
|
||||||
19
components/department-dashboard/workspace-navigation.test.ts
Normal file
19
components/department-dashboard/workspace-navigation.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
114
components/department-dashboard/workspace-navigation.ts
Normal file
114
components/department-dashboard/workspace-navigation.ts
Normal 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 }))
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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
28
docker-compose.dev.yml
Normal 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:
|
||||||
30
lib/__tests__/department-dashboard-scope.test.ts
Normal file
30
lib/__tests__/department-dashboard-scope.test.ts
Normal 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__',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
36
lib/__tests__/work-review-metrics.test.ts
Normal file
36
lib/__tests__/work-review-metrics.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
37
lib/department-dashboard-scope.ts
Normal file
37
lib/department-dashboard-scope.ts
Normal 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}/` } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
65
lib/work-review-metrics.ts
Normal file
65
lib/work-review-metrics.ts
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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]
|
||||||
|
|||||||
@ -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*',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user