From fb4fa3efcaccd3b3b244304b769048ac4c9edd98 Mon Sep 17 00:00:00 2001
From: jingchuan
Date: Mon, 17 Aug 2026 11:38:37 +0800
Subject: [PATCH 1/7] fix(oa-board): prefer latest restarted child flow
---
.../src/lib/oa-board-flow-selection.test.ts | 92 +++++++++++++++++++
.../src/lib/oa-board-flow-selection.ts | 47 ++++++++++
.../server/services/oa-board-sync.service.ts | 6 +-
3 files changed, 144 insertions(+), 1 deletion(-)
create mode 100644 furniture-3d-system/src/lib/oa-board-flow-selection.test.ts
create mode 100644 furniture-3d-system/src/lib/oa-board-flow-selection.ts
diff --git a/furniture-3d-system/src/lib/oa-board-flow-selection.test.ts b/furniture-3d-system/src/lib/oa-board-flow-selection.test.ts
new file mode 100644
index 00000000..920985bf
--- /dev/null
+++ b/furniture-3d-system/src/lib/oa-board-flow-selection.test.ts
@@ -0,0 +1,92 @@
+import { selectLatestRestartableChildFlows } from './oa-board-flow-selection'
+
+type TestFlowRecord = {
+ flowType: string
+ processInstanceId: string
+ createdAtSource: Date | null
+ updatedAtSource: Date | null
+ status: string
+}
+
+function buildFlow(
+ flowType: string,
+ processInstanceId: string,
+ createdAtSource: string | null,
+ status: string,
+ updatedAtSource = createdAtSource,
+): TestFlowRecord {
+ return {
+ flowType,
+ processInstanceId,
+ createdAtSource: createdAtSource ? new Date(createdAtSource) : null,
+ updatedAtSource: updatedAtSource ? new Date(updatedAtSource) : null,
+ status,
+ }
+}
+
+describe('OA 看板子流程版本选择', () => {
+ it('首单、美术和限价流程各自只保留最晚发起的一条', () => {
+ const records = [
+ buildFlow('review', 'review-1', '2026-08-03T02:30:00Z', 'COMPLETED'),
+ buildFlow('order', 'order-refused', '2026-08-03T08:00:00Z', 'REFUSED'),
+ buildFlow('order', 'order-running', '2026-08-04T08:00:00Z', 'RUNNING'),
+ buildFlow('art', 'art-refused', '2026-08-05T08:00:00Z', 'REFUSED'),
+ buildFlow('art', 'art-completed', '2026-08-06T08:00:00Z', 'COMPLETED'),
+ buildFlow('pricing', 'pricing-terminated', '2026-08-17T01:49:00Z', 'TERMINATED'),
+ buildFlow('pricing', 'pricing-completed', '2026-08-17T01:51:00Z', 'COMPLETED'),
+ buildFlow('main', 'main-1', '2026-08-17T02:00:00Z', 'RUNNING'),
+ ]
+
+ expect(
+ selectLatestRestartableChildFlows(records).map((record) => record.processInstanceId),
+ ).toEqual([
+ 'review-1',
+ 'order-running',
+ 'art-completed',
+ 'pricing-completed',
+ 'main-1',
+ ])
+ })
+
+ it('以发起时间为主,不让旧流程的较晚更新时间覆盖新流程', () => {
+ const records = [
+ buildFlow(
+ 'art',
+ 'old-art-updated-later',
+ '2026-08-05T08:00:00Z',
+ 'REFUSED',
+ '2026-08-08T08:00:00Z',
+ ),
+ buildFlow(
+ 'art',
+ 'new-art',
+ '2026-08-06T08:00:00Z',
+ 'RUNNING',
+ '2026-08-06T09:00:00Z',
+ ),
+ ]
+
+ expect(selectLatestRestartableChildFlows(records)).toEqual([records[1]])
+ })
+
+ it('发起时间相同时用更新时间稳定决定最新记录', () => {
+ const records = [
+ buildFlow(
+ 'pricing',
+ 'pricing-older-update',
+ '2026-08-17T01:51:00Z',
+ 'TERMINATED',
+ '2026-08-17T01:52:00Z',
+ ),
+ buildFlow(
+ 'pricing',
+ 'pricing-newer-update',
+ '2026-08-17T01:51:00Z',
+ 'COMPLETED',
+ '2026-08-17T01:58:00Z',
+ ),
+ ]
+
+ expect(selectLatestRestartableChildFlows(records)).toEqual([records[1]])
+ })
+})
diff --git a/furniture-3d-system/src/lib/oa-board-flow-selection.ts b/furniture-3d-system/src/lib/oa-board-flow-selection.ts
new file mode 100644
index 00000000..b5847080
--- /dev/null
+++ b/furniture-3d-system/src/lib/oa-board-flow-selection.ts
@@ -0,0 +1,47 @@
+const RESTARTABLE_CHILD_FLOW_TYPES = new Set(['order', 'art', 'pricing'])
+
+export type OaBoardFlowSelectionRecord = {
+ flowType: string
+ processInstanceId: string
+ createdAtSource: Date | null
+ updatedAtSource: Date | null
+}
+
+function toTimestamp(value: Date | null) {
+ const timestamp = value?.getTime() ?? 0
+ return Number.isFinite(timestamp) ? timestamp : 0
+}
+
+function compareFlowRecency(
+ left: OaBoardFlowSelectionRecord,
+ right: OaBoardFlowSelectionRecord,
+) {
+ const createdAtDiff = toTimestamp(left.createdAtSource) - toTimestamp(right.createdAtSource)
+ if (createdAtDiff !== 0) return createdAtDiff
+
+ const updatedAtDiff = toTimestamp(left.updatedAtSource) - toTimestamp(right.updatedAtSource)
+ if (updatedAtDiff !== 0) return updatedAtDiff
+
+ return left.processInstanceId.localeCompare(right.processInstanceId)
+}
+
+export function selectLatestRestartableChildFlows(
+ records: T[],
+) {
+ const latestByFlowType = new Map()
+
+ records.forEach((record) => {
+ if (!RESTARTABLE_CHILD_FLOW_TYPES.has(record.flowType)) return
+
+ const currentLatest = latestByFlowType.get(record.flowType)
+ if (!currentLatest || compareFlowRecency(record, currentLatest) > 0) {
+ latestByFlowType.set(record.flowType, record)
+ }
+ })
+
+ return records.filter(
+ (record) =>
+ !RESTARTABLE_CHILD_FLOW_TYPES.has(record.flowType) ||
+ latestByFlowType.get(record.flowType) === record,
+ )
+}
diff --git a/furniture-3d-system/src/server/services/oa-board-sync.service.ts b/furniture-3d-system/src/server/services/oa-board-sync.service.ts
index 056c91bf..ffa4b4ce 100644
--- a/furniture-3d-system/src/server/services/oa-board-sync.service.ts
+++ b/furniture-3d-system/src/server/services/oa-board-sync.service.ts
@@ -6,6 +6,7 @@ import {
import type sql from 'mssql'
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
+import { selectLatestRestartableChildFlows } from '@/lib/oa-board-flow-selection'
import { externalEmployeeDirectoryService } from './external-employee-directory.service'
import type { OaBoardProjectGraphInput } from './oa-board.service'
import {
@@ -3106,7 +3107,10 @@ function buildProjectGroups(records: NormalizedFlowRecord[]): PreparedProjectGro
})
}
- return Array.from(groups.values())
+ return Array.from(groups.values()).map((group) => ({
+ ...group,
+ flowRecords: selectLatestRestartableChildFlows(group.flowRecords),
+ }))
}
function countNestedRecords(projectGraphs: OaBoardProjectGraphInput[]) {
From 5d60abd57bc63ab64bd62b33c00954f65d63c5d8 Mon Sep 17 00:00:00 2001
From: WuYongJie
Date: Mon, 17 Aug 2026 12:07:57 +0800
Subject: [PATCH 2/7] feat(supply-chain): sync product archive sales
performance
---
furniture-3d-system/package.json | 1 +
.../migration.sql | 35 +++++
furniture-3d-system/prisma/schema.prisma | 21 +++
...ct-archive-sales-performance-sync-timer.sh | 68 +++++++++
...chain-product-archive-sales-performance.ts | 97 +++++++++++++
.../src/lib/supply-chain-product-archive.ts | 9 ++
...ive-sales-performance-source.repository.ts | 134 ++++++++++++++++++
...chive-sales-performance-sync.repository.ts | 86 +++++++++++
...supply-chain-product-archive.repository.ts | 11 ++
.../product-archive-sales-performance.test.ts | 41 ++++++
...ive-sales-performance-sync.service.test.ts | 107 ++++++++++++++
.../product-archive-sales-performance.ts | 61 ++++++++
...-archive-sales-performance-sync.service.ts | 117 +++++++++++++++
.../supply-chain-product-archive.mapper.ts | 15 ++
14 files changed, 803 insertions(+)
create mode 100644 furniture-3d-system/prisma/migrations/20260817103000_add_product_archive_sales_performance/migration.sql
create mode 100644 furniture-3d-system/scripts/install-supply-chain-product-archive-sales-performance-sync-timer.sh
create mode 100644 furniture-3d-system/scripts/sync-supply-chain-product-archive-sales-performance.ts
create mode 100644 furniture-3d-system/src/server/repositories/product-archive-sales-performance-source.repository.ts
create mode 100644 furniture-3d-system/src/server/repositories/supply-chain-product-archive-sales-performance-sync.repository.ts
create mode 100644 furniture-3d-system/src/server/services/__tests__/product-archive-sales-performance.test.ts
create mode 100644 furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sales-performance-sync.service.test.ts
create mode 100644 furniture-3d-system/src/server/services/product-archive-sales-performance.ts
create mode 100644 furniture-3d-system/src/server/services/supply-chain-product-archive-sales-performance-sync.service.ts
diff --git a/furniture-3d-system/package.json b/furniture-3d-system/package.json
index 4e95d7e1..a3e55f2b 100644
--- a/furniture-3d-system/package.json
+++ b/furniture-3d-system/package.json
@@ -18,6 +18,7 @@
"prisma:studio": "prisma studio",
"sync:supply-chain-product-archive": "tsx scripts/sync-supply-chain-product-archive.ts",
"sync:supply-chain-product-archive-enrichment": "tsx scripts/sync-supply-chain-product-archive-enrichment.ts",
+ "sync:supply-chain-product-archive-sales-performance": "tsx scripts/sync-supply-chain-product-archive-sales-performance.ts",
"sync:jst-sku-product-archive": "tsx scripts/sync-jst-sku-product-archive.ts",
"sync:bom-cost-reference": "tsx scripts/sync-bom-cost-reference.ts",
"backtest:bom-cost-reference": "tsx scripts/backtest-bom-cost-reference.ts",
diff --git a/furniture-3d-system/prisma/migrations/20260817103000_add_product_archive_sales_performance/migration.sql b/furniture-3d-system/prisma/migrations/20260817103000_add_product_archive_sales_performance/migration.sql
new file mode 100644
index 00000000..a5b8373c
--- /dev/null
+++ b/furniture-3d-system/prisma/migrations/20260817103000_add_product_archive_sales_performance/migration.sql
@@ -0,0 +1,35 @@
+-- CreateTable
+CREATE TABLE "SupplyChainProductArchiveSalesPerformance" (
+ "archiveId" TEXT NOT NULL,
+ "barcode" TEXT NOT NULL,
+ "periodStart" DATE NOT NULL,
+ "periodEnd" DATE NOT NULL,
+ "shipAmount" DECIMAL(24,4) NOT NULL,
+ "returnAmount" DECIMAL(24,4) NOT NULL,
+ "realPerformance" DECIMAL(24,4) NOT NULL,
+ "shipQty" BIGINT NOT NULL,
+ "returnQty" BIGINT NOT NULL,
+ "grossProfit" DECIMAL(24,4) NOT NULL,
+ "grossMargin" DECIMAL(20,8),
+ "syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "SupplyChainProductArchiveSalesPerformance_pkey" PRIMARY KEY ("archiveId")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "SupplyChainProductArchiveSalesPerformance_barcode_key"
+ON "SupplyChainProductArchiveSalesPerformance"("barcode");
+
+-- CreateIndex
+CREATE INDEX "SupplyChainProductArchiveSalesPerformance_periodEnd_idx"
+ON "SupplyChainProductArchiveSalesPerformance"("periodEnd");
+
+-- CreateIndex
+CREATE INDEX "SupplyChainProductArchiveSalesPerformance_syncedAt_idx"
+ON "SupplyChainProductArchiveSalesPerformance"("syncedAt");
+
+-- AddForeignKey
+ALTER TABLE "SupplyChainProductArchiveSalesPerformance"
+ADD CONSTRAINT "SupplyChainProductArchiveSalesPerformance_archiveId_fkey"
+FOREIGN KEY ("archiveId") REFERENCES "SupplyChainProductArchive"("id")
+ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma
index eeef1727..e7f449f6 100644
--- a/furniture-3d-system/prisma/schema.prisma
+++ b/furniture-3d-system/prisma/schema.prisma
@@ -563,6 +563,7 @@ model SupplyChainProductArchive {
sources SupplyChainProductArchiveSource[]
imageEmbedding ProductArchiveImageEmbedding?
imageCache SupplyChainProductArchiveImageCache?
+ salesPerformance SupplyChainProductArchiveSalesPerformance?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -576,6 +577,26 @@ model SupplyChainProductArchive {
@@index([firstSourceCode, createdAt])
}
+// 销售宽表按条码汇总的每日快照;费比和营业利润额待完整费用字段开放后再接入。
+model SupplyChainProductArchiveSalesPerformance {
+ archiveId String @id
+ archive SupplyChainProductArchive @relation(fields: [archiveId], references: [id], onDelete: Cascade)
+ barcode String @unique
+ periodStart DateTime @db.Date
+ periodEnd DateTime @db.Date
+ shipAmount Decimal @db.Decimal(24, 4)
+ returnAmount Decimal @db.Decimal(24, 4)
+ realPerformance Decimal @db.Decimal(24, 4)
+ shipQty BigInt
+ returnQty BigInt
+ grossProfit Decimal @db.Decimal(24, 4)
+ grossMargin Decimal? @db.Decimal(20, 8)
+ syncedAt DateTime @default(now())
+
+ @@index([periodEnd])
+ @@index([syncedAt])
+}
+
// 图纸与视频导出来源:按条码保存当前有效的图纸/视频状态,支持月度增量覆盖。
model SupplyChainProductArchiveImageCache {
id String @id @default(cuid())
diff --git a/furniture-3d-system/scripts/install-supply-chain-product-archive-sales-performance-sync-timer.sh b/furniture-3d-system/scripts/install-supply-chain-product-archive-sales-performance-sync-timer.sh
new file mode 100644
index 00000000..851e5a78
--- /dev/null
+++ b/furniture-3d-system/scripts/install-supply-chain-product-archive-sales-performance-sync-timer.sh
@@ -0,0 +1,68 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+BASE="${FURNITURE_PROD_BASE:-/opt/furniture-3d-system-prod}"
+SERVICE_NAME="${SUPPLY_CHAIN_ARCHIVE_SALES_SYNC_SERVICE_NAME:-furniture-supply-chain-product-archive-sales-performance-sync}"
+APP_ROOT="$BASE/current/furniture-3d-system"
+SHARED="$BASE/shared"
+APP_ENV="$SHARED/.env"
+SYNC_ENV="$SHARED/supply-chain-product-archive-sync.env"
+SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
+TIMER_FILE="/etc/systemd/system/$SERVICE_NAME.timer"
+SYNC_CALENDAR="${SUPPLY_CHAIN_ARCHIVE_SALES_SYNC_CALENDAR:-*-*-* 03:30:00}"
+
+if [ "$(id -u)" -ne 0 ]; then
+ echo "This installer must run as root." >&2
+ exit 1
+fi
+
+if [ ! -d "$APP_ROOT" ]; then
+ echo "Missing app root: $APP_ROOT" >&2
+ exit 1
+fi
+
+if [ ! -f "$APP_ENV" ]; then
+ echo "Missing app env: $APP_ENV" >&2
+ exit 1
+fi
+
+if [ ! -f "$SYNC_ENV" ]; then
+ echo "Missing sync env: $SYNC_ENV" >&2
+ echo "Run install-supply-chain-product-archive-sync-timer.sh and fill the guard identities first." >&2
+ exit 1
+fi
+
+cat >"$SERVICE_FILE" <"$TIMER_FILE" < argument.startsWith(prefix))?.slice(prefix.length) ?? ''
+}
+
+function databaseIdentity(databaseUrl: string) {
+ const parsed = new URL(databaseUrl)
+ return [
+ parsed.protocol.replace(/:$/, ''),
+ decodeURIComponent(parsed.username || ''),
+ parsed.hostname,
+ parsed.port || '',
+ parsed.pathname.replace(/^\//, ''),
+ parsed.searchParams.get('schema') ?? '',
+ ].join('|')
+}
+
+function sourceDatabaseIdentity() {
+ const host = process.env.JST_REPORT_DB_HOST?.trim() ?? ''
+ const database = process.env.JST_REPORT_DB_NAME?.trim() ?? ''
+ const user = process.env.JST_REPORT_DB_USER?.trim() ?? ''
+ if (!host || !database || !user) return ''
+ return [
+ 'sqlserver',
+ user,
+ host,
+ process.env.JST_REPORT_DB_PORT?.trim() || '1433',
+ database,
+ process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt',
+ ].join('|')
+}
+
+function readAsOf() {
+ const value = readArgument('as-of')
+ if (!value) return undefined
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
+ throw new Error('--as-of must use YYYY-MM-DD.')
+ }
+ const date = new Date(`${value}T00:00:00.000Z`)
+ if (Number.isNaN(date.getTime())) throw new Error('--as-of is not a valid date.')
+ return date
+}
+
+async function main() {
+ if (!process.argv.includes('--apply')) {
+ throw new Error('This sync writes the archive. Pass --apply explicitly.')
+ }
+ if (readArgument('confirm') !== CONFIRM_TOKEN) {
+ throw new Error(`Pass --confirm=${CONFIRM_TOKEN}.`)
+ }
+
+ const databaseUrl = process.env.DATABASE_URL?.trim() ?? ''
+ if (!databaseUrl) throw new Error('DATABASE_URL is required.')
+ const actualDatabaseIdentity = databaseIdentity(databaseUrl)
+ if (readArgument('expected-database-identity') !== actualDatabaseIdentity) {
+ throw new Error(`Pass --expected-database-identity=${actualDatabaseIdentity}.`)
+ }
+
+ const actualSourceIdentity = sourceDatabaseIdentity()
+ if (!actualSourceIdentity) throw new Error('JST_REPORT_DB_* is required.')
+ if (readArgument('expected-source-database-identity') !== actualSourceIdentity) {
+ throw new Error(`Pass --expected-source-database-identity=${actualSourceIdentity}.`)
+ }
+
+ const batchSize = Number(readArgument('batch-size') || '1000')
+ if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 2_000) {
+ throw new Error('--batch-size must be an integer between 1 and 2000.')
+ }
+
+ const [
+ { prisma },
+ { supplyChainProductArchiveSalesPerformanceSyncService },
+ ] = await Promise.all([
+ import('../src/lib/prisma'),
+ import('../src/server/services/supply-chain-product-archive-sales-performance-sync.service'),
+ ])
+ try {
+ const result = await supplyChainProductArchiveSalesPerformanceSyncService.syncAll({
+ asOf: readAsOf(),
+ batchSize,
+ })
+ console.log(JSON.stringify(result, null, 2))
+ } finally {
+ await prisma.$disconnect()
+ }
+}
+
+void main().catch((error) => {
+ console.error(error instanceof Error ? error.message : String(error))
+ process.exitCode = 1
+})
diff --git a/furniture-3d-system/src/lib/supply-chain-product-archive.ts b/furniture-3d-system/src/lib/supply-chain-product-archive.ts
index 0cc53b34..8c9871b4 100644
--- a/furniture-3d-system/src/lib/supply-chain-product-archive.ts
+++ b/furniture-3d-system/src/lib/supply-chain-product-archive.ts
@@ -55,6 +55,15 @@ export type SupplyChainProductArchiveRow = {
purchasePrice: string
costPrice: string
costPriceSecondary: string
+ shipAmount: string
+ returnAmount: string
+ realPerformance: string
+ shipQty: string
+ grossProfit: string
+ grossMargin: string
+ feeRatio: string
+ operatingProfit: string
+ returnQty: string
}
export const PRODUCT_ARCHIVE_SALES_STATUSES = ['销售', '停产', '退市'] as const
diff --git a/furniture-3d-system/src/server/repositories/product-archive-sales-performance-source.repository.ts b/furniture-3d-system/src/server/repositories/product-archive-sales-performance-source.repository.ts
new file mode 100644
index 00000000..58690209
--- /dev/null
+++ b/furniture-3d-system/src/server/repositories/product-archive-sales-performance-source.repository.ts
@@ -0,0 +1,134 @@
+import sql from 'mssql'
+
+export type ProductArchiveSalesPerformanceAggregate = {
+ barcode: string
+ shipAmount: string
+ returnAmount: string
+ realPerformance: string
+ shipQty: string
+ returnQty: string
+ clearSalesCost: string
+ sendFreight: string
+}
+
+export interface ProductArchiveSalesPerformanceSourceRepository {
+ latestAvailableDate(input: {
+ periodStart: Date
+ notAfter: Date
+ }): Promise
+ listAggregates(input: {
+ periodStart: Date
+ periodEnd: Date
+ }): Promise
+ close(): Promise
+}
+
+function requiredEnv(name: string) {
+ const value = process.env[name]?.trim()
+ if (!value) throw new Error(`Missing required environment variable: ${name}`)
+ return value
+}
+
+function booleanEnv(name: string, fallback: boolean) {
+ const value = process.env[name]?.trim().toLowerCase()
+ if (!value) return fallback
+ return value === 'true' || value === '1'
+}
+
+function safeIdentifier(value: string, label: string) {
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
+ throw new Error(`${label} is not a valid SQL Server identifier`)
+ }
+ return value
+}
+
+class ProductArchiveSalesPerformanceSourceRepositoryClass
+ implements ProductArchiveSalesPerformanceSourceRepository
+{
+ private pool: sql.ConnectionPool | null = null
+
+ private async getPool() {
+ if (this.pool?.connected) return this.pool
+
+ this.pool = await new sql.ConnectionPool({
+ server: requiredEnv('JST_REPORT_DB_HOST'),
+ port: Number(process.env.JST_REPORT_DB_PORT?.trim() || '1433'),
+ database: requiredEnv('JST_REPORT_DB_NAME'),
+ user: requiredEnv('JST_REPORT_DB_USER'),
+ password: requiredEnv('JST_REPORT_DB_PASSWORD'),
+ options: {
+ encrypt: booleanEnv('JST_REPORT_DB_ENCRYPT', true),
+ trustServerCertificate: booleanEnv(
+ 'JST_REPORT_DB_TRUST_SERVER_CERTIFICATE',
+ true,
+ ),
+ },
+ pool: {
+ min: 0,
+ max: 2,
+ idleTimeoutMillis: 30_000,
+ },
+ requestTimeout: 180_000,
+ connectionTimeout: 15_000,
+ }).connect()
+ return this.pool
+ }
+
+ private sourceObject() {
+ const schema = safeIdentifier(
+ process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt',
+ 'JST_REPORT_DB_SCHEMA',
+ )
+ return `[${schema}].[dws_sn_sales_full_info_di]`
+ }
+
+ async latestAvailableDate(input: { periodStart: Date; notAfter: Date }) {
+ const pool = await this.getPool()
+ const result = await pool
+ .request()
+ .input('periodStart', sql.Date, input.periodStart)
+ .input('notAfter', sql.Date, input.notAfter)
+ .query<{ latestDate: Date | null }>(`
+ SELECT MAX(stat_date) AS latestDate
+ FROM ${this.sourceObject()}
+ WHERE stat_date >= @periodStart
+ AND stat_date <= @notAfter
+ `)
+ return result.recordset[0]?.latestDate ?? null
+ }
+
+ async listAggregates(input: { periodStart: Date; periodEnd: Date }) {
+ const pool = await this.getPool()
+ const result = await pool
+ .request()
+ .input('periodStart', sql.Date, input.periodStart)
+ .input('periodEnd', sql.Date, input.periodEnd)
+ .query(`
+ SELECT
+ UPPER(LTRIM(RTRIM(CAST(skuoid AS nvarchar(100))))) AS barcode,
+ CONVERT(varchar(64), SUM(CAST(COALESCE(clear_goods_cost, 0) AS decimal(38, 4)))) AS shipAmount,
+ CONVERT(varchar(64), SUM(CAST(COALESCE(refund_amount, 0) AS decimal(38, 4)))) AS returnAmount,
+ CONVERT(varchar(64), SUM(CAST(COALESCE(real_performance, 0) AS decimal(38, 4)))) AS realPerformance,
+ CONVERT(varchar(64), SUM(CAST(COALESCE(goods_count, 0) AS bigint))) AS shipQty,
+ CONVERT(varchar(64), SUM(CAST(COALESCE(refund_count, 0) AS bigint))) AS returnQty,
+ CONVERT(varchar(64), SUM(CAST(COALESCE(clear_sales_cost, 0) AS decimal(38, 4)))) AS clearSalesCost,
+ CONVERT(varchar(64), SUM(CAST(COALESCE(send_freight, 0) AS decimal(38, 4)))) AS sendFreight
+ FROM ${this.sourceObject()}
+ WHERE stat_date >= @periodStart
+ AND stat_date <= @periodEnd
+ AND NULLIF(LTRIM(RTRIM(CAST(skuoid AS nvarchar(100)))), '') IS NOT NULL
+ GROUP BY UPPER(LTRIM(RTRIM(CAST(skuoid AS nvarchar(100)))))
+ ORDER BY barcode
+ `)
+ return result.recordset
+ }
+
+ async close() {
+ if (!this.pool) return
+ await this.pool.close()
+ this.pool = null
+ }
+}
+
+export const productArchiveSalesPerformanceSourceRepository =
+ new ProductArchiveSalesPerformanceSourceRepositoryClass()
diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sales-performance-sync.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sales-performance-sync.repository.ts
new file mode 100644
index 00000000..db5f3b88
--- /dev/null
+++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sales-performance-sync.repository.ts
@@ -0,0 +1,86 @@
+import { Prisma } from '@prisma/client'
+import { prisma } from '@/lib/prisma'
+import type { ProductArchiveSalesPerformanceValues } from '@/server/services/product-archive-sales-performance'
+
+export type ProductArchiveSalesPerformanceTarget = {
+ id: string
+ barcode: string
+}
+
+export type ProductArchiveSalesPerformanceSnapshot =
+ ProductArchiveSalesPerformanceValues & {
+ archiveId: string
+ periodStart: Date
+ periodEnd: Date
+ syncedAt: Date
+ }
+
+export interface SupplyChainProductArchiveSalesPerformanceSyncRepository {
+ listTargets(): Promise
+ replaceAll(input: {
+ snapshots: ProductArchiveSalesPerformanceSnapshot[]
+ batchSize: number
+ }): Promise
+}
+
+function chunks(values: readonly T[], size: number) {
+ const result: T[][] = []
+ for (let index = 0; index < values.length; index += size) {
+ result.push(values.slice(index, index + size))
+ }
+ return result
+}
+
+class SupplyChainProductArchiveSalesPerformanceSyncRepositoryClass
+ implements SupplyChainProductArchiveSalesPerformanceSyncRepository
+{
+ async listTargets() {
+ return prisma.supplyChainProductArchive.findMany({
+ select: { id: true, barcode: true },
+ orderBy: { id: 'asc' },
+ })
+ }
+
+ async replaceAll(input: {
+ snapshots: ProductArchiveSalesPerformanceSnapshot[]
+ batchSize: number
+ }) {
+ if (input.snapshots.length === 0) {
+ throw new Error('Refusing to replace sales performance with an empty snapshot')
+ }
+ const batchSize = Math.min(2_000, Math.max(1, Math.trunc(input.batchSize)))
+ const batches = chunks(input.snapshots, batchSize)
+
+ await prisma.$transaction(
+ async (tx) => {
+ await tx.supplyChainProductArchiveSalesPerformance.deleteMany()
+ for (const batch of batches) {
+ await tx.supplyChainProductArchiveSalesPerformance.createMany({
+ data: batch.map((snapshot) => ({
+ archiveId: snapshot.archiveId,
+ barcode: snapshot.barcode,
+ periodStart: snapshot.periodStart,
+ periodEnd: snapshot.periodEnd,
+ shipAmount: new Prisma.Decimal(snapshot.shipAmount),
+ returnAmount: new Prisma.Decimal(snapshot.returnAmount),
+ realPerformance: new Prisma.Decimal(snapshot.realPerformance),
+ shipQty: snapshot.shipQty,
+ returnQty: snapshot.returnQty,
+ grossProfit: new Prisma.Decimal(snapshot.grossProfit),
+ grossMargin:
+ snapshot.grossMargin === null
+ ? null
+ : new Prisma.Decimal(snapshot.grossMargin),
+ syncedAt: snapshot.syncedAt,
+ })),
+ })
+ }
+ },
+ { maxWait: 10_000, timeout: 180_000 },
+ )
+ return input.snapshots.length
+ }
+}
+
+export const supplyChainProductArchiveSalesPerformanceSyncRepository =
+ new SupplyChainProductArchiveSalesPerformanceSyncRepositoryClass()
diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts
index be572a28..50553596 100644
--- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts
+++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts
@@ -60,6 +60,17 @@ const productArchiveSelect = {
purchasePrice: true,
costPrice: true,
costPriceSecondary: true,
+ salesPerformance: {
+ select: {
+ shipAmount: true,
+ returnAmount: true,
+ realPerformance: true,
+ shipQty: true,
+ returnQty: true,
+ grossProfit: true,
+ grossMargin: true,
+ },
+ },
createdAt: true,
updatedAt: true,
} satisfies Prisma.SupplyChainProductArchiveSelect
diff --git a/furniture-3d-system/src/server/services/__tests__/product-archive-sales-performance.test.ts b/furniture-3d-system/src/server/services/__tests__/product-archive-sales-performance.test.ts
new file mode 100644
index 00000000..b751b482
--- /dev/null
+++ b/furniture-3d-system/src/server/services/__tests__/product-archive-sales-performance.test.ts
@@ -0,0 +1,41 @@
+import { calculateProductArchiveSalesPerformance } from '../product-archive-sales-performance'
+
+describe('calculateProductArchiveSalesPerformance', () => {
+ it('calculates gross profit and gross margin from the confirmed components', () => {
+ expect(calculateProductArchiveSalesPerformance({
+ barcode: ' sku-001 ',
+ shipAmount: '70',
+ returnAmount: '5.5',
+ realPerformance: '100',
+ shipQty: '3',
+ returnQty: '1',
+ clearSalesCost: '60',
+ sendFreight: '15',
+ })).toEqual({
+ barcode: 'SKU-001',
+ shipAmount: '70.0000',
+ returnAmount: '5.5000',
+ realPerformance: '100.0000',
+ shipQty: 3n,
+ returnQty: 1n,
+ grossProfit: '25.0000',
+ grossMargin: '0.25000000',
+ })
+ })
+
+ it('leaves gross margin empty when real performance is zero', () => {
+ const result = calculateProductArchiveSalesPerformance({
+ barcode: 'SKU-002',
+ shipAmount: '0',
+ returnAmount: '0',
+ realPerformance: '0',
+ shipQty: '0',
+ returnQty: '0',
+ clearSalesCost: '10',
+ sendFreight: '2',
+ })
+
+ expect(result.grossProfit).toBe('-12.0000')
+ expect(result.grossMargin).toBeNull()
+ })
+})
diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sales-performance-sync.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sales-performance-sync.service.test.ts
new file mode 100644
index 00000000..9e115b81
--- /dev/null
+++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sales-performance-sync.service.test.ts
@@ -0,0 +1,107 @@
+import type { ProductArchiveSalesPerformanceSourceRepository } from '@/server/repositories/product-archive-sales-performance-source.repository'
+import type {
+ ProductArchiveSalesPerformanceSnapshot,
+ SupplyChainProductArchiveSalesPerformanceSyncRepository,
+} from '@/server/repositories/supply-chain-product-archive-sales-performance-sync.repository'
+import { SupplyChainProductArchiveSalesPerformanceSyncService } from '../supply-chain-product-archive-sales-performance-sync.service'
+
+jest.mock('@/server/repositories/product-archive-sales-performance-source.repository', () => ({
+ productArchiveSalesPerformanceSourceRepository: {},
+}))
+
+jest.mock('@/server/repositories/supply-chain-product-archive-sales-performance-sync.repository', () => ({
+ supplyChainProductArchiveSalesPerformanceSyncRepository: {},
+}))
+
+describe('SupplyChainProductArchiveSalesPerformanceSyncService', () => {
+ it('matches source aggregates to archive barcodes and replaces one complete snapshot', async () => {
+ const source: ProductArchiveSalesPerformanceSourceRepository = {
+ latestAvailableDate: jest.fn().mockResolvedValue(new Date('2026-08-15T00:00:00Z')),
+ listAggregates: jest.fn().mockResolvedValue([
+ {
+ barcode: ' sku-001 ',
+ shipAmount: '70',
+ returnAmount: '5',
+ realPerformance: '100',
+ shipQty: '3',
+ returnQty: '1',
+ clearSalesCost: '60',
+ sendFreight: '15',
+ },
+ {
+ barcode: 'NOT-IN-ARCHIVE',
+ shipAmount: '20',
+ returnAmount: '0',
+ realPerformance: '20',
+ shipQty: '1',
+ returnQty: '0',
+ clearSalesCost: '10',
+ sendFreight: '2',
+ },
+ ]),
+ close: jest.fn().mockResolvedValue(undefined),
+ }
+ let written: ProductArchiveSalesPerformanceSnapshot[] = []
+ const repository: SupplyChainProductArchiveSalesPerformanceSyncRepository = {
+ listTargets: jest.fn().mockResolvedValue([
+ { id: 'archive-1', barcode: 'SKU-001' },
+ { id: 'archive-2', barcode: 'SKU-002' },
+ ]),
+ replaceAll: jest.fn().mockImplementation(async ({ snapshots }) => {
+ written = snapshots
+ return snapshots.length
+ }),
+ }
+ const service = new SupplyChainProductArchiveSalesPerformanceSyncService({
+ source,
+ repository,
+ now: () => new Date('2026-08-17T02:00:00Z'),
+ })
+
+ await expect(service.syncAll({
+ asOf: new Date('2026-08-17T00:00:00Z'),
+ batchSize: 500,
+ })).resolves.toEqual({
+ periodStart: '2026-01-01',
+ periodEnd: '2026-08-15',
+ sourceAggregates: 2,
+ archiveTargets: 2,
+ matched: 1,
+ unmatchedSource: 1,
+ unmatchedArchive: 1,
+ })
+ expect(written).toHaveLength(1)
+ expect(written[0]).toMatchObject({
+ archiveId: 'archive-1',
+ barcode: 'SKU-001',
+ grossProfit: '25.0000',
+ grossMargin: '0.25000000',
+ periodStart: new Date('2026-01-01T00:00:00Z'),
+ periodEnd: new Date('2026-08-15T00:00:00Z'),
+ })
+ expect(source.close).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not replace local data when the source period is empty', async () => {
+ const source: ProductArchiveSalesPerformanceSourceRepository = {
+ latestAvailableDate: jest.fn().mockResolvedValue(null),
+ listAggregates: jest.fn(),
+ close: jest.fn().mockResolvedValue(undefined),
+ }
+ const repository: SupplyChainProductArchiveSalesPerformanceSyncRepository = {
+ listTargets: jest.fn(),
+ replaceAll: jest.fn(),
+ }
+ const service = new SupplyChainProductArchiveSalesPerformanceSyncService({
+ source,
+ repository,
+ now: () => new Date('2026-08-17T02:00:00Z'),
+ })
+
+ await expect(service.syncAll()).rejects.toThrow(
+ 'Sales performance source has no data in the requested period',
+ )
+ expect(repository.replaceAll).not.toHaveBeenCalled()
+ expect(source.close).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/furniture-3d-system/src/server/services/product-archive-sales-performance.ts b/furniture-3d-system/src/server/services/product-archive-sales-performance.ts
new file mode 100644
index 00000000..92e97a01
--- /dev/null
+++ b/furniture-3d-system/src/server/services/product-archive-sales-performance.ts
@@ -0,0 +1,61 @@
+import { Prisma } from '@prisma/client'
+import type { ProductArchiveSalesPerformanceAggregate } from '@/server/repositories/product-archive-sales-performance-source.repository'
+
+export type ProductArchiveSalesPerformanceValues = {
+ barcode: string
+ shipAmount: string
+ returnAmount: string
+ realPerformance: string
+ shipQty: bigint
+ returnQty: bigint
+ grossProfit: string
+ grossMargin: string | null
+}
+
+export function normalizeSalesPerformanceBarcode(value: string) {
+ return value.trim().toLocaleUpperCase('zh-CN')
+}
+
+function decimal(value: string, field: string) {
+ try {
+ return new Prisma.Decimal(value || '0')
+ } catch {
+ throw new Error(`Invalid ${field} value: ${value}`)
+ }
+}
+
+function integer(value: string, field: string) {
+ try {
+ return BigInt(value || '0')
+ } catch {
+ throw new Error(`Invalid ${field} value: ${value}`)
+ }
+}
+
+export function calculateProductArchiveSalesPerformance(
+ aggregate: ProductArchiveSalesPerformanceAggregate,
+): ProductArchiveSalesPerformanceValues {
+ const barcode = normalizeSalesPerformanceBarcode(aggregate.barcode)
+ if (!barcode) throw new Error('Sales performance barcode is required')
+
+ const shipAmount = decimal(aggregate.shipAmount, 'shipAmount')
+ const returnAmount = decimal(aggregate.returnAmount, 'returnAmount')
+ const realPerformance = decimal(aggregate.realPerformance, 'realPerformance')
+ const clearSalesCost = decimal(aggregate.clearSalesCost, 'clearSalesCost')
+ const sendFreight = decimal(aggregate.sendFreight, 'sendFreight')
+ const grossProfit = realPerformance.minus(clearSalesCost).minus(sendFreight)
+ const grossMargin = realPerformance.isZero()
+ ? null
+ : grossProfit.dividedBy(realPerformance).toFixed(8)
+
+ return {
+ barcode,
+ shipAmount: shipAmount.toFixed(4),
+ returnAmount: returnAmount.toFixed(4),
+ realPerformance: realPerformance.toFixed(4),
+ shipQty: integer(aggregate.shipQty, 'shipQty'),
+ returnQty: integer(aggregate.returnQty, 'returnQty'),
+ grossProfit: grossProfit.toFixed(4),
+ grossMargin,
+ }
+}
diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-sales-performance-sync.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-sales-performance-sync.service.ts
new file mode 100644
index 00000000..30f61d27
--- /dev/null
+++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-sales-performance-sync.service.ts
@@ -0,0 +1,117 @@
+import {
+ productArchiveSalesPerformanceSourceRepository,
+ type ProductArchiveSalesPerformanceSourceRepository,
+} from '@/server/repositories/product-archive-sales-performance-source.repository'
+import {
+ supplyChainProductArchiveSalesPerformanceSyncRepository,
+ type SupplyChainProductArchiveSalesPerformanceSyncRepository,
+} from '@/server/repositories/supply-chain-product-archive-sales-performance-sync.repository'
+import {
+ calculateProductArchiveSalesPerformance,
+ normalizeSalesPerformanceBarcode,
+} from './product-archive-sales-performance'
+
+const PERIOD_START = new Date('2026-01-01T00:00:00.000Z')
+
+function utcDate(value: Date) {
+ return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()))
+}
+
+export type SupplyChainProductArchiveSalesPerformanceSyncResult = {
+ periodStart: string
+ periodEnd: string
+ sourceAggregates: number
+ archiveTargets: number
+ matched: number
+ unmatchedSource: number
+ unmatchedArchive: number
+}
+
+type Dependencies = {
+ source: ProductArchiveSalesPerformanceSourceRepository
+ repository: SupplyChainProductArchiveSalesPerformanceSyncRepository
+ now(): Date
+}
+
+export class SupplyChainProductArchiveSalesPerformanceSyncService {
+ constructor(
+ private readonly dependencies: Dependencies = {
+ source: productArchiveSalesPerformanceSourceRepository,
+ repository: supplyChainProductArchiveSalesPerformanceSyncRepository,
+ now: () => new Date(),
+ },
+ ) {}
+
+ async syncAll(input: { asOf?: Date; batchSize?: number } = {}) {
+ const notAfter = utcDate(input.asOf ?? this.dependencies.now())
+ const batchSize = Math.min(2_000, Math.max(1, Math.trunc(input.batchSize ?? 1_000)))
+
+ try {
+ const periodEnd = await this.dependencies.source.latestAvailableDate({
+ periodStart: PERIOD_START,
+ notAfter,
+ })
+ if (!periodEnd) {
+ throw new Error('Sales performance source has no data in the requested period')
+ }
+
+ const normalizedPeriodEnd = utcDate(periodEnd)
+ const [aggregates, targets] = await Promise.all([
+ this.dependencies.source.listAggregates({
+ periodStart: PERIOD_START,
+ periodEnd: normalizedPeriodEnd,
+ }),
+ this.dependencies.repository.listTargets(),
+ ])
+ if (aggregates.length === 0) {
+ throw new Error('Sales performance aggregation returned no rows')
+ }
+
+ const targetsByBarcode = new Map(
+ targets.map((target) => [
+ normalizeSalesPerformanceBarcode(target.barcode),
+ target,
+ ]),
+ )
+ const syncedAt = this.dependencies.now()
+ let unmatchedSource = 0
+ const snapshots = aggregates.flatMap((aggregate) => {
+ const values = calculateProductArchiveSalesPerformance(aggregate)
+ const target = targetsByBarcode.get(values.barcode)
+ if (!target) {
+ unmatchedSource += 1
+ return []
+ }
+ return [{
+ ...values,
+ archiveId: target.id,
+ periodStart: PERIOD_START,
+ periodEnd: normalizedPeriodEnd,
+ syncedAt,
+ }]
+ })
+ if (snapshots.length === 0) {
+ throw new Error('Sales performance source did not match any product archive rows')
+ }
+
+ const matched = await this.dependencies.repository.replaceAll({
+ snapshots,
+ batchSize,
+ })
+ return {
+ periodStart: PERIOD_START.toISOString().slice(0, 10),
+ periodEnd: normalizedPeriodEnd.toISOString().slice(0, 10),
+ sourceAggregates: aggregates.length,
+ archiveTargets: targets.length,
+ matched,
+ unmatchedSource,
+ unmatchedArchive: Math.max(0, targets.length - matched),
+ } satisfies SupplyChainProductArchiveSalesPerformanceSyncResult
+ } finally {
+ await this.dependencies.source.close()
+ }
+ }
+}
+
+export const supplyChainProductArchiveSalesPerformanceSyncService =
+ new SupplyChainProductArchiveSalesPerformanceSyncService()
diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts
index 2c4e420d..954f1b47 100644
--- a/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts
+++ b/furniture-3d-system/src/server/services/supply-chain-product-archive.mapper.ts
@@ -12,6 +12,12 @@ function decimalText(value: { toString(): string } | null) {
return value?.toString() ?? ''
}
+function percentageText(
+ value: { mul(multiplier: number): { toFixed(digits: number): string } } | null,
+) {
+ return value ? `${value.mul(100).toFixed(2)}%` : ''
+}
+
export function normalizeSupplierName(value: string) {
return value.trim().toLocaleLowerCase('zh-CN')
}
@@ -88,5 +94,14 @@ export function mapProductArchiveRow({
purchasePrice: record.purchasePrice,
costPrice: record.costPrice,
costPriceSecondary: record.costPriceSecondary,
+ shipAmount: decimalText(record.salesPerformance?.shipAmount ?? null),
+ returnAmount: decimalText(record.salesPerformance?.returnAmount ?? null),
+ realPerformance: decimalText(record.salesPerformance?.realPerformance ?? null),
+ shipQty: record.salesPerformance?.shipQty.toString() ?? '',
+ grossProfit: decimalText(record.salesPerformance?.grossProfit ?? null),
+ grossMargin: percentageText(record.salesPerformance?.grossMargin ?? null),
+ feeRatio: '',
+ operatingProfit: '',
+ returnQty: record.salesPerformance?.returnQty.toString() ?? '',
} satisfies SupplyChainProductArchiveRow
}
From 5af369ef3d856118f6d35a2d1c5036ecec7f7088 Mon Sep 17 00:00:00 2001
From: jingchuan
Date: Mon, 17 Aug 2026 13:36:19 +0800
Subject: [PATCH 3/7] feat: add new product management executive dashboard
---
.../NewProductManagementDashboard.tsx | 2393 +++++++++++++++++
.../intake/new-product-management/page.tsx | 39 +
2 files changed, 2432 insertions(+)
create mode 100644 furniture-3d-system/src/app/intake/new-product-management/NewProductManagementDashboard.tsx
create mode 100644 furniture-3d-system/src/app/intake/new-product-management/page.tsx
diff --git a/furniture-3d-system/src/app/intake/new-product-management/NewProductManagementDashboard.tsx b/furniture-3d-system/src/app/intake/new-product-management/NewProductManagementDashboard.tsx
new file mode 100644
index 00000000..f67686e1
--- /dev/null
+++ b/furniture-3d-system/src/app/intake/new-product-management/NewProductManagementDashboard.tsx
@@ -0,0 +1,2393 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import {
+ AlertTriangle,
+ ArrowUpRight,
+ BarChart3,
+ CalendarDays,
+ CheckCircle2,
+ ChevronLeft,
+ ChevronRight,
+ CircleDot,
+ Clock3,
+ Filter,
+ Layers3,
+ SlidersHorizontal,
+ TimerReset,
+ Users,
+ X,
+} from "lucide-react";
+import type {
+ OaBoardDashboardDTO,
+ OaBoardProjectListItemDTO,
+} from "@/server/services/oa-board.service";
+
+type Period = "week" | "month" | "year";
+type AnalysisView =
+ "overview" | "design" | "sample" | "production" | "art" | "pricing";
+type SampleRow = {
+ id: string;
+ taskKey?: string;
+ openingCode?: string;
+ spu: string;
+ name: string;
+ structure: string;
+ supplier: string;
+ buyer: string;
+ stage: string;
+ stageStatus: string;
+ scheduleDate: string;
+ startDate: string;
+ finishDate: string;
+ days: number;
+ risk: "high" | "medium" | "normal";
+};
+
+type StageDatum = {
+ id: string;
+ label: string;
+ actual: number;
+ target: number;
+ count: number;
+ onTime?: number;
+ tone: "red" | "amber" | "green";
+ riskCount?: number;
+ maxDelay?: number;
+};
+
+type CurrentBottleneckDatum = {
+ id: string;
+ label: string;
+ count: number;
+ maxDelay: number;
+ owner: string;
+ view: Exclude;
+};
+
+type CurrentNodeGapDatum = Omit;
+
+type BaseAnalysisDatum = (typeof ANALYSIS_DATA)[Exclude<
+ AnalysisView,
+ "overview"
+>];
+type AnalysisDatum = Omit & {
+ riskTaskKeys?: string[];
+ blockers: Array<
+ BaseAnalysisDatum["blockers"][number] & { taskKeys?: string[] }
+ >;
+};
+
+type AnalysisDrilldown = {
+ label: string;
+ rows: SampleRow[];
+} | null;
+
+const VIEW_STAGE_PATTERNS: Record, RegExp> = {
+ design: /立项|设计|结构|外观|评审/,
+ sample: /打样|审样|样品/,
+ production: /生产|首单|产前样|出货|质检|送仓/,
+ art: /视觉|出图|商品资料|安装图|寄样|上架/,
+ pricing: /定价|限价|核价|价格/,
+};
+
+const VIEW_TIMELINE_KEYS: Record<
+ Exclude,
+ string[]
+> = {
+ design: ["exterior", "structure", "market_analysis"],
+ sample: ["sample_prepare", "sample_review"],
+ production: [
+ "production",
+ "first_order",
+ "pre_production",
+ "shipment",
+ "warehouse_qc",
+ ],
+ art: ["material", "install_manual", "sample_send", "art", "listing"],
+ pricing: ["pricing", "pricing_flow"],
+};
+
+const ANALYSIS_DIMENSION_KEYS = ["structure", "supplier", "buyer"] as const;
+
+const PERIOD_LABELS: Record = {
+ week: "周",
+ month: "月",
+ year: "年",
+};
+
+const PERIOD_DATA: Record<
+ Period,
+ {
+ total: number;
+ running: number;
+ completed: number;
+ rate: number;
+ newCount: number;
+ totalDays: number;
+ targetDays: number;
+ assessed: number;
+ timeout: number;
+ maxOverdueDays: number;
+ maxOverdueTask: string;
+ }
+> = {
+ week: {
+ total: 18,
+ running: 8,
+ completed: 10,
+ rate: 56,
+ newCount: 5,
+ totalDays: 31.4,
+ targetDays: 28,
+ assessed: 18,
+ timeout: 4,
+ maxOverdueDays: 8.6,
+ maxOverdueTask: "KK-260812-018",
+ },
+ month: {
+ total: 96,
+ running: 38,
+ completed: 58,
+ rate: 60,
+ newCount: 24,
+ totalDays: 34.6,
+ targetDays: 30,
+ assessed: 91,
+ timeout: 17,
+ maxOverdueDays: 14.2,
+ maxOverdueTask: "KK-260805-031",
+ },
+ year: {
+ total: 428,
+ running: 126,
+ completed: 302,
+ rate: 71,
+ newCount: 428,
+ totalDays: 32.1,
+ targetDays: 30,
+ assessed: 406,
+ timeout: 64,
+ maxOverdueDays: 26.8,
+ maxOverdueTask: "KK-260318-106",
+ },
+};
+
+const STAGE_DATA: StageDatum[] = [
+ {
+ id: "design",
+ label: "产品设计",
+ actual: 4.1,
+ target: 3.2,
+ count: 17,
+ onTime: 68,
+ riskCount: 7,
+ maxDelay: 6.2,
+ tone: "red",
+ },
+ {
+ id: "sample",
+ label: "打样",
+ actual: 8.5,
+ target: 5.5,
+ count: 12,
+ onTime: 61,
+ riskCount: 8,
+ maxDelay: 9.4,
+ tone: "red",
+ },
+ {
+ id: "production",
+ label: "生产",
+ actual: 7.2,
+ target: 7.5,
+ count: 21,
+ onTime: 76,
+ riskCount: 6,
+ maxDelay: 5.1,
+ tone: "amber",
+ },
+ {
+ id: "art",
+ label: "视觉交付",
+ actual: 6.4,
+ target: 5,
+ count: 6,
+ onTime: 67,
+ riskCount: 4,
+ maxDelay: 7.8,
+ tone: "red",
+ },
+ {
+ id: "pricing",
+ label: "定价",
+ actual: 2.6,
+ target: 2,
+ count: 4,
+ onTime: 79,
+ riskCount: 3,
+ maxDelay: 3.4,
+ tone: "amber",
+ },
+];
+
+const CURRENT_BOTTLENECK_DATA: CurrentBottleneckDatum[] = [
+ {
+ id: "sample-arrange",
+ label: "确认打样出货时间",
+ count: 5,
+ maxDelay: 6.4,
+ owner: "采购组",
+ view: "sample",
+ },
+ {
+ id: "first-order",
+ label: "首单下单",
+ count: 4,
+ maxDelay: 5.1,
+ owner: "采购组",
+ view: "production",
+ },
+ {
+ id: "material",
+ label: "商品资料输出",
+ count: 3,
+ maxDelay: 3.8,
+ owner: "品牌策划",
+ view: "art",
+ },
+];
+
+const PLAN_DATA = [
+ { label: "常规开发", count: 42, rate: 74, color: "#245f5a" },
+ { label: "快速开发", count: 27, rate: 81, color: "#477a8c" },
+ { label: "供应商共创", count: 16, rate: 44, color: "#b66b35" },
+ { label: "定制开发", count: 11, rate: 36, color: "#8d5960" },
+];
+
+const ANALYSIS_DATA: Record<
+ Exclude,
+ {
+ label: string;
+ subtitle: string;
+ active: number;
+ avgDays: number;
+ targetDays: number;
+ onTime: number;
+ risk: number;
+ statuses: Array<{ label: string; count: number; color: string }>;
+ blockers: Array<{ label: string; count: number; meta: string }>;
+ dimensions: Array<{ label: string; count: number; note: string }>;
+ }
+> = {
+ design: {
+ label: "产品设计",
+ subtitle: "结构、外观和设计评审的交付质量",
+ active: 26,
+ avgDays: 8.8,
+ targetDays: 7,
+ onTime: 68,
+ risk: 9,
+ statuses: [
+ { label: "结构设计中", count: 8, color: "#b34e46" },
+ { label: "外观设计中", count: 6, color: "#d49344" },
+ { label: "待评审", count: 7, color: "#477a8c" },
+ { label: "已通过", count: 5, color: "#3d9279" },
+ ],
+ blockers: [
+ { label: "结构方案反复修改", count: 6, meta: "平均多 2.1 天" },
+ { label: "外观稿待确认", count: 5, meta: "集中在家具类" },
+ { label: "评审排期等待", count: 4, meta: "最长 6 天" },
+ ],
+ dimensions: [
+ { label: "框架结构 A", count: 9, note: "达标率 67%" },
+ { label: "板式结构 B", count: 8, note: "达标率 75%" },
+ { label: "岩板结构 C", count: 5, note: "达标率 40%" },
+ ],
+ },
+ sample: {
+ label: "打样",
+ subtitle: "安排、开样、完成与供应商协同效率",
+ active: 21,
+ avgDays: 12.8,
+ targetDays: 8,
+ onTime: 54,
+ risk: 12,
+ statuses: [
+ { label: "待安排", count: 4, color: "#d49344" },
+ { label: "已安排未开始", count: 5, color: "#b34e46" },
+ { label: "打样中", count: 3, color: "#477a8c" },
+ { label: "已完成", count: 9, color: "#3d9279" },
+ ],
+ blockers: [
+ { label: "供应商未确认排期", count: 7, meta: "最长 12 天" },
+ { label: "采购尚未安排", count: 4, meta: "集中在框架结构" },
+ { label: "样品反复返工", count: 3, meta: "平均返工 1.6 次" },
+ ],
+ dimensions: [
+ { label: "东莞木作一厂", count: 7, note: "平均 12.0 天" },
+ { label: "佛山优木", count: 6, note: "平均 9.0 天" },
+ { label: "中山石材协作厂", count: 4, note: "平均 13.0 天" },
+ ],
+ },
+ production: {
+ label: "生产",
+ subtitle: "首单、产前样与生产交付的节奏",
+ active: 18,
+ avgDays: 18.4,
+ targetDays: 20,
+ onTime: 81,
+ risk: 4,
+ statuses: [
+ { label: "等待首单", count: 5, color: "#d49344" },
+ { label: "产前样确认", count: 4, color: "#477a8c" },
+ { label: "生产中", count: 6, color: "#245f5a" },
+ { label: "已完成", count: 3, color: "#3d9279" },
+ ],
+ blockers: [
+ { label: "首单合同未回签", count: 4, meta: "最长 8 天" },
+ { label: "产前样待确认", count: 3, meta: "影响排产" },
+ { label: "物料到位等待", count: 2, meta: "采购跟进中" },
+ ],
+ dimensions: [
+ { label: "东莞木作一厂", count: 6, note: "达标率 83%" },
+ { label: "佛山优木", count: 5, note: "达标率 80%" },
+ { label: "宁波金属件", count: 3, note: "达标率 67%" },
+ ],
+ },
+ art: {
+ label: "视觉交付",
+ subtitle: "视觉出图、详情制作与商品资料准备",
+ active: 24,
+ avgDays: 6.4,
+ targetDays: 5,
+ onTime: 73,
+ risk: 6,
+ statuses: [
+ { label: "待出图", count: 7, color: "#d49344" },
+ { label: "出图中", count: 8, color: "#477a8c" },
+ { label: "详情制作", count: 5, color: "#245f5a" },
+ { label: "已完成", count: 4, color: "#3d9279" },
+ ],
+ blockers: [
+ { label: "实拍素材未到", count: 5, meta: "等待生产出样" },
+ { label: "品牌规范待确认", count: 3, meta: "平均 2.0 天" },
+ { label: "详情文案返修", count: 2, meta: "涉及 2 个品牌" },
+ ],
+ dimensions: [
+ { label: "沙发类", count: 9, note: "平均 6.1 天" },
+ { label: "柜类", count: 8, note: "平均 5.8 天" },
+ { label: "桌几类", count: 5, note: "平均 7.2 天" },
+ ],
+ },
+ pricing: {
+ label: "定价",
+ subtitle: "成本核算、价格审批与运营上架准备",
+ active: 16,
+ avgDays: 2.6,
+ targetDays: 2,
+ onTime: 79,
+ risk: 3,
+ statuses: [
+ { label: "待成本核算", count: 4, color: "#d49344" },
+ { label: "限价审批中", count: 5, color: "#477a8c" },
+ { label: "待运营确认", count: 4, color: "#245f5a" },
+ { label: "已完成", count: 3, color: "#3d9279" },
+ ],
+ blockers: [
+ { label: "供应商成本未回传", count: 3, meta: "最长 5 天" },
+ { label: "价格带冲突", count: 2, meta: "需要老板拍板" },
+ { label: "运营上架排期", count: 2, meta: "等待活动计划" },
+ ],
+ dimensions: [
+ { label: "常规开发", count: 7, note: "平均 2.1 天" },
+ { label: "快速开发", count: 5, note: "平均 1.8 天" },
+ { label: "供应商共创", count: 3, note: "平均 3.6 天" },
+ ],
+ },
+};
+
+const SAMPLE_ROWS: SampleRow[] = [
+ {
+ id: "1",
+ spu: "SPU-260811-018",
+ name: "云朵模块沙发",
+ structure: "框架结构 A",
+ supplier: "东莞木作一厂",
+ buyer: "陈思远",
+ stage: "打样",
+ stageStatus: "已安排未开始",
+ scheduleDate: "2026-08-04",
+ startDate: "-",
+ finishDate: "-",
+ days: 12,
+ risk: "high",
+ },
+ {
+ id: "2",
+ spu: "SPU-260808-011",
+ name: "悬浮床头柜",
+ structure: "板式结构 B",
+ supplier: "佛山优木",
+ buyer: "周琳",
+ stage: "打样",
+ stageStatus: "打样中",
+ scheduleDate: "2026-08-01",
+ startDate: "2026-08-03",
+ finishDate: "-",
+ days: 9,
+ risk: "high",
+ },
+ {
+ id: "3",
+ spu: "SPU-260805-007",
+ name: "岩板餐桌 1.4m",
+ structure: "岩板结构 C",
+ supplier: "中山石材协作厂",
+ buyer: "李浩",
+ stage: "打样",
+ stageStatus: "已完成超时",
+ scheduleDate: "2026-07-26",
+ startDate: "2026-07-29",
+ finishDate: "2026-08-08",
+ days: 13,
+ risk: "medium",
+ },
+ {
+ id: "4",
+ spu: "SPU-260802-003",
+ name: "窄体收纳高柜",
+ structure: "板式结构 B",
+ supplier: "佛山优木",
+ buyer: "周琳",
+ stage: "产品结构",
+ stageStatus: "结构评审",
+ scheduleDate: "-",
+ startDate: "-",
+ finishDate: "-",
+ days: 7,
+ risk: "medium",
+ },
+ {
+ id: "5",
+ spu: "SPU-260728-029",
+ name: "奶油风书桌",
+ structure: "框架结构 A",
+ supplier: "东莞木作一厂",
+ buyer: "陈思远",
+ stage: "采购 / 首单",
+ stageStatus: "等待供应商确认",
+ scheduleDate: "-",
+ startDate: "-",
+ finishDate: "-",
+ days: 8,
+ risk: "high",
+ },
+ {
+ id: "6",
+ spu: "SPU-260724-016",
+ name: "可叠放边几",
+ structure: "金属结构 D",
+ supplier: "宁波金属件",
+ buyer: "王璐",
+ stage: "审样",
+ stageStatus: "待审样",
+ scheduleDate: "2026-07-22",
+ startDate: "2026-07-25",
+ finishDate: "-",
+ days: 6,
+ risk: "normal",
+ },
+];
+
+function toLiveSampleRow(
+ item: OaBoardProjectListItemDTO,
+ index: number,
+): SampleRow {
+ const stage =
+ item.currentStageName ||
+ item.currentApprovalStageName ||
+ item.formSummary.approvalStageDisplay.split(";")[0] ||
+ "未开始";
+ const actualDays =
+ item.timelineMetrics.find((metric) => metric.key === "total_actual")
+ ?.actualDays ??
+ item.overallActualHours ??
+ 0;
+ const createdDate = item.createdDate?.slice(0, 10) || "-";
+ const isCompleted = item.overallStatus === "COMPLETED";
+ const currentAssessment = item.nodeAssessments.find(
+ (assessment) =>
+ assessment.status === "RUNNING" ||
+ assessment.nodeName === item.currentStageName ||
+ assessment.approvalStageName === item.currentApprovalStageName,
+ );
+ return {
+ id: item.id || String(index),
+ taskKey: getItemKey(item),
+ openingCode:
+ item.formSummary.openingCode || item.openingCode || "字段待补充",
+ spu: item.spu || item.formSummary.spu || item.openingCode || item.boardKey,
+ name: item.productName || item.title || "未命名新品",
+ structure:
+ item.formSummary.categoryName || item.categoryName || "字段待补充",
+ supplier: "字段待补充",
+ buyer: item.formSummary.purchaseOwner || "待分配",
+ stage,
+ stageStatus: isCompleted
+ ? "已完成"
+ : item.overallTimeoutFlag
+ ? "超时处理中"
+ : "进行中",
+ scheduleDate: createdDate,
+ startDate: currentAssessment?.startedAtSource?.slice(0, 10) || "-",
+ finishDate:
+ currentAssessment?.finishedAtSource?.slice(0, 10) ||
+ item.finishedDate?.slice(0, 10) ||
+ "-",
+ days: Number(Math.max(0, actualDays).toFixed(1)),
+ risk: item.overallTimeoutFlag
+ ? "high"
+ : item.overallStatus === "RUNNING"
+ ? "medium"
+ : "normal",
+ };
+}
+
+function getItemKey(item: OaBoardProjectListItemDTO) {
+ return item.formSummary.openingCode || item.openingCode || item.boardKey;
+}
+
+function getRowTaskKey(row: SampleRow) {
+ return row.taskKey || row.openingCode || row.spu;
+}
+
+function groupItemsByTask(items: OaBoardProjectListItemDTO[]) {
+ const groups = new Map();
+ items.forEach((item) => {
+ const key = getItemKey(item);
+ groups.set(key, [...(groups.get(key) ?? []), item]);
+ });
+ return Array.from(groups.values());
+}
+
+function getTaskCreatedDate(items: OaBoardProjectListItemDTO[]) {
+ return items
+ .map((item) => item.createdDate?.slice(0, 10) || "")
+ .filter(Boolean)
+ .sort()[0];
+}
+
+function getItemStage(item: OaBoardProjectListItemDTO) {
+ return (
+ item.currentStageName ||
+ item.currentApprovalStageName ||
+ item.formSummary.approvalStageDisplay.split(";")[0] ||
+ "未标注阶段"
+ );
+}
+
+function classifyStageName(stage: string) {
+ if (/定价|限价|核价|价格/.test(stage)) return "pricing";
+ if (/视觉|出图|商品资料|安装图|寄样|上架/.test(stage)) return "art";
+ if (/打样/.test(stage)) return "sample";
+ if (/审样/.test(stage)) return "review";
+ if (/生产|出货/.test(stage)) return "production";
+ if (/首单|产前样/.test(stage)) return "purchase";
+ if (/结构/.test(stage)) return "structure";
+ if (/外观|设计/.test(stage)) return "appearance";
+ if (/立项|需求|评审/.test(stage)) return "requirements";
+ return null;
+}
+
+function analysisViewForStage(
+ stageId: string,
+): Exclude {
+ if (["design", "requirements", "structure", "appearance"].includes(stageId)) {
+ return "design";
+ }
+ if (["sample", "review"].includes(stageId)) return "sample";
+ if (["purchase", "production"].includes(stageId)) return "production";
+ if (stageId === "art") return "art";
+ return "pricing";
+}
+
+function dedupeLiveItems(items: OaBoardProjectListItemDTO[]) {
+ const byTask = new Map();
+ items.forEach((item) => {
+ const key = getItemKey(item);
+ const current = byTask.get(key);
+ if (
+ !current ||
+ (current.overallStatus === "COMPLETED" &&
+ item.overallStatus !== "COMPLETED") ||
+ (item.overallActualHours ?? 0) > (current.overallActualHours ?? 0)
+ ) {
+ byTask.set(key, item);
+ }
+ });
+ return Array.from(byTask.values());
+}
+
+function buildLiveRows(items: OaBoardProjectListItemDTO[]) {
+ return groupItemsByTask(items).map((group, index) => {
+ const base = dedupeLiveItems(group)[0];
+ const row = toLiveSampleRow(base, index);
+ const stages = Array.from(new Set(group.map(getItemStage)));
+ const allCompleted = group.every(
+ (item) => item.overallStatus === "COMPLETED",
+ );
+ const hasTimeout = group.some((item) => item.overallTimeoutFlag);
+ return {
+ ...row,
+ stage: stages.join(" / "),
+ stageStatus: allCompleted
+ ? "已完成"
+ : hasTimeout
+ ? "超时处理中"
+ : "进行中",
+ days: Math.max(...group.map((item) => toLiveSampleRow(item, index).days)),
+ risk: hasTimeout ? "high" : allCompleted ? "normal" : "medium",
+ } satisfies SampleRow;
+ });
+}
+
+function buildLiveSummary(dashboard: OaBoardDashboardDTO) {
+ const items = dashboard.items;
+ const uniqueItems = dedupeLiveItems(items);
+ const allKeys = new Set(items.map(getItemKey));
+ const statusByKey = new Map>();
+ items.forEach((item) => {
+ const statuses = statusByKey.get(getItemKey(item)) ?? new Set();
+ statuses.add(item.overallStatus || "UNKNOWN");
+ statusByKey.set(getItemKey(item), statuses);
+ });
+ const total = allKeys.size;
+ const completed = Array.from(statusByKey.values()).filter(
+ (statuses) => statuses.size === 1 && statuses.has("COMPLETED"),
+ ).length;
+ const running = total - completed;
+ const durationRows = uniqueItems
+ .map(
+ (item) =>
+ item.timelineMetrics.find((metric) => metric.key === "total_actual")
+ ?.actualDays ?? item.overallActualHours,
+ )
+ .filter(
+ (value): value is number =>
+ value !== null && Number.isFinite(value) && value > 0,
+ );
+ const targetRows = uniqueItems
+ .map(
+ (item) =>
+ item.timelineMetrics.find((metric) => metric.key === "total_actual")
+ ?.targetDays ?? item.overallTargetHours,
+ )
+ .filter(
+ (value): value is number =>
+ value !== null && Number.isFinite(value) && value > 0,
+ );
+ const timingByKey = new Map<
+ string,
+ { delay: number; isAssessed: boolean; task: string }
+ >();
+ items.forEach((item) => {
+ const totalMetric = item.timelineMetrics.find(
+ (metric) => metric.key === "total_actual",
+ );
+ const actual = totalMetric?.actualDays ?? item.overallActualHours;
+ const target = totalMetric?.targetDays ?? item.overallTargetHours;
+ const isAssessed =
+ actual !== null &&
+ target !== null &&
+ Number.isFinite(actual) &&
+ Number.isFinite(target) &&
+ target > 0;
+ const delay = isAssessed ? Math.max(0, actual - target) : 0;
+ const key = getItemKey(item);
+ const current = timingByKey.get(key);
+ if (
+ !current ||
+ delay > current.delay ||
+ (isAssessed && !current.isAssessed)
+ ) {
+ timingByKey.set(key, {
+ delay,
+ isAssessed,
+ task: key,
+ });
+ }
+ });
+ const assessedRows = Array.from(timingByKey.values()).filter(
+ (row) => row.isAssessed,
+ );
+ const overdueRows = assessedRows.filter((row) => row.delay > 0);
+ const maxOverdueRow = overdueRows.reduce<(typeof overdueRows)[number] | null>(
+ (max, row) => (!max || row.delay > max.delay ? row : max),
+ null,
+ );
+ return {
+ total,
+ running,
+ completed,
+ rate: total > 0 ? Math.round((completed / total) * 100) : 0,
+ newCount: total,
+ totalDays: durationRows.length
+ ? Number(
+ (
+ durationRows.reduce((sum, value) => sum + value, 0) /
+ durationRows.length
+ ).toFixed(1),
+ )
+ : 0,
+ targetDays: targetRows.length
+ ? Number(
+ (
+ targetRows.reduce((sum, value) => sum + value, 0) /
+ targetRows.length
+ ).toFixed(1),
+ )
+ : 0,
+ assessed: assessedRows.length,
+ timeout: overdueRows.length,
+ maxOverdueDays: maxOverdueRow ? Number(maxOverdueRow.delay.toFixed(1)) : 0,
+ maxOverdueTask: maxOverdueRow?.task ?? "暂无超期",
+ };
+}
+
+function average(values: number[]) {
+ return values.length
+ ? Number(
+ (values.reduce((sum, value) => sum + value, 0) / values.length).toFixed(
+ 1,
+ ),
+ )
+ : 0;
+}
+
+function buildLiveStageData(items: OaBoardProjectListItemDTO[]): StageDatum[] {
+ const configs: Array<{
+ id: Exclude;
+ label: string;
+ keys: string[];
+ }> = [
+ { id: "design", label: "产品设计", keys: VIEW_TIMELINE_KEYS.design },
+ { id: "sample", label: "打样", keys: VIEW_TIMELINE_KEYS.sample },
+ {
+ id: "production",
+ label: "生产",
+ keys: VIEW_TIMELINE_KEYS.production,
+ },
+ { id: "art", label: "视觉交付", keys: VIEW_TIMELINE_KEYS.art },
+ { id: "pricing", label: "定价", keys: VIEW_TIMELINE_KEYS.pricing },
+ ];
+ const uniqueItems = dedupeLiveItems(items);
+ return configs.map((config) => {
+ const metricRows = uniqueItems.flatMap((item) =>
+ item.timelineMetrics
+ .filter((metric) => config.keys.includes(metric.key))
+ .map((metric) => ({ item, metric })),
+ );
+ const actual = average(
+ metricRows
+ .map(({ metric }) => metric.actualDays)
+ .filter(
+ (value): value is number => value !== null && Number.isFinite(value),
+ ),
+ );
+ const target = average(
+ metricRows
+ .map(({ metric }) => metric.targetDays)
+ .filter(
+ (value): value is number => value !== null && Number.isFinite(value),
+ ),
+ );
+ const count = uniqueItems.filter((item) => {
+ const stageId = classifyStageName(getItemStage(item));
+ return stageId ? analysisViewForStage(stageId) === config.id : false;
+ }).length;
+ const assessableRows = metricRows.filter(
+ ({ metric }) =>
+ metric.actualDays !== null &&
+ metric.targetDays !== null &&
+ Number.isFinite(metric.actualDays) &&
+ Number.isFinite(metric.targetDays) &&
+ metric.targetDays > 0,
+ );
+ const assessedTaskKeys = new Set(
+ assessableRows.map(({ item }) => getItemKey(item)),
+ );
+ const overdueRows = assessableRows.filter(
+ ({ metric }) => (metric.actualDays ?? 0) > (metric.targetDays ?? 0),
+ );
+ const overdueTaskKeys = new Set(
+ overdueRows.map(({ item }) => getItemKey(item)),
+ );
+ const onTime = assessedTaskKeys.size
+ ? Math.round(
+ ((assessedTaskKeys.size - overdueTaskKeys.size) /
+ assessedTaskKeys.size) *
+ 100,
+ )
+ : 0;
+ return {
+ id: config.id,
+ label: config.label,
+ actual,
+ target,
+ count,
+ onTime,
+ riskCount: overdueTaskKeys.size,
+ maxDelay: overdueRows.length
+ ? Math.max(
+ ...overdueRows.map(
+ ({ metric }) =>
+ (metric.actualDays ?? 0) - (metric.targetDays ?? 0),
+ ),
+ )
+ : 0,
+ tone: onTime < 70 ? "red" : onTime < 85 ? "amber" : "green",
+ };
+ });
+}
+
+function getNodeAnalysisView(
+ item: OaBoardProjectListItemDTO,
+ assessment: OaBoardProjectListItemDTO["nodeAssessments"][number],
+): Exclude {
+ const description = [
+ assessment.nodeName,
+ assessment.approvalStageName,
+ assessment.flowLabel,
+ assessment.processName,
+ ]
+ .filter(Boolean)
+ .join(" ");
+ const stageId = classifyStageName(description);
+ if (stageId) return analysisViewForStage(stageId);
+ if (/视觉|出图|美工|摄影/.test(description)) return "art";
+ if (/定价|限价|核价|价格/.test(description)) return "pricing";
+ const currentStageId = classifyStageName(getItemStage(item));
+ return currentStageId ? analysisViewForStage(currentStageId) : "design";
+}
+
+function buildLiveCurrentBottlenecks(
+ items: OaBoardProjectListItemDTO[],
+): CurrentBottleneckDatum[] {
+ const groups = new Map<
+ string,
+ {
+ taskKeys: Set;
+ maxDelay: number;
+ owners: Set;
+ view: Exclude;
+ }
+ >();
+ items.forEach((item) => {
+ item.nodeAssessments
+ .filter(
+ (assessment) =>
+ assessment.status === "RUNNING" && assessment.isOvertime,
+ )
+ .forEach((assessment) => {
+ const label =
+ assessment.approvalStageName || assessment.nodeName || "未标注节点";
+ const current = groups.get(label) ?? {
+ taskKeys: new Set(),
+ maxDelay: 0,
+ owners: new Set(),
+ view: getNodeAnalysisView(item, assessment),
+ };
+ const calculatedDelay =
+ assessment.actualHours !== null && assessment.targetHours !== null
+ ? assessment.actualHours - assessment.targetHours
+ : 0;
+ current.taskKeys.add(getItemKey(item));
+ current.maxDelay = Math.max(
+ current.maxDelay,
+ assessment.overtimeHours ?? calculatedDelay,
+ 0,
+ );
+ if (assessment.ownerLabel) current.owners.add(assessment.ownerLabel);
+ groups.set(label, current);
+ });
+ });
+ return Array.from(groups.entries())
+ .map(([label, value]) => ({
+ id: `${value.view}:${label}`,
+ label,
+ count: value.taskKeys.size,
+ maxDelay: Number(value.maxDelay.toFixed(1)),
+ owner:
+ value.owners.size === 0
+ ? "待分配"
+ : value.owners.size === 1
+ ? Array.from(value.owners)[0]
+ : `${value.owners.size} 位负责人`,
+ view: value.view,
+ }))
+ .sort((a, b) => b.count - a.count || b.maxDelay - a.maxDelay)
+ .slice(0, 6);
+}
+
+function buildLiveCurrentNodeCoverage(items: OaBoardProjectListItemDTO[]) {
+ const activeTaskKeys = new Set();
+ const assessedTaskKeys = new Set();
+ items.forEach((item) => {
+ item.nodeAssessments
+ .filter((assessment) => assessment.status === "RUNNING")
+ .forEach((assessment) => {
+ const taskKey = getItemKey(item);
+ activeTaskKeys.add(taskKey);
+ if (
+ assessment.targetHours !== null &&
+ Number.isFinite(assessment.targetHours) &&
+ assessment.targetHours > 0
+ ) {
+ assessedTaskKeys.add(taskKey);
+ }
+ });
+ });
+ return { total: activeTaskKeys.size, assessed: assessedTaskKeys.size };
+}
+
+function buildLiveCurrentNodeGaps(
+ items: OaBoardProjectListItemDTO[],
+): CurrentNodeGapDatum[] {
+ const groups = new Map<
+ string,
+ {
+ taskKeys: Set;
+ owners: Set;
+ view: Exclude;
+ }
+ >();
+ items.forEach((item) => {
+ item.nodeAssessments
+ .filter(
+ (assessment) =>
+ assessment.status === "RUNNING" &&
+ (assessment.targetHours === null || assessment.targetHours <= 0),
+ )
+ .forEach((assessment) => {
+ const label =
+ assessment.approvalStageName || assessment.nodeName || "未标注节点";
+ const current = groups.get(label) ?? {
+ taskKeys: new Set(),
+ owners: new Set(),
+ view: getNodeAnalysisView(item, assessment),
+ };
+ current.taskKeys.add(getItemKey(item));
+ if (assessment.ownerLabel) current.owners.add(assessment.ownerLabel);
+ groups.set(label, current);
+ });
+ });
+ return Array.from(groups.entries())
+ .map(([label, value]) => ({
+ id: `gap:${value.view}:${label}`,
+ label,
+ count: value.taskKeys.size,
+ owner:
+ value.owners.size === 0
+ ? "待分配"
+ : value.owners.size === 1
+ ? Array.from(value.owners)[0]
+ : `${value.owners.size} 位负责人`,
+ view: value.view,
+ }))
+ .sort((a, b) => b.count - a.count)
+ .slice(0, 6);
+}
+
+function buildLivePlanData(items: OaBoardProjectListItemDTO[]) {
+ const groups = new Map();
+ dedupeLiveItems(items).forEach((item) => {
+ const label = item.formSummary.developmentPlan || "未填写方案";
+ const group = groups.get(label) ?? { total: 0, completed: 0 };
+ group.total += 1;
+ if (item.overallStatus === "COMPLETED") group.completed += 1;
+ groups.set(label, group);
+ });
+ const colors = ["#245f5a", "#477a8c", "#b66b35", "#8d5960"];
+ return Array.from(groups.entries())
+ .map(([label, group], index) => ({
+ label,
+ count: group.total,
+ rate: group.total ? Math.round((group.completed / group.total) * 100) : 0,
+ color: colors[index % colors.length],
+ }))
+ .sort((a, b) => b.count - a.count)
+ .slice(0, 4);
+}
+
+function buildLiveAnalysisData(
+ view: Exclude,
+ items: OaBoardProjectListItemDTO[],
+ rows: SampleRow[],
+): AnalysisDatum {
+ const base = ANALYSIS_DATA[view];
+ const pattern = VIEW_STAGE_PATTERNS[view];
+ const keys = VIEW_TIMELINE_KEYS[view];
+ const uniqueItems = dedupeLiveItems(items);
+ const domainRows = rows.filter((row) => pattern.test(row.stage));
+ const relevantMetrics = uniqueItems.flatMap((item) =>
+ item.timelineMetrics
+ .filter((metric) => keys.includes(metric.key))
+ .map((metric) => ({ item, metric })),
+ );
+ const actualMetrics = relevantMetrics.filter(
+ ({ metric }) =>
+ metric.actualDays !== null && Number.isFinite(metric.actualDays),
+ );
+ const assessableMetrics = actualMetrics.filter(
+ ({ metric }) =>
+ metric.targetDays !== null && Number.isFinite(metric.targetDays),
+ );
+ const riskKeys = new Set(
+ assessableMetrics
+ .filter(
+ ({ metric }) =>
+ (metric.actualDays ?? 0) >
+ (metric.targetDays ?? Number.POSITIVE_INFINITY),
+ )
+ .map(({ item }) => getItemKey(item)),
+ );
+ const statuses = Array.from(
+ domainRows.reduce((groups, row) => {
+ const matchingStages = row.stage
+ .split(" / ")
+ .filter((stage) => pattern.test(stage));
+ const label = matchingStages.join(" / ") || row.stage;
+ groups.set(label, (groups.get(label) ?? 0) + 1);
+ return groups;
+ }, new Map()),
+ )
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 4)
+ .map(([label, count], index) => ({
+ label,
+ count,
+ color: ["#b34e46", "#d49344", "#477a8c", "#3d9279"][index],
+ }));
+ const blockers = Array.from(
+ assessableMetrics
+ .filter(
+ ({ metric }) => (metric.actualDays ?? 0) > (metric.targetDays ?? 0),
+ )
+ .reduce((groups, { item, metric }) => {
+ const delay = (metric.actualDays ?? 0) - (metric.targetDays ?? 0);
+ const current = groups.get(metric.label) ?? {
+ count: 0,
+ maxDelay: 0,
+ taskKeys: new Set(),
+ };
+ current.count += 1;
+ current.maxDelay = Math.max(current.maxDelay, delay);
+ current.taskKeys.add(getItemKey(item));
+ groups.set(metric.label, current);
+ return groups;
+ }, new Map }>()),
+ )
+ .sort((a, b) => b[1].count - a[1].count)
+ .slice(0, 3)
+ .map(([label, value]) => ({
+ label: `${label}超时`,
+ count: value.taskKeys.size,
+ meta: `最长超 ${value.maxDelay.toFixed(1)} 天`,
+ taskKeys: Array.from(value.taskKeys),
+ }));
+ return {
+ ...base,
+ active: domainRows.length,
+ avgDays: average(actualMetrics.map(({ metric }) => metric.actualDays ?? 0)),
+ targetDays: average(
+ relevantMetrics
+ .map(({ metric }) => metric.targetDays)
+ .filter(
+ (value): value is number => value !== null && Number.isFinite(value),
+ ),
+ ),
+ onTime: assessableMetrics.length
+ ? Math.round(
+ (assessableMetrics.filter(
+ ({ metric }) =>
+ (metric.actualDays ?? 0) <= (metric.targetDays ?? 0),
+ ).length /
+ assessableMetrics.length) *
+ 100,
+ )
+ : 0,
+ risk: riskKeys.size,
+ riskTaskKeys: Array.from(riskKeys),
+ statuses:
+ statuses.length > 0
+ ? statuses
+ : [{ label: "当前无在途任务", count: 0, color: "#9aa5a8" }],
+ blockers:
+ blockers.length > 0
+ ? blockers
+ : [{ label: "当前无超时节点", count: 0, meta: "按阶段目标时效计算" }],
+ };
+}
+
+function formatDateInput(date: Date) {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+}
+
+function getPeriodRange(period: Period, anchorDate: string) {
+ const anchor = new Date(`${anchorDate}T00:00:00`);
+ const start = new Date(anchor);
+ const end = new Date(anchor);
+ if (period === "week") {
+ const mondayOffset = (anchor.getDay() + 6) % 7;
+ start.setDate(anchor.getDate() - mondayOffset);
+ end.setDate(start.getDate() + 6);
+ } else if (period === "month") {
+ start.setDate(1);
+ end.setMonth(anchor.getMonth() + 1, 0);
+ } else {
+ start.setMonth(0, 1);
+ end.setMonth(11, 31);
+ }
+ return { start, end };
+}
+
+function MetricCard({
+ label,
+ value,
+ suffix,
+ meta,
+ trend,
+ icon,
+ tone,
+ onClick,
+}: {
+ label: string;
+ value: string | number;
+ suffix?: string;
+ meta: string;
+ trend: string;
+ icon: React.ReactNode;
+ tone: "teal" | "amber" | "green" | "blue";
+ onClick?: () => void;
+}) {
+ const toneClass = {
+ teal: "text-[#245f5a] bg-[#edf7f5]",
+ amber: "text-[#a75b29] bg-[#fff5e8]",
+ green: "text-[#28705a] bg-[#edf8f1]",
+ blue: "text-[#3e6881] bg-[#edf4f8]",
+ }[tone];
+ const content = (
+ <>
+
+ {label}
+
+ {icon}
+
+
+
+
+
+ {value}
+
+ {suffix ? (
+
+ {suffix}
+
+ ) : null}
+
{meta}
+
+
+ {trend}
+
+
+ >
+ );
+ const className = `group flex min-h-[132px] flex-col justify-between rounded-lg border border-black/[0.08] bg-white p-4 text-left ${onClick ? "transition hover:-translate-y-0.5 hover:shadow-[0_10px_26px_rgba(30,40,46,0.08)]" : ""}`;
+ return onClick ? (
+
+ ) : (
+ {content}
+ );
+}
+
+function EfficiencyCard({
+ label,
+ value,
+ suffix,
+ benchmark,
+ note,
+ tone,
+ onClick,
+}: {
+ label: string;
+ value: string | number;
+ suffix: string;
+ benchmark: string;
+ note: string;
+ tone: "neutral" | "good" | "warning" | "danger";
+ onClick?: () => void;
+}) {
+ const toneClass = {
+ neutral: "border-l-[#477a8c] text-[#334044]",
+ good: "border-l-[#3d9279] text-[#28705a]",
+ warning: "border-l-[#d49344] text-[#a75b29]",
+ danger: "border-l-[#b34e46] text-[#b34e46]",
+ }[tone];
+
+ const content = (
+ <>
+
+ {label}
+ {onClick ? (
+
+ ) : null}
+
+
+
+
+ {value}
+
+
+ {suffix}
+
+
{benchmark}
+
+
+ {note}
+
+
+ >
+ );
+ const className = `group flex min-h-[104px] flex-col justify-between rounded-lg border border-l-[3px] border-black/[0.08] bg-white px-4 py-3 text-left ${onClick ? "transition hover:bg-[#fbfcfc] hover:shadow-[0_8px_20px_rgba(30,40,46,0.06)]" : ""} ${toneClass}`;
+ return onClick ? (
+
+ ) : (
+ {content}
+ );
+}
+
+function SectionHeader({
+ eyebrow,
+ title,
+ detail,
+ icon,
+}: {
+ eyebrow?: string;
+ title: string;
+ detail?: React.ReactNode;
+ icon?: React.ReactNode;
+}) {
+ return (
+
+
+
+ {icon}
+
{title}
+
+ {eyebrow ? (
+
{eyebrow}
+ ) : null}
+
+ {detail ? (
+
{detail}
+ ) : null}
+
+ );
+}
+
+function DetailDrawer({
+ row,
+ onClose,
+}: {
+ row: SampleRow;
+ onClose: () => void;
+}) {
+ return (
+
+
+
+ );
+}
+
+function UnifiedWideTable({
+ contextLabel,
+ rows,
+ onClose,
+ onSelectRow,
+}: {
+ contextLabel: string;
+ rows: SampleRow[];
+ onClose: () => void;
+ onSelectRow: (row: SampleRow) => void;
+}) {
+ return (
+
+ }
+ detail={
+
+ }
+ />
+
+
+
+
+ {[
+ "开款编号 / 新品",
+ "当前阶段",
+ "阶段状态",
+ "类目",
+ "合作供应商(待接入)",
+ "采购负责人",
+ "立项日期",
+ "当前节点开始",
+ "流程完成日期",
+ "总耗时",
+ "风险",
+ ].map((header) => (
+ |
+ {header}
+ |
+ ))}
+
+
+
+ {rows.length === 0 ? (
+
+ |
+ 当前条件下暂无新品明细
+ |
+
+ ) : null}
+ {rows.map((row) => (
+ onSelectRow(row)}
+ className="cursor-pointer border-t border-black/[0.05] hover:bg-[#f5faf9]"
+ >
+ |
+
+ {row.openingCode || getRowTaskKey(row)}
+
+
+ {row.name} · {row.spu}
+
+ |
+
+ {row.stage}
+ |
+
+ {row.stageStatus}
+ |
+ {row.structure} |
+ {row.supplier} |
+ {row.buyer} |
+
+ {row.scheduleDate}
+ |
+ {row.startDate} |
+ {row.finishDate} |
+
+ {row.days} 天
+ |
+
+ {row.risk === "high"
+ ? "高风险"
+ : row.risk === "medium"
+ ? "关注"
+ : "正常"}
+ |
+
+ ))}
+
+
+
+
+ );
+}
+
+function AnalysisPage({
+ view,
+ domain,
+ rows: sourceRows,
+ isFormal,
+ onBack,
+ onSelectRow,
+}: {
+ view: Exclude;
+ domain: AnalysisDatum;
+ rows: SampleRow[];
+ isFormal: boolean;
+ onBack: () => void;
+ onSelectRow: (row: SampleRow) => void;
+}) {
+ const [dimension, setDimension] = useState(0);
+ const [drilldown, setDrilldown] = useState(null);
+ const domainRows = useMemo(
+ () => sourceRows.filter((row) => VIEW_STAGE_PATTERNS[view].test(row.stage)),
+ [sourceRows, view],
+ );
+ const dimensionItems = useMemo(() => {
+ const key = ANALYSIS_DIMENSION_KEYS[dimension];
+ const groups = new Map();
+ domainRows.forEach((row) => {
+ const label = row[key] || "字段待补充";
+ const group = groups.get(label) ?? { count: 0, risk: 0 };
+ group.count += 1;
+ if (row.risk === "high") group.risk += 1;
+ groups.set(label, group);
+ });
+ return Array.from(groups.entries())
+ .map(([label, group]) => ({
+ label,
+ count: group.count,
+ note: group.risk ? `${group.risk} 个高风险` : "暂无高风险",
+ }))
+ .sort((a, b) => b.count - a.count)
+ .slice(0, 6);
+ }, [dimension, domainRows]);
+ const maxStatus = Math.max(
+ ...domain.statuses.map((status) => status.count),
+ 1,
+ );
+ const maxDimension = Math.max(...dimensionItems.map((item) => item.count), 1);
+ const rowsForTaskKeys = (taskKeys?: string[]) =>
+ taskKeys
+ ? sourceRows.filter((row) => taskKeys.includes(getRowTaskKey(row)))
+ : isFormal
+ ? []
+ : domainRows.filter((row) => row.risk === "high");
+
+ return (
+
+
+
+
+ setDrilldown({ label: "当前阶段任务", rows: domainRows })
+ }
+ />
+ domain.targetDays
+ ? `超 ${(domain.avgDays - domain.targetDays).toFixed(1)} 天`
+ : "在目标内"
+ }
+ tone={domain.avgDays > domain.targetDays ? "warning" : "good"}
+ onClick={() =>
+ setDrilldown({ label: "阶段时效任务", rows: domainRows })
+ }
+ />
+
+ setDrilldown({
+ label: "未达目标时效",
+ rows: rowsForTaskKeys(domain.riskTaskKeys),
+ })
+ }
+ />
+
+ setDrilldown({
+ label: "阶段超期任务",
+ rows: rowsForTaskKeys(domain.riskTaskKeys),
+ })
+ }
+ />
+
+
+
+ }
+ />
+
+ {domain.statuses.map((status) => (
+
+ ))}
+
+
+
+ }
+ />
+
+ {domain.blockers.map((blocker) => (
+
+ ))}
+
+
+
+
+ }
+ />
+
+
+ {["类目", "合作供应商", "采购负责人"].map((label, index) => (
+
+ ))}
+
+
+ {dimensionItems.map((item) => (
+
+ ))}
+
+
+
+ {drilldown ? (
+
setDrilldown(null)}
+ onSelectRow={onSelectRow}
+ />
+ ) : null}
+
+ );
+}
+
+export default function NewProductManagementDashboard({
+ dashboard,
+ loadErrorMessage,
+}: {
+ dashboard?: OaBoardDashboardDTO | null;
+ loadErrorMessage?: string | null;
+}) {
+ const [period, setPeriod] = useState("month");
+ const [activeView, setActiveView] = useState("overview");
+ const [anchorDate, setAnchorDate] = useState(() => {
+ const latestDate = groupItemsByTask(dashboard?.items ?? [])
+ .map(getTaskCreatedDate)
+ .filter(Boolean)
+ .sort()
+ .at(-1);
+ return latestDate || "2026-08-16";
+ });
+ const [selectedRow, setSelectedRow] = useState(null);
+ const periodRange = useMemo(
+ () => getPeriodRange(period, anchorDate),
+ [anchorDate, period],
+ );
+ const liveItems = useMemo(() => {
+ if (!dashboard) return null;
+ const start = periodRange.start.getTime();
+ const end = periodRange.end.getTime();
+ return groupItemsByTask(dashboard.items)
+ .filter((items) => {
+ const createdDate = getTaskCreatedDate(items);
+ const timestamp = createdDate
+ ? new Date(`${createdDate}T00:00:00`).getTime()
+ : Number.NaN;
+ return (
+ Number.isFinite(timestamp) && timestamp >= start && timestamp <= end
+ );
+ })
+ .flat();
+ }, [dashboard, periodRange]);
+ const data =
+ dashboard && liveItems
+ ? buildLiveSummary({ ...dashboard, items: liveItems })
+ : PERIOD_DATA[period];
+ const timingComplianceRate = data.assessed
+ ? Math.round(((data.assessed - data.timeout) / data.assessed) * 100)
+ : 0;
+ const liveTaskItems = useMemo(
+ () => (liveItems ? dedupeLiveItems(liveItems) : null),
+ [liveItems],
+ );
+ const rows = useMemo(
+ () => (dashboard && liveItems ? buildLiveRows(liveItems) : SAMPLE_ROWS),
+ [dashboard, liveItems],
+ );
+ const stageData: StageDatum[] = useMemo(
+ () => (dashboard && liveItems ? buildLiveStageData(liveItems) : STAGE_DATA),
+ [dashboard, liveItems],
+ );
+ const currentBottlenecks = useMemo(
+ () =>
+ dashboard && liveItems
+ ? buildLiveCurrentBottlenecks(liveItems)
+ : CURRENT_BOTTLENECK_DATA,
+ [dashboard, liveItems],
+ );
+ const currentNodeCoverage = useMemo(
+ () =>
+ dashboard && liveItems
+ ? buildLiveCurrentNodeCoverage(liveItems)
+ : { total: 12, assessed: 12 },
+ [dashboard, liveItems],
+ );
+ const currentNodeGaps = useMemo(
+ () => (dashboard && liveItems ? buildLiveCurrentNodeGaps(liveItems) : []),
+ [dashboard, liveItems],
+ );
+ const planData = useMemo(
+ () => (dashboard && liveItems ? buildLivePlanData(liveItems) : PLAN_DATA),
+ [dashboard, liveItems],
+ );
+ const ownerLoad = useMemo(() => {
+ if (!dashboard || !liveTaskItems)
+ return [
+ ["陈思远", 12],
+ ["周琳", 9],
+ ["李浩", 7],
+ ["王璐", 5],
+ ] as Array<[string, number]>;
+ const groups = new Map();
+ liveTaskItems
+ .filter((item) => item.overallStatus !== "COMPLETED")
+ .forEach((item) => {
+ const owner = item.formSummary.currentOwnerDisplay || "待分配";
+ groups.set(owner, (groups.get(owner) ?? 0) + 1);
+ });
+ return Array.from(groups.entries())
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 4);
+ }, [dashboard, liveTaskItems]);
+ const sampleRows = rows.filter((row) =>
+ VIEW_STAGE_PATTERNS.sample.test(row.stage),
+ );
+ const sampleProgress = {
+ pending: sampleRows.filter((row) => row.startDate === "-").length,
+ running: sampleRows.filter(
+ (row) => row.startDate !== "-" && row.finishDate === "-",
+ ).length,
+ completed: sampleRows.filter((row) => row.finishDate !== "-").length,
+ timeout: sampleRows.filter((row) => row.risk === "high").length,
+ };
+ const activeAnalysis =
+ activeView === "overview"
+ ? null
+ : dashboard && liveItems
+ ? buildLiveAnalysisData(activeView, liveItems, rows)
+ : ANALYSIS_DATA[activeView];
+ const periodLabel =
+ period === "week"
+ ? `${formatDateInput(periodRange.start)} - ${formatDateInput(periodRange.end)}`
+ : period === "month"
+ ? `${periodRange.start.getFullYear()} 年 ${periodRange.start.getMonth() + 1} 月`
+ : `${periodRange.start.getFullYear()} 年度`;
+
+ return (
+
+
+
+ {loadErrorMessage ? (
+
+ {loadErrorMessage}
+
+ ) : null}
+
+
+ 统计区间:
+
+ {periodLabel}
+ {" "}
+ · 按立项日期筛选 · 当前状态 · 开款编号口径
+
+
+
+ {dashboard
+ ? `正式系统数据 · ${data.total} 个开款任务 / ${liveItems?.length ?? 0} 条流程`
+ : "本地演示数据"}
+
+
+
+ {activeView === "overview" ? (
+ <>
+
+ }
+ tone="teal"
+ />
+ }
+ tone="amber"
+ />
+ }
+ tone="green"
+ />
+ }
+ tone="blue"
+ />
+
+
+
+
+
+
+ 总体时效
+
+
+ 先看整款总周期,再通过下方定位阶段节点
+
+
+
+
按整款总目标统计
+
+ 可评估 {data.assessed} / {data.total} 个任务
+
+
+
+
+ data.targetDays
+ ? `超 ${(data.totalDays - data.targetDays).toFixed(1)} 天`
+ : "目标内"
+ }
+ tone={data.totalDays > data.targetDays ? "warning" : "good"}
+ />
+ = 85
+ ? "达到 ≥85% 目标"
+ : "未达 ≥85% 目标"
+ }
+ tone={
+ data.assessed > 0 && timingComplianceRate >= 85
+ ? "good"
+ : "warning"
+ }
+ />
+
+
+
+
+
+
+
+
+
+ 阶段时效
+
+
+ 按节点目标识别超期环节,再进入专项分析
+
+
+
+ 按开款编号去重
+
+
+
+
+ }
+ detail="点击进入专项分析"
+ />
+
+ {stageData.map((stage) => (
+
+ ))}
+
+
+
+ }
+ detail={`${currentBottlenecks.length} 类超期 · ${currentNodeGaps.length} 类待配置`}
+ />
+
+ {currentBottlenecks.map((bottleneck) => (
+
+ ))}
+ {currentNodeGaps
+ .slice(0, Math.max(0, 6 - currentBottlenecks.length))
+ .map((gap) => (
+
+ ))}
+ {currentBottlenecks.length === 0 &&
+ currentNodeGaps.length === 0 ? (
+
+ 当前没有节点预警
+
+ ) : null}
+
+
+
+
+
+
+
+ }
+ />
+
+ {planData.map((plan) => (
+
+
+
+ {plan.label}
+
+
+ {plan.count} 个 · {plan.rate}%
+
+
+
+
+ ))}
+
+
+
+ }
+ detail={
+
+ }
+ />
+
+
+
待处理
+
+ {sampleProgress.pending}
+
+
+
+
超时
+
+ {sampleProgress.timeout}
+
+
+
+
进行中
+
+ {sampleProgress.running}
+
+
+
+
已完成
+
+ {sampleProgress.completed}
+
+
+
+
+
+ }
+ />
+
+ {ownerLoad.map(([name, count]) => (
+
+
+
+ {name}
+
+ {count} 个
+
+
+
entry[1]), 1)) * 100}%`,
+ }}
+ />
+
+
+ ))}
+
+
+
+ >
+ ) : (
+ setActiveView("overview")}
+ onSelectRow={setSelectedRow}
+ />
+ )}
+
+ {selectedRow ? (
+
setSelectedRow(null)} />
+ ) : null}
+
+ );
+}
diff --git a/furniture-3d-system/src/app/intake/new-product-management/page.tsx b/furniture-3d-system/src/app/intake/new-product-management/page.tsx
new file mode 100644
index 00000000..e431bb65
--- /dev/null
+++ b/furniture-3d-system/src/app/intake/new-product-management/page.tsx
@@ -0,0 +1,39 @@
+import { requireSession } from "@/lib/server-session";
+import { OA_BOARD_DEFAULT_TRACKED_STATUSES } from "@/server/services/oa-board.constants";
+import {
+ createEmptyOaBoardDashboard,
+ oaBoardService,
+} from "@/server/services/oa-board.service";
+import NewProductManagementDashboard from "./NewProductManagementDashboard";
+
+export const dynamic = "force-dynamic";
+
+export default async function NewProductManagementPage() {
+ await requireSession("/login");
+
+ const result = await oaBoardService
+ .getDashboard({
+ overallStatus: [...OA_BOARD_DEFAULT_TRACKED_STATUSES],
+ })
+ .then((dashboard) => ({
+ dashboard,
+ loadErrorMessage: null as string | null,
+ }))
+ .catch((error) => {
+ console.error("Failed to load new product management dashboard", error);
+ return {
+ dashboard: createEmptyOaBoardDashboard(),
+ loadErrorMessage:
+ error instanceof Error
+ ? `新品管理数据读取失败:${error.message}`
+ : "新品管理数据读取失败,请稍后重试。",
+ };
+ });
+
+ return (
+
+ );
+}
From 330cf30ac999e29623135e59a5ddf41489a7e195 Mon Sep 17 00:00:00 2001
From: jingchuan
Date: Mon, 17 Aug 2026 13:44:30 +0800
Subject: [PATCH 4/7] feat: expose new product management dashboard
---
furniture-3d-system/src/components/layout/AppShell.tsx | 6 ++++--
furniture-3d-system/src/components/layout/AppTopNav.tsx | 8 ++++----
2 files changed, 8 insertions(+), 6 deletions(-)
diff --git a/furniture-3d-system/src/components/layout/AppShell.tsx b/furniture-3d-system/src/components/layout/AppShell.tsx
index fabfee6d..78b03022 100644
--- a/furniture-3d-system/src/components/layout/AppShell.tsx
+++ b/furniture-3d-system/src/components/layout/AppShell.tsx
@@ -96,6 +96,7 @@ function AppShellContent({ children, user, localOaBoardPreview = false }: AppShe
const isBomCostingRoute =
pathname === '/bom-costing' || pathname.startsWith('/bom-costing/')
const isOaBoardRoute = pathname === '/intake/oa-board' || pathname.startsWith('/intake/oa-board/')
+ const isNewProductManagementRoute = pathname === '/intake/new-product-management' || pathname.startsWith('/intake/new-product-management/')
const isSampleProgressRoute = pathname === '/intake/sample-progress' || pathname.startsWith('/intake/sample-progress/')
const isLocalOaBoardPreview = localOaBoardPreview && isOaBoardRoute
const isWarehouseRoute = pathname === '/warehouse' || pathname.startsWith('/warehouse/')
@@ -132,6 +133,7 @@ function AppShellContent({ children, user, localOaBoardPreview = false }: AppShe
const shouldLockMainViewport =
isWorkbenchShellRoute &&
!isOaBoardRoute &&
+ !isNewProductManagementRoute &&
!isSampleProgressRoute &&
!isMobileProductDetailLinkView
@@ -290,10 +292,10 @@ function AppShellContent({ children, user, localOaBoardPreview = false }: AppShe
Date: Mon, 17 Aug 2026 14:55:22 +0800
Subject: [PATCH 5/7] feat(installation-demo): refine printable drawing
workflow
---
.../drawing-text-annotations/route.ts | 50 +++
.../InstallationDemoShareDrawingPage.tsx | 3 +
...stallationDemoShareDrawingDocument.test.ts | 1 +
.../InstallationDemoWorkbenchClient.tsx | 15 +
.../InstallationDrawingActionsPage.tsx | 43 ++-
...allationDrawingAutoCaptureControl.test.tsx | 56 ++++
.../InstallationDrawingAutoCaptureControl.tsx | 180 +++++++----
.../components/InstallationDrawingSheet.tsx | 67 ++++
.../InstallationDrawingSheetScroll.test.tsx | 2 +
...nstallationDrawingTextAnnotationDialog.tsx | 199 ++++++++++++
...nstallationDrawingTextAnnotations.test.tsx | 76 +++++
.../InstallationDrawingTextAnnotations.tsx | 133 ++++++++
.../useInstallationDrawingAutoCapture.ts | 23 +-
.../hooks/useInstallationDrawingCapture.ts | 19 +-
.../useInstallationDrawingTextAnnotations.ts | 117 +++++++
...tallationDrawingActionCaptureFrame.test.ts | 82 +++++
.../installationDrawingActionCaptureFrame.ts | 42 ++-
...installationDrawingAutoCapturePlan.test.ts | 35 ++-
.../lib/installationDrawingAutoCapturePlan.ts | 17 +-
.../lib/installationDrawingTextAnnotations.ts | 74 +++++
furniture-3d-system/src/lib/3d/engine.ts | 35 +++
.../lib/3d/technical-line-annotations.test.ts | 293 +++++++++++++++++
.../src/lib/3d/technical-line-annotations.ts | 297 ++++++++++++++++++
.../src/lib/3d/technical-line-capture.test.ts | 58 ++++
.../src/lib/3d/technical-line-capture.ts | 88 ++++++
.../lib/3d/technical-line-feature-loops.ts | 102 ++++++
.../src/lib/installation-demo-share-types.ts | 2 +
...mo-drawing-text-annotation.service.test.ts | 87 +++++
...on-demo-drawing-text-annotation.service.ts | 143 +++++++++
.../installation-demo-share.service.test.ts | 16 +
.../installation-demo-share.service.ts | 5 +
31 files changed, 2289 insertions(+), 71 deletions(-)
create mode 100644 furniture-3d-system/src/app/api/installation-demo-workbench/drawing-text-annotations/route.ts
create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingAutoCaptureControl.test.tsx
create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingTextAnnotationDialog.tsx
create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingTextAnnotations.test.tsx
create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingTextAnnotations.tsx
create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingTextAnnotations.ts
create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingTextAnnotations.ts
create mode 100644 furniture-3d-system/src/lib/3d/technical-line-annotations.test.ts
create mode 100644 furniture-3d-system/src/lib/3d/technical-line-annotations.ts
create mode 100644 furniture-3d-system/src/lib/3d/technical-line-feature-loops.ts
create mode 100644 furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-text-annotation.service.test.ts
create mode 100644 furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-text-annotation.service.ts
diff --git a/furniture-3d-system/src/app/api/installation-demo-workbench/drawing-text-annotations/route.ts b/furniture-3d-system/src/app/api/installation-demo-workbench/drawing-text-annotations/route.ts
new file mode 100644
index 00000000..dad767f4
--- /dev/null
+++ b/furniture-3d-system/src/app/api/installation-demo-workbench/drawing-text-annotations/route.ts
@@ -0,0 +1,50 @@
+import { NextRequest } from "next/server";
+import { AppError } from "@/server/core/errors";
+import { handleRequest, jsonError, jsonSuccess } from "@/server/core/response";
+import { installationDemoDrawingTextAnnotationService } from "@/server/services/installation-demo/installation-demo-drawing-text-annotation.service";
+
+function respondWithAppError(error: unknown, requestId: string) {
+ if (error instanceof AppError) {
+ return jsonError(error.code, error.message, error.statusCode, requestId);
+ }
+ throw error;
+}
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+ return handleRequest(
+ request,
+ async (ctx) => {
+ try {
+ const snapshot = await installationDemoDrawingTextAnnotationService.get(
+ ctx.user,
+ request.nextUrl.searchParams.get("drawingId"),
+ );
+ return jsonSuccess({ snapshot }, 200, ctx.requestId);
+ } catch (error) {
+ return respondWithAppError(error, ctx.requestId);
+ }
+ },
+ { requireAuth: true },
+ );
+}
+
+export async function PATCH(request: NextRequest) {
+ return handleRequest(
+ request,
+ async (ctx) => {
+ try {
+ const snapshot =
+ await installationDemoDrawingTextAnnotationService.updateTarget(
+ ctx.user,
+ await request.json(),
+ );
+ return jsonSuccess({ snapshot }, 200, ctx.requestId);
+ } catch (error) {
+ return respondWithAppError(error, ctx.requestId);
+ }
+ },
+ { requireAuth: true },
+ );
+}
diff --git a/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingPage.tsx b/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingPage.tsx
index efab831c..7366f261 100644
--- a/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingPage.tsx
+++ b/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingPage.tsx
@@ -65,6 +65,9 @@ function InstallationDemoShareDrawingPanel({
imageMode={imageMode}
snapshotUrls={snapshot.numberingSnapshotUrls}
snapshotLineArtUrls={snapshotLineArtUrls}
+ textAnnotationsByTargetKey={
+ snapshot.drawingDocument?.textAnnotationsByTargetKey ?? {}
+ }
allowManualCapture={false}
readOnly
/>
diff --git a/furniture-3d-system/src/app/installation-demo-share/[token]/installationDemoShareDrawingDocument.test.ts b/furniture-3d-system/src/app/installation-demo-share/[token]/installationDemoShareDrawingDocument.test.ts
index b6c7fda1..779e36ef 100644
--- a/furniture-3d-system/src/app/installation-demo-share/[token]/installationDemoShareDrawingDocument.test.ts
+++ b/furniture-3d-system/src/app/installation-demo-share/[token]/installationDemoShareDrawingDocument.test.ts
@@ -41,6 +41,7 @@ function createSnapshot(): InstallationDemoShareSnapshot {
actions: [],
inventoryTransforms: {},
},
+ textAnnotationsByTargetKey: {},
snapshotLineArtUrls: {},
toolSnapshotLineArtUrls: {},
},
diff --git a/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx b/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx
index f188ff3b..6e37fa52 100644
--- a/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx
+++ b/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx
@@ -107,6 +107,7 @@ import { InstallationDrawingToolLineArtGenerator } from "./components/Installati
import { useInstallationDrawingCapture } from "./hooks/useInstallationDrawingCapture";
import { useInstallationDrawingAutoCapture } from "./hooks/useInstallationDrawingAutoCapture";
import { useInstallationDrawingConfirmation } from "./hooks/useInstallationDrawingConfirmation";
+import { useInstallationDrawingTextAnnotations } from "./hooks/useInstallationDrawingTextAnnotations";
import { buildInstallationDrawingCaptureFingerprint } from "./lib/installationDrawingCaptureFingerprint";
import {
isInstallationDrawingActionCaptureTarget,
@@ -401,6 +402,10 @@ export default function InstallationDemoWorkbenchClient({
setMode,
setActiveStepId: stepEditor.setActiveStepId,
});
+ const drawingTextAnnotations = useInstallationDrawingTextAnnotations(
+ selectedDrawingId,
+ catalog.access.canEdit,
+ );
const drawingOverviewCaptureActive =
isInstallationDrawingOverviewCaptureTarget(drawingCapture.captureTarget);
const drawingActionCaptureTarget = isInstallationDrawingActionCaptureTarget(
@@ -1732,6 +1737,8 @@ export default function InstallationDemoWorkbenchClient({
templateId: "set-1",
imageMode: drawingImageMode ?? "line-art",
captures: drawingCapture.captures,
+ textAnnotationsByTargetKey:
+ drawingTextAnnotations.annotationsByTargetKey,
snapshotLineArtUrls:
shareNumberingSnapshotBatch.snapshotLineArtUrls ?? {},
toolSnapshotLineArtUrls:
@@ -1763,6 +1770,7 @@ export default function InstallationDemoWorkbenchClient({
numberingModelParts.length,
numberingPlaying,
drawingCapture.captures,
+ drawingTextAnnotations.annotationsByTargetKey,
drawingTools.selectedTools,
numberingVisibleModelParts,
playing,
@@ -2614,6 +2622,10 @@ export default function InstallationDemoWorkbenchClient({
numberingLineArtGenerating || Boolean(missingDrawingToolLineArt)
}
captures={drawingCapture.captures}
+ textAnnotationsByTargetKey={
+ drawingTextAnnotations.annotationsByTargetKey
+ }
+ textAnnotationMessage={drawingTextAnnotations.message}
initialScrollTop={
drawingSheetScrollPosition.drawingId === selectedDrawingId
? drawingSheetScrollPosition.scrollTop
@@ -2638,6 +2650,9 @@ export default function InstallationDemoWorkbenchClient({
numberingMarkerCountOverrides,
);
}}
+ onUpdateTextAnnotations={
+ drawingTextAnnotations.saveTargetAnnotations
+ }
autoCapture={{
phase: drawingAutoCapture.phase,
error: drawingAutoCapture.error,
diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingActionsPage.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingActionsPage.tsx
index 9db6a7cb..36b032b8 100644
--- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingActionsPage.tsx
+++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingActionsPage.tsx
@@ -1,14 +1,22 @@
-import { ListChecks, Maximize2 } from 'lucide-react'
+import { ListChecks, Maximize2, Type } from 'lucide-react'
import type {
InstallationDrawingActionCapture,
InstallationDrawingCaptureTarget,
InstallationDrawingImageMode,
InstallationDrawingNumberMarker,
} from '../lib/installationDrawingCaptureTypes'
-import { isInstallationDrawingImageCaptured } from '../lib/installationDrawingCaptureTypes'
+import {
+ getInstallationDrawingCaptureTargetKey,
+ isInstallationDrawingImageCaptured,
+} from '../lib/installationDrawingCaptureTypes'
import type { InstallationDrawingActionGroup } from '../lib/installationDrawingLayout'
+import type {
+ InstallationDrawingTextAnnotation,
+ InstallationDrawingTextAnnotationsByTarget,
+} from '../lib/installationDrawingTextAnnotations'
import { InstallationDrawingImageFrame } from './InstallationDrawingImageFrame'
import { InstallationDrawingNumberMarkers } from './InstallationDrawingNumberMarkers'
+import { InstallationDrawingTextAnnotations } from './InstallationDrawingTextAnnotations'
interface Props {
groups: InstallationDrawingActionGroup[]
@@ -23,6 +31,12 @@ interface Props {
target: InstallationDrawingCaptureTarget,
markers: InstallationDrawingNumberMarker[],
) => void
+ textAnnotationsByTargetKey?: InstallationDrawingTextAnnotationsByTarget
+ onManageTextAnnotations?: (target: InstallationDrawingCaptureTarget) => void
+ onCommitTextAnnotations?: (
+ target: InstallationDrawingCaptureTarget,
+ annotations: InstallationDrawingTextAnnotation[],
+ ) => void
allowManualCapture: boolean
readOnly?: boolean
}
@@ -77,6 +91,9 @@ export function InstallationDrawingActionsPage({
onAdjustImage,
onManageNumberingMarkers,
onCommitNumberingMarkers,
+ textAnnotationsByTargetKey = {},
+ onManageTextAnnotations,
+ onCommitTextAnnotations,
allowManualCapture,
readOnly = false,
}: Props) {
@@ -100,6 +117,8 @@ export function InstallationDrawingActionsPage({
actionId: action.actionId,
sequence: action.sequence,
}
+ const targetKey = getInstallationDrawingCaptureTargetKey(target)
+ const textAnnotations = textAnnotationsByTargetKey[targetKey] ?? []
const captured = capture ? isInstallationDrawingImageCaptured(capture.image) : false
return (
) : null}
+ {captured && textAnnotations.length > 0 ? (
+ onCommitTextAnnotations(target, annotations)
+ : undefined}
+ />
+ ) : null}
{capture && capture.tools.length > 0 ? (
@@ -155,6 +183,17 @@ export function InstallationDrawingActionsPage({
) : null}
+ {capture && captured ? (
+
+ ) : null}
{capture && captured ? (
,
+ );
+ return onStart;
+}
+
+describe("InstallationDrawingAutoCaptureControl", () => {
+ it("asks for confirmation before replacing a completed batch", () => {
+ const onStart = renderControl();
+
+ fireEvent.click(
+ screen.getByRole("button", { name: "重新批量带入 75/75" }),
+ );
+
+ expect(screen.getByRole("dialog", { name: "重新批量带入" })).toBeInTheDocument();
+ expect(onStart).not.toHaveBeenCalled();
+ });
+
+ it("starts replacement only after confirmation", () => {
+ const onStart = renderControl();
+ fireEvent.click(
+ screen.getByRole("button", { name: "重新批量带入 75/75" }),
+ );
+ fireEvent.click(screen.getByRole("button", { name: "开始重新带入" }));
+
+ expect(onStart).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole("dialog", { name: "重新批量带入" })).not.toBeInTheDocument();
+ });
+
+ it("keeps the first automatic capture as a direct action", () => {
+ const onStart = renderControl({ hasActiveBatch: false });
+
+ fireEvent.click(screen.getByRole("button", { name: "自动抓取" }));
+
+ expect(onStart).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+});
diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingAutoCaptureControl.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingAutoCaptureControl.tsx
index 68920504..a2a1ee5c 100644
--- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingAutoCaptureControl.tsx
+++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingAutoCaptureControl.tsx
@@ -1,6 +1,14 @@
"use client";
-import { Camera, CheckCircle2, LoaderCircle, Square } from "lucide-react";
+import { useState } from "react";
+import {
+ Camera,
+ LoaderCircle,
+ RefreshCw,
+ Square,
+ TriangleAlert,
+ X,
+} from "lucide-react";
interface InstallationDrawingAutoCaptureControlProps {
phase:
@@ -32,6 +40,7 @@ export function InstallationDrawingAutoCaptureControl({
onStart,
onPause,
}: InstallationDrawingAutoCaptureControlProps) {
+ const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const running =
phase === "seeding" || phase === "running" || phase === "finalizing";
const loading = phase === "loading";
@@ -39,60 +48,121 @@ export function InstallationDrawingAutoCaptureControl({
const startLabel = hasDraftBatch ? "继续抓取" : "自动抓取";
return (
-
- {settled ? (
-
-
- 已带入 {completedCount}/{totalCount}
-
- ) : running ? (
-
- ) : (
-
- )}
- {hasDraftBatch && !running ? (
-
- 已完成 {completedCount}/{totalCount}
-
+ <>
+
+ {settled ? (
+
+ ) : running ? (
+
+ ) : (
+
+ )}
+ {hasDraftBatch && !running ? (
+
+ 已完成 {completedCount}/{totalCount}
+
+ ) : null}
+ {configurationChanged ? (
+
+ 配置已变更
+
+ ) : null}
+ {error && !running ? (
+
+ {error}
+
+ ) : null}
+
+
+ {restartDialogOpen ? (
+
+
+
+
+
+
+
+
+ 将重新抓取 {totalCount} 张原图和打印线稿。当前版本会保留到新批次全部完成,已有画面位置和编号设置继续沿用。
+
+
+
+
+
+
+
+
) : null}
- {configurationChanged ? (
-
- 配置已变更
-
- ) : null}
- {error && !running ? (
-
- {error}
-
- ) : null}
-
+ >
);
}
diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheet.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheet.tsx
index 527d0237..66e7ae03 100644
--- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheet.tsx
+++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheet.tsx
@@ -12,6 +12,7 @@ import type {
} from "../types";
import type {
InstallationDrawingCaptures,
+ InstallationDrawingActionCaptureTarget,
InstallationDrawingCaptureTarget,
InstallationDrawingCapturedImage,
InstallationDrawingImageMode,
@@ -20,11 +21,16 @@ import type {
} from "../lib/installationDrawingCaptureTypes";
import {
DEFAULT_INSTALLATION_DRAWING_IMAGE_TRANSFORM,
+ getInstallationDrawingCaptureTargetKey,
isInstallationDrawingImageAvailable,
isInstallationDrawingImageCaptured,
resolveInstallationDrawingConfirmedAtForMode,
resolveInstallationDrawingImageMode,
} from "../lib/installationDrawingCaptureTypes";
+import type {
+ InstallationDrawingTextAnnotation,
+ InstallationDrawingTextAnnotationsByTarget,
+} from "../lib/installationDrawingTextAnnotations";
import { buildInstallationDrawingLayout } from "../lib/installationDrawingLayout";
import { useInstallationDrawingMarkerInventory } from "../hooks/useInstallationDrawingMarkerInventory";
import {
@@ -39,6 +45,7 @@ import { InstallationDrawingInventoryPage } from "./InstallationDrawingInventory
import { InstallationDrawingOverviewPage } from "./InstallationDrawingOverviewPage";
import { InstallationDrawingImageAdjustDialog } from "./InstallationDrawingImageAdjustDialog";
import { InstallationDrawingMarkerManagerDialog } from "./InstallationDrawingMarkerManagerDialog";
+import { InstallationDrawingTextAnnotationDialog } from "./InstallationDrawingTextAnnotationDialog";
import {
buildInstallationDrawingInventoryItems,
type InstallationDrawingOverviewTool,
@@ -57,6 +64,8 @@ interface InstallationDrawingSheetProps {
toolSnapshotLineArtUrls: Record;
lineArtGenerating?: boolean;
captures: InstallationDrawingCaptures;
+ textAnnotationsByTargetKey: InstallationDrawingTextAnnotationsByTarget;
+ textAnnotationMessage?: string | null;
initialScrollTop: number;
onCaptureTarget: (
target: InstallationDrawingCaptureTarget,
@@ -68,6 +77,10 @@ interface InstallationDrawingSheetProps {
numberingMarkers: InstallationDrawingNumberMarker[],
numberingMarkerCountOverrides: Record,
) => void;
+ onUpdateTextAnnotations: (
+ targetKey: string,
+ annotations: InstallationDrawingTextAnnotation[],
+ ) => void;
autoCapture: {
phase:
| "loading"
@@ -162,9 +175,12 @@ export function InstallationDrawingSheet({
toolSnapshotLineArtUrls,
lineArtGenerating = false,
captures,
+ textAnnotationsByTargetKey,
+ textAnnotationMessage = null,
initialScrollTop,
onCaptureTarget,
onUpdateImageLayout,
+ onUpdateTextAnnotations,
autoCapture,
confirmation,
onClose,
@@ -180,6 +196,8 @@ export function InstallationDrawingSheet({
);
const [adjustTarget, setAdjustTarget] =
useState(null);
+ const [textAnnotationTarget, setTextAnnotationTarget] =
+ useState(null);
const scrollContainerRef = useRef(null);
const {
markerManagerTarget,
@@ -297,6 +315,23 @@ export function InstallationDrawingSheet({
image.numberingMarkerCountOverrides,
);
};
+ const commitTextAnnotations = (
+ target: InstallationDrawingActionCaptureTarget,
+ annotations: InstallationDrawingTextAnnotation[],
+ ) => {
+ const image = resolveCapturedImage(reconciledCaptures, target);
+ if (!image) return;
+ onUpdateTextAnnotations(
+ getInstallationDrawingCaptureTargetKey(target),
+ annotations,
+ );
+ onUpdateImageLayout(
+ target,
+ image.transform,
+ image.numberingMarkers,
+ image.numberingMarkerCountOverrides,
+ );
+ };
const captureFromCurrentPosition = (
target: InstallationDrawingCaptureTarget,
) => {
@@ -429,6 +464,14 @@ export function InstallationDrawingSheet({
{confirmation.message}
) : null}
+ {textAnnotationMessage ? (
+
+ {textAnnotationMessage}
+
+ ) : null}
{
+ if (target.kind === "action") setTextAnnotationTarget(target);
+ }}
+ onCommitTextAnnotations={(target, annotations) => {
+ if (target.kind === "action") {
+ commitTextAnnotations(target, annotations);
+ }
+ }}
allowManualCapture={
!autoCapture.running && !autoCapture.hasDraftBatch
}
@@ -647,6 +699,21 @@ export function InstallationDrawingSheet({
onClose={() => setMarkerManagerTarget(null)}
/>
) : null}
+ {textAnnotationTarget ? (
+ {
+ commitTextAnnotations(textAnnotationTarget, annotations);
+ setTextAnnotationTarget(null);
+ }}
+ onClose={() => setTextAnnotationTarget(null)}
+ />
+ ) : null}
,
document.body,
);
diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheetScroll.test.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheetScroll.test.tsx
index 1fbf77f8..0703228c 100644
--- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheetScroll.test.tsx
+++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheetScroll.test.tsx
@@ -80,9 +80,11 @@ function buildProps(initialScrollTop: number, onCaptureTarget: jest.Mock) {
actions: [],
inventoryTransforms: {},
},
+ textAnnotationsByTargetKey: {},
initialScrollTop,
onCaptureTarget,
onUpdateImageLayout: jest.fn(),
+ onUpdateTextAnnotations: jest.fn(),
autoCapture: {
phase: "idle" as const,
error: null,
diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingTextAnnotationDialog.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingTextAnnotationDialog.tsx
new file mode 100644
index 00000000..002fe343
--- /dev/null
+++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingTextAnnotationDialog.tsx
@@ -0,0 +1,199 @@
+"use client";
+
+import { useState } from "react";
+import { Check, Minus, Plus, Trash2, Type, X } from "lucide-react";
+import {
+ MAX_INSTALLATION_DRAWING_TEXT_ANNOTATIONS_PER_TARGET,
+ MAX_INSTALLATION_DRAWING_TEXT_FONT_SIZE,
+ MAX_INSTALLATION_DRAWING_TEXT_LENGTH,
+ MIN_INSTALLATION_DRAWING_TEXT_FONT_SIZE,
+ clampInstallationDrawingTextFontSize,
+ cloneInstallationDrawingTextAnnotations,
+ createInstallationDrawingTextAnnotation,
+ type InstallationDrawingTextAnnotation,
+} from "../lib/installationDrawingTextAnnotations";
+
+interface Props {
+ title: string;
+ annotations: InstallationDrawingTextAnnotation[];
+ onSave: (annotations: InstallationDrawingTextAnnotation[]) => void;
+ onClose: () => void;
+}
+
+export function InstallationDrawingTextAnnotationDialog({
+ title,
+ annotations,
+ onSave,
+ onClose,
+}: Props) {
+ const [items, setItems] = useState(() =>
+ cloneInstallationDrawingTextAnnotations(annotations),
+ );
+
+ const updateItem = (
+ id: string,
+ patch: Partial,
+ ) => {
+ setItems((current) =>
+ current.map((item) => (item.id === id ? { ...item, ...patch } : item)),
+ );
+ };
+
+ return (
+
+
+
+
+
+
+ 共 {items.length} 个文本框
+
+
+
+ {items.length === 0 ? (
+
+ 点击“添加文本”创建第一个文字标注
+
+ ) : (
+
+ {items.map((item, index) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingTextAnnotations.test.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingTextAnnotations.test.tsx
new file mode 100644
index 00000000..de7727b1
--- /dev/null
+++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingTextAnnotations.test.tsx
@@ -0,0 +1,76 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { InstallationDrawingTextAnnotations } from "./InstallationDrawingTextAnnotations";
+
+describe("InstallationDrawingTextAnnotations", () => {
+ const annotation = {
+ id: "text-1",
+ text: "锁紧四颗螺丝",
+ x: 0.4,
+ y: 0.3,
+ fontSize: 14,
+ };
+
+ it("commits the dragged template position", () => {
+ const onCommit = jest.fn();
+ render(
+
+
+
,
+ );
+ const text = screen.getByRole("button", { name: "移动文字标注 锁紧四颗螺丝" });
+ const layer = text.parentElement as HTMLDivElement;
+ Object.defineProperty(layer, "getBoundingClientRect", {
+ configurable: true,
+ value: () => ({ width: 200, height: 100 }),
+ });
+ Object.defineProperty(text, "setPointerCapture", {
+ configurable: true,
+ value: jest.fn(),
+ });
+ Object.defineProperty(text, "hasPointerCapture", {
+ configurable: true,
+ value: jest.fn(() => false),
+ });
+
+ const dispatchPointer = (type: string, clientX: number, clientY: number) => {
+ const event = new MouseEvent(type, { bubbles: true, clientX, clientY });
+ Object.defineProperty(event, "pointerId", { value: 5 });
+ fireEvent(text, event);
+ };
+ dispatchPointer("pointerdown", 80, 30);
+ dispatchPointer("pointermove", 120, 50);
+ dispatchPointer("pointerup", 120, 50);
+
+ expect(onCommit).toHaveBeenCalledTimes(1);
+ const committed = onCommit.mock.calls[0][0][0];
+ expect(committed).toMatchObject({
+ id: "text-1",
+ text: "锁紧四颗螺丝",
+ fontSize: 14,
+ });
+ expect(committed.x).toBeCloseTo(0.6);
+ expect(committed.y).toBeCloseTo(0.5);
+ });
+
+ it("keeps a separate static print layer", () => {
+ const { container } = render(
+ ,
+ );
+
+ const printText = container.querySelector(
+ '[data-drawing-print-text-annotation="text-1"]',
+ );
+ expect(printText).not.toBeNull();
+ expect(printText).not.toHaveAttribute("role");
+ expect(printText).not.toHaveAttribute("title");
+ expect(printText).toHaveStyle({ fontSize: "14px" });
+ });
+});
diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingTextAnnotations.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingTextAnnotations.tsx
new file mode 100644
index 00000000..609618ce
--- /dev/null
+++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingTextAnnotations.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import { useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
+import {
+ clampInstallationDrawingTextPosition,
+ type InstallationDrawingTextAnnotation,
+} from "../lib/installationDrawingTextAnnotations";
+
+interface Props {
+ annotations: InstallationDrawingTextAnnotation[];
+ editable?: boolean;
+ onCommit?: (annotations: InstallationDrawingTextAnnotation[]) => void;
+}
+
+interface DragState {
+ id: string;
+ pointerId: number;
+ clientX: number;
+ clientY: number;
+ x: number;
+ y: number;
+}
+
+export function InstallationDrawingTextAnnotations({
+ annotations,
+ editable = false,
+ onCommit,
+}: Props) {
+ const layerRef = useRef(null);
+ const dragRef = useRef(null);
+ const latestRef = useRef(annotations);
+ const [draggedAnnotations, setDraggedAnnotations] = useState(null);
+ const currentAnnotations = draggedAnnotations ?? annotations;
+
+ const beginDrag = (
+ event: ReactPointerEvent,
+ annotation: InstallationDrawingTextAnnotation,
+ ) => {
+ if (!editable || !onCommit) return;
+ event.stopPropagation();
+ event.currentTarget.setPointerCapture(event.pointerId);
+ latestRef.current = annotations;
+ setDraggedAnnotations(annotations);
+ dragRef.current = {
+ id: annotation.id,
+ pointerId: event.pointerId,
+ clientX: event.clientX,
+ clientY: event.clientY,
+ x: annotation.x,
+ y: annotation.y,
+ };
+ };
+
+ const moveText = (event: ReactPointerEvent) => {
+ const drag = dragRef.current;
+ const layer = layerRef.current;
+ if (!editable || !drag || drag.pointerId !== event.pointerId || !layer) return;
+ event.stopPropagation();
+ const bounds = layer.getBoundingClientRect();
+ if (bounds.width <= 0 || bounds.height <= 0) return;
+ const nextX = clampInstallationDrawingTextPosition(
+ drag.x + (event.clientX - drag.clientX) / bounds.width,
+ );
+ const nextY = clampInstallationDrawingTextPosition(
+ drag.y + (event.clientY - drag.clientY) / bounds.height,
+ );
+ const nextAnnotations = latestRef.current.map((item) =>
+ item.id === drag.id ? { ...item, x: nextX, y: nextY } : item,
+ );
+ latestRef.current = nextAnnotations;
+ setDraggedAnnotations(nextAnnotations);
+ };
+
+ const endDrag = (event: ReactPointerEvent) => {
+ if (dragRef.current?.pointerId !== event.pointerId) return;
+ event.stopPropagation();
+ dragRef.current = null;
+ onCommit?.(latestRef.current);
+ setDraggedAnnotations(null);
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ }
+ };
+
+ return (
+ <>
+
+ {currentAnnotations.map((annotation) => (
+ beginDrag(event, annotation)}
+ onPointerMove={moveText}
+ onPointerUp={endDrag}
+ onPointerCancel={endDrag}
+ className={`absolute -translate-x-1/2 -translate-y-1/2 whitespace-pre-wrap break-words text-left font-semibold leading-[1.25] text-[#202526] ${editable ? "pointer-events-auto cursor-move rounded-[2px] bg-white/85 px-1 py-0.5 shadow-[0_1px_4px_rgba(32,37,38,0.18)] ring-1 ring-[#202526]/25 touch-none" : "pointer-events-none"}`}
+ style={{
+ left: `${annotation.x * 100}%`,
+ top: `${annotation.y * 100}%`,
+ maxWidth: "84%",
+ fontSize: `${annotation.fontSize}px`,
+ }}
+ >
+ {annotation.text}
+
+ ))}
+
+
+ {annotations.map((annotation) => (
+
+ {annotation.text}
+
+ ))}
+
+ >
+ );
+}
diff --git a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingAutoCapture.ts b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingAutoCapture.ts
index dbb28766..f0c0cad8 100644
--- a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingAutoCapture.ts
+++ b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingAutoCapture.ts
@@ -24,7 +24,10 @@ import {
type InstallationDrawingImageMode,
type InstallationDrawingNumberMarker,
} from "../lib/installationDrawingCaptureTypes";
-import { resolveInstallationDrawingPendingTargets } from "../lib/installationDrawingAutoCapturePlan";
+import {
+ resolveInstallationDrawingAutoCaptureStartMode,
+ resolveInstallationDrawingPendingTargets,
+} from "../lib/installationDrawingAutoCapturePlan";
import { preserveInstallationDrawingCaptureAnnotations } from "../lib/installationDrawingCapturePreservation";
type AutoCapturePhase =
@@ -177,14 +180,14 @@ export function useInstallationDrawingAutoCapture({
);
const start = useCallback(async () => {
- if (
- !drawingId ||
- !modelUrl ||
- !configFingerprint ||
- phase === "loading" ||
- (snapshot?.active && !snapshot.draft)
- )
- return;
+ const startMode = resolveInstallationDrawingAutoCaptureStartMode({
+ drawingId,
+ modelUrl,
+ configFingerprint,
+ hasActiveBatch: Boolean(snapshot?.active),
+ loading: phase === "loading",
+ });
+ if (!startMode || !drawingId || !modelUrl || !configFingerprint) return;
pauseRequestedRef.current = false;
setError(null);
setPhase("seeding");
@@ -195,7 +198,7 @@ export function useInstallationDrawingAutoCapture({
configFingerprint,
expectedTargetKeys: targetKeys,
inventoryTransforms: captures.inventoryTransforms,
- mode: snapshot?.active ? "replace" : "fill",
+ mode: startMode,
});
const draft = workingSnapshot?.draft;
if (!draft) throw new Error("自动抓取草稿批次创建失败");
diff --git a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingCapture.ts b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingCapture.ts
index 8999bdb9..1dc3ed27 100644
--- a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingCapture.ts
+++ b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingCapture.ts
@@ -11,6 +11,7 @@ import type {
import { buildInstallationStepMaterialItems } from '../lib/installationStepMaterials'
import { collectInstallationToolAttachmentIdentities } from '../lib/installationToolAttachmentIdentity'
import { preserveInstallationDrawingCaptureAnnotations } from '../lib/installationDrawingCapturePreservation'
+import { resolveInstallationDrawingTechnicalLineAnnotation } from '../lib/installationDrawingActionCaptureFrame'
import type {
InstallationDrawingActionCapture,
InstallationDrawingCaptures,
@@ -290,6 +291,13 @@ export function useInstallationDrawingCapture({
const lineArtUrl = capture({
...INSTALLATION_DRAWING_LINE_CAPTURE_OPTIONS,
technicalLineStyle: captureTarget.kind === 'finished' ? 'clean' : 'detail',
+ technicalLineAnnotation: captureTarget.kind === 'action'
+ ? resolveInstallationDrawingTechnicalLineAnnotation({
+ target: captureTarget,
+ steps,
+ numberingParts,
+ })
+ : undefined,
})
if (!lineArtUrl) throw new Error('当前模型无法生成打印线稿,请确认模型几何已完整加载。')
const capturedImage = {
@@ -323,7 +331,16 @@ export function useInstallationDrawingCapture({
} finally {
setCapturing(false)
}
- }, [captureReady, captureRef, captureTarget, captures, capturing, numberingMarkersRef])
+ }, [
+ captureReady,
+ captureRef,
+ captureTarget,
+ captures,
+ capturing,
+ numberingMarkersRef,
+ numberingParts,
+ steps,
+ ])
const applyPersistedBatch = useCallback((batch: InstallationDrawingCaptureBatch | null) => {
if (!batch) {
diff --git a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingTextAnnotations.ts b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingTextAnnotations.ts
new file mode 100644
index 00000000..1ab1d190
--- /dev/null
+++ b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingTextAnnotations.ts
@@ -0,0 +1,117 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ cloneInstallationDrawingTextAnnotations,
+ sanitizeInstallationDrawingTextAnnotations,
+ type InstallationDrawingTextAnnotation,
+ type InstallationDrawingTextAnnotationsByTarget,
+} from "../lib/installationDrawingTextAnnotations";
+
+interface AnnotationSnapshot {
+ annotationsByTargetKey?: unknown;
+}
+
+interface AnnotationApiResponse {
+ success?: boolean;
+ data?: { snapshot?: AnnotationSnapshot };
+ error?: { message?: string };
+}
+
+async function readResponse(response: Response, fallback: string) {
+ const payload = (await response.json().catch(() => null)) as AnnotationApiResponse | null;
+ if (!response.ok || !payload?.success) {
+ throw new Error(payload?.error?.message || fallback);
+ }
+ return sanitizeInstallationDrawingTextAnnotations(
+ payload.data?.snapshot?.annotationsByTargetKey,
+ );
+}
+
+export function useInstallationDrawingTextAnnotations(
+ drawingId: string | null,
+ canEdit: boolean,
+) {
+ const [snapshotState, setSnapshotState] = useState<{
+ drawingId: string | null;
+ annotations: InstallationDrawingTextAnnotationsByTarget;
+ }>({ drawingId: null, annotations: {} });
+ const [messageState, setMessageState] = useState<{
+ drawingId: string | null;
+ message: string | null;
+ }>({ drawingId: null, message: null });
+ const saveQueueRef = useRef>(Promise.resolve());
+ const annotationsByTargetKey =
+ snapshotState.drawingId === drawingId ? snapshotState.annotations : {};
+ const message = messageState.drawingId === drawingId ? messageState.message : null;
+
+ useEffect(() => {
+ let cancelled = false;
+ if (!drawingId) {
+ return () => {
+ cancelled = true;
+ };
+ }
+ const searchParams = new URLSearchParams({ drawingId });
+ void fetch(
+ `/api/installation-demo-workbench/drawing-text-annotations?${searchParams.toString()}`,
+ { credentials: "same-origin" },
+ )
+ .then((response) => readResponse(response, "图纸文字标注读取失败"))
+ .then((annotations) => {
+ if (!cancelled) setSnapshotState({ drawingId, annotations });
+ })
+ .catch((error) => {
+ if (!cancelled) {
+ setMessageState({
+ drawingId,
+ message: error instanceof Error ? error.message : "图纸文字标注读取失败",
+ });
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [drawingId]);
+
+ const saveTargetAnnotations = useCallback(
+ (targetKey: string, annotations: InstallationDrawingTextAnnotation[]) => {
+ if (!drawingId || !canEdit) return;
+ const nextAnnotations = cloneInstallationDrawingTextAnnotations(annotations);
+ setSnapshotState((current) => {
+ const next = current.drawingId === drawingId ? { ...current.annotations } : {};
+ if (nextAnnotations.length === 0) delete next[targetKey];
+ else next[targetKey] = nextAnnotations;
+ return { drawingId, annotations: next };
+ });
+ setMessageState({ drawingId, message: null });
+ saveQueueRef.current = saveQueueRef.current
+ .catch(() => undefined)
+ .then(async () => {
+ const response = await fetch(
+ "/api/installation-demo-workbench/drawing-text-annotations",
+ {
+ method: "PATCH",
+ credentials: "same-origin",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ drawingId, targetKey, annotations: nextAnnotations }),
+ },
+ );
+ await readResponse(response, "图纸文字标注保存失败");
+ })
+ .catch((error) => {
+ setMessageState({
+ drawingId,
+ message: error instanceof Error ? error.message : "图纸文字标注保存失败",
+ });
+ });
+ },
+ [canEdit, drawingId],
+ );
+
+ return {
+ annotationsByTargetKey,
+ message,
+ saveTargetAnnotations,
+ };
+}
diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingActionCaptureFrame.test.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingActionCaptureFrame.test.ts
index 1364705e..ab4ad599 100644
--- a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingActionCaptureFrame.test.ts
+++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingActionCaptureFrame.test.ts
@@ -8,6 +8,7 @@ import {
isInstallationDrawingActionCaptureFrameApplied,
isInstallationDrawingActionWorkspaceReady,
resolveInstallationDrawingActionCaptureFrame,
+ resolveInstallationDrawingTechnicalLineAnnotation,
} from './installationDrawingActionCaptureFrame'
import type { InstallationPartItem, InstallationStepDraft } from '../types'
@@ -61,6 +62,87 @@ const step: InstallationStepDraft = {
}
describe('resolveInstallationDrawingActionCaptureFrame', () => {
+ it('builds red action nodes and blue hardware paths from the saved assembled transforms', () => {
+ const actionStep = {
+ ...step,
+ hardwareNodeNames: ['action-2'],
+ }
+ const target = {
+ kind: 'action' as const,
+ id: 'step-1:action-2',
+ stepId: 'step-1',
+ frameKind: 'action' as const,
+ actionId: 'action-2',
+ sequence: 3,
+ }
+
+ expect(resolveInstallationDrawingTechnicalLineAnnotation({
+ target,
+ steps: [actionStep],
+ numberingParts: [],
+ })).toEqual({
+ activeNodeNames: ['action-2'],
+ hardwareNodeNames: ['action-2'],
+ assembledTransforms: [
+ { nodeName: 'fixed', position: [0, 0, 0], rotation: [0, 0, 0, 1] },
+ { nodeName: 'action-1', position: [1, 0, 0], rotation: [0, 0, 0, 1] },
+ { nodeName: 'action-2', position: [2, 0, 0], rotation: [0, 0, 0, 1] },
+ ],
+ })
+ })
+
+ it('does not add action annotations to a fixed frame', () => {
+ expect(resolveInstallationDrawingTechnicalLineAnnotation({
+ target: {
+ kind: 'action',
+ id: 'step-1:fixed',
+ stepId: 'step-1',
+ frameKind: 'fixed',
+ actionId: null,
+ sequence: 1,
+ },
+ steps: [step],
+ numberingParts: [],
+ })).toBeUndefined()
+ })
+
+ it('keeps a part-only action red without inventing a hardware path', () => {
+ expect(resolveInstallationDrawingTechnicalLineAnnotation({
+ target: {
+ kind: 'action',
+ id: 'step-1:action-1',
+ stepId: 'step-1',
+ frameKind: 'action',
+ actionId: 'action-1',
+ sequence: 2,
+ },
+ steps: [step],
+ numberingParts: [],
+ })?.hardwareNodeNames).toEqual([])
+ })
+
+ it('recognizes hardware from numbering bindings when legacy step role arrays are empty', () => {
+ const numberingParts = [{
+ id: 'hardware-part',
+ nodeName: 'action-2',
+ nodeNames: ['action-2'],
+ kind: 'hardware',
+ }] as InstallationPartItem[]
+
+ expect(resolveInstallationDrawingTechnicalLineAnnotation({
+ target: {
+ kind: 'action',
+ id: 'step-1:action-2',
+ stepId: 'step-1',
+ frameKind: 'action',
+ actionId: 'action-2',
+ sequence: 3,
+ },
+ steps: [step],
+ numberingParts,
+ })?.hardwareNodeNames).toEqual(['action-2'])
+ })
+
it('shows only the step fixed nodes in the fixed opening frame', () => {
const frame = resolveInstallationDrawingActionCaptureFrame({
target: {
diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingActionCaptureFrame.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingActionCaptureFrame.ts
index dc0320df..90cb2c02 100644
--- a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingActionCaptureFrame.ts
+++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingActionCaptureFrame.ts
@@ -1,4 +1,8 @@
-import type { NodeLocalTransformState, ThreeDEngine } from '@/lib/3d/engine'
+import type {
+ EngineTechnicalLineAnnotation,
+ NodeLocalTransformState,
+ ThreeDEngine,
+} from '@/lib/3d/engine'
import { buildInstallationStepEditFrame } from './installationStepActionPlayback'
import { deriveInstallationFinishClosePlaybackStep } from './installationFinishCloseBinding'
import type { InstallationDrawingActionCaptureTarget } from './installationDrawingCaptureTypes'
@@ -137,6 +141,42 @@ interface ResolveInstallationDrawingActionCaptureFrameInput {
modelNodeNames: string[]
}
+export function resolveInstallationDrawingTechnicalLineAnnotation({
+ target,
+ steps,
+ numberingParts,
+}: Omit):
+ EngineTechnicalLineAnnotation | undefined {
+ if (!target || target.frameKind !== 'action' || !target.actionId) return undefined
+ const storedStep = steps.find((item) => item.id === target.stepId) ?? null
+ if (!storedStep) return undefined
+ const step = deriveInstallationFinishClosePlaybackStep({
+ step: storedStep,
+ numberingParts,
+ })
+ const action = step?.actions?.find((item) => item.id === target.actionId) ?? null
+ if (!step || !action) return undefined
+
+ const hardwareNodeNameSet = new Set([
+ ...step.hardwareNodeNames,
+ ...numberingParts
+ .filter((part) => part.kind === 'hardware')
+ .flatMap((part) => [part.nodeName, ...part.nodeNames])
+ .filter((nodeName): nodeName is string => Boolean(nodeName)),
+ ])
+ const assembledTransforms = Object.entries(step.assembledTransforms ?? {}).flatMap(
+ ([nodeName, transform]) => transform.position
+ ? [{ nodeName, position: transform.position, rotation: transform.rotation }]
+ : [],
+ )
+
+ return {
+ activeNodeNames: action.nodeNames,
+ hardwareNodeNames: action.nodeNames.filter((nodeName) => hardwareNodeNameSet.has(nodeName)),
+ assembledTransforms,
+ }
+}
+
export function resolveInstallationDrawingActionCaptureFrame({
target,
steps,
diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingAutoCapturePlan.test.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingAutoCapturePlan.test.ts
index d2052c2f..98743d4b 100644
--- a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingAutoCapturePlan.test.ts
+++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingAutoCapturePlan.test.ts
@@ -1,4 +1,7 @@
-import { resolveInstallationDrawingPendingTargets } from './installationDrawingAutoCapturePlan'
+import {
+ resolveInstallationDrawingAutoCaptureStartMode,
+ resolveInstallationDrawingPendingTargets,
+} from './installationDrawingAutoCapturePlan'
import type { InstallationDrawingCaptureTarget } from './installationDrawingCaptureTypes'
const targets: Array> = [
@@ -25,3 +28,33 @@ describe('resolveInstallationDrawingPendingTargets', () => {
})
})
+describe('resolveInstallationDrawingAutoCaptureStartMode', () => {
+ const readyState = {
+ drawingId: 'drawing-1',
+ modelUrl: '/model.glb',
+ configFingerprint: 'config-1',
+ loading: false,
+ }
+
+ it('starts a full replacement when a completed batch already exists', () => {
+ expect(resolveInstallationDrawingAutoCaptureStartMode({
+ ...readyState,
+ hasActiveBatch: true,
+ })).toBe('replace')
+ })
+
+ it('starts a fill batch when no completed batch exists', () => {
+ expect(resolveInstallationDrawingAutoCaptureStartMode({
+ ...readyState,
+ hasActiveBatch: false,
+ })).toBe('fill')
+ })
+
+ it('does not start before the drawing context is ready', () => {
+ expect(resolveInstallationDrawingAutoCaptureStartMode({
+ ...readyState,
+ hasActiveBatch: true,
+ loading: true,
+ })).toBeNull()
+ })
+})
diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingAutoCapturePlan.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingAutoCapturePlan.ts
index 2c023934..bf92c938 100644
--- a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingAutoCapturePlan.ts
+++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingAutoCapturePlan.ts
@@ -5,6 +5,22 @@ import {
type CaptureTarget = Exclude
+export function resolveInstallationDrawingAutoCaptureStartMode(input: {
+ drawingId: string | null
+ modelUrl: string | null
+ configFingerprint: string | null
+ hasActiveBatch: boolean
+ loading: boolean
+}) {
+ if (
+ !input.drawingId
+ || !input.modelUrl
+ || !input.configFingerprint
+ || input.loading
+ ) return null
+ return input.hasActiveBatch ? 'replace' : 'fill'
+}
+
export function resolveInstallationDrawingPendingTargets(
targets: CaptureTarget[],
completedTargetKeys: Iterable,
@@ -14,4 +30,3 @@ export function resolveInstallationDrawingPendingTargets(
(target) => !completed.has(getInstallationDrawingCaptureTargetKey(target)),
)
}
-
diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingTextAnnotations.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingTextAnnotations.ts
new file mode 100644
index 00000000..a2708cfa
--- /dev/null
+++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingTextAnnotations.ts
@@ -0,0 +1,74 @@
+export interface InstallationDrawingTextAnnotation {
+ id: string;
+ text: string;
+ x: number;
+ y: number;
+ fontSize: number;
+}
+
+export type InstallationDrawingTextAnnotationsByTarget = Record<
+ string,
+ InstallationDrawingTextAnnotation[]
+>;
+
+export const DEFAULT_INSTALLATION_DRAWING_TEXT_FONT_SIZE = 12;
+export const MIN_INSTALLATION_DRAWING_TEXT_FONT_SIZE = 8;
+export const MAX_INSTALLATION_DRAWING_TEXT_FONT_SIZE = 32;
+export const MAX_INSTALLATION_DRAWING_TEXT_LENGTH = 240;
+export const MAX_INSTALLATION_DRAWING_TEXT_ANNOTATIONS_PER_TARGET = 24;
+
+export function createInstallationDrawingTextAnnotation(
+ index = 0,
+): InstallationDrawingTextAnnotation {
+ return {
+ id: `text-${Date.now()}-${index}`,
+ text: "",
+ x: Math.min(0.8, 0.42 + index * 0.04),
+ y: Math.min(0.82, 0.42 + index * 0.05),
+ fontSize: DEFAULT_INSTALLATION_DRAWING_TEXT_FONT_SIZE,
+ };
+}
+
+export function clampInstallationDrawingTextPosition(value: number) {
+ if (!Number.isFinite(value)) return 0.5;
+ return Math.min(0.98, Math.max(0.02, value));
+}
+
+export function clampInstallationDrawingTextFontSize(value: number) {
+ if (!Number.isFinite(value)) return DEFAULT_INSTALLATION_DRAWING_TEXT_FONT_SIZE;
+ return Math.min(
+ MAX_INSTALLATION_DRAWING_TEXT_FONT_SIZE,
+ Math.max(MIN_INSTALLATION_DRAWING_TEXT_FONT_SIZE, Math.round(value)),
+ );
+}
+
+export function sanitizeInstallationDrawingTextAnnotations(
+ value: unknown,
+): InstallationDrawingTextAnnotationsByTarget {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
+ const result: InstallationDrawingTextAnnotationsByTarget = {};
+ for (const [targetKey, entries] of Object.entries(value)) {
+ if (!targetKey.trim() || !Array.isArray(entries)) continue;
+ result[targetKey] = entries
+ .filter((entry): entry is Record => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry))
+ .map((entry, index) => {
+ const text = typeof entry.text === "string" ? entry.text.slice(0, MAX_INSTALLATION_DRAWING_TEXT_LENGTH) : "";
+ return {
+ id: typeof entry.id === "string" && entry.id.trim() ? entry.id : `text-${targetKey}-${index}`,
+ text,
+ x: clampInstallationDrawingTextPosition(Number(entry.x)),
+ y: clampInstallationDrawingTextPosition(Number(entry.y)),
+ fontSize: clampInstallationDrawingTextFontSize(Number(entry.fontSize) || DEFAULT_INSTALLATION_DRAWING_TEXT_FONT_SIZE),
+ };
+ })
+ .filter((entry) => entry.text.trim().length > 0)
+ .slice(0, MAX_INSTALLATION_DRAWING_TEXT_ANNOTATIONS_PER_TARGET);
+ }
+ return result;
+}
+
+export function cloneInstallationDrawingTextAnnotations(
+ annotations: InstallationDrawingTextAnnotation[],
+) {
+ return annotations.map((annotation) => ({ ...annotation }));
+}
diff --git a/furniture-3d-system/src/lib/3d/engine.ts b/furniture-3d-system/src/lib/3d/engine.ts
index f830fd59..9751e550 100644
--- a/furniture-3d-system/src/lib/3d/engine.ts
+++ b/furniture-3d-system/src/lib/3d/engine.ts
@@ -76,6 +76,12 @@ export interface NodeLocalTransformState {
rotation: [number, number, number, number];
}
+export interface EngineTechnicalLineAnnotation {
+ activeNodeNames: string[];
+ hardwareNodeNames: string[];
+ assembledTransforms: NodeLocalTransformState[];
+}
+
export type EngineWorldNodeSource =
| "desktop"
| "leg"
@@ -113,6 +119,7 @@ export interface EngineCaptureOptions {
cropAlphaThreshold?: number;
renderMode?: "default" | "technical-line";
technicalLineStyle?: "clean" | "detail";
+ technicalLineAnnotation?: EngineTechnicalLineAnnotation;
renderCamera?: THREE.Camera;
}
@@ -1676,6 +1683,33 @@ export class ThreeDEngine {
...(this.modelRoot ? [this.modelRoot] : []),
...Array.from(this.externalRaycastRoots),
];
+ const annotation = options.technicalLineAnnotation
+ ? {
+ activeObjects: options.technicalLineAnnotation.activeNodeNames.flatMap(
+ (nodeName) => {
+ const object = this.resolveObjectByNodeName(nodeName);
+ return object ? [object] : [];
+ },
+ ),
+ hardwareObjects: options.technicalLineAnnotation.hardwareNodeNames.flatMap(
+ (nodeName) => {
+ const object = this.resolveObjectByNodeName(nodeName);
+ return object ? [object] : [];
+ },
+ ),
+ assembledTransforms:
+ options.technicalLineAnnotation.assembledTransforms.flatMap((transform) => {
+ const object = this.resolveObjectByNodeName(transform.nodeName);
+ return object
+ ? [{
+ object,
+ position: transform.position,
+ rotation: transform.rotation,
+ }]
+ : [];
+ }),
+ }
+ : undefined;
const drawingBufferSize = this.renderer.getDrawingBufferSize(new THREE.Vector2());
return withTechnicalLineCaptureScene(
roots,
@@ -1698,6 +1732,7 @@ export class ThreeDEngine {
viewportWidth: drawingBufferSize.x,
viewportHeight: drawingBufferSize.y,
style: options.technicalLineStyle ?? "detail",
+ annotation,
},
);
}
diff --git a/furniture-3d-system/src/lib/3d/technical-line-annotations.test.ts b/furniture-3d-system/src/lib/3d/technical-line-annotations.test.ts
new file mode 100644
index 00000000..19439dd8
--- /dev/null
+++ b/furniture-3d-system/src/lib/3d/technical-line-annotations.test.ts
@@ -0,0 +1,293 @@
+import * as THREE from 'three'
+import {
+ collectTechnicalLineActiveMeshes,
+ resolveTechnicalLineHardwarePaths,
+ type TechnicalLineCaptureAnnotation,
+} from './technical-line-annotations'
+
+function createMesh(name: string) {
+ const mesh = new THREE.Mesh(
+ new THREE.BoxGeometry(2, 2, 2),
+ new THREE.MeshBasicMaterial(),
+ )
+ mesh.name = name
+ return mesh
+}
+
+function createFrontCamera() {
+ const camera = new THREE.OrthographicCamera(-5, 5, 5, -5, 0.1, 20)
+ camera.position.set(0, 0, 10)
+ camera.lookAt(0, 0, 0)
+ camera.updateProjectionMatrix()
+ camera.updateMatrixWorld(true)
+ return camera
+}
+
+function createBoardWithHole() {
+ const shape = new THREE.Shape()
+ shape.moveTo(-2, -2)
+ shape.lineTo(2, -2)
+ shape.lineTo(2, 2)
+ shape.lineTo(-2, 2)
+ shape.closePath()
+ const hole = new THREE.Path()
+ hole.absellipse(0, 0, 0.6, 0.6, 0, Math.PI * 2, false, 0)
+ shape.holes.push(hole)
+ const geometry = new THREE.ExtrudeGeometry(shape, {
+ depth: 1,
+ bevelEnabled: false,
+ curveSegments: 16,
+ steps: 1,
+ })
+ geometry.translate(0, 0, -0.5)
+ return new THREE.Mesh(geometry, new THREE.MeshBasicMaterial())
+}
+
+describe('technical line annotations', () => {
+ test('collects only meshes below the current action objects', () => {
+ const scene = new THREE.Scene()
+ const activeGroup = new THREE.Group()
+ const activeMesh = createMesh('active')
+ const inactiveMesh = createMesh('inactive')
+ activeGroup.add(activeMesh)
+ scene.add(activeGroup, inactiveMesh)
+ scene.updateMatrixWorld(true)
+
+ expect(collectTechnicalLineActiveMeshes(
+ [activeMesh, inactiveMesh],
+ [activeGroup],
+ )).toEqual([activeMesh])
+ })
+
+ test('uses the visible opening of the real receiving hole instead of the saved hardware center', () => {
+ const scene = new THREE.Scene()
+ const modelRoot = new THREE.Group()
+ const hardware = new THREE.Mesh(
+ new THREE.BoxGeometry(1, 1, 0.4),
+ new THREE.MeshBasicMaterial(),
+ )
+ const board = createBoardWithHole()
+ hardware.position.set(0, 1, 3)
+ board.position.set(0, 1, 0)
+ modelRoot.add(hardware, board)
+ scene.add(modelRoot)
+ scene.updateMatrixWorld(true)
+ modelRoot.matrixWorld.makeTranslation(4, 0, 0)
+ hardware.updateMatrixWorld(true)
+ board.updateMatrixWorld(true)
+
+ const [path] = resolveTechnicalLineHardwarePaths({
+ activeObjects: [hardware],
+ hardwareObjects: [hardware],
+ assembledTransforms: [
+ { object: hardware, position: [0, 0, 0.2], rotation: [0, 0, 0, 1] },
+ { object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
+ ],
+ }, [hardware, board], createFrontCamera())
+
+ expect(path.start.x).toBeCloseTo(4)
+ expect(path.start.y).toBeCloseTo(1)
+ expect(path.start.z).toBeCloseTo(2.8)
+ expect(path.end.x).toBeCloseTo(4)
+ expect(path.end.y).toBeCloseTo(1)
+ expect(path.end.z).toBeCloseTo(0.5)
+ })
+
+ test('skips a nearer hidden hole loop and uses the next visible opening', () => {
+ const scene = new THREE.Scene()
+ const hardware = new THREE.Mesh(
+ new THREE.BoxGeometry(1, 1, 0.4),
+ new THREE.MeshBasicMaterial(),
+ )
+ const board = createBoardWithHole()
+ const internalBlocker = new THREE.Mesh(
+ new THREE.BoxGeometry(2, 2, 0.1),
+ new THREE.MeshBasicMaterial(),
+ )
+ hardware.position.set(0, 0, 3)
+ scene.add(hardware, board, internalBlocker)
+ scene.updateMatrixWorld(true)
+
+ const [path] = resolveTechnicalLineHardwarePaths({
+ activeObjects: [hardware],
+ hardwareObjects: [hardware],
+ assembledTransforms: [
+ { object: hardware, position: [0, 0, -0.2], rotation: [0, 0, 0, 1] },
+ { object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
+ ],
+ }, [hardware, board, internalBlocker], createFrontCamera())
+
+ expect(path.start.z).toBeCloseTo(2.8)
+ expect(path.end.x).toBeCloseTo(0)
+ expect(path.end.y).toBeCloseTo(0)
+ expect(path.end.z).toBeCloseTo(0.5)
+ })
+
+ test('does not draw a receiving hole hidden behind another board', () => {
+ const scene = new THREE.Scene()
+ const hardware = new THREE.Mesh(
+ new THREE.BoxGeometry(1, 1, 0.4),
+ new THREE.MeshBasicMaterial(),
+ )
+ const board = createBoardWithHole()
+ const blocker = new THREE.Mesh(
+ new THREE.BoxGeometry(2, 2, 0.5),
+ new THREE.MeshBasicMaterial(),
+ )
+ hardware.position.set(0, 0, 3)
+ blocker.position.set(0, 0, 2)
+ scene.add(hardware, board, blocker)
+ scene.updateMatrixWorld(true)
+
+ expect(resolveTechnicalLineHardwarePaths({
+ activeObjects: [hardware],
+ hardwareObjects: [hardware],
+ assembledTransforms: [
+ { object: hardware, position: [0, 0, 0.2], rotation: [0, 0, 0, 1] },
+ { object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
+ ],
+ }, [hardware, board, blocker], createFrontCamera())).toEqual([])
+ })
+
+ test('draws from the hardware installation end along its own axis to the board surface', () => {
+ const scene = new THREE.Scene()
+ const hardware = new THREE.Mesh(
+ new THREE.BoxGeometry(0.2, 2, 0.2),
+ new THREE.MeshBasicMaterial(),
+ )
+ const board = new THREE.Mesh(
+ new THREE.BoxGeometry(4, 1, 4),
+ new THREE.MeshBasicMaterial(),
+ )
+ hardware.name = 'hardware'
+ board.name = 'board'
+ hardware.position.set(0, 2, 0)
+ scene.add(hardware, board)
+ scene.updateMatrixWorld(true)
+
+ const annotation: TechnicalLineCaptureAnnotation = {
+ activeObjects: [hardware],
+ hardwareObjects: [hardware],
+ assembledTransforms: [
+ { object: hardware, position: [0, 0.8, 0], rotation: [0, 0, 0, 1] },
+ { object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
+ ],
+ }
+ const paths = resolveTechnicalLineHardwarePaths(annotation, [hardware, board])
+
+ expect(paths).toHaveLength(1)
+ expect(paths[0].start.toArray()).toEqual([0, 1, 0])
+ expect(paths[0].end.x).toBeCloseTo(0)
+ expect(paths[0].end.y).toBeCloseTo(0.5)
+ expect(paths[0].end.z).toBeCloseTo(0)
+ })
+
+ test('keeps the path on the hardware axis after rotation', () => {
+ const scene = new THREE.Scene()
+ const hardware = new THREE.Mesh(
+ new THREE.BoxGeometry(0.2, 2, 0.2),
+ new THREE.MeshBasicMaterial(),
+ )
+ const board = new THREE.Mesh(
+ new THREE.BoxGeometry(1, 4, 4),
+ new THREE.MeshBasicMaterial(),
+ )
+ hardware.name = 'rotated-hardware'
+ hardware.position.set(2, 0, 0)
+ hardware.rotation.z = Math.PI / 2
+ scene.add(hardware, board)
+ scene.updateMatrixWorld(true)
+
+ const [path] = resolveTechnicalLineHardwarePaths({
+ activeObjects: [hardware],
+ hardwareObjects: [hardware],
+ assembledTransforms: [
+ {
+ object: hardware,
+ position: [0.8, 0, 0],
+ rotation: [0, 0, Math.SQRT1_2, Math.SQRT1_2],
+ },
+ { object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
+ ],
+ }, [hardware, board])
+
+ expect(path.start.x).toBeCloseTo(1)
+ expect(path.start.y).toBeCloseTo(0)
+ expect(path.end.x).toBeCloseTo(0.5)
+ expect(path.end.y).toBeCloseTo(0)
+ })
+
+ test('stops at the receiving surface when the saved hardware pose flips', () => {
+ const scene = new THREE.Scene()
+ const hardware = new THREE.Mesh(
+ new THREE.BoxGeometry(0.2, 2, 0.2),
+ new THREE.MeshBasicMaterial(),
+ )
+ const board = new THREE.Mesh(
+ new THREE.BoxGeometry(4, 1, 4),
+ new THREE.MeshBasicMaterial(),
+ )
+ hardware.position.set(0, 2, 0)
+ scene.add(hardware, board)
+ scene.updateMatrixWorld(true)
+
+ const [path] = resolveTechnicalLineHardwarePaths({
+ activeObjects: [hardware],
+ hardwareObjects: [hardware],
+ assembledTransforms: [
+ { object: hardware, position: [0, 0.8, 0], rotation: [0, 0, 1, 0] },
+ { object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
+ ],
+ }, [hardware, board])
+
+ expect(path.start.y).toBeCloseTo(1)
+ expect(path.end.y).toBeCloseTo(0.5)
+ })
+
+ test('creates one path per hardware object and skips missing final transforms', () => {
+ const scene = new THREE.Scene()
+ const first = new THREE.Mesh(new THREE.BoxGeometry(0.2, 2, 0.2), new THREE.MeshBasicMaterial())
+ const second = new THREE.Mesh(new THREE.BoxGeometry(0.2, 2, 0.2), new THREE.MeshBasicMaterial())
+ const missing = new THREE.Mesh(new THREE.BoxGeometry(0.2, 2, 0.2), new THREE.MeshBasicMaterial())
+ const board = new THREE.Mesh(new THREE.BoxGeometry(10, 1, 4), new THREE.MeshBasicMaterial())
+ first.name = 'first'
+ second.name = 'second'
+ missing.name = 'missing'
+ first.position.set(-2, 2, 0)
+ second.position.set(2, 2, 0)
+ missing.position.set(0, 2, 0)
+ scene.add(first, second, missing, board)
+ scene.updateMatrixWorld(true)
+
+ const paths = resolveTechnicalLineHardwarePaths({
+ activeObjects: [first, second, missing],
+ hardwareObjects: [first, second, missing],
+ assembledTransforms: [
+ { object: first, position: [-2, 0.8, 0], rotation: [0, 0, 0, 1] },
+ { object: second, position: [2, 0.8, 0], rotation: [0, 0, 0, 1] },
+ { object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
+ ],
+ }, [first, second, missing, board])
+
+ expect(paths.map((path) => path.object.name)).toEqual(['first', 'second'])
+ })
+
+ test('does not fall back to a center line when no receiving surface is on the axis', () => {
+ const scene = new THREE.Scene()
+ const hardware = new THREE.Mesh(
+ new THREE.BoxGeometry(0.2, 2, 0.2),
+ new THREE.MeshBasicMaterial(),
+ )
+ hardware.position.set(3, 0, 0)
+ scene.add(hardware)
+ scene.updateMatrixWorld(true)
+
+ expect(resolveTechnicalLineHardwarePaths({
+ activeObjects: [hardware],
+ hardwareObjects: [hardware],
+ assembledTransforms: [
+ { object: hardware, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
+ ],
+ }, [hardware])).toEqual([])
+ })
+})
diff --git a/furniture-3d-system/src/lib/3d/technical-line-annotations.ts b/furniture-3d-system/src/lib/3d/technical-line-annotations.ts
new file mode 100644
index 00000000..db1ad774
--- /dev/null
+++ b/furniture-3d-system/src/lib/3d/technical-line-annotations.ts
@@ -0,0 +1,297 @@
+import * as THREE from 'three'
+import { collectClosedTechnicalLineFeatureLoops } from './technical-line-feature-loops'
+
+export interface TechnicalLineAnnotationObjectTransform {
+ object: THREE.Object3D
+ position: [number, number, number]
+ rotation: [number, number, number, number]
+}
+
+export interface TechnicalLineCaptureAnnotation {
+ activeObjects: THREE.Object3D[]
+ hardwareObjects: THREE.Object3D[]
+ assembledTransforms: TechnicalLineAnnotationObjectTransform[]
+}
+
+export interface TechnicalLineHardwarePath {
+ object: THREE.Object3D
+ start: THREE.Vector3
+ end: THREE.Vector3
+}
+
+function isDescendantOf(object: THREE.Object3D, ancestor: THREE.Object3D) {
+ let current: THREE.Object3D | null = object
+ while (current) {
+ if (current === ancestor) return true
+ current = current.parent
+ }
+ return false
+}
+
+export function collectTechnicalLineActiveMeshes(
+ meshes: THREE.Mesh[],
+ activeObjects: THREE.Object3D[],
+) {
+ if (activeObjects.length === 0) return []
+ return meshes.filter((mesh) => activeObjects.some((object) => isDescendantOf(mesh, object)))
+}
+
+function getObjectLocalGeometryBounds(
+ object: THREE.Object3D,
+ meshes: THREE.Mesh[],
+) {
+ const objectWorldInverse = object.matrixWorld.clone().invert()
+ const bounds = new THREE.Box3()
+ let hasGeometry = false
+
+ meshes.forEach((mesh) => {
+ if (!isDescendantOf(mesh, object)) return
+ if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
+ if (!mesh.geometry.boundingBox) return
+ const meshToObject = new THREE.Matrix4().multiplyMatrices(
+ objectWorldInverse,
+ mesh.matrixWorld,
+ )
+ bounds.union(mesh.geometry.boundingBox.clone().applyMatrix4(meshToObject))
+ hasGeometry = true
+ })
+
+ return hasGeometry && !bounds.isEmpty() ? bounds : null
+}
+
+function getWorldBounds(meshes: THREE.Mesh[]) {
+ const bounds = new THREE.Box3()
+ meshes.forEach((mesh) => {
+ if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
+ if (mesh.geometry.boundingBox) {
+ bounds.union(mesh.geometry.boundingBox.clone().applyMatrix4(mesh.matrixWorld))
+ }
+ })
+ return bounds
+}
+
+function getPrimaryAxisIndex(size: THREE.Vector3) {
+ if (size.y >= size.x && size.y >= size.z) return 1
+ return size.z >= size.x ? 2 : 0
+}
+
+function createWorldAxisPairs(
+ bounds: THREE.Box3,
+ worldMatrix: THREE.Matrix4,
+) {
+ const center = bounds.getCenter(new THREE.Vector3())
+ return [0, 1, 2].flatMap((axisIndex) => {
+ const minimum = center.clone().setComponent(axisIndex, bounds.min.getComponent(axisIndex))
+ .applyMatrix4(worldMatrix)
+ const maximum = center.clone().setComponent(axisIndex, bounds.max.getComponent(axisIndex))
+ .applyMatrix4(worldMatrix)
+ const axis = maximum.clone().sub(minimum)
+ if (axis.lengthSq() <= 1e-12) return []
+ return [{ axisIndex, minimum, maximum, axis: axis.normalize() }]
+ })
+}
+
+function createAxialEndpoints(
+ bounds: THREE.Box3,
+ worldMatrix: THREE.Matrix4,
+) {
+ const axisIndex = getPrimaryAxisIndex(bounds.getSize(new THREE.Vector3()))
+ const pair = createWorldAxisPairs(bounds, worldMatrix)
+ .find((candidate) => candidate.axisIndex === axisIndex)
+ if (!pair) return []
+ return [
+ { start: pair.minimum, direction: pair.axis.clone().multiplyScalar(-1) },
+ { start: pair.maximum, direction: pair.axis },
+ ]
+}
+
+function buildFinalWorldMatrix(
+ object: THREE.Object3D,
+ transformByObjectUuid: Map,
+ cache: Map,
+): THREE.Matrix4 {
+ const cached = cache.get(object.uuid)
+ if (cached) return cached
+
+ const transform = transformByObjectUuid.get(object.uuid)
+ if (!transform) {
+ const currentWorldMatrix = object.matrixWorld.clone()
+ cache.set(object.uuid, currentWorldMatrix)
+ return currentWorldMatrix
+ }
+
+ const parentWorld = object.parent
+ ? object.parent.matrixWorld.clone()
+ : new THREE.Matrix4()
+ const localMatrix = transform
+ ? new THREE.Matrix4().compose(
+ new THREE.Vector3(...transform.position),
+ new THREE.Quaternion(...transform.rotation).normalize(),
+ object.scale,
+ )
+ : object.matrix.clone()
+ const worldMatrix = parentWorld.clone().multiply(localMatrix)
+ cache.set(object.uuid, worldMatrix)
+ return worldMatrix
+}
+
+function isWorldPointVisible(
+ point: THREE.Vector3,
+ camera: THREE.Camera,
+ meshes: THREE.Mesh[],
+ excludedObjects: THREE.Object3D[],
+ tolerance: number,
+) {
+ const projected = point.clone().project(camera)
+ if (
+ projected.x < -1 || projected.x > 1
+ || projected.y < -1 || projected.y > 1
+ || projected.z < -1 || projected.z > 1
+ ) return false
+
+ const visibleMeshes = meshes.filter((mesh) => (
+ excludedObjects.every((object) => !isDescendantOf(mesh, object))
+ ))
+ const raycaster = new THREE.Raycaster()
+ raycaster.setFromCamera(new THREE.Vector2(projected.x, projected.y), camera)
+ const targetDistance = point.clone().sub(raycaster.ray.origin).dot(raycaster.ray.direction)
+ if (targetDistance <= tolerance) return false
+ raycaster.near = 0
+ raycaster.far = targetDistance + tolerance
+ const firstHit = raycaster.intersectObjects(visibleMeshes, false)[0]
+ return !firstHit || firstHit.distance >= targetDistance - tolerance
+}
+
+export function resolveTechnicalLineHardwarePaths(
+ annotation: TechnicalLineCaptureAnnotation,
+ meshes: THREE.Mesh[],
+ camera?: THREE.Camera,
+) {
+ const transformByObjectUuid = new Map(
+ annotation.assembledTransforms.map((transform) => [transform.object.uuid, transform] as const),
+ )
+ const finalWorldMatrixCache = new Map()
+ const uniqueHardwareObjects = Array.from(
+ new Map(annotation.hardwareObjects.map((object) => [object.uuid, object] as const)).values(),
+ )
+ const hardwareObjectUuids = new Set(uniqueHardwareObjects.map((object) => object.uuid))
+ const receivingObjects = annotation.assembledTransforms
+ .map((transform) => transform.object)
+ .filter((object) => !hardwareObjectUuids.has(object.uuid))
+ const receivingMeshes = meshes.filter((mesh) => (
+ uniqueHardwareObjects.every((object) => !isDescendantOf(mesh, object))
+ && receivingObjects.some((object) => isDescendantOf(mesh, object))
+ ))
+ const referenceWorldMatrixByReceivingObjectUuid = new Map(
+ receivingObjects.map((object) => [
+ object.uuid,
+ buildFinalWorldMatrix(object, transformByObjectUuid, finalWorldMatrixCache),
+ ] as const),
+ )
+ const referenceWorldMatrixByMeshUuid = new Map(
+ receivingMeshes.flatMap((mesh) => {
+ const receivingObject = receivingObjects.find((object) => isDescendantOf(mesh, object))
+ const referenceReceivingWorld = receivingObject
+ ? referenceWorldMatrixByReceivingObjectUuid.get(receivingObject.uuid)
+ : null
+ if (!receivingObject || !referenceReceivingWorld) return []
+ const meshToReceiving = receivingObject.matrixWorld.clone().invert().multiply(mesh.matrixWorld)
+ return [[mesh.uuid, referenceReceivingWorld.clone().multiply(meshToReceiving)] as const]
+ }),
+ )
+ const worldBounds = getWorldBounds(meshes)
+ const sceneDiagonal = worldBounds.isEmpty()
+ ? 1
+ : Math.max(worldBounds.getSize(new THREE.Vector3()).length(), 1e-4)
+ const rayOffset = sceneDiagonal * 1e-6
+ const minimumPathLength = sceneDiagonal * 1e-4
+ const raycaster = new THREE.Raycaster()
+ raycaster.near = rayOffset
+ raycaster.far = sceneDiagonal * 2
+
+ return uniqueHardwareObjects.flatMap((object): TechnicalLineHardwarePath[] => {
+ if (!transformByObjectUuid.has(object.uuid)) return []
+ const localBounds = getObjectLocalGeometryBounds(object, meshes)
+ if (!localBounds) return []
+ if (camera) {
+ const currentCenter = localBounds.getCenter(new THREE.Vector3())
+ .applyMatrix4(object.matrixWorld)
+ const finalCenter = localBounds.getCenter(new THREE.Vector3()).applyMatrix4(
+ buildFinalWorldMatrix(object, transformByObjectUuid, finalWorldMatrixCache),
+ )
+ const hardwareDiameter = localBounds.getSize(new THREE.Vector3()).length()
+ * object.matrixWorld.getMaxScaleOnAxis()
+ const featureSearchDistance = Math.max(hardwareDiameter * 2.5, minimumPathLength * 10)
+ const currentAxisPairs = createWorldAxisPairs(localBounds, object.matrixWorld)
+ const finalAxisPairs = createWorldAxisPairs(
+ localBounds,
+ buildFinalWorldMatrix(object, transformByObjectUuid, finalWorldMatrixCache),
+ )
+ const nearbyReceivingMeshes = receivingMeshes.filter((mesh) => {
+ if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
+ const referenceWorldMatrix = referenceWorldMatrixByMeshUuid.get(mesh.uuid)
+ if (!mesh.geometry.boundingBox || !referenceWorldMatrix) return false
+ return mesh.geometry.boundingBox.clone().applyMatrix4(referenceWorldMatrix)
+ .distanceToPoint(finalCenter) <= featureSearchDistance
+ })
+ const featureLoops = collectClosedTechnicalLineFeatureLoops(
+ nearbyReceivingMeshes,
+ referenceWorldMatrixByMeshUuid,
+ )
+ const candidateLoops = featureLoops
+ .filter((loop) => loop.diameter <= featureSearchDistance)
+ .map((loop) => ({ loop, distance: loop.referenceCenter.distanceTo(finalCenter) }))
+ .filter(({ distance }) => distance <= featureSearchDistance)
+ .sort((left, right) => left.distance - right.distance)
+
+ if (candidateLoops.length > 0) {
+ const isAlignedWithAxis = (
+ point: THREE.Vector3,
+ pairs: ReturnType,
+ tolerance: number,
+ ) => pairs.some((pair) => {
+ const offset = point.clone().sub(pair.minimum)
+ return offset.addScaledVector(pair.axis, -offset.dot(pair.axis)).length() <= tolerance
+ })
+ const targetLoop = candidateLoops.find(({ loop }) => {
+ const alignmentTolerance = Math.max(loop.diameter * 0.5, minimumPathLength * 10)
+ return currentCenter.distanceTo(loop.center) >= minimumPathLength
+ && isAlignedWithAxis(loop.referenceCenter, finalAxisPairs, alignmentTolerance)
+ && isAlignedWithAxis(loop.center, currentAxisPairs, alignmentTolerance)
+ && isWorldPointVisible(
+ loop.center,
+ camera,
+ meshes,
+ uniqueHardwareObjects,
+ rayOffset,
+ )
+ })?.loop
+ if (!targetLoop) return []
+ const start = currentAxisPairs
+ .flatMap((pair) => [pair.minimum, pair.maximum])
+ .sort((left, right) => (
+ left.distanceToSquared(targetLoop.center) - right.distanceToSquared(targetLoop.center)
+ ))[0]
+ if (start && start.distanceTo(targetLoop.center) >= minimumPathLength) {
+ return [{ object, start, end: targetLoop.center }]
+ }
+ return []
+ }
+ }
+ const candidates = createAxialEndpoints(localBounds, object.matrixWorld).flatMap(
+ ({ start, direction }) => {
+ raycaster.set(start.clone().addScaledVector(direction, rayOffset), direction)
+ const hit = raycaster.intersectObjects(receivingMeshes, false)[0]
+ if (!hit || hit.distance < minimumPathLength) return []
+ return [{
+ start,
+ end: hit.point.clone(),
+ pathLength: hit.distance,
+ }]
+ },
+ )
+ candidates.sort((left, right) => left.pathLength - right.pathLength)
+ const candidate = candidates[0]
+ return candidate ? [{ object, start: candidate.start, end: candidate.end }] : []
+ })
+}
diff --git a/furniture-3d-system/src/lib/3d/technical-line-capture.test.ts b/furniture-3d-system/src/lib/3d/technical-line-capture.test.ts
index 6ae0e0f7..ef83633a 100644
--- a/furniture-3d-system/src/lib/3d/technical-line-capture.test.ts
+++ b/furniture-3d-system/src/lib/3d/technical-line-capture.test.ts
@@ -46,15 +46,73 @@ describe('withTechnicalLineCaptureScene', () => {
test('restores the scene when capture throws', () => {
const { scene, root, mesh, originalMaterial } = createSceneMesh()
+ const receivingMesh = new THREE.Mesh(
+ new THREE.BoxGeometry(1, 4, 4),
+ new THREE.MeshBasicMaterial(),
+ )
+ root.add(receivingMesh)
+ mesh.position.set(2, 0, 0)
+ scene.updateMatrixWorld(true)
expect(() => withTechnicalLineCaptureScene([root], () => {
throw new Error('capture failed')
}, {
sourceCamera: createSourceCamera(),
...viewport,
+ annotation: {
+ activeObjects: [mesh],
+ hardwareObjects: [mesh],
+ assembledTransforms: [
+ { object: mesh, position: [0, 3, 0], rotation: [0, 0, 0, 1] },
+ { object: receivingMesh, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
+ ],
+ },
})).toThrow('capture failed')
expect(mesh.material).toBe(originalMaterial)
expect(scene.children.some((child) => child.name === '__technical_line_capture_edge__')).toBe(false)
+ expect(scene.getObjectByName('__technical_line_capture_active_edge__')).toBeUndefined()
+ expect(scene.getObjectByName('__technical_line_capture_hardware_path__')).toBeUndefined()
+ })
+
+ test('adds red current-part edges and one blue dashed hardware path temporarily', () => {
+ const { scene, root, mesh, originalMaterial } = createSceneMesh()
+ const receivingMesh = new THREE.Mesh(
+ new THREE.BoxGeometry(1, 4, 4),
+ new THREE.MeshBasicMaterial(),
+ )
+ root.add(receivingMesh)
+ mesh.position.set(3, 0, 0)
+ scene.updateMatrixWorld(true)
+
+ withTechnicalLineCaptureScene([root], () => {
+ const activeEdges = scene.getObjectByName('__technical_line_capture_active_edge__')
+ const hardwarePaths = scene.getObjectByName('__technical_line_capture_hardware_path__')
+
+ expect(activeEdges).toBeInstanceOf(THREE.LineSegments)
+ expect((activeEdges as THREE.LineSegments).material).toMatchObject({ depthTest: true })
+ expect(((activeEdges as THREE.LineSegments).material as THREE.LineBasicMaterial).color.getHex())
+ .toBe(0xd3262e)
+ expect(hardwarePaths).toBeInstanceOf(THREE.LineSegments)
+ expect((hardwarePaths as THREE.LineSegments).geometry.getAttribute('position').count).toBe(2)
+ expect((hardwarePaths as THREE.LineSegments).geometry.getAttribute('lineDistance').count).toBe(2)
+ expect(((hardwarePaths as THREE.LineSegments).material as THREE.LineDashedMaterial).color.getHex())
+ .toBe(0x1d63d8)
+ }, {
+ sourceCamera: createSourceCamera(),
+ ...viewport,
+ annotation: {
+ activeObjects: [mesh],
+ hardwareObjects: [mesh],
+ assembledTransforms: [
+ { object: mesh, position: [0, 3, 0], rotation: [0, 0, 0, 1] },
+ { object: receivingMesh, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
+ ],
+ },
+ })
+
+ expect(mesh.material).toBe(originalMaterial)
+ expect(scene.getObjectByName('__technical_line_capture_active_edge__')).toBeUndefined()
+ expect(scene.getObjectByName('__technical_line_capture_hardware_path__')).toBeUndefined()
})
test('rejects scenes without usable visible geometry', () => {
diff --git a/furniture-3d-system/src/lib/3d/technical-line-capture.ts b/furniture-3d-system/src/lib/3d/technical-line-capture.ts
index 198208e8..e07b8743 100644
--- a/furniture-3d-system/src/lib/3d/technical-line-capture.ts
+++ b/furniture-3d-system/src/lib/3d/technical-line-capture.ts
@@ -2,8 +2,15 @@ import * as THREE from 'three'
import { isHighlightHelperObjectName } from './engine-highlight-resources'
import { createTechnicalLineOrthographicCamera } from './technical-line-camera'
import { buildMergedTechnicalLineGeometry } from './technical-line-geometry'
+import {
+ collectTechnicalLineActiveMeshes,
+ resolveTechnicalLineHardwarePaths,
+ type TechnicalLineCaptureAnnotation,
+} from './technical-line-annotations'
const TECHNICAL_LINE_EDGE_NAME = '__technical_line_capture_edge__'
+const TECHNICAL_LINE_ACTIVE_EDGE_NAME = '__technical_line_capture_active_edge__'
+const TECHNICAL_LINE_HARDWARE_PATH_NAME = '__technical_line_capture_hardware_path__'
export type TechnicalLineCaptureStyle = 'clean' | 'detail'
@@ -12,6 +19,7 @@ export interface TechnicalLineCaptureSceneOptions {
viewportWidth: number
viewportHeight: number
style?: TechnicalLineCaptureStyle
+ annotation?: TechnicalLineCaptureAnnotation
}
interface MaterialSnapshot {
@@ -63,6 +71,24 @@ function createOcclusionMaterial() {
})
}
+function createHardwarePathGeometry(
+ paths: ReturnType,
+) {
+ const positions = paths.flatMap(({ start, end }) => [
+ start.x, start.y, start.z,
+ end.x, end.y, end.z,
+ ])
+ const geometry = new THREE.BufferGeometry()
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
+ return geometry
+}
+
+function getSceneDiagonal(meshes: THREE.Mesh[]) {
+ const bounds = new THREE.Box3()
+ meshes.forEach((mesh) => bounds.expandByObject(mesh))
+ return bounds.isEmpty() ? 1 : Math.max(bounds.getSize(new THREE.Vector3()).length(), 1e-4)
+}
+
export function withTechnicalLineCaptureScene(
roots: THREE.Object3D[],
capture: (camera: THREE.Camera) => T,
@@ -110,6 +136,65 @@ export function withTechnicalLineCaptureScene(
lines.frustumCulled = false
lines.matrixAutoUpdate = false
lines.matrix.copy(lineParent.matrixWorld).invert()
+ const annotationObjects: THREE.Object3D[] = []
+ const annotationResources: Array = []
+
+ if (options.annotation) {
+ const activeMeshes = collectTechnicalLineActiveMeshes(
+ meshes,
+ options.annotation.activeObjects,
+ )
+ if (activeMeshes.length > 0) {
+ const activeGeometry = buildMergedTechnicalLineGeometry({
+ meshes: activeMeshes,
+ camera,
+ viewportWidth: options.viewportWidth,
+ viewportHeight: options.viewportHeight,
+ style: options.style ?? 'detail',
+ })
+ if ((activeGeometry.getAttribute('position')?.count ?? 0) > 0) {
+ const activeMaterial = new THREE.LineBasicMaterial({
+ color: 0xd3262e,
+ depthTest: true,
+ depthWrite: false,
+ toneMapped: false,
+ })
+ const activeLines = new THREE.LineSegments(activeGeometry, activeMaterial)
+ activeLines.name = TECHNICAL_LINE_ACTIVE_EDGE_NAME
+ activeLines.renderOrder = 3
+ activeLines.frustumCulled = false
+ activeLines.matrixAutoUpdate = false
+ activeLines.matrix.copy(lineParent.matrixWorld).invert()
+ annotationObjects.push(activeLines)
+ annotationResources.push(activeGeometry, activeMaterial)
+ } else {
+ activeGeometry.dispose()
+ }
+ }
+
+ const hardwarePaths = resolveTechnicalLineHardwarePaths(options.annotation, meshes, camera)
+ if (hardwarePaths.length > 0) {
+ const pathGeometry = createHardwarePathGeometry(hardwarePaths)
+ const sceneDiagonal = getSceneDiagonal(meshes)
+ const pathMaterial = new THREE.LineDashedMaterial({
+ color: 0x1d63d8,
+ dashSize: sceneDiagonal * 0.012,
+ gapSize: sceneDiagonal * 0.008,
+ depthTest: false,
+ depthWrite: false,
+ toneMapped: false,
+ })
+ const pathLines = new THREE.LineSegments(pathGeometry, pathMaterial)
+ pathLines.name = TECHNICAL_LINE_HARDWARE_PATH_NAME
+ pathLines.renderOrder = 4
+ pathLines.frustumCulled = false
+ pathLines.matrixAutoUpdate = false
+ pathLines.matrix.copy(lineParent.matrixWorld).invert()
+ pathLines.computeLineDistances()
+ annotationObjects.push(pathLines)
+ annotationResources.push(pathGeometry, pathMaterial)
+ }
+ }
const materialSnapshots: MaterialSnapshot[] = []
try {
@@ -118,12 +203,15 @@ export function withTechnicalLineCaptureScene(
mesh.material = createOcclusionMaterial()
})
lineParent.add(lines)
+ annotationObjects.forEach((object) => lineParent.add(object))
lineParent.updateMatrixWorld(true)
return capture(camera)
} finally {
lineParent.remove(lines)
+ annotationObjects.forEach((object) => lineParent.remove(object))
geometry.dispose()
lineMaterial.dispose()
+ annotationResources.forEach((resource) => resource.dispose())
materialSnapshots.forEach(({ mesh, material }) => {
if (Array.isArray(mesh.material)) {
mesh.material.forEach((item) => item.dispose())
diff --git a/furniture-3d-system/src/lib/3d/technical-line-feature-loops.ts b/furniture-3d-system/src/lib/3d/technical-line-feature-loops.ts
new file mode 100644
index 00000000..daf53f62
--- /dev/null
+++ b/furniture-3d-system/src/lib/3d/technical-line-feature-loops.ts
@@ -0,0 +1,102 @@
+import * as THREE from 'three'
+
+export interface TechnicalLineFeatureLoop {
+ center: THREE.Vector3
+ referenceCenter: THREE.Vector3
+ diameter: number
+}
+
+export function collectClosedTechnicalLineFeatureLoops(
+ meshes: THREE.Mesh[],
+ referenceWorldMatrixByMeshUuid: Map,
+) {
+ return meshes.flatMap((mesh): TechnicalLineFeatureLoop[] => {
+ if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
+ if (!mesh.geometry.boundingBox) return []
+ const localSize = mesh.geometry.boundingBox.getSize(new THREE.Vector3())
+ const weldTolerance = Math.max(localSize.length() * 1e-5, 1e-6)
+ const edgeGeometry = new THREE.EdgesGeometry(mesh.geometry, 18)
+
+ try {
+ const position = edgeGeometry.getAttribute('position')
+ if (!position || position.count < 6) return []
+ const nodes = new Map }>()
+ const edges = new Set()
+ const getKey = (point: THREE.Vector3) => [
+ Math.round(point.x / weldTolerance),
+ Math.round(point.y / weldTolerance),
+ Math.round(point.z / weldTolerance),
+ ].join(':')
+
+ for (let index = 0; index + 1 < position.count; index += 2) {
+ const start = new THREE.Vector3().fromBufferAttribute(position, index)
+ const end = new THREE.Vector3().fromBufferAttribute(position, index + 1)
+ const startKey = getKey(start)
+ const endKey = getKey(end)
+ if (startKey === endKey) continue
+ const edgeKey = startKey < endKey
+ ? `${startKey}|${endKey}`
+ : `${endKey}|${startKey}`
+ if (edges.has(edgeKey)) continue
+ edges.add(edgeKey)
+ const startNode = nodes.get(startKey) ?? { point: start, neighbors: new Set() }
+ const endNode = nodes.get(endKey) ?? { point: end, neighbors: new Set() }
+ startNode.neighbors.add(endKey)
+ endNode.neighbors.add(startKey)
+ nodes.set(startKey, startNode)
+ nodes.set(endKey, endNode)
+ }
+
+ const visited = new Set()
+ const loops: TechnicalLineFeatureLoop[] = []
+ nodes.forEach((_node, initialKey) => {
+ if (visited.has(initialKey)) return
+ const stack = [initialKey]
+ const componentKeys: string[] = []
+ visited.add(initialKey)
+ while (stack.length > 0) {
+ const key = stack.pop()
+ if (!key) continue
+ componentKeys.push(key)
+ nodes.get(key)?.neighbors.forEach((neighborKey) => {
+ if (visited.has(neighborKey)) return
+ visited.add(neighborKey)
+ stack.push(neighborKey)
+ })
+ }
+ if (
+ componentKeys.length < 3
+ || componentKeys.some((key) => nodes.get(key)?.neighbors.size !== 2)
+ ) return
+
+ const localBounds = new THREE.Box3()
+ componentKeys.forEach((key) => {
+ const point = nodes.get(key)?.point
+ if (point) localBounds.expandByPoint(point)
+ })
+ const sortedSpans = localBounds.getSize(new THREE.Vector3()).toArray()
+ .sort((left, right) => right - left)
+ if ((sortedSpans[1] ?? 0) <= weldTolerance * 4) return
+
+ const localCenter = localBounds.getCenter(new THREE.Vector3())
+ const center = localCenter.clone().applyMatrix4(mesh.matrixWorld)
+ const referenceCenter = localCenter.clone().applyMatrix4(
+ referenceWorldMatrixByMeshUuid.get(mesh.uuid) ?? mesh.matrixWorld,
+ )
+ const worldBounds = new THREE.Box3()
+ componentKeys.forEach((key) => {
+ const point = nodes.get(key)?.point
+ if (point) worldBounds.expandByPoint(point.clone().applyMatrix4(mesh.matrixWorld))
+ })
+ loops.push({
+ center,
+ referenceCenter,
+ diameter: worldBounds.getSize(new THREE.Vector3()).length(),
+ })
+ })
+ return loops
+ } finally {
+ edgeGeometry.dispose()
+ }
+ })
+}
diff --git a/furniture-3d-system/src/lib/installation-demo-share-types.ts b/furniture-3d-system/src/lib/installation-demo-share-types.ts
index 95e4be78..3ab221ab 100644
--- a/furniture-3d-system/src/lib/installation-demo-share-types.ts
+++ b/furniture-3d-system/src/lib/installation-demo-share-types.ts
@@ -5,6 +5,7 @@ import type {
InstallationDrawingImageMode,
} from "@/app/installation-demo-workbench/lib/installationDrawingCaptureTypes";
import type { InstallationDrawingTemplateId } from "@/app/installation-demo-workbench/lib/installationDrawingTemplates";
+import type { InstallationDrawingTextAnnotationsByTarget } from "@/app/installation-demo-workbench/lib/installationDrawingTextAnnotations";
import type {
InstallationDetailView,
InstallationPartItem,
@@ -35,6 +36,7 @@ export interface InstallationDemoShareDrawingDocument {
templateId: InstallationDrawingTemplateId;
imageMode: InstallationDrawingImageMode;
captures: InstallationDrawingCaptures;
+ textAnnotationsByTargetKey: InstallationDrawingTextAnnotationsByTarget;
snapshotLineArtUrls: Record;
toolSnapshotLineArtUrls: Record;
}
diff --git a/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-text-annotation.service.test.ts b/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-text-annotation.service.test.ts
new file mode 100644
index 00000000..4f29bc0e
--- /dev/null
+++ b/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-text-annotation.service.test.ts
@@ -0,0 +1,87 @@
+jest.mock("server-only", () => ({}));
+
+jest.mock("../system-config.service", () => ({
+ systemConfigService: {
+ get: jest.fn(),
+ set: jest.fn(),
+ },
+}));
+
+jest.mock("./installation-demo-access.service", () => ({
+ installationDemoAccessService: {
+ getAccess: jest.fn(),
+ },
+}));
+
+import { systemConfigService } from "../system-config.service";
+import { installationDemoAccessService } from "./installation-demo-access.service";
+import { InstallationDemoDrawingTextAnnotationService } from "./installation-demo-drawing-text-annotation.service";
+
+const user = {
+ id: "user-1",
+ email: "user@example.com",
+ name: "Test user",
+ role: "USER" as const,
+ businessRoles: [],
+ status: "ACTIVE" as const,
+};
+
+describe("InstallationDemoDrawingTextAnnotationService", () => {
+ const mockedAccessService = jest.mocked(installationDemoAccessService);
+ const mockedConfigService = jest.mocked(systemConfigService);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockedAccessService.getAccess.mockResolvedValue({
+ canView: true,
+ canEdit: true,
+ canShare: true,
+ });
+ mockedConfigService.get.mockResolvedValue(null);
+ mockedConfigService.set.mockResolvedValue(undefined);
+ });
+
+ it("returns an empty document before the first annotation is added", async () => {
+ const service = new InstallationDemoDrawingTextAnnotationService();
+ const snapshot = await service.get(user, "drawing-1");
+
+ expect(snapshot.annotationsByTargetKey).toEqual({});
+ });
+
+ it("updates one action without removing annotations from other actions", async () => {
+ mockedConfigService.get.mockResolvedValue({
+ version: 1,
+ drawingId: "drawing-1",
+ annotationsByTargetKey: {
+ "action:step-1:fixed": [
+ { id: "existing", text: "先固定底板", x: 0.3, y: 0.7, fontSize: 12 },
+ ],
+ },
+ savedAt: "2026-08-17T00:00:00.000Z",
+ });
+ const service = new InstallationDemoDrawingTextAnnotationService();
+ const snapshot = await service.updateTarget(user, {
+ drawingId: "drawing-1",
+ targetKey: "action:step-2:action-1",
+ annotations: [
+ { id: "text-2", text: "使用螺丝刀锁紧", x: 0.55, y: 0.25, fontSize: 16 },
+ ],
+ });
+
+ expect(snapshot.annotationsByTargetKey).toEqual({
+ "action:step-1:fixed": [
+ { id: "existing", text: "先固定底板", x: 0.3, y: 0.7, fontSize: 12 },
+ ],
+ "action:step-2:action-1": [
+ { id: "text-2", text: "使用螺丝刀锁紧", x: 0.55, y: 0.25, fontSize: 16 },
+ ],
+ });
+ expect(mockedConfigService.set).toHaveBeenCalledWith(
+ "installation-demo-workbench:drawing-text-annotations:drawing-1",
+ expect.objectContaining({
+ annotationsByTargetKey: snapshot.annotationsByTargetKey,
+ }),
+ expect.any(String),
+ );
+ });
+});
diff --git a/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-text-annotation.service.ts b/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-text-annotation.service.ts
new file mode 100644
index 00000000..ebd98240
--- /dev/null
+++ b/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-text-annotation.service.ts
@@ -0,0 +1,143 @@
+import "server-only";
+
+import { AppError, ErrorCode } from "@/server/core/errors";
+import type { AuthUser } from "@/server/core/types";
+import { systemConfigService } from "../system-config.service";
+import { installationDemoAccessService } from "./installation-demo-access.service";
+import {
+ MAX_INSTALLATION_DRAWING_TEXT_ANNOTATIONS_PER_TARGET,
+ sanitizeInstallationDrawingTextAnnotations,
+ type InstallationDrawingTextAnnotation,
+ type InstallationDrawingTextAnnotationsByTarget,
+} from "@/app/installation-demo-workbench/lib/installationDrawingTextAnnotations";
+
+const CONFIG_PREFIX = "installation-demo-workbench:drawing-text-annotations:";
+const VERSION = 1;
+const MAX_TARGET_COUNT = 400;
+
+export interface InstallationDemoDrawingTextAnnotationsDTO {
+ version: number;
+ drawingId: string;
+ annotationsByTargetKey: InstallationDrawingTextAnnotationsByTarget;
+ savedAt: string;
+}
+
+function buildConfigKey(drawingId: string) {
+ return `${CONFIG_PREFIX}${drawingId}`;
+}
+
+function normalizeDrawingId(value: unknown) {
+ const drawingId = typeof value === "string" ? value.trim() : "";
+ if (!drawingId) {
+ throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, "缺少图纸 ID");
+ }
+ if (drawingId.length > 240) {
+ throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, "图纸 ID 过长");
+ }
+ return drawingId;
+}
+
+function normalizeTargetKey(value: unknown) {
+ const targetKey = typeof value === "string" ? value.trim() : "";
+ if (!targetKey) {
+ throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, "缺少文字标注目标");
+ }
+ if (targetKey.length > 300) {
+ throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, "文字标注目标过长");
+ }
+ return targetKey;
+}
+
+function normalizeMap(value: unknown) {
+ const map = sanitizeInstallationDrawingTextAnnotations(value);
+ if (Object.keys(map).length > MAX_TARGET_COUNT) {
+ throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, "文字标注目标数量超出上限");
+ }
+ for (const [targetKey, annotations] of Object.entries(map)) {
+ if (targetKey.length > 300 || annotations.length > MAX_INSTALLATION_DRAWING_TEXT_ANNOTATIONS_PER_TARGET) {
+ throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, "文字标注数量或目标无效");
+ }
+ }
+ return map;
+}
+
+function normalizeStored(value: unknown, drawingId: string): InstallationDemoDrawingTextAnnotationsDTO | null {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
+ const record = value as Record;
+ if (record.version !== VERSION) return null;
+ const storedAnnotations = sanitizeInstallationDrawingTextAnnotations(
+ record.annotationsByTargetKey,
+ );
+ return {
+ version: VERSION,
+ drawingId,
+ annotationsByTargetKey: Object.fromEntries(
+ Object.entries(storedAnnotations).slice(0, MAX_TARGET_COUNT),
+ ),
+ savedAt: typeof record.savedAt === "string" ? record.savedAt : new Date(0).toISOString(),
+ };
+}
+
+async function assertCanView(user: AuthUser) {
+ if (!(await installationDemoAccessService.getAccess(user)).canView) {
+ throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, "没有权限查看安装图纸文字标注");
+ }
+}
+
+async function assertCanEdit(user: AuthUser) {
+ if (!(await installationDemoAccessService.getAccess(user)).canEdit) {
+ throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, "没有权限保存安装图纸文字标注");
+ }
+}
+
+export class InstallationDemoDrawingTextAnnotationService {
+ private async read(drawingId: string) {
+ return normalizeStored(
+ await systemConfigService.get(buildConfigKey(drawingId)),
+ drawingId,
+ );
+ }
+
+ async get(user: AuthUser, rawDrawingId: unknown) {
+ await assertCanView(user);
+ const drawingId = normalizeDrawingId(rawDrawingId);
+ return (await this.read(drawingId)) ?? {
+ version: VERSION,
+ drawingId,
+ annotationsByTargetKey: {},
+ savedAt: new Date(0).toISOString(),
+ };
+ }
+
+ async updateTarget(
+ user: AuthUser,
+ input: { drawingId: unknown; targetKey: unknown; annotations: unknown },
+ ) {
+ await assertCanEdit(user);
+ const drawingId = normalizeDrawingId(input.drawingId);
+ const targetKey = normalizeTargetKey(input.targetKey);
+ const targetMap = normalizeMap({ [targetKey]: input.annotations });
+ const current = await this.read(drawingId);
+ const annotationsByTargetKey = { ...(current?.annotationsByTargetKey ?? {}) };
+ const nextAnnotations = targetMap[targetKey] ?? [];
+ if (nextAnnotations.length === 0) delete annotationsByTargetKey[targetKey];
+ else annotationsByTargetKey[targetKey] = nextAnnotations;
+ const next: InstallationDemoDrawingTextAnnotationsDTO = {
+ version: VERSION,
+ drawingId,
+ annotationsByTargetKey: normalizeMap(annotationsByTargetKey),
+ savedAt: new Date().toISOString(),
+ };
+ await systemConfigService.set(
+ buildConfigKey(drawingId),
+ next,
+ `Installation drawing text annotations for ${drawingId}`,
+ );
+ return next;
+ }
+}
+
+export const installationDemoDrawingTextAnnotationService =
+ new InstallationDemoDrawingTextAnnotationService();
+
+export type { InstallationDrawingTextAnnotation };
diff --git a/furniture-3d-system/src/server/services/installation-demo/installation-demo-share.service.test.ts b/furniture-3d-system/src/server/services/installation-demo/installation-demo-share.service.test.ts
index 65236380..130c1f3c 100644
--- a/furniture-3d-system/src/server/services/installation-demo/installation-demo-share.service.test.ts
+++ b/furniture-3d-system/src/server/services/installation-demo/installation-demo-share.service.test.ts
@@ -65,6 +65,17 @@ function createInput() {
actions: [],
inventoryTransforms: {},
},
+ textAnnotationsByTargetKey: {
+ "action:step-1:fixed": [
+ {
+ id: "text-1",
+ text: "锁紧四颗螺丝",
+ x: 0.42,
+ y: 0.76,
+ fontSize: 14,
+ },
+ ],
+ },
snapshotLineArtUrls: {
part: "/api/storage/object?key=snapshots%2Fpart-line.png",
},
@@ -118,6 +129,11 @@ describe("InstallationDemoShareService", () => {
expect(snapshot.drawingDocument).toMatchObject({
templateId: "set-1",
imageMode: "original",
+ textAnnotationsByTargetKey: {
+ "action:step-1:fixed": [
+ expect.objectContaining({ text: "锁紧四颗螺丝", fontSize: 14 }),
+ ],
+ },
snapshotLineArtUrls: {
part: "/api/storage/object?key=snapshots%2Fpart-line.png",
},
diff --git a/furniture-3d-system/src/server/services/installation-demo/installation-demo-share.service.ts b/furniture-3d-system/src/server/services/installation-demo/installation-demo-share.service.ts
index 6eef23ff..b9e88024 100644
--- a/furniture-3d-system/src/server/services/installation-demo/installation-demo-share.service.ts
+++ b/furniture-3d-system/src/server/services/installation-demo/installation-demo-share.service.ts
@@ -12,6 +12,7 @@ import type {
InstallationDemoShareSnapshot,
} from "@/lib/installation-demo-share-types";
import type { InstallationStepMaterialShareSnapshot } from "@/app/installation-demo-workbench/lib/installationStepMaterialShareSnapshots";
+import { sanitizeInstallationDrawingTextAnnotations } from "@/app/installation-demo-workbench/lib/installationDrawingTextAnnotations";
import { systemConfigService } from "../system-config.service";
import { installationDemoAccessService } from "./installation-demo-access.service";
@@ -205,6 +206,10 @@ function normalizeDrawingDocument(
imageMode: normalizeDrawingImageMode(value.imageMode),
captures:
value.captures as unknown as InstallationDemoShareDrawingDocument["captures"],
+ textAnnotationsByTargetKey:
+ sanitizeInstallationDrawingTextAnnotations(
+ value.textAnnotationsByTargetKey,
+ ),
snapshotLineArtUrls: normalizeStringRecord(
value.snapshotLineArtUrls,
"部件线稿快照",
From 5441d1786d2cf4d2d1bc27e80a7d2ce3dfedafd7 Mon Sep 17 00:00:00 2001
From: jingchuan
Date: Mon, 17 Aug 2026 18:36:57 +0800
Subject: [PATCH 6/7] feat: add executive overview to supply chain new products
---
.../intake/new-product-management/page.tsx | 38 +-------------
.../components/supply-chain-tabs.tsx | 1 +
.../config/supply-chain-menu.test.ts | 5 ++
.../supply-chain/config/supply-chain-menu.ts | 5 ++
.../new-products/overview/page.tsx | 49 +++++++++++++++++++
.../SupplyChainPermissionWorkspace.tsx | 1 +
.../src/components/layout/AppTopNav.tsx | 6 +--
.../supply-chain-permission-settings.test.ts | 3 ++
.../lib/supply-chain-permission-settings.ts | 4 ++
9 files changed, 73 insertions(+), 39 deletions(-)
create mode 100644 furniture-3d-system/src/app/supply-chain/new-products/overview/page.tsx
diff --git a/furniture-3d-system/src/app/intake/new-product-management/page.tsx b/furniture-3d-system/src/app/intake/new-product-management/page.tsx
index e431bb65..ced4b8c2 100644
--- a/furniture-3d-system/src/app/intake/new-product-management/page.tsx
+++ b/furniture-3d-system/src/app/intake/new-product-management/page.tsx
@@ -1,39 +1,5 @@
-import { requireSession } from "@/lib/server-session";
-import { OA_BOARD_DEFAULT_TRACKED_STATUSES } from "@/server/services/oa-board.constants";
-import {
- createEmptyOaBoardDashboard,
- oaBoardService,
-} from "@/server/services/oa-board.service";
-import NewProductManagementDashboard from "./NewProductManagementDashboard";
-
-export const dynamic = "force-dynamic";
+import { redirect } from 'next/navigation'
export default async function NewProductManagementPage() {
- await requireSession("/login");
-
- const result = await oaBoardService
- .getDashboard({
- overallStatus: [...OA_BOARD_DEFAULT_TRACKED_STATUSES],
- })
- .then((dashboard) => ({
- dashboard,
- loadErrorMessage: null as string | null,
- }))
- .catch((error) => {
- console.error("Failed to load new product management dashboard", error);
- return {
- dashboard: createEmptyOaBoardDashboard(),
- loadErrorMessage:
- error instanceof Error
- ? `新品管理数据读取失败:${error.message}`
- : "新品管理数据读取失败,请稍后重试。",
- };
- });
-
- return (
-
- );
+ redirect('/supply-chain/new-products/overview')
}
diff --git a/furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx b/furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx
index 8f5f1a37..587580a3 100644
--- a/furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx
+++ b/furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx
@@ -90,6 +90,7 @@ export const SUPPLY_CHAIN_ROUTE_TITLES: Record = {
'/supply-chain/database/product-archive': '产品全量档案',
'/supply-chain/database/category-config': '类目配置',
'/supply-chain/database/warehouse-config': '仓库配置',
+ '/supply-chain/new-products/overview': '新品经营总览',
'/supply-chain/new-products/unified-table': '新品开发进度总表',
'/supply-chain/new-products/sample-progress': '打样进度明细',
'/supply-chain/purchase/daily-affairs': '日常事项',
diff --git a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts
index c5c4266f..77f099b6 100644
--- a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts
+++ b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts
@@ -6,6 +6,11 @@ describe('supply chain menu', () => {
expect(items).toEqual(
expect.arrayContaining([
+ expect.objectContaining({
+ label: '新品经营总览',
+ module: '新品管理',
+ route: '/supply-chain/new-products/overview',
+ }),
expect.objectContaining({
label: '新品开发进度总表',
module: '新品管理',
diff --git a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts
index e2fdf5b2..c2b4157a 100644
--- a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts
+++ b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts
@@ -54,6 +54,11 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [
label: '新品管理',
icon: 'PackagePlus',
sections: [
+ {
+ items: [
+ { label: '新品经营总览', route: '/supply-chain/new-products/overview', targetKey: 'newProductOverview' },
+ ],
+ },
{
title: '公共数据源',
items: [
diff --git a/furniture-3d-system/src/app/supply-chain/new-products/overview/page.tsx b/furniture-3d-system/src/app/supply-chain/new-products/overview/page.tsx
new file mode 100644
index 00000000..39845d5c
--- /dev/null
+++ b/furniture-3d-system/src/app/supply-chain/new-products/overview/page.tsx
@@ -0,0 +1,49 @@
+import { redirect } from 'next/navigation'
+import NewProductManagementDashboard from '@/app/intake/new-product-management/NewProductManagementDashboard'
+import { requireSession } from '@/lib/server-session'
+import { OA_BOARD_DEFAULT_TRACKED_STATUSES } from '@/server/services/oa-board.constants'
+import {
+ createEmptyOaBoardDashboard,
+ oaBoardService,
+} from '@/server/services/oa-board.service'
+import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
+
+export const dynamic = 'force-dynamic'
+export const metadata = { title: '新品经营总览 · 供应链中心' }
+
+export default async function NewProductOverviewPage() {
+ const { authUser } = await requireSession('/login')
+ const access = await supplyChainPermissionSettingsService.getUserAccess(
+ authUser,
+ 'newProductOverview',
+ )
+ if (!access.view) redirect('/supply-chain')
+
+ const result = await oaBoardService
+ .getDashboard({
+ overallStatus: [...OA_BOARD_DEFAULT_TRACKED_STATUSES],
+ })
+ .then((dashboard) => ({
+ dashboard,
+ loadErrorMessage: null as string | null,
+ }))
+ .catch((error) => {
+ console.error('Failed to load supply chain new product overview', error)
+ return {
+ dashboard: createEmptyOaBoardDashboard(),
+ loadErrorMessage:
+ error instanceof Error
+ ? `新品管理数据读取失败:${error.message}`
+ : '新品管理数据读取失败,请稍后重试。',
+ }
+ })
+
+ return (
+
+
+
+ )
+}
diff --git a/furniture-3d-system/src/app/users/components/SupplyChainPermissionWorkspace.tsx b/furniture-3d-system/src/app/users/components/SupplyChainPermissionWorkspace.tsx
index 0c21195c..9e957d9f 100644
--- a/furniture-3d-system/src/app/users/components/SupplyChainPermissionWorkspace.tsx
+++ b/furniture-3d-system/src/app/users/components/SupplyChainPermissionWorkspace.tsx
@@ -22,6 +22,7 @@ const TARGET_ICONS: Record {
})
it('maps new product pages to their permission nodes', () => {
+ expect(
+ getSupplyChainPermissionTargetForPath('/supply-chain/new-products/overview'),
+ ).toBe('newProductOverview')
expect(
getSupplyChainPermissionTargetForPath('/supply-chain/new-products/unified-table'),
).toBe('newProductUnifiedTable')
diff --git a/furniture-3d-system/src/lib/supply-chain-permission-settings.ts b/furniture-3d-system/src/lib/supply-chain-permission-settings.ts
index fac5c1db..5a8b0a12 100644
--- a/furniture-3d-system/src/lib/supply-chain-permission-settings.ts
+++ b/furniture-3d-system/src/lib/supply-chain-permission-settings.ts
@@ -19,6 +19,7 @@ export type SupplyChainPermissionTargetKey =
| 'productArchive'
| 'categoryConfig'
| 'warehouseConfig'
+ | 'newProductOverview'
| 'newProductUnifiedTable'
| 'newProductSampleProgress'
| 'purchaseDailyAffairs'
@@ -67,6 +68,7 @@ export const SUPPLY_CHAIN_PERMISSION_TARGETS: SupplyChainPermissionTarget[] = [
{ key: 'productArchive', label: '产品全量档案', description: '商品、供应链和运营档案' },
{ key: 'categoryConfig', label: '类目配置', description: '产品全量档案类目维护' },
{ key: 'warehouseConfig', label: '仓库配置', description: '出入库仓库维护' },
+ { key: 'newProductOverview', label: '新品经营总览', description: '新品经营指标和阶段时效分析' },
{ key: 'newProductUnifiedTable', label: '新品开发进度总表', description: '新品流程公共数据源' },
{ key: 'newProductSampleProgress', label: '打样进度明细', description: '新品打样进度公共数据源' },
{ key: 'purchaseDailyAffairs', label: '日常事项', description: '采购日常协作' },
@@ -85,6 +87,7 @@ export const SUPPLY_CHAIN_PERMISSION_TARGET_ROUTES: Partial<
productArchive: '/supply-chain/database/product-archive',
categoryConfig: '/supply-chain/database/category-config',
warehouseConfig: '/supply-chain/database/warehouse-config',
+ newProductOverview: '/supply-chain/new-products/overview',
newProductUnifiedTable: '/supply-chain/new-products/unified-table',
newProductSampleProgress: '/supply-chain/new-products/sample-progress',
purchaseDailyAffairs: '/supply-chain/purchase/daily-affairs',
@@ -238,6 +241,7 @@ export function getSupplyChainPermissionTargetForPath(pathname: string) {
if (pathname === '/supply-chain/database/product-archive') return 'productArchive' as const
if (pathname === '/supply-chain/database/category-config') return 'categoryConfig' as const
if (pathname === '/supply-chain/database/warehouse-config') return 'warehouseConfig' as const
+ if (pathname === '/supply-chain/new-products/overview') return 'newProductOverview' as const
if (pathname === '/supply-chain/new-products/unified-table') return 'newProductUnifiedTable' as const
if (pathname === '/supply-chain/new-products/sample-progress') return 'newProductSampleProgress' as const
if (pathname === '/supply-chain/purchase/daily-affairs') return 'purchaseDailyAffairs' as const
From 5ac98a6b82349bf85ed4d7c52f0d0ea9ad8533c7 Mon Sep 17 00:00:00 2001
From: jingchuan
Date: Mon, 17 Aug 2026 18:46:47 +0800
Subject: [PATCH 7/7] fix(supply-chain): show product overview in sidebar
---
.../server/services/supply-chain-permission-settings.service.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/furniture-3d-system/src/server/services/supply-chain-permission-settings.service.ts b/furniture-3d-system/src/server/services/supply-chain-permission-settings.service.ts
index 5f970236..87fe1a92 100644
--- a/furniture-3d-system/src/server/services/supply-chain-permission-settings.service.ts
+++ b/furniture-3d-system/src/server/services/supply-chain-permission-settings.service.ts
@@ -102,6 +102,7 @@ class SupplyChainPermissionSettingsService {
'productArchive',
'categoryConfig',
'warehouseConfig',
+ 'newProductOverview',
'newProductUnifiedTable',
'newProductSampleProgress',
'purchaseDailyAffairs',
@@ -119,6 +120,7 @@ class SupplyChainPermissionSettingsService {
'productArchive',
'categoryConfig',
'warehouseConfig',
+ 'newProductOverview',
'newProductUnifiedTable',
'newProductSampleProgress',
'purchaseDailyAffairs',