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