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">
你当前没有审批权限,审批页入口已禁用。
+532 -2
查看文件
@@ -1,10 +1,23 @@
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { fetchPortalHome, type PortalHomePayload } from "../api/portal";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
clockInAttendance,
clockOutAttendance,
fetchMyTodayAttendance,
type AttendanceTodayResponse,
} from "../api/attendance";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
fetchPortalHome,
type PortalHomePayload,
type PortalRecentActivity,
type PortalTeamStatusItem,
} from "../api/portal";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, isManagerRole } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
import { EmptyStateCard } from "../components/common/EmptyStateCard";
import { MetricCard } from "../components/patterns/MetricCard";
import { PageScaffold } from "../components/patterns/PageScaffold";
@@ -23,6 +36,32 @@ type SummaryItem = {
tone?: "normal" | "warning";
};
type TodoAggregateItem = {
title: string;
sourceLabel: string;
severity: "normal" | "warning" | "high";
href: string;
icon: string;
};
type QuickActionConfig = {
key: string;
icon: string;
label: string;
description: string;
to?: string;
onClick?: () => void;
disabled?: boolean;
};
const LEAVE_PORTAL_PERMISSIONS = ["leave.view_own", "leave.view_all", "leave.create", "leave.submit"];
const EXPENSE_PORTAL_PERMISSIONS = ["expense.view_own", "expense.view_all", "expense.create", "expense.proxy_manage"];
const TODO_SEVERITY_RANK = {
high: 3,
warning: 2,
normal: 1,
} as const;
function isSummaryItem(item: SummaryItem | null): item is SummaryItem {
return Boolean(item);
}
@@ -47,6 +86,251 @@ function formatWeeklyStatus(payload: PortalHomePayload["weeklyReport"]) {
return payload.status === "submitted" ? "已提交" : "待填写";
}
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 isAttendanceFeatureDisabled(error: unknown) {
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
}
function formatRelativeTime(value: string | null) {
if (!value) {
return "-";
}
const timestamp = new Date(value).getTime();
if (Number.isNaN(timestamp)) {
return "-";
}
const diffMs = Date.now() - timestamp;
if (diffMs < 60 * 1000) {
return "刚刚";
}
const diffMinutes = Math.floor(diffMs / (60 * 1000));
if (diffMinutes < 60) {
return `${diffMinutes} 分钟前`;
}
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) {
return `${diffHours} 小时前`;
}
const diffDays = Math.floor(diffHours / 24);
return `${diffDays} 天前`;
}
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 formatWorkMinutes(value: number | null | undefined) {
if (value == null) {
return "-";
}
const hours = Math.floor(value / 60);
const minutes = value % 60;
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
}
function buildTodoAggregateItems(payload: PortalHomePayload) {
const items: TodoAggregateItem[] = [];
if (payload.pendingTasks.count > 0) {
items.push({
title: `审批待办 ${payload.pendingTasks.count} 条`,
sourceLabel: "审批",
severity: "warning",
href: payload.pendingTasks.href,
icon: "fact_check",
});
}
if ((payload.todoSummary?.openCount ?? 0) > 0) {
items.push({
title: `TODO 待办 ${payload.todoSummary?.openCount ?? 0} 条`,
sourceLabel: "TODO",
severity: (payload.todoSummary?.highPriorityCount ?? 0) > 0 ? "high" : "normal",
href: payload.todoSummary?.href ?? "/todos",
icon: "check_circle",
});
}
for (const card of payload.cards) {
if (!card.severity || (card.severity !== "warning" && card.severity !== "high")) {
continue;
}
items.push({
title: `${card.title}:${card.value}`,
sourceLabel: "工作台",
severity: card.severity,
href: card.href,
icon: card.severity === "high" ? "priority_high" : "warning",
});
}
const deduped = new Map<string, TodoAggregateItem>();
for (const item of items) {
deduped.set(`${item.title}|${item.href}|${item.sourceLabel}`, item);
}
return Array.from(deduped.values())
.sort((left, right) => {
const severityCompare = TODO_SEVERITY_RANK[right.severity] - TODO_SEVERITY_RANK[left.severity];
if (severityCompare !== 0) {
return severityCompare;
}
return left.title.localeCompare(right.title, "zh-CN");
})
.slice(0, 10);
}
function resolveTeamStatusBadge(item: PortalTeamStatusItem) {
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.5 py-0.5 text-xs 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.5 py-0.5 text-xs 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.5 py-0.5 text-xs font-semibold text-slate-700 ring-1 ring-inset ring-slate-600/10",
};
}
function formatTeamStatusAttendance(item: PortalTeamStatusItem) {
if (item.onLeave) {
return item.leaveType ? `${item.leaveType} 已审批` : "今日请假";
}
if (item.attendanceClockIn && item.attendanceClockOut) {
return `${formatShortTime(item.attendanceClockIn)} - ${formatShortTime(item.attendanceClockOut)}`;
}
if (item.attendanceClockIn) {
return `${formatShortTime(item.attendanceClockIn)} 已打卡`;
}
if (item.presenceStatus === "travel") {
return "今日差旅";
}
if (item.presenceStatus === "out") {
return "当前外出";
}
if (item.presenceStatus === "remote") {
return "今日居家办公";
}
return "暂无打卡记录";
}
function resolveClockAction(
attendanceToday: AttendanceTodayResponse | null,
state: {
isLoading: boolean;
isError: boolean;
featureDisabled: boolean;
pending: boolean;
},
) {
if (state.pending) {
return {
label: "处理中...",
description: "正在提交考勤动作",
disabled: true,
action: null,
} as const;
}
if (state.isLoading) {
return {
label: "打卡读取中",
description: "正在读取今日考勤状态",
disabled: true,
action: null,
} as const;
}
if (state.isError && !state.featureDisabled) {
return {
label: "打卡不可用",
description: "当前无法读取今日考勤状态",
disabled: true,
action: null,
} as const;
}
if (!attendanceToday) {
return {
label: "今日考勤",
description: "暂无可用考勤数据",
disabled: true,
action: null,
} as const;
}
if (!attendanceToday.needClockIn) {
return {
label: "今日无需打卡",
description: "系统已自动排除缺卡统计",
disabled: true,
action: null,
} as const;
}
if (!attendanceToday.item?.clockInAt) {
return {
label: "上班打卡",
description: "记录今日到岗时间",
disabled: false,
action: "clock-in",
} as const;
}
if (!attendanceToday.item.clockOutAt) {
return {
label: "下班打卡",
description: `已上班 ${formatShortTime(attendanceToday.item.clockInAt)}`,
disabled: false,
action: "clock-out",
} as const;
}
return {
label: "今日已完成",
description: `工时 ${formatWorkMinutes(attendanceToday.item.workMinutes)}`,
disabled: true,
action: null,
} as const;
}
function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] }) {
if (!items.length) {
return <EmptyStateCard title={title} description="暂无需要优先处理的摘要信息。" />;
@@ -197,12 +481,196 @@ function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: NonNullabl
);
}
function QuickActionTile({
icon,
label,
description,
to,
onClick,
disabled = false,
}: QuickActionConfig) {
const baseClassName = disabled
? "rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-left text-zinc-400"
: "rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-left shadow-sm transition-colors hover:bg-zinc-50";
const content = (
<>
<div className="flex items-center gap-3">
<div className={disabled
? "flex size-10 items-center justify-center rounded-2xl bg-zinc-100 text-zinc-400"
: "flex size-10 items-center justify-center rounded-2xl bg-primary/10 text-primary"}
>
<span className="material-symbols-outlined text-[20px]">{icon}</span>
</div>
<div className="min-w-0">
<div className={disabled ? "font-medium text-zinc-500" : "font-medium text-zinc-900"}>{label}</div>
<div className="mt-1 text-xs text-zinc-500">{description}</div>
</div>
</div>
</>
);
if (to && !disabled) {
return <Link to={to} className={baseClassName}>{content}</Link>;
}
if (onClick) {
return (
<button type="button" className={baseClassName} disabled={disabled} onClick={onClick}>
{content}
</button>
);
}
return <div className={baseClassName}>{content}</div>;
}
function QuickActionsPanel({
items,
}: {
items: QuickActionConfig[];
}) {
return (
<SectionPanel title="快捷操作栏" description="把每天高频动作直接前置到首页,不再绕路。">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{items.length ? items.map((item) => (
<QuickActionTile
key={item.key}
icon={item.icon}
label={item.label}
description={item.description}
to={item.to}
onClick={item.onClick}
disabled={item.disabled}
/>
)) : <EmptyStateCard title="暂无快捷动作" description="当前账号没有可直接执行的首页动作。" />}
</div>
</SectionPanel>
);
}
function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
return (
<SectionPanel title="今日待办聚合" description="合并审批、TODO 和高风险指标,只保留今天最该处理的事。">
<div className="space-y-3">
{items.length ? items.map((item) => (
<Link key={`${item.title}-${item.href}-${item.sourceLabel}`} to={item.href} className="block rounded-2xl border border-zinc-200 bg-white px-4 py-4 hover:bg-zinc-50">
<div className="flex items-start gap-3">
<div className={item.severity === "high"
? "mt-0.5 flex size-9 items-center justify-center rounded-2xl bg-red-50 text-red-600"
: item.severity === "warning"
? "mt-0.5 flex size-9 items-center justify-center rounded-2xl bg-amber-50 text-amber-600"
: "mt-0.5 flex size-9 items-center justify-center rounded-2xl bg-zinc-100 text-zinc-600"}
>
<span className="material-symbols-outlined text-[18px]">{item.icon}</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium text-zinc-900">{item.title}</div>
<span className="badge-neutral">{item.sourceLabel}</span>
{item.severity === "high" ? <span className="badge-error">高优先</span> : null}
{item.severity === "warning" ? <span className="badge-warning">关注</span> : null}
</div>
<div className="mt-2 text-sm text-primary">进入处理</div>
</div>
</div>
</Link>
)) : <EmptyStateCard title="暂无待办事项,休息一下吧" description="当前没有需要立刻处理的审批、TODO 或风险提醒。" />}
</div>
</SectionPanel>
);
}
function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) {
return (
<SectionPanel title="团队全员状态" description="同时看人在不在岗、有没有请假,以及今天是否已打卡。">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{items.length ? items.map((item) => {
const badge = resolveTeamStatusBadge(item);
return (
<div key={item.employeeId} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4">
<div className="flex items-center gap-3">
<EmployeeAvatar name={item.name} src={item.avatarUrl} size="sm" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-zinc-900">{item.name}</div>
<div className="mt-1 text-xs text-zinc-500">{formatTeamStatusAttendance(item)}</div>
</div>
<span className={badge.className}>{badge.label}</span>
</div>
</div>
);
}) : <EmptyStateCard title="暂无团队状态" description="当前没有可展示的团队成员状态。" />}
</div>
</SectionPanel>
);
}
function RecentActivitiesPanel({ items }: { items: PortalRecentActivity[] }) {
return (
<SectionPanel title="近期动态流" description="把最近发生的报销、请假、考勤和资产动作放在同一条时间线上。">
<div className="space-y-3">
{items.length ? items.map((item) => (
<Link key={item.id} to={item.href} className="block rounded-2xl border border-zinc-200 bg-white px-4 py-4 hover:bg-zinc-50">
<div className="flex items-start gap-3">
<EmployeeAvatar name={item.actorName} size="sm" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium leading-6 text-zinc-900">{item.summary}</div>
<div className="mt-1 text-xs text-zinc-500">{formatRelativeTime(item.createdAt)}</div>
</div>
</div>
</Link>
)) : <EmptyStateCard title="暂无近期动态" description="当前没有可展示的最近业务动作。" />}
</div>
</SectionPanel>
);
}
export function PortalHomePage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() });
const attendanceTodayQuery = useQuery({
queryKey: ["portal-home", "attendance", "today"],
queryFn: () => fetchMyTodayAttendance(),
retry: (failureCount, error) => !isAttendanceFeatureDisabled(error) && failureCount < 2,
});
const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
const isManagerView = isManagerRole(permissions);
const attendanceClockInMutation = useMutation({
mutationFn: () => clockInAttendance(),
onSuccess: async (response) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home", "attendance", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
]);
toast.success(
response.item.isLate
? `上班打卡成功,已迟到 ${response.item.lateMinutes} 分钟`
: "上班打卡成功",
);
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const attendanceClockOutMutation = useMutation({
mutationFn: () => clockOutAttendance(),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home", "attendance", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
]);
toast.success("下班打卡成功");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
if (portalQuery.isLoading) {
return <div className="card-std p-6 text-sm text-zinc-500">加载 Portal 中...</div>;
}
@@ -219,6 +687,15 @@ export function PortalHomePage() {
const primaryCards = pickCards(payload.cards, portalSchema.heroCards);
const primaryCardKeySet = primaryCards.map((card) => card.key);
const secondaryCards = compactCards(payload.cards, primaryCardKeySet);
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
const attendanceToday = attendanceTodayQuery.data ?? null;
const clockAction = resolveClockAction(attendanceToday, {
isLoading: attendanceTodayQuery.isLoading,
isError: attendanceTodayQuery.isError,
featureDisabled: attendanceFeatureDisabled,
pending: attendanceClockInMutation.isPending || attendanceClockOutMutation.isPending,
});
const todoAggregateItems = buildTodoAggregateItems(payload);
const employeeSummaryItems = ([
{
@@ -272,6 +749,49 @@ export function PortalHomePage() {
: null,
] as Array<SummaryItem | null>).filter(isSummaryItem);
const quickActions: QuickActionConfig[] = [];
if (hasAnyPermission(permissions, LEAVE_PORTAL_PERMISSIONS)) {
quickActions.push({
key: "leave",
icon: "event_available",
label: "提交请假",
description: "直接进入请假申请页",
to: "/leave-requests?action=create",
});
}
if (hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS)) {
quickActions.push({
key: "expense",
icon: "request_quote",
label: "提交报销",
description: "进入报销工作台创建单据",
to: "/reimbursements",
});
}
if (!attendanceFeatureDisabled) {
quickActions.push({
key: "attendance",
icon: clockAction.action === "clock-out" ? "logout" : "schedule",
label: clockAction.label,
description: clockAction.description,
disabled: clockAction.disabled,
onClick: clockAction.action === "clock-in"
? () => attendanceClockInMutation.mutate()
: clockAction.action === "clock-out"
? () => attendanceClockOutMutation.mutate()
: undefined,
});
}
if (payload.weeklyReport || payload.weeklySummary) {
quickActions.push({
key: "weekly",
icon: "article",
label: "提交周报",
description: "进入周报页继续填写或查看团队缺口",
to: "/weekly-reports",
});
}
return (
<PageScaffold>
<WorkbenchHero
@@ -309,6 +829,16 @@ export function PortalHomePage() {
))}
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
<QuickActionsPanel items={quickActions} />
<TodoAggregatePanel items={todoAggregateItems} />
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr),minmax(0,0.85fr)]">
<TeamStatusBoardPanel items={payload.teamStatusBoard} />
<RecentActivitiesPanel items={payload.recentActivities} />
</div>
{isManagerView ? (
<>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.05fr),minmax(0,0.95fr)]">