R89 A6-A7 移动触控与回归补齐
这个提交包含在:
@@ -230,6 +230,7 @@ export function MobileApprovalsPage() {
|
||||
const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false);
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
const autoSyncRequestedRef = useRef(false);
|
||||
const detailSectionRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const ownerEmployeeId = me?.id ?? null;
|
||||
const canUseRemote = isOnline && Boolean(accessToken);
|
||||
@@ -258,6 +259,21 @@ export function MobileApprovalsPage() {
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
}
|
||||
|
||||
function scrollDetailIntoViewOnMobile() {
|
||||
if (typeof window === "undefined" || !window.matchMedia("(max-width: 767px)").matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
detailSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
}
|
||||
|
||||
function handleTaskSelection(taskId: string) {
|
||||
updateTaskSelection(taskId);
|
||||
scrollDetailIntoViewOnMobile();
|
||||
}
|
||||
|
||||
async function invalidateWorkflowQueries(taskId: string | null = selectedTaskId) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }),
|
||||
@@ -781,11 +797,12 @@ export function MobileApprovalsPage() {
|
||||
/>
|
||||
|
||||
{taskTab === "pending" ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={batchMode ? "default" : "secondary"}
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
disabled={!filteredTaskCards.length}
|
||||
onClick={() => {
|
||||
setBatchMode((current) => !current);
|
||||
@@ -800,13 +817,14 @@ export function MobileApprovalsPage() {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
disabled={!filteredTaskCards.length}
|
||||
onClick={toggleSelectAllVisibleTasks}
|
||||
>
|
||||
{allVisibleSelected ? "清空选择" : "全选当前筛选结果"}
|
||||
</Button>
|
||||
) : null}
|
||||
<span className="text-sm text-zinc-500">
|
||||
<span className="text-sm text-zinc-500 sm:ml-1">
|
||||
当前结果 {filteredTaskCards.length} 条
|
||||
{batchMode ? `,已选 ${selectedBatchTaskIds.length} 条` : ""}
|
||||
</span>
|
||||
@@ -820,10 +838,11 @@ export function MobileApprovalsPage() {
|
||||
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-3 text-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-zinc-700">已选择 {selectedBatchTaskIds.length} 条待办,可在线串行处理或离线逐条入队。</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="grid w-full gap-2 sm:flex sm:w-auto sm:flex-wrap">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending}
|
||||
onClick={() => handleBatchAction("approve")}
|
||||
>
|
||||
@@ -833,6 +852,7 @@ export function MobileApprovalsPage() {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending || !batchRejectComment.trim()}
|
||||
onClick={() => handleBatchAction("reject")}
|
||||
>
|
||||
@@ -899,7 +919,7 @@ export function MobileApprovalsPage() {
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
className="min-h-16 min-w-0 flex-1 text-left"
|
||||
data-testid={`mobile-approvals-task-${task.id}`}
|
||||
onClick={() => toggleBatchTask(task.id)}
|
||||
>
|
||||
@@ -918,11 +938,11 @@ export function MobileApprovalsPage() {
|
||||
return (
|
||||
<button
|
||||
key={task.id}
|
||||
className={`w-full rounded-xl border px-3 py-3 text-left ${
|
||||
className={`min-h-20 w-full rounded-xl border px-3 py-3 text-left transition-colors ${
|
||||
isSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white"
|
||||
}`}
|
||||
data-testid={`mobile-approvals-task-${task.id}`}
|
||||
onClick={() => updateTaskSelection(task.id)}
|
||||
onClick={() => handleTaskSelection(task.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -941,7 +961,7 @@ export function MobileApprovalsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card-std space-y-3 p-4" data-testid="mobile-approvals-detail">
|
||||
<section ref={detailSectionRef} className="card-std scroll-mt-4 space-y-3 p-4" data-testid="mobile-approvals-detail">
|
||||
<h3 className="text-base font-semibold text-zinc-900">任务详情与动作</h3>
|
||||
|
||||
{!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null}
|
||||
@@ -1021,8 +1041,9 @@ export function MobileApprovalsPage() {
|
||||
<p className="text-xs text-zinc-500">离线状态下仅支持通过 / 驳回草稿,退回与转审需联网执行。</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-approve"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("approve")}
|
||||
onClick={() => {
|
||||
@@ -1037,6 +1058,7 @@ export function MobileApprovalsPage() {
|
||||
{canUseRemote ? "通过" : "离线加入通过草稿"}
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-reject"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("reject")}
|
||||
onClick={() => {
|
||||
@@ -1052,6 +1074,7 @@ export function MobileApprovalsPage() {
|
||||
{canUseRemote ? "驳回" : "离线加入驳回草稿"}
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-return"
|
||||
disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")}
|
||||
onClick={() => actionMutation.mutate("return")}
|
||||
@@ -1063,7 +1086,7 @@ export function MobileApprovalsPage() {
|
||||
</div>
|
||||
|
||||
{canTransfer ? (
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
<div className="space-y-3 rounded-lg border border-zinc-200 bg-white p-3 text-sm">
|
||||
<p className="font-medium text-zinc-800">转审</p>
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||
@@ -1088,6 +1111,7 @@ export function MobileApprovalsPage() {
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-transfer"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("transfer")}
|
||||
onClick={() => actionMutation.mutate("transfer")}
|
||||
@@ -1111,8 +1135,9 @@ export function MobileApprovalsPage() {
|
||||
</div>
|
||||
|
||||
{currentQueueItems.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="grid gap-2 sm:flex sm:flex-wrap">
|
||||
<Button
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
data-testid="mobile-approvals-sync-queue"
|
||||
disabled={!canUseRemote || syncMutation.isPending || !retryableQueueItems.length}
|
||||
onClick={() => syncMutation.mutate(retryableQueueItems)}
|
||||
@@ -1135,12 +1160,13 @@ export function MobileApprovalsPage() {
|
||||
<div className="text-zinc-500">状态:{queueStatusLabel(item.status)} · 更新时间:{formatDateTime(item.updatedAt)}</div>
|
||||
<div className="text-zinc-500">意见:{item.comment ?? "-"}</div>
|
||||
<div className="text-zinc-500">最近错误:{item.lastError ?? "-"}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<div className="mt-2 grid gap-2 sm:flex sm:flex-wrap">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => updateTaskSelection(item.taskId)}
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
onClick={() => handleTaskSelection(item.taskId)}
|
||||
>
|
||||
查看任务
|
||||
</Button>
|
||||
@@ -1149,6 +1175,7 @@ export function MobileApprovalsPage() {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
disabled={syncMutation.isPending}
|
||||
onClick={async () => {
|
||||
await updateWorkflowActionDraftStatus({
|
||||
@@ -1170,6 +1197,7 @@ export function MobileApprovalsPage() {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
onClick={async () => {
|
||||
await removeWorkflowActionDraft(item.taskId, ownerEmployeeId);
|
||||
await refreshDraftQueueCache();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { uploadInvoice } from "../api/invoicePool";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Spinner } from "../components/ui/spinner";
|
||||
import {
|
||||
@@ -125,10 +126,10 @@ export function MobileCapturePage() {
|
||||
}, [queuedItems, syncMutation]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageScaffold className="space-y-4">
|
||||
<PageHeader
|
||||
title="移动拍照上传"
|
||||
subtitle="支持离线缓存与联网自动重放。"
|
||||
title="票据上传"
|
||||
subtitle="拍照或选择发票文件,离线时自动加入待上传队列。"
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
@@ -139,18 +140,30 @@ export function MobileCapturePage() {
|
||||
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
|
||||
) : null}
|
||||
|
||||
<div className="card-std space-y-3 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-zinc-900">拍照/选择文件</h3>
|
||||
<span className={isOnline ? "text-emerald-600" : "text-orange-600"}>
|
||||
<div className="card-std space-y-4 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">选择票据</h3>
|
||||
<p className="mt-1 text-sm leading-5 text-zinc-500">支持图片和 PDF,手机会优先打开相机。</p>
|
||||
</div>
|
||||
<span className={isOnline ? "shrink-0 text-sm text-emerald-600" : "shrink-0 text-sm text-orange-600"}>
|
||||
{isOnline ? "在线" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label
|
||||
htmlFor="mobile-capture-file"
|
||||
className="flex min-h-36 cursor-pointer flex-col items-center justify-center rounded-2xl border border-dashed border-zinc-300 bg-zinc-50 px-4 py-6 text-center transition-colors hover:border-zinc-400 hover:bg-white"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[32px] text-zinc-700">add_photo_alternate</span>
|
||||
<span className="mt-2 text-sm font-semibold text-zinc-900">拍照或选择文件</span>
|
||||
<span className="mt-1 text-xs leading-5 text-zinc-500">JPG、PNG、PDF 均可上传</span>
|
||||
</label>
|
||||
<input
|
||||
id="mobile-capture-file"
|
||||
accept="image/*,application/pdf"
|
||||
capture="environment"
|
||||
className="w-full text-sm"
|
||||
className="sr-only"
|
||||
data-testid="mobile-capture-file"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
@@ -159,12 +172,14 @@ export function MobileCapturePage() {
|
||||
type="file"
|
||||
/>
|
||||
|
||||
<div className="text-sm text-zinc-600">
|
||||
当前文件:{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : "未选择"}
|
||||
<div className="rounded-xl border border-zinc-200 bg-white px-3 py-3 text-sm text-zinc-600">
|
||||
<span className="font-medium text-zinc-800">当前文件:</span>
|
||||
{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : "未选择"}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-capture-upload"
|
||||
disabled={uploadMutation.isPending || !selectedFile}
|
||||
onClick={async () => {
|
||||
@@ -202,6 +217,7 @@ export function MobileCapturePage() {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-capture-sync-queue"
|
||||
disabled={!queuedItems.length || syncMutation.isPending || !isOnline}
|
||||
onClick={() => syncMutation.mutate(queuedItems)}
|
||||
@@ -214,9 +230,9 @@ export function MobileCapturePage() {
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-3 p-4" data-testid="mobile-capture-queue">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">{queuedCountLabel}</span>
|
||||
<span className="shrink-0 text-sm text-zinc-500">{queuedCountLabel}</span>
|
||||
</div>
|
||||
|
||||
{queueQuery.isLoading ? <div className="flex items-center gap-2 text-sm text-zinc-500"><Spinner size="sm" />加载中...</div> : null}
|
||||
@@ -226,11 +242,12 @@ export function MobileCapturePage() {
|
||||
) : null}
|
||||
|
||||
{queuedItems.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">
|
||||
<div key={item.id} className="rounded-xl border border-zinc-200 bg-white px-3 py-3 text-sm">
|
||||
<div className="font-medium text-zinc-800">{item.fileName}</div>
|
||||
<div className="text-zinc-500">创建时间:{formatTime(item.createdAt)}</div>
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
data-testid={`mobile-capture-remove-${item.id}`}
|
||||
onClick={async () => {
|
||||
await removeQueuedInvoiceUpload(item.id);
|
||||
@@ -246,6 +263,6 @@ export function MobileCapturePage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户