文件
hyb-oa/client/src/pages/MobileAssetScanPage.tsx
T

657 行
25 KiB
TypeScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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<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 [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 = (
<div className="flex flex-wrap gap-2">
{isInventoryMode ? (
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan")}>
返回盘点页
</Button>
) : (
<Button type="button" variant="secondary" onClick={() => navigate("/mobile")}>
返回移动工作台
</Button>
)}
</div>
);
return (
<PageScaffold className="mobile-page-stack">
<PageHeader
title={isInventoryMode ? "移动扫码盘点" : "移动资产扫码"}
subtitle={isInventoryMode ? "在盘点任务上下文中扫码定位资产并快捷录入结果。" : "扫码或手工输入资产编号,快速查看资产卡片。"}
actions={actions}
/>
<section className="mobile-card mobile-focus-card mobile-page-stack" data-testid="mobile-asset-scan-focus">
<div className="mobile-section-header mobile-section-header--top">
<div>
<h3 className="mobile-section-title">{isInventoryMode ? "先扫码,再确认盘点" : "扫码查资产"}</h3>
<p className="mt-1 text-sm text-zinc-600">二维码不可用时,可输入资产编号、序列号或名称兜底。</p>
</div>
<span className={`mobile-section-meta ${isOnline ? "text-emerald-600" : "text-orange-600"}`}>{isOnline ? "在线" : "离线"}</span>
</div>
<div className="mobile-info-grid text-sm">
<div className="mobile-subcard">模式:{isInventoryMode ? "盘点录入" : "资产查看"}</div>
<div className="mobile-subcard">确认:先看摘要,再提交</div>
<div className="mobile-subcard">范围:{selectedInventoryCheck ? inventoryScopeLabel(selectedInventoryCheck) : "按当前权限"}</div>
</div>
</section>
{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="mobile-card space-y-3" 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="mobile-list-card flex flex-wrap items-center justify-between gap-3 text-sm">
<div>
<div className="font-medium text-zinc-900">{selectedInventoryCheck.checkNo}</div>
<div className="text-zinc-500">状态:{inventoryStatusLabel(selectedInventoryCheck.status)} · 范围:{inventoryScopeLabel(selectedInventoryCheck)} / {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="mobile-card space-y-3" 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="grid gap-2 min-[390px]:grid-cols-[minmax(0,1fr)_auto]">
<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="mobile-card space-y-3" 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={`mobile-list-card w-full 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="mobile-card mobile-focus-card space-y-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("/mobile/assets/scan")}>
继续扫码
</Button>
</div>
<div className="mobile-info-grid">
<div className="mobile-subcard text-sm">类型:{assetTypeLabel(selectedAsset.assetType)}</div>
<div className="mobile-subcard text-sm">状态:{statusLabel(selectedAsset.status)}</div>
<div className="mobile-subcard text-sm">法体:{selectedAsset.legalEntity?.name ?? "-"}</div>
<div className="mobile-subcard text-sm">成本中心:{selectedAsset.costCenter?.name ?? "-"}</div>
<div className="mobile-subcard text-sm">位置:{selectedAsset.location ?? "-"}</div>
<div className="mobile-subcard text-sm">归属人:{selectedAsset.assignee?.name ?? "未分配"}</div>
<div className="mobile-subcard text-sm">序列号:{selectedAsset.serialNo ?? "-"}</div>
<div className="mobile-subcard text-sm">责任人:{selectedAsset.owner?.name ?? "-"}</div>
</div>
{isInventoryMode && canRecordInventory && selectedInventoryCheck ? (
<div className="mobile-subcard space-y-3 bg-zinc-50">
<div>
<h4 className="text-sm font-semibold text-zinc-900">盘点录入</h4>
<p className="text-xs text-zinc-500">仅在盘点中的任务可提交或离线缓存结果。</p>
</div>
<div className="mobile-info-grid">
<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={() => setConfirmRecordScan(true)}
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="mobile-card space-y-3" 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="mobile-list-card 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}
<ActionConfirmDialog
open={confirmRecordScan}
onOpenChange={setConfirmRecordScan}
kind="submit"
targetName={selectedAsset?.assetNo}
description={`将按“${inventoryResultLabel(recordResult)}”记录当前资产盘点结果${isOnline ? "" : ",当前离线时会先进入待同步队列"}。请确认资产名称、位置和责任人摘要无误。`}
confirmLabel={isOnline ? "确认提交结果" : "确认加入队列"}
onConfirm={() => handleRecord()}
/>
</PageScaffold>
);
}