修复 R124 企业微信移动入口与部署迁移门禁
这个提交包含在:
@@ -0,0 +1,222 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { 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 { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
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 statusTone(status?: string) {
|
||||
if (status === "active") return "badge-success";
|
||||
if (status === "inactive") return "badge-warning";
|
||||
return "badge-neutral";
|
||||
}
|
||||
|
||||
function resolveProfileError(error: unknown) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${error.message}`;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return "请求失败,请稍后重试。";
|
||||
}
|
||||
|
||||
function resolveBinding(
|
||||
binding: {
|
||||
externalUserId?: string;
|
||||
userId?: string;
|
||||
createdAt?: string | null;
|
||||
boundAt?: string | null;
|
||||
} | null | undefined,
|
||||
) {
|
||||
return {
|
||||
externalUserId: binding?.externalUserId ?? binding?.userId ?? "",
|
||||
createdAt: binding?.createdAt ?? binding?.boundAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function maskExternalUserId(value: string) {
|
||||
if (!value) return "-";
|
||||
if (value.length <= 8) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, 4)}****${value.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function MobileProfilePage() {
|
||||
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("/mobile/profile"),
|
||||
onSuccess: (payload) => {
|
||||
window.location.assign(payload.loginUrl);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveProfileError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const unbindMutation = useMutation({
|
||||
mutationFn: () => unbindWecom(),
|
||||
onSuccess: async () => {
|
||||
await refreshMe();
|
||||
toast.success("企业微信已解绑");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveProfileError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const notificationPreferenceMutation = useMutation({
|
||||
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
|
||||
onSuccess: async () => {
|
||||
await refreshMe();
|
||||
toast.success("外部推送偏好已更新");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveProfileError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const binding = resolveBinding(me?.wecomBinding);
|
||||
const isBound = Boolean(binding.externalUserId);
|
||||
const wecomStatus = resolveWecomEntryStatus(wecomConfigQuery.data);
|
||||
const bindNotice = resolveWecomBindNotice(searchParams.get("wecomBind"));
|
||||
|
||||
return (
|
||||
<PageScaffold className="pb-4">
|
||||
<div className="space-y-3" data-testid="mobile-profile-page">
|
||||
<PageHeader
|
||||
title="个人设置"
|
||||
meta={<span className={statusTone(me?.status)}>{me?.status === "active" ? "账号正常" : "账号待确认"}</span>}
|
||||
/>
|
||||
|
||||
{bindNotice ? (
|
||||
<div className={`rounded-lg border px-3 py-2 text-sm ${noticeClasses(bindNotice.tone)}`} data-testid="mobile-profile-wecom-notice">
|
||||
<div className="font-medium">{bindNotice.title}</div>
|
||||
<div className="mt-1 leading-5">{bindNotice.description}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SectionPanel title="基本信息">
|
||||
<dl className="grid gap-3 text-sm">
|
||||
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-3">
|
||||
<dt className="text-xs text-zinc-500">姓名</dt>
|
||||
<dd className="mt-1 font-medium text-zinc-900">{me?.name ?? "-"}</dd>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-3">
|
||||
<dt className="text-xs text-zinc-500">员工号</dt>
|
||||
<dd className="mt-1 font-medium text-zinc-900">{me?.employeeNo ?? "-"}</dd>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-3">
|
||||
<dt className="text-xs text-zinc-500">邮箱</dt>
|
||||
<dd className="mt-1 break-words font-medium text-zinc-900">{me?.email ?? "-"}</dd>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-3">
|
||||
<dt className="text-xs text-zinc-500">手机号</dt>
|
||||
<dd className="mt-1 font-medium text-zinc-900">{me?.phone ?? "-"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="外部通知推送">
|
||||
<label className="flex min-h-12 items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-3 text-sm text-zinc-800">
|
||||
<span>接收外部渠道推送</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-5 w-5 rounded border-zinc-300"
|
||||
checked={Boolean(me?.notificationPushEnabled ?? true)}
|
||||
disabled={!me || notificationPreferenceMutation.isPending}
|
||||
onChange={(event) => notificationPreferenceMutation.mutate(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="企业微信绑定">
|
||||
<div className="space-y-3" data-testid="mobile-profile-wecom-card">
|
||||
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-3">
|
||||
<div className="text-xs text-zinc-500">接入状态</div>
|
||||
<div className="mt-1 font-semibold text-zinc-900">{wecomStatus.label}</div>
|
||||
<div className="mt-1 text-sm leading-5 text-zinc-500">{wecomStatus.description}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">当前绑定</div>
|
||||
<div className="mt-1 font-semibold text-zinc-900">{isBound ? "已绑定" : "未绑定"}</div>
|
||||
</div>
|
||||
<span className={isBound ? "badge-success" : "badge-neutral"}>{isBound ? "可用" : "待绑定"}</span>
|
||||
</div>
|
||||
{isBound ? (
|
||||
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs leading-5 text-zinc-500">
|
||||
<div>绑定标识:{maskExternalUserId(binding.externalUserId)}</div>
|
||||
<div>绑定时间:{binding.createdAt ? new Date(binding.createdAt).toLocaleString() : "-"}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
disabled={!wecomConfigQuery.data?.bindEnabled || bindMutation.isPending}
|
||||
onClick={() => bindMutation.mutate()}
|
||||
>
|
||||
{isBound ? "重新绑定" : "立即绑定"}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={!isBound || unbindMutation.isPending}
|
||||
onClick={() => unbindMutation.mutate()}
|
||||
>
|
||||
解绑
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="账号状态">
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className={statusTone(me?.status)}>{me?.status === "active" ? "active" : me?.status ?? "unknown"}</span>
|
||||
{(me?.roleCodes ?? []).map((role) => (
|
||||
<span className="badge-neutral" key={role}>{role}</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-zinc-500">角色和权限由管理员统一维护。</p>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</div>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileProfilePage;
|
||||
在新工单中引用
屏蔽一个用户