feat(P21PWA1-4): 移动扫码页面与盘点路由导航联动

这个提交包含在:
2026-03-11 20:52:11 +08:00
父节点 efd798c192
当前提交 f6de5443c4
共修改 8 个文件,包含 977 行新增和 5 行删除
+1
查看文件
@@ -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: "移动审批",
+118
查看文件
@@ -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;
});
}
+11 -4
查看文件
@@ -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>
}
/>
+16 -1
查看文件
@@ -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">
+602
查看文件
@@ -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>
);
}
+10
查看文件
@@ -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={