feat: R27-2 R27-5 优化企微入口与 Portal 体验

这个提交包含在:
2026-03-22 19:52:54 +08:00
父节点 eaef213d22
当前提交 312200ec1b
共修改 16 个文件,包含 1213 行新增和 315 行删除
+148 -76
查看文件
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "react-router-dom";
import { fetchSsoProviders, fetchWecomConfig } from "../api/auth";
@@ -8,20 +8,33 @@ import { useAuth } from "../auth/AuthContext";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { isWecomWebView } from "../lib/runtimeContext";
import {
buildWecomLoginUrl,
resolveWecomBindNotice,
resolveWecomEntryStatus,
resolveWecomLoginNotice,
type WecomNotice,
} from "../lib/wecomEntry";
function resolveRedirect(searchParams: URLSearchParams) {
const redirect = searchParams.get("redirect");
if (!redirect) {
if (!redirect || !redirect.startsWith("/") || redirect.startsWith("//")) {
return "/portal";
}
if (!redirect.startsWith("/")) {
return "/portal";
}
return redirect;
}
function noticeClasses(tone: WecomNotice["tone"]) {
if (tone === "success") {
return "border-emerald-200 bg-emerald-50 text-emerald-700";
}
if (tone === "warning") {
return "border-amber-200 bg-amber-50 text-amber-700";
}
return "border-zinc-200 bg-zinc-50 text-zinc-700";
}
export function LoginPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -33,18 +46,29 @@ export function LoginPage() {
const [error, setError] = useState("");
const redirectTo = resolveRedirect(searchParams);
const isWecomContext = isWecomWebView();
const ssoProvidersQuery = useQuery({
queryKey: ["auth", "sso-providers"],
queryFn: () => fetchSsoProviders(),
staleTime: 60_000,
});
const oidcProvider = (ssoProvidersQuery.data ?? []).find((item) => item.id === "oidc" && item.enabled);
const wecomConfigQuery = useQuery({
queryKey: ["auth", "wecom-config"],
queryFn: () => fetchWecomConfig(),
staleTime: 60_000,
});
const wecomEntry = wecomConfigQuery.data?.enabled ? wecomConfigQuery.data : null;
const wecomConfig = wecomConfigQuery.data;
const wecomStatus = resolveWecomEntryStatus(wecomConfig);
const wecomEntry = wecomConfig?.entryEnabled ? wecomConfig : null;
const primaryMode = isWecomContext && wecomEntry?.loginUrl ? "wecom" : "password";
const wecomNotice = useMemo(
() => resolveWecomBindNotice(searchParams.get("wecomBind")) ?? resolveWecomLoginNotice(searchParams.get("wecom")),
[searchParams],
);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -71,85 +95,133 @@ export function LoginPage() {
}
}
function handleWecomLogin() {
if (!wecomEntry?.loginUrl) {
return;
}
window.location.assign(buildWecomLoginUrl(wecomEntry.loginUrl, redirectTo));
}
return (
<div className="card-std space-y-6 border-primary-100 p-6">
<div className="mb-6">
<div className="card-std overflow-hidden border-primary-100 p-0">
<div className="border-b border-zinc-200 bg-gradient-to-br from-primary-50 via-white to-zinc-50 p-6">
<p className="text-xs font-semibold uppercase tracking-wide text-primary">hyb-oa</p>
<h1 className="mt-2 text-2xl font-bold text-zinc-900">登录工作台</h1>
<p className="mt-2 text-sm text-secondary">使用邮箱/手机号与密码登录。</p>
<p className="mt-1 text-xs text-zinc-500">忘记密码:请联系管理员重置。</p>
<p className="mt-2 text-sm text-secondary">审批、项目、周报和团队状态统一在 OA 完成。</p>
<div className="mt-4 rounded-xl border border-zinc-200 bg-white/90 px-4 py-3 text-sm text-zinc-600">
{isWecomContext
? "当前在企业微信中打开,推荐直接使用企业微信进入工作台。"
: "桌面浏览器可直接使用账号密码登录;企业微信仅作为入口与提醒。"}
</div>
</div>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-1">
<Label htmlFor="identifier">账号</Label>
<Input
autoComplete="username"
data-testid="login-identifier"
id="identifier"
onChange={(event) => setIdentifier(event.target.value)}
placeholder="邮箱或手机号"
value={identifier}
/>
</div>
<div className="space-y-1">
<Label htmlFor="password">密码</Label>
<Input
autoComplete="current-password"
data-testid="login-password"
id="password"
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
type="password"
value={password}
/>
</div>
{error ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
data-testid="login-error"
>
{error}
</p>
<div className="space-y-5 p-6">
{wecomNotice ? (
<div className={`rounded-xl border px-4 py-3 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice">
<div className="font-medium">{wecomNotice.title}</div>
<div className="mt-1">{wecomNotice.description}</div>
</div>
) : null}
<Button data-testid="login-submit" className="w-full" disabled={submitting} type="submit">
{submitting ? "登录中..." : "登录"}
</Button>
</form>
{oidcProvider || wecomEntry ? (
<div className="space-y-3 border-t border-zinc-200 pt-4">
<p className="text-xs uppercase tracking-wide text-zinc-500">企业统一入口</p>
{wecomEntry?.loginUrl ? (
<Button
className="w-full"
type="button"
variant="secondary"
onClick={() => {
const loginUrl = wecomEntry.loginUrl ?? "/api/auth/wecom/login";
window.location.assign(loginUrl.replace(/returnTo=%2Fportal$/, `returnTo=${encodeURIComponent(redirectTo)}`));
}}
>
{primaryMode === "wecom" && wecomEntry?.loginUrl ? (
<div className="space-y-3 rounded-2xl border border-primary-100 bg-primary-50/70 p-5" data-testid="login-wecom-primary-panel">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-primary">推荐入口</p>
<h2 className="mt-1 text-lg font-semibold text-zinc-900">使用企业微信进入工作台</h2>
<p className="mt-1 text-sm text-zinc-600">在企业微信工作台打开时,优先使用免登入口;失败时仍可回退到账号密码登录。</p>
</div>
<Button className="w-full" data-testid="wecom-entry-primary" type="button" onClick={handleWecomLogin}>
使用企业微信进入工作台
</Button>
) : null}
{oidcProvider ? (
<Button
className="w-full"
type="button"
variant="secondary"
onClick={() => {
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
}}
>
使用{oidcProvider.label}
<p className="text-xs text-zinc-500">若当前企业微信账号尚未绑定,请先使用账号密码登录,再到个人设置完成绑定。</p>
</div>
) : null}
<div className="space-y-4 rounded-2xl border border-zinc-200 p-5" data-testid={primaryMode === "password" ? "login-password-primary-panel" : "login-password-secondary-panel"}>
<div>
<h2 className="text-lg font-semibold text-zinc-900">账号密码登录</h2>
<p className="mt-1 text-sm text-zinc-500">
{primaryMode === "password"
? "使用公司邮箱或手机号登录,复杂填写与审批仍建议在 Web OA 内完成。"
: "企业微信不可用时,可改用账号密码登录。"}
</p>
<p className="mt-1 text-xs text-zinc-500">忘记密码:请联系管理员重置。</p>
</div>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-1">
<Label htmlFor="identifier">账号</Label>
<Input
autoComplete="username"
data-testid="login-identifier"
id="identifier"
onChange={(event) => setIdentifier(event.target.value)}
placeholder="邮箱或手机号"
value={identifier}
/>
</div>
<div className="space-y-1">
<Label htmlFor="password">密码</Label>
<Input
autoComplete="current-password"
data-testid="login-password"
id="password"
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
type="password"
value={password}
/>
</div>
{error ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
data-testid="login-error"
>
{error}
</p>
) : null}
<Button data-testid="login-submit" className="w-full" disabled={submitting} type="submit" variant={primaryMode === "password" ? "default" : "secondary"}>
{submitting ? "登录中..." : "登录"}
</Button>
) : null}
</form>
</div>
) : null}
{((primaryMode !== "wecom" && wecomEntry?.loginUrl) || oidcProvider) ? (
<div className="space-y-3 rounded-2xl border border-zinc-200 bg-zinc-50 p-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-zinc-500">其他入口</p>
<p className="mt-1 text-sm text-zinc-600">企业微信负责打开 OA 和接收提醒,实际业务仍在 OA 内完成。</p>
</div>
{primaryMode !== "wecom" && wecomEntry?.loginUrl ? (
<Button className="w-full" data-testid="wecom-entry-secondary" type="button" variant="secondary" onClick={handleWecomLogin}>
使用企业微信进入工作台
</Button>
) : null}
{oidcProvider ? (
<Button
className="w-full"
type="button"
variant="secondary"
onClick={() => {
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
}}
>
使用{oidcProvider.label}
</Button>
) : null}
</div>
) : null}
{isWecomContext && !wecomEntry?.loginUrl && !wecomConfigQuery.isLoading ? (
<div className={`rounded-xl border px-4 py-3 text-sm ${noticeClasses(wecomStatus.tone)}`}>
<div className="font-medium">企业微信入口当前不可用</div>
<div className="mt-1">{wecomStatus.description}</div>
</div>
) : null}
</div>
</div>
);
}
+344 -146
查看文件
@@ -1,6 +1,6 @@
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { fetchPortalHome } from "../api/portal";
import { fetchPortalHome, type PortalHomePayload } from "../api/portal";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { PageHeader } from "../components/common/PageHeader";
@@ -9,17 +9,226 @@ import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { Button } from "../components/ui/button";
function domainLabel(domain: string) {
if (domain === "reimbursement") return "报销";
if (domain === "leave") return "请假";
return domain;
type SummaryItem = {
key: string;
href: string;
label: string;
value: string;
note?: string;
tone?: "normal" | "warning";
};
function isSummaryItem(item: SummaryItem | null): item is SummaryItem {
return Boolean(item);
}
function cardClasses(severity?: "normal" | "warning" | "high") {
if (severity === "high") {
return "card-std border-red-200 bg-red-50 p-5";
}
if (severity === "warning") {
return "card-std border-amber-200 bg-amber-50 p-5";
}
return "card-std p-5";
}
function pickCards(cards: PortalHomePayload["cards"], keys: string[]) {
const map = new Map(cards.map((card) => [card.key, card]));
return keys.map((key) => map.get(key)).filter((card): card is PortalHomePayload["cards"][number] => Boolean(card));
}
function compactCards(cards: PortalHomePayload["cards"], excludedKeys: string[]) {
return cards.filter((card) => !excludedKeys.includes(card.key));
}
function formatWeeklyStatus(payload: PortalHomePayload["weeklyReport"]) {
if (!payload) {
return "未启用";
}
return payload.status === "submitted" ? "已提交" : "待填写";
}
function renderAnnouncementLink(item: PortalHomePayload["announcements"][number]) {
if (!item.href) {
return null;
}
return (
<a
href={item.href}
className="mt-2 inline-flex text-sm font-medium text-primary hover:underline"
rel="noreferrer"
target="_blank"
>
查看详情
</a>
);
}
function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] }) {
if (!items.length) {
return null;
}
return (
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">{title}</h3>
<div className="space-y-2 text-sm">
{items.map((item) => (
<Link
key={item.key}
to={item.href}
className={item.tone === "warning"
? "block rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 hover:bg-amber-100"
: "block rounded-xl border border-zinc-200 px-4 py-3 hover:bg-zinc-50"}
>
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-zinc-900">{item.label}</span>
<span className="text-sm font-semibold text-zinc-900">{item.value}</span>
</div>
{item.note ? <div className="mt-1 text-xs text-zinc-500">{item.note}</div> : null}
</Link>
))}
</div>
</div>
);
}
function SecondaryCardsPanel({ title, cards }: { title: string; cards: PortalHomePayload["cards"] }) {
if (!cards.length) {
return null;
}
return (
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">{title}</h3>
<div className="grid gap-3 sm:grid-cols-2">
{cards.map((card) => (
<Link
key={card.key}
to={card.href}
className={card.severity === "high"
? "rounded-xl border border-red-200 bg-red-50 px-4 py-3 hover:bg-red-100"
: card.severity === "warning"
? "rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 hover:bg-amber-100"
: "rounded-xl border border-zinc-200 px-4 py-3 hover:bg-zinc-50"}
>
<div className="text-xs uppercase tracking-wide text-zinc-500">{card.title}</div>
<div className="mt-2 text-lg font-semibold text-zinc-900">{card.value}</div>
</Link>
))}
</div>
</div>
);
}
function QuickLinksPanel({ items }: { items: PortalHomePayload["quickLinks"] }) {
if (!items.length) {
return null;
}
return (
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">常用入口</h3>
<div className="grid gap-2">
{items.map((item) => (
<Link key={item.key} to={item.href} className="rounded-xl border border-zinc-200 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-50">
{item.title}
</Link>
))}
</div>
</div>
);
}
function RecentVisitsPanel() {
const recentVisits = readRecentVisits();
return (
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">最近访问</h3>
<div className="space-y-2 text-sm">
{recentVisits.length ? recentVisits.map((item) => (
<Link key={item.path} to={item.path} className="block rounded-xl border border-zinc-200 px-3 py-2 hover:bg-zinc-50">
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">{item.path}</div>
</Link>
)) : <div className="text-zinc-500">暂无最近访问</div>}
</div>
</div>
);
}
function AnnouncementsPanel({ announcements }: { announcements: PortalHomePayload["announcements"] }) {
return (
<div className="card-std space-y-3 p-6">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">公告</h3>
<Link to="/announcements" className="text-sm text-primary hover:underline">查看全部</Link>
</div>
<div className="space-y-3">
{announcements.length ? announcements.slice(0, 4).map((item) => (
<div key={item.id} className="rounded-xl border border-zinc-200 p-3">
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="mt-1 text-sm text-zinc-600">{item.summary}</div>
{renderAnnouncementLink(item)}
</div>
)) : <div className="rounded-xl border border-dashed border-zinc-200 px-4 py-6 text-sm text-zinc-500">暂无公告,后续可由管理员在公告模块统一发布通知。</div>}
</div>
</div>
);
}
function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: PortalHomePayload["adoptionSummary"] }) {
if (!adoptionSummary) {
return null;
}
return (
<details className="card-std p-6" open>
<summary className="cursor-pointer list-none">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">试点观测摘要</h3>
<p className="mt-1 text-sm text-zinc-500">仅管理员默认展开,用于观察登录、周报和状态覆盖率。</p>
</div>
<span className="text-xs uppercase tracking-wide text-zinc-400">展开 / 收起</span>
</div>
</summary>
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">近 7 天登录覆盖率</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{adoptionSummary.login7dRate}%</div>
</div>
<div className="rounded-xl border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">周报提交率</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{adoptionSummary.weeklySubmissionRate}%</div>
</div>
<div className="rounded-xl border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">团队状态覆盖率</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{adoptionSummary.presenceCoverageRate}%</div>
</div>
<div className="rounded-xl border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">当前公告数</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{adoptionSummary.announcementCount}</div>
</div>
</div>
<div className="mt-4 rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm">
<div className="font-medium text-zinc-900">本周观测提示</div>
<ul className="mt-2 list-disc space-y-1 pl-5 text-zinc-600">
{adoptionSummary.hints.map((hint) => (
<li key={hint}>{hint}</li>
))}
</ul>
</div>
</details>
);
}
export function PortalHomePage() {
const { permissions } = useAuth();
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() });
const recentVisits = readRecentVisits();
const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
const isManagerView = hasAnyPermission(permissions, ["system.users", "report.dashboard.view", "*"]);
if (portalQuery.isLoading) {
return <div className="card-std p-6 text-sm text-zinc-500">加载 Portal 中...</div>;
@@ -38,13 +247,107 @@ export function PortalHomePage() {
}
const payload = portalQuery.data;
const primaryCardKeys = isManagerView
? ["pending_approvals", "team_presence", "weekly_missing", "weekly_help"]
: ["my_todos", "today_presence", "my_weekly_report", "my_projects"];
const primaryCards = pickCards(payload.cards, primaryCardKeys);
const primaryCardKeySet = primaryCards.map((card) => card.key);
const secondaryCards = compactCards(payload.cards, primaryCardKeySet);
const employeeSummaryCandidates: Array<SummaryItem | null> = [
{
key: "expense",
href: "/reimbursements",
label: "我的报销",
value: `${payload.myApplications.find((item) => item.domain === "reimbursement")?.count ?? 0} 条`,
},
{
key: "leave",
href: "/leave-requests",
label: "我的请假",
value: `${payload.myApplications.find((item) => item.domain === "leave")?.count ?? 0} 条`,
},
{
key: "assets",
href: "/assets",
label: "我的资产",
value: String(secondaryCards.find((card) => card.key === "my_assets")?.value ?? 0),
},
payload.todayPresence
? {
key: "today-status-note",
href: payload.todayPresence.href,
label: "今日状态",
value: payload.todayPresence.label,
note: payload.todayPresence.note ?? "可在团队页手动更新在岗、居家、外出或出差状态。",
tone: payload.todayPresence.status === "leave" ? "warning" as const : "normal" as const,
}
: null,
];
const employeeSummaryItems = employeeSummaryCandidates.filter(isSummaryItem);
const managerSummaryCandidates: Array<SummaryItem | null> = [
{
key: "team-summary",
href: payload.teamPresence?.href ?? "/team",
label: "今日团队状态",
value: payload.teamPresence ? `在岗 ${payload.teamPresence.office}/${payload.teamPresence.total}` : "待启用",
note: payload.teamPresence ? `请假 ${payload.teamPresence.leave},未更新 ${payload.teamPresence.unspecified}` : "团队状态板可展示在岗、居家、外出、出差与请假。",
tone: payload.teamPresence && (payload.teamPresence.leave > 0 || payload.teamPresence.unspecified > 0) ? "warning" as const : "normal" as const,
},
payload.weeklySummary
? {
key: "weekly-summary",
href: payload.weeklySummary.href,
label: "未提交周报",
value: `${payload.weeklySummary.missingCount} 人`,
note: `需协助 ${payload.weeklySummary.needsHelpCount} 人`,
tone: payload.weeklySummary.missingCount > 0 ? "warning" as const : "normal" as const,
}
: null,
payload.projectSummary
? {
key: "project-summary",
href: payload.projectSummary.href,
label: "待跟进 / 待收口项目",
value: `${payload.projectSummary.followUpDueTodayCount} / ${payload.projectSummary.closurePendingProjectCount}`,
note: `逾期里程碑 ${payload.projectSummary.overdueMilestonesCount},风险 ${payload.projectSummary.openRiskCount}`,
tone: payload.projectSummary.openRiskCount > 0 || payload.projectSummary.closurePendingProjectCount > 0 ? "warning" as const : "normal" as const,
}
: null,
payload.peopleLifecycle
? {
key: "people-lifecycle",
href: payload.peopleLifecycle.href,
label: "入离职 / 资产待归还",
value: `${payload.peopleLifecycle.onboardingCount + payload.peopleLifecycle.offboardingCount} / ${payload.peopleLifecycle.assetReturnPendingCount}`,
note: "用于跟进入职、离职和资产交接闭环。",
tone: payload.peopleLifecycle.offboardingCount > 0 || payload.peopleLifecycle.assetReturnPendingCount > 0 ? "warning" as const : "normal" as const,
}
: null,
payload.riskSummary
? {
key: "risk-summary",
href: payload.riskSummary.href,
label: "待复核风险",
value: `${payload.riskSummary.openCount} 项`,
note: "仅展示脱敏摘要,详细处理仍在 OA 内完成。",
tone: payload.riskSummary.openCount > 0 ? "warning" as const : "normal" as const,
}
: null,
];
const managerSummaryItems = managerSummaryCandidates.filter(isSummaryItem);
const headerSubtitle = isManagerView
? `你好,${payload.greeting.name}。先看今天最重要的管理动作。`
: `你好,${payload.greeting.name}。先处理今天要做的几件事。`;
return (
<div className="space-y-6">
<PageHeader title="Portal" subtitle={`你好,${payload.greeting.name}。`} />
<PageHeader title="Portal" subtitle={headerSubtitle} />
{!payload.workspace.persisted && canSetupWorkspace ? (
<div className="card-std border-amber-200 bg-amber-50 p-4 flex items-center justify-between gap-3">
<div className="card-std flex items-center justify-between gap-3 border-amber-200 bg-amber-50 p-4">
<div>
<div className="font-medium text-zinc-900">工作区尚未初始化</div>
<div className="text-sm text-zinc-600">建议先完成小团队模式的导航与模块设置,再邀请全员使用。</div>
@@ -55,19 +358,13 @@ export function PortalHomePage() {
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
{payload.cards.map((card) => (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{primaryCards.map((card) => (
<Link
key={card.key}
to={card.href}
aria-label={`portal-card-${card.key}`}
className={
card.severity === "high"
? "card-std border-red-200 bg-red-50 p-5"
: card.severity === "warning"
? "card-std border-amber-200 bg-amber-50 p-5"
: "card-std p-5"
}
className={cardClasses(card.severity)}
>
<div className="text-sm text-zinc-500">{card.title}</div>
<div className="mt-2 text-2xl font-bold text-zinc-900">{card.value}</div>
@@ -75,137 +372,38 @@ export function PortalHomePage() {
))}
</div>
<div className="grid gap-6 xl:grid-cols-3">
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">快捷入口</h3>
<div className="grid gap-2">
{payload.quickLinks.map((item) => (
<Link key={item.key} to={item.href} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-50">
{item.title}
</Link>
))}
</div>
</div>
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">我的申请</h3>
<div className="space-y-2 text-sm">
{payload.myApplications.map((item) => (
<Link key={item.domain} to={item.href} className="flex items-center justify-between rounded-lg border border-zinc-200 px-3 py-2 hover:bg-zinc-50">
<span>{domainLabel(item.domain)}</span>
<span className="font-medium text-zinc-900">{item.count}</span>
</Link>
))}
</div>
{payload.todayPresence ? (
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm">
今日状态:<span className="font-medium text-zinc-900">{payload.todayPresence.label}</span>
{payload.todayPresence.note ? <div className="mt-1 text-zinc-500">{payload.todayPresence.note}</div> : null}
</div>
) : null}
</div>
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">最近访问</h3>
<div className="space-y-2 text-sm">
{recentVisits.length ? recentVisits.map((item) => (
<Link key={item.path} to={item.path} className="block rounded-lg border border-zinc-200 px-3 py-2 hover:bg-zinc-50">
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">{item.path}</div>
</Link>
)) : <div className="text-zinc-500">暂无最近访问</div>}
</div>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">公告</h3>
<div className="space-y-3">
{payload.announcements.length ? payload.announcements.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 p-3">
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="mt-1 text-sm text-zinc-600">{item.summary}</div>
{item.href ? <a href={item.href} className="mt-2 inline-block text-sm text-primary" target="_blank" rel="noreferrer">查看链接</a> : null}
</div>
)) : <div className="text-sm text-zinc-500">暂无公告</div>}
</div>
</div>
{payload.adoptionSummary ? (
<div className="card-std space-y-3 p-6 xl:col-span-2">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">试点观测摘要</h3>
<Link to="/system/workspace" className="text-sm text-primary hover:underline">查看工作区设置</Link>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4 text-sm">
<div className="rounded-lg border border-zinc-200 px-3 py-3">
近 7 天登录:<span className="font-medium text-zinc-900">{payload.adoptionSummary.login7dCount}/{payload.adoptionSummary.activeEmployeeCount}</span>
<div className="mt-1 text-xs text-zinc-500">覆盖率 {payload.adoptionSummary.login7dRate}%</div>
</div>
<div className="rounded-lg border border-zinc-200 px-3 py-3">
本周周报:<span className="font-medium text-zinc-900">已交 {payload.adoptionSummary.weeklySubmittedCount}</span>
<div className="mt-1 text-xs text-zinc-500">提交率 {payload.adoptionSummary.weeklySubmissionRate}%</div>
</div>
<div className="rounded-lg border border-zinc-200 px-3 py-3">
今日状态:<span className="font-medium text-zinc-900">已更新 {payload.adoptionSummary.presenceUpdatedCount}</span>
<div className="mt-1 text-xs text-zinc-500">覆盖率 {payload.adoptionSummary.presenceCoverageRate}%</div>
</div>
<div className="rounded-lg border border-zinc-200 px-3 py-3">
当前公告:<span className="font-medium text-zinc-900">{payload.adoptionSummary.announcementCount}</span>
<div className="mt-1 text-xs text-zinc-500">用于试点期统一通知</div>
</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm">
<div className="font-medium text-zinc-900">本周观测提示</div>
<ul className="mt-2 space-y-1 text-zinc-600 list-disc pl-5">
{payload.adoptionSummary.hints.map((hint) => (
<li key={hint}>{hint}</li>
))}
</ul>
</div>
</div>
) : null}
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">工作台摘要</h3>
<div className="space-y-3 text-sm">
{payload.teamPresence ? (
<Link to={payload.teamPresence.href} className="block rounded-lg border border-zinc-200 px-3 py-3 hover:bg-zinc-50">
今日团队状态:在岗 {payload.teamPresence.office} / 总数 {payload.teamPresence.total},请假 {payload.teamPresence.leave},未更新 {payload.teamPresence.unspecified}
</Link>
) : null}
{payload.weeklySummary ? (
<Link to={payload.weeklySummary.href} className="block rounded-lg border border-zinc-200 px-3 py-3 hover:bg-zinc-50">
本周周报:未提交 {payload.weeklySummary.missingCount},需协助 {payload.weeklySummary.needsHelpCount}
</Link>
) : null}
{payload.todoSummary ? (
<Link to={payload.todoSummary.href} className="block rounded-lg border border-zinc-200 px-3 py-3 hover:bg-zinc-50">
我的待办:未完成 {payload.todoSummary.openCount},高优先级 {payload.todoSummary.highPriorityCount}
</Link>
) : null}
{payload.projectSummary ? (
<Link to={payload.projectSummary.href} className="block rounded-lg border border-zinc-200 px-3 py-3 hover:bg-zinc-50">
项目摘要:我的项目 {payload.projectSummary.myCount},逾期里程碑 {payload.projectSummary.overdueMilestonesCount},风险 {payload.projectSummary.openRiskCount},今日待跟进 {payload.projectSummary.followUpDueTodayCount},待收口项目 {payload.projectSummary.closurePendingProjectCount}
</Link>
) : null}
{payload.peopleLifecycle ? (
<Link to={payload.peopleLifecycle.href} className="block rounded-lg border border-zinc-200 px-3 py-3 hover:bg-zinc-50">
人员闭环:入职 {payload.peopleLifecycle.onboardingCount},离职 {payload.peopleLifecycle.offboardingCount},资产待归还 {payload.peopleLifecycle.assetReturnPendingCount}
</Link>
) : null}
<Link to={payload.pendingTasks.href} className="block rounded-lg border border-zinc-200 px-3 py-3 text-sm hover:bg-zinc-50">
审批待办:<span className="font-medium text-zinc-900">{payload.pendingTasks.count}</span>
</Link>
{payload.riskSummary ? (
<Link to={payload.riskSummary.href} className="block rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm hover:bg-amber-100">
待复核风险:<span className="font-medium text-zinc-900">{payload.riskSummary.openCount}</span>
</Link>
) : null}
</div>
</div>
<SummaryPanel title={isManagerView ? "今日管理重点" : "我的申请与资产"} items={isManagerView ? managerSummaryItems : employeeSummaryItems} />
<AnnouncementsPanel announcements={payload.announcements} />
</div>
<div className="grid gap-6 xl:grid-cols-3">
<QuickLinksPanel items={payload.quickLinks} />
<RecentVisitsPanel />
<SecondaryCardsPanel title={isManagerView ? "其他提醒" : "更多事项"} cards={secondaryCards} />
</div>
{payload.adoptionSummary ? <AdoptionSummaryPanel adoptionSummary={payload.adoptionSummary} /> : null}
{!isManagerView && payload.projectSummary ? (
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">项目与周报概览</h3>
<div className="grid gap-3 md:grid-cols-3">
<Link to={payload.projectSummary.href} className="rounded-xl border border-zinc-200 px-4 py-3 hover:bg-zinc-50">
<div className="text-xs uppercase tracking-wide text-zinc-500">我的项目</div>
<div className="mt-1 text-lg font-semibold text-zinc-900">{payload.projectSummary.myCount}</div>
</Link>
<Link to={payload.projectSummary.href} className="rounded-xl border border-zinc-200 px-4 py-3 hover:bg-zinc-50">
<div className="text-xs uppercase tracking-wide text-zinc-500">今日待跟进</div>
<div className="mt-1 text-lg font-semibold text-zinc-900">{payload.projectSummary.followUpDueTodayCount}</div>
</Link>
<Link to={payload.weeklyReport?.href ?? "/weekly-reports"} className="rounded-xl border border-zinc-200 px-4 py-3 hover:bg-zinc-50">
<div className="text-xs uppercase tracking-wide text-zinc-500">本周周报</div>
<div className="mt-1 text-lg font-semibold text-zinc-900">{formatWeeklyStatus(payload.weeklyReport)}</div>
</Link>
</div>
</div>
) : null}
</div>
);
}
+99 -17
查看文件
@@ -4,23 +4,57 @@ import { fetchWecomConfig, startWecomBind, unbindWecom } from "../api/auth";
import { PageHeader } from "../components/common/PageHeader";
import { useAuth } from "../auth/AuthContext";
import { Button } from "../components/ui/button";
import {
resolveWecomBindNotice,
resolveWecomEntryStatus,
type WecomNotice,
} from "../lib/wecomEntry";
function noticeClasses(tone: WecomNotice["tone"]) {
if (tone === "success") {
return "border-emerald-200 bg-emerald-50 text-emerald-700";
}
if (tone === "warning") {
return "border-amber-200 bg-amber-50 text-amber-700";
}
return "border-zinc-200 bg-zinc-50 text-zinc-700";
}
function maskExternalUserId(value: string) {
if (value.length <= 8) {
return value;
}
return `${value.slice(0, 4)}****${value.slice(-4)}`;
}
export function ProfilePage() {
const { me, permissions } = useAuth();
const [searchParams] = useSearchParams();
const wecomConfigQuery = useQuery({ queryKey: ["auth", "wecom-config"], queryFn: () => fetchWecomConfig(), staleTime: 60_000 });
const wecomConfigQuery = useQuery({
queryKey: ["auth", "wecom-config"],
queryFn: () => fetchWecomConfig(),
staleTime: 60_000,
});
const bindMutation = useMutation({
mutationFn: async () => startWecomBind("/settings/profile"),
onSuccess: (payload) => { window.location.assign(payload.loginUrl); },
onSuccess: (payload) => {
window.location.assign(payload.loginUrl);
},
});
const unbindMutation = useMutation({
mutationFn: () => unbindWecom(),
onSuccess: () => { window.location.reload(); },
onSuccess: () => {
window.location.reload();
},
});
const wecomStatus = resolveWecomEntryStatus(wecomConfigQuery.data);
const bindNotice = resolveWecomBindNotice(searchParams.get("wecomBind"));
const isBound = Boolean(me?.wecomBinding?.externalUserId);
return (
<div>
<PageHeader title="个人设置" subtitle="展示当前登录人的基础信息与权限摘要。" />
<PageHeader title="个人设置" subtitle="维护个人信息、权限摘要和企业微信绑定状态。" />
<section className="card-std">
<h3 className="card-title">个人信息</h3>
@@ -42,28 +76,76 @@ export function ProfilePage() {
<dd className="font-medium text-zinc-900">{me?.phone ?? "-"}</dd>
</div>
<div>
<dt className="text-secondary">部门ID</dt>
<dt className="text-secondary">部门 ID</dt>
<dd className="font-medium text-zinc-900">{me?.departmentId ?? "-"}</dd>
</div>
<div>
<dt className="text-secondary">直属上级ID</dt>
<dt className="text-secondary">直属上级 ID</dt>
<dd className="font-medium text-zinc-900">{me?.directManagerId ?? "-"}</dd>
</div>
</dl>
</section>
<section className="card-std mt-4">
<h3 className="card-title">企业微信绑定</h3>
<div className="space-y-3 text-sm text-zinc-700">
<div>接入状态:<span className="font-medium text-zinc-900">{wecomConfigQuery.data?.enabled ? "已启用" : "未启用"}</span></div>
<div>绑定状态:<span className="font-medium text-zinc-900">{me?.wecomBinding?.externalUserId ?? "未绑定"}</span></div>
{searchParams.get("wecomBind") ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2">绑定结果:{searchParams.get("wecomBind")}</div>
) : null}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" disabled={!wecomConfigQuery.data?.enabled || bindMutation.isPending} onClick={() => bindMutation.mutate()}>绑定企业微信</Button>
<Button type="button" variant="secondary" disabled={!me?.wecomBinding || unbindMutation.isPending} onClick={() => unbindMutation.mutate()}>解绑企业微信</Button>
<section className="card-std mt-4 space-y-4">
<div>
<h3 className="card-title">企业微信绑定</h3>
<p className="text-sm text-zinc-500">绑定后可从企业微信工作台直接进入 OA;企业微信只做入口与提醒,不承接第二套业务录入。</p>
</div>
{bindNotice ? (
<div className={`rounded-xl border px-4 py-3 text-sm ${noticeClasses(bindNotice.tone)}`} data-testid="profile-wecom-notice">
<div className="font-medium">{bindNotice.title}</div>
<div className="mt-1">{bindNotice.description}</div>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-zinc-200 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">接入状态</div>
<div className="mt-2 text-lg font-semibold text-zinc-900">{wecomStatus.label}</div>
<div className="mt-1 text-sm text-zinc-600">{wecomStatus.description}</div>
{wecomStatus.reasonLabels.length ? (
<ul className="mt-3 list-disc space-y-1 pl-5 text-xs text-zinc-500">
{wecomStatus.reasonLabels.map((label) => (
<li key={label}>{label}</li>
))}
</ul>
) : null}
</div>
<div className="rounded-2xl border border-zinc-200 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">当前绑定状态</div>
<div className="mt-2 text-lg font-semibold text-zinc-900">{isBound ? "已绑定" : "未绑定"}</div>
<div className="mt-1 text-sm text-zinc-600">
{isBound
? "已完成绑定,下次可从企业微信工作台直接进入 OA。"
: "未绑定时,企业微信登录会回退到账号密码登录。"}
</div>
{isBound ? (
<div className="mt-3 rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-500">
绑定标识:{maskExternalUserId(me?.wecomBinding?.externalUserId ?? "")}<br />
绑定时间:{me?.wecomBinding?.createdAt ? new Date(me.wecomBinding.createdAt).toLocaleString() : "-"}
</div>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
disabled={!wecomConfigQuery.data?.bindEnabled || bindMutation.isPending}
onClick={() => bindMutation.mutate()}
>
{isBound ? "重新绑定企业微信" : "立即绑定企业微信"}
</Button>
<Button
type="button"
variant="secondary"
disabled={!isBound || unbindMutation.isPending}
onClick={() => unbindMutation.mutate()}
>
解绑
</Button>
</div>
</section>