diff --git a/client/src/api/assets.ts b/client/src/api/assets.ts index c0318374..ff735a33 100644 --- a/client/src/api/assets.ts +++ b/client/src/api/assets.ts @@ -103,6 +103,7 @@ export type AssetListQuery = { assigneeId?: string; legalEntityId?: string; costCenterId?: string; + q?: string; mine?: boolean; }; diff --git a/client/src/components/mobile/BarcodeScannerPanel.tsx b/client/src/components/mobile/BarcodeScannerPanel.tsx new file mode 100644 index 00000000..40549bbb --- /dev/null +++ b/client/src/components/mobile/BarcodeScannerPanel.tsx @@ -0,0 +1,213 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "../ui/button"; + +type BarcodeScannerPanelProps = { + disabled?: boolean; + onDetected: (rawValue: string) => void; +}; + +type DetectorResult = { + rawValue?: string; +}; + +type BarcodeDetectorLike = { + detect: (source: HTMLVideoElement) => Promise; +}; + +type BarcodeDetectorConstructorLike = new (options?: { formats?: string[] }) => BarcodeDetectorLike; + +type BrowserWithBarcodeDetector = Window & { + BarcodeDetector?: BarcodeDetectorConstructorLike; +}; + +const SCAN_FORMATS = ["qr_code", "code_128", "code_39", "ean_13", "ean_8", "upc_a", "upc_e"]; + +function resolveErrorMessage(error: unknown) { + if (error instanceof Error && error.message) { + return error.message; + } + return "无法启动相机扫码,请改用手工输入。"; +} + +export function BarcodeScannerPanel({ disabled = false, onDetected }: BarcodeScannerPanelProps) { + const videoRef = useRef(null); + const mediaStreamRef = useRef(null); + const cleanupRef = useRef<(() => void) | null>(null); + const pollTimerRef = useRef(null); + const detectingRef = useRef(false); + const [engineLabel, setEngineLabel] = useState(""); + const [error, setError] = useState(""); + const [isStarting, setIsStarting] = useState(false); + const [isScanning, setIsScanning] = useState(false); + + const stopScanner = useCallback(() => { + if (pollTimerRef.current) { + window.clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + + cleanupRef.current?.(); + cleanupRef.current = null; + + if (mediaStreamRef.current) { + for (const track of mediaStreamRef.current.getTracks()) { + track.stop(); + } + mediaStreamRef.current = null; + } + + if (videoRef.current) { + videoRef.current.pause(); + videoRef.current.srcObject = null; + } + + detectingRef.current = false; + setIsStarting(false); + setIsScanning(false); + setEngineLabel(""); + }, []); + + useEffect(() => stopScanner, [stopScanner]); + + const handleDetected = useCallback( + (value: string) => { + const normalized = value.trim(); + if (!normalized) { + return; + } + onDetected(normalized); + stopScanner(); + }, + [onDetected, stopScanner], + ); + + const startWithBarcodeDetector = useCallback(async () => { + if (!navigator.mediaDevices?.getUserMedia) { + throw new Error("当前浏览器不支持相机访问"); + } + + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: { ideal: "environment" }, + }, + audio: false, + }); + + const videoElement = videoRef.current; + if (!videoElement) { + for (const track of stream.getTracks()) { + track.stop(); + } + throw new Error("视频预览不可用"); + } + + mediaStreamRef.current = stream; + videoElement.srcObject = stream; + videoElement.muted = true; + videoElement.playsInline = true; + await videoElement.play(); + + const BarcodeDetector = (window as BrowserWithBarcodeDetector).BarcodeDetector; + if (!BarcodeDetector) { + throw new Error("BarcodeDetector 不可用"); + } + + const detector = new BarcodeDetector({ formats: SCAN_FORMATS }); + setEngineLabel("BarcodeDetector"); + setIsScanning(true); + + pollTimerRef.current = window.setInterval(async () => { + if (detectingRef.current || !videoRef.current) { + return; + } + + detectingRef.current = true; + try { + const results = await detector.detect(videoRef.current); + const matched = results.find((item) => item.rawValue?.trim()); + if (matched?.rawValue) { + handleDetected(matched.rawValue); + } + } catch { + } finally { + detectingRef.current = false; + } + }, 600); + }, [handleDetected]); + + const startWithZxing = useCallback(async () => { + const videoElement = videoRef.current; + if (!videoElement) { + throw new Error("视频预览不可用"); + } + + const { BrowserMultiFormatReader } = await import("@zxing/browser"); + const reader = new BrowserMultiFormatReader(); + const controls = await reader.decodeFromVideoDevice(undefined, videoElement, (result) => { + if (result) { + handleDetected(result.getText()); + } + }); + + cleanupRef.current = () => { + controls.stop(); + }; + setEngineLabel("ZXing"); + setIsScanning(true); + }, [handleDetected]); + + const startScanner = useCallback(async () => { + setError(""); + setIsStarting(true); + + const BarcodeDetector = (window as BrowserWithBarcodeDetector).BarcodeDetector; + if (BarcodeDetector) { + try { + await startWithBarcodeDetector(); + setIsStarting(false); + return; + } catch (detectorError) { + stopScanner(); + setError(`BarcodeDetector 启动失败,已回退到 ZXing:${resolveErrorMessage(detectorError)}`); + } + } + + try { + await startWithZxing(); + } catch (fallbackError) { + stopScanner(); + setError(resolveErrorMessage(fallbackError)); + } + + setIsStarting(false); + }, [startWithBarcodeDetector, startWithZxing, stopScanner]); + + return ( +
+
+
+

相机扫码

+

优先使用 BarcodeDetector,回退到 ZXing。

+
+
{isScanning ? `当前引擎:${engineLabel}` : "未启动"}
+
+ + {error ? ( +

{error}

+ ) : null} + +
+
+ +
+ + +
+
+ ); +} diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index 193f6e1e..25dc4796 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -66,6 +66,12 @@ export const NAV_ITEMS: NavItem[] = [ icon: "document_scanner", anyOf: ["invoice.upload", "invoice.manage"], }, + { + to: "/mobile/assets/scan", + label: "资产扫码", + icon: "qr_code_scanner", + anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"], + }, { to: "/mobile/approvals", label: "移动审批", diff --git a/client/src/mobile/inventoryScanQueue.ts b/client/src/mobile/inventoryScanQueue.ts new file mode 100644 index 00000000..bf65fc7c --- /dev/null +++ b/client/src/mobile/inventoryScanQueue.ts @@ -0,0 +1,118 @@ +import type { InventoryCheckResult } from "../api/inventoryChecks"; + +export type QueuedInventoryScanItem = { + id: string; + inventoryCheckId: string; + assetId: string; + assetNo: string; + result: InventoryCheckResult; + note: string | null; + createdAt: string; + lastError: string | null; +}; + +const DB_NAME = "hyb-oa-mobile-inventory"; +const DB_VERSION = 1; +const STORE_NAME = "inventory_scan_queue"; + +let dbPromise: Promise | null = null; + +function getDatabase() { + if (!dbPromise) { + dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: "id" }); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("indexeddb_open_failed")); + }); + } + + return dbPromise; +} + +function requestPromise(request: IDBRequest) { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("indexeddb_request_failed")); + }); +} + +async function withStore(mode: IDBTransactionMode, executor: (store: IDBObjectStore) => Promise) { + const db = await getDatabase(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, mode); + const store = tx.objectStore(STORE_NAME); + + executor(store) + .then((result) => { + tx.oncomplete = () => resolve(result); + tx.onerror = () => reject(tx.error ?? new Error("indexeddb_tx_failed")); + }) + .catch((error) => reject(error)); + }); +} + +export async function enqueueInventoryScan(input: { + inventoryCheckId: string; + assetId: string; + assetNo: string; + result: InventoryCheckResult; + note?: string | null; +}) { + const id = `${input.inventoryCheckId}:${input.assetId}`; + const item: QueuedInventoryScanItem = { + id, + inventoryCheckId: input.inventoryCheckId, + assetId: input.assetId, + assetNo: input.assetNo, + result: input.result, + note: input.note?.trim() ? input.note.trim() : null, + createdAt: new Date().toISOString(), + lastError: null, + }; + + await withStore("readwrite", async (store) => { + await requestPromise(store.put(item)); + return true; + }); + + return item; +} + +export async function listQueuedInventoryScans() { + return withStore("readonly", async (store) => { + const records = (await requestPromise(store.getAll())) as QueuedInventoryScanItem[]; + return records.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + }); +} + +export async function removeQueuedInventoryScan(id: string) { + await withStore("readwrite", async (store) => { + await requestPromise(store.delete(id)); + return true; + }); +} + +export async function updateQueuedInventoryScanError(id: string, lastError: string | null) { + await withStore("readwrite", async (store) => { + const existing = (await requestPromise(store.get(id))) as QueuedInventoryScanItem | undefined; + if (!existing) { + return false; + } + await requestPromise( + store.put({ + ...existing, + lastError, + }), + ); + return true; + }); +} diff --git a/client/src/pages/AssetRegistryPage.tsx b/client/src/pages/AssetRegistryPage.tsx index 9d36f083..d5103624 100644 --- a/client/src/pages/AssetRegistryPage.tsx +++ b/client/src/pages/AssetRegistryPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { mapApiErrorMessage } from "../api/errorMessages"; @@ -112,6 +113,7 @@ function resolveAssetErpActionLabel(actionHint: AssetErpTrack["stage"]["actionHi } export function AssetRegistryPage() { + const navigate = useNavigate(); const queryClient = useQueryClient(); const { permissions } = useAuth(); @@ -539,11 +541,16 @@ export function AssetRegistryPage() { title="资产台账" subtitle="维护资产分类与资产卡片,支持法体、成本中心和采购来源引用。" actions={ - canManage ? ( - - ) : null + {canManage ? ( + + ) : null} + } /> diff --git a/client/src/pages/InventoryChecksPage.tsx b/client/src/pages/InventoryChecksPage.tsx index 2586bc2c..255a8fd5 100644 --- a/client/src/pages/InventoryChecksPage.tsx +++ b/client/src/pages/InventoryChecksPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { fetchAssets } from "../api/assets"; @@ -74,6 +75,7 @@ function statusLabel(status: string) { } export function InventoryChecksPage() { + const navigate = useNavigate(); const queryClient = useQueryClient(); const [scopeType, setScopeType] = useState("all"); @@ -227,7 +229,20 @@ export function InventoryChecksPage() { return (
- + navigate(selectedCheckId ? `/mobile/assets/scan?inventoryCheckId=${selectedCheckId}` : "/mobile/assets/scan")} + > + 移动扫码盘点 + + } + />
diff --git a/client/src/pages/MobileAssetScanPage.tsx b/client/src/pages/MobileAssetScanPage.tsx new file mode 100644 index 00000000..fb9b88f7 --- /dev/null +++ b/client/src/pages/MobileAssetScanPage.tsx @@ -0,0 +1,602 @@ +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 { PageHeader } from "../components/common/PageHeader"; +import { BarcodeScannerPanel } from "../components/mobile/BarcodeScannerPanel"; +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 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 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 ( +
+ + + {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)} · 范围:{selectedInventoryCheck.scopeType} / {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}

+
+ +
+ +
+
状态:{statusLabel(selectedAsset.status)}
+
法体:{selectedAsset.legalEntity?.name ?? "-"}
+
成本中心:{selectedAsset.costCenter?.name ?? "-"}
+
位置:{selectedAsset.location ?? "-"}
+
归属人:{selectedAsset.assignee?.name ?? "未分配"}
+
序列号:{selectedAsset.serialNo ?? "-"}
+
+ + {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} +
+ ); +} diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index 5ed42893..50d2f9fd 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -12,6 +12,7 @@ import { MasterDataPage } from "../pages/MasterDataPage"; import { DelegationsPage } from "../pages/DelegationsPage"; import { ForbiddenPage } from "../pages/ForbiddenPage"; import { MobileApprovalsPage } from "../pages/MobileApprovalsPage"; +import { MobileAssetScanPage } from "../pages/MobileAssetScanPage"; import { MobileCapturePage } from "../pages/MobileCapturePage"; import { InvoicePoolPage } from "../pages/InvoicePoolPage"; import { LoginPage } from "../pages/LoginPage"; @@ -176,6 +177,15 @@ export function AppRouter() { } /> + + + + } + /> +