R60 A1-A6:首页工作台优化

这个提交包含在:
2026-04-12 12:30:14 +08:00
父节点 dbab1d7b6e
当前提交 3f8c5144c2
共修改 7 个文件,包含 1514 行新增和 14 行删除
+145 -11
查看文件
@@ -8,12 +8,14 @@ import {
fetchMyTodayAttendance,
} from "../api/attendance";
import { fetchNotifications } from "../api/notifications";
import { fetchPortalTeamStatus } from "../api/portal";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { fetchMyPresence, upsertMyPresence, type PresenceItem } from "../api/presence";
import { fetchWorkflowTasks } from "../api/workflow";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
import { PageHeader } from "../components/common/PageHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
@@ -53,6 +55,21 @@ function formatDateTime(value: string | null) {
}
}
function formatShortTime(value: string | null) {
if (!value) {
return "--:--";
}
try {
return new Date(value).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
} catch {
return value;
}
}
function presenceStatusLabel(item: PresenceItem | null) {
if (!item) {
return "未更新";
@@ -123,6 +140,50 @@ function resolveNotificationTargetPath(
return "/notifications";
}
function workflowEntityLabel(entityType: string | null | undefined) {
if (entityType === "leave") return "请假";
if (entityType === "reimbursement") return "报销";
if (entityType === "purchase_request") return "采购";
if (entityType === "travel_request") return "差旅";
return entityType || "审批";
}
function resolveTeamStatusBadge(item: {
onLeave: boolean;
leaveType: string | null;
presenceStatus: string | null;
attendanceClockIn: string | null;
}) {
if (item.onLeave) {
return { label: item.leaveType ? `请假 · ${item.leaveType}` : "请假", className: "badge-warning" };
}
if (item.presenceStatus === "office") {
return { label: "在岗", className: "badge-success" };
}
if (item.presenceStatus === "out") {
return {
label: "外出",
className: "inline-flex items-center rounded-full bg-sky-50 px-2 py-0.5 text-[11px] font-semibold text-sky-700 ring-1 ring-inset ring-sky-600/10",
};
}
if (item.presenceStatus === "travel") {
return {
label: "差旅",
className: "inline-flex items-center rounded-full bg-sky-50 px-2 py-0.5 text-[11px] font-semibold text-sky-700 ring-1 ring-inset ring-sky-600/10",
};
}
if (item.presenceStatus === "remote") {
return { label: "居家", className: "badge-neutral" };
}
if (!item.attendanceClockIn) {
return { label: "未打卡", className: "badge-neutral" };
}
return {
label: "已打卡",
className: "inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-700 ring-1 ring-inset ring-slate-600/10",
};
}
function QuickLinkCard({
label,
description,
@@ -183,11 +244,13 @@ export function MobilePortalPage() {
const pendingCountQuery = useQuery({
enabled: canAccessApprovals,
queryKey: ["mobile", "workflow", "pending-count"],
queryFn: async () => {
const response = await fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 1 });
return response.total;
},
queryKey: ["mobile", "workflow", "pending-items"],
queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 5 }),
});
const teamStatusQuery = useQuery({
queryKey: ["mobile", "portal", "team-status"],
queryFn: () => fetchPortalTeamStatus(),
});
const notificationsQuery = useQuery({
@@ -249,6 +312,8 @@ export function MobilePortalPage() {
const presenceItem = presenceQuery.data?.item ?? null;
const attendanceToday = attendanceTodayQuery.data ?? null;
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
const teamStatusItems = teamStatusQuery.data ?? [];
const pendingTasks = pendingCountQuery.data?.items ?? [];
const notifications = notificationsQuery.data?.items ?? [];
return (
@@ -333,6 +398,48 @@ export function MobilePortalPage() {
</section>
) : null}
<section className="card-std space-y-4 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-zinc-900">团队状态</div>
<div className="mt-1 text-xs text-zinc-500">查看全员今日在岗、请假和打卡状态。</div>
</div>
<span className="badge-neutral">{teamStatusItems.length} 人</span>
</div>
{teamStatusQuery.isLoading ? <p className="text-sm text-zinc-500">加载团队状态中...</p> : null}
{teamStatusQuery.isError ? (
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(teamStatusQuery.error)}
</p>
) : null}
{!teamStatusQuery.isLoading && !teamStatusQuery.isError && !teamStatusItems.length ? (
<p className="text-sm text-zinc-500">暂无团队状态。</p>
) : null}
<div className="grid grid-cols-2 gap-3">
{teamStatusItems.map((item) => {
const badge = resolveTeamStatusBadge(item);
return (
<div key={item.employeeId} className="rounded-2xl border border-zinc-200 bg-white px-3 py-3 shadow-sm">
<div className="flex items-center gap-2">
<EmployeeAvatar name={item.name} src={item.avatarUrl} size="sm" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-zinc-900">{item.name}</div>
<div className="mt-1 text-[11px] text-zinc-500">
{item.attendanceClockIn ? `${formatShortTime(item.attendanceClockIn)} 已打卡` : "暂无打卡"}
</div>
</div>
</div>
<div className="mt-2">
<span className={badge.className}>{badge.label}</span>
</div>
</div>
);
})}
</div>
</section>
<section className="card-std space-y-4 p-4">
<div className="flex items-center justify-between gap-3">
<div>
@@ -369,20 +476,47 @@ export function MobilePortalPage() {
<section className="card-std space-y-3 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-zinc-900">待审批</div>
<div className="text-sm font-semibold text-zinc-900">待办聚合</div>
<div className="mt-1 text-xs text-zinc-500">
{canAccessApprovals ? "点击进入移动审批" : "当前账号没有移动审批权限"}
{canAccessApprovals ? "展示前 5 条审批待办,可直接进入移动审批。" : "当前账号没有移动审批权限"}
</div>
</div>
<span className={canAccessApprovals ? "badge-warning" : "badge-neutral"}>
{canAccessApprovals ? `${pendingCountQuery.data ?? 0} 条` : "不可用"}
{canAccessApprovals ? `${pendingCountQuery.data?.total ?? 0} 条` : "不可用"}
</span>
</div>
{canAccessApprovals ? (
<Button asChild type="button" className="w-full">
<Link to="/mobile/approvals">进入移动审批</Link>
</Button>
<>
<Button asChild type="button" className="w-full">
<Link to="/mobile/approvals">进入移动审批</Link>
</Button>
{pendingCountQuery.isLoading ? <p className="text-sm text-zinc-500">加载待办中...</p> : null}
{pendingCountQuery.isError ? (
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(pendingCountQuery.error)}
</p>
) : null}
{!pendingCountQuery.isLoading && !pendingTasks.length ? (
<p className="text-sm text-zinc-500">当前没有审批待办。</p>
) : null}
<div className="space-y-2">
{pendingTasks.map((task) => (
<Link
key={task.id}
to={buildMobileApprovalsPath(task.id)}
className="block rounded-2xl border border-zinc-200 bg-white px-4 py-3 text-sm shadow-sm transition-colors hover:bg-zinc-50"
>
<div className="font-medium text-zinc-900">{task.stepName}</div>
<div className="mt-1 text-xs text-zinc-500">
业务:{workflowEntityLabel(task.workflowInstance?.entityType)} · 实体:{task.workflowInstance?.entityId ?? "-"}
</div>
</Link>
))}
</div>
</>
) : (
<div className="rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-500">
你当前没有审批权限,审批页入口已禁用。