feat(P21PWA1-4): 移动扫码页面与盘点路由导航联动
这个提交包含在:
@@ -103,6 +103,7 @@ export type AssetListQuery = {
|
||||
assigneeId?: string;
|
||||
legalEntityId?: string;
|
||||
costCenterId?: string;
|
||||
q?: string;
|
||||
mine?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -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<DetectorResult[]>;
|
||||
};
|
||||
|
||||
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<HTMLVideoElement | null>(null);
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
const pollTimerRef = useRef<number | null>(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 (
|
||||
<div className="space-y-3 rounded-xl border border-zinc-200 p-4" data-testid="barcode-scanner-panel">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">相机扫码</h3>
|
||||
<p className="text-sm text-zinc-500">优先使用 BarcodeDetector,回退到 ZXing。</p>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">{isScanning ? `当前引擎:${engineLabel}` : "未启动"}</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-black">
|
||||
<video ref={videoRef} autoPlay muted playsInline className="aspect-video w-full object-cover" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" disabled={disabled || isStarting || isScanning} onClick={() => void startScanner()}>
|
||||
{isStarting ? "启动中..." : isScanning ? "扫码中..." : "开始扫码"}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" disabled={!isScanning && !isStarting} onClick={stopScanner}>
|
||||
停止扫码
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: "移动审批",
|
||||
|
||||
@@ -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<IDBDatabase> | 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<T>(request: IDBRequest<T>) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error("indexeddb_request_failed"));
|
||||
});
|
||||
}
|
||||
|
||||
async function withStore<T>(mode: IDBTransactionMode, executor: (store: IDBObjectStore) => Promise<T>) {
|
||||
const db = await getDatabase();
|
||||
|
||||
return new Promise<T>((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;
|
||||
});
|
||||
}
|
||||
@@ -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 ? (
|
||||
<Button type="button" data-testid="asset-create" onClick={resetAssetEditor}>
|
||||
新建资产卡片
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }>
|
||||
移动扫码查找
|
||||
</Button>
|
||||
) : null
|
||||
{canManage ? (
|
||||
<Button type="button" data-testid="asset-create" onClick={resetAssetEditor}>
|
||||
新建资产卡片
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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<InventoryCheckScopeType>("all");
|
||||
@@ -227,7 +229,20 @@ export function InventoryChecksPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="资产盘点" subtitle="创建、启动、记录并完成资产盘点任务。" />
|
||||
<PageHeader
|
||||
title="资产盘点"
|
||||
subtitle="创建、启动、记录并完成资产盘点任务。"
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={!selectedCheckId}
|
||||
onClick={() => navigate(selectedCheckId ? `/mobile/assets/scan?inventoryCheckId=${selectedCheckId}` : "/mobile/assets/scan")}
|
||||
>
|
||||
移动扫码盘点
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<div className="card-std space-y-4 p-6">
|
||||
|
||||
@@ -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<boolean>(navigator.onLine);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<AssetItem[]>([]);
|
||||
const [selectedAssetId, setSelectedAssetId] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [recordResult, setRecordResult] = useState<InventoryCheckResult>("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 = (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isInventoryMode ? (
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/inventory-checks")}>
|
||||
返回盘点页
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/assets")}>
|
||||
返回资产台账
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title={isInventoryMode ? "移动扫码盘点" : "移动资产扫码"}
|
||||
subtitle={isInventoryMode ? "在盘点任务上下文中扫码定位资产并快捷录入结果。" : "扫码或手工输入资产编号,快速查看资产卡片。"}
|
||||
actions={actions}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
|
||||
) : null}
|
||||
|
||||
{isInventoryMode ? (
|
||||
<div className="card-std space-y-3 p-4" data-testid="mobile-asset-scan-inventory-context">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">盘点任务上下文</h3>
|
||||
<p className="text-sm text-zinc-500">任务 ID:{inventoryCheckId}</p>
|
||||
</div>
|
||||
<span className={`text-sm ${isOnline ? "text-emerald-600" : "text-orange-600"}`}>{isOnline ? "在线" : "离线"}</span>
|
||||
</div>
|
||||
|
||||
{!canRecordInventory ? (
|
||||
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">当前账号没有盘点权限,只能查看扫码结果,不能录入盘点。</p>
|
||||
) : null}
|
||||
|
||||
{canRecordInventory && inventoryChecksQuery.isLoading ? (
|
||||
<p className="text-sm text-zinc-500">正在加载盘点任务...</p>
|
||||
) : null}
|
||||
|
||||
{canRecordInventory && inventoryChecksQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveErrorMessage(inventoryChecksQuery.error)}</p>
|
||||
) : null}
|
||||
|
||||
{canRecordInventory && !inventoryChecksQuery.isLoading && !selectedInventoryCheck ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">未找到对应盘点任务,无法进入盘点录入模式。</p>
|
||||
) : null}
|
||||
|
||||
{selectedInventoryCheck ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 px-3 py-3 text-sm">
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">{selectedInventoryCheck.checkNo}</div>
|
||||
<div className="text-zinc-500">状态:{inventoryStatusLabel(selectedInventoryCheck.status)} · 范围:{selectedInventoryCheck.scopeType} / {selectedInventoryCheck.scopeRefId ?? "全量"}</div>
|
||||
</div>
|
||||
{canRecordInventory && selectedInventoryCheck.status === "draft" ? (
|
||||
<Button type="button" onClick={() => startCheckMutation.mutate()} disabled={startCheckMutation.isPending}>
|
||||
{startCheckMutation.isPending ? "启动中..." : "开始并扫码"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<BarcodeScannerPanel
|
||||
disabled={searchMutation.isPending || (isInventoryMode && selectedInventoryCheck?.status === "draft")}
|
||||
onDetected={(rawValue) => {
|
||||
setSearchInput(rawValue);
|
||||
void handleSearch(rawValue);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="card-std space-y-3 p-4" data-testid="mobile-asset-scan-search">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">手工输入 / 搜索</h3>
|
||||
<span className={`text-sm ${isOnline ? "text-emerald-600" : "text-orange-600"}`}>{isOnline ? "在线" : "离线"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
data-testid="mobile-asset-scan-input"
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
placeholder="输入资产编号、序列号或名称"
|
||||
/>
|
||||
<Button type="button" onClick={() => void handleSearch()} disabled={searchMutation.isPending}>
|
||||
{searchMutation.isPending ? "查询中..." : "查询资产"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!isOnline ? (
|
||||
<p className="text-sm text-zinc-500">离线状态下不可解析新资产;若当前页面已解析出目标资产,可继续离线缓存盘点结果。</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{filteredSearchResults.length > 1 ? (
|
||||
<div className="card-std space-y-3 p-4" data-testid="mobile-asset-scan-candidates">
|
||||
<h3 className="text-base font-semibold text-zinc-900">候选资产</h3>
|
||||
<div className="space-y-2">
|
||||
{filteredSearchResults.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`w-full rounded-lg border px-3 py-3 text-left text-sm ${selectedAssetId === item.id ? "border-primary bg-primary-50 text-primary" : "border-zinc-200 bg-white text-zinc-800"}`}
|
||||
onClick={() => setSelectedAssetId(item.id)}
|
||||
data-testid={`mobile-asset-scan-candidate-${item.id}`}
|
||||
>
|
||||
<div className="font-medium">{item.assetNo} · {item.name}</div>
|
||||
<div className="text-xs text-zinc-500">状态:{statusLabel(item.status)} · 位置:{item.location ?? "未设置"}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedAsset ? (
|
||||
<div className="card-std space-y-4 p-4" data-testid="mobile-asset-scan-detail">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-zinc-900">{selectedAsset.assetNo}</h3>
|
||||
<p className="text-sm text-zinc-500">{selectedAsset.name}</p>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/assets")}>
|
||||
打开资产台账
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">状态:{statusLabel(selectedAsset.status)}</div>
|
||||
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">法体:{selectedAsset.legalEntity?.name ?? "-"}</div>
|
||||
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">成本中心:{selectedAsset.costCenter?.name ?? "-"}</div>
|
||||
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">位置:{selectedAsset.location ?? "-"}</div>
|
||||
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">归属人:{selectedAsset.assignee?.name ?? "未分配"}</div>
|
||||
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">序列号:{selectedAsset.serialNo ?? "-"}</div>
|
||||
</div>
|
||||
|
||||
{isInventoryMode && canRecordInventory && selectedInventoryCheck ? (
|
||||
<div className="space-y-3 rounded-xl border border-zinc-200 bg-zinc-50 p-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900">盘点录入</h4>
|
||||
<p className="text-xs text-zinc-500">仅在盘点中的任务可提交或离线缓存结果。</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">盘点结果</label>
|
||||
<select className="select-std" value={recordResult} onChange={(event) => setRecordResult(event.target.value as InventoryCheckResult)}>
|
||||
<option value="matched">一致</option>
|
||||
<option value="missing">缺失</option>
|
||||
<option value="extra">盘盈</option>
|
||||
<option value="damaged">损坏</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">备注</label>
|
||||
<Input value={recordNote} onChange={(event) => setRecordNote(event.target.value)} placeholder="可选备注" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleRecord()}
|
||||
disabled={recordMutation.isPending || !selectedInventoryCheck || selectedInventoryCheck.status === "completed"}
|
||||
>
|
||||
{isOnline ? (recordMutation.isPending ? "提交中..." : "提交盘点结果") : "离线加入队列"}
|
||||
</Button>
|
||||
{currentQueueItems.length ? (
|
||||
<Button type="button" variant="secondary" disabled={!isOnline || syncMutation.isPending} onClick={() => syncMutation.mutate(currentQueueItems)}>
|
||||
{syncMutation.isPending ? "同步中..." : `同步队列 (${currentQueueItems.length})`}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isInventoryMode && canRecordInventory ? (
|
||||
<div className="card-std space-y-3 p-4" data-testid="mobile-asset-scan-queue">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">离线盘点队列</h3>
|
||||
<span className="text-sm text-zinc-500">{currentQueueItems.length ? `待同步 ${currentQueueItems.length} 项` : "无待同步项"}</span>
|
||||
</div>
|
||||
|
||||
{!currentQueueItems.length ? (
|
||||
<p className="text-sm text-zinc-500">当前任务暂无离线队列。</p>
|
||||
) : null}
|
||||
|
||||
{currentQueueItems.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-3 text-sm">
|
||||
<div className="font-medium text-zinc-900">{item.assetNo}</div>
|
||||
<div className="text-zinc-500">结果:{inventoryResultLabel(item.result)} · 创建时间:{formatDateTime(item.createdAt)}</div>
|
||||
<div className="text-zinc-500">备注:{item.note ?? "-"}</div>
|
||||
<div className="text-zinc-500">最近错误:{item.lastError ?? "-"}</div>
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
await removeQueuedInventoryScan(item.id);
|
||||
await queryClient.invalidateQueries({ queryKey: ["mobile", "inventory-scan-queue"] });
|
||||
}}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="mobile/assets/scan"
|
||||
element={
|
||||
<RequirePermission anyOf={["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]}>
|
||||
<MobileAssetScanPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="mobile/approvals"
|
||||
element={
|
||||
|
||||
在新工单中引用
屏蔽一个用户