From ab0d57bd251f0fbfb939217f80db19ebc10a505f Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 30 May 2026 13:23:47 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E8=B5=84=E4=BA=A7=E7=9B=98?= =?UTF-8?q?=E7=82=B9=E4=B8=8E=E7=A7=BB=E5=8A=A8=E6=89=AB=E7=A0=81=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=EF=BC=8805-30-asset-inventory-mobile-redesign?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../prd.md | 8 +- .../task.json | 2 +- client/src/lib/actionConfirm.ts | 7 + client/src/pages/AssetRegistryPage.tsx | 261 +++++++++++++----- client/src/pages/InventoryChecksPage.tsx | 56 +++- client/src/pages/MobileAssetScanPage.tsx | 63 ++++- client/tests/e2e/assets-flow.spec.ts | 8 +- 7 files changed, 321 insertions(+), 84 deletions(-) diff --git a/.trellis/tasks/05-30-asset-inventory-mobile-redesign/prd.md b/.trellis/tasks/05-30-asset-inventory-mobile-redesign/prd.md index 236c1768..a96a7fe6 100644 --- a/.trellis/tasks/05-30-asset-inventory-mobile-redesign/prd.md +++ b/.trellis/tasks/05-30-asset-inventory-mobile-redesign/prd.md @@ -32,10 +32,10 @@ ## Acceptance Criteria -- [ ] 资产台账不把分类维护、盘点任务、资产流转和 ERP 同步混成一组首屏按钮。 -- [ ] 资产详情能说明责任链、位置、状态、来源和下一步。 -- [ ] 盘点任务和移动扫码动作清楚、单一、可确认。 -- [ ] 高敏电子资产只展示脱敏索引和责任摘要。 +- [x] 资产台账不把分类维护、盘点任务、资产流转和 ERP 同步混成一组首屏按钮。 +- [x] 资产详情能说明责任链、位置、状态、来源和下一步。 +- [x] 盘点任务和移动扫码动作清楚、单一、可确认。 +- [x] 高敏电子资产只展示脱敏索引和责任摘要。 ## Test Plan diff --git a/.trellis/tasks/05-30-asset-inventory-mobile-redesign/task.json b/.trellis/tasks/05-30-asset-inventory-mobile-redesign/task.json index b36203cc..276cdbe7 100644 --- a/.trellis/tasks/05-30-asset-inventory-mobile-redesign/task.json +++ b/.trellis/tasks/05-30-asset-inventory-mobile-redesign/task.json @@ -3,7 +3,7 @@ "name": "asset-inventory-mobile-redesign", "title": "资产盘点与移动扫码页面重构", "description": "统一资产台账、盘点任务和移动扫码页面:固定资产责任链、二维码/序列号兜底、流转和处置动作归位。", - "status": "planning", + "status": "in_progress", "dev_type": null, "scope": "frontend-page-redesign", "package": null, diff --git a/client/src/lib/actionConfirm.ts b/client/src/lib/actionConfirm.ts index 2f5fd794..a2930312 100644 --- a/client/src/lib/actionConfirm.ts +++ b/client/src/lib/actionConfirm.ts @@ -10,6 +10,7 @@ export type ActionConfirmKind = | "disable" | "batchApprove" | "batchReject" + | "complete" | "financeReview" | "financePayment" | "duplicateOverride" @@ -94,6 +95,12 @@ export function getActionConfirmCopy(kind: ActionConfirmKind, context: ActionCon confirmLabel: "确认批量驳回", variant: "destructive", }, + complete: { + title: withTarget("确认完成", targetName), + description: detail ?? "确认后该任务将进入完成状态,后续不能继续作为当前任务处理。", + confirmLabel: "确认完成", + variant: "default", + }, financeReview: { title: withTarget("确认财务复核", targetName), description: detail ?? "确认后将更新财务复核结果,并写入财务处理记录。", diff --git a/client/src/pages/AssetRegistryPage.tsx b/client/src/pages/AssetRegistryPage.tsx index 5996bdca..567315d5 100644 --- a/client/src/pages/AssetRegistryPage.tsx +++ b/client/src/pages/AssetRegistryPage.tsx @@ -20,6 +20,7 @@ import { type AssetCategoryItem, type AssetErpStage, type AssetErpTrack, + type AssetItem, type AssetSensitivityLevel, type AssetStatus, type AssetType, @@ -136,10 +137,6 @@ function sensitivityOptions(): Array<{ value: AssetSensitivityLevel | ""; label: ]; } -function sensitivityLabel(value: AssetSensitivityLevel | string | null | undefined) { - return sensitivityOptions().find((item) => item.value === value)?.label ?? "未分级"; -} - function typeHint(assetType: AssetType) { switch (assetType) { case "electronic_document": @@ -155,6 +152,25 @@ function typeHint(assetType: AssetType) { } } +function isElectronicAssetType(assetType: AssetType) { + return assetType !== "fixed_asset"; +} + +function assetNextStep(item: Pick) { + if (item.status === "archived") return "已归档留存"; + if (item.status === "retired") return "核对处置记录"; + if (item.status === "under_repair") return "跟进维修结果"; + if (item.assetType === "fixed_asset") { + if (!item.assignee) return "待领用或确认保管"; + if (!item.lastVerifiedAt) return "待首次盘点"; + return "按季度盘点"; + } + if (!item.owner) return "补负责人"; + if (!item.lastVerifiedAt) return "待验证责任/备份"; + if (item.expiresAt && item.expiresAt < new Date().toISOString().slice(0, 10)) return "检查到期续费"; + return "按期验证权限"; +} + function CompactAssetNo({ value, copyable = true }: { value: string; copyable?: boolean }) { const displayValue = formatCompactIdentifier(value, { head: 14, tail: 6, minLength: 24 }); const compacted = isCompactedIdentifier(value, displayValue); @@ -270,6 +286,7 @@ export function AssetRegistryPage() { const [assigneeFilter, setAssigneeFilter] = useState(""); const [assetSearch, setAssetSearch] = useState(""); const [mine, setMine] = useState(!canViewAll); + const [showLedgerTools, setShowLedgerTools] = useState(false); const [selectedAssetId, setSelectedAssetId] = useState(searchParams.get("id") ?? ""); const [isCreatingAsset, setIsCreatingAsset] = useState(false); @@ -780,6 +797,35 @@ export function AssetRegistryPage() { const categoryItems = categoriesQuery.data?.items ?? []; const assetItems = assetsQuery.data?.items ?? []; + const orderedAssetItems = useMemo(() => { + return [...assetItems].sort((a, b) => { + const score = (item: AssetItem) => { + let value = 0; + if (item.status === "under_repair") value += 50; + if (item.assetType === "fixed_asset" && !item.assignee) value += 35; + if (!item.lastVerifiedAt) value += 25; + if (item.status === "in_use") value += 10; + return value; + }; + const scoreDelta = score(b) - score(a); + if (scoreDelta !== 0) return scoreDelta; + return (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""); + }); + }, [assetItems]); + const assetStats = useMemo(() => { + return assetItems.reduce( + (acc, item) => { + acc.total += 1; + if (item.assetType === "fixed_asset") acc.fixedAsset += 1; + else acc.electronicAsset += 1; + if (item.assetType === "fixed_asset" && !item.assignee) acc.unassigned += 1; + if (!item.lastVerifiedAt) acc.needsVerification += 1; + if ((item.assetType === "fixed_asset" && !item.assignee) || !item.lastVerifiedAt) acc.attention += 1; + return acc; + }, + { total: 0, fixedAsset: 0, electronicAsset: 0, unassigned: 0, needsVerification: 0, attention: 0 }, + ); + }, [assetItems]); const selectAsset = (assetId: string) => { setIsCreatingAsset(false); setSelectedAssetId(assetId); @@ -789,23 +835,55 @@ export function AssetRegistryPage() { - {canManage ? ( - - ) : null} + } /> -
-
+
+
+
当前可见
+
{assetStats.total}
+
{mine ? "默认我相关资产" : "全范围查看"}
+
+
+
固定资产 FA
+
{assetStats.fixedAsset}
+
责任、位置、盘点和交接
+
+
+
电子资产索引
+
{assetStats.electronicAsset}
+
只登记责任和验证摘要
+
+
+
待补责任/验证
+
{assetStats.attention}
+
优先补齐保管和最近盘点
+
+
+ +
+ {showLedgerTools ? ( +
+
+
台账工具
+

新建资产卡片、分类维护、全范围与高级筛选放在这里,普通首屏只保留查找和当前资产处理。

+ {canManage ? ( + + ) : null} +
+

资产分类

{canManage ? ( @@ -846,6 +924,57 @@ export function AssetRegistryPage() { ) : null}
+
+
+

高级筛选

+

低频分类、组织、责任人和敏感级别筛选放在工具区。

+
+
+ + + + + +
+
+ {categoriesQuery.isLoading ? : null} {categoriesQuery.isError ? (

{resolveError(categoriesQuery.error)}

@@ -909,10 +1038,11 @@ export function AssetRegistryPage() { ) : null}
+ ) : null} -
+
-
+
))} - - - - - {canViewAll ? (
{item.assignee?.name ?? "-"}
+
+
位置
+
{item.location ?? "-"}
+
+
+
下一步
+
{assetNextStep(item)}
+
)) @@ -1045,15 +1141,15 @@ export function AssetRegistryPage() { 资产 类型 - 分类 - 敏感级别 + 责任链 + 位置 状态 - 负责人 + 下一步 - {assetItems.length ? ( - assetItems.map((item) => ( + {orderedAssetItems.length ? ( + orderedAssetItems.map((item) => ( { selectAsset(item.id); }} className={item.id === selectedAssetId && !isCreatingAsset ? "bg-zinc-50" : "cursor-pointer"}> @@ -1066,12 +1162,16 @@ export function AssetRegistryPage() {
{assetTypeLabel(item.assetType)} - - {formatTextWithCompactLongNumbers(item.category?.name ?? "-")} + +
{item.owner?.name ?? item.assignee?.name ?? "-"}
+
保管 {item.keeper?.name ?? "-"}
+
+ +
{formatTextWithCompactLongNumbers(item.location ?? "-")}
+
盘点 {item.lastVerifiedAt ? formatDisplayDateTime(item.lastVerifiedAt) : "未登记"}
- {sensitivityLabel(item.sensitivityLevel)} - {item.owner?.name ?? item.assignee?.name ?? "-"} + {assetNextStep(item)} )) ) : ( @@ -1124,6 +1224,27 @@ export function AssetRegistryPage() {

{assetFormError}

) : null} + {!isCreatingAsset && currentAsset ? ( +
+
+
责任人
+
{currentAsset.owner?.name ?? currentAsset.assignee?.name ?? "未指定"}
+
+
+
保管/位置
+
{currentAsset.keeper?.name ?? currentAsset.location ?? "待确认"}
+
+
+
{isElectronicAssetType(currentAsset.assetType) ? "最近验证" : "最近盘点"}
+
{currentAsset.lastVerifiedAt ? formatDisplayDateTime(currentAsset.lastVerifiedAt) : "未登记"}
+
+
+
下一步
+
{assetNextStep(currentAsset)}
+
+
+ ) : null} +
@@ -1292,6 +1413,7 @@ export function AssetRegistryPage() {
+ {isElectronicAssetType(assetForm.assetType) ? (

电子资产安全字段

@@ -1325,10 +1447,11 @@ export function AssetRegistryPage() {
+ ) : null}
- {currentAsset && canTransfer && !isCreatingAsset ? ( + {currentAsset && canTransfer && !isCreatingAsset && currentAsset.assetType === "fixed_asset" ? (
diff --git a/client/src/pages/InventoryChecksPage.tsx b/client/src/pages/InventoryChecksPage.tsx index 255a8fd5..24d1b818 100644 --- a/client/src/pages/InventoryChecksPage.tsx +++ b/client/src/pages/InventoryChecksPage.tsx @@ -15,8 +15,10 @@ import { } from "../api/inventoryChecks"; import { fetchCostCenters, fetchLegalEntities } from "../api/masterData"; import { isApiError } from "../api/types"; +import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog"; import { PageHeader } from "../components/common/PageHeader"; import { TableSkeleton } from "../components/common/TableSkeleton"; +import { PageScaffold } from "../components/patterns/PageScaffold"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; @@ -84,6 +86,7 @@ export function InventoryChecksPage() { const [recordAssetId, setRecordAssetId] = useState(""); const [recordResult, setRecordResult] = useState("matched"); const [recordNote, setRecordNote] = useState(""); + const [confirmCompleteCheck, setConfirmCompleteCheck] = useState(false); const inventoryChecksQuery = useQuery({ queryKey: ["inventory-checks"], @@ -122,6 +125,19 @@ export function InventoryChecksPage() { () => (inventoryChecksQuery.data?.items ?? []).find((item) => item.id === selectedCheckId) ?? null, [inventoryChecksQuery.data?.items, selectedCheckId], ); + const inventoryStats = useMemo(() => { + const items = inventoryChecksQuery.data?.items ?? []; + return items.reduce( + (acc, item) => { + acc.total += 1; + if (item.status === "draft") acc.draft += 1; + if (item.status === "counting") acc.counting += 1; + if (item.status === "completed") acc.completed += 1; + return acc; + }, + { total: 0, draft: 0, counting: 0, completed: 0 }, + ); + }, [inventoryChecksQuery.data?.items]); const scopedAssets = useMemo(() => { const items = assetsQuery.data?.items ?? []; @@ -228,10 +244,10 @@ export function InventoryChecksPage() { }, [costCentersQuery.data?.items, legalEntitiesQuery.data?.items, scopeType]); return ( -
+ +
+
+
盘点任务
+
{inventoryStats.total}
+
按季度或事件触发
+
+
+
草稿
+
{inventoryStats.draft}
+
待启动后录入
+
+
+
盘点中
+
{inventoryStats.counting}
+
可扫码或手工录入
+
+
+
已完成
+
{inventoryStats.completed}
+
保留差异和备注
+
+
+

新建盘点任务

@@ -347,7 +386,7 @@ export function InventoryChecksPage() { ) : null} {selectedCheck.status === "counting" ? ( - ) : null} @@ -426,6 +465,15 @@ export function InventoryChecksPage() { ) : null}
-
+ completeCheckMutation.mutate()} + /> + ); } diff --git a/client/src/pages/MobileAssetScanPage.tsx b/client/src/pages/MobileAssetScanPage.tsx index 7dc51adf..1d6ab50c 100644 --- a/client/src/pages/MobileAssetScanPage.tsx +++ b/client/src/pages/MobileAssetScanPage.tsx @@ -14,6 +14,7 @@ import { import { isApiError } from "../api/types"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; +import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog"; import { PageHeader } from "../components/common/PageHeader"; import { BarcodeScannerPanel } from "../components/mobile/BarcodeScannerPanel"; import { PageScaffold } from "../components/patterns/PageScaffold"; @@ -82,6 +83,30 @@ function inventoryResultLabel(result: InventoryCheckResult) { } } +function assetTypeLabel(assetType: AssetItem["assetType"]) { + switch (assetType) { + case "fixed_asset": + return "固定资产 FA"; + case "electronic_document": + return "电子资料 EA-DOC"; + case "electronic_system": + return "电子系统 EA-SYS"; + case "code_repository": + return "代码仓库 EA-REPO"; + case "backup_media": + return "备份介质 EA-BKP"; + default: + return "资产"; + } +} + +function inventoryScopeLabel(inventoryCheck: InventoryCheck) { + if (inventoryCheck.scopeType === "all") return "全量盘点"; + if (inventoryCheck.scopeType === "legal_entity") return "法体盘点"; + if (inventoryCheck.scopeType === "cost_center") return "成本中心盘点"; + return "位置盘点"; +} + function formatDateTime(value: string) { try { return new Date(value).toLocaleString("zh-CN"); @@ -123,6 +148,7 @@ export function MobileAssetScanPage() { const [error, setError] = useState(""); const [recordResult, setRecordResult] = useState("matched"); const [recordNote, setRecordNote] = useState(""); + const [confirmRecordScan, setConfirmRecordScan] = useState(false); const inventoryChecksQuery = useQuery({ enabled: isInventoryMode && canRecordInventory, @@ -388,20 +414,35 @@ export function MobileAssetScanPage() { ) : ( )}
); return ( - + +
+
+
+

{isInventoryMode ? "先扫码,再确认盘点" : "扫码查资产"}

+

二维码不可用时,可输入资产编号、序列号或名称兜底。

+
+ {isOnline ? "在线" : "离线"} +
+
+
模式:{isInventoryMode ? "盘点录入" : "资产查看"}
+
确认:先看摘要,再提交
+
范围:{selectedInventoryCheck ? inventoryScopeLabel(selectedInventoryCheck) : "按当前权限"}
+
+
+ {error ? (

{error}

) : null} @@ -440,7 +481,7 @@ export function MobileAssetScanPage() {
{selectedInventoryCheck.checkNo}
-
状态:{inventoryStatusLabel(selectedInventoryCheck.status)} · 范围:{selectedInventoryCheck.scopeType} / {selectedInventoryCheck.scopeRefId ?? "全量"}
+
状态:{inventoryStatusLabel(selectedInventoryCheck.status)} · 范围:{inventoryScopeLabel(selectedInventoryCheck)} / {selectedInventoryCheck.scopeRefId ?? "全量"}
{canRecordInventory && selectedInventoryCheck.status === "draft" ? (
) : null} + + handleRecord()} + />
); } diff --git a/client/tests/e2e/assets-flow.spec.ts b/client/tests/e2e/assets-flow.spec.ts index 0f7b12ff..ac5e4207 100644 --- a/client/tests/e2e/assets-flow.spec.ts +++ b/client/tests/e2e/assets-flow.spec.ts @@ -82,7 +82,11 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn const assetName = `Playwright资产卡片${suffix}`; await login(page, FINANCE_MGR); - await page.getByRole("link", { name: "资产台账" }).click(); + await (await openNavLink(page, "资产")).click(); + await expect(page.getByTestId("asset-registry-summary")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("asset-category-code")).toHaveCount(0); + await page.getByRole("button", { name: "台账工具" }).click(); + await expect(page.getByTestId("asset-ledger-tools")).toBeVisible(); await expect(page.getByTestId("asset-category-code")).toBeVisible({ timeout: 30_000 }); await page.getByTestId("asset-category-reset").click(); @@ -163,6 +167,8 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn await page.getByTestId("inventory-item-save").click(); await expect(page.getByRole("row").filter({ hasText: assetName }).last()).toBeVisible({ timeout: 30_000 }); await page.getByTestId("inventory-complete").click(); + await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认完成"); + await page.getByTestId("action-confirm-submit").click(); await expect(page.locator(".card-std").nth(1).getByText("已完成", { exact: true })).toBeVisible({ timeout: 30_000 }); await logout(page, FINANCE_MGR.name);