import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { fetchAssets, type AssetItem } from "../api/assets"; import { mapApiErrorMessage } from "../api/errorMessages"; import { fetchInventoryChecks, recordInventoryItems, startInventoryCheck, type InventoryCheck, type InventoryCheckResult, } from "../api/inventoryChecks"; 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"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { enqueueInventoryScan, listQueuedInventoryScans, removeQueuedInventoryScan, updateQueuedInventoryScanError, type QueuedInventoryScanItem, } from "../mobile/inventoryScanQueue"; function resolveErrorMessage(error: unknown) { if (isApiError(error)) { return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; } if (error instanceof Error) { return error.message; } return "请求失败,请稍后重试。"; } function statusLabel(status: string) { switch (status) { case "in_stock": return "库存中"; case "in_use": return "使用中"; case "under_repair": return "维修中"; case "retired": return "已退役"; case "archived": return "已归档"; default: return status; } } function inventoryStatusLabel(status: string) { switch (status) { case "draft": return "草稿"; case "counting": return "盘点中"; case "completed": return "已完成"; default: return status; } } function inventoryResultLabel(result: InventoryCheckResult) { switch (result) { case "matched": return "一致"; case "missing": return "缺失"; case "extra": return "盘盈"; case "damaged": return "损坏"; default: return result; } } 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"); } catch { return value; } } function matchesInventoryScope(asset: AssetItem, inventoryCheck: InventoryCheck | null) { if (!inventoryCheck) { return true; } if (inventoryCheck.scopeType === "all") { return true; } if (inventoryCheck.scopeType === "legal_entity") { return asset.legalEntityId === inventoryCheck.scopeRefId; } if (inventoryCheck.scopeType === "cost_center") { return asset.costCenterId === inventoryCheck.scopeRefId; } return (asset.location ?? "") === (inventoryCheck.scopeRefId ?? ""); } export function MobileAssetScanPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { permissions } = useAuth(); const [searchParams] = useSearchParams(); const inventoryCheckId = searchParams.get("inventoryCheckId") ?? ""; const isInventoryMode = Boolean(inventoryCheckId); const canRecordInventory = hasPermission(permissions, "asset.inventory"); const [isOnline, setIsOnline] = useState(navigator.onLine); const [searchInput, setSearchInput] = useState(""); const [searchResults, setSearchResults] = useState([]); const [selectedAssetId, setSelectedAssetId] = useState(""); const [message, setMessage] = useState(""); const [error, setError] = useState(""); const [recordResult, setRecordResult] = useState("matched"); const [recordNote, setRecordNote] = useState(""); const [confirmRecordScan, setConfirmRecordScan] = useState(false); const inventoryChecksQuery = useQuery({ enabled: isInventoryMode && canRecordInventory, queryKey: ["mobile", "asset-scan", "inventory-checks"], queryFn: () => fetchInventoryChecks(), }); const queueQuery = useQuery({ enabled: isInventoryMode && canRecordInventory, queryKey: ["mobile", "inventory-scan-queue"], queryFn: () => listQueuedInventoryScans(), }); const selectedInventoryCheck = useMemo(() => { if (!isInventoryMode) { return null; } return (inventoryChecksQuery.data?.items ?? []).find((item) => item.id === inventoryCheckId) ?? null; }, [inventoryCheckId, inventoryChecksQuery.data?.items, isInventoryMode]); const filteredSearchResults = useMemo(() => { return searchResults.filter((item) => matchesInventoryScope(item, selectedInventoryCheck)); }, [searchResults, selectedInventoryCheck]); const selectedAsset = useMemo( () => filteredSearchResults.find((item) => item.id === selectedAssetId) ?? null, [filteredSearchResults, selectedAssetId], ); const currentQueueItems = useMemo(() => { const items = queueQuery.data ?? []; if (!inventoryCheckId) { return items; } return items.filter((item) => item.inventoryCheckId === inventoryCheckId); }, [inventoryCheckId, queueQuery.data]); const searchMutation = useMutation({ mutationFn: async (keyword: string) => fetchAssets({ q: keyword }), onSuccess: (data, keyword) => { const items = data.items; setSearchResults(items); setSelectedAssetId(""); setError(""); const inScopeItems = items.filter((item) => matchesInventoryScope(item, selectedInventoryCheck)); if (!items.length) { setMessage(`未找到与“${keyword}”匹配的资产`); return; } if (!inScopeItems.length) { setMessage("已命中资产,但不在当前盘点范围内"); return; } if (inScopeItems.length === 1) { setSelectedAssetId(inScopeItems[0].id); setMessage(`已定位资产:${inScopeItems[0].assetNo}`); return; } setMessage(`找到 ${inScopeItems.length} 条候选,请手动选择目标资产`); }, onError: (searchError) => { setSearchResults([]); setSelectedAssetId(""); setError(resolveErrorMessage(searchError)); setMessage(""); }, }); const startCheckMutation = useMutation({ mutationFn: async () => { if (!selectedInventoryCheck) { throw new Error("未找到盘点任务"); } return startInventoryCheck(selectedInventoryCheck.id); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["mobile", "asset-scan", "inventory-checks"] }); setError(""); setMessage("盘点任务已启动,可继续扫码录入。"); toast.success("盘点任务已启动"); }, onError: (startError) => { const nextError = resolveErrorMessage(startError); setError(nextError); toast.error(nextError); }, }); const recordMutation = useMutation({ mutationFn: async () => { if (!selectedInventoryCheck) { throw new Error("未找到盘点任务"); } if (!selectedAsset) { throw new Error("请先选择资产"); } return recordInventoryItems(selectedInventoryCheck.id, { items: [ { assetId: selectedAsset.id, result: recordResult, note: recordNote.trim() || null, }, ], }); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["mobile", "asset-scan", "inventory-checks"] }); setError(""); setMessage("盘点结果已提交"); setRecordNote(""); toast.success("盘点结果已提交"); }, onError: (recordError) => { const nextError = resolveErrorMessage(recordError); setError(nextError); toast.error(nextError); }, }); const syncMutation = useMutation({ mutationFn: async (items: QueuedInventoryScanItem[]) => { let succeeded = 0; let failed = 0; for (const item of items) { try { await recordInventoryItems(item.inventoryCheckId, { items: [ { assetId: item.assetId, result: item.result, note: item.note, }, ], }); await removeQueuedInventoryScan(item.id); succeeded += 1; } catch (syncError) { failed += 1; await updateQueuedInventoryScanError(item.id, resolveErrorMessage(syncError)); } } return { succeeded, failed }; }, onSuccess: async (result) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["mobile", "inventory-scan-queue"] }), queryClient.invalidateQueries({ queryKey: ["mobile", "asset-scan", "inventory-checks"] }), ]); setError(""); if (result.succeeded || result.failed) { setMessage(`队列同步完成:成功 ${result.succeeded},失败 ${result.failed}`); } if (result.succeeded) { toast.success(`队列同步完成:成功 ${result.succeeded},失败 ${result.failed}`); } }, onError: (syncError) => { const nextError = resolveErrorMessage(syncError); setError(nextError); toast.error(nextError); }, }); useEffect(() => { const onOnline = () => { setIsOnline(true); if ((queueQuery.data ?? []).length && canRecordInventory) { syncMutation.mutate(queueQuery.data ?? []); } }; const onOffline = () => setIsOnline(false); window.addEventListener("online", onOnline); window.addEventListener("offline", onOffline); return () => { window.removeEventListener("online", onOnline); window.removeEventListener("offline", onOffline); }; }, [canRecordInventory, queueQuery.data, syncMutation]); const handleSearch = async (rawValue = searchInput) => { const keyword = rawValue.trim(); setSearchInput(keyword); setMessage(""); setError(""); if (!keyword) { setError("请输入资产编号、序列号或名称"); return; } if (!isOnline) { setError("当前离线,无法查找新资产。若已解析资产,可直接提交离线盘点结果。"); return; } await searchMutation.mutateAsync(keyword); }; const handleOfflineQueue = async () => { if (!selectedInventoryCheck) { setError("未找到盘点任务,无法离线缓存"); return; } if (!selectedAsset) { setError("请先在线解析出目标资产"); return; } if (selectedInventoryCheck.status !== "counting") { setError("仅盘点中的任务支持离线缓存结果"); return; } await enqueueInventoryScan({ inventoryCheckId: selectedInventoryCheck.id, assetId: selectedAsset.id, assetNo: selectedAsset.assetNo, result: recordResult, note: recordNote, }); await queryClient.invalidateQueries({ queryKey: ["mobile", "inventory-scan-queue"] }); setError(""); setMessage("当前离线,盘点结果已加入待同步队列"); toast.success("已加入离线队列"); }; const handleRecord = async () => { if (!canRecordInventory) { setError("当前账号没有盘点录入权限"); return; } if (!selectedInventoryCheck) { setError("未找到盘点任务"); return; } if (selectedInventoryCheck.status === "draft") { setError("请先启动盘点任务再记录结果"); return; } if (selectedInventoryCheck.status === "completed") { setError("盘点任务已完成,不能继续录入"); return; } if (!isOnline) { await handleOfflineQueue(); return; } await recordMutation.mutateAsync(); }; const actions = (
{isInventoryMode ? ( ) : ( )}
); return (

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

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

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

{error}

) : null} {message ? (

{message}

) : null} {isInventoryMode ? (

盘点任务上下文

任务 ID:{inventoryCheckId}

{isOnline ? "在线" : "离线"}
{!canRecordInventory ? (

当前账号没有盘点权限,只能查看扫码结果,不能录入盘点。

) : null} {canRecordInventory && inventoryChecksQuery.isLoading ? (

正在加载盘点任务...

) : null} {canRecordInventory && inventoryChecksQuery.isError ? (

{resolveErrorMessage(inventoryChecksQuery.error)}

) : null} {canRecordInventory && !inventoryChecksQuery.isLoading && !selectedInventoryCheck ? (

未找到对应盘点任务,无法进入盘点录入模式。

) : null} {selectedInventoryCheck ? (
{selectedInventoryCheck.checkNo}
状态:{inventoryStatusLabel(selectedInventoryCheck.status)} · 范围:{inventoryScopeLabel(selectedInventoryCheck)} / {selectedInventoryCheck.scopeRefId ?? "全量"}
{canRecordInventory && selectedInventoryCheck.status === "draft" ? ( ) : null}
) : null}
) : null} { setSearchInput(rawValue); void handleSearch(rawValue); }} />

手工输入 / 搜索

{isOnline ? "在线" : "离线"}
setSearchInput(event.target.value)} placeholder="输入资产编号、序列号或名称" />
{!isOnline ? (

离线状态下不可解析新资产;若当前页面已解析出目标资产,可继续离线缓存盘点结果。

) : null}
{filteredSearchResults.length > 1 ? (

候选资产

{filteredSearchResults.map((item) => ( ))}
) : null} {selectedAsset ? (

{selectedAsset.assetNo}

{selectedAsset.name}

类型:{assetTypeLabel(selectedAsset.assetType)}
状态:{statusLabel(selectedAsset.status)}
法体:{selectedAsset.legalEntity?.name ?? "-"}
成本中心:{selectedAsset.costCenter?.name ?? "-"}
位置:{selectedAsset.location ?? "-"}
归属人:{selectedAsset.assignee?.name ?? "未分配"}
序列号:{selectedAsset.serialNo ?? "-"}
责任人:{selectedAsset.owner?.name ?? "-"}
{isInventoryMode && canRecordInventory && selectedInventoryCheck ? (

盘点录入

仅在盘点中的任务可提交或离线缓存结果。

setRecordNote(event.target.value)} placeholder="可选备注" />
{currentQueueItems.length ? ( ) : null}
) : null}
) : null} {isInventoryMode && canRecordInventory ? (

离线盘点队列

{currentQueueItems.length ? `待同步 ${currentQueueItems.length} 项` : "无待同步项"}
{!currentQueueItems.length ? (

当前任务暂无离线队列。

) : null} {currentQueueItems.map((item) => (
{item.assetNo}
结果:{inventoryResultLabel(item.result)} · 创建时间:{formatDateTime(item.createdAt)}
备注:{item.note ?? "-"}
最近错误:{item.lastError ?? "-"}
))}
) : null} handleRecord()} />
); }