diff --git a/DESIGN.md b/DESIGN.md index 4093604..4f19285 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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=` 和右侧真实业务操作面始终同步。 diff --git a/PRODUCT.md b/PRODUCT.md index 84c2109..26b3c38 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -34,10 +34,11 @@ web - “本月上新”只读取表单字段“需求类型”为“新品”的六品牌美术设计流程,不以流程标题关键词判断,也不计“优化”等其他需求类型;分子为本月整条流程已完结数量,分母为本月已完结数量与当前进行中数量之和;其中进行中流程直接复用“进行中项目”的美术设计流程状态、时间与当前品牌中心处理人条件。 - “按时交付”只统计对应主管/规则审核节点在本月完成、审批状态为已完结且审批结论未拒绝的美术设计与内容视频流程;待计算流程不进入分母。详情页可在全部流程、美术设计流程和内容视频流程之间筛选,公式、数量与明细必须共用当前筛选口径。 - “首轮通过”读取作品过稿数据,分子为 V1 版本审核通过的作品数,分母为当前已通过的作品数;详情页列出部门范围内所有含 V1 的作品,搜索只过滤明细,不改变顶部完整口径。 +- “价值指标”直接读取素材资产库的可见素材,按资产类型展示下载过的资产数、该类型总数与复用率,并按下载次数从高到低列出该类型资产明细。 - “团队负荷”统计品牌中心在职人员当前进行中的流程负荷,排除指定管理人员和商品企划部;该部门及其人员不进入流程归属、部门明细、人员分母或平均负荷。 - “团队产能”仅展示品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部五个二级部门,并按该顺序展示近 8 个上海自然周的当前在职人数、收到流程数和完结流程数;人数统一读取当前钉钉组织架构,不还原历史人员快照。每个部门再按美术设计流程、内容视频流程分别展示近 8 周趋势与明细;流程按当前任务负责人部门归属映射,但美术设计流程不计“品牌策划主管分配任务/品牌策划主管审核”,内容视频流程不计“内容主管分配任务/内容主管审核”等管理节点,点击周次查看进行中与已完结明细。 - “团队职能”按用户确认的职责图展示品牌创意A部、品牌创意B部、空间设计部和综合支持部四个团队,明确负责人、服务品牌、前置能力、岗位及职责边界;它是静态职责参考,不展示虚构人数或经营指标。 -- 除素材资产库、自动化工作流、人员架构、数据口径和已接入的中心指标外,其余板块现阶段只展示可理解、可扩展的模块框架,不虚构业务数据。 +- 除素材资产库、自动化工作流、人员架构、数据口径、价值指标和已接入的中心指标外,其余板块现阶段只展示可理解、可扩展的模块框架,不虚构业务数据。 - 沿用项目现有 Next.js、NextAuth、Tailwind CSS 和权限体系。 ## Brand Commitments diff --git a/app/api/department-dashboard/assets/value-metrics/route.ts b/app/api/department-dashboard/assets/value-metrics/route.ts new file mode 100644 index 0000000..df38fa6 --- /dev/null +++ b/app/api/department-dashboard/assets/value-metrics/route.ts @@ -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; 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(), 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(), 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, + prisma.material.groupBy({ + by: ['materialType'], + where: { AND: [where, { downloadCount: { gt: 0 } }] }, + _count: { _all: true }, + }) as unknown as Promise, + ]) + + 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 }, + ) + } +} diff --git a/app/workspaces/[section]/[item]/page.tsx b/app/workspaces/[section]/[item]/page.tsx index 53cb9ee..797f4af 100644 --- a/app/workspaces/[section]/[item]/page.tsx +++ b/app/workspaces/[section]/[item]/page.tsx @@ -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 } + if (workspace.section.key === 'assets' && workspace.item.key === 'value-metrics') { + return + } + if (workspace.section.key === 'governance' && workspace.item.key === 'data-definitions') { return } diff --git a/components/department-dashboard/ValueMetricsDashboard.tsx b/components/department-dashboard/ValueMetricsDashboard.tsx new file mode 100644 index 0000000..9ec2bc8 --- /dev/null +++ b/components/department-dashboard/ValueMetricsDashboard.tsx @@ -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 ( + <> +
+ + + + + + + + + + + + + {materials.map((material) => ( + + + + + + + + ))} + +
按下载次数从高到低排列的资产明细
资产上传人分类信息上传日期下载次数

{material.title}

{material.materialType}

{material.authorName}{materialMeta(material) || '—'}{formatDate(material.createdAt)}
+
+
    + {materials.map((material) => ( +
  • +

    {material.title}

    {material.materialType}

    +
    上传人
    {material.authorName}
    上传日期
    {formatDate(material.createdAt)}
    分类信息
    {materialMeta(material) || '—'}
    +
  • + ))} +
+ + ) +} + +export default function ValueMetricsDashboard() { + const [selectedType, setSelectedType] = useState('') + const [state, setState] = useState({ 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 ( + +
+
+
+

价值指标

+

从素材资产库读取各资产类型的复用情况,并按下载次数查看资产明细。

+
+

+
+ + {state.error &&
{state.error}
} + +
+ + +
+ {state.loading && !state.data ?
: !selectedSummary ?
: ( + <> +

{selectedSummary.label}

资产复用率 = 下载过的资产数 ÷ 该类型资产总数

实时读取
+
该类型资产总数
{selectedSummary.totalCount}
下载过的资产
{selectedSummary.reusedCount}
复用率
{formatRate(selectedSummary.reuseRate)}
+

资产下载明细

按下载次数从高到低排列 · 共 {state.data?.materialsTotal || 0} 项

{state.loading && }
+ {state.data?.materials.length ? :
} + {state.data?.hasMore &&
} + + )} +
+
+
+
+ ) +} diff --git a/components/department-dashboard/workspace-navigation.test.ts b/components/department-dashboard/workspace-navigation.test.ts index e1d5507..795fdc6 100644 --- a/components/department-dashboard/workspace-navigation.test.ts +++ b/components/department-dashboard/workspace-navigation.test.ts @@ -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', diff --git a/components/department-dashboard/workspace-navigation.ts b/components/department-dashboard/workspace-navigation.ts index 8c736ff..4fa49ea 100644 --- a/components/department-dashboard/workspace-navigation.ts +++ b/components/department-dashboard/workspace-navigation.ts @@ -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: '素材资产库复用率' }, ], }, { diff --git a/lib/value-metrics.ts b/lib/value-metrics.ts new file mode 100644 index 0000000..2b5d26b --- /dev/null +++ b/lib/value-metrics.ts @@ -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)) +} +