Files
track-LICA/frontend/src/contexts/AuthContext.tsx
duxingchen 3c1c5d6fb5 feat(分组权限): 分组管理接口 + 管理页 + 端到端验收
后端 endpoints/groups.py(**仅 SUPER_ADMIN**):
· 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的
  生产/测试小组都不用再配)
· 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT)
· **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然
  看不到数据了;强制「先移人再删组」多一步,但出错时是可见的
· 停用组的语义写死在接口文档:成员**立即**退回未分组状态

为什么只有超管能管分组(不是偏好,是必须):
  被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组,
  他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。

前端:
· AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认
· AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼
  提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈
· AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它)
· constants/task.ts 新增 isSuperAdmin,不手写 === 比较

端到端验收(实测):先造 1 生产 + 1 售后产品,然后
  生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来
  维修组员 → 1 条,全 AFTER_SALES
  超管     → 2 条,全量
  任务列表 total 与 returned 一致(验证 count/select 双过滤)
  扫码跨组仍 200(符合「能看、不能操作」的既定决策)
  停用维修大组 → 成员立即退回未分组
  上述测试数据已还原
2026-09-21 17:10:33 +08:00

177 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
createContext,
useContext,
useState,
useCallback,
useMemo,
useEffect,
type ReactNode,
} from "react";
import { login as loginApi, getMe, logout as logoutApi } from "../services/authApi";
// ============================================================
// 类型
// ============================================================
/**
* 业务分组数据范围 —— 来自 /auth/me。
*
* ⚠️ 登录接口(/auth/login**不查库**、不返回 scope所以登录后要先静默
* 调一次 getMe() 才拿得到;否则页头的范围徽标会空一下。
*/
export interface DataScopeInfo {
is_unrestricted: boolean; // 全厂(超管 / 未分组主管)
is_empty: boolean; // 未分组 —— 看不到任何数据
phases: string[];
phase_labels: string[]; // 中文标签,服务端下发
groups: string[];
is_leader: boolean;
reason: string;
}
export interface UserInfo {
id: string;
username: string;
display_name: string;
role: string;
scope?: DataScopeInfo;
}
interface AuthState {
user: UserInfo | null;
token: string | null;
loading: boolean;
}
interface AuthContextValue extends AuthState {
login: (username: string, password: string) => Promise<void>;
/** 登出。async 是因为必须先 await 审计上报、再清 token —— 顺序反了会丢日志 */
logout: () => Promise<void>;
isAuthenticated: boolean;
}
// ============================================================
// Token 存储 key
// ============================================================
const ACCESS_TOKEN_KEY = "track_admin_token";
const REFRESH_TOKEN_KEY = "track_admin_refresh_token";
const USER_KEY = "track_admin_user";
// ============================================================
// Context
// ============================================================
const AuthContext = createContext<AuthContextValue | null>(null);
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth 必须在 AuthProvider 内使用");
return ctx;
}
// ============================================================
// Provider
// ============================================================
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthState>({
user: null,
token: null,
loading: true,
});
// 初始化:从 localStorage 恢复双 Token
useEffect(() => {
const savedToken = localStorage.getItem(ACCESS_TOKEN_KEY);
const savedUser = localStorage.getItem(USER_KEY);
if (savedToken && savedUser) {
try {
const user = JSON.parse(savedUser) as UserInfo;
setState({ user, token: savedToken, loading: false });
// 后台静默验证 token 是否仍有效
getMe(savedToken)
.then((fresh) => {
setState((prev) => ({ ...prev, user: fresh }));
localStorage.setItem(USER_KEY, JSON.stringify(fresh));
})
.catch((err) => {
// 验证失败不踢出用户 — 真正的过期由业务 API 401 拦截器
// 通过 Refresh Token 无感刷新,彻底失败才跳转登录
console.error(
"[Auth] Token 后台验证失败(保留本地登录态,依赖拦截器刷新):",
err?.message ?? err,
);
});
} catch {
logoutInternal();
}
} else {
setState((prev) => ({ ...prev, loading: false }));
}
}, []);
function logoutInternal() {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(USER_KEY);
setState({ user: null, token: null, loading: false });
}
const login = useCallback(async (username: string, password: string) => {
const result = await loginApi(username, password);
const accessToken = result.access_token;
const refreshToken = result.refresh_token;
let user: UserInfo = result.user;
// 存储双 Token
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
// 补拉一次 /auth/me 拿数据范围scope
// ⚠️ 登录接口不查库、不返回 scope直接用 result.user 的话页头范围徽标会
// 空一下,用户会以为分组没生效。多这一次请求换状态一致,值得。
// 失败不阻断登录:拿不到 scope 只是徽标不显示,不影响使用。
try {
user = { ...user, ...(await getMe(accessToken)) };
} catch {
/* 忽略:保持登录,只是暂时没有范围信息 */
}
localStorage.setItem(USER_KEY, JSON.stringify(user));
setState({ user, token: accessToken, loading: false });
}, []);
const logout = useCallback(async () => {
// ⚠️ 必须【先 await 上报、再清 token】。两边顺序反了或不等退出就留不下痕
// 1) axios 的请求拦截器是在微任务里执行的,它去 localStorage 读 token 时,
// 同步的 logoutInternal() 早已把 token 清掉 → 请求不带 Authorization
// → 后端只能记成「未认证」,退出归因不到人;
// 2) 调用方点完退出还会立刻 navigate 到登录页,进一步压缩执行窗口。
// 所以这里(async) + 调用方(await) 两处都得改,只改一处等于没改。
// 失败绝不影响退出JWT 无状态,服务端本就不需要它成功。
try {
await logoutApi();
} catch {
/* 静默:断网/超时也照退不误 */
}
logoutInternal();
}, []);
const ctxValue = useMemo<AuthContextValue>(
() => ({
...state,
login,
logout,
isAuthenticated: !!state.token && !!state.user,
}),
[state, login, logout],
);
return (
<AuthContext.Provider value={ctxValue}>
{children}
</AuthContext.Provider>
);
}