247 行
10 KiB
TypeScript
247 行
10 KiB
TypeScript
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import { Link, useSearchParams } from "react-router-dom";
|
|
import { fetchWecomConfig, startWecomBind, unbindWecom, updateNotificationPreferences } from "../api/auth";
|
|
import { isApiError } from "../api/types";
|
|
import { useAuth } from "../auth/AuthContext";
|
|
import { PageHeader } from "../components/common/PageHeader";
|
|
import { SectionPanel } from "../components/patterns/SectionPanel";
|
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
|
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)}`;
|
|
}
|
|
|
|
function accountStatusLabel(status?: string) {
|
|
if (status === "active") return "正常";
|
|
if (status === "inactive") return "停用";
|
|
return "待确认";
|
|
}
|
|
|
|
function accountStatusClassName(status?: string) {
|
|
if (status === "active") return "badge-success";
|
|
if (status === "inactive") return "badge-warning";
|
|
return "badge-neutral";
|
|
}
|
|
|
|
function roleLabel(roleCode: string) {
|
|
const labels: Record<string, string> = {
|
|
admin: "管理员",
|
|
ceo: "负责人",
|
|
employee: "员工",
|
|
finance: "财务",
|
|
finance_mgr: "财务负责人",
|
|
hr: "人事",
|
|
manager: "审批人",
|
|
};
|
|
return labels[roleCode] ?? "其他角色";
|
|
}
|
|
|
|
function resolveProfileError(error: unknown) {
|
|
if (isApiError(error)) {
|
|
return `${error.code}: ${error.message}`;
|
|
}
|
|
if (error instanceof Error) {
|
|
return error.message;
|
|
}
|
|
return "请求失败,请稍后重试。";
|
|
}
|
|
|
|
export function ProfilePage() {
|
|
const { me, refreshMe } = useAuth();
|
|
const [searchParams] = useSearchParams();
|
|
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);
|
|
},
|
|
});
|
|
|
|
const unbindMutation = useMutation({
|
|
mutationFn: () => unbindWecom(),
|
|
onSuccess: () => {
|
|
window.location.reload();
|
|
},
|
|
});
|
|
const notificationPreferenceMutation = useMutation({
|
|
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
|
|
onSuccess: async () => {
|
|
await refreshMe();
|
|
toast.success("通知设置已更新");
|
|
},
|
|
onError: (error) => {
|
|
toast.error(resolveProfileError(error));
|
|
},
|
|
});
|
|
|
|
const wecomStatus = resolveWecomEntryStatus(wecomConfigQuery.data);
|
|
const bindNotice = resolveWecomBindNotice(searchParams.get("wecomBind"));
|
|
const isBound = Boolean(me?.wecomBinding?.externalUserId);
|
|
const roleLabels = (me?.roleCodes ?? []).map(roleLabel);
|
|
|
|
return (
|
|
<PageScaffold>
|
|
<PageHeader
|
|
title="个人设置"
|
|
eyebrow="账号与通知"
|
|
subtitle="只处理个人资料、安全、通知偏好和企业微信绑定。组织和权限维护统一在管理入口处理。"
|
|
meta={<span className={accountStatusClassName(me?.status)}>{accountStatusLabel(me?.status)}</span>}
|
|
/>
|
|
|
|
<div className="mx-auto max-w-5xl space-y-5">
|
|
<SectionPanel
|
|
title="个人资料"
|
|
description="只展示本人基础信息。如需调整组织、岗位或角色,由管理员在管理入口维护。"
|
|
>
|
|
<dl className="grid gap-4 text-sm text-zinc-700 md:grid-cols-2">
|
|
<div>
|
|
<dt className="text-zinc-500">姓名</dt>
|
|
<dd className="mt-1 font-medium text-zinc-900">{me?.name ?? "-"}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-zinc-500">员工号</dt>
|
|
<dd className="mt-1 font-medium text-zinc-900">{me?.employeeNo ?? "-"}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-zinc-500">邮箱</dt>
|
|
<dd className="mt-1 font-medium text-zinc-900">{me?.email ?? "-"}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-zinc-500">手机号</dt>
|
|
<dd className="mt-1 font-medium text-zinc-900">{me?.phone ?? "-"}</dd>
|
|
</div>
|
|
</dl>
|
|
</SectionPanel>
|
|
|
|
<SectionPanel title="密码与安全" description="修改登录密码,保持账号入口可控。">
|
|
<div className="flex flex-col gap-3 text-sm text-zinc-600 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<div className="font-medium text-zinc-900">账号密码</div>
|
|
<p className="mt-1">修改密码需要输入当前密码;忘记密码时请从登录页找回。</p>
|
|
</div>
|
|
<Button asChild type="button" variant="secondary">
|
|
<Link to="/change-password">修改密码</Link>
|
|
</Button>
|
|
</div>
|
|
</SectionPanel>
|
|
|
|
<SectionPanel title="通知设置" description="选择是否接收企业微信个人应用提醒。">
|
|
<div className="space-y-3 text-sm text-zinc-600">
|
|
<label className="flex items-center gap-3 text-zinc-800">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-zinc-300"
|
|
checked={Boolean(me?.notificationPushEnabled ?? true)}
|
|
disabled={!me || notificationPreferenceMutation.isPending}
|
|
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
|
|
/>
|
|
接收企业微信消息提醒
|
|
</label>
|
|
<p>关闭后仍会保留 OA 站内通知,不影响你的业务权限和工作台登录。</p>
|
|
</div>
|
|
</SectionPanel>
|
|
|
|
<SectionPanel title="企业微信绑定" description="绑定后可从企业微信工作台直接进入 OA;企业微信只负责入口和提醒。">
|
|
<div className="space-y-4" data-testid="profile-wecom-card">
|
|
{bindNotice ? (
|
|
<div className={`rounded-lg 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="surface-muted px-4 py-4">
|
|
<div className="text-sm text-zinc-500">企业微信入口</div>
|
|
<div className="mt-2 text-lg font-semibold text-zinc-900">{wecomStatus.label}</div>
|
|
<div className="mt-2 text-sm leading-6 text-zinc-600">{wecomStatus.description}</div>
|
|
</div>
|
|
|
|
<div className="surface-muted px-4 py-4">
|
|
<div className="text-sm text-zinc-500">当前绑定</div>
|
|
<div className="mt-2 text-lg font-semibold text-zinc-900">{isBound ? "已绑定" : "未绑定"}</div>
|
|
<div className="mt-2 text-sm leading-6 text-zinc-600">
|
|
{isBound ? "下次可直接从企业微信工作台进入 OA。" : "未绑定时仍可使用账号密码登录。"}
|
|
</div>
|
|
{isBound ? (
|
|
<div className="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-3 text-xs text-zinc-500">
|
|
<div>绑定标识:{maskExternalUserId(me?.wecomBinding?.externalUserId ?? "")}</div>
|
|
<div className="mt-1">绑定时间:{me?.wecomBinding?.createdAt ? new Date(me.wecomBinding.createdAt).toLocaleString() : "-"}</div>
|
|
</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>
|
|
</div>
|
|
</SectionPanel>
|
|
|
|
<details className="card-std">
|
|
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-5 py-4">
|
|
<div>
|
|
<div className="text-base font-semibold text-zinc-900">账号详情</div>
|
|
<div className="mt-1 text-sm text-zinc-500">组织归属和角色摘要,通常不需要修改。</div>
|
|
</div>
|
|
<span className="text-sm text-zinc-500">展开 / 收起</span>
|
|
</summary>
|
|
<div className="grid gap-3 border-t border-zinc-200 px-5 py-5 text-sm text-zinc-700 md:grid-cols-3">
|
|
<div className="rounded-lg border border-zinc-200 px-4 py-4">
|
|
<div className="text-zinc-500">部门状态</div>
|
|
<div className="mt-2 font-medium text-zinc-900">{me?.departmentId ? "已关联组织" : "待管理员维护"}</div>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 px-4 py-4">
|
|
<div className="text-zinc-500">直属汇报关系</div>
|
|
<div className="mt-2 font-medium text-zinc-900">{me?.directManagerId ? "已维护" : "待管理员维护"}</div>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 px-4 py-4">
|
|
<div className="text-zinc-500">角色</div>
|
|
<div className="mt-2 font-medium text-zinc-900">{roleLabels.length ? roleLabels.join("、") : "员工"}</div>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</PageScaffold>
|
|
);
|
|
}
|