MIF7T-1/MIF7T-7 收口真机反馈并统一移动体验

这个提交包含在:
2026-05-13 10:17:50 +08:00
父节点 44b5af4f1e
当前提交 cf2bba02d5
共修改 30 个文件,包含 793 行新增和 264 行删除
@@ -1,4 +1,6 @@
import type { ReactNode } from "react";
import { useEffect } from "react";
import { ArrowLeft } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "../ui/button";
@@ -26,14 +28,26 @@ export function MobileDetailHeader({ title, subtitle, status, fallbackTo, testId
const currentPath = `${location.pathname}${location.search}`;
const from = isSafeMobileSource(state?.from, currentPath) ? state?.from : null;
useEffect(() => {
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "auto" });
document.querySelector<HTMLElement>(".hyb-mobile-shell .overflow-y-auto")?.scrollTo({ top: 0, behavior: "auto" });
};
scrollToTop();
const frameId = window.requestAnimationFrame(scrollToTop);
return () => window.cancelAnimationFrame(frameId);
}, [currentPath]);
const handleBack = () => {
navigate(from ?? fallbackTo, { replace: true });
};
return (
<header className="mobile-detail-header flex min-h-12 items-center gap-3" data-testid={testId}>
<Button className="shrink-0 px-3" size="sm" type="button" variant="secondary" onClick={handleBack}>
返回
<Button className="min-h-11 shrink-0 gap-1 px-3" size="sm" type="button" variant="secondary" onClick={handleBack}>
<ArrowLeft aria-hidden="true" className="size-4" />
<span>返回</span>
</Button>
<div className="min-w-0 flex-1">
<div className="truncate text-base font-semibold text-zinc-900">{title}</div>
@@ -12,8 +12,10 @@ import {
IdCard,
Network,
PackageSearch,
QrCode,
ReceiptText,
Settings,
ShoppingCart,
UserCog,
UserRound,
Users,
@@ -34,9 +36,11 @@ const MOBILE_ICON_MAP: Record<string, LucideIcon> = {
notifications: Bell,
person: UserRound,
picture_as_pdf: FileText,
qr_code_scanner: QrCode,
receipt_long: ReceiptText,
request_quote: FileCheck2,
settings: Settings,
shopping_cart: ShoppingCart,
workspaces: BriefcaseBusiness,
};
@@ -1,6 +1,12 @@
import type { ReactNode } from "react";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "../../lib/utils";
export function PageScaffold({ className, children }: { className?: string; children: ReactNode }) {
return <div className={cn("page-scaffold", className)}>{children}</div>;
type PageScaffoldProps = ComponentPropsWithoutRef<"div">;
export function PageScaffold({ className, children, ...props }: PageScaffoldProps) {
return (
<div className={cn("page-scaffold", className)} {...props}>
{children}
</div>
);
}
+13
查看文件
@@ -15,8 +15,21 @@ export const MOBILE_ASSET_SCAN_PERMISSIONS = [
"asset.inventory",
] as const;
export const MOBILE_HOME_ASSET_SCAN_PERMISSIONS = [
"asset.manage",
"asset.transfer",
"asset.inventory",
] as const;
export const MOBILE_LEAVE_PERMISSIONS = ["leave.view_own", "leave.view_all"] as const;
export const MOBILE_PURCHASE_PERMISSIONS = [
"procurement.create",
"procurement.view_own",
"procurement.view_all",
"procurement.manage",
] as const;
export const MOBILE_REIMBURSEMENT_PERMISSIONS = ["expense.view_own", "expense.view_all"] as const;
export type MobileNavItem = {
+2 -1
查看文件
@@ -29,7 +29,8 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/mobile/capture", title: "移动 PDF 发票上传", group: "移动工作台" },
{ path: "/mobile/leave", title: "移动请假", group: "移动工作台" },
{ path: "/mobile/me", title: "移动我的", group: "移动工作台" },
{ path: "/mobile/purchase-requests", title: "移动采购详情", group: "移动工作台" },
{ path: "/mobile/purchase-requests/", title: "移动采购详情", group: "移动工作台" },
{ path: "/mobile/purchase-requests", title: "移动采购", group: "移动工作台" },
{ path: "/mobile/reimbursements", title: "移动报销", group: "移动工作台" },
{ path: "/mobile", title: "移动首页", group: "移动工作台" },
{ path: "/settings/profile", title: "个人设置", group: "账号设置" },
+1 -1
查看文件
@@ -128,7 +128,7 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
if (purchaseRequestId) {
return `/mobile/purchase-requests/${encodeSegment(purchaseRequestId)}`;
}
return "/mobile";
return "/mobile/purchase-requests";
}
const purchaseRequestDetailMatch = normalizedPath.match(/^\/purchase-requests\/([^/]+)$/);
+29 -5
查看文件
@@ -940,6 +940,7 @@ export function MobileApprovalsPage() {
const listTitle = taskTab === "pending" ? "待我审批" : "已处理";
const allVisibleSelected =
filteredTaskCards.length > 0 && filteredTaskCards.every((task) => selectedBatchTaskIds.includes(task.id));
const shouldShowDraftQueue = !isOnline || currentQueueItems.length > 0;
return (
<PageScaffold className="space-y-4">
@@ -1271,10 +1272,33 @@ export function MobileApprovalsPage() {
</div>
<StatusBadge status={selectedTask.task.status} label={workflowStatusLabel(selectedTask.task.status)} />
</div>
<div className="mt-3 space-y-1 text-zinc-600">
<div>{joinBusinessMeta([`类型:${entityLabel(selectedTask.workflowInstance?.entityType)}`, `业务状态:${businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}`])}</div>
<div>{joinBusinessMeta([selectedApplicantName ? `申请人:${selectedApplicantName}` : null, selectedMetric ? `金额/天数:${selectedMetric}` : null])}</div>
{selectedTask.task.completedAt ? <div>完成时间:{formatDateTime(selectedTask.task.completedAt)}</div> : null}
<div className="mobile-field-list mt-3">
<div className="mobile-field-row">
<div className="mobile-field-label">类型</div>
<div className="mobile-field-value">{entityLabel(selectedTask.workflowInstance?.entityType)}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">业务状态</div>
<div className="mobile-field-value">{businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}</div>
</div>
{selectedApplicantName ? (
<div className="mobile-field-row">
<div className="mobile-field-label">申请人</div>
<div className="mobile-field-value">{selectedApplicantName}</div>
</div>
) : null}
{selectedMetric ? (
<div className="mobile-field-row">
<div className="mobile-field-label">金额/天数</div>
<div className="mobile-field-value">{selectedMetric}</div>
</div>
) : null}
{selectedTask.task.completedAt ? (
<div className="mobile-field-row">
<div className="mobile-field-label">完成时间</div>
<div className="mobile-field-value">{formatDateTime(selectedTask.task.completedAt)}</div>
</div>
) : null}
</div>
{selectedTask.reimbursement ? (
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
@@ -1404,7 +1428,7 @@ export function MobileApprovalsPage() {
</section>
) : null}
{!isPushDetail ? (
{!isPushDetail && shouldShowDraftQueue ? (
<section className="mobile-card space-y-3" data-testid="mobile-approvals-queue">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">离线草稿</h3>
+226 -143
查看文件
@@ -1,5 +1,5 @@
import { useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation, useParams, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -29,6 +29,7 @@ import {
} from "../lib/approvalDisplay";
type LeaveStatusFilter = "all" | LeaveRequestStatus;
type LeaveView = "form" | "records";
const STATUS_TABS: Array<{ value: LeaveStatusFilter; label: string }> = [
{ value: "all", label: "全部" },
@@ -53,6 +54,14 @@ function leaveProgressFallback(status: LeaveRequestStatus | undefined) {
return "/mobile/approvals?progress=reviewing";
}
function isLeaveStatusFilter(value: string | null): value is LeaveStatusFilter {
return ["all", "draft", "reviewing", "approved", "rejected", "cancelled"].includes(value ?? "");
}
function resolveLeaveView(value: string | null): LeaveView {
return value === "form" ? "form" : "records";
}
function todayDate() {
return new Date().toISOString().slice(0, 10);
}
@@ -70,20 +79,23 @@ function LeaveRecordCard({
isSaving,
onEdit,
onSubmit,
detailTo,
currentMobileSource,
showActions = true,
}: {
item: LeaveRequestItem;
isSaving: boolean;
onEdit: (item: LeaveRequestItem) => void;
onSubmit: (item: LeaveRequestItem) => void;
detailTo?: string;
currentMobileSource?: string;
showActions?: boolean;
}) {
const canEdit = item.status === "draft" || item.status === "rejected";
const canSubmit = item.status === "draft" || item.status === "rejected";
const approvalHistory = item.approvalHistory ?? [];
return (
<article className="mobile-card" data-testid="mobile-leave-record-card">
const content = (
<>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{item.leaveType?.name ?? "请假"}</div>
@@ -115,16 +127,26 @@ function LeaveRecordCard({
{formatApprovalHistoryTime(approvalHistory[0].createdAt)}
</div>
) : null}
</>
);
return (
<article className="mobile-card" data-testid="mobile-leave-record-card">
{detailTo ? (
<Link className="block" to={detailTo} state={{ from: currentMobileSource }}>
{content}
</Link>
) : content}
{showActions && (canEdit || canSubmit) ? (
<div className="mobile-two-action-grid mt-4">
<div className="mt-3 flex flex-wrap gap-2">
{canEdit ? (
<Button variant="secondary" type="button" disabled={isSaving} onClick={() => onEdit(item)}>
<Button size="sm" variant="secondary" type="button" disabled={isSaving} onClick={() => onEdit(item)}>
编辑
</Button>
) : null}
{canSubmit ? (
<Button type="button" disabled={isSaving} onClick={() => onSubmit(item)}>
<Button size="sm" type="button" disabled={isSaving} onClick={() => onSubmit(item)}>
提交
</Button>
) : null}
@@ -136,10 +158,16 @@ function LeaveRecordCard({
export function MobileLeavePage() {
const queryClient = useQueryClient();
const location = useLocation();
const { leaveRequestId = "" } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const { permissions } = useAuth();
const canCreateLeave = hasPermission(permissions, "leave.create");
const [statusFilter, setStatusFilter] = useState<LeaveStatusFilter>("all");
const [view, setView] = useState<LeaveView>(() => resolveLeaveView(searchParams.get("view")));
const [statusFilter, setStatusFilter] = useState<LeaveStatusFilter>(() => {
const queryStatus = searchParams.get("status");
return isLeaveStatusFilter(queryStatus) ? queryStatus : "all";
});
const [editingItem, setEditingItem] = useState<LeaveRequestItem | null>(null);
const [leaveTypeId, setLeaveTypeId] = useState("");
const [startDate, setStartDate] = useState(todayDate);
@@ -149,6 +177,19 @@ export function MobileLeavePage() {
const [reason, setReason] = useState("");
const [attachmentFile, setAttachmentFile] = useState<File | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const currentMobileSource = useMemo(() => {
const params = new URLSearchParams(location.search);
params.set("view", view);
params.set("status", statusFilter);
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ""}`;
}, [location.pathname, location.search, statusFilter, view]);
useEffect(() => {
setView(resolveLeaveView(searchParams.get("view")));
const queryStatus = searchParams.get("status");
setStatusFilter(isLeaveStatusFilter(queryStatus) ? queryStatus : "all");
}, [searchParams]);
const leaveTypesQuery = useQuery({
queryKey: ["mobile", "leave", "types"],
@@ -273,6 +314,11 @@ export function MobileLeavePage() {
const isSaving = createMutation.isPending || updateMutation.isPending || submitMutation.isPending;
const startEditing = (item: LeaveRequestItem) => {
setView("form");
const nextParams = new URLSearchParams(searchParams);
nextParams.set("view", "form");
nextParams.set("status", statusFilter);
setSearchParams(nextParams, { replace: true });
setEditingItem(item);
setLeaveTypeId(item.leaveType?.id ?? "");
setStartDate(item.startDate);
@@ -284,6 +330,26 @@ export function MobileLeavePage() {
window.scrollTo({ top: 0, behavior: "smooth" });
};
function handleViewChange(value: string) {
const nextView = resolveLeaveView(value);
setView(nextView);
const nextParams = new URLSearchParams(searchParams);
nextParams.set("view", nextView);
nextParams.set("status", statusFilter);
setSearchParams(nextParams, { replace: true });
}
function handleStatusChange(value: string) {
if (!isLeaveStatusFilter(value)) {
return;
}
setStatusFilter(value);
const nextParams = new URLSearchParams(searchParams);
nextParams.set("view", "records");
nextParams.set("status", value);
setSearchParams(nextParams, { replace: true });
}
if (leaveRequestId) {
const detail = detailQuery.data;
@@ -318,158 +384,172 @@ export function MobileLeavePage() {
<PageScaffold className="space-y-3 pb-4">
<PageHeader title="请假" />
<section className="mobile-card" data-testid="mobile-leave-summary">
<div className="mobile-metric-grid text-sm">
<div>
<div className="text-xs text-zinc-500">可用</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.available.toFixed(1)}</div>
</div>
<div>
<div className="text-xs text-zinc-500">冻结</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.reserved.toFixed(1)}</div>
</div>
<div>
<div className="text-xs text-zinc-500">已用</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.used.toFixed(1)}</div>
</div>
</div>
<section className="mobile-card">
<Tabs value={view} onValueChange={handleViewChange}>
<TabsList className="grid h-auto w-full grid-cols-2">
<TabsTrigger value="records" data-testid="mobile-leave-view-records">请假记录</TabsTrigger>
<TabsTrigger value="form" data-testid="mobile-leave-view-form">新建请假</TabsTrigger>
</TabsList>
</Tabs>
</section>
<section className="mobile-card" data-testid="mobile-leave-form">
<div className="mb-4 flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">{editingItem ? "编辑请假" : "新建请假"}</h3>
{editingItem ? (
<Button variant="secondary" size="sm" type="button" onClick={resetForm}>
取消
</Button>
) : null}
</div>
{view === "form" ? (
<>
<section className="mobile-card" data-testid="mobile-leave-summary">
<div className="mobile-metric-grid text-sm">
<div>
<div className="text-xs text-zinc-500">可用</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.available.toFixed(1)}</div>
</div>
<div>
<div className="text-xs text-zinc-500">冻结</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.reserved.toFixed(1)}</div>
</div>
<div>
<div className="text-xs text-zinc-500">已用</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.used.toFixed(1)}</div>
</div>
</div>
</section>
<div className="space-y-3">
<label className="block space-y-2 text-sm text-zinc-700">
<span>假期类型</span>
<select className="select-std" value={leaveTypeId} onChange={(event) => setLeaveTypeId(event.target.value)}>
<option value="">请选择</option>
{leaveTypes.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</label>
<section className="mobile-card" data-testid="mobile-leave-form">
<div className="mb-4 flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">{editingItem ? "编辑请假" : "新建请假"}</h3>
{editingItem ? (
<Button variant="secondary" size="sm" type="button" onClick={resetForm}>
取消
</Button>
) : null}
</div>
<div className="mobile-info-grid">
<label className="block space-y-2 text-sm text-zinc-700">
<span>开始日期</span>
<input className="input-std" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} />
</label>
<label className="block space-y-2 text-sm text-zinc-700">
<span>结束日期</span>
<input className="input-std" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} />
</label>
</div>
<div className="space-y-3">
<label className="block space-y-2 text-sm text-zinc-700">
<span>假期类型</span>
<select className="select-std" value={leaveTypeId} onChange={(event) => setLeaveTypeId(event.target.value)}>
<option value="">请选择</option>
{leaveTypes.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</label>
<div className="mobile-info-grid">
<label className="block space-y-2 text-sm text-zinc-700">
<span>开始时段</span>
<select className="select-std" value={startSession} onChange={(event) => setStartSession(event.target.value as LeaveSession)}>
<option value="am">上午</option>
<option value="pm">下午</option>
</select>
</label>
<label className="block space-y-2 text-sm text-zinc-700">
<span>结束时段</span>
<select className="select-std" value={endSession} onChange={(event) => setEndSession(event.target.value as LeaveSession)}>
<option value="am">上午</option>
<option value="pm">下午</option>
</select>
</label>
</div>
<div className="mobile-info-grid">
<label className="block space-y-2 text-sm text-zinc-700">
<span>开始日期</span>
<input className="input-std" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} />
</label>
<label className="block space-y-2 text-sm text-zinc-700">
<span>结束日期</span>
<input className="input-std" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} />
</label>
</div>
<label className="block space-y-2 text-sm text-zinc-700">
<span>说明</span>
<textarea
className="input-std h-24 resize-none"
placeholder="请输入请假原因"
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
<div className="mobile-info-grid">
<label className="block space-y-2 text-sm text-zinc-700">
<span>开始时段</span>
<select className="select-std" value={startSession} onChange={(event) => setStartSession(event.target.value as LeaveSession)}>
<option value="am">上午</option>
<option value="pm">下午</option>
</select>
</label>
<label className="block space-y-2 text-sm text-zinc-700">
<span>结束时段</span>
<select className="select-std" value={endSession} onChange={(event) => setEndSession(event.target.value as LeaveSession)}>
<option value="am">上午</option>
<option value="pm">下午</option>
</select>
</label>
</div>
<label className="block space-y-2 text-sm text-zinc-700">
<span>说明</span>
<textarea
className="input-std h-24 resize-none"
placeholder="请输入请假原因"
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
<label className="block space-y-2 text-sm text-zinc-700">
<span>附件</span>
<input
ref={attachmentInputRef}
className="hidden"
type="file"
onChange={(event) => setAttachmentFile(event.target.files?.[0] ?? null)}
/>
<div className="flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => attachmentInputRef.current?.click()}
>
选择附件
</Button>
<span className="min-w-0 flex-1 truncate text-sm text-zinc-500" data-testid="mobile-leave-attachment-name">
{attachmentFile?.name ?? "未选择文件"}
</span>
</div>
</label>
<label className="block space-y-2 text-sm text-zinc-700">
<span>附件</span>
<input
ref={attachmentInputRef}
className="hidden"
type="file"
onChange={(event) => setAttachmentFile(event.target.files?.[0] ?? null)}
/>
<div className="flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2">
<Button
className="min-h-12 w-full"
type="button"
variant="secondary"
size="sm"
onClick={() => attachmentInputRef.current?.click()}
disabled={!canCreateLeave || isSaving || !leaveTypeId || !startDate || !endDate}
onClick={() => {
if (editingItem) {
updateMutation.mutate();
return;
}
createMutation.mutate();
}}
data-testid="mobile-leave-save"
>
选择附件
{editingItem ? "保存修改" : "创建草稿"}
</Button>
<span className="min-w-0 flex-1 truncate text-sm text-zinc-500" data-testid="mobile-leave-attachment-name">
{attachmentFile?.name ?? "未选择文件"}
</span>
</div>
</label>
</section>
<Button
className="min-h-12 w-full"
type="button"
disabled={!canCreateLeave || isSaving || !leaveTypeId || !startDate || !endDate}
onClick={() => {
if (editingItem) {
updateMutation.mutate();
return;
}
createMutation.mutate();
}}
data-testid="mobile-leave-save"
>
{editingItem ? "保存修改" : "创建草稿"}
</Button>
</div>
</section>
<section className="mobile-card" data-testid="mobile-leave-balances">
<h3 className="text-base font-semibold text-zinc-900">额度</h3>
<div className="mt-3 space-y-2">
{balancesQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
{!balancesQuery.isLoading && !balances.length ? <div className="text-sm text-zinc-500">暂无额度</div> : null}
{balances.slice(0, 3).map((item) => (
<div key={item.leaveType.id} className="mobile-list-card">
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{item.leaveType.name}</div>
<span className="badge-success">启用</span>
</div>
<div className="mobile-metric-grid mt-3 text-sm">
<div>
<div className="text-xs text-zinc-500">可用</div>
<div className="font-semibold text-zinc-900">{item.available}</div>
<section className="mobile-card" data-testid="mobile-leave-balances">
<h3 className="text-base font-semibold text-zinc-900">额度</h3>
<div className="mt-3 space-y-2">
{balancesQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
{!balancesQuery.isLoading && !balances.length ? <div className="text-sm text-zinc-500">暂无额度</div> : null}
{balances.slice(0, 3).map((item) => (
<div key={item.leaveType.id} className="mobile-list-card">
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{item.leaveType.name}</div>
<span className="badge-success">启用</span>
</div>
<div className="mobile-metric-grid mt-3 text-sm">
<div>
<div className="text-xs text-zinc-500">可用</div>
<div className="font-semibold text-zinc-900">{item.available}</div>
</div>
<div>
<div className="text-xs text-zinc-500">冻结</div>
<div className="font-semibold text-zinc-900">{item.reserved}</div>
</div>
<div>
<div className="text-xs text-zinc-500">已用</div>
<div className="font-semibold text-zinc-900">{item.used}</div>
</div>
</div>
</div>
<div>
<div className="text-xs text-zinc-500">冻结</div>
<div className="font-semibold text-zinc-900">{item.reserved}</div>
</div>
<div>
<div className="text-xs text-zinc-500">已用</div>
<div className="font-semibold text-zinc-900">{item.used}</div>
</div>
</div>
))}
</div>
))}
</div>
</section>
</section>
</>
) : null}
{view === "records" ? (
<section className="mobile-card" data-testid="mobile-leave-records">
<h3 className="text-base font-semibold text-zinc-900">请假记录</h3>
<Tabs value={statusFilter} onValueChange={(value) => setStatusFilter(value as LeaveStatusFilter)}>
<Tabs value={statusFilter} onValueChange={handleStatusChange}>
<TabsList className="mt-3 grid h-auto w-full grid-cols-5">
{STATUS_TABS.map((item) => (
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-leave-tab-${item.value}`}>
@@ -491,6 +571,8 @@ export function MobileLeavePage() {
) : null}
{records.map((item) => (
<LeaveRecordCard
currentMobileSource={currentMobileSource}
detailTo={`/mobile/leave/${encodeURIComponent(item.id)}`}
item={item}
isSaving={isSaving}
key={item.id}
@@ -500,6 +582,7 @@ export function MobileLeavePage() {
))}
</div>
</section>
) : null}
</PageScaffold>
);
}
+3
查看文件
@@ -13,6 +13,7 @@ import { MobileIcon } from "../components/mobile/MobileIcon";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { MOBILE_PURCHASE_PERMISSIONS } from "../features/mobile/navigation";
function resolveErrorMessage(error: unknown) {
if (isApiError(error)) {
@@ -62,6 +63,7 @@ export function MobileMePage() {
]);
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
const canAccessProjects = hasAnyPermission(permissions, ["project.view_own", "project.view_all", "project.manage"]);
const canAccessPurchase = hasAnyPermission(permissions, [...MOBILE_PURCHASE_PERMISSIONS]);
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
const profileQuery = useQuery({
@@ -124,6 +126,7 @@ export function MobileMePage() {
<MobileActionLink to="/mobile/notifications" label="通知中心" icon="notifications" />
<MobileActionLink to="/mobile/leave" label="请假" icon="event_available" />
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
{canAccessPurchase ? <MobileActionLink to="/mobile/purchase-requests" label="采购" icon="shopping_cart" /> : null}
<MobileActionLink to="/mobile/approvals" label={canAccessApprovals ? "审批" : "审批进度"} icon="fact_check" />
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
{canAccessProjects ? <MobileActionLink to="/mobile" label="项目动态" icon="workspaces" /> : null}
+15 -3
查看文件
@@ -23,7 +23,9 @@ import { Button } from "../components/ui/button";
import {
MOBILE_CAPTURE_PERMISSIONS,
MOBILE_ASSET_SCAN_PERMISSIONS,
MOBILE_HOME_ASSET_SCAN_PERMISSIONS,
MOBILE_LEAVE_PERMISSIONS,
MOBILE_PURCHASE_PERMISSIONS,
MOBILE_REIMBURSEMENT_PERMISSIONS,
} from "../features/mobile/navigation";
@@ -166,7 +168,9 @@ export function MobilePortalPage() {
const canUseFinance = hasFinanceRole(me?.roleCodes, permissions);
const canAccessCapture = hasAnyPermission(permissions, [...MOBILE_CAPTURE_PERMISSIONS]);
const canAccessAssetScan = hasAnyPermission(permissions, [...MOBILE_ASSET_SCAN_PERMISSIONS]);
const canShowAssetScanOnHome = hasAnyPermission(permissions, [...MOBILE_HOME_ASSET_SCAN_PERMISSIONS]);
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
const canAccessPurchase = hasAnyPermission(permissions, [...MOBILE_PURCHASE_PERMISSIONS]);
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
const canCreateExpense = hasAnyPermission(permissions, ["expense.create"]);
const currentMobileSource = `${location.pathname}${location.search}`;
@@ -297,10 +301,19 @@ export function MobilePortalPage() {
label: "请假",
meta: "提交申请",
icon: "event_available",
to: "/mobile/leave",
to: "/mobile/leave?view=form",
}
: null,
canAccessAssetScan
canAccessPurchase
? {
key: "purchase",
label: "采购",
meta: "查看申请",
icon: "shopping_cart",
to: "/mobile/purchase-requests",
}
: null,
canAccessAssetScan && canShowAssetScanOnHome
? {
key: "asset-scan",
label: "资产扫码",
@@ -349,7 +362,6 @@ export function MobilePortalPage() {
<div className="min-w-0 truncate font-medium text-zinc-900">
{reference ? `${businessEntityLabel(entityType, { noun: true })} ${reference}` : businessEntityLabel(entityType, { noun: true })}
</div>
<span className="badge-warning shrink-0">待处理</span>
</div>
<div className="mt-1 truncate text-xs text-zinc-500">
{joinBusinessMeta([task.businessSummary?.applicantName ? `申请人:${task.businessSummary.applicantName}` : null, task.businessSummary?.amount ?? task.businessSummary?.duration, `节点:${task.stepName}`])}
@@ -103,11 +103,37 @@ export function MobilePurchaseRequestDetailPage() {
<section className="mobile-card" data-testid="mobile-purchase-detail-fields">
<h3 className="text-base font-semibold text-zinc-900">业务信息</h3>
<div className="mt-3 space-y-2 text-sm text-zinc-700">
<div>{joinBusinessMeta([`供应商:${detail.supplier?.name ?? "-"}`, `期望到货:${formatDate(detail.expectedDeliveryDate)}`])}</div>
<div>{joinBusinessMeta([`法体:${detail.legalEntity?.name ?? "-"}`, `成本中心:${detail.costCenter?.name ?? "-"}`])}</div>
<div>{joinBusinessMeta([`预算池:${detail.budgetPool?.name ?? "未匹配"}`, `当前节点:${detail.pendingWorkflow?.task?.stepName ?? "-"}`])}</div>
{detail.reason ? <p className="leading-6 text-zinc-500">{detail.reason}</p> : null}
<div className="mobile-field-list mt-3">
<div className="mobile-field-row">
<div className="mobile-field-label">供应商</div>
<div className="mobile-field-value">{detail.supplier?.name ?? "-"}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">期望到货</div>
<div className="mobile-field-value">{formatDate(detail.expectedDeliveryDate)}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">法体</div>
<div className="mobile-field-value">{detail.legalEntity?.name ?? "-"}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">成本中心</div>
<div className="mobile-field-value">{detail.costCenter?.name ?? "-"}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">预算池</div>
<div className="mobile-field-value">{detail.budgetPool?.name ?? "未匹配"}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">当前节点</div>
<div className="mobile-field-value">{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
</div>
{detail.reason ? (
<div className="mobile-field-row">
<div className="mobile-field-label">申请说明</div>
<div className="mobile-field-value">{detail.reason}</div>
</div>
) : null}
</div>
</section>
@@ -0,0 +1,164 @@
import { useMemo, useState } from "react";
import { Link, useLocation, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
fetchPurchaseRequests,
type PurchaseRequestStatus,
type PurchaseRequestSummary,
} from "../api/purchaseRequests";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
import { joinBusinessMeta } from "../lib/businessDisplay";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
const STATUS_TABS: Array<{ value: PurchaseRequestStatus; label: string }> = [
{ value: "draft", label: "草稿" },
{ value: "reviewing", label: "审批中" },
{ value: "approved", label: "已通过" },
{ value: "rejected", label: "已驳回" },
{ value: "cancelled", label: "已取消" },
];
function isPurchaseStatus(value: string | null): value is PurchaseRequestStatus {
return ["draft", "reviewing", "approved", "rejected", "cancelled"].includes(value ?? "");
}
function formatCurrency(value: string | null | undefined, currencyCode = "CNY") {
const amount = Number(value ?? 0);
if (!Number.isFinite(amount)) {
return "-";
}
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: currencyCode }).format(amount);
}
function formatDate(value: string | null | undefined) {
return value ? value.slice(0, 10) : "-";
}
function PurchaseRequestCard({
item,
currentMobileSource,
}: {
item: PurchaseRequestSummary;
currentMobileSource: string;
}) {
const displayCode = resolvePurchaseDisplayCode(item);
return (
<Link
className="mobile-list-card block"
data-testid="mobile-purchase-request-card"
state={{ from: currentMobileSource }}
to={`/mobile/purchase-requests/${encodeURIComponent(item.id)}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{displayCode}</div>
<div className="mt-1 truncate text-xs text-zinc-500">
{joinBusinessMeta([
item.title || null,
item.applicant?.name ? `申请人:${item.applicant.name}` : null,
item.updatedAt ? `更新:${formatDate(item.updatedAt)}` : null,
])}
</div>
</div>
<StatusBadge className="shrink-0" status={item.status} />
</div>
<div className="mt-3 text-sm text-zinc-600">
{joinBusinessMeta([
`金额:${formatCurrency(item.totalAmount, item.currencyCode)}`,
item.supplier?.name ? `供应商:${item.supplier.name}` : null,
item.expectedDeliveryDate ? `到货:${formatDate(item.expectedDeliveryDate)}` : null,
])}
</div>
</Link>
);
}
export function MobilePurchaseRequestsPage() {
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const [status, setStatus] = useState<PurchaseRequestStatus>(() => {
const queryStatus = searchParams.get("status");
return isPurchaseStatus(queryStatus) ? queryStatus : "draft";
});
const currentMobileSource = useMemo(() => {
const params = new URLSearchParams(location.search);
params.set("status", status);
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ""}`;
}, [location.pathname, location.search, status]);
const listQuery = useQuery({
queryKey: ["mobile", "purchase-requests", status],
queryFn: () => fetchPurchaseRequests({ mine: true, status, page: 1, pageSize: 20 }),
});
function handleStatusChange(value: string) {
if (!isPurchaseStatus(value)) {
return;
}
setStatus(value);
const nextParams = new URLSearchParams(searchParams);
nextParams.set("status", value);
setSearchParams(nextParams, { replace: true });
}
const items = listQuery.data?.items ?? [];
const totalAmount = useMemo(
() => items.reduce((sum, item) => sum + Number(item.totalAmount ?? 0), 0),
[items],
);
return (
<PageScaffold className="space-y-4">
<PageHeader title="采购" />
<section className="mobile-card">
<div className="mobile-info-grid">
<div>
<div className="text-xs text-zinc-500">当前列表</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{listQuery.data?.total ?? 0}</div>
</div>
<div>
<div className="text-xs text-zinc-500">当前金额</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{formatCurrency(String(totalAmount))}</div>
</div>
</div>
</section>
<section className="mobile-card" data-testid="mobile-purchase-requests-list">
<Tabs value={status} onValueChange={handleStatusChange}>
<TabsList className="grid h-auto w-full grid-cols-3 gap-1 min-[390px]:grid-cols-5">
{STATUS_TABS.map((item) => (
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-purchase-tab-${item.value}`}>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="mt-4 space-y-3">
{listQuery.isLoading ? <div className="mobile-empty-state">加载中...</div> : null}
{listQuery.isError ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{resolveErrorMessage(listQuery.error)}
</div>
) : null}
{!listQuery.isLoading && !listQuery.isError && !items.length ? (
<div className="mobile-empty-state">暂无采购申请</div>
) : null}
{items.map((item) => (
<PurchaseRequestCard currentMobileSource={currentMobileSource} item={item} key={item.id} />
))}
</div>
</section>
</PageScaffold>
);
}
export default MobilePurchaseRequestsPage;
+30 -36
查看文件
@@ -67,51 +67,45 @@ function ReimbursementCard({
isDeleting: boolean;
onDelete: (item: ReimbursementListItem) => void;
}) {
const actionLabel = item.status === "draft" || item.status === "rejected" ? "继续编辑" : "查看详情";
const displayCode = resolveReimbursementDisplayCode(item);
return (
<article className="mobile-card" data-testid="mobile-reimbursement-card">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{displayCode}</div>
<div className="mt-1 text-xs text-zinc-500">{formatDate(item.reimbursementDate)}</div>
<Link className="block" to={`/mobile/reimbursements/${item.id}`} state={{ from: currentMobileSource }}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{displayCode}</div>
<div className="mt-1 text-xs text-zinc-500">{formatDate(item.reimbursementDate)}</div>
</div>
<span className="badge-neutral shrink-0">{STATUS_LABEL[item.status]}</span>
</div>
<span className="badge-neutral shrink-0">{STATUS_LABEL[item.status]}</span>
</div>
<div className="mobile-info-grid mt-4 text-sm">
<div>
<div className="text-xs text-zinc-500">金额</div>
<div className="mt-1 text-lg font-semibold text-zinc-900">{formatCurrency(item.totalAmount)}</div>
<div className="mt-3 text-sm text-zinc-600">
金额:{formatCurrency(item.totalAmount)}
{" · "}
报销人:{item.owner?.name ?? item.submitter?.name ?? "-"}
</div>
<div>
<div className="text-xs text-zinc-500">报销人</div>
<div className="mt-1 truncate text-zinc-900">{item.owner?.name ?? item.submitter?.name ?? "-"}</div>
</div>
</div>
{item.description ? <p className="mt-3 line-clamp-2 text-sm text-zinc-500">{item.description}</p> : null}
{item.description ? <p className="mt-2 line-clamp-2 text-sm text-zinc-500">{item.description}</p> : null}
</Link>
<div className={canDeleteDraft ? "mobile-two-action-grid mt-4" : "mt-4"}>
<Button asChild className="w-full" type="button">
<Link to={`/mobile/reimbursements/${item.id}`} state={{ from: currentMobileSource }}>
{actionLabel}
</Link>
</Button>
{canDeleteDraft ? (
<Button
className="w-full"
data-testid="mobile-reimbursement-delete"
disabled={isDeleting}
onClick={() => onDelete(item)}
type="button"
variant="destructive"
>
删除草稿
</Button>
) : null}
</div>
{canDeleteDraft ? (
<details className="mt-3 text-sm">
<summary className="cursor-pointer select-none text-zinc-500">更多</summary>
<div className="mt-2">
<Button
data-testid="mobile-reimbursement-delete"
disabled={isDeleting}
onClick={() => onDelete(item)}
size="sm"
type="button"
variant="destructive"
>
删除草稿
</Button>
</div>
</details>
) : null}
</article>
);
}
+12
查看文件
@@ -37,6 +37,7 @@ import {
MobilePortalPage,
MobileProfilePage,
MobilePurchaseRequestDetailPage,
MobilePurchaseRequestsPage,
MobileReimbursementDetailPage,
MobileReimbursementsPage,
NotificationChannelsPage,
@@ -355,6 +356,17 @@ export function AppRouter() {
}
/>
<Route
path="mobile/purchase-requests"
element={
<RequirePermission
anyOf={["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"]}
>
{withRouteFallback(<MobilePurchaseRequestsPage />)}
</RequirePermission>
}
/>
<Route
path="mobile/purchase-requests/:purchaseRequestId"
element={
@@ -0,0 +1 @@
export { MobilePurchaseRequestsPage as default } from "../../pages/MobilePurchaseRequestsPage";
+1
查看文件
@@ -24,6 +24,7 @@ export const MobileNotificationsPage = lazy(() => import("./MobileNotificationsP
export const MobilePortalPage = lazy(() => import("./MobilePortalPage"));
export const MobileProfilePage = lazy(() => import("./MobileProfilePage"));
export const MobilePurchaseRequestDetailPage = lazy(() => import("./MobilePurchaseRequestDetailPage"));
export const MobilePurchaseRequestsPage = lazy(() => import("./MobilePurchaseRequestsPage"));
export const MobileReimbursementDetailPage = lazy(() => import("./MobileReimbursementDetailPage"));
export const MobileReimbursementsPage = lazy(() => import("./MobileReimbursementsPage"));
export const NotificationChannelsPage = lazy(() => import("./NotificationChannelsPage"));
+26
查看文件
@@ -248,6 +248,32 @@
min-width: 0;
}
.mobile-field-list {
display: grid;
gap: 0.5rem;
min-width: 0;
}
.mobile-field-row {
display: grid;
grid-template-columns: minmax(4.5rem, 34%) minmax(0, 1fr);
gap: 0.5rem;
align-items: start;
min-width: 0;
font-size: 0.875rem;
line-height: 1.5rem;
}
.mobile-field-label {
color: rgb(113 113 122);
}
.mobile-field-value {
min-width: 0;
color: rgb(39 39 42);
overflow-wrap: anywhere;
}
.mobile-metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 5.75rem), 1fr));