feat: connect material reuse value metrics

This commit is contained in:
江林铖 2026-09-07 12:20:03 +08:00
parent 576b62507f
commit c15444060b
8 changed files with 418 additions and 5 deletions

View File

@ -119,7 +119,7 @@ components:
品牌中心是一套固定导航、快速切换与逐层下钻的内部运营界面。深海军蓝导航轨提供稳定坐标,白色操作顶栏承载全局控制,冷灰画布上以矩形白色工作面板组织真实数据、示例状态与待接入框架。
它保留品牌中心自己的五个职责域、17 个小项入口,以及素材库、自动化工作流、人员架构与数据口径四项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。品牌中心架构外的普通用户只进入素材资产库,使用无侧栏、无返回品牌中心入口的独立工作壳。
它保留品牌中心自己的五个职责域、17 个小项入口,以及素材库、自动化工作流、人员架构、数据口径与价值指标五项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。品牌中心架构外的普通用户只进入素材资产库,使用无侧栏、无返回品牌中心入口的独立工作壳。
**Key Characteristics:**
@ -303,7 +303,8 @@ components:
### Do:
- **Do** 让品牌中心成员从根路径直接进入核心指标,并在框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell;范围外普通用户在素材库使用独立工作壳。
- **Do** 保持 5 个职责域、17 个小项、11 个框架入口,以及素材库、自动化工作流、人员架构和数据口径四项已接入业务的产品真相。
- **Do** 保持 5 个职责域、17 个小项、10 个框架入口,以及素材库、自动化工作流、人员架构、数据口径和价值指标五项已接入业务的产品真相。
- **Do** 让价值指标直接复用素材资产库数据,按资产类型显示复用率,并按下载次数降序展示明细。
- **Do** 明确区分真实数据、示例值、公式待接入与空数据。
- **Do** 在桌面与 390px 移动宽度验证导航、焦点和横向溢出。
- **Do** 让自动化工作流的菜单、`?workflow=` 和右侧真实业务操作面始终同步。

View File

@ -34,10 +34,11 @@ web
- “本月上新”只读取表单字段“需求类型”为“新品”的六品牌美术设计流程,不以流程标题关键词判断,也不计“优化”等其他需求类型;分子为本月整条流程已完结数量,分母为本月已完结数量与当前进行中数量之和;其中进行中流程直接复用“进行中项目”的美术设计流程状态、时间与当前品牌中心处理人条件。
- “按时交付”只统计对应主管/规则审核节点在本月完成、审批状态为已完结且审批结论未拒绝的美术设计与内容视频流程;待计算流程不进入分母。详情页可在全部流程、美术设计流程和内容视频流程之间筛选,公式、数量与明细必须共用当前筛选口径。
- “首轮通过”读取作品过稿数据,分子为 V1 版本审核通过的作品数,分母为当前已通过的作品数;详情页列出部门范围内所有含 V1 的作品,搜索只过滤明细,不改变顶部完整口径。
- “价值指标”直接读取素材资产库的可见素材,按资产类型展示下载过的资产数、该类型总数与复用率,并按下载次数从高到低列出该类型资产明细。
- “团队负荷”统计品牌中心在职人员当前进行中的流程负荷,排除指定管理人员和商品企划部;该部门及其人员不进入流程归属、部门明细、人员分母或平均负荷。
- “团队产能”仅展示品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部五个二级部门,并按该顺序展示近 8 个上海自然周的当前在职人数、收到流程数和完结流程数;人数统一读取当前钉钉组织架构,不还原历史人员快照。每个部门再按美术设计流程、内容视频流程分别展示近 8 周趋势与明细;流程按当前任务负责人部门归属映射,但美术设计流程不计“品牌策划主管分配任务/品牌策划主管审核”,内容视频流程不计“内容主管分配任务/内容主管审核”等管理节点,点击周次查看进行中与已完结明细。
- “团队职能”按用户确认的职责图展示品牌创意A部、品牌创意B部、空间设计部和综合支持部四个团队,明确负责人、服务品牌、前置能力、岗位及职责边界;它是静态职责参考,不展示虚构人数或经营指标。
- 除素材资产库、自动化工作流、人员架构、数据口径和已接入的中心指标外,其余板块现阶段只展示可理解、可扩展的模块框架,不虚构业务数据。
- 除素材资产库、自动化工作流、人员架构、数据口径、价值指标和已接入的中心指标外,其余板块现阶段只展示可理解、可扩展的模块框架,不虚构业务数据。
- 沿用项目现有 Next.js、NextAuth、Tailwind CSS 和权限体系。
## Brand Commitments

View File

@ -0,0 +1,204 @@
import { getServerSession } from 'next-auth'
import { NextRequest, NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import {
buildMaterialWhereFromSearchParams,
createMaterialQueryContext,
normalizeMaterialTypeForUi,
} from '@/lib/material-query'
import { MATERIAL_TYPE_OPTIONS } from '@/lib/material-type-options'
import { calculateReuseRate, type ValueMetricsMaterial } from '@/lib/value-metrics'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const PRIVATE_HEADERS = {
'Cache-Control': 'private, no-store, max-age=0',
}
const MAX_DETAIL_LIMIT = 100
type MaterialTypeCountRow = {
materialType: string
_count: { _all: number }
}
function typeOrder(type: string) {
const index = MATERIAL_TYPE_OPTIONS.indexOf(type as typeof MATERIAL_TYPE_OPTIONS[number])
return index === -1 ? MATERIAL_TYPE_OPTIONS.length : index
}
function buildTypeSummaries(
totalRows: MaterialTypeCountRow[],
reusedRows: MaterialTypeCountRow[],
) {
const grouped = new Map<string, { rawTypes: Set<string>; totalCount: number; reusedCount: number }>()
for (const row of totalRows) {
const label = normalizeMaterialTypeForUi(row.materialType)
if (!label) continue
const current = grouped.get(label) || { rawTypes: new Set<string>(), totalCount: 0, reusedCount: 0 }
current.rawTypes.add(row.materialType)
current.totalCount += Number(row._count?._all || 0)
grouped.set(label, current)
}
for (const row of reusedRows) {
const label = normalizeMaterialTypeForUi(row.materialType)
if (!label) continue
const current = grouped.get(label) || { rawTypes: new Set<string>(), totalCount: 0, reusedCount: 0 }
current.rawTypes.add(row.materialType)
current.reusedCount += Number(row._count?._all || 0)
grouped.set(label, current)
}
return Array.from(grouped.entries())
.map(([label, value]) => ({
key: label,
label,
totalCount: value.totalCount,
reusedCount: value.reusedCount,
reuseRate: calculateReuseRate(value.reusedCount, value.totalCount),
rawTypes: Array.from(value.rawTypes),
}))
.sort((a, b) => typeOrder(a.label) - typeOrder(b.label) || a.label.localeCompare(b.label, 'zh-CN'))
}
function toMaterialDetail(material: any): ValueMetricsMaterial {
const authorName = String(material.user?.realName || material.user?.username || material.authorName || '').trim() || '未标注'
const title = String(material.reportTitle || material.productModel || material.taskNo || '').trim() || `素材 ${String(material.id).slice(0, 8)}`
return {
id: String(material.id),
title,
materialType: normalizeMaterialTypeForUi(material.materialType) || String(material.materialType || '未分类'),
authorName,
createdAt: new Date(material.createdAt).toISOString(),
downloadCount: Number(material.downloadCount || 0),
categoryName: String(material.categoryName || '').trim(),
sceneName: String(material.sceneName || '').trim(),
platformName: String(material.platformName || '').trim(),
}
}
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user || !(session.user as any).id) {
return NextResponse.json({ success: false, error: '未登录', code: 'UNAUTHORIZED' }, { status: 401, headers: PRIVATE_HEADERS })
}
// 复用素材资产库的筛选、删除状态、素材类型权限和私密任务详情准入规则,
// 保证价值指标不会比用户在资产库里能看到的范围更宽。
const user = session.user as any
const baseParams = new URLSearchParams()
const context = await createMaterialQueryContext(baseParams, user)
const { where, empty } = await buildMaterialWhereFromSearchParams(baseParams, user, { context })
if (empty) {
return NextResponse.json({
success: true,
data: {
types: [],
selectedType: null,
selectedTypeSummary: null,
materials: [],
materialsTotal: 0,
hasMore: false,
summary: { totalAssets: 0, reusedAssets: 0, reuseRate: null },
},
}, { headers: PRIVATE_HEADERS })
}
const [totalRows, reusedRows] = await Promise.all([
prisma.material.groupBy({
by: ['materialType'],
where,
_count: { _all: true },
}) as unknown as Promise<MaterialTypeCountRow[]>,
prisma.material.groupBy({
by: ['materialType'],
where: { AND: [where, { downloadCount: { gt: 0 } }] },
_count: { _all: true },
}) as unknown as Promise<MaterialTypeCountRow[]>,
])
const typeRows = buildTypeSummaries(totalRows, reusedRows)
const requestedType = normalizeMaterialTypeForUi(request.nextUrl.searchParams.get('materialType'))
const selectedSummary = typeRows.find((type) => type.key === requestedType) || typeRows[0] || null
const selectedType = selectedSummary?.key || null
const detailLimit = Math.min(Math.max(Number.parseInt(request.nextUrl.searchParams.get('limit') || '100', 10) || 100, 1), MAX_DETAIL_LIMIT)
const detailOffset = Math.max(Number.parseInt(request.nextUrl.searchParams.get('offset') || '0', 10) || 0, 0)
let materials: ValueMetricsMaterial[] = []
if (selectedSummary) {
const detailRows = await prisma.material.findMany({
where: {
AND: [where, { materialType: { in: (selectedSummary as any).rawTypes } }],
},
select: {
id: true,
reportTitle: true,
productModel: true,
taskNo: true,
materialType: true,
authorName: true,
createdAt: true,
downloadCount: true,
categoryName: true,
sceneName: true,
platformName: true,
user: { select: { realName: true, username: true } },
},
orderBy: [
{ downloadCount: 'desc' },
{ createdAt: 'desc' },
{ id: 'asc' },
],
skip: detailOffset,
take: detailLimit + 1,
})
const hasMore = detailRows.length > detailLimit
materials = detailRows.slice(0, detailLimit).map(toMaterialDetail)
const totalAssets = typeRows.reduce((sum, type) => sum + type.totalCount, 0)
const reusedAssets = typeRows.reduce((sum, type) => sum + type.reusedCount, 0)
return NextResponse.json({
success: true,
data: {
types: typeRows.map(({ rawTypes: _rawTypes, ...type }) => type),
selectedType,
selectedTypeSummary: selectedSummary
? (({ rawTypes: _rawTypes, ...summary }) => summary)(selectedSummary)
: null,
materials,
materialsTotal: selectedSummary.totalCount,
hasMore,
summary: {
totalAssets,
reusedAssets,
reuseRate: calculateReuseRate(reusedAssets, totalAssets),
},
},
}, { headers: PRIVATE_HEADERS })
}
return NextResponse.json({
success: true,
data: {
types: typeRows,
selectedType: null,
selectedTypeSummary: null,
materials: [],
materialsTotal: 0,
hasMore: false,
summary: { totalAssets: 0, reusedAssets: 0, reuseRate: null },
},
}, { headers: PRIVATE_HEADERS })
} catch (error) {
console.error('[department-dashboard] 价值指标读取失败:', error)
return NextResponse.json(
{ success: false, error: '价值指标读取失败,请稍后重试' },
{ status: 500, headers: PRIVATE_HEADERS },
)
}
}

View File

@ -12,6 +12,7 @@ import DataDefinitionsDashboard from '@/components/department-dashboard/DataDefi
import DepartmentShell from '@/components/department-dashboard/DepartmentShell'
import PeopleStructureDashboard from '@/components/department-dashboard/PeopleStructureDashboard'
import TeamFunctionsDashboard from '@/components/department-dashboard/TeamFunctionsDashboard'
import ValueMetricsDashboard from '@/components/department-dashboard/ValueMetricsDashboard'
import WorkspacePlaceholder from '@/components/department-dashboard/WorkspacePlaceholder'
import {
getFrameworkWorkspaceParams,
@ -93,6 +94,13 @@ export function generateMetadata({ params }: WorkspaceItemPageProps): Metadata {
}
}
if (workspace.section.key === 'assets' && workspace.item.key === 'value-metrics') {
return {
title: '价值指标 | 资产与AI自动化 | 品牌中心部门看板',
description: '从素材资产库按资产类型查看复用率与下载次数明细。',
}
}
if (workspace.item.status !== 'framework') return {}
return {
@ -130,6 +138,10 @@ export default function WorkspaceItemPage({ params }: WorkspaceItemPageProps) {
return <TeamFunctionsDashboard />
}
if (workspace.section.key === 'assets' && workspace.item.key === 'value-metrics') {
return <ValueMetricsDashboard />
}
if (workspace.section.key === 'governance' && workspace.item.key === 'data-definitions') {
return <DataDefinitionsDashboard />
}

View File

@ -0,0 +1,152 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
ArrowDownToLine,
BarChart3,
Database,
RefreshCw,
} from 'lucide-react'
import DepartmentShell from './DepartmentShell'
import type { ValueMetricsInsights, ValueMetricsMaterial } from '@/lib/value-metrics'
type ViewState = {
data: ValueMetricsInsights | null
loading: boolean
error: string | null
}
const DETAIL_PAGE_SIZE = 100
function formatRate(rate: number | null) {
if (rate === null) return '—'
return `${Number.isInteger(rate) ? rate.toFixed(0) : rate.toFixed(1)}%`
}
function formatDate(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
timeZone: 'Asia/Shanghai',
}).format(date)
}
function materialMeta(material: ValueMetricsMaterial) {
return [material.categoryName, material.sceneName, material.platformName].filter(Boolean).join(' · ')
}
function MaterialTable({ materials }: { materials: ValueMetricsMaterial[] }) {
return (
<>
<div className="hidden overflow-x-auto border-t border-slate-200 md:block" tabIndex={0} aria-label="资产下载明细表">
<table className="w-full min-w-[760px] 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-[38%] px-5 py-3">资产</th>
<th scope="col" className="w-[18%] px-4 py-3">上传人</th>
<th scope="col" className="w-[18%] px-4 py-3">分类信息</th>
<th scope="col" className="w-[14%] px-4 py-3">上传日期</th>
<th scope="col" className="w-[12%] px-4 py-3 text-right">下载次数</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{materials.map((material) => (
<tr key={material.id} className="align-top hover:bg-slate-50">
<td className="px-5 py-4"><p className="line-clamp-2 font-bold text-slate-950">{material.title}</p><p className="mt-1 text-xs text-slate-500">{material.materialType}</p></td>
<td className="px-4 py-4 font-semibold text-slate-700">{material.authorName}</td>
<td className="px-4 py-4 text-xs leading-5 text-slate-600">{materialMeta(material) || '—'}</td>
<td className="px-4 py-4 tabular-nums text-slate-600">{formatDate(material.createdAt)}</td>
<td className="px-4 py-4 text-right"><span className="inline-flex items-center gap-1.5 font-bold tabular-nums text-[#1677ff]"><ArrowDownToLine className="h-4 w-4" aria-hidden="true" />{material.downloadCount}</span></td>
</tr>
))}
</tbody>
</table>
</div>
<ul className="divide-y divide-slate-200 md:hidden" aria-label="资产下载明细列表">
{materials.map((material) => (
<li key={material.id} className="p-4">
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><h3 className="break-words font-bold text-slate-950">{material.title}</h3><p className="mt-1 text-xs text-slate-500">{material.materialType}</p></div><span className="inline-flex shrink-0 items-center gap-1 text-sm font-bold tabular-nums text-[#1677ff]"><ArrowDownToLine className="h-4 w-4" aria-hidden="true" />{material.downloadCount}</span></div>
<dl className="mt-3 grid grid-cols-2 gap-x-3 gap-y-3 text-sm"><div><dt className="text-xs text-slate-500">上传人</dt><dd className="mt-1 font-semibold text-slate-800">{material.authorName}</dd></div><div><dt className="text-xs text-slate-500">上传日期</dt><dd className="mt-1 tabular-nums text-slate-700">{formatDate(material.createdAt)}</dd></div><div className="col-span-2"><dt className="text-xs text-slate-500">分类信息</dt><dd className="mt-1 text-slate-700">{materialMeta(material) || '—'}</dd></div></dl>
</li>
))}
</ul>
</>
)
}
export default function ValueMetricsDashboard() {
const [selectedType, setSelectedType] = useState('')
const [state, setState] = useState<ViewState>({ data: null, loading: true, error: null })
const load = useCallback(async (type = '', offset = 0, append = false) => {
setState((current) => ({ ...current, loading: true, error: null }))
try {
const params = new URLSearchParams({ limit: String(DETAIL_PAGE_SIZE), offset: String(offset) })
if (type) params.set('materialType', type)
const query = `?${params.toString()}`
const response = await fetch(`/api/department-dashboard/assets/value-metrics${query}`, { cache: 'no-store' })
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success || !payload.data) throw new Error(payload?.error || '价值指标读取失败')
setState((current) => ({
data: append && current.data && current.data.selectedType === payload.data.selectedType
? { ...payload.data, materials: [...current.data.materials, ...payload.data.materials] }
: payload.data,
loading: false,
error: null,
}))
setSelectedType(payload.data.selectedType || '')
} catch (error) {
setState((current) => ({ ...current, loading: false, error: (error as Error).message || '价值指标读取失败' }))
}
}, [])
useEffect(() => { void load() }, [load])
const selectedSummary = useMemo(() => {
if (!state.data) return null
return state.data.types.find((type) => type.key === (selectedType || state.data?.selectedType)) || state.data.selectedTypeSummary
}, [selectedType, state.data])
return (
<DepartmentShell>
<main className="mx-auto max-w-[1600px] px-4 pb-16 pt-7 sm:px-6 lg:px-8">
<header className="flex flex-col gap-4 border-b border-[#dfe4ea] pb-6 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="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]">从素材资产库读取各资产类型的复用情况,并按下载次数查看资产明细。</p>
</div>
<p className="inline-flex items-center gap-2 self-start text-xs font-semibold text-emerald-700 lg:self-auto"><span className="h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" />实时读取素材资产库</p>
</header>
{state.error && <div role="alert" className="mt-6 flex items-center justify-between gap-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-800"><span>{state.error}</span><button type="button" onClick={() => void load(selectedType)} className="inline-flex min-h-9 items-center gap-2 rounded-lg bg-white px-3 text-xs font-bold text-red-800 ring-1 ring-inset ring-red-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"><RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />重试</button></div>}
<div className="mt-6 overflow-hidden rounded-lg border border-slate-200 bg-white lg:grid lg:grid-cols-[232px_minmax(0,1fr)]">
<aside aria-label="资产类型导航" className="border-b border-slate-200 bg-slate-50 lg:border-b-0 lg:border-r">
<div className="border-b border-slate-200 px-4 py-4"><p className="text-xs font-bold text-slate-500">素材资产库</p><p className="mt-1 text-sm font-bold text-slate-900">资产类型</p></div>
<nav className="flex gap-1 overflow-x-auto p-2 lg:block lg:space-y-1 lg:overflow-visible" aria-label="选择资产类型">
{state.data?.types.map((type) => (
<button key={type.key} type="button" aria-current={selectedType === type.key ? 'page' : undefined} onClick={() => { setSelectedType(type.key); void load(type.key) }} className={`flex min-h-11 shrink-0 items-center gap-2 rounded-lg px-3 text-left text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] lg:w-full ${selectedType === type.key ? 'bg-[#1677ff] text-white' : 'text-slate-600 hover:bg-slate-200/70 hover:text-slate-950'}`}><span className="min-w-0 flex-1 truncate">{type.label}</span><span className={`tabular-nums text-xs ${selectedType === type.key ? 'text-white/80' : 'text-slate-400'}`}>{type.totalCount}</span></button>
))}
{state.loading && !state.data && <div role="status" className="px-3 py-4 text-xs font-semibold text-slate-500">正在读取资产类型…</div>}
{!state.loading && state.data?.types.length === 0 && <div className="px-3 py-4 text-xs font-semibold leading-5 text-slate-500">当前权限范围内暂无素材数据</div>}
</nav>
</aside>
<section className="min-w-0 bg-[#f8fafc]" aria-labelledby="value-metrics-detail-title">
{state.loading && !state.data ? <div role="status" className="px-5 py-16 text-center"><RefreshCw className="mx-auto h-7 w-7 animate-spin text-[#1677ff]" aria-hidden="true" /><p className="mt-4 text-sm font-semibold text-slate-700">正在读取素材资产库</p></div> : !selectedSummary ? <div className="px-5 py-16 text-center"><Database className="mx-auto h-7 w-7 text-slate-400" aria-hidden="true" /><p className="mt-4 text-sm font-semibold text-slate-700">暂无可展示的资产类型</p></div> : (
<>
<header className="flex flex-col gap-3 border-b border-slate-200 bg-white px-5 py-5 sm:px-7 lg:flex-row lg:items-center lg:justify-between"><div className="flex min-w-0 items-center gap-3"><span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[#e8f7f2] text-[#087052]"><BarChart3 className="h-5 w-5" aria-hidden="true" /></span><div className="min-w-0"><h2 id="value-metrics-detail-title" className="truncate text-xl font-bold tracking-[-0.02em] text-slate-950">{selectedSummary.label}</h2><p className="mt-1 text-xs font-semibold text-slate-500">资产复用率 = 下载过的资产数 ÷ 该类型资产总数</p></div></div><span className="inline-flex min-h-8 items-center self-start rounded-lg bg-[#e8f7f2] px-3 text-xs font-bold text-[#087052] lg:self-auto">实时读取</span></header>
<dl className="grid gap-px border-b border-slate-200 bg-slate-200 sm:grid-cols-3"><div className="bg-white px-5 py-4 sm:px-6"><dt className="text-xs font-bold text-slate-500">该类型资产总数</dt><dd className="mt-1 text-2xl font-bold tabular-nums text-slate-950">{selectedSummary.totalCount}</dd></div><div className="bg-white px-5 py-4 sm:px-6"><dt className="text-xs font-bold text-slate-500">下载过的资产</dt><dd className="mt-1 text-2xl font-bold tabular-nums text-[#087052]">{selectedSummary.reusedCount}</dd></div><div className="bg-white px-5 py-4 sm:px-6"><dt className="text-xs font-bold text-slate-500">复用率</dt><dd className="mt-1 text-2xl font-bold tabular-nums text-[#1677ff]">{formatRate(selectedSummary.reuseRate)}</dd></div></dl>
<div className="border-b border-slate-200 bg-white px-5 py-4 sm:px-7"><div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between"><div><h3 className="font-bold text-slate-950">资产下载明细</h3><p className="mt-1 text-xs text-slate-500">按下载次数从高到低排列 · 共 {state.data?.materialsTotal || 0} 项</p></div>{state.loading && <span className="inline-flex items-center gap-2 text-xs font-semibold text-slate-500"><RefreshCw className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />正在更新</span>}</div></div>
{state.data?.materials.length ? <MaterialTable materials={state.data.materials} /> : <div role="status" className="bg-white px-5 py-14 text-center"><Database className="mx-auto h-6 w-6 text-slate-400" aria-hidden="true" /><p className="mt-3 text-sm font-semibold text-slate-700">该类型暂无资产明细</p></div>}
{state.data?.hasMore && <div className="border-t border-slate-200 bg-white px-5 py-4 text-center sm:px-7"><button type="button" disabled={state.loading} onClick={() => void load(selectedType, state.data?.materials.length || 0, true)} className="inline-flex min-h-10 items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 text-sm font-bold text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:cursor-wait disabled:opacity-60">{state.loading && <RefreshCw className="h-4 w-4 animate-spin" aria-hidden="true" />}加载更多明细</button></div>}
</>
)}
</section>
</div>
</main>
</DepartmentShell>
)
}

View File

@ -11,10 +11,14 @@ describe('workspace navigation', () => {
expect(items).toHaveLength(17)
expect(new Set(hrefs).size).toBe(17)
expect(getFrameworkWorkspaceParams()).toHaveLength(11)
expect(getFrameworkWorkspaceParams()).toHaveLength(10)
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')
expect(getWorkspaceItem('assets', 'value-metrics')?.item).toMatchObject({
status: 'connected',
productName: '素材资产库复用率',
})
expect(getWorkspaceItem('governance', 'roles-permissions')?.item).toMatchObject({
title: '权限组',
href: '/workspaces/governance/roles-permissions',

View File

@ -59,7 +59,7 @@ export const WORKSPACE_SECTIONS: WorkspaceSection[] = [
{ 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: 'value-metrics', title: '价值指标', href: '/workspaces/assets/value-metrics', status: 'connected', productName: '素材资产库复用率' },
],
},
{

39
lib/value-metrics.ts Normal file
View File

@ -0,0 +1,39 @@
export type ValueMetricsTypeSummary = {
key: string
label: string
totalCount: number
reusedCount: number
reuseRate: number | null
}
export type ValueMetricsMaterial = {
id: string
title: string
materialType: string
authorName: string
createdAt: string
downloadCount: number
categoryName: string
sceneName: string
platformName: string
}
export type ValueMetricsInsights = {
types: ValueMetricsTypeSummary[]
selectedType: string | null
selectedTypeSummary: ValueMetricsTypeSummary | null
materials: ValueMetricsMaterial[]
materialsTotal: number
hasMore: boolean
summary: {
totalAssets: number
reusedAssets: number
reuseRate: number | null
}
}
export function calculateReuseRate(reusedCount: number, totalCount: number): number | null {
if (totalCount <= 0) return null
return Number(((reusedCount / totalCount) * 100).toFixed(1))
}