R101/A1-A7 收口企业微信移动端体验

这个提交包含在:
2026-05-06 22:34:08 +08:00
父节点 2a13fb7be5
当前提交 ed35967a65
共修改 31 个文件,包含 3582 行新增和 37 行删除
@@ -65,7 +65,9 @@ export function MobileBottomNav() {
className={({ isActive }) =>
cn(
"touch-target flex flex-col items-center justify-center gap-1 rounded-2xl px-2 py-2 text-xs font-medium transition-colors",
isActive ? "bg-zinc-900 text-white shadow-sm" : "text-zinc-500 hover:bg-zinc-50 hover:text-zinc-900",
isActive
? "bg-primary-50 text-primary ring-1 ring-primary-100"
: "text-zinc-500 hover:bg-zinc-50 hover:text-zinc-900",
)
}
>
@@ -77,7 +79,7 @@ export function MobileBottomNav() {
<span
className={cn(
"absolute -right-3 -top-2 min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none",
isActive ? "bg-white text-zinc-900" : "bg-red-500 text-white",
isActive ? "bg-primary text-white" : "bg-red-500 text-white",
)}
>
{badge}
+4 -6
查看文件
@@ -26,14 +26,12 @@ export function AppShellLayout() {
return (
<div className="relative flex min-h-screen overflow-hidden bg-[var(--color-bg-canvas)]">
<Sidebar />
{isMobileRoute ? null : <Sidebar />}
<main className="flex min-w-0 flex-1 flex-col lg:ml-72">
<div className={isMobileRoute ? "hidden md:block" : ""}>
<Topbar />
</div>
<main className={`flex min-w-0 flex-1 flex-col ${isMobileRoute ? "" : "lg:ml-72"}`}>
{isMobileRoute ? null : <Topbar />}
<div className={`flex-1 px-4 py-5 sm:px-7 sm:py-7 lg:px-8 ${isMobileRoute ? "pb-24 pt-4 md:pb-8" : ""}`}>
<div className={`flex-1 px-4 py-5 ${isMobileRoute ? "pb-24 pt-4 sm:px-5 sm:py-5" : "sm:px-7 sm:py-7 lg:px-8"}`}>
<div className="layout-container max-w-none">
<ErrorBoundary>
<Outlet />
+440
查看文件
@@ -0,0 +1,440 @@
import { useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
createLeaveRequest,
fetchLeaveBalances,
fetchLeaveRequests,
fetchLeaveTypes,
patchLeaveRequest,
submitLeaveRequest,
type LeaveRequestItem,
type LeaveRequestStatus,
type LeaveSession,
} from "../api/leave";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
type LeaveStatusFilter = "all" | LeaveRequestStatus;
const STATUS_TABS: Array<{ value: LeaveStatusFilter; label: string }> = [
{ value: "all", label: "全部" },
{ value: "draft", label: "草稿" },
{ value: "reviewing", label: "审批中" },
{ value: "approved", label: "已通过" },
];
const STATUS_LABEL: Record<LeaveRequestStatus, string> = {
draft: "草稿",
reviewing: "审批中",
approved: "已通过",
rejected: "已驳回",
cancelled: "已取消",
};
function todayDate() {
return new Date().toISOString().slice(0, 10);
}
function formatSession(value: LeaveSession) {
return value === "am" ? "上午" : "下午";
}
function formatLeaveRange(item: LeaveRequestItem) {
return `${item.startDate} ${formatSession(item.startSession)} - ${item.endDate} ${formatSession(item.endSession)}`;
}
function LeaveRecordCard({
item,
isSaving,
onEdit,
onSubmit,
}: {
item: LeaveRequestItem;
isSaving: boolean;
onEdit: (item: LeaveRequestItem) => void;
onSubmit: (item: LeaveRequestItem) => void;
}) {
const canEdit = item.status === "draft" || item.status === "rejected";
const canSubmit = item.status === "draft" || item.status === "rejected";
return (
<article className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-leave-record-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">{item.leaveType?.name ?? "请假"}</div>
<div className="mt-1 text-xs text-zinc-500">{formatLeaveRange(item)}</div>
</div>
<span className="badge-neutral shrink-0">{STATUS_LABEL[item.status]}</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
<div>
<div className="text-xs text-zinc-500">天数</div>
<div className="mt-1 text-lg font-semibold text-zinc-900">{item.requestedDays}</div>
</div>
<div>
<div className="text-xs text-zinc-500">附件</div>
<div className="mt-1 truncate text-zinc-900">{item.attachment?.originalName ?? "-"}</div>
</div>
</div>
{item.reason ? <p className="mt-3 line-clamp-2 text-sm text-zinc-500">{item.reason}</p> : null}
{canEdit || canSubmit ? (
<div className="mt-4 grid grid-cols-2 gap-2">
{canEdit ? (
<Button variant="secondary" type="button" disabled={isSaving} onClick={() => onEdit(item)}>
编辑
</Button>
) : null}
{canSubmit ? (
<Button type="button" disabled={isSaving} onClick={() => onSubmit(item)}>
提交
</Button>
) : null}
</div>
) : null}
</article>
);
}
export function MobileLeavePage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
const canCreateLeave = hasPermission(permissions, "leave.create");
const [statusFilter, setStatusFilter] = useState<LeaveStatusFilter>("all");
const [editingItem, setEditingItem] = useState<LeaveRequestItem | null>(null);
const [leaveTypeId, setLeaveTypeId] = useState("");
const [startDate, setStartDate] = useState(todayDate);
const [endDate, setEndDate] = useState(todayDate);
const [startSession, setStartSession] = useState<LeaveSession>("am");
const [endSession, setEndSession] = useState<LeaveSession>("pm");
const [reason, setReason] = useState("");
const [attachmentFile, setAttachmentFile] = useState<File | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const leaveTypesQuery = useQuery({
queryKey: ["mobile", "leave", "types"],
queryFn: () => fetchLeaveTypes({ enabled: true }),
});
const balancesQuery = useQuery({
queryKey: ["mobile", "leave", "balances"],
queryFn: () => fetchLeaveBalances(),
});
const requestsQuery = useQuery({
queryKey: ["mobile", "leave", "requests", statusFilter],
queryFn: () =>
fetchLeaveRequests({
status: statusFilter === "all" ? undefined : statusFilter,
scope: "own",
page: 1,
pageSize: 20,
}),
});
const invalidateLeaveQueries = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["mobile", "leave"] }),
queryClient.invalidateQueries({ queryKey: ["leave"] }),
]);
};
const resetForm = () => {
setEditingItem(null);
setLeaveTypeId("");
setStartDate(todayDate());
setEndDate(todayDate());
setStartSession("am");
setEndSession("pm");
setReason("");
setAttachmentFile(null);
if (attachmentInputRef.current) {
attachmentInputRef.current.value = "";
}
};
const createMutation = useMutation({
mutationFn: () =>
createLeaveRequest({
leaveTypeId,
startDate,
endDate,
startSession,
endSession,
reason,
file: attachmentFile,
}),
onSuccess: async () => {
toast.success("请假草稿已创建");
resetForm();
await invalidateLeaveQueries();
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const updateMutation = useMutation({
mutationFn: () => {
if (!editingItem) {
throw new Error("未选择请假单");
}
return patchLeaveRequest(editingItem.id, {
leaveTypeId,
startDate,
endDate,
startSession,
endSession,
reason,
file: attachmentFile,
});
},
onSuccess: async () => {
toast.success("请假单已保存");
resetForm();
await invalidateLeaveQueries();
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const submitMutation = useMutation({
mutationFn: (item: LeaveRequestItem) => submitLeaveRequest(item.id),
onSuccess: async () => {
toast.success("请假单已提交");
await invalidateLeaveQueries();
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const leaveTypes = leaveTypesQuery.data?.items ?? [];
const records = requestsQuery.data?.items ?? [];
const balances = balancesQuery.data?.items ?? [];
const balanceSummary = useMemo(
() =>
balances.reduce(
(summary, item) => ({
available: summary.available + item.available,
reserved: summary.reserved + item.reserved,
used: summary.used + item.used,
}),
{ available: 0, reserved: 0, used: 0 },
),
[balances],
);
const isSaving = createMutation.isPending || updateMutation.isPending || submitMutation.isPending;
const startEditing = (item: LeaveRequestItem) => {
setEditingItem(item);
setLeaveTypeId(item.leaveType?.id ?? "");
setStartDate(item.startDate);
setEndDate(item.endDate);
setStartSession(item.startSession);
setEndSession(item.endSession);
setReason(item.reason ?? "");
setAttachmentFile(null);
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<PageScaffold className="space-y-4">
<PageHeader title="请假" />
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-leave-summary">
<div className="grid grid-cols-3 gap-3 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>
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" 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="space-y-4">
<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="grid grid-cols-2 gap-3">
<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="grid grid-cols-2 gap-3">
<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>
<Button
className="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="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" 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="rounded-lg border border-zinc-200 p-3">
<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="mt-3 grid grid-cols-3 gap-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>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-leave-records">
<h3 className="text-base font-semibold text-zinc-900">请假记录</h3>
<Tabs value={statusFilter} onValueChange={(value) => setStatusFilter(value as LeaveStatusFilter)}>
<TabsList className="mt-3 grid h-auto w-full grid-cols-4">
{STATUS_TABS.map((item) => (
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-leave-tab-${item.value}`}>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="mt-4 space-y-3">
{requestsQuery.isLoading ? <div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">加载中...</div> : null}
{requestsQuery.isError ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{resolveErrorMessage(requestsQuery.error)}
</div>
) : null}
{!requestsQuery.isLoading && !requestsQuery.isError && !records.length ? (
<div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">暂无请假记录</div>
) : null}
{records.map((item) => (
<LeaveRecordCard
item={item}
isSaving={isSaving}
key={item.id}
onEdit={startEditing}
onSubmit={(nextItem) => submitMutation.mutate(nextItem)}
/>
))}
</div>
</section>
</PageScaffold>
);
}
export default MobileLeavePage;
+14 -20
查看文件
@@ -79,14 +79,11 @@ export function MobileMePage() {
return (
<PageScaffold className="space-y-4">
<PageHeader
title="我的"
subtitle="个人信息、常用入口和低频管理操作。"
/>
<PageHeader title="我的" />
<SectionPanel title="个人信息" description="头像与部门信息从完整员工档案读取。">
<SectionPanel title="个人信息">
<div className="flex items-center gap-4">
<EmployeeAvatar name={me?.name ?? "员工"} src={profile?.avatarUrl} size="xl" />
<EmployeeAvatar name={me?.name ?? "员工"} src={profile?.avatarUrl} size="lg" />
<div className="min-w-0 space-y-1">
<div className="text-lg font-semibold text-zinc-900">{me?.name ?? "-"}</div>
<div className="text-sm text-zinc-500">{departmentName}</div>
@@ -102,28 +99,28 @@ export function MobileMePage() {
) : null}
</SectionPanel>
<SectionPanel title="账号与绑定" description="个人设置里可维护企业微信绑定、通知偏好和账号资料。">
<SectionPanel title="账号与绑定">
<div className="grid gap-3">
<MobileActionLink to="/settings/profile" label="个人设置" description="绑定企业微信、维护账号与通知偏好" icon="manage_accounts" />
<MobileActionLink to="/settings/profile" label="个人设置" description="企业微信、账号与通知" icon="manage_accounts" />
{me?.id ? (
<MobileActionLink to={`/people/${me.id}`} label="完整员工档案" description="办公地点、紧急联系人和完整档案" icon="badge" />
<MobileActionLink to={`/people/${me.id}`} label="员工档案" description="办公地点和紧急联系人" icon="badge" />
) : null}
</div>
</SectionPanel>
<SectionPanel title="更多入口" description="低频模块放在这里,避免干扰移动首页。">
<SectionPanel title="更多入口">
<div className="grid gap-3" data-testid="mobile-more-links">
<MobileActionLink to="/notifications" label="通知中心" description="查看全部站内通知" icon="notifications" />
<MobileActionLink to="/leave-requests" label="请假" description="提交或查看请假申请" icon="event_available" />
<MobileActionLink to="/reimbursements" label="报销" description="提交报销和查看报销进度" icon="request_quote" />
{canAccessApprovals ? <MobileActionLink to="/mobile/approvals" label="审批" description="处理移动审批待办" icon="fact_check" /> : null}
{canAccessAssets ? <MobileActionLink to="/assets" label="资产" description="查看资产台账和盘点入口" icon="inventory_2" /> : null}
{canAccessProjects ? <MobileActionLink to="/projects" label="项目" description="查看项目、风险和里程碑" icon="workspaces" /> : null}
<MobileActionLink to="/mobile/leave" label="请假" description="申请和记录" icon="event_available" />
<MobileActionLink to="/mobile/reimbursements" label="报销" description="草稿和进度" icon="request_quote" />
{canAccessApprovals ? <MobileActionLink to="/mobile/approvals" label="审批" description="待办处理" icon="fact_check" /> : null}
{canAccessAssets ? <MobileActionLink to="/assets" label="资产" description="台账和盘点" icon="inventory_2" /> : null}
{canAccessProjects ? <MobileActionLink to="/projects" label="项目" description="项目和里程碑" icon="workspaces" /> : null}
</div>
</SectionPanel>
{canAccessAdmin ? (
<SectionPanel title="管理入口" description="管理员和负责人低频使用。">
<SectionPanel title="管理入口">
<div className="grid gap-3">
<MobileActionLink to="/people" label="人员管理" description="用户、角色和员工资料" icon="group" />
<MobileActionLink to="/organization" label="组织架构" description="部门和汇报关系" icon="account_tree" />
@@ -132,7 +129,7 @@ export function MobileMePage() {
</SectionPanel>
) : null}
<SectionPanel title="外部通知推送" description="关闭后仅停止外部渠道推送,站内通知仍保留。">
<SectionPanel title="外部通知推送">
<label className="flex items-center gap-3 text-sm text-zinc-800">
<input
type="checkbox"
@@ -143,9 +140,6 @@ export function MobileMePage() {
/>
接收外部渠道推送
</label>
<p className="mt-3 text-sm leading-6 text-zinc-500">
适用于审批待办、报销付款和资产流转提醒。关闭后不会影响站内通知或系统登录。
</p>
</SectionPanel>
<Button type="button" variant="secondary" className="w-full border-red-200 text-red-600 hover:bg-red-50" onClick={() => void logout()}>
+7 -9
查看文件
@@ -130,7 +130,7 @@ function ActionTile({ action }: { action: MobileAction }) {
to={action.to}
className={
isPrimary
? "flex min-h-24 flex-col justify-between rounded-lg bg-zinc-900 p-3 text-white shadow-sm"
? "flex min-h-24 flex-col justify-between rounded-lg border border-primary-100 bg-primary-50 p-3 text-primary shadow-sm"
: isWarning
? "flex min-h-24 flex-col justify-between rounded-lg border border-amber-200 bg-amber-50 p-3 text-amber-900"
: "flex min-h-24 flex-col justify-between rounded-lg border border-zinc-200 bg-white p-3 text-zinc-900 shadow-sm"
@@ -141,7 +141,7 @@ function ActionTile({ action }: { action: MobileAction }) {
<span className="text-sm font-semibold">{action.label}</span>
<span className="material-symbols-outlined text-[20px]">{action.icon}</span>
</div>
<div className={isPrimary ? "text-xs text-white/75" : "text-xs text-zinc-500"}>{action.meta}</div>
<div className={isPrimary ? "text-xs text-primary/70" : "text-xs text-zinc-500"}>{action.meta}</div>
</Link>
);
}
@@ -234,9 +234,9 @@ export function MobilePortalPage() {
? {
key: "reimbursements",
label: "报销",
meta: "新建和查进度",
meta: "新建与进度",
icon: "request_quote",
to: "/reimbursements",
to: "/mobile/reimbursements",
}
: null,
canAccessLeave
@@ -245,7 +245,7 @@ export function MobilePortalPage() {
label: "请假",
meta: "提交申请",
icon: "event_available",
to: "/leave-requests",
to: "/mobile/leave",
}
: null,
{
@@ -263,10 +263,8 @@ export function MobilePortalPage() {
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-today-focus">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">先处理这几件事</div>
<span className={pendingApprovalCount > 0 ? "badge-warning" : "badge-success"}>
{pendingApprovalCount > 0 ? `${pendingApprovalCount} 待批` : "清爽"}
</span>
<div className="text-base font-semibold text-zinc-900">待处理</div>
{pendingApprovalCount > 0 ? <span className="badge-warning">{pendingApprovalCount} 待批</span> : null}
</div>
<div className="grid grid-cols-2 gap-2">
{actions.map((action) => (
@@ -0,0 +1,175 @@
import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
createReimbursement,
fetchReimbursements,
type ReimbursementListItem,
type ReimbursementStatus,
} from "../api/reimbursements";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { Button } from "../components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { PageHeader } from "../components/common/PageHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
const STATUS_TABS: Array<{ value: ReimbursementStatus; label: string }> = [
{ value: "draft", label: "草稿" },
{ value: "reviewing", label: "审批中" },
{ value: "approved", label: "已通过" },
{ value: "paid", label: "已支付" },
];
const STATUS_LABEL: Record<ReimbursementStatus, string> = {
draft: "草稿",
reviewing: "审批中",
approved: "已通过",
paid: "已支付",
rejected: "已驳回",
cancelled: "已取消",
};
function formatCurrency(value: string | null | undefined) {
const numberValue = Number(value ?? 0);
if (!Number.isFinite(numberValue)) {
return "¥0.00";
}
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
}).format(numberValue);
}
function formatDate(value: string | null | undefined) {
return value || "未填写日期";
}
function ReimbursementCard({ item }: { item: ReimbursementListItem }) {
const actionLabel = item.status === "draft" || item.status === "rejected" ? "继续编辑" : "查看详情";
return (
<article className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" 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">{item.formNumber || item.id}</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>
<div className="mt-4 grid grid-cols-2 gap-3 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>
<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}
<Button asChild className="mt-4 w-full" type="button">
<Link to={`/reimbursements/${item.id}`}>{actionLabel}</Link>
</Button>
</article>
);
}
export function MobileReimbursementsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { permissions } = useAuth();
const [status, setStatus] = useState<ReimbursementStatus>("draft");
const canCreateExpense = hasPermission(permissions, "expense.create");
const listQuery = useQuery({
queryKey: ["mobile", "reimbursements", status],
queryFn: () => fetchReimbursements({ status, page: 1, pageSize: 20, scope: "own" }),
});
const createMutation = useMutation({
mutationFn: () => createReimbursement(),
onSuccess: async (response) => {
await queryClient.invalidateQueries({ queryKey: ["mobile", "reimbursements"] });
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
navigate(`/reimbursements/${response.id}`);
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const items = listQuery.data?.items ?? [];
const total = listQuery.data?.total ?? 0;
const totalAmount = useMemo(
() => items.reduce((sum, item) => sum + Number(item.totalAmount ?? 0), 0),
[items],
);
return (
<PageScaffold className="space-y-4">
<PageHeader
title="报销"
actions={
canCreateExpense ? (
<Button
data-testid="mobile-reimbursements-create"
disabled={createMutation.isPending}
onClick={() => createMutation.mutate()}
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
) : null
}
/>
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm">
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-zinc-500">当前列表</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{total}</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="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursements-list">
<Tabs value={status} onValueChange={(value) => setStatus(value as ReimbursementStatus)}>
<TabsList className="grid h-auto w-full grid-cols-4">
{STATUS_TABS.map((item) => (
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-reimbursements-tab-${item.value}`}>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="mt-4 space-y-3">
{listQuery.isLoading ? <div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">加载中...</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="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">暂无报销记录</div>
) : null}
{items.map((item) => (
<ReimbursementCard item={item} key={item.id} />
))}
</div>
</section>
</PageScaffold>
);
}
export default MobileReimbursementsPage;
+20
查看文件
@@ -31,8 +31,10 @@ import {
MobileApprovalsPage,
MobileAssetScanPage,
MobileCapturePage,
MobileLeavePage,
MobileMePage,
MobilePortalPage,
MobileReimbursementsPage,
NotificationChannelsPage,
NotificationsPage,
OrganizationPage,
@@ -309,6 +311,24 @@ export function AppRouter() {
}
/>
<Route
path="mobile/reimbursements"
element={
<RequirePermission anyOf={["expense.view_own", "expense.view_all"]}>
{withRouteFallback(<MobileReimbursementsPage />)}
</RequirePermission>
}
/>
<Route
path="mobile/leave"
element={
<RequirePermission anyOf={["leave.view_own", "leave.view_all"]}>
{withRouteFallback(<MobileLeavePage />)}
</RequirePermission>
}
/>
<Route
path="mobile/assets/scan"
element={
@@ -0,0 +1 @@
export { MobileLeavePage as default } from "../../pages/MobileLeavePage";
@@ -0,0 +1 @@
export { MobileReimbursementsPage as default } from "../../pages/MobileReimbursementsPage";
+2
查看文件
@@ -18,8 +18,10 @@ export const MasterDataPage = lazy(() => import("./MasterDataPage"));
export const MobileApprovalsPage = lazy(() => import("./MobileApprovalsPage"));
export const MobileAssetScanPage = lazy(() => import("./MobileAssetScanPage"));
export const MobileCapturePage = lazy(() => import("./MobileCapturePage"));
export const MobileLeavePage = lazy(() => import("./MobileLeavePage"));
export const MobileMePage = lazy(() => import("./MobileMePage"));
export const MobilePortalPage = lazy(() => import("./MobilePortalPage"));
export const MobileReimbursementsPage = lazy(() => import("./MobileReimbursementsPage"));
export const NotificationChannelsPage = lazy(() => import("./NotificationChannelsPage"));
export const NotificationsPage = lazy(() => import("./NotificationsPage"));
export const AnnouncementsPage = lazy(() => import("./AnnouncementsPage"));