feat: R27-2 R27-5 优化企微入口与 Portal 体验
这个提交包含在:
@@ -1,6 +1,6 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchSystemNavigation } from "../../api/workspace";
|
||||
import { fetchSystemNavigation, type NavigationItem } from "../../api/workspace";
|
||||
import { useAuth } from "../../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../../auth/permissions";
|
||||
import { cn } from "../../lib/utils";
|
||||
@@ -22,7 +22,6 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/todos", label: "待办", icon: "check_circle" },
|
||||
{ to: "/announcements", label: "公告", icon: "campaign" },
|
||||
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] },
|
||||
{ to: "/contracts-registry", label: "合同台账", icon: "description", anyOf: ["project.view_own", "project.view_all", "project.manage", "system.settings"] },
|
||||
{ to: "/system/workspace", label: "工作区设置", icon: "tune", anyOf: ["system.settings"] },
|
||||
{ to: "/people", label: "人员管理", icon: "group", anyOf: ["system.users", "report.dashboard.view"] },
|
||||
{ to: "/system/workflow-definitions", label: "流程定义", icon: "schema", anyOf: ["system.settings"] },
|
||||
@@ -30,6 +29,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/system/feature-flags", label: "功能开关", icon: "toggle_on", anyOf: ["system.settings"] },
|
||||
{ to: "/audit-logs", label: "审计日志", icon: "manage_search", anyOf: ["system.audit.view"] },
|
||||
{ to: "/workflow/tasks", label: "审批待办", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"] },
|
||||
{ to: "/contracts-registry", label: "合同台账", icon: "description", anyOf: ["project.view_own", "project.view_all", "project.manage", "system.settings"] },
|
||||
{ to: "/purchase-requests", label: "采购申请", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
|
||||
{ to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.create", "travel.view_own", "travel.view_all", "travel.manage", "travel.payment.manage", "integration.manage"] },
|
||||
{ to: "/budgets", label: "预算管理", icon: "account_balance", anyOf: ["budget.view", "budget.manage"] },
|
||||
@@ -43,17 +43,47 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/settings/profile", label: "个人设置", icon: "manage_accounts" },
|
||||
];
|
||||
|
||||
const MAIN_NAV_ORDER = ["/portal", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"];
|
||||
const ADMIN_NAV_ORDER = ["/system/workspace", "/people", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"];
|
||||
const BACKUP_NAV_ORDER = ["/workflow/tasks", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"];
|
||||
|
||||
const LEGACY_ACCESSIBLE_LABELS: Record<string, string> = {
|
||||
"/reimbursements": "我的报销",
|
||||
"/workflow/tasks": "待我处理",
|
||||
"/assets": "资产台账",
|
||||
};
|
||||
|
||||
function renderSection(
|
||||
title: string,
|
||||
items: Array<{ key?: string; href: string; label: string; icon: string }>,
|
||||
onNavigate?: () => void,
|
||||
) {
|
||||
type ResolvedNavItem = {
|
||||
key?: string;
|
||||
href: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) {
|
||||
return (
|
||||
<NavLink
|
||||
key={item.key ?? item.href}
|
||||
to={item.href}
|
||||
aria-label={LEGACY_ACCESSIBLE_LABELS[item.href] ?? item.label}
|
||||
title={LEGACY_ACCESSIBLE_LABELS[item.href] ?? item.label}
|
||||
onClick={() => onNavigate?.()}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-zinc-100 font-bold text-zinc-900"
|
||||
: "font-medium text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900",
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSection(title: string, items: ResolvedNavItem[], onNavigate?: () => void) {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
@@ -61,30 +91,29 @@ function renderSection(
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="px-3 pt-2 text-[11px] font-semibold uppercase tracking-wide text-zinc-400">{title}</div>
|
||||
{items.map((item) => (
|
||||
<NavLink
|
||||
key={item.key ?? item.href}
|
||||
to={item.href}
|
||||
aria-label={LEGACY_ACCESSIBLE_LABELS[item.href] ?? item.label}
|
||||
title={LEGACY_ACCESSIBLE_LABELS[item.href] ?? item.label}
|
||||
onClick={() => onNavigate?.()}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 px-3 py-2 my-1 rounded-lg text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-zinc-100 font-bold text-zinc-900"
|
||||
: "font-medium text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900",
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
{items.map((item) => renderNavItem(item, onNavigate))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBackupSection(items: ResolvedNavItem[], onNavigate?: () => void) {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="rounded-xl border border-zinc-200 bg-zinc-50/80 px-2 py-2">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between px-2 py-1 text-sm font-semibold text-zinc-700">
|
||||
<span>备用模块</span>
|
||||
<span className="text-xs font-medium text-zinc-400">按需展开</span>
|
||||
</summary>
|
||||
<div className="mt-2 space-y-1 pb-1">
|
||||
{items.map((item) => renderNavItem(item, onNavigate))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function buildFallbackItems(permissions: string[]) {
|
||||
const mapItems = (paths: string[]) =>
|
||||
NAV_ITEMS.filter((item) => paths.includes(item.to))
|
||||
@@ -92,23 +121,55 @@ function buildFallbackItems(permissions: string[]) {
|
||||
.map((item) => ({ key: item.to, href: item.to, label: item.label, icon: item.icon }));
|
||||
|
||||
return {
|
||||
main: mapItems(["/portal", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"]),
|
||||
admin: mapItems(["/system/workspace", "/people", "/contracts-registry", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"]),
|
||||
backup: mapItems(["/workflow/tasks", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard"]),
|
||||
main: mapItems(MAIN_NAV_ORDER),
|
||||
admin: mapItems(ADMIN_NAV_ORDER),
|
||||
backup: mapItems(BACKUP_NAV_ORDER),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSections(sections: { main: NavigationItem[]; admin: NavigationItem[]; backup: NavigationItem[] }) {
|
||||
const allItems = [...sections.main, ...sections.admin, ...sections.backup];
|
||||
const byHref = new Map<string, ResolvedNavItem>();
|
||||
for (const item of allItems) {
|
||||
if (!byHref.has(item.href)) {
|
||||
byHref.set(item.href, item);
|
||||
}
|
||||
}
|
||||
|
||||
const used = new Set<string>();
|
||||
const take = (hrefs: string[]) => hrefs
|
||||
.map((href) => {
|
||||
const item = byHref.get(href);
|
||||
if (item) {
|
||||
used.add(href);
|
||||
}
|
||||
return item;
|
||||
})
|
||||
.filter((item): item is ResolvedNavItem => Boolean(item));
|
||||
|
||||
const main = take(MAIN_NAV_ORDER);
|
||||
const admin = take(ADMIN_NAV_ORDER);
|
||||
const backup = [
|
||||
...take(BACKUP_NAV_ORDER),
|
||||
...allItems.filter((item) => !used.has(item.href)).map((item) => ({ key: item.key, href: item.href, label: item.label, icon: item.icon })),
|
||||
];
|
||||
|
||||
return { main, admin, backup };
|
||||
}
|
||||
|
||||
export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const { permissions } = useAuth();
|
||||
const navigationQuery = useQuery({ queryKey: ["system-navigation"], queryFn: fetchSystemNavigation, staleTime: 60_000 });
|
||||
|
||||
const sections = navigationQuery.data?.sections ?? buildFallbackItems(permissions);
|
||||
const sections = navigationQuery.data?.sections
|
||||
? normalizeSections(navigationQuery.data.sections)
|
||||
: buildFallbackItems(permissions);
|
||||
|
||||
return (
|
||||
<nav className="space-y-3">
|
||||
<nav className="space-y-4">
|
||||
{renderSection("主导航", sections.main, onNavigate)}
|
||||
{renderSection("管理", sections.admin, onNavigate)}
|
||||
{renderSection("备用", sections.backup, onNavigate)}
|
||||
{renderSection("管理入口", sections.admin, onNavigate)}
|
||||
{renderBackupSection(sections.backup, onNavigate)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@ import { NavLinks } from "./NavLinks";
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="fixed inset-y-0 z-20 hidden w-64 border-r border-zinc-200 bg-white lg:flex lg:flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-3 border-b border-zinc-200 p-6">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-zinc-900 text-lg font-bold text-white">H</div>
|
||||
<span className="font-bold tracking-tight text-zinc-900">海音达工作台</span>
|
||||
<aside className="fixed inset-y-0 z-20 hidden w-64 overflow-hidden border-r border-zinc-200 bg-white lg:flex lg:flex-col">
|
||||
<div className="border-b border-zinc-200 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-zinc-900 text-lg font-bold text-white">H</div>
|
||||
<div>
|
||||
<div className="font-bold tracking-tight text-zinc-900">海音达工作台</div>
|
||||
<div className="text-xs text-zinc-500">企业微信入口 + OA 主平台</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchNotifications } from "../../api/notifications";
|
||||
import { useAuth } from "../../auth/AuthContext";
|
||||
import { isWecomWebView } from "../../lib/runtimeContext";
|
||||
import { GlobalSearchDialog } from "../GlobalSearchDialog";
|
||||
import { Button } from "../ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "../ui/dialog";
|
||||
@@ -53,9 +54,9 @@ function BellIcon() {
|
||||
|
||||
export function Topbar() {
|
||||
const { me, logout } = useAuth();
|
||||
const roleCodes = me?.roleCodes ?? [];
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const wecomEntry = isWecomWebView();
|
||||
|
||||
const unreadQuery = useQuery({
|
||||
enabled: Boolean(me),
|
||||
@@ -69,9 +70,9 @@ export function Topbar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-10 h-16 flex flex-col justify-center border-b border-zinc-200 bg-white px-4 sm:px-8">
|
||||
<header className="sticky top-0 z-10 border-b border-zinc-200 bg-white px-4 py-3 sm:px-8">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Dialog open={mobileNavOpen} onOpenChange={setMobileNavOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="lg:hidden" size="icon" variant="secondary" aria-label="打开菜单" type="button">
|
||||
@@ -79,13 +80,11 @@ export function Topbar() {
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="left-0 top-0 h-full w-72 max-w-[80vw] tranzinc-x-0 tranzinc-y-0 rounded-none rounded-r-xl p-0">
|
||||
<DialogContent className="left-0 top-0 h-full w-72 max-w-[80vw] rounded-none rounded-r-xl p-0">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
|
||||
<span className="text-secondary capitalize">{me?.roleCodes?.[0] || "User"}</span>
|
||||
</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-zinc-500">主业务平台</p>
|
||||
<p className="mt-1 text-sm font-bold text-zinc-900">海音达工作台</p>
|
||||
</div>
|
||||
<DialogClose asChild>
|
||||
@@ -102,9 +101,16 @@ export function Topbar() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-zinc-500">欢迎回来</p>
|
||||
<p className="text-base font-semibold text-zinc-900">{me?.name ?? "-"}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="truncate text-base font-semibold text-zinc-900">{me?.name ?? "-"}</p>
|
||||
{wecomEntry ? (
|
||||
<span className="rounded-full border border-primary-200 bg-primary-50 px-2 py-0.5 text-xs font-medium text-primary" data-testid="topbar-wecom-entry-badge">
|
||||
当前通过企业微信进入
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -151,11 +157,6 @@ export function Topbar() {
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="hidden text-right sm:block">
|
||||
<p className="text-xs uppercase tracking-wide text-zinc-500">角色</p>
|
||||
<p className="text-sm font-medium text-zinc-900">{roleCodes.join(" / ") || "-"}</p>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" type="button">
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const WECOM_WEBVIEW_PATTERN = /(wxwork|wecom|wechatwork)/i;
|
||||
const ENTERPRISE_IM_PATTERN = /(wxwork|wecom|wechatwork|dingtalk|aliapp\(dingtalk\))/i;
|
||||
|
||||
export function canUseWindow() {
|
||||
return typeof window !== "undefined";
|
||||
}
|
||||
|
||||
export function matchesMedia(query: string) {
|
||||
return canUseWindow() && typeof window.matchMedia === "function" && window.matchMedia(query).matches;
|
||||
}
|
||||
|
||||
export function readUserAgent() {
|
||||
return canUseWindow() ? window.navigator.userAgent : "";
|
||||
}
|
||||
|
||||
export function isWecomWebView(userAgent = readUserAgent()) {
|
||||
return WECOM_WEBVIEW_PATTERN.test(userAgent);
|
||||
}
|
||||
|
||||
export function isEnterpriseMessengerContainer(userAgent = readUserAgent()) {
|
||||
return ENTERPRISE_IM_PATTERN.test(userAgent);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { WecomEntryConfig } from "../api/auth";
|
||||
|
||||
export type WecomNoticeTone = "info" | "warning" | "success";
|
||||
|
||||
export type WecomNotice = {
|
||||
tone: WecomNoticeTone;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const STATUS_REASON_LABELS: Record<string, string> = {
|
||||
wecom_disabled: "管理员尚未启用企业微信接入",
|
||||
entry_disabled: "企业微信工作台入口尚未正式开放",
|
||||
oauth_disabled: "企业微信 OAuth 尚未启用",
|
||||
corp_id_missing: "未配置 CorpID",
|
||||
agent_id_missing: "未配置 AgentID",
|
||||
redirect_uri_missing: "未配置 OAuth 回调地址",
|
||||
};
|
||||
|
||||
export function resolveWecomStatusReasonLabels(statusReasons: string[]) {
|
||||
return statusReasons.map((reason) => STATUS_REASON_LABELS[reason] ?? reason);
|
||||
}
|
||||
|
||||
export function resolveWecomEntryStatus(config?: WecomEntryConfig | null) {
|
||||
if (!config) {
|
||||
return {
|
||||
label: "检测中",
|
||||
tone: "info" as const,
|
||||
description: "正在读取企业微信接入状态。",
|
||||
reasonLabels: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (config.status === "ready") {
|
||||
return {
|
||||
label: "已启用",
|
||||
tone: "success" as const,
|
||||
description: "员工可从企业微信工作台直接进入 OA,也可在个人设置里维护绑定。",
|
||||
reasonLabels: [],
|
||||
};
|
||||
}
|
||||
|
||||
const reasonLabels = resolveWecomStatusReasonLabels(config.statusReasons);
|
||||
|
||||
if (config.bindEnabled && !config.entryEnabled) {
|
||||
return {
|
||||
label: "入口未开放",
|
||||
tone: "warning" as const,
|
||||
description: "当前可先完成绑定,企业微信工作台直达入口尚未正式开放。",
|
||||
reasonLabels,
|
||||
};
|
||||
}
|
||||
|
||||
if (config.status === "misconfigured") {
|
||||
return {
|
||||
label: "配置不完整",
|
||||
tone: "warning" as const,
|
||||
description: "请管理员补齐 CorpID、AgentID 与回调地址后再开放企业微信入口。",
|
||||
reasonLabels,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: "未启用",
|
||||
tone: "info" as const,
|
||||
description: "当前未启用企业微信入口,仍可使用账号密码登录。",
|
||||
reasonLabels,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWecomLoginNotice(value: string | null): WecomNotice | null {
|
||||
if (value === "unbound") {
|
||||
return {
|
||||
tone: "warning",
|
||||
title: "当前企业微信账号尚未绑定",
|
||||
description: "请先用账号密码登录,再到个人设置完成绑定。",
|
||||
};
|
||||
}
|
||||
|
||||
if (value === "invalid") {
|
||||
return {
|
||||
tone: "warning",
|
||||
title: "企业微信登录状态已失效",
|
||||
description: "请重新从企业微信工作台进入,或改用账号密码登录。",
|
||||
};
|
||||
}
|
||||
|
||||
if (value === "expired") {
|
||||
return {
|
||||
tone: "warning",
|
||||
title: "企业微信登录已超时",
|
||||
description: "请重新从企业微信工作台进入 OA。",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveWecomBindNotice(value: string | null): WecomNotice | null {
|
||||
if (value === "success") {
|
||||
return {
|
||||
tone: "success",
|
||||
title: "企业微信绑定成功",
|
||||
description: "下次可直接从企业微信工作台进入 OA。",
|
||||
};
|
||||
}
|
||||
|
||||
if (value === "conflict") {
|
||||
return {
|
||||
tone: "warning",
|
||||
title: "该企业微信账号已绑定其他员工",
|
||||
description: "请联系管理员核对后再处理。",
|
||||
};
|
||||
}
|
||||
|
||||
if (value === "employee_inactive") {
|
||||
return {
|
||||
tone: "warning",
|
||||
title: "当前账号已停用",
|
||||
description: "停用账号无法绑定企业微信,请联系管理员。",
|
||||
};
|
||||
}
|
||||
|
||||
if (value === "invalid") {
|
||||
return {
|
||||
tone: "warning",
|
||||
title: "本次绑定状态无效",
|
||||
description: "请回到个人设置后重新发起绑定。",
|
||||
};
|
||||
}
|
||||
|
||||
if (value === "expired") {
|
||||
return {
|
||||
tone: "warning",
|
||||
title: "本次绑定已超时",
|
||||
description: "请回到个人设置后重新发起绑定。",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildWecomLoginUrl(loginUrl: string, returnTo: string) {
|
||||
const baseOrigin = typeof window !== "undefined" ? window.location.origin : "http://127.0.0.1";
|
||||
const url = new URL(loginUrl, baseOrigin);
|
||||
url.searchParams.set("returnTo", returnTo);
|
||||
return url.origin === baseOrigin ? `${url.pathname}${url.search}` : url.toString();
|
||||
}
|
||||
+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>
|
||||
|
||||
|
||||
+2
-11
@@ -1,17 +1,10 @@
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
import { canUseWindow, isEnterpriseMessengerContainer, matchesMedia } from "./lib/runtimeContext";
|
||||
|
||||
const MOBILE_APPROVAL_TASK_PATH = /^\/workflow\/tasks\/([^/]+)$/i;
|
||||
const OFFLINE_ALLOWED_PATHS = ["/mobile/approvals"];
|
||||
const RUNTIME_CACHE_NAMES = ["app-api-runtime"];
|
||||
|
||||
function canUseWindow() {
|
||||
return typeof window !== "undefined";
|
||||
}
|
||||
|
||||
function matchesMedia(query: string) {
|
||||
return canUseWindow() && typeof window.matchMedia === "function" && window.matchMedia(query).matches;
|
||||
}
|
||||
|
||||
export function registerPwaIfEnabled() {
|
||||
if (import.meta.env.DEV) {
|
||||
return;
|
||||
@@ -59,12 +52,10 @@ export function shouldPreferMobileApprovalExperience() {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userAgent = window.navigator.userAgent.toLowerCase();
|
||||
const isStandalone = matchesMedia("(display-mode: standalone)") || (window.navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||
const isImContainer = /(wxwork|wecom|wechatwork|dingtalk|aliapp\(dingtalk\))/i.test(userAgent);
|
||||
const isTouchSmallScreen = window.navigator.maxTouchPoints > 0 && matchesMedia("(max-width: 1024px)");
|
||||
|
||||
return isStandalone || isImContainer || isTouchSmallScreen;
|
||||
return isStandalone || isEnterpriseMessengerContainer() || isTouchSmallScreen;
|
||||
}
|
||||
|
||||
export async function clearPwaRuntimeCaches() {
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { EMPLOYEE, login } from "./helpers/auth";
|
||||
|
||||
const ADMIN = { identifier: "[email protected]", password: "AdminPassword1234", name: "Admin" };
|
||||
|
||||
function envelope(data: unknown) {
|
||||
return {
|
||||
requestId: "mock-request",
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function mockMe(page: import("@playwright/test").Page, roleCodes: string[], name: string) {
|
||||
return page.route("**/api/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope({
|
||||
id: "employee-mock-1",
|
||||
employeeNo: "E001",
|
||||
name,
|
||||
phone: "13800000000",
|
||||
email: `${name.toLowerCase()}@hyinda.com`,
|
||||
departmentId: "dept-1",
|
||||
directManagerId: null,
|
||||
status: "active",
|
||||
roleCodes,
|
||||
wecomBinding: null,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mockNavigation(page: import("@playwright/test").Page, mode: "employee" | "manager") {
|
||||
return page.route("**/api/system/navigation", async (route) => {
|
||||
const sections = mode === "manager"
|
||||
? {
|
||||
main: [
|
||||
{ key: "portal", href: "/portal", label: "Portal", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "team", href: "/team", label: "团队", icon: "groups", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "leave", href: "/leave-requests", label: "请假", icon: "event_available", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "expense", href: "/reimbursements", label: "报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "assets", href: "/assets", label: "资产", icon: "inventory_2", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "weekly", href: "/weekly-reports", label: "周报", icon: "article", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "todos", href: "/todos", label: "待办", icon: "check_circle", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "announcements", href: "/announcements", label: "公告", icon: "campaign", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "projects", href: "/projects", label: "项目", icon: "workspaces", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
],
|
||||
admin: [
|
||||
{ key: "workspace", href: "/system/workspace", label: "工作区设置", icon: "tune", placement: "admin", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "people", href: "/people", label: "人员管理", icon: "group", placement: "admin", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "workflow", href: "/system/workflow-definitions", label: "流程定义", icon: "schema", placement: "admin", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "settings", href: "/system/settings", label: "系统设置", icon: "settings", placement: "admin", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "feature-flags", href: "/system/feature-flags", label: "功能开关", icon: "toggle_on", placement: "admin", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "audit", href: "/audit-logs", label: "审计日志", icon: "manage_search", placement: "admin", permissionCodes: [], featureFlagKey: null },
|
||||
],
|
||||
backup: [
|
||||
{ key: "purchase", href: "/purchase-requests", label: "采购申请", icon: "shopping_cart", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "travel", href: "/travel-requests", label: "差旅申请", icon: "flight", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
],
|
||||
}
|
||||
: {
|
||||
main: [
|
||||
{ key: "portal", href: "/portal", label: "Portal", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "team", href: "/team", label: "团队", icon: "groups", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "leave", href: "/leave-requests", label: "请假", icon: "event_available", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "expense", href: "/reimbursements", label: "报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "assets", href: "/assets", label: "资产", icon: "inventory_2", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "weekly", href: "/weekly-reports", label: "周报", icon: "article", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "todos", href: "/todos", label: "待办", icon: "check_circle", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "announcements", href: "/announcements", label: "公告", icon: "campaign", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
{ key: "projects", href: "/projects", label: "项目", icon: "workspaces", placement: "main", permissionCodes: [], featureFlagKey: null },
|
||||
],
|
||||
admin: [],
|
||||
backup: [],
|
||||
};
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope({
|
||||
workspace: {
|
||||
id: "workspace-1",
|
||||
persisted: true,
|
||||
profile: "small_team_internal",
|
||||
orgMode: "single_company",
|
||||
primaryEntryMode: "password",
|
||||
notificationStrategy: "in_app",
|
||||
devicePriority: "desktop",
|
||||
moduleVisibility: {},
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
},
|
||||
sections,
|
||||
recommendedRolePresets: [],
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mockPortalHome(page: import("@playwright/test").Page, mode: "employee" | "manager") {
|
||||
return page.route("**/api/portal/home", async (route) => {
|
||||
const data = mode === "manager"
|
||||
? {
|
||||
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
|
||||
greeting: { name: "Manager" },
|
||||
cards: [
|
||||
{ key: "pending_approvals", title: "审批待办", value: 3, href: "/workflow/tasks", severity: "warning" },
|
||||
{ key: "team_presence", title: "今日团队状态", value: "6/8 在岗", href: "/team", severity: "warning" },
|
||||
{ key: "weekly_missing", title: "未提交周报", value: 2, href: "/weekly-reports", severity: "warning" },
|
||||
{ key: "weekly_help", title: "需协助员工", value: 1, href: "/weekly-reports", severity: "high" },
|
||||
{ key: "people_lifecycle", title: "入/离职待办", value: 1, href: "/people", severity: "warning" },
|
||||
{ key: "asset_return_pending", title: "资产待归还", value: 1, href: "/people", severity: "warning" },
|
||||
{ key: "project_milestones", title: "逾期里程碑", value: 2, href: "/projects", severity: "warning" },
|
||||
{ key: "project_risks", title: "项目风险", value: 1, href: "/projects", severity: "high" },
|
||||
],
|
||||
pendingTasks: { count: 3, href: "/workflow/tasks" },
|
||||
myApplications: [
|
||||
{ domain: "reimbursement", count: 1, href: "/reimbursements" },
|
||||
{ domain: "leave", count: 0, href: "/leave-requests" },
|
||||
],
|
||||
riskSummary: { openCount: 1, href: "/ap-operations" },
|
||||
announcements: [{ id: "announcement-1", title: "试点公告", summary: "今天统一从 Portal 进入。", href: null }],
|
||||
quickLinks: [{ key: "projects", title: "项目", href: "/projects" }],
|
||||
todayPresence: null,
|
||||
teamPresence: { date: "2026-03-22", href: "/team", office: 6, remote: 1, out: 0, travel: 1, leave: 1, unspecified: 1, total: 8 },
|
||||
weeklyReport: null,
|
||||
weeklySummary: { weekStart: "2026-03-16", submittedCount: 6, missingCount: 2, needsHelpCount: 1, href: "/weekly-reports" },
|
||||
todoSummary: { openCount: 4, highPriorityCount: 1, href: "/todos" },
|
||||
projectSummary: { myCount: 5, overdueMilestonesCount: 2, highPriorityTaskCount: 2, openRiskCount: 1, followUpDueTodayCount: 2, closurePendingProjectCount: 1, href: "/projects" },
|
||||
peopleLifecycle: { onboardingCount: 1, offboardingCount: 0, assetReturnPendingCount: 1, href: "/people" },
|
||||
adoptionSummary: { activeEmployeeCount: 8, login7dCount: 8, weeklySubmittedCount: 6, weeklyMissingCount: 2, presenceUpdatedCount: 7, presenceMissingCount: 1, announcementCount: 1, login7dRate: 100, weeklySubmissionRate: 75, presenceCoverageRate: 87.5, hints: ["本周仍有 2 人未提交周报"] },
|
||||
}
|
||||
: {
|
||||
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
|
||||
greeting: { name: "Employee" },
|
||||
cards: [
|
||||
{ key: "my_todos", title: "我的待办", value: 4, href: "/todos", severity: "warning" },
|
||||
{ key: "today_presence", title: "今日状态", value: "在岗", href: "/team", severity: "normal" },
|
||||
{ key: "my_weekly_report", title: "本周周报", value: "待填写", href: "/weekly-reports", severity: "warning" },
|
||||
{ key: "my_projects", title: "我的项目", value: 2, href: "/projects", severity: "warning" },
|
||||
{ key: "my_leave", title: "我的请假", value: 1, href: "/leave-requests", severity: "normal" },
|
||||
{ key: "my_reimbursements", title: "我的报销", value: 2, href: "/reimbursements", severity: "normal" },
|
||||
{ key: "my_assets", title: "我的资产", value: 1, href: "/assets", severity: "normal" },
|
||||
],
|
||||
pendingTasks: { count: 0, href: "/workflow/tasks" },
|
||||
myApplications: [
|
||||
{ domain: "reimbursement", count: 2, href: "/reimbursements" },
|
||||
{ domain: "leave", count: 1, href: "/leave-requests" },
|
||||
],
|
||||
riskSummary: null,
|
||||
announcements: [{ id: "announcement-1", title: "本周安排", summary: "本周统一回填周报。", href: null }],
|
||||
quickLinks: [{ key: "weekly", title: "周报", href: "/weekly-reports" }],
|
||||
todayPresence: { date: "2026-03-22", status: "office", label: "在岗", note: "今天在公司办公", href: "/team" },
|
||||
teamPresence: null,
|
||||
weeklyReport: { weekStart: "2026-03-16", status: "draft", needsHelp: false, href: "/weekly-reports" },
|
||||
weeklySummary: null,
|
||||
todoSummary: { openCount: 4, highPriorityCount: 1, href: "/todos" },
|
||||
projectSummary: { myCount: 2, overdueMilestonesCount: 1, highPriorityTaskCount: 1, openRiskCount: 0, followUpDueTodayCount: 1, closurePendingProjectCount: 0, href: "/projects" },
|
||||
peopleLifecycle: null,
|
||||
adoptionSummary: null,
|
||||
};
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope(data)),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
|
||||
await mockNavigation(page, "employee");
|
||||
await mockPortalHome(page, "employee");
|
||||
await mockMe(page, ["employee"], "Employee");
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible();
|
||||
|
||||
await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible();
|
||||
|
||||
const labels = await page.locator('[aria-label^="portal-card-"]').evaluateAll((nodes) =>
|
||||
nodes.map((node) => node.getAttribute("aria-label")),
|
||||
);
|
||||
expect(labels).toEqual([
|
||||
"portal-card-my_todos",
|
||||
"portal-card-today_presence",
|
||||
"portal-card-my_weekly_report",
|
||||
"portal-card-my_projects",
|
||||
]);
|
||||
|
||||
await expect(page.getByText("我的申请与资产")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "公告" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page }) => {
|
||||
await mockNavigation(page, "manager");
|
||||
await mockPortalHome(page, "manager");
|
||||
await mockMe(page, ["admin"], "Admin");
|
||||
|
||||
await login(page, ADMIN);
|
||||
|
||||
const labels = await page.locator('[aria-label^="portal-card-"]').evaluateAll((nodes) =>
|
||||
nodes.map((node) => node.getAttribute("aria-label")),
|
||||
);
|
||||
expect(labels).toEqual([
|
||||
"portal-card-pending_approvals",
|
||||
"portal-card-team_presence",
|
||||
"portal-card-weekly_missing",
|
||||
"portal-card-weekly_help",
|
||||
]);
|
||||
|
||||
await expect(page.getByText("今日管理重点")).toBeVisible();
|
||||
await expect(page.getByText("试点观测摘要")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "采购申请" })).not.toBeVisible();
|
||||
await page.locator("summary").filter({ hasText: "备用模块" }).click();
|
||||
await expect(page.getByRole("link", { name: "采购申请" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("企微 WebView 下顶栏显示入口提示", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(window.navigator, "userAgent", {
|
||||
configurable: true,
|
||||
value: "Mozilla/5.0 wxwork/4.1.20 MicroMessenger/7.0.1",
|
||||
});
|
||||
});
|
||||
await mockNavigation(page, "employee");
|
||||
await mockPortalHome(page, "employee");
|
||||
await mockMe(page, ["employee"], "Employee");
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
await expect(page.getByTestId("topbar-wecom-entry-badge")).toBeVisible();
|
||||
});
|
||||
@@ -2,20 +2,29 @@ import { expect, test } from "@playwright/test";
|
||||
|
||||
import { EMPLOYEE, login } from "./helpers/auth";
|
||||
|
||||
test("E2E 企微入口:登录页展示入口并保留账号密码回退", async ({ page }) => {
|
||||
function mockWecomConfigPayload() {
|
||||
return {
|
||||
enabled: true,
|
||||
entryEnabled: true,
|
||||
oauthEnabled: true,
|
||||
corpId: "wx-test-corp",
|
||||
agentId: "1000001",
|
||||
loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal",
|
||||
bindEnabled: true,
|
||||
defaultReturnTo: "/portal",
|
||||
status: "ready",
|
||||
statusReasons: [],
|
||||
};
|
||||
}
|
||||
|
||||
test("E2E 企微入口:普通浏览器下企微按钮为次级入口", async ({ page }) => {
|
||||
await page.route("**/api/auth/wecom/config", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
requestId: "mock-wecom-config",
|
||||
data: {
|
||||
enabled: true,
|
||||
corpId: "wx-test-corp",
|
||||
agentId: "1000001",
|
||||
oauthEnabled: true,
|
||||
loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal",
|
||||
},
|
||||
data: mockWecomConfigPayload(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -28,13 +37,46 @@ test("E2E 企微入口:登录页展示入口并保留账号密码回退", asyn
|
||||
});
|
||||
|
||||
await page.goto("/login");
|
||||
await expect(page.getByRole("button", { name: "使用企业微信进入工作台" })).toBeVisible();
|
||||
await expect(page.getByTestId("login-password-primary-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("wecom-entry-secondary")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "使用企业微信进入工作台" }).click();
|
||||
await page.getByTestId("wecom-entry-secondary").click();
|
||||
await expect(page).toHaveURL(/\/api\/auth\/wecom\/login\?returnTo=/);
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
await page.goto("/settings/profile");
|
||||
await expect(page.getByText("企业微信绑定")).toBeVisible();
|
||||
await expect(page.getByText(/接入状态:/)).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "企业微信绑定" })).toBeVisible();
|
||||
await expect(page.getByText("接入状态", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("E2E 企微入口:企业微信场景优先显示企微 CTA,并展示未绑定提示", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(window.navigator, "userAgent", {
|
||||
configurable: true,
|
||||
value: "Mozilla/5.0 wxwork/4.1.20 MicroMessenger/7.0.1",
|
||||
});
|
||||
});
|
||||
await page.route("**/api/auth/wecom/config", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
requestId: "mock-wecom-config",
|
||||
data: mockWecomConfigPayload(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/auth/wecom/login?**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/plain",
|
||||
body: "mock wecom login entry",
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/login?redirect=%2Fportal&wecom=unbound");
|
||||
await expect(page.getByTestId("login-wecom-primary-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("wecom-entry-primary")).toBeVisible();
|
||||
await expect(page.getByTestId("login-wecom-notice")).toContainText("当前企业微信账号尚未绑定");
|
||||
await expect(page.getByTestId("login-password-secondary-panel")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -108,3 +108,15 @@
|
||||
- [ ] 至少 1 个项目已验证:收口清单 -> Portal 待收口统计生效
|
||||
- [ ] 至少 1 条涉密合同或资料引用已验证搜索边界
|
||||
- [ ] 每周五复盘模板中已加入“聊天/表格补流程案例”字段
|
||||
|
||||
## R27 企业微信正式接入与交互优化检查
|
||||
- [ ] 登录页已区分普通浏览器与企业微信 WebView 场景
|
||||
- [ ] 企业微信工作台入口 URL 已配置为 `https://oa.hyinda.com/api/auth/wecom/login?returnTo=%2Fportal`
|
||||
- [ ] OAuth 回调地址已配置为 `https://oa.hyinda.com/api/auth/wecom/callback`
|
||||
- [ ] `deploy/trialrun/.env` 已补齐 `WECOM_ENABLED / WECOM_ENTRY_ENABLED / WECOM_OAUTH_ENABLED / WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_AGENT_SECRET / WECOM_REDIRECT_URI / WECOM_ALLOWED_RETURN_HOSTS`
|
||||
- [ ] 至少 1 个管理员已在 `个人设置` 成功完成企微绑定
|
||||
- [ ] 已验证:已绑定员工从企业微信工作台进入后可直接落到 `Portal`
|
||||
- [ ] 已验证:未绑定员工会回退到登录页并看到明确绑定提示
|
||||
- [ ] 已验证:Portal 首屏第一排卡片顺序符合员工 / 管理员视角设计
|
||||
- [ ] 已验证:备用模块默认折叠,不干扰主导航
|
||||
- [ ] 已验证:企业微信通知仍保持脱敏,不包含涉密正文与金额明细
|
||||
|
||||
@@ -87,6 +87,27 @@
|
||||
- 企微免登建议先完成账号绑定,再逐步在试点部门开启;任何时候都保留账号密码回退。
|
||||
- 若 OA 部署到公网,生产建议同时满足:`HTTPS`、`COOKIE_SECURE=true`、登录失败锁定、限流、审计开启。
|
||||
|
||||
### 正式接入最小步骤
|
||||
1. 在企业微信管理后台创建或编辑自建应用。
|
||||
2. 将工作台入口配置为:`https://oa.hyinda.com/api/auth/wecom/login?returnTo=%2Fportal`。
|
||||
3. 将 OAuth 回调地址配置为:`https://oa.hyinda.com/api/auth/wecom/callback`。
|
||||
4. 在 `deploy/trialrun/.env` 中补齐并开启:
|
||||
- `WECOM_ENABLED=true`
|
||||
- `WECOM_ENTRY_ENABLED=true`
|
||||
- `WECOM_OAUTH_ENABLED=true`
|
||||
- `WECOM_CORP_ID=...`
|
||||
- `WECOM_AGENT_ID=...`
|
||||
- `WECOM_AGENT_SECRET=...`
|
||||
- `WECOM_REDIRECT_URI=https://oa.hyinda.com/api/auth/wecom/callback`
|
||||
- `WECOM_ALLOWED_RETURN_HOSTS=oa.hyinda.com`
|
||||
5. 先让管理员用账号密码登录,在 `个人设置` 完成一次绑定,再测试企业微信工作台直达。
|
||||
|
||||
### 员工会看到的典型状态
|
||||
- 未绑定:登录页会提示“当前企业微信账号尚未绑定”,回退到账号密码登录。
|
||||
- 已绑定:从企业微信工作台进入后直接落到 `Portal`。
|
||||
- 入口未开放:个人设置仍可绑定,但登录页不会把企业微信作为正式入口展示。
|
||||
- 配置不完整:个人设置会明确提示缺少 `CorpID / AgentID / 回调地址` 等原因。
|
||||
|
||||
## 13. 轻项目管理使用建议
|
||||
- 项目主档只做轻量管理:成员、里程碑、任务、风险、交付记录、合同台账与资料引用。
|
||||
- 不要把 `hyb-oa` 推成完整项目组合平台;甘特图、资源排产、CRM 仍不在本主线。
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
- 如果公司启用了企业微信工作台入口,优先从企业微信打开 OA。
|
||||
- 企业微信只是入口和提醒,真正填写和修改仍在 OA 页面内完成。
|
||||
- 如果企业微信免登失败,直接回到账号密码登录即可。
|
||||
- 如果你第一次从企业微信进入但看到“尚未绑定”,请不要反复重试,先用账号密码登录,再到 `个人设置 -> 企业微信绑定` 完成绑定。
|
||||
- 如果看到“登录已失效 / 已超时”,重新从企业微信工作台进入即可。
|
||||
- 日常建议:在企业微信里打开 OA,看 `Portal` 和通知;复杂填写、项目更新、报销与周报仍在 OA 页面里完成。
|
||||
|
||||
## 10. 项目怎么用
|
||||
- 如果你是项目成员,优先在 `项目` 页面查看自己的里程碑、任务、风险和交付记录。
|
||||
|
||||
@@ -98,6 +98,7 @@ async function authPlugin(fastify) {
|
||||
id: true,
|
||||
status: true,
|
||||
token_version: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
legal_entity_id: true,
|
||||
roles: { select: { role_code: true } },
|
||||
@@ -125,6 +126,7 @@ async function authPlugin(fastify) {
|
||||
request.employee = {
|
||||
id: employee.id,
|
||||
status: employee.status,
|
||||
name: employee.name ?? "",
|
||||
token_version: employee.token_version,
|
||||
department_id: employee.department_id,
|
||||
legal_entity_id: employee.legal_entity_id ?? null,
|
||||
|
||||
@@ -473,7 +473,7 @@ async function portalRoutes(fastify) {
|
||||
|
||||
return sendData(reply, request, {
|
||||
workspace,
|
||||
greeting: { name: request.employee.name ?? request.employee.id },
|
||||
greeting: { name: request.employee.name || request.employee.id },
|
||||
cards,
|
||||
pendingTasks: { count: pendingTaskCount, href: "/workflow/tasks" },
|
||||
myApplications: [
|
||||
|
||||
在新工单中引用
屏蔽一个用户