feat(identity): 完成 A1 身份认证与用户管理全栈落地
ci / 质量门禁 (push) Canceled after 0s

背景:
- 开发计划 A1 批次要求登录/刷新/登出、用户生命周期、路由与按钮权限、
  权限三态用例;视觉已选定方案 B(主从分栏 + 分屏登录)。

改动:
- packages/shared:新增 identity 契约(User/Me/Login/Refresh/Department/Role)
  与 user:view 权限点;写接口 strict 校验。
- apps/api:新增 identity 模块与 Prisma expand 迁移(departments/users/roles/
  user_roles/role_permissions/refresh_tokens);Argon2id、JWT access+refresh
  旋转与 jti 吊销、登录失败锁定、首登强改、守卫链、/me、@Audit 上下文、幂等种子。
- apps/web:按方案 B 落地 /users、/users/new、/users/:id、分屏登录与 403;
  usePermission/<Can>、路由守卫、401 单飞刷新、停用会话兜底、Semi + token。
- e2e:新增 A1-QA-01 六类端到端用例(A1_E2E 门控)。
- openapi 与前端生成类型同步;.env.example 登记认证变量;实施报告入库。

影响面:
- 新增运行时依赖 @nestjs/jwt、argon2(计划 A1-BE-02 已批准,版本精确锁定)。
- 迁移为 expand,不改既有表;本地需 prisma migrate 后重新 seed。
- 平台导航「用户与组织」由 /platform/users 调整为 /users。

回滚方式:
- 代码整体回滚本提交;迁移为 expand,回滚代码不需删表;
- 本地如需彻底回退按实施报告 §6 处理。

验证证据:
- pnpm format:check / lint / typecheck 通过;
- pnpm test:shared 95 / api 60 / web 55;
- pnpm test:int:7 个(真实 PG/Redis);
- A1_E2E=1 playwright test e2e/a1-identity.spec.ts:6/6;
- 报告:docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md
This commit is contained in:
Stev_Wang committed 2026-11-03 00:03:33 +08:00
1 parent f0d218bd99
commit 4d9eb980af
120 files changed
+13112 -314

No files matched your search

+1
View File
@@ -8,6 +8,7 @@
"build": "tsc -p tsconfig.json --noEmit && vite build",
"preview": "vite preview --port 4173 --strictPort",
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "eslint src --max-warnings=0",
"test": "vitest run --coverage"
},
"dependencies": {
+1 -1
View File
@@ -16,7 +16,7 @@ function signIn(accessToken = 'token-1', refreshToken = 'refresh-1'): void {
useAuthStore.getState().setSession({
accessToken,
refreshToken,
user: { id: '01J0TEST000000000000000000', name: '测试用户', email: 'test@lingxi.local' },
user: { id: '01J0TEST000000000000000000', name: '测试用户', account: 'test@lingxi.local' },
permissions: [],
});
}
+12 -9
View File
@@ -108,6 +108,13 @@ async function toApiError(response: Response): Promise<ApiError> {
});
}
/** 刷新失败:清本地会话并通知外壳跳登录(会话过期统一入口) */
function failRefresh(): false {
useAuthStore.getState().clearSession();
unauthorizedHandler?.();
return false;
}
let refreshPromise: Promise<boolean> | null = null;
/** 单飞刷新:并发 401 只触发一次 /auth/refresh */
@@ -119,9 +126,9 @@ export function refreshSession(): Promise<boolean> {
}
async function doRefresh(): Promise<boolean> {
const { refreshToken, clearSession } = useAuthStore.getState();
const { refreshToken } = useAuthStore.getState();
if (refreshToken === null) {
return false;
return failRefresh();
}
let response: Response;
try {
@@ -131,12 +138,10 @@ async function doRefresh(): Promise<boolean> {
body: JSON.stringify({ refreshToken }),
});
} catch {
return false;
return failRefresh();
}
if (!response.ok) {
clearSession();
unauthorizedHandler?.();
return false;
return failRefresh();
}
const payload: unknown = await response.json().catch(() => null);
const accessToken =
@@ -148,9 +153,7 @@ async function doRefresh(): Promise<boolean> {
? payload.refreshToken
: null;
if (typeof accessToken !== 'string' || accessToken.length === 0) {
clearSession();
unauthorizedHandler?.();
return false;
return failRefresh();
}
useAuthStore.getState().setTokens({
accessToken,
+23
View File
@@ -0,0 +1,23 @@
/**
* identity 模块接口路径(唯一出处)。
* 依据:技术架构 §6.3.1、开发计划附录 D。
* 后端若调整路径,只改这里;联调时以 `apps/web/src/api/generated/openapi.d.ts` 为准。
*/
const USERS = '/users';
export const ENDPOINTS = {
login: '/auth/login',
refresh: '/auth/refresh',
logout: '/auth/logout',
changePassword: '/auth/change-password',
me: '/me',
users: USERS,
user: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}`,
userDisable: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}/disable`,
userEnable: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}/enable`,
userCustomers: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}/customers`,
userTransferCustomers: (userId: string): string =>
`${USERS}/${encodeURIComponent(userId)}/transfer-customers`,
departments: '/departments/tree',
roles: '/roles',
} as const;
+536
View File
@@ -4,6 +4,270 @@
*/
export interface paths {
'/api/v1/auth/login': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* 账号密码登录
* @description 返回 accessToken / refreshToken / expiresIn / me
*/
post: operations['AuthController_login'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/auth/refresh': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** 刷新令牌(旋转,旧 refresh 立即失效) */
post: operations['AuthController_refresh'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/auth/logout': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* 登出
* @description 吊销 refresh 令牌所在家族
*/
post: operations['AuthController_logout'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/auth/change-password': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** 修改密码(首登强改) */
post: operations['AuthController_changePassword'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/me': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 当前用户、角色、权限点、数据范围 */
get: operations['MeController_current'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/users': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* 用户列表(游标分页)
* @description status 默认 active(停用默认隐藏);sort/order 走白名单;手机号仅返回掩码
*/
get: operations['UsersController_list'];
put?: never;
/**
* 新建用户
* @description 初始密码由管理员设定,首登强制修改
*/
post: operations['UsersController_create'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/users/{id}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* 用户详情
* @description 仅 user:manage 返回 phone 全号
*/
get: operations['UsersController_detail'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/** 更新用户(不含账号 / 状态 / 密码) */
patch: operations['UsersController_update'];
trace?: never;
};
'/api/v1/users/{id}/disable': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* 停用用户
* @description 停用后旧令牌立即失效;可指定客户转交人
*/
post: operations['UsersController_disable'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/users/{id}/enable': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** 启用用户 */
post: operations['UsersController_enable'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/users/{id}/transfer-customers': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* 客户转交(占位)
* @description A3 客户模块落地前返回 42201
*/
post: operations['UsersController_transferCustomers'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/departments/tree': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 部门树(两级,含成员数与负责人) */
get: operations['DepartmentsController_tree'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/departments': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* 新建部门 / 小组
* @description 最多两级,在小组下新建返回 42201
*/
post: operations['DepartmentsController_create'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/departments/{id}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/** 部门改名 */
patch: operations['DepartmentsController_rename'];
trace?: never;
};
'/api/v1/roles': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 角色列表(绑定用) */
get: operations['RolesController_list'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/health': {
parameters: {
query?: never;
@@ -135,6 +399,278 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
AuthController_login: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
AuthController_refresh: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
AuthController_logout: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
AuthController_changePassword: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
MeController_current: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
UsersController_list: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
UsersController_create: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
201: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
UsersController_detail: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
UsersController_update: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
UsersController_disable: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
UsersController_enable: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
UsersController_transferCustomers: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
DepartmentsController_tree: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
DepartmentsController_create: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
201: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
DepartmentsController_rename: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
RolesController_list: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
HealthController_check: {
parameters: {
query?: never;
+199
View File
@@ -0,0 +1,199 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { ApiError, request, setUnauthorizedHandler } from './client';
import {
changePassword,
createUser,
disableUser,
enableUser,
fetchDepartments,
fetchMe,
fetchRoles,
fetchUserCustomers,
login,
logout,
transferCustomers,
updateUser,
} from './identity';
import { fetchUsers } from './users';
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
const GROUP_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ3';
const userSummary = {
id: USER_ID,
name: '陈默',
phoneMasked: '138****2046',
account: 'chen.mo',
departmentId: GROUP_ID,
departmentName: '内容生产中心',
groupId: GROUP_ID,
groupName: '素材一组',
roles: [{ id: ROLE_ID, code: 'ops', name: '素材运营', isSystem: false }],
status: 'active',
lastLoginAt: '2026-11-02T09:41:00.000Z',
createdAt: '2026-09-30T18:40:00.000Z',
};
const me = {
user: userSummary,
roles: userSummary.roles,
permissions: ['user:view'],
dataScope: { type: 'all' },
mustChangePassword: false,
};
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
function toUrl(input: string | URL | Request | undefined): string {
if (typeof input === 'string') {
return input;
}
if (input instanceof URL) {
return input.href;
}
return input?.url ?? '';
}
function calls(): { url: string; init: RequestInit | undefined }[] {
const fetchMock = vi.mocked(globalThis.fetch);
return fetchMock.mock.calls.map((call) => ({
url: toUrl(call[0]),
init: call[1],
}));
}
function lastBody(): unknown {
const last = calls().at(-1);
const body = last?.init?.body;
return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined;
}
describe('identity 接口封装', () => {
beforeEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
useAuthStore.getState().clearSession();
setUnauthorizedHandler(null);
useAuthStore.getState().setSession({
accessToken: 'token',
user: { id: USER_ID, name: '陈默', account: 'chen.mo' },
permissions: ['user:view'],
});
});
it('登录:POST /auth/login,body 为 account/password 且不带 token', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
jsonResponse({
accessToken: 'a',
refreshToken: 'r',
expiresIn: 3600,
me,
}),
);
const response = await login({ account: 'chen.mo', password: 'Lx123456' });
expect(response.me.permissions).toEqual(['user:view']);
expect(calls()[0]?.init?.method).toBe('POST');
expect(lastBody()).toEqual({ account: 'chen.mo', password: 'Lx123456' });
});
it('/me、部门与角色列表按共享 schema 解析', async () => {
const department = {
id: GROUP_ID,
name: '内容生产中心',
parentId: null,
leader: null,
memberCount: 19,
createdAt: '2026-09-01T00:00:00.000Z',
children: [
{
id: USER_ID,
name: '素材一组',
parentId: GROUP_ID,
leader: null,
memberCount: 9,
createdAt: '2026-09-01T00:00:00.000Z',
children: [],
},
],
};
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse(me))
.mockResolvedValueOnce(jsonResponse({ items: [department] }))
.mockResolvedValueOnce(jsonResponse({ items: userSummary.roles }));
expect((await fetchMe()).user.name).toBe('陈默');
const departments = await fetchDepartments();
expect(departments.map((node) => node.name)).toEqual(['内容生产中心', '素材一组']);
expect((await fetchRoles())[0]?.name).toBe('素材运营');
});
it('用户列表保留 next_cursor,写接口使用约定方法与路径', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ items: [userSummary], next_cursor: 'cursor-2' }))
.mockResolvedValueOnce(jsonResponse({ id: USER_ID }))
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(jsonResponse([{ id: USER_ID, name: '花漾家居' }]))
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
const list = await fetchUsers({ limit: 10, status: 'active' });
expect(list.next_cursor).toBe('cursor-2');
expect(
await createUser({
name: '测试成员',
phone: '13900001111',
account: 'test.member',
password: 'Lx123456',
departmentId: GROUP_ID,
roleIds: [ROLE_ID],
}),
).toBe(USER_ID);
await updateUser(USER_ID, { name: '陈默' });
await disableUser(USER_ID, { reason: '离职' });
await enableUser(USER_ID);
await transferCustomers(USER_ID, { customerIds: [USER_ID], toUserId: ROLE_ID });
expect((await fetchUserCustomers(USER_ID))[0]?.name).toBe('花漾家居');
await changePassword({ currentPassword: 'Lx123456', newPassword: 'Lx654321' });
await logout('refresh-token');
const paths = calls().map((call) => ({
url: call.url.replace('http://127.0.0.1:3000/api/v1', ''),
method: call.init?.method ?? 'GET',
}));
expect(paths).toContainEqual({ url: '/users', method: 'POST' });
expect(paths).toContainEqual({ url: `/users/${USER_ID}`, method: 'PATCH' });
expect(paths).toContainEqual({ url: `/users/${USER_ID}/disable`, method: 'POST' });
expect(paths).toContainEqual({ url: `/users/${USER_ID}/enable`, method: 'POST' });
expect(paths).toContainEqual({
url: `/users/${USER_ID}/transfer-customers`,
method: 'POST',
});
expect(paths).toContainEqual({ url: '/auth/change-password', method: 'POST' });
expect(paths).toContainEqual({ url: '/auth/logout', method: 'POST' });
});
it('刷新失败时清理会话并触发跳登录', async () => {
const handler = vi.fn();
setUnauthorizedHandler(handler);
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ code: 40101, message: '登录已过期', trace_id: 't-401' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
}),
);
useAuthStore.getState().setTokens({ accessToken: 'old', refreshToken: 'refresh-1' });
await expect(request('/me')).rejects.toBeInstanceOf(ApiError);
expect(handler).toHaveBeenCalled();
expect(useAuthStore.getState().accessToken).toBeNull();
});
});
+135
View File
@@ -0,0 +1,135 @@
import {
type ChangePasswordInput,
changePasswordInputSchema,
type CreateUserInput,
createUserInputSchema,
type DepartmentNode,
departmentNodeSchema,
type DisableUserInput,
disableUserInputSchema,
type LoginInput,
loginInputSchema,
type LoginResponse,
loginResponseSchema,
type MeResponse,
meResponseSchema,
type RoleSummary,
roleSummarySchema,
type TransferCustomersInput,
transferCustomersInputSchema,
type UpdateUserInput,
updateUserInputSchema,
} from '@lingxi/shared';
import { z } from 'zod';
import { request } from './client';
import { ENDPOINTS } from './endpoints';
/** 客户摘要:A3 客户模块落地前的转交占位契约(仅 id + name) */
export interface CustomerSummary {
id: string;
name: string;
}
const customerSummarySchema = z.object({ id: z.string().min(1), name: z.string().min(1) });
const createdUserSchema = z.object({ id: z.string().min(1) }).passthrough();
/** 兼容 `T[]` 与 `{ items: T[] }` 两种列表返回,逐项用共享 schema 校验 */
function parseList<T>(payload: unknown, itemSchema: z.ZodType<T>): T[] {
const candidate: unknown[] = Array.isArray(payload)
? payload
: isRecord(payload) && Array.isArray(payload.items)
? payload.items
: [];
return candidate.map((item) => itemSchema.parse(item));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export async function login(input: LoginInput): Promise<LoginResponse> {
const body = loginInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.login, { method: 'POST', auth: false, body });
return loginResponseSchema.parse(payload);
}
export async function logout(refreshToken: string | null): Promise<void> {
const body: { refreshToken?: string } = refreshToken === null ? {} : { refreshToken };
await request<unknown>(ENDPOINTS.logout, { method: 'POST', body });
}
export async function changePassword(input: ChangePasswordInput): Promise<void> {
const body = changePasswordInputSchema.parse(input);
await request<unknown>(ENDPOINTS.changePassword, { method: 'POST', body });
}
export async function fetchMe(): Promise<MeResponse> {
const payload: unknown = await request(ENDPOINTS.me);
return meResponseSchema.parse(payload);
}
/** 部门树(后端返回两级嵌套 children),前端拍平成列表供下拉与筛选使用 */
export async function fetchDepartments(): Promise<DepartmentNode[]> {
const payload: unknown = await request(ENDPOINTS.departments);
const raw: unknown = isRecord(payload) && Array.isArray(payload.items) ? payload.items : payload;
if (!Array.isArray(raw)) {
return [];
}
const flat: DepartmentNode[] = [];
const walk = (nodes: unknown[]): void => {
for (const node of nodes) {
if (!isRecord(node)) {
continue;
}
flat.push(departmentNodeSchema.parse(node));
const children: unknown = node.children;
if (Array.isArray(children)) {
walk(children);
}
}
};
walk(raw);
return flat;
}
export async function fetchRoles(): Promise<RoleSummary[]> {
const payload: unknown = await request(ENDPOINTS.roles);
return parseList(payload, roleSummarySchema);
}
/** 新建用户:返回新用户 id;后端若未返回 id,则由调用方回落到列表页 */
export async function createUser(input: CreateUserInput): Promise<string | null> {
const body = createUserInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.users, { method: 'POST', body });
const parsed = createdUserSchema.safeParse(payload);
return parsed.success ? parsed.data.id : null;
}
export async function updateUser(userId: string, input: UpdateUserInput): Promise<void> {
const body = updateUserInputSchema.parse(input);
await request<unknown>(ENDPOINTS.user(userId), { method: 'PATCH', body });
}
export async function disableUser(userId: string, input: DisableUserInput): Promise<void> {
const body = disableUserInputSchema.parse(input);
await request<unknown>(ENDPOINTS.userDisable(userId), { method: 'POST', body });
}
export async function enableUser(userId: string): Promise<void> {
await request<unknown>(ENDPOINTS.userEnable(userId), { method: 'POST' });
}
export async function transferCustomers(
userId: string,
input: TransferCustomersInput,
): Promise<void> {
const body = transferCustomersInputSchema.parse(input);
await request<unknown>(ENDPOINTS.userTransferCustomers(userId), { method: 'POST', body });
}
/** 负责客户列表(A3 接口落地前调用会 404,由 hook 降级为空态) */
export async function fetchUserCustomers(userId: string): Promise<CustomerSummary[]> {
const payload: unknown = await request(ENDPOINTS.userCustomers(userId));
return parseList(payload, customerSummarySchema);
}
+12
View File
@@ -0,0 +1,12 @@
import type { UserListQueryInput } from './users';
/** TanStack Query 缓存键(唯一出处,避免散落字符串) */
export const queryKeys = {
me: ['me'] as const,
users: ['users'] as const,
userList: (query: UserListQueryInput) => ['users', 'list', query] as const,
user: (userId: string) => ['users', 'detail', userId] as const,
userCustomers: (userId: string) => ['users', 'customers', userId] as const,
departments: ['departments'] as const,
roles: ['roles'] as const,
};
+111
View File
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { fetchUserDetail, fetchUsers, toUserListSearchParams } from './users';
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZZ';
const DEPT_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
const summary = {
id: USER_ID,
name: '陈默',
phoneMasked: '138****2046',
account: 'chen.mo',
departmentId: DEPT_ID,
departmentName: '内容生产中心',
groupId: null,
groupName: '素材一组',
roles: [{ id: ROLE_ID, code: 'ops', name: '素材运营', isSystem: false }],
status: 'active',
lastLoginAt: '2026-11-02T09:41:00.000Z',
createdAt: '2026-09-30T18:40:00.000Z',
};
describe('用户列表请求参数', () => {
it('按后端 query 约定拼装 snake_case 参数,空值不传', () => {
const params = toUserListSearchParams({
limit: 10,
status: 'active',
sort: 'last_login',
order: 'desc',
keyword: ' 陈 ',
});
expect(params.get('limit')).toBe('10');
expect(params.get('status')).toBe('active');
expect(params.get('sort')).toBe('last_login');
expect(params.get('order')).toBe('desc');
expect(params.get('keyword')).toBe('陈');
expect(params.has('department_id')).toBe(false);
expect(params.has('cursor')).toBe(false);
});
it('带游标与部门/角色筛选时完整输出', () => {
const params = toUserListSearchParams({
limit: 10,
cursor: 'cursor-2',
status: 'all',
sort: 'created_at',
order: 'asc',
department_id: DEPT_ID,
role_id: ROLE_ID,
});
expect(params.toString()).toBe(
`limit=10&cursor=cursor-2&department_id=${DEPT_ID}&role_id=${ROLE_ID}&status=all&sort=created_at&order=asc`,
);
});
});
function toUrl(input: string | URL | Request | undefined): string {
if (typeof input === 'string') {
return input;
}
if (input instanceof URL) {
return input.href;
}
return input?.url ?? '';
}
describe('用户列表请求', () => {
beforeEach(() => {
window.localStorage.clear();
useAuthStore.getState().setSession({
accessToken: 'token',
user: { id: USER_ID, name: '陈默', account: 'chen.mo' },
permissions: ['user:view'],
});
});
it('解析列表信封与详情', async () => {
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(
new Response(JSON.stringify({ items: [summary], next_cursor: 'cursor-2' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
...summary,
phone: '13800002046',
mustChangePassword: true,
updatedAt: '2026-11-02T09:41:00.000Z',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
const list = await fetchUsers({ limit: 10, status: 'active' });
expect(list.items).toHaveLength(1);
expect(list.next_cursor).toBe('cursor-2');
expect(toUrl(fetchMock.mock.calls[0]?.[0])).toContain('/users?limit=10&status=active');
const detail = await fetchUserDetail(USER_ID);
expect(detail.phone).toBe('13800002046');
expect(detail.mustChangePassword).toBe(true);
expect(toUrl(fetchMock.mock.calls[1]?.[0])).toContain(`/users/${USER_ID}`);
});
});
+60
View File
@@ -0,0 +1,60 @@
import {
type ListResponse,
listResponseSchema,
type UserDetail,
userDetailSchema,
type UserListQuery,
type UserSummary,
userSummarySchema,
} from '@lingxi/shared';
import { request } from './client';
import { ENDPOINTS } from './endpoints';
const userListResponseSchema = listResponseSchema(userSummarySchema);
/** 用户列表筛选与游标分页查询(query 参数 snake_case) */
export interface UserListQueryInput extends Partial<UserListQuery> {
limit: number;
cursor?: string;
}
/** 组装 GET /users 的 query 参数;空值不落 query,避免后端严格校验报错 */
export function toUserListSearchParams(query: UserListQueryInput): URLSearchParams {
const params = new URLSearchParams();
params.set('limit', String(query.limit));
if (query.cursor !== undefined && query.cursor !== '') {
params.set('cursor', query.cursor);
}
if (query.keyword !== undefined && query.keyword.trim() !== '') {
params.set('keyword', query.keyword.trim());
}
if (query.department_id !== undefined) {
params.set('department_id', query.department_id);
}
if (query.role_id !== undefined) {
params.set('role_id', query.role_id);
}
if (query.status !== undefined) {
params.set('status', query.status);
}
if (query.sort !== undefined) {
params.set('sort', query.sort);
}
if (query.order !== undefined) {
params.set('order', query.order);
}
return params;
}
export async function fetchUsers(query: UserListQueryInput): Promise<ListResponse<UserSummary>> {
const search = toUserListSearchParams(query).toString();
const path = search === '' ? ENDPOINTS.users : `${ENDPOINTS.users}?${search}`;
const payload: unknown = await request(path);
return userListResponseSchema.parse(payload);
}
export async function fetchUserDetail(userId: string): Promise<UserDetail> {
const payload: unknown = await request(ENDPOINTS.user(userId));
return userDetailSchema.parse(payload);
}
+19
View File
@@ -0,0 +1,19 @@
import type { PermissionCode } from '@lingxi/shared';
import type { ReactNode } from 'react';
import { usePermission } from '@/hooks/use-permission';
export interface CanProps {
/** 需要的权限点(数组时按 mode 组合判断) */
permission: PermissionCode | readonly PermissionCode[];
mode?: 'all' | 'any';
/** 无权限时的渲染内容;默认不渲染(按钮级控制要求「不渲染」而非隐藏) */
fallback?: ReactNode;
children: ReactNode;
}
/** 按钮级权限控制:无权限时不渲染子树 */
export function Can({ permission, mode = 'all', fallback = null, children }: CanProps): ReactNode {
const allowed = usePermission(permission, mode);
return allowed ? children : fallback;
}
@@ -0,0 +1,119 @@
/* eslint-disable max-lines-per-function -- 弹窗按字段顺序展开,拆分后状态传递反而更繁琐 */
import { Button, Input, Modal } from '@douyinfe/semi-ui-19';
import { IconLock } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useChangePasswordForm } from './use-change-password-form';
export interface MustChangePasswordModalProps {
/** 改密成功:调用方负责清会话并停留在登录页 */
onCompleted: () => void;
/** 取消 / 关闭:调用方负责清会话回登录表单 */
onCancel: () => void;
}
function Field({
label,
value,
error,
disabled,
placeholder,
onChange,
}: {
label: string;
value: string;
error: string | null;
disabled: boolean;
placeholder: string;
onChange: (value: string) => void;
}): ReactNode {
return (
<label className="lx-field">
<span className="lx-field__label">{label}</span>
<Input
type="password"
size="large"
value={value}
disabled={disabled}
placeholder={placeholder}
onChange={onChange}
/>
{error === null ? null : <span className="lx-field__error">{error}</span>}
</label>
);
}
/**
* 首登强制改密:只在登录流程内渲染(不进入 AppLayout),
* 改密成功后由登录页清会话并要求用新密码重新登录。
*/
export function MustChangePasswordModal({
onCompleted,
onCancel,
}: MustChangePasswordModalProps): ReactNode {
const form = useChangePasswordForm();
const handleSubmit = (): void => {
void form.submit().then((ok) => {
if (ok) {
onCompleted();
}
});
};
return (
<Modal
visible
closable={!form.submitting}
maskClosable={false}
title="首次登录,请修改初始密码"
width={440}
onCancel={onCancel}
footer={
<div className="lx-must-change__footer">
<Button onClick={onCancel} disabled={form.submitting}>
取消
</Button>
<Button
theme="solid"
type="primary"
loading={form.submitting}
onClick={handleSubmit}
data-testid="change-password-submit"
>
更新密码
</Button>
</div>
}
>
<div className="lx-must-change">
<p className="lx-must-change__hint">
<IconLock size={16} />
为保障账号安全,初始密码仅用于首次登录;新密码需 8–64 位且同时包含字母与数字。
</p>
<Field
label="当前密码"
value={form.currentPassword}
error={form.currentError}
disabled={form.submitting}
placeholder="请输入初始密码"
onChange={form.setCurrentPassword}
/>
<Field
label="新密码"
value={form.newPassword}
error={form.newError}
disabled={form.submitting}
placeholder="请输入新密码"
onChange={form.setNewPassword}
/>
{form.submitError === null ? null : (
<p className="lx-must-change__error">{form.submitError}</p>
)}
<p className="lx-must-change__hint is-muted">
修改成功后当前会话将退出,请使用新密码重新登录。
</p>
</div>
</Modal>
);
}
@@ -0,0 +1,84 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiError } from '@/api/client';
import { fetchMe } from '@/api/identity';
import { useAuthStore } from '@/stores/auth-store';
// @lingxi/ui 的骨架/错误态依赖 Semi(lottie),jsdom 无 canvas;守卫单测只关心跳转逻辑
vi.mock('@lingxi/ui', () => ({
PageSkeleton: () => <div data-testid="skeleton" />,
ErrorState: () => <div data-testid="error-state" />,
}));
vi.mock('@/api/identity', () => ({
fetchMe: vi.fn(),
}));
import { RequireAuth } from './RequireAuth';
const mockedFetchMe = vi.mocked(fetchMe);
function renderGuard(entry: string): void {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route
path="/users"
element={
<RequireAuth>
<div>受保护页面</div>
</RequireAuth>
}
/>
<Route path="/login" element={<div data-testid="login-route">登录页</div>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('登录守卫', () => {
afterEach(() => {
cleanup();
window.localStorage.clear();
});
beforeEach(() => {
mockedFetchMe.mockReset();
useAuthStore.getState().clearSession();
});
it('未登录时跳登录页并携带 redirect', async () => {
renderGuard('/users');
expect(await screen.findByTestId('login-route')).toBeTruthy();
expect(screen.queryByText('受保护页面')).toBeNull();
});
it('会话处于「未改初始密码」时清会话并回登录页(后端 40903)', async () => {
mockedFetchMe.mockRejectedValue(
new ApiError({ code: 40903, status: 409, message: '请先修改初始密码' }),
);
useAuthStore.getState().setSession({
accessToken: 'real-token',
refreshToken: 'refresh',
user: { id: '01HZZZZZZZZZZZZZZZZZZZZZZZ', name: '管理员', account: 'admin' },
permissions: ['user:view'],
mustChangePassword: true,
});
renderGuard('/users');
expect(await screen.findByTestId('login-route')).toBeTruthy();
expect(useAuthStore.getState().accessToken).toBeNull();
});
it('已登录(演示会话)时直接渲染受保护内容', () => {
useAuthStore.getState().signInDemo();
expect(useAuthStore.getState().accessToken).not.toBeNull();
renderGuard('/users');
expect(screen.getByText('受保护页面')).toBeTruthy();
});
});
@@ -0,0 +1,83 @@
/* eslint-disable max-lines-per-function -- 守卫串联 /me 同步、401 与 409 兜底,集中便于追踪 */
import { ErrorState, PageSkeleton } from '@lingxi/ui';
import { type ReactNode, useEffect } from 'react';
import { Navigate, useLocation } from 'react-router';
import {
isAccountDisabledError,
isMustChangePasswordError,
isUnauthorizedError,
useMeQuery,
} from '@/hooks/queries/use-me';
import { loginPath } from '@/routes';
import { isDemoSession, useAuthStore } from '@/stores/auth-store';
export interface RequireAuthProps {
children: ReactNode;
}
/** 登录守卫:未登录跳登录页(带 redirect),已登录则拉 /me 后再渲染外壳 */
export function RequireAuth({ children }: RequireAuthProps): ReactNode {
const location = useLocation();
const accessToken = useAuthStore((state) => state.accessToken);
const syncFromMe = useAuthStore((state) => state.syncFromMe);
const clearSession = useAuthStore((state) => state.clearSession);
const meQuery = useMeQuery();
const from = `${location.pathname}${location.search}`;
const mustChangeBlocked = isMustChangePasswordError(meQuery.error);
useEffect(() => {
if (meQuery.data !== undefined) {
syncFromMe(meQuery.data);
}
}, [meQuery.data, syncFromMe]);
// 刷新页面时若会话仍处于「未改初始密码」状态:清会话回登录页完成改密,避免卡在 409
useEffect(() => {
if (mustChangeBlocked) {
clearSession();
}
}, [mustChangeBlocked, clearSession]);
if (accessToken === null) {
return <Navigate to={loginPath(from)} replace />;
}
if (isDemoSession(accessToken)) {
return children;
}
if (meQuery.isPending) {
return (
<div className="lx-auth-loading" data-testid="auth-loading">
<PageSkeleton rows={10} />
</div>
);
}
if (mustChangeBlocked) {
return <Navigate to={loginPath(from)} replace />;
}
if (meQuery.isError) {
if (isAccountDisabledError(meQuery.error)) {
// 会话在登录页按 reason 清理,避免先 clearSession 触发「未登录」分支把 reason 丢掉
const base = loginPath(from);
return (
<Navigate to={`${base}${base.includes('?') ? '&' : '?'}reason=account-disabled`} replace />
);
}
if (isUnauthorizedError(meQuery.error)) {
clearSession();
return <Navigate to={loginPath(from)} replace />;
}
return (
<div className="lx-auth-loading">
<ErrorState
title="权限信息加载失败"
message={meQuery.error.message}
onRetry={() => {
void meQuery.refetch();
}}
/>
</div>
);
}
return children;
}
@@ -0,0 +1,55 @@
import { Permission } from '@lingxi/shared';
import { cleanup, render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { RequirePermission } from './RequirePermission';
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZZ';
function renderGuard(permissions: string[]): void {
useAuthStore.getState().setSession({
accessToken: 'token',
user: { id: USER_ID, name: '测试用户', account: 'test.user' },
permissions,
});
render(
<MemoryRouter initialEntries={['/users']}>
<Routes>
<Route
path="/users"
element={
<RequirePermission permission={Permission.USER_VIEW}>
<div>用户页面</div>
</RequirePermission>
}
/>
<Route path="/403" element={<div>无权限页面</div>} />
</Routes>
</MemoryRouter>,
);
}
describe('路由权限守卫', () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
window.localStorage.clear();
useAuthStore.getState().clearSession();
});
it('有 user:view 时渲染子路由', () => {
renderGuard([Permission.USER_VIEW]);
expect(screen.getByText('用户页面')).toBeTruthy();
});
it('无权限时跳转 403,不渲染子路由', () => {
renderGuard([Permission.SCRIPT_READ]);
expect(screen.queryByText('用户页面')).toBeNull();
expect(screen.getByText('无权限页面')).toBeTruthy();
});
});
@@ -0,0 +1,22 @@
import type { PermissionCode } from '@lingxi/shared';
import type { ReactNode } from 'react';
import { Navigate } from 'react-router';
import { usePermission } from '@/hooks/use-permission';
import { ROUTES } from '@/routes';
export interface RequirePermissionProps {
permission: PermissionCode | readonly PermissionCode[];
mode?: 'all' | 'any';
children: ReactNode;
}
/** 路由级权限守卫:缺少权限点时跳 403(权限来自 /me 或登录响应) */
export function RequirePermission({
permission,
mode = 'all',
children,
}: RequirePermissionProps): ReactNode {
const allowed = usePermission(permission, mode);
return allowed ? children : <Navigate to={ROUTES.forbidden} replace />;
}
@@ -0,0 +1,68 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiError } from '@/api/client';
import { changePassword } from '@/api/identity';
import { useChangePasswordForm } from './use-change-password-form';
vi.mock('@/api/identity', () => ({
changePassword: vi.fn(),
}));
const mockedChangePassword = vi.mocked(changePassword);
describe('首登强改密码表单', () => {
beforeEach(() => {
mockedChangePassword.mockReset();
});
it('当前密码错误(后端 401)显示在字段下且不返回成功', async () => {
mockedChangePassword.mockRejectedValue(
new ApiError({ code: 40101, status: 401, message: '当前密码不正确' }),
);
const { result } = renderHook(() => useChangePasswordForm());
act(() => {
result.current.setCurrentPassword('wrong-pass1');
result.current.setNewPassword('Lingxi2026a');
});
let ok = true;
await act(async () => {
ok = await result.current.submit();
});
expect(ok).toBe(false);
expect(result.current.currentError).toBe('当前密码不正确,请重新输入');
expect(result.current.submitError).toBeNull();
});
it('校验不过时不发请求', async () => {
const { result } = renderHook(() => useChangePasswordForm());
act(() => {
result.current.setCurrentPassword('lingxi2026');
result.current.setNewPassword('short');
});
await act(async () => {
await result.current.submit();
});
expect(mockedChangePassword).not.toHaveBeenCalled();
expect(result.current.newError).toBeTruthy();
});
it('改密成功返回 true,由调用方负责清会话', async () => {
mockedChangePassword.mockResolvedValue();
const { result } = renderHook(() => useChangePasswordForm());
act(() => {
result.current.setCurrentPassword('lingxi2026');
result.current.setNewPassword('Lingxi2026a');
});
let ok = false;
await act(async () => {
ok = await result.current.submit();
});
expect(ok).toBe(true);
expect(mockedChangePassword).toHaveBeenCalledWith({
currentPassword: 'lingxi2026',
newPassword: 'Lingxi2026a',
});
});
});
@@ -0,0 +1,88 @@
/* eslint-disable max-lines-per-function -- 表单校验与提交流程集中在一个 Hook 便于追踪 */
import { changePasswordInputSchema, passwordSchema } from '@lingxi/shared';
import { useState } from 'react';
import { ApiError } from '@/api/client';
import { changePassword } from '@/api/identity';
export interface ChangePasswordFormState {
currentPassword: string;
newPassword: string;
currentError: string | null;
newError: string | null;
submitting: boolean;
submitError: string | null;
setCurrentPassword: (value: string) => void;
setNewPassword: (value: string) => void;
/** 提交成功返回 true(由调用方决定清会话/跳转),失败返回 false */
submit: () => Promise<boolean>;
}
function fieldError(message: string | undefined): string | null {
return message ?? null;
}
function isWrongCurrentPassword(error: unknown): boolean {
return error instanceof ApiError && (error.status === 401 || error.code === 40101);
}
/** 首次登录强制改密:密码规则与后端共用 shared 的 zod schema;不改会话,副作用交给调用方 */
export function useChangePasswordForm(): ChangePasswordFormState {
const [currentPassword, setCurrent] = useState('');
const [newPassword, setNew] = useState('');
const [currentError, setCurrentError] = useState<string | null>(null);
const [newError, setNewError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const submit = async (): Promise<boolean> => {
const parsed = changePasswordInputSchema.safeParse({ currentPassword, newPassword });
if (!parsed.success) {
const issues = parsed.error.issues;
setCurrentError(
fieldError(issues.find((issue) => issue.path[0] === 'currentPassword')?.message),
);
setNewError(fieldError(issues.find((issue) => issue.path[0] === 'newPassword')?.message));
return false;
}
const passwordCheck = passwordSchema.safeParse(newPassword);
if (!passwordCheck.success) {
setNewError(fieldError(passwordCheck.error.issues[0]?.message));
return false;
}
setSubmitting(true);
setSubmitError(null);
try {
await changePassword(parsed.data);
return true;
} catch (error) {
if (isWrongCurrentPassword(error)) {
// 后端对「当前密码不正确」返回 401;按字段错误展示,不触发全局登出
setCurrentError('当前密码不正确,请重新输入');
} else {
setSubmitError(error instanceof Error ? error.message : '修改密码失败,请稍后重试');
}
return false;
} finally {
setSubmitting(false);
}
};
return {
currentPassword,
newPassword,
currentError,
newError,
submitting,
submitError,
setCurrentPassword: (value) => {
setCurrent(value);
setCurrentError(null);
},
setNewPassword: (value) => {
setNew(value);
setNewError(null);
},
submit,
};
}
@@ -0,0 +1,21 @@
import { type DepartmentNode, type RoleSummary } from '@lingxi/shared';
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
import { fetchDepartments, fetchRoles } from '@/api/identity';
import { queryKeys } from '@/api/query-keys';
export function useDepartmentsQuery(): UseQueryResult<DepartmentNode[]> {
return useQuery({
queryKey: queryKeys.departments,
queryFn: fetchDepartments,
staleTime: 5 * 60_000,
});
}
export function useRolesQuery(): UseQueryResult<RoleSummary[]> {
return useQuery({
queryKey: queryKeys.roles,
queryFn: fetchRoles,
staleTime: 5 * 60_000,
});
}
+34
View File
@@ -0,0 +1,34 @@
import type { MeResponse } from '@lingxi/shared';
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
import { ApiError } from '@/api/client';
import { fetchMe } from '@/api/identity';
import { queryKeys } from '@/api/query-keys';
import { isDemoSession, useAuthStore } from '@/stores/auth-store';
/** /me 全局缓存:守卫与权限 Hook 共用;演示会话不请求后端 */
export function useMeQuery(): UseQueryResult<MeResponse> {
const accessToken = useAuthStore((state) => state.accessToken);
const enabled = accessToken !== null && !isDemoSession(accessToken);
return useQuery({
queryKey: queryKeys.me,
queryFn: fetchMe,
enabled,
retry: false,
staleTime: 60_000,
});
}
export function isUnauthorizedError(error: unknown): boolean {
return error instanceof ApiError && error.status === 401;
}
/** 账号被停用:/me 由登录守卫返回 40301,此时旧会话应视为失效并回登录页 */
export function isAccountDisabledError(error: unknown): boolean {
return error instanceof ApiError && (error.status === 403 || error.code === 40301);
}
/** 后端守卫:初始密码未修改时,除改密外的接口统一返回 40903「请先修改初始密码」 */
export function isMustChangePasswordError(error: unknown): boolean {
return error instanceof ApiError && (error.status === 409 || error.code === 40903);
}
+152
View File
@@ -0,0 +1,152 @@
import {
type CreateUserInput,
type DisableUserInput,
type ListResponse,
type TransferCustomersInput,
type UpdateUserInput,
type UserDetail,
type UserSummary,
} from '@lingxi/shared';
import {
keepPreviousData,
useMutation,
type UseMutationResult,
useQuery,
useQueryClient,
type UseQueryResult,
} from '@tanstack/react-query';
import { ApiError } from '@/api/client';
import {
createUser,
type CustomerSummary,
disableUser,
enableUser,
fetchUserCustomers,
transferCustomers,
updateUser,
} from '@/api/identity';
import { queryKeys } from '@/api/query-keys';
import { fetchUserDetail, fetchUsers, type UserListQueryInput } from '@/api/users';
/** 转交/停用弹窗的接收人候选:一次拉取在职成员(上限 100),长时间复用 */
export function useUserCandidatesQuery(
enabled: boolean,
): UseQueryResult<ListResponse<UserSummary>> {
return useQuery({
queryKey: [...queryKeys.users, 'candidates'] as const,
queryFn: () => fetchUsers({ limit: 100, status: 'active', sort: 'last_login', order: 'desc' }),
enabled,
staleTime: 60_000,
});
}
export function useUserListQuery(
query: UserListQueryInput,
): UseQueryResult<ListResponse<UserSummary>> {
return useQuery({
queryKey: queryKeys.userList(query),
queryFn: () => fetchUsers(query),
placeholderData: keepPreviousData,
staleTime: 10_000,
});
}
export function useUserDetailQuery(userId: string | null): UseQueryResult<UserDetail> {
return useQuery({
queryKey: queryKeys.user(userId ?? ''),
queryFn: () => fetchUserDetail(userId ?? ''),
enabled: userId !== null && userId !== '',
});
}
/** 负责客户:A3 客户模块接口未落地时降级为空列表,不阻塞详情其它 Tab */
export function useUserCustomersQuery(userId: string | null): UseQueryResult<CustomerSummary[]> {
return useQuery({
queryKey: queryKeys.userCustomers(userId ?? ''),
queryFn: async () => {
try {
return await fetchUserCustomers(userId ?? '');
} catch (error) {
if (error instanceof ApiError && (error.status === 404 || error.status === 422)) {
return [];
}
throw error;
}
},
enabled: userId !== null && userId !== '',
retry: false,
});
}
function useInvalidateUsers(): () => Promise<void> {
const queryClient = useQueryClient();
return async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.users });
};
}
export function useCreateUserMutation(): UseMutationResult<string | null, Error, CreateUserInput> {
const invalidate = useInvalidateUsers();
return useMutation({
mutationFn: (input: CreateUserInput) => createUser(input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useUpdateUserMutation(): UseMutationResult<
void,
Error,
{ userId: string; input: UpdateUserInput }
> {
const invalidate = useInvalidateUsers();
return useMutation({
mutationFn: ({ userId, input }: { userId: string; input: UpdateUserInput }) =>
updateUser(userId, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useDisableUserMutation(): UseMutationResult<
void,
Error,
{ userId: string; input: DisableUserInput }
> {
const invalidate = useInvalidateUsers();
return useMutation({
mutationFn: ({ userId, input }: { userId: string; input: DisableUserInput }) =>
disableUser(userId, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useEnableUserMutation(): UseMutationResult<void, Error, string> {
const invalidate = useInvalidateUsers();
return useMutation({
mutationFn: (userId: string) => enableUser(userId),
onSuccess: async () => {
await invalidate();
},
});
}
export function useTransferCustomersMutation(): UseMutationResult<
void,
Error,
{ userId: string; input: TransferCustomersInput }
> {
const invalidate = useInvalidateUsers();
return useMutation({
mutationFn: ({ userId, input }: { userId: string; input: TransferCustomersInput }) =>
transferCustomers(userId, input),
onSuccess: async () => {
await invalidate();
},
});
}
+17
View File
@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
/** 输入防抖:UI 即时反馈,请求参数延后生效 */
export function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = window.setTimeout(() => {
setDebounced(value);
}, delay);
return () => {
window.clearTimeout(timer);
};
}, [value, delay]);
return debounced;
}
+26
View File
@@ -0,0 +1,26 @@
import { useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useNavigate } from 'react-router';
import { logout } from '@/api/identity';
import { ROUTES } from '@/routes';
import { useAuthStore } from '@/stores/auth-store';
/** 登出:先吊销服务端会话(失败不阻塞),再清本地会话与查询缓存 */
export function useLogout(): () => void {
const navigate = useNavigate();
const queryClient = useQueryClient();
const refreshToken = useAuthStore((state) => state.refreshToken);
const clearSession = useAuthStore((state) => state.clearSession);
return useCallback(() => {
const finish = (): void => {
clearSession();
queryClient.clear();
void navigate(ROUTES.login, { replace: true });
};
void logout(refreshToken)
.catch(() => undefined)
.finally(finish);
}, [clearSession, navigate, queryClient, refreshToken]);
}
+55
View File
@@ -0,0 +1,55 @@
import { Permission } from '@lingxi/shared';
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { can, usePermission, usePermissions } from './use-permission';
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZZ';
function signIn(permissions: string[]): void {
act(() => {
useAuthStore.getState().setSession({
accessToken: 'token',
user: { id: USER_ID, name: '测试用户', account: 'test.user' },
permissions,
});
});
}
describe('权限判断', () => {
beforeEach(() => {
window.localStorage.clear();
act(() => {
useAuthStore.getState().clearSession();
});
});
it('can 支持单权限、全部命中与任一命中', () => {
expect(can([Permission.USER_VIEW], Permission.USER_VIEW)).toBe(true);
expect(can([], Permission.USER_VIEW)).toBe(false);
expect(can([Permission.USER_VIEW], [Permission.USER_VIEW, Permission.USER_MANAGE])).toBe(false);
expect(can([Permission.USER_VIEW], [Permission.USER_VIEW, Permission.USER_MANAGE], 'any')).toBe(
true,
);
expect(can([], [])).toBe(true);
});
it('usePermission 随 auth store 权限变化更新', () => {
const view = renderHook(() => usePermission(Permission.USER_VIEW));
expect(view.result.current).toBe(false);
signIn([Permission.USER_VIEW]);
expect(view.result.current).toBe(true);
signIn([]);
expect(view.result.current).toBe(false);
});
it('usePermissions 返回当前权限集合', () => {
signIn([Permission.USER_VIEW, Permission.USER_MANAGE]);
const perms = renderHook(() => usePermissions());
expect(perms.result.current).toEqual([Permission.USER_VIEW, Permission.USER_MANAGE]);
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { PermissionCode } from '@lingxi/shared';
import { useAuthStore } from '@/stores/auth-store';
/** 纯函数:权限点判断(便于单测与在非组件上下文复用) */
export function can(
permissions: readonly string[],
permission: PermissionCode | readonly PermissionCode[],
mode: 'all' | 'any' = 'all',
): boolean {
const required = typeof permission === 'string' ? [permission] : permission;
if (required.length === 0) {
return true;
}
return mode === 'all'
? required.every((code) => permissions.includes(code))
: required.some((code) => permissions.includes(code));
}
/** 当前登录用户的权限点集合(/me 或登录响应写入 auth store) */
export function usePermissions(): readonly string[] {
return useAuthStore((state) => state.permissions);
}
/** 单个(或一组)权限点判断;`mode='all'` 要求全部命中 */
export function usePermission(
permission: PermissionCode | readonly PermissionCode[],
mode: 'all' | 'any' = 'all',
): boolean {
const permissions = usePermissions();
return can(permissions, permission, mode);
}
+6 -1
View File
@@ -11,6 +11,11 @@ import { PageBreadcrumbs } from './PageBreadcrumbs';
import { SideNav } from './SideNav';
import { TopBar } from './TopBar';
/** 用户与组织为「满高主从布局」,由页面自己控制内部滚动 */
function isFlushRoute(pathname: string): boolean {
return pathname === ROUTES.users || pathname.startsWith(`${ROUTES.users}/`);
}
export function AppLayout(): ReactNode {
const location = useLocation();
const permissions = useAuthStore((state) => state.permissions);
@@ -46,7 +51,7 @@ export function AppLayout(): ReactNode {
/>
<main className="lx-workspace">
<PageBreadcrumbs pathname={location.pathname} />
<div className="lx-page">
<div className={isFlushRoute(location.pathname) ? 'lx-page lx-page--flush' : 'lx-page'}>
<Suspense fallback={<PageSkeleton rows={8} />}>
<Outlet />
</Suspense>
+2 -1
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import { getUserSectionTrail } from '@/navigation/breadcrumbs';
import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config';
export interface PageBreadcrumbsProps {
@@ -8,7 +9,7 @@ export interface PageBreadcrumbsProps {
}
export function PageBreadcrumbs({ pathname }: PageBreadcrumbsProps): ReactNode {
const entries = getBreadcrumbs(NAV_MODULES, pathname);
const entries = getUserSectionTrail(pathname) ?? getBreadcrumbs(NAV_MODULES, pathname);
return (
<nav className="lx-breadcrumbs" aria-label="面包屑">
{entries.map((entry, index) => {
+6 -14
View File
@@ -2,8 +2,8 @@ import { IconChevronDown, IconLogout, IconNotification, IconSearch } from '@ling
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { useLogout } from '@/hooks/use-logout';
import type { NavModule } from '@/navigation/nav-config';
import { ROUTES } from '@/routes';
import { useAuthStore } from '@/stores/auth-store';
export interface TopBarProps {
@@ -42,10 +42,9 @@ function ModuleTabs({
}
function UserMenu(): ReactNode {
const navigate = useNavigate();
const location = useLocation();
const user = useAuthStore((state) => state.user);
const clearSession = useAuthStore((state) => state.clearSession);
const logout = useLogout();
const [openPath, setOpenPath] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const open = openPath === location.pathname;
@@ -80,25 +79,18 @@ function UserMenu(): ReactNode {
</span>
<IconChevronDown size={14} className="lx-user__chevron" />
</button>
<UserMenuDropdown
open={open}
email={user?.email ?? '—'}
onLogout={() => {
clearSession();
void navigate(ROUTES.login, { replace: true });
}}
/>
<UserMenuDropdown open={open} account={user?.account ?? '—'} onLogout={logout} />
</div>
);
}
function UserMenuDropdown({
open,
email,
account,
onLogout,
}: {
open: boolean;
email: string;
account: string;
onLogout: () => void;
}): ReactNode {
if (!open) {
@@ -106,7 +98,7 @@ function UserMenuDropdown({
}
return (
<div className="lx-user__menu" role="menu">
<div className="lx-user__menu-header">{email}</div>
<div className="lx-user__menu-header">{account}</div>
<button type="button" role="menuitem" className="lx-user__menu-item" onClick={onLogout}>
<IconLogout size={16} />
退出登录
+8 -2
View File
@@ -5,6 +5,7 @@ import '@lingxi/ui/styles.css';
import '@/styles/global.css';
import '@/styles/layout.css';
import '@/styles/pages.css';
import '@/styles/users.css';
import { LocaleProvider } from '@douyinfe/semi-ui-19';
import { localeZhCN } from '@lingxi/ui';
@@ -16,10 +17,15 @@ import { RouterProvider } from 'react-router';
import { setUnauthorizedHandler } from '@/api/client';
import { queryClient } from '@/api/query-client';
import { router } from '@/router';
import { ROUTES } from '@/routes';
import { loginPath, ROUTES } from '@/routes';
setUnauthorizedHandler(() => {
void router.navigate(ROUTES.login, { replace: true });
const { pathname, search } = router.state.location;
queryClient.clear();
if (pathname === ROUTES.login) {
return;
}
void router.navigate(loginPath(`${pathname}${search}`), { replace: true });
});
const container = document.getElementById('root');
+25
View File
@@ -0,0 +1,25 @@
import type { BreadcrumbEntry } from './nav-config';
/**
* /users 下的子路由(新建 / 详情)不在 nav-config 的菜单项里,
* 这里补齐面包屑:工作台 / 平台 / 用户与组织 / 当前页。
*/
export function getUserSectionTrail(pathname: string): BreadcrumbEntry[] | null {
if (pathname === '/users/new') {
return [
{ key: 'home', label: '工作台', path: '/' },
{ key: 'platform', label: '平台' },
{ key: 'users', label: '用户与组织', path: '/users' },
{ key: 'user-new', label: '新建用户' },
];
}
if (/^\/users\/[^/]+$/.test(pathname)) {
return [
{ key: 'home', label: '工作台', path: '/' },
{ key: 'platform', label: '平台' },
{ key: 'users', label: '用户与组织', path: '/users' },
{ key: 'user-detail', label: '成员详情' },
];
}
return null;
}
+8 -1
View File
@@ -26,6 +26,13 @@ describe('导航配置', () => {
expect(modules.map((module) => module.key)).toEqual(['dashboard']);
});
it('子路由(/users/new、/users/:id)也归属用户与组织', () => {
expect(findActiveNav(NAV_MODULES, '/users/new')?.item?.label).toBe('用户与组织');
expect(findActiveNav(NAV_MODULES, '/users/01HZZZZZZZZZZZZZZZZZZZZZZZ')?.module.key).toBe(
'platform',
);
});
it('按路径定位当前模块与菜单', () => {
const active = findActiveNav(NAV_MODULES, '/material/quick-review');
expect(active?.module.key).toBe('material');
@@ -35,7 +42,7 @@ describe('导航配置', () => {
it('面包屑从导航配置派生', () => {
expect(getBreadcrumbs(NAV_MODULES, '/')).toEqual([{ key: 'home', label: '工作台', path: '/' }]);
const breadcrumbs = getBreadcrumbs(NAV_MODULES, '/platform/users');
const breadcrumbs = getBreadcrumbs(NAV_MODULES, '/users');
expect(breadcrumbs.map((entry) => entry.label)).toEqual(['工作台', '平台', '用户与组织']);
});
});
+9 -5
View File
@@ -242,16 +242,16 @@ export const NAV_MODULES: readonly NavModule[] = [
{
key: 'platform',
label: '平台',
path: '/platform/users',
path: '/users',
icon: IconSettings,
permission: Permission.USER_MANAGE,
permission: Permission.USER_VIEW,
items: [
{
key: 'users',
label: '用户与组织',
path: '/platform/users',
path: '/users',
icon: IconUser,
permission: Permission.USER_MANAGE,
permission: Permission.USER_VIEW,
description: '账号、组织与登录状态',
},
{
@@ -378,7 +378,11 @@ export interface ActiveNav {
export function findActiveNav(modules: readonly NavModule[], pathname: string): ActiveNav | null {
const normalized = pathname !== '/' ? pathname.replace(/\/+$/, '') : pathname;
for (const module of modules) {
const item = module.items.find((candidate) => candidate.path === normalized);
const item = module.items.find(
(candidate) =>
candidate.path === normalized ||
(candidate.path !== '/' && normalized.startsWith(`${candidate.path}/`)),
);
if (item) {
return { module, item };
}
+76 -7
View File
@@ -1,14 +1,83 @@
import type { ReactNode } from 'react';
export function LoginBrandPanel(): ReactNode {
import {
LOGIN_CAPABILITIES,
LOGIN_MATERIAL_SAMPLES,
type LoginCapability,
type LoginMaterialSample,
} from './login-content';
function LoginBrand(): ReactNode {
return (
<header className="lx-login__topbar">
<div className="lx-login__brand">
<span className="lx-login__mark">灵</span>
<div className="lx-login__brand">
<span className="lx-login__mark">灵</span>
<span className="lx-login__brand-text">
<span className="lx-login__wordmark">灵犀</span>
<span className="lx-login__brand-sub">素材生产平台 · LINGXI</span>
<span className="lx-login__brand-sub">素材生产平台</span>
</span>
</div>
);
}
function CapabilityItem({ capability }: { capability: LoginCapability }): ReactNode {
const Icon = capability.icon;
return (
<li className="lx-login__capability">
<span className="lx-login__capability-icon" aria-hidden="true">
<Icon size={17} />
</span>
<span className="lx-login__capability-copy">
<span className="lx-login__capability-title">{capability.title}</span>
<span className="lx-login__capability-desc">{capability.description}</span>
</span>
</li>
);
}
function MaterialSample({ sample }: { sample: LoginMaterialSample }): ReactNode {
return (
<figure className="lx-login__sample">
<span
className={`lx-login__sample-thumb lx-login__sample-thumb--${String(sample.tone)}`}
aria-hidden="true"
>
<span className="lx-login__sample-ratio">{sample.ratio}</span>
</span>
<figcaption className="lx-login__sample-label">{sample.label}</figcaption>
</figure>
);
}
/** 左栏:品牌 + 一句定位 + 三条能力 + CSS 素材墙(与方案 B 原型一致,不引图片) */
export function LoginBrandPanel(): ReactNode {
return (
<aside className="lx-login__aside" aria-label="灵犀产品介绍">
<LoginBrand />
<div className="lx-login__pitch">
<h1 className="lx-login__headline">
一条产线,
<br />
把素材从脚本做到成片
</h1>
<p className="lx-login__desc">
行业包、要素卡、脚本、图片合成与视频逐镜生成收在同一条生产线上;快筛通过的素材直接导出归档,交付团队拿到的就是可投放的成品。
</p>
<ul className="lx-login__capabilities">
{LOGIN_CAPABILITIES.map((capability) => (
<CapabilityItem key={capability.key} capability={capability} />
))}
</ul>
<div className="lx-login__samples" aria-label="可交付素材规格示例">
{LOGIN_MATERIAL_SAMPLES.map((sample) => (
<MaterialSample key={sample.key} sample={sample} />
))}
</div>
</div>
<span className="lx-login__badge">内部系统 · 仅限授权成员</span>
</header>
<p className="lx-login__aside-foot">灵犀 · 素材生产平台 · v2.4.0</p>
</aside>
);
}
+125 -85
View File
@@ -1,114 +1,154 @@
import { Button, Checkbox, Input } from '@douyinfe/semi-ui-19';
import { IconLock, IconMail } from '@lingxi/ui/icons';
import type { ReactNode, SyntheticEvent } from 'react';
import { notifyWarning } from '@lingxi/ui';
import { IconAlert, IconCheck, IconLock, IconMail, IconView } from '@lingxi/ui/icons';
import { type ReactNode, useState } from 'react';
import type { LoginFormState } from './use-login-form';
export interface LoginFormCardProps {
account: string;
password: string;
submitting: boolean;
onAccountChange: (value: string) => void;
onPasswordChange: (value: string) => void;
onSubmit: (event: SyntheticEvent<HTMLFormElement>) => void;
form: LoginFormState;
/** 改密成功等成功提示(登录页内嵌提示位) */
successMessage?: string | null;
onSubmit: () => void;
}
function LoginFormHeader(): ReactNode {
function LoginSuccess({ message }: { message: string }): ReactNode {
return (
<header className="lx-login__form-head">
<h2>登录灵犀</h2>
<p>使用企业账号登录素材生产平台</p>
</header>
<div className="lx-login__alert lx-login__alert--success" role="status">
<IconCheck size={16} />
<span>{message}</span>
</div>
);
}
function LoginFormFooter(): ReactNode {
return (
<footer className="lx-login__form-foot">
<span className="lx-login__form-foot-dot" aria-hidden="true" />
账号由平台管理员开通,如需协助请联系 IT 服务台
</footer>
);
}
function LoginFields({
account,
password,
onAccountChange,
onPasswordChange,
}: Pick<
LoginFormCardProps,
'account' | 'password' | 'onAccountChange' | 'onPasswordChange'
>): ReactNode {
return (
<>
<label className="lx-field">
<span className="lx-field__label">账号</span>
<Input
size="large"
value={account}
onChange={onAccountChange}
placeholder="请输入账号或邮箱"
prefix={<IconMail size={16} />}
autoComplete="username"
/>
</label>
<label className="lx-field">
<span className="lx-field__label">密码</span>
<Input
size="large"
type="password"
value={password}
onChange={onPasswordChange}
placeholder="请输入密码"
prefix={<IconLock size={16} />}
autoComplete="current-password"
/>
</label>
<div className="lx-login__form-meta">
<Checkbox defaultChecked>记住我(7 天)</Checkbox>
<button type="button" className="lx-link-button">
忘记密码?
</button>
function LoginAlert({ form }: { form: LoginFormState }): ReactNode {
if (form.lockedMessage !== null) {
return (
<div className="lx-login__alert lx-login__alert--warn" role="alert">
<IconAlert size={16} />
<span>{form.lockedMessage}</span>
</div>
</>
);
}
if (form.formError !== null) {
return (
<div className="lx-login__alert" role="alert">
<IconAlert size={16} />
<span>{form.formError}</span>
</div>
);
}
return null;
}
function AccountField({ form }: { form: LoginFormState }): ReactNode {
return (
<label className="lx-field">
<span className="lx-field__label">账号</span>
<Input
size="large"
value={form.account}
prefix={<IconMail size={16} />}
placeholder="请输入账号或手机号"
autoComplete="username"
disabled={form.submitting || form.lockedMessage !== null || form.mustChangePassword}
onChange={form.setAccount}
/>
{form.accountError === null ? null : (
<span className="lx-field__error">{form.accountError}</span>
)}
</label>
);
}
export function LoginFormCard({
account,
password,
submitting,
onAccountChange,
onPasswordChange,
onSubmit,
}: LoginFormCardProps): ReactNode {
function PasswordField({ form }: { form: LoginFormState }): ReactNode {
const [visible, setVisible] = useState(false);
return (
<div className="lx-login__form">
<LoginFormHeader />
<label className="lx-field">
<span className="lx-field__label">密码</span>
<Input
size="large"
type={visible ? 'text' : 'password'}
value={form.password}
prefix={<IconLock size={16} />}
suffix={
<button
type="button"
className="lx-login__eye"
aria-label={visible ? '隐藏密码' : '显示密码'}
onClick={() => {
setVisible((current) => !current);
}}
>
<IconView size={16} />
</button>
}
placeholder="请输入密码"
autoComplete="current-password"
disabled={form.submitting || form.lockedMessage !== null || form.mustChangePassword}
onChange={form.setPassword}
/>
{form.passwordError === null ? null : (
<span className="lx-field__error">{form.passwordError}</span>
)}
</label>
);
}
<form onSubmit={onSubmit} data-testid="login-form">
<LoginFields
account={account}
password={password}
onAccountChange={onAccountChange}
onPasswordChange={onPasswordChange}
/>
function LoginMeta(): ReactNode {
return (
<div className="lx-login__meta">
<Checkbox defaultChecked>记住我(7 天)</Checkbox>
<button
type="button"
className="lx-link-button"
onClick={() => {
notifyWarning('请联系平台管理员重置密码,或通过 IT 服务台提交申请');
}}
>
忘记密码?
</button>
</div>
);
}
/** 右栏登录卡片:错误 / 锁定 / 提交中三态由 form 状态驱动 */
export function LoginFormCard({ form, successMessage, onSubmit }: LoginFormCardProps): ReactNode {
const disabled = form.submitting || form.lockedMessage !== null || form.mustChangePassword;
return (
<div className="lx-login__card">
<header className="lx-login__card-head">
<h2>登录灵犀</h2>
<p>使用企业账号登录素材生产平台</p>
</header>
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
data-testid="login-form"
>
{successMessage === undefined || successMessage === null ? null : (
<LoginSuccess message={successMessage} />
)}
<LoginAlert form={form} />
<AccountField form={form} />
<PasswordField form={form} />
<LoginMeta />
<Button
htmlType="submit"
theme="solid"
type="primary"
size="large"
block
loading={submitting}
loading={form.submitting}
disabled={disabled}
data-testid="login-submit"
>
登录
{form.lockedMessage === null ? '登录' : '账号已锁定'}
</Button>
</form>
<LoginFormFooter />
<p className="lx-login__help">账号由平台管理员开通,如需协助请联系 IT 服务台</p>
</div>
);
}
+56 -18
View File
@@ -1,35 +1,73 @@
import type { ReactNode } from 'react';
import { IconAlert } from '@lingxi/ui/icons';
import { type ReactNode, useEffect } from 'react';
import { Navigate, useSearchParams } from 'react-router';
import { MustChangePasswordModal } from '@/components/auth/MustChangePasswordModal';
import { ROUTES, sanitizeRedirect } from '@/routes';
import { useAuthStore } from '@/stores/auth-store';
import { LoginBrandPanel } from './LoginBrandPanel';
import { LoginFormCard } from './LoginFormCard';
import { useLoginForm } from './use-login-form';
function AccountDisabledNotice(): ReactNode {
return (
<p className="lx-login__notice" role="alert">
<IconAlert size={15} />
你的账号已被停用,请联系平台管理员。停用账号无法登录,历史数据仍会保留。
</p>
);
}
export default function LoginPage(): ReactNode {
const { account, password, submitting, setAccount, setPassword, handleSubmit } = useLoginForm();
const accessToken = useAuthStore((state) => state.accessToken);
const [searchParams] = useSearchParams();
const form = useLoginForm();
const clearSession = useAuthStore((state) => state.clearSession);
const target = sanitizeRedirect(searchParams.get('redirect')) ?? ROUTES.users;
const accountDisabled = searchParams.get('reason') === 'account-disabled';
useEffect(() => {
if (accountDisabled) {
clearSession();
}
}, [accountDisabled, clearSession]);
// 首登强改期间已写入 token,但不能进入 AppLayout(后端会拒绝其它接口)
if (accessToken !== null && !form.mustChangePassword && !accountDisabled) {
return <Navigate to={target} replace />;
}
return (
<main className="lx-login">
<LoginBrandPanel />
<section className="lx-login__panel">
<section className="lx-login__main">
<span className="lx-login__env">
<span className="lx-login__env-dot" aria-hidden="true" />
内部系统 · 仅限授权成员
</span>
{accountDisabled ? <AccountDisabledNotice /> : null}
<LoginFormCard
account={account}
password={password}
submitting={submitting}
onAccountChange={setAccount}
onPasswordChange={setPassword}
onSubmit={(event) => {
void handleSubmit(event);
form={form}
successMessage={form.successMessage}
onSubmit={() => {
void form.submit();
}}
/>
{form.mustChangePassword ? (
<MustChangePasswordModal
onCompleted={form.completePasswordChange}
onCancel={form.cancelPasswordChange}
/>
) : null}
<footer className="lx-login__footer">
<span>© 2026 灵犀 · 内部系统</span>
<span className="lx-login__footer-links">
<span>隐私政策</span>
<span>服务条款</span>
</span>
</footer>
</section>
<footer className="lx-login__footer">
<span>© 2026 灵犀 · 内部系统</span>
<span className="lx-login__footer-links">
<span>隐私政策</span>
<span>服务条款</span>
<span>华东一区</span>
</span>
</footer>
</main>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { IconCheck, IconMaterial, IconScript } from '@lingxi/ui/icons';
import type { ComponentType } from 'react';
export type LoginCapabilityIcon = ComponentType<{ size?: number | string; className?: string }>;
/** 登录页能力说明(左栏) */
export interface LoginCapability {
key: string;
title: string;
description: string;
icon: LoginCapabilityIcon;
}
export const LOGIN_CAPABILITIES: readonly LoginCapability[] = [
{
key: 'script',
title: '从要素卡到脚本',
description: '行业包给定字段与话术边界,脚本按镜头拆分,逐镜可改。',
icon: IconScript,
},
{
key: 'material',
title: '图片确定性与视频逐镜生成',
description: 'PSD 套版合成稳定出品,视频按分镜逐条生成、逐条替换。',
icon: IconMaterial,
},
{
key: 'delivery',
title: '快筛之后即归档',
description: '多版同屏对比快筛,导出即归档,每次导出都留痕可追溯。',
icon: IconCheck,
},
];
/** 素材墙:用 CSS 渐变模拟同一支片子的四种规格,不引图片资源 */
export interface LoginMaterialSample {
key: string;
ratio: string;
label: string;
tone: number;
}
export const LOGIN_MATERIAL_SAMPLES: readonly LoginMaterialSample[] = [
{ key: 'vertical', ratio: '9:16', label: '竖版主图', tone: 1 },
{ key: 'square', ratio: '1:1', label: '方形主图', tone: 2 },
{ key: 'wide', ratio: '16:9', label: '横版封面', tone: 3 },
{ key: 'classic', ratio: '4:3', label: '经典比例', tone: 4 },
];
@@ -0,0 +1,143 @@
import type { MeResponse } from '@lingxi/shared';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, renderHook } from '@testing-library/react';
import type { ReactNode } from 'react';
import { MemoryRouter } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { login } from '@/api/identity';
import { useAuthStore } from '@/stores/auth-store';
import { useLoginForm } from './use-login-form';
vi.mock('@/api/identity', () => ({
login: vi.fn(),
}));
vi.mock('@lingxi/ui', () => ({
notifySuccess: vi.fn(),
notifyWarning: vi.fn(),
}));
const mockedLogin = vi.mocked(login);
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
const GROUP_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ3';
function meWith(mustChangePassword: boolean): MeResponse {
return {
user: {
id: USER_ID,
name: '管理员',
phoneMasked: '133****0071',
account: 'admin',
departmentId: GROUP_ID,
departmentName: '平台技术部',
groupId: null,
groupName: null,
roles: [{ id: ROLE_ID, code: 'super_admin', name: '超级管理员', isSystem: true }],
status: 'active',
lastLoginAt: null,
createdAt: '2026-09-01T00:00:00.000Z',
},
roles: [{ id: ROLE_ID, code: 'super_admin', name: '超级管理员', isSystem: true }],
permissions: ['user:view', 'user:manage'],
dataScope: { type: 'all' },
mustChangePassword,
};
}
function createWrapper(): ({ children }: { children: ReactNode }) => ReactNode {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }): ReactNode {
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter>{children}</MemoryRouter>
</QueryClientProvider>
);
};
}
async function submitLogin(
result: { current: ReturnType<typeof useLoginForm> },
account: string,
password: string,
): Promise<void> {
act(() => {
result.current.setAccount(account);
result.current.setPassword(password);
});
await act(async () => {
await result.current.submit();
});
}
describe('登录流程 · 首登强改', () => {
beforeEach(() => {
window.localStorage.clear();
useAuthStore.getState().clearSession();
mockedLogin.mockReset();
});
it('mustChangePassword=true 时写入会话但停留在登录页等待改密', async () => {
mockedLogin.mockResolvedValue({
accessToken: 'access-1',
refreshToken: 'refresh-1',
expiresIn: 3600,
me: meWith(true),
});
const { result } = renderHook(() => useLoginForm(), { wrapper: createWrapper() });
await submitLogin(result, 'admin', 'lingxi2026');
expect(result.current.mustChangePassword).toBe(true);
expect(useAuthStore.getState().accessToken).toBe('access-1');
expect(useAuthStore.getState().mustChangePassword).toBe(true);
});
it('改密成功:清会话并提示用新密码重新登录', async () => {
mockedLogin.mockResolvedValue({
accessToken: 'access-1',
refreshToken: 'refresh-1',
expiresIn: 3600,
me: meWith(true),
});
const { result } = renderHook(() => useLoginForm(), { wrapper: createWrapper() });
await submitLogin(result, 'admin', 'lingxi2026');
act(() => {
result.current.completePasswordChange();
});
expect(result.current.mustChangePassword).toBe(false);
expect(result.current.successMessage).toContain('请使用新密码重新登录');
expect(useAuthStore.getState().accessToken).toBeNull();
});
it('取消改密:清会话回到登录表单', async () => {
mockedLogin.mockResolvedValue({
accessToken: 'access-1',
refreshToken: 'refresh-1',
expiresIn: 3600,
me: meWith(true),
});
const { result } = renderHook(() => useLoginForm(), { wrapper: createWrapper() });
await submitLogin(result, 'admin', 'lingxi2026');
act(() => {
result.current.cancelPasswordChange();
});
expect(result.current.mustChangePassword).toBe(false);
expect(result.current.successMessage).toBeNull();
expect(useAuthStore.getState().accessToken).toBeNull();
});
it('无需改密时直接写会话并跳转(不回退到改密态)', async () => {
mockedLogin.mockResolvedValue({
accessToken: 'access-2',
refreshToken: 'refresh-2',
expiresIn: 3600,
me: meWith(false),
});
const { result } = renderHook(() => useLoginForm(), { wrapper: createWrapper() });
await submitLogin(result, 'admin', 'Lingxi2026a');
expect(result.current.mustChangePassword).toBe(false);
expect(useAuthStore.getState().accessToken).toBe('access-2');
});
});
+126 -43
View File
@@ -1,70 +1,153 @@
import { notifyError, notifyWarning } from '@lingxi/ui';
import { type SyntheticEvent, useState } from 'react';
import { useNavigate } from 'react-router';
/* eslint-disable max-lines-per-function, complexity -- 登录状态机集中在一个 Hook 便于追踪 */
import { loginInputSchema } from '@lingxi/shared';
import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { ApiError, request } from '@/api/client';
import { ROUTES } from '@/routes';
import type { AuthUser } from '@/stores/auth-store';
import { useAuthStore } from '@/stores/auth-store';
import { ApiError } from '@/api/client';
import { login } from '@/api/identity';
import { queryKeys } from '@/api/query-keys';
import { ROUTES, sanitizeRedirect } from '@/routes';
import { authUserFromMe, useAuthStore } from '@/stores/auth-store';
interface LoginResponse {
accessToken: string;
refreshToken?: string;
user: AuthUser;
permissions?: string[];
}
const demoEnabled = import.meta.env.VITE_PUBLIC_DEMO_SESSION === 'true';
const demoEnabled = import.meta.env.VITE_PUBLIC_DEMO_SESSION !== 'false';
const LOCKED_FALLBACK = '账号已临时锁定,请 15 分钟后重试,或联系平台管理员解锁。';
export function useLoginForm(): {
const PASSWORD_CHANGED_NOTICE = '初始密码已更新,请使用新密码重新登录';
export interface LoginFormState {
account: string;
password: string;
submitting: boolean;
accountError: string | null;
passwordError: string | null;
formError: string | null;
lockedMessage: string | null;
/** 登录返回 me.mustChangePassword=true:停留在登录页完成首登改密 */
mustChangePassword: boolean;
/** 改密成功后的成功提示位 */
successMessage: string | null;
setAccount: (value: string) => void;
setPassword: (value: string) => void;
handleSubmit: (event: SyntheticEvent<HTMLFormElement>) => Promise<void>;
} {
submit: () => Promise<void>;
/** 改密成功:清会话与缓存,留在登录页提示用新密码登录 */
completePasswordChange: () => void;
/** 取消改密:清会话回登录表单 */
cancelPasswordChange: () => void;
}
function isLockedError(error: ApiError): boolean {
return error.status === 429 || error.code === 42902;
}
/** 登录表单状态机:校验 → 提交 → 写会话/查询缓存 → (首登)改密 → 回跳 redirect */
export function useLoginForm(): LoginFormState {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const signInDemo = useAuthStore((state) => state.signInDemo);
const setSession = useAuthStore((state) => state.setSession);
const [account, setAccount] = useState('');
const [password, setPassword] = useState('');
const clearSession = useAuthStore((state) => state.clearSession);
const [account, setAccountValue] = useState('');
const [password, setPasswordValue] = useState('');
const [submitting, setSubmitting] = useState(false);
const [accountError, setAccountError] = useState<string | null>(null);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [lockedMessage, setLockedMessage] = useState<string | null>(null);
const [mustChangePassword, setMustChangePassword] = useState(false);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const handleSubmit = async (event: SyntheticEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
if (account.trim() === '' || password === '') {
notifyWarning('请输入账号和密码');
const redirectTarget = (): string =>
sanitizeRedirect(searchParams.get('redirect')) ?? ROUTES.users;
const submit = async (): Promise<void> => {
if (submitting || lockedMessage !== null || mustChangePassword) {
return;
}
setFormError(null);
setSuccessMessage(null);
const parsed = loginInputSchema.safeParse({ account: account.trim(), password });
if (!parsed.success) {
const issues = parsed.error.issues;
setAccountError(issues.find((issue) => issue.path[0] === 'account')?.message ?? null);
setPasswordError(issues.find((issue) => issue.path[0] === 'password')?.message ?? null);
return;
}
setAccountError(null);
setPasswordError(null);
if (demoEnabled) {
signInDemo();
queryClient.removeQueries({ queryKey: queryKeys.me });
void navigate(redirectTarget(), { replace: true });
return;
}
setSubmitting(true);
try {
if (demoEnabled) {
signInDemo();
await navigate(ROUTES.home, { replace: true });
const response = await login(parsed.data);
setSession({
accessToken: response.accessToken,
refreshToken: response.refreshToken,
user: authUserFromMe(response.me),
permissions: response.me.permissions,
mustChangePassword: response.me.mustChangePassword,
});
// 用登录响应写 /me 缓存,进入 AppLayout 后不再重复请求
queryClient.setQueryData(queryKeys.me, response.me);
if (response.me.mustChangePassword) {
setMustChangePassword(true);
return;
}
const session = await request<LoginResponse>('/auth/login', {
method: 'POST',
auth: false,
body: { email: account.trim(), password },
});
setSession({
accessToken: session.accessToken,
refreshToken: session.refreshToken ?? null,
user: session.user,
permissions: session.permissions,
});
await navigate(ROUTES.home, { replace: true });
void navigate(redirectTarget(), { replace: true });
} catch (error) {
notifyError(
error instanceof Error ? error.message : '登录失败,请稍后重试',
error instanceof ApiError ? error.traceId : undefined,
);
if (error instanceof ApiError && isLockedError(error)) {
setLockedMessage(error.message === '' ? LOCKED_FALLBACK : error.message);
} else {
setFormError(error instanceof Error ? error.message : '登录失败,请稍后重试');
}
} finally {
setSubmitting(false);
}
};
return { account, password, submitting, setAccount, setPassword, handleSubmit };
const resetAfterPasswordChange = (notice: string | null): void => {
clearSession();
queryClient.clear();
setMustChangePassword(false);
setPasswordValue('');
setPasswordError(null);
setFormError(null);
setSuccessMessage(notice);
};
return {
account,
password,
submitting,
accountError,
passwordError,
formError,
lockedMessage,
mustChangePassword,
successMessage,
setAccount: (value) => {
setAccountValue(value);
setAccountError(null);
setFormError(null);
setSuccessMessage(null);
},
setPassword: (value) => {
setPasswordValue(value);
setPasswordError(null);
setFormError(null);
},
submit,
completePasswordChange: () => {
resetAfterPasswordChange(PASSWORD_CHANGED_NOTICE);
},
cancelPasswordChange: () => {
resetAfterPasswordChange(null);
},
};
}
@@ -0,0 +1,50 @@
import type { RoleSummary } from '@lingxi/shared';
import type { ReactNode } from 'react';
import { isSuperAdminRole } from './role-utils';
export interface RoleChipsSelectProps {
roles: readonly RoleSummary[];
value: readonly string[];
disabled?: boolean;
onChange: (roleIds: string[]) => void;
}
export function RoleChipsSelect({
roles,
value,
disabled = false,
onChange,
}: RoleChipsSelectProps): ReactNode {
const selected = new Set(value);
return (
<div className="lx-role-picker" role="group" aria-label="角色">
{roles.map((role) => {
const active = selected.has(role.id);
return (
<button
key={role.id}
type="button"
className={active ? 'lx-role-picker__item is-active' : 'lx-role-picker__item'}
aria-pressed={active}
disabled={disabled}
onClick={() => {
const next = new Set(selected);
if (next.has(role.id)) {
next.delete(role.id);
} else {
next.add(role.id);
}
onChange([...next]);
}}
>
{role.name}
{isSuperAdminRole(role) ? (
<span className="lx-role-picker__badge">内部预置,不可停用</span>
) : null}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,249 @@
/* eslint-disable max-lines-per-function -- 详情面板按头部/标签页分区组织,拆分降低可读性 */
import { Button, Tabs } from '@douyinfe/semi-ui-19';
import type { UserDetail } from '@lingxi/shared';
import { Permission } from '@lingxi/shared';
import { ErrorState, PageEmpty, PageSkeleton } from '@lingxi/ui';
import { IconEdit, IconLock, IconRefresh } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { Can } from '@/components/Can';
import { displayPhone, formatDateTime, userInitial } from './user-display';
import {
UserActivityTab,
UserCustomersTab,
UserProfileTab,
UserRolesTab,
UserSecurityTab,
} from './UserDetailTabs';
import { UserStatusTag } from './UserStatusTag';
const TABS = [
{ key: 'profile', label: '基本资料' },
{ key: 'roles', label: '角色权限' },
{ key: 'customers', label: '负责客户' },
{ key: 'security', label: '登录与安全' },
{ key: 'activity', label: '操作记录' },
] as const;
export interface UserDetailPanelProps {
userId: string | null;
detail: UserDetail | undefined;
loading: boolean;
error: Error | null;
canManage: boolean;
onRetry: () => void;
onEdit: (user: UserDetail) => void;
onDisable: (user: UserDetail) => void;
onEnable: (user: UserDetail) => void;
onAdjustRoles: (user: UserDetail) => void;
onTransfer: (user: UserDetail) => void;
}
function DetailHeader({
user,
canManage,
onEdit,
onDisable,
onEnable,
}: Pick<UserDetailPanelProps, 'canManage' | 'onEdit' | 'onDisable' | 'onEnable'> & {
user: UserDetail;
}): ReactNode {
return (
<header className="lx-user-detail__head">
<span
className={
user.status === 'disabled'
? 'lx-user-detail__avatar is-disabled'
: 'lx-user-detail__avatar'
}
>
{userInitial(user.name)}
</span>
<div className="lx-user-detail__ident">
<div className="lx-user-detail__name">
<h2>{user.name}</h2>
<UserStatusTag status={user.status} />
</div>
<p className="lx-user-detail__meta">
{user.groupName === null
? user.departmentName
: `${user.departmentName} / ${user.groupName}`}
<span className="lx-user-detail__dot">·</span>
{displayPhone(user, canManage)}
<span className="lx-user-detail__dot">·</span>
最近登录 {formatDateTime(user.lastLoginAt)}
</p>
</div>
<div className="lx-user-detail__actions">
<Can permission={Permission.USER_MANAGE}>
<Button
icon={<IconEdit size={14} />}
onClick={() => {
onEdit(user);
}}
>
编辑
</Button>
{user.status === 'active' ? (
<Button
type="danger"
icon={<IconLock size={14} />}
onClick={() => {
onDisable(user);
}}
>
停用账号
</Button>
) : (
<Button
icon={<IconRefresh size={14} />}
onClick={() => {
onEnable(user);
}}
>
启用账号
</Button>
)}
</Can>
</div>
</header>
);
}
function TabContent({
tabKey,
user,
canManage,
onAdjustRoles,
onTransfer,
}: {
tabKey: string;
user: UserDetail;
canManage: boolean;
onAdjustRoles: () => void;
onTransfer: () => void;
}): ReactNode {
if (tabKey === 'roles') {
return <UserRolesTab user={user} canManage={canManage} onAdjustRoles={onAdjustRoles} />;
}
if (tabKey === 'customers') {
return <UserCustomersTab user={user} onTransfer={onTransfer} />;
}
if (tabKey === 'security') {
return <UserSecurityTab user={user} canManage={canManage} />;
}
if (tabKey === 'activity') {
return <UserActivityTab user={user} canManage={canManage} />;
}
return <UserProfileTab user={user} canManage={canManage} />;
}
function DetailBody({
user,
canManage,
onAdjustRoles,
onTransfer,
}: Pick<UserDetailPanelProps, 'canManage' | 'onAdjustRoles' | 'onTransfer'> & {
user: UserDetail;
}): ReactNode {
const [activeKey, setActiveKey] = useState<string>('profile');
return (
<Tabs
className="lx-user-detail__tabs"
type="line"
activeKey={activeKey}
onChange={(key) => {
setActiveKey(key);
}}
>
{TABS.map((tab) => (
<Tabs.TabPane key={tab.key} itemKey={tab.key} tab={tab.label}>
{tab.key !== activeKey ? null : (
<TabContent
tabKey={tab.key}
user={user}
canManage={canManage}
onAdjustRoles={() => {
onAdjustRoles(user);
}}
onTransfer={() => {
onTransfer(user);
}}
/>
)}
</Tabs.TabPane>
))}
</Tabs>
);
}
function DetailState({
userId,
loading,
error,
onRetry,
}: Pick<UserDetailPanelProps, 'userId' | 'loading' | 'error' | 'onRetry'>): ReactNode {
if (userId === null) {
return (
<PageEmpty
title="选择左侧成员查看详情"
description="支持按姓名、手机号与登录账号搜索成员。"
/>
);
}
if (loading) {
return (
<div className="lx-user-detail__state">
<PageSkeleton rows={8} />
</div>
);
}
if (error !== null) {
return (
<ErrorState
className="lx-user-detail__state"
title="成员资料加载失败"
message={error.message}
onRetry={onRetry}
/>
);
}
return null;
}
/** 右侧详情面板:头部操作 + 五个 Tab;数据由 /users/{id} 提供 */
export function UserDetailPanel(props: UserDetailPanelProps): ReactNode {
const ready = props.userId !== null && props.detail !== undefined && props.error === null;
return (
<section className="lx-user-detail" aria-label="成员详情">
{ready && props.detail !== undefined ? (
<>
<DetailHeader
user={props.detail}
canManage={props.canManage}
onEdit={props.onEdit}
onDisable={props.onDisable}
onEnable={props.onEnable}
/>
<DetailBody
user={props.detail}
canManage={props.canManage}
onAdjustRoles={props.onAdjustRoles}
onTransfer={props.onTransfer}
/>
</>
) : (
<div className="lx-user-detail__empty">
<DetailState
userId={props.userId}
loading={props.loading}
error={props.error}
onRetry={props.onRetry}
/>
</div>
)}
</section>
);
}
+235
View File
@@ -0,0 +1,235 @@
import { Button } from '@douyinfe/semi-ui-19';
import { Permission } from '@lingxi/shared';
import { type UserDetail } from '@lingxi/shared';
import { PageEmpty } from '@lingxi/ui';
import { IconEdit, IconShare, IconUser } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { Can } from '@/components/Can';
import { useUserCustomersQuery } from '@/hooks/queries/use-users';
import { displayPhone, formatDateTime } from './user-display';
import { UserStatusTag } from './UserStatusTag';
interface TabProps {
user: UserDetail;
canManage: boolean;
}
function Kvi({ label, value }: { label: string; value: ReactNode }): ReactNode {
return (
<div className="lx-kvi">
<span className="lx-kvi__label">{label}</span>
<span className="lx-kvi__value">{value}</span>
</div>
);
}
function Section({ title, children }: { title: string; children: ReactNode }): ReactNode {
return (
<section className="lx-detail-section">
<h4 className="lx-detail-section__title">{title}</h4>
<div className="lx-kv-grid">{children}</div>
</section>
);
}
function SectionHead({
title,
hint,
action,
}: {
title: string;
hint: string;
action?: ReactNode;
}): ReactNode {
return (
<div className="lx-detail-tab__head">
<div>
<h4 className="lx-detail-section__title">{title}</h4>
<p className="lx-detail-tab__hint">{hint}</p>
</div>
{action}
</div>
);
}
export function UserProfileTab({ user, canManage }: TabProps): ReactNode {
const department =
user.groupName === null ? user.departmentName : `${user.departmentName} / ${user.groupName}`;
return (
<div className="lx-detail-tab">
<Section title="身份">
<Kvi label="姓名" value={user.name} />
<Kvi label="登录账号" value={user.account} />
<Kvi label="手机号" value={displayPhone(user, canManage)} />
<Kvi label="所属部门" value={department} />
</Section>
<Section title="权限">
<Kvi
label="角色"
value={user.roles.length === 0 ? '—' : user.roles.map((role) => role.name).join('、')}
/>
<Kvi label="账号状态" value={<UserStatusTag status={user.status} />} />
<Kvi label="最近登录" value={formatDateTime(user.lastLoginAt)} />
<Kvi label="登录地址" value={user.lastLoginIp ?? '—'} />
</Section>
<Section title="状态">
<Kvi label="创建时间" value={formatDateTime(user.createdAt)} />
<Kvi label="最近更新" value={formatDateTime(user.updatedAt)} />
<Kvi label="停用时间" value={formatDateTime(user.disabledAt)} />
<Kvi label="密码状态" value={user.mustChangePassword ? '初始密码待修改' : '已修改'} />
</Section>
</div>
);
}
export function UserRolesTab({
user,
canManage,
onAdjustRoles,
}: TabProps & { onAdjustRoles: () => void }): ReactNode {
return (
<div className="lx-detail-tab">
<SectionHead
title="已绑定角色"
hint="角色决定成员可访问的模块与操作;权限点由「角色权限」统一维护。"
action={
canManage ? (
<Button
icon={<IconEdit size={14} />}
onClick={onAdjustRoles}
data-testid="adjust-roles"
>
调整角色
</Button>
) : null
}
/>
<div className="lx-role-chips">
{user.roles.map((role) => (
<span key={role.id} className="lx-role-chip">
{role.name}
{role.isSystem ? <span className="lx-role-chip__badge">内置</span> : null}
</span>
))}
</div>
<p className="lx-detail-tab__hint">
内置角色由系统预置,不可停用;角色变更即时生效并写入审计。
</p>
</div>
);
}
export function UserCustomersTab({
user,
onTransfer,
}: {
user: UserDetail;
onTransfer: () => void;
}): ReactNode {
const query = useUserCustomersQuery(user.id);
const customers = query.data ?? [];
return (
<div className="lx-detail-tab">
<SectionHead
title="负责客户"
hint="客户资料与交付记录仅对负责人及平台管理员可见。"
action={
<Can permission={Permission.USER_MANAGE}>
<Button
icon={<IconShare size={14} />}
disabled={customers.length === 0}
onClick={onTransfer}
>
转交客户
</Button>
</Can>
}
/>
{query.isLoading ? <p className="lx-detail-tab__hint">加载中…</p> : null}
{!query.isLoading && customers.length === 0 ? (
<PageEmpty title="暂未负责客户" description="该成员当前不是任何客户的服务负责人。" />
) : (
<ul className="lx-customer-list">
{customers.map((customer) => (
<li key={customer.id} className="lx-customer-item">
<span className="lx-customer-item__icon">
<IconUser size={16} />
</span>
<span className="lx-customer-item__name">{customer.name}</span>
<span className="lx-role-tag">合作中</span>
</li>
))}
</ul>
)}
</div>
);
}
export function UserSecurityTab({ user }: TabProps): ReactNode {
return (
<div className="lx-detail-tab">
<Section title="登录与安全">
<Kvi label="最近登录" value={formatDateTime(user.lastLoginAt)} />
<Kvi label="登录地址" value={user.lastLoginIp ?? '—'} />
<Kvi label="账号状态" value={<UserStatusTag status={user.status} />} />
<Kvi label="密码状态" value={user.mustChangePassword ? '初始密码待修改' : '已修改'} />
<Kvi label="停用时间" value={formatDateTime(user.disabledAt)} />
</Section>
<p className="lx-detail-tab__hint">
登录失败次数与临时锁定由平台统一管理;密码到期或重置后需在下次登录时修改。
</p>
</div>
);
}
interface ActivityEntry {
key: string;
title: string;
at: string | null | undefined;
extra?: string;
}
function buildActivity(user: UserDetail): ActivityEntry[] {
const entries: ActivityEntry[] = [
{ key: 'created', title: '创建用户', at: user.createdAt },
{ key: 'updated', title: '最近更新资料', at: user.updatedAt },
{
key: 'login',
title: '最近登录成功',
at: user.lastLoginAt,
extra: user.lastLoginIp ?? undefined,
},
];
if (user.disabledAt !== null && user.disabledAt !== undefined) {
entries.push({ key: 'disabled', title: '停用账号', at: user.disabledAt });
}
return entries
.filter((entry) => entry.at !== null && entry.at !== undefined)
.sort((left, right) => (right.at ?? '').localeCompare(left.at ?? ''));
}
export function UserActivityTab({ user }: TabProps): ReactNode {
const entries = buildActivity(user);
return (
<div className="lx-detail-tab">
<h4 className="lx-detail-section__title">操作记录</h4>
<p className="lx-detail-tab__hint">完整审计日志将在审计模块上线后提供检索与导出。</p>
<ol className="lx-timeline">
{entries.map((entry, index) => (
<li key={entry.key} className="lx-timeline__item">
<span className={index === 0 ? 'lx-timeline__dot is-latest' : 'lx-timeline__dot'} />
<div className="lx-timeline__main">
<span className="lx-timeline__title">{entry.title}</span>
<span className="lx-timeline__meta">
{formatDateTime(entry.at)}
{entry.extra === undefined ? '' : ` · ${entry.extra}`}
</span>
</div>
</li>
))}
</ol>
</div>
);
}
+359
View File
@@ -0,0 +1,359 @@
/* eslint-disable max-lines-per-function -- 列表卡片按筛选/行/状态分区组织,拆分降低可读性 */
import { Button, Input, Select, Skeleton } from '@douyinfe/semi-ui-19';
import type { DepartmentNode, RoleSummary, UserListStatus, UserSummary } from '@lingxi/shared';
import { ErrorState, PageEmpty } from '@lingxi/ui';
import { IconBack, IconChevronRight, IconRefresh, IconSearch } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { userInitial, userOrgLabel } from './user-display';
import {
USER_SORT_OPTIONS,
USER_STATUS_OPTIONS,
type UserListFilters,
type UserSortValue,
} from './user-filters';
import { UserStatusTag } from './UserStatusTag';
export interface UserFilterBarProps {
filters: UserListFilters;
departments: readonly DepartmentNode[];
roles: readonly RoleSummary[];
onFiltersChange: (next: Partial<UserListFilters>) => void;
onResetFilters: () => void;
}
export interface UserListPanelProps {
filters: UserListFilters;
users: readonly UserSummary[];
loading: boolean;
error: Error | null;
selectedUserId: string | null;
hasNext: boolean;
hasPrev: boolean;
onSortChange: (sort: UserSortValue) => void;
onSelect: (userId: string) => void;
onPrev: () => void;
onNext: () => void;
onRetry: () => void;
onCreate: () => void;
onResetFilters: () => void;
}
function firstString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function StatusSegmented({
value,
onChange,
}: {
value: UserListStatus;
onChange: (value: UserListStatus) => void;
}): ReactNode {
return (
<div className="lx-seg" role="group" aria-label="账号状态">
{USER_STATUS_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={option.value === value ? 'lx-seg__item is-active' : 'lx-seg__item'}
aria-pressed={option.value === value}
onClick={() => {
onChange(option.value);
}}
>
{option.label}
</button>
))}
</div>
);
}
/** 顶部筛选条:关键词 / 部门 / 角色 / 状态;变更即重置游标并重新查询 */
export function UserFilterBar({
filters,
departments,
roles,
onFiltersChange,
onResetFilters,
}: UserFilterBarProps): ReactNode {
return (
<section className="lx-user-filters" aria-label="筛选成员">
<Input
className="lx-user-filters__keyword"
value={filters.keyword}
prefix={<IconSearch size={16} />}
placeholder="搜索姓名、手机号或登录账号"
aria-label="搜索成员"
showClear
onChange={(value) => {
onFiltersChange({ keyword: value });
}}
/>
<SelectField
className="lx-user-filters__select"
value={filters.departmentId}
placeholder="全部部门"
options={departments.map((node) => ({
label: `${node.name}(${String(node.memberCount)} 人)`,
value: node.id,
}))}
onChange={(value) => {
onFiltersChange({ departmentId: value });
}}
/>
<SelectField
className="lx-user-filters__select"
value={filters.roleId}
placeholder="全部角色"
options={roles.map((role) => ({
label: role.isSystem ? `${role.name}(内置)` : role.name,
value: role.id,
}))}
onChange={(value) => {
onFiltersChange({ roleId: value });
}}
/>
<StatusSegmented
value={filters.status}
onChange={(status) => {
onFiltersChange({ status });
}}
/>
<Button icon={<IconRefresh size={14} />} onClick={onResetFilters}>
重置
</Button>
</section>
);
}
interface Option {
label: string;
value: string;
}
/** 受控 Select:值统一降为 string,空字符串表示未选择 */
function SelectField({
className,
value,
placeholder,
options,
onChange,
}: {
className: string;
value: string;
placeholder: string;
options: Option[];
onChange: (value: string) => void;
}): ReactNode {
return (
<div className={className}>
<Select
className="lx-user-filters__control"
value={value === '' ? undefined : value}
placeholder={placeholder}
optionList={options}
showClear
onChange={(selected) => {
onChange(firstString(selected));
}}
/>
</div>
);
}
function ListRow({
user,
selected,
onSelect,
}: {
user: UserSummary;
selected: boolean;
onSelect: () => void;
}): ReactNode {
return (
<li>
<button
type="button"
className={selected ? 'lx-user-row is-selected' : 'lx-user-row'}
aria-current={selected ? 'true' : undefined}
onClick={onSelect}
>
<span
className={
user.status === 'disabled' ? 'lx-user-row__avatar is-disabled' : 'lx-user-row__avatar'
}
>
{userInitial(user.name)}
</span>
<span className="lx-user-row__main">
<span className="lx-user-row__name">
{user.name}
<UserStatusTag status={user.status} dotOnly />
</span>
<span className="lx-user-row__phone">{user.phoneMasked}</span>
</span>
<span className="lx-user-row__side">
<span className="lx-user-row__org">{userOrgLabel(user)}</span>
<span className="lx-user-row__roles">
{user.roles.slice(0, 1).map((role) => (
<span key={role.id} className="lx-role-tag">
{role.name}
</span>
))}
{user.roles.length > 1 ? (
<span className="lx-role-tag lx-role-tag--more">+{user.roles.length - 1}</span>
) : null}
</span>
</span>
</button>
</li>
);
}
function isEmptyFilter(filters: UserListFilters): boolean {
return (
filters.keyword.trim() !== '' ||
filters.departmentId !== '' ||
filters.roleId !== '' ||
filters.status !== 'active'
);
}
function ListState({
props,
}: {
props: Pick<
UserListPanelProps,
| 'users'
| 'loading'
| 'error'
| 'selectedUserId'
| 'onSelect'
| 'onRetry'
| 'onCreate'
| 'onResetFilters'
| 'filters'
>;
}): ReactNode {
if (props.loading) {
return (
<div className="lx-user-list__state">
<Skeleton placeholder={<Skeleton.Paragraph rows={6} />} loading />
</div>
);
}
if (props.error !== null) {
return (
<ErrorState
className="lx-user-list__state"
title="成员加载失败"
message={props.error.message}
onRetry={props.onRetry}
/>
);
}
if (props.users.length === 0) {
return isEmptyFilter(props.filters) ? (
<PageEmpty
title="没有符合条件的成员"
description="调整关键词或筛选条件后再试。"
action={<Button onClick={props.onResetFilters}>重置筛选</Button>}
/>
) : (
<PageEmpty
title="团队还没有用户"
description="创建第一位成员,为 TA 分配角色与负责部门。"
action={
<Button theme="solid" type="primary" onClick={props.onCreate}>
新建用户
</Button>
}
/>
);
}
return (
<ul className="lx-user-list__items">
{props.users.map((user) => (
<ListRow
key={user.id}
user={user}
selected={user.id === props.selectedUserId}
onSelect={() => {
props.onSelect(user.id);
}}
/>
))}
</ul>
);
}
function ListFooter({
count,
hasPrev,
hasNext,
onPrev,
onNext,
}: {
count: number;
hasPrev: boolean;
hasNext: boolean;
onPrev: () => void;
onNext: () => void;
}): ReactNode {
return (
<footer className="lx-user-list__footer">
<span className="lx-user-list__range">本页 {count} 人</span>
<div className="lx-user-list__pager">
<Button
size="small"
icon={<IconBack size={14} />}
disabled={!hasPrev}
aria-label="上一页"
onClick={onPrev}
/>
<Button
size="small"
icon={<IconChevronRight size={14} />}
disabled={!hasNext}
aria-label="下一页"
onClick={onNext}
/>
</div>
</footer>
);
}
/** 左侧成员列表卡:卡头排序 + 列表 + 游标分页页脚 */
export function UserListPanel(props: UserListPanelProps): ReactNode {
return (
<section className="lx-user-list" aria-label="成员列表">
<header className="lx-user-list__head">
<span className="lx-user-list__title">成员</span>
<div className="lx-user-list__sort">
<Select
size="small"
value={props.filters.sort}
optionList={USER_SORT_OPTIONS.map((option) => ({ ...option }))}
onChange={(value) => {
const sort = firstString(value);
if (sort !== '') {
props.onSortChange(sort as UserSortValue);
}
}}
/>
</div>
</header>
<div className="lx-user-list__body">
<ListState props={props} />
</div>
<ListFooter
count={props.users.length}
hasPrev={props.hasPrev}
hasNext={props.hasNext}
onPrev={props.onPrev}
onNext={props.onNext}
/>
</section>
);
}
+303
View File
@@ -0,0 +1,303 @@
/* eslint-disable max-lines-per-function -- 表单页按三段顺序展开,与设计稿一一对应 */
import { Button, Form, Input, Select } from '@douyinfe/semi-ui-19';
import { type CreateUserInput, type DepartmentNode, type RoleSummary } from '@lingxi/shared';
import { notifySuccess } from '@lingxi/ui';
import { IconAdd, IconBack, IconRefresh } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useDepartmentsQuery, useRolesQuery } from '@/hooks/queries/use-directory';
import { useCreateUserMutation } from '@/hooks/queries/use-users';
import { ROUTES, userDetailPath } from '@/routes';
import { SuperAdminGrantDialog } from './dialogs/SuperAdminGrantDialog';
import { isSuperAdminRole } from './role-utils';
import { RoleChipsSelect } from './RoleChipsSelect';
import { type NewUserFormState, useNewUserForm } from './use-user-new-form';
import { childDepartments, topLevelDepartments } from './user-filters';
function firstString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function Field({
label,
error,
required = false,
optional = false,
children,
}: {
label: string;
error: string | undefined;
required?: boolean;
optional?: boolean;
children: ReactNode;
}): ReactNode {
return (
<label className="lx-field">
<span className="lx-field__label">
{label}
{required ? <span className="lx-field__required">*</span> : null}
{optional ? <span className="lx-field__optional">选填</span> : null}
</span>
{children}
{error === undefined ? null : <span className="lx-field__error">{error}</span>}
</label>
);
}
function Section({
index,
title,
children,
}: {
index: number;
title: string;
children: ReactNode;
}): ReactNode {
return (
<section className="lx-new-user__section">
<h3 className="lx-new-user__section-title">
<span className="lx-new-user__section-index">{index}</span>
{title}
</h3>
<div className="lx-new-user__grid">{children}</div>
</section>
);
}
function AccountFields({ form }: { form: NewUserFormState }): ReactNode {
return (
<Section index={1} title="账号信息">
<Field label="姓名" error={form.errors.name} required>
<Input
value={form.values.name}
placeholder="请输入成员姓名"
onChange={(value) => {
form.setField('name', value);
}}
/>
</Field>
<Field label="手机号" error={form.errors.phone} required>
<Input
value={form.values.phone}
placeholder="11 位手机号,用于登录与通知"
onChange={(value) => {
form.setField('phone', value);
}}
/>
</Field>
<Field label="登录账号" error={form.errors.account} required>
<Input
value={form.values.account}
placeholder="字母或数字开头,可含 . _ -"
onChange={(value) => {
form.setField('account', value);
}}
/>
</Field>
<Field label="初始密码" error={form.errors.password} required>
<div className="lx-new-user__password">
<Input
value={form.values.password}
onChange={(value) => {
form.setField('password', value);
}}
/>
<Button
icon={<IconRefresh size={14} />}
onClick={() => {
form.regeneratePassword();
}}
>
重新生成
</Button>
</div>
</Field>
</Section>
);
}
function OrganizationFields({
form,
departments,
roles,
}: {
form: NewUserFormState;
departments: readonly DepartmentNode[];
roles: readonly RoleSummary[];
}): ReactNode {
const groups = childDepartments(departments, form.values.departmentId);
return (
<Section index={2} title="组织与角色">
<Field label="所属部门" error={form.errors.departmentId} required>
<Select
value={form.values.departmentId === '' ? undefined : form.values.departmentId}
placeholder="请选择一级部门"
optionList={topLevelDepartments(departments).map((node) => ({
label: node.name,
value: node.id,
}))}
onChange={(value) => {
form.setField('departmentId', firstString(value));
form.setField('groupId', '');
}}
/>
</Field>
<Field label="所属小组" error={undefined}>
<Select
value={form.values.groupId === '' ? undefined : form.values.groupId}
placeholder={groups.length === 0 ? '该部门无二级小组' : '请选择小组'}
disabled={groups.length === 0}
optionList={groups.map((node) => ({ label: node.name, value: node.id }))}
onChange={(value) => {
form.setField('groupId', firstString(value));
}}
/>
</Field>
<div className="lx-field lx-new-user__roles">
<span className="lx-field__label">
角色
<span className="lx-field__required">*</span>
<span className="lx-field__optional">至少选择一个</span>
</span>
<RoleChipsSelect
roles={roles}
value={form.values.roleIds}
onChange={(roleIds) => {
form.setField('roleIds', roleIds);
}}
/>
{form.errors.roleIds === undefined ? null : (
<span className="lx-field__error">{form.errors.roleIds}</span>
)}
</div>
</Section>
);
}
function SettingsFields({ form }: { form: NewUserFormState }): ReactNode {
return (
<Section index={3} title="初始设置">
<div className="lx-field lx-new-user__full">
<span className="lx-field__label">
备注
<span className="lx-field__optional">选填</span>
</span>
<Input
value={form.values.remark}
placeholder="例如:入职批次、岗位交接说明"
onChange={(value) => {
form.setField('remark', value);
}}
/>
</div>
<p className="lx-new-user__note lx-new-user__full">
创建后账号立即生效,成员首次登录需修改初始密码;初始密码仅通过安全渠道线下交付。
</p>
</Section>
);
}
/** 新建用户:单栏三段式(账号信息 / 组织与角色 / 初始设置) */
export function UserNewPage(): ReactNode {
const navigate = useNavigate();
const form = useNewUserForm();
const departments = useDepartmentsQuery();
const roles = useRolesQuery();
const createUser = useCreateUserMutation();
const [pendingGrant, setPendingGrant] = useState<CreateUserInput | null>(null);
const runCreate = (input: CreateUserInput): void => {
createUser.mutate(input, {
onSuccess: (userId) => {
notifySuccess('成员已创建,初始密码已生成');
void navigate(userId === null ? ROUTES.users : userDetailPath(userId), { replace: true });
},
});
};
const submit = (): void => {
const input = form.validate();
if (input === null) {
return;
}
const superAdminIds = (roles.data ?? []).filter(isSuperAdminRole).map((role) => role.id);
if (input.roleIds.some((id) => superAdminIds.includes(id))) {
setPendingGrant(input);
return;
}
runCreate(input);
};
return (
<div className="lx-new-user">
<header className="lx-users__header">
<div>
<h1>新建用户</h1>
<p>填写账号信息、组织与角色后创建;初始密码由管理员设定</p>
</div>
<Button
icon={<IconBack size={14} />}
onClick={() => {
void navigate(ROUTES.users);
}}
>
返回列表
</Button>
</header>
<Form
className="lx-new-user__form"
onSubmit={() => {
submit();
}}
>
<AccountFields form={form} />
<OrganizationFields
form={form}
departments={departments.data ?? []}
roles={roles.data ?? []}
/>
<SettingsFields form={form} />
<footer className="lx-new-user__footer">
<Button
onClick={() => {
void navigate(ROUTES.users);
}}
>
取消
</Button>
<Button
htmlType="submit"
theme="solid"
type="primary"
icon={<IconAdd size={16} />}
loading={createUser.isPending}
data-testid="create-user"
>
创建用户
</Button>
</footer>
</Form>
<SuperAdminGrantDialog
key={`grant-${pendingGrant === null ? 'closed' : 'open'}`}
visible={pendingGrant !== null}
targetTitle={form.values.name === '' ? '待填写成员' : form.values.name}
targetMeta={form.values.account === '' ? form.values.phone : form.values.account}
loading={createUser.isPending}
onCancel={() => {
setPendingGrant(null);
}}
onConfirm={() => {
if (pendingGrant !== null) {
runCreate(pendingGrant);
}
setPendingGrant(null);
}}
/>
</div>
);
}
@@ -0,0 +1,31 @@
import type { UserStatus } from '@lingxi/shared';
import type { ReactNode } from 'react';
import { USER_STATUS_LABEL } from './user-display';
export interface UserStatusTagProps {
status: UserStatus;
/** 列表行内只显示状态点,详情展示完整标签 */
dotOnly?: boolean;
}
export function UserStatusTag({ status, dotOnly = false }: UserStatusTagProps): ReactNode {
const active = status === 'active';
return (
<span
className={
active
? dotOnly
? 'lx-status-dot'
: 'lx-status-tag is-active'
: dotOnly
? 'lx-status-dot is-disabled'
: 'lx-status-tag is-disabled'
}
title={USER_STATUS_LABEL[status]}
>
<span className="lx-status-tag__dot" aria-hidden="true" />
{dotOnly ? null : USER_STATUS_LABEL[status]}
</span>
);
}
+187
View File
@@ -0,0 +1,187 @@
/* eslint-disable max-lines-per-function, complexity -- 页面负责主从联动与弹窗编排,集中在一处便于追踪数据流 */
import { Button } from '@douyinfe/semi-ui-19';
import { Permission } from '@lingxi/shared';
import { IconAdd } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Can } from '@/components/Can';
import { useDepartmentsQuery, useRolesQuery } from '@/hooks/queries/use-directory';
import {
useUserCandidatesQuery,
useUserCustomersQuery,
useUserDetailQuery,
} from '@/hooks/queries/use-users';
import { usePermission } from '@/hooks/use-permission';
import { ROUTES, userDetailPath } from '@/routes';
import { AdjustRolesDialog } from './dialogs/AdjustRolesDialog';
import { DisableUserDialog } from './dialogs/DisableUserDialog';
import { EditUserDialog } from './dialogs/EditUserDialog';
import { EnableUserDialog } from './dialogs/EnableUserDialog';
import { SuperAdminGrantDialog } from './dialogs/SuperAdminGrantDialog';
import { TransferCustomersDialog } from './dialogs/TransferCustomersDialog';
import { useUserDialogs } from './use-user-dialogs';
import { useUserListController } from './use-user-list-controller';
import { UserDetailPanel } from './UserDetailPanel';
import { UserFilterBar, UserListPanel } from './UserListPanel';
function UsersHeader(): ReactNode {
const navigate = useNavigate();
return (
<header className="lx-users__header">
<div>
<h1>用户与组织</h1>
<p>管理成员账号、角色与客户归属</p>
</div>
<Can permission={Permission.USER_MANAGE}>
<Button
theme="solid"
type="primary"
icon={<IconAdd size={16} />}
onClick={() => {
void navigate(ROUTES.userNew);
}}
>
新建用户
</Button>
</Can>
</header>
);
}
/** 用户管理主页面:左列表 + 右详情主从联动(/users 与 /users/:id 共用) */
export function UsersPage(): ReactNode {
const params = useParams<{ id?: string }>();
const navigate = useNavigate();
const list = useUserListController();
const canManage = usePermission(Permission.USER_MANAGE);
const departments = useDepartmentsQuery();
const roles = useRolesQuery();
const dialogs = useUserDialogs(roles.data ?? []);
const selectedId = params.id ?? list.users[0]?.id ?? null;
const detail = useUserDetailQuery(selectedId);
const customers = useUserCustomersQuery(dialogs.dialog?.user.id ?? null);
const candidates = useUserCandidatesQuery(dialogs.dialog !== null);
const dialogUser = dialogs.dialog?.user ?? null;
return (
<div className="lx-users">
<UsersHeader />
<UserFilterBar
filters={list.filters}
departments={departments.data ?? []}
roles={roles.data ?? []}
onFiltersChange={list.setFilters}
onResetFilters={list.resetFilters}
/>
<div className="lx-users__split">
<UserListPanel
filters={list.filters}
users={list.users}
loading={list.loading}
error={list.error}
selectedUserId={selectedId}
hasNext={list.hasNext}
hasPrev={list.hasPrev}
onSortChange={(sort) => {
list.setFilters({ sort });
}}
onSelect={(userId) => {
void navigate(userDetailPath(userId), { replace: true });
}}
onPrev={list.onPrev}
onNext={list.onNext}
onRetry={list.onRetry}
onCreate={() => {
void navigate(ROUTES.userNew);
}}
onResetFilters={list.resetFilters}
/>
<UserDetailPanel
userId={selectedId}
detail={detail.data}
loading={detail.isPending}
error={detail.error}
canManage={canManage}
onRetry={() => {
void detail.refetch();
}}
onEdit={(user) => {
dialogs.open('edit', user);
}}
onDisable={(user) => {
dialogs.open('disable', user);
}}
onEnable={(user) => {
dialogs.open('enable', user);
}}
onAdjustRoles={(user) => {
dialogs.open('roles', user);
}}
onTransfer={(user) => {
dialogs.open('transfer', user);
}}
/>
</div>
<EditUserDialog
key={`edit-${dialogUser?.id ?? ''}`}
visible={dialogs.dialog?.type === 'edit'}
user={dialogUser}
departments={departments.data ?? []}
roles={roles.data ?? []}
loading={dialogs.updatePending}
onCancel={dialogs.close}
onConfirm={dialogs.submitEdit}
/>
<DisableUserDialog
key={`disable-${dialogUser?.id ?? ''}`}
visible={dialogs.dialog?.type === 'disable'}
user={dialogUser}
candidates={candidates.data?.items ?? []}
loading={dialogs.updatePending}
onCancel={dialogs.close}
onConfirm={dialogs.submitDisable}
/>
<EnableUserDialog
key={`enable-${dialogUser?.id ?? ''}`}
visible={dialogs.dialog?.type === 'enable'}
user={dialogUser}
loading={dialogs.updatePending}
onCancel={dialogs.close}
onConfirm={dialogs.submitEnable}
/>
<AdjustRolesDialog
key={`roles-${dialogUser?.id ?? ''}`}
visible={dialogs.dialog?.type === 'roles' && !dialogs.grantSuper}
user={dialogUser}
roles={roles.data ?? []}
loading={dialogs.updatePending}
onCancel={dialogs.close}
onConfirm={dialogs.submitRoles}
/>
<TransferCustomersDialog
key={`transfer-${dialogUser?.id ?? ''}`}
visible={dialogs.dialog?.type === 'transfer'}
user={dialogUser}
customers={customers.data ?? []}
candidates={candidates.data?.items ?? []}
loading={dialogs.updatePending}
onCancel={dialogs.close}
onConfirm={dialogs.submitTransfer}
/>
<SuperAdminGrantDialog
key={`grant-${dialogs.grantSuper ? 'open' : 'closed'}`}
visible={dialogs.grantSuper}
targetTitle={dialogUser?.name ?? ''}
targetMeta={
dialogUser === null ? '' : `${dialogUser.account} · ${dialogUser.departmentName}`
}
loading={dialogs.updatePending}
onCancel={dialogs.closeGrant}
onConfirm={dialogs.confirmGrant}
/>
</div>
);
}
@@ -0,0 +1,56 @@
import type { RoleSummary, UserDetail } from '@lingxi/shared';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { RoleChipsSelect } from '../RoleChipsSelect';
import { ConfirmActionDialog } from './ConfirmActionDialog';
export interface AdjustRolesDialogProps {
visible: boolean;
user: UserDetail | null;
roles: readonly RoleSummary[];
loading: boolean;
onCancel: () => void;
onConfirm: (roleIds: string[]) => void;
}
/** 调整角色:变更前后角色对比 + 影响与审计说明,勾选后保存 */
export function AdjustRolesDialog({
visible,
user,
roles,
loading,
onCancel,
onConfirm,
}: AdjustRolesDialogProps): ReactNode {
const initial = user?.roles.map((role) => role.id) ?? [];
const [roleIds, setRoleIds] = useState<string[]>(initial);
const unchanged =
roleIds.length === initial.length && roleIds.every((id) => initial.includes(id));
return (
<ConfirmActionDialog
visible={visible}
title="调整角色"
objectTitle={user?.name ?? ''}
objectMeta={user === null ? '' : `${user.account} · ${user.departmentName}`}
consequences={[
'角色变更即时生效,成员下一次请求即使用新权限',
'涉及权限扩大时请确认职责范围,变更将写入审计',
]}
acknowledgeLabel="我已确认本次角色变更"
confirmText="保存角色"
loading={loading}
confirmDisabled={unchanged || roleIds.length === 0}
onCancel={onCancel}
onConfirm={() => {
onConfirm(roleIds);
}}
>
<div className="lx-field">
<span className="lx-field__label">角色(至少选择一个)</span>
<RoleChipsSelect roles={roles} value={roleIds} onChange={setRoleIds} />
</div>
</ConfirmActionDialog>
);
}
@@ -0,0 +1,131 @@
/* eslint-disable max-lines-per-function -- 确认弹窗模板固定,保持单文件便于复用 */
import { Button, Checkbox, Modal } from '@douyinfe/semi-ui-19';
import { IconAlert } from '@lingxi/ui/icons';
import { type ReactNode, useState } from 'react';
export interface ConfirmActionDialogProps {
visible: boolean;
title: string;
objectTitle: string;
objectMeta: string;
objectTags?: ReactNode;
consequences: readonly string[];
acknowledgeLabel: string;
confirmText: string;
danger?: boolean;
loading?: boolean;
/** 额外条件(如转交接收人未选)导致不可提交 */
confirmDisabled?: boolean;
onCancel: () => void;
onConfirm: () => void;
children?: ReactNode;
}
function ModalFooter({
loading,
danger,
disabled,
confirmText,
onCancel,
onConfirm,
}: {
loading: boolean;
danger: boolean;
disabled: boolean;
confirmText: string;
onCancel: () => void;
onConfirm: () => void;
}): ReactNode {
return (
<>
<Button onClick={onCancel} disabled={loading}>
取消
</Button>
<Button
theme="solid"
type={danger ? 'danger' : 'primary'}
loading={loading}
disabled={disabled}
onClick={onConfirm}
data-testid="confirm-action"
>
{confirmText}
</Button>
</>
);
}
/** 统一二次确认弹窗:对象卡 → 后果说明 → 勾选确认后才可提交 */
export function ConfirmActionDialog({
visible,
title,
objectTitle,
objectMeta,
objectTags,
consequences,
acknowledgeLabel,
confirmText,
danger = false,
loading = false,
confirmDisabled = false,
onCancel,
onConfirm,
children,
}: ConfirmActionDialogProps): ReactNode {
const [acknowledged, setAcknowledged] = useState(false);
return (
<Modal
visible={visible}
title={title}
width={520}
maskClosable={false}
closeOnEsc={!loading}
onCancel={onCancel}
footer={
<ModalFooter
loading={loading}
danger={danger}
disabled={!acknowledged || confirmDisabled}
confirmText={confirmText}
onCancel={onCancel}
onConfirm={onConfirm}
/>
}
>
<div className="lx-confirm-dialog">
<div className="lx-confirm-dialog__object">
<span className="lx-confirm-dialog__object-icon" aria-hidden="true">
{objectTitle.slice(0, 1)}
</span>
<span className="lx-confirm-dialog__object-copy">
<span className="lx-confirm-dialog__object-title">{objectTitle}</span>
<span className="lx-confirm-dialog__object-meta">{objectMeta}</span>
</span>
{objectTags}
</div>
<div className={danger ? 'lx-confirm-dialog__warn' : 'lx-confirm-dialog__warn is-info'}>
<IconAlert size={16} />
<ul>
{consequences.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
{children}
<Checkbox
className="lx-confirm-dialog__ack"
checked={acknowledged}
onChange={(event) => {
setAcknowledged(event.target.checked === true);
}}
>
{acknowledgeLabel}
</Checkbox>
</div>
</Modal>
);
}
@@ -0,0 +1,85 @@
/* eslint-disable max-lines-per-function -- 确认弹窗模板固定,保持单文件便于复用 */
import { Input, Select } from '@douyinfe/semi-ui-19';
import type { DisableUserInput, UserDetail, UserSummary } from '@lingxi/shared';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { ConfirmActionDialog } from './ConfirmActionDialog';
export interface DisableUserDialogProps {
visible: boolean;
user: UserDetail | null;
candidates: readonly UserSummary[];
loading: boolean;
onCancel: () => void;
onConfirm: (input: DisableUserInput) => void;
}
function firstString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
/** 停用账号:对象卡 → 后果 → 客户转交接收人 + 勾选确认 */
export function DisableUserDialog({
visible,
user,
candidates,
loading,
onCancel,
onConfirm,
}: DisableUserDialogProps): ReactNode {
const [reason, setReason] = useState('');
const [transferTo, setTransferTo] = useState('');
const submit = (): void => {
const input: DisableUserInput = {};
const trimmed = reason.trim();
if (trimmed !== '') {
input.reason = trimmed;
}
if (transferTo !== '') {
input.transferToUserId = transferTo;
}
onConfirm(input);
};
return (
<ConfirmActionDialog
visible={visible}
title="停用账号"
objectTitle={user?.name ?? ''}
objectMeta={user === null ? '' : `${user.account} · ${user.departmentName}`}
consequences={[
'账号停用后立即失效,在线会话同步退出',
'名下客户需在停用前完成转交,避免交付中断',
'操作记录与产出物保留,可随时重新启用',
]}
acknowledgeLabel="我已了解上述影响,并确认停用该账号"
confirmText="停用账号"
danger
loading={loading}
onCancel={onCancel}
onConfirm={submit}
>
<label className="lx-field">
<span className="lx-field__label">停用原因(选填)</span>
<Input value={reason} maxLength={200} placeholder="例如:离职、转岗" onChange={setReason} />
</label>
<label className="lx-field">
<span className="lx-field__label">客户转交给(选填)</span>
<Select
value={transferTo === '' ? undefined : transferTo}
placeholder="暂不转交"
optionList={candidates.map((candidate) => ({
label: `${candidate.name} · ${candidate.departmentName}`,
value: candidate.id,
}))}
showClear
onChange={(value) => {
setTransferTo(firstString(value));
}}
/>
</label>
</ConfirmActionDialog>
);
}
@@ -0,0 +1,138 @@
/* eslint-disable max-lines-per-function, complexity -- 编辑表单字段与校验集中在一个弹窗组件 */
import { Button, Input, Modal, Select } from '@douyinfe/semi-ui-19';
import type { DepartmentNode, RoleSummary, UpdateUserInput, UserDetail } from '@lingxi/shared';
import { updateUserInputSchema } from '@lingxi/shared';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { RoleChipsSelect } from '../RoleChipsSelect';
export interface EditUserDialogProps {
visible: boolean;
user: UserDetail | null;
departments: readonly DepartmentNode[];
roles: readonly RoleSummary[];
loading: boolean;
onCancel: () => void;
onConfirm: (input: UpdateUserInput) => void;
}
interface FieldErrors {
name?: string;
phone?: string;
departmentId?: string;
roleIds?: string;
}
function departmentOptions(nodes: readonly DepartmentNode[]): { label: string; value: string }[] {
const parents = nodes.filter((node) => node.parentId === null);
return parents.flatMap((parent) => {
const children = nodes.filter((node) => node.parentId === parent.id);
if (children.length === 0) {
return [{ label: parent.name, value: parent.id }];
}
return [
{ label: parent.name, value: parent.id },
...children.map((child) => ({ label: `— ${child.name}`, value: child.id })),
];
});
}
function firstString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
/** 编辑资料:姓名 / 手机号 / 部门 / 角色;account 与状态不在本弹窗修改 */
export function EditUserDialog({
visible,
user,
departments,
roles,
loading,
onCancel,
onConfirm,
}: EditUserDialogProps): ReactNode {
const [name, setName] = useState(user?.name ?? '');
const [phone, setPhone] = useState(user?.phone ?? '');
const [departmentId, setDepartmentId] = useState(user?.departmentId ?? '');
const [roleIds, setRoleIds] = useState<string[]>(user?.roles.map((role) => role.id) ?? []);
const [errors, setErrors] = useState<FieldErrors>({});
const submit = (): void => {
const parsed = updateUserInputSchema.safeParse({ name, phone, departmentId, roleIds });
if (!parsed.success) {
const next: FieldErrors = {};
for (const issue of parsed.error.issues) {
const key = issue.path[0];
if (key === 'name' || key === 'phone' || key === 'departmentId' || key === 'roleIds') {
next[key] = issue.message;
}
}
setErrors(next);
return;
}
setErrors({});
onConfirm(parsed.data);
};
return (
<Modal
visible={visible}
title="编辑资料"
width={520}
maskClosable={false}
onCancel={onCancel}
footer={
<>
<Button onClick={onCancel} disabled={loading}>
取消
</Button>
<Button theme="solid" type="primary" loading={loading} onClick={submit}>
保存
</Button>
</>
}
>
<div className="lx-edit-dialog">
<p className="lx-detail-tab__hint">
登录账号 {user?.account ?? '—'}{' '}
由身份源维护,不可修改;手机号仅具备用户管理权限的成员可见。
</p>
<label className="lx-field">
<span className="lx-field__label">姓名</span>
<Input value={name} onChange={setName} />
{errors.name === undefined ? null : (
<span className="lx-field__error">{errors.name}</span>
)}
</label>
<label className="lx-field">
<span className="lx-field__label">手机号</span>
<Input value={phone} placeholder="11 位手机号" onChange={setPhone} />
{errors.phone === undefined ? null : (
<span className="lx-field__error">{errors.phone}</span>
)}
</label>
<label className="lx-field">
<span className="lx-field__label">所属部门</span>
<Select
value={departmentId === '' ? undefined : departmentId}
optionList={departmentOptions(departments)}
onChange={(value) => {
setDepartmentId(firstString(value));
}}
/>
{errors.departmentId === undefined ? null : (
<span className="lx-field__error">{errors.departmentId}</span>
)}
</label>
<div className="lx-field">
<span className="lx-field__label">角色</span>
<RoleChipsSelect roles={roles} value={roleIds} onChange={setRoleIds} />
{errors.roleIds === undefined ? null : (
<span className="lx-field__error">{errors.roleIds}</span>
)}
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,40 @@
import type { UserDetail } from '@lingxi/shared';
import type { ReactNode } from 'react';
import { ConfirmActionDialog } from './ConfirmActionDialog';
export interface EnableUserDialogProps {
visible: boolean;
user: UserDetail | null;
loading: boolean;
onCancel: () => void;
onConfirm: () => void;
}
/** 启用账号:恢复登录能力,角色与数据范围按停用前保留 */
export function EnableUserDialog({
visible,
user,
loading,
onCancel,
onConfirm,
}: EnableUserDialogProps): ReactNode {
return (
<ConfirmActionDialog
visible={visible}
title="启用账号"
objectTitle={user?.name ?? ''}
objectMeta={user === null ? '' : `${user.account} · ${user.departmentName}`}
consequences={[
'启用后成员可立即使用原密码登录',
'角色与数据范围沿用停用前的配置',
'本次启用将写入审计日志',
]}
acknowledgeLabel="我已了解上述影响,并确认启用该账号"
confirmText="启用账号"
loading={loading}
onCancel={onCancel}
onConfirm={onConfirm}
/>
);
}
@@ -0,0 +1,42 @@
import type { ReactNode } from 'react';
import { ConfirmActionDialog } from './ConfirmActionDialog';
export interface SuperAdminGrantDialogProps {
visible: boolean;
targetTitle: string;
targetMeta: string;
loading: boolean;
onCancel: () => void;
onConfirm: () => void;
}
/** 授予超级管理员:权限后果说明 + 强制勾选;新建与调整角色共用 */
export function SuperAdminGrantDialog({
visible,
targetTitle,
targetMeta,
loading,
onCancel,
onConfirm,
}: SuperAdminGrantDialogProps): ReactNode {
return (
<ConfirmActionDialog
visible={visible}
title="授予超级管理员"
objectTitle={targetTitle}
objectMeta={targetMeta}
consequences={[
'超级管理员拥有全部模块权限与全部客户数据范围',
'授予即时生效并写入审计;建议全平台超级管理员不超过 2 人',
'如需收回,请在「角色权限」中移除该角色',
]}
acknowledgeLabel="我已确认授权范围,并同意授予超级管理员"
confirmText="确认授予"
danger
loading={loading}
onCancel={onCancel}
onConfirm={onConfirm}
/>
);
}
@@ -0,0 +1,102 @@
/* eslint-disable max-lines-per-function -- 转交弹窗字段与校验集中在一个弹窗组件 */
import { Select } from '@douyinfe/semi-ui-19';
import type { TransferCustomersInput, UserDetail, UserSummary } from '@lingxi/shared';
import type { ReactNode } from 'react';
import { useState } from 'react';
import type { CustomerSummary } from '@/api/identity';
import { ConfirmActionDialog } from './ConfirmActionDialog';
export interface TransferCustomersDialogProps {
visible: boolean;
user: UserDetail | null;
customers: readonly CustomerSummary[];
candidates: readonly UserSummary[];
loading: boolean;
onCancel: () => void;
onConfirm: (input: TransferCustomersInput) => void;
}
function firstString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
/** 客户转交:选择客户 + 新负责人,确认后一次提交;A3 客户模块落地前后端返回占位错误 */
export function TransferCustomersDialog({
visible,
user,
customers,
candidates,
loading,
onCancel,
onConfirm,
}: TransferCustomersDialogProps): ReactNode {
const [selected, setSelected] = useState<string[]>([]);
const [ownerId, setOwnerId] = useState('');
const toggle = (id: string): void => {
setSelected((current) =>
current.includes(id) ? current.filter((item) => item !== id) : [...current, id],
);
};
return (
<ConfirmActionDialog
visible={visible}
title="转交客户"
objectTitle={user?.name ?? ''}
objectMeta={
user === null ? '' : `${user.departmentName} · 名下客户 ${String(customers.length)} 个`
}
consequences={[
'客户资料、交付记录与历史素材将一并移交',
'转交后原负责人不再拥有该客户的数据权限',
'本次转交将写入审计日志',
]}
acknowledgeLabel="我已了解上述影响,并确认转交所选客户"
confirmText="确认转交"
loading={loading}
confirmDisabled={selected.length === 0 || ownerId === ''}
onCancel={onCancel}
onConfirm={() => {
onConfirm({ customerIds: selected, toUserId: ownerId });
}}
>
<div className="lx-field">
<span className="lx-field__label">选择客户</span>
<div className="lx-check-list">
{customers.map((customer) => (
<label key={customer.id} className="lx-check-list__item">
<input
type="checkbox"
checked={selected.includes(customer.id)}
onChange={() => {
toggle(customer.id);
}}
/>
<span>{customer.name}</span>
</label>
))}
{customers.length === 0 ? (
<p className="lx-detail-tab__hint">该成员暂无可转交客户。</p>
) : null}
</div>
</div>
<label className="lx-field">
<span className="lx-field__label">新负责人</span>
<Select
value={ownerId === '' ? undefined : ownerId}
placeholder="请选择接收人"
optionList={candidates.map((candidate) => ({
label: `${candidate.name} · ${candidate.departmentName}`,
value: candidate.id,
}))}
onChange={(value) => {
setOwnerId(firstString(value));
}}
/>
</label>
</ConfirmActionDialog>
);
}
+6
View File
@@ -0,0 +1,6 @@
import type { RoleSummary } from '@lingxi/shared';
/** 超级管理员(内置角色):授予/收回需二次确认并审计 */
export function isSuperAdminRole(role: RoleSummary): boolean {
return role.code === 'super_admin' || role.name === '超级管理员';
}
@@ -0,0 +1,160 @@
/* eslint-disable max-lines-per-function -- 页面动作编排集中在此 Hook,便于统一维护二次确认与缓存失效 */
import {
type DisableUserInput,
type RoleSummary,
type TransferCustomersInput,
type UpdateUserInput,
type UserDetail,
} from '@lingxi/shared';
import { notifySuccess } from '@lingxi/ui';
import { useState } from 'react';
import {
useDisableUserMutation,
useEnableUserMutation,
useTransferCustomersMutation,
useUpdateUserMutation,
} from '@/hooks/queries/use-users';
import { isSuperAdminRole } from './role-utils';
export type UserDialogType = 'disable' | 'enable' | 'edit' | 'roles' | 'transfer';
export interface UserDialogState {
type: UserDialogType;
user: UserDetail;
}
export interface UserDialogsController {
dialog: UserDialogState | null;
grantSuper: boolean;
updatePending: boolean;
open: (type: UserDialogType, user: UserDetail) => void;
close: () => void;
closeGrant: () => void;
submitEdit: (input: UpdateUserInput) => void;
submitDisable: (input: DisableUserInput) => void;
submitEnable: () => void;
submitRoles: (roleIds: string[]) => void;
confirmGrant: () => void;
submitTransfer: (input: TransferCustomersInput) => void;
}
/** 用户写操作:调整角色若新增「超级管理员」则先走授予二次确认 */
export function useUserDialogs(roles: readonly RoleSummary[]): UserDialogsController {
const [dialog, setDialog] = useState<UserDialogState | null>(null);
const [pendingRoleIds, setPendingRoleIds] = useState<string[]>([]);
const [grantSuper, setGrantSuper] = useState(false);
const updateMutation = useUpdateUserMutation();
const disableMutation = useDisableUserMutation();
const enableMutation = useEnableUserMutation();
const transferMutation = useTransferCustomersMutation();
const close = (): void => {
setDialog(null);
setGrantSuper(false);
setPendingRoleIds([]);
};
const open = (type: UserDialogType, user: UserDetail): void => {
setDialog({ type, user });
};
const saveRoles = (roleIds: string[]): void => {
if (dialog === null) {
return;
}
updateMutation.mutate(
{ userId: dialog.user.id, input: { roleIds } },
{
onSuccess: () => {
notifySuccess('角色已更新');
close();
},
},
);
};
return {
dialog,
grantSuper,
updatePending:
updateMutation.isPending ||
disableMutation.isPending ||
enableMutation.isPending ||
transferMutation.isPending,
open,
close,
closeGrant: () => {
setGrantSuper(false);
},
submitEdit: (input) => {
if (dialog === null) {
return;
}
updateMutation.mutate(
{ userId: dialog.user.id, input },
{
onSuccess: () => {
notifySuccess('成员资料已更新');
close();
},
},
);
},
submitDisable: (input) => {
if (dialog === null) {
return;
}
disableMutation.mutate(
{ userId: dialog.user.id, input },
{
onSuccess: () => {
notifySuccess('账号已停用,在线会话已退出');
close();
},
},
);
},
submitEnable: () => {
if (dialog === null) {
return;
}
enableMutation.mutate(dialog.user.id, {
onSuccess: () => {
notifySuccess('账号已启用');
close();
},
});
},
submitRoles: (roleIds) => {
const before = dialog?.user.roles.map((role) => role.id) ?? [];
const addedSuper = roles.some(
(role) => isSuperAdminRole(role) && roleIds.includes(role.id) && !before.includes(role.id),
);
setPendingRoleIds(roleIds);
if (addedSuper) {
setGrantSuper(true);
return;
}
saveRoles(roleIds);
},
confirmGrant: () => {
setGrantSuper(false);
saveRoles(pendingRoleIds);
},
submitTransfer: (input) => {
if (dialog === null) {
return;
}
transferMutation.mutate(
{ userId: dialog.user.id, input },
{
onSuccess: () => {
notifySuccess('客户已转交');
},
},
);
},
};
}
@@ -0,0 +1,94 @@
/* eslint-disable max-lines-per-function -- 列表筛选与游标分页状态集中在一个 Hook */
import type { UserSummary } from '@lingxi/shared';
import { useCallback, useMemo, useState } from 'react';
import { useUserListQuery } from '@/hooks/queries/use-users';
import { useDebouncedValue } from '@/hooks/use-debounced-value';
import {
DEFAULT_USER_LIST_FILTERS,
toUserListQueryInput,
type UserListFilters,
} from './user-filters';
export interface UserListController {
filters: UserListFilters;
users: UserSummary[];
loading: boolean;
error: Error | null;
hasNext: boolean;
hasPrev: boolean;
setFilters: (next: Partial<UserListFilters>) => void;
resetFilters: () => void;
onNext: () => void;
onPrev: () => void;
onRetry: () => void;
}
/** 列表筛选 + 游标分页(cursor 历史栈,前进/后退都复用后端返回的 next_cursor) */
export function useUserListController(): UserListController {
const [filters, setFiltersState] = useState<UserListFilters>(DEFAULT_USER_LIST_FILTERS);
const debouncedKeyword = useDebouncedValue(filters.keyword);
const [cursors, setCursors] = useState<(string | null)[]>([null]);
const [pageIndex, setPageIndex] = useState(0);
const queryInput = useMemo(
() =>
toUserListQueryInput(
{ ...filters, keyword: debouncedKeyword },
cursors[pageIndex] ?? undefined,
),
[filters, debouncedKeyword, cursors, pageIndex],
);
const query = useUserListQuery(queryInput);
const resetPaging = useCallback(() => {
setCursors([null]);
setPageIndex(0);
}, []);
const setFilters = useCallback(
(next: Partial<UserListFilters>) => {
setFiltersState((current) => ({ ...current, ...next }));
resetPaging();
},
[resetPaging],
);
const resetFilters = useCallback(() => {
setFiltersState(DEFAULT_USER_LIST_FILTERS);
resetPaging();
}, [resetPaging]);
const nextCursor = query.data?.next_cursor ?? null;
const onNext = useCallback(() => {
if (nextCursor === null) {
return;
}
setCursors((current) => [...current.slice(0, pageIndex + 1), nextCursor]);
setPageIndex((current) => current + 1);
}, [nextCursor, pageIndex]);
const onPrev = useCallback(() => {
setPageIndex((current) => Math.max(0, current - 1));
}, []);
const onRetry = useCallback(() => {
void query.refetch();
}, [query]);
return {
filters,
users: query.data?.items ?? [],
loading: query.isPending,
error: query.error,
hasNext: nextCursor !== null,
hasPrev: pageIndex > 0,
setFilters,
resetFilters,
onNext,
onPrev,
onRetry,
};
}
@@ -0,0 +1,64 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useNewUserForm } from './use-user-new-form';
const DEPARTMENT_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
describe('新建用户表单校验', () => {
it('空表单提交时逐字段给出错误', () => {
const { result } = renderHook(() => useNewUserForm());
let submitted: unknown = null;
act(() => {
submitted = result.current.validate();
});
expect(submitted).toBeNull();
expect(result.current.errors.name).toBeTruthy();
expect(result.current.errors.phone).toBeTruthy();
expect(result.current.errors.account).toBeTruthy();
expect(result.current.errors.departmentId).toBeTruthy();
expect(result.current.errors.roleIds).toBeTruthy();
});
it('手机号与账号格式错误使用共享 schema 文案', () => {
const { result } = renderHook(() => useNewUserForm());
act(() => {
result.current.setField('name', '测试成员');
result.current.setField('phone', '12345');
result.current.setField('account', 'ab');
result.current.setField('departmentId', DEPARTMENT_ID);
result.current.setField('roleIds', [ROLE_ID]);
});
act(() => {
result.current.validate();
});
expect(result.current.errors.phone).toContain('11 位手机号');
expect(result.current.errors.account).toBeTruthy();
});
it('填写合规后返回提交入参(小组优先作为 departmentId)', () => {
const { result } = renderHook(() => useNewUserForm());
act(() => {
result.current.setField('name', '林清');
result.current.setField('phone', '13900001111');
result.current.setField('account', 'lin.qing');
result.current.setField('departmentId', DEPARTMENT_ID);
result.current.setField('roleIds', [ROLE_ID]);
result.current.setField('remark', ' 入职批次 A ');
});
let submitted: ReturnType<typeof result.current.validate> = null;
act(() => {
submitted = result.current.validate();
});
expect(submitted).toEqual({
name: '林清',
phone: '13900001111',
account: 'lin.qing',
password: expect.any(String) as string,
departmentId: DEPARTMENT_ID,
roleIds: [ROLE_ID],
remark: '入职批次 A',
});
});
});
@@ -0,0 +1,100 @@
import { type CreateUserInput, createUserInputSchema } from '@lingxi/shared';
import { useState } from 'react';
import { generateInitialPassword } from './user-new-password';
export interface NewUserFormValues {
name: string;
phone: string;
account: string;
password: string;
departmentId: string;
groupId: string;
roleIds: string[];
remark: string;
}
export type NewUserField = 'name' | 'phone' | 'account' | 'password' | 'departmentId' | 'roleIds';
export interface NewUserFormState {
values: NewUserFormValues;
errors: Partial<Record<NewUserField, string>>;
setField: <K extends keyof NewUserFormValues>(field: K, value: NewUserFormValues[K]) => void;
regeneratePassword: () => void;
/** 校验通过返回提交入参,否则写回字段错误并返回 null */
validate: () => CreateUserInput | null;
}
function initialValues(): NewUserFormValues {
return {
name: '',
phone: '',
account: '',
password: generateInitialPassword(),
departmentId: '',
groupId: '',
roleIds: [],
remark: '',
};
}
function toInput(values: NewUserFormValues): CreateUserInput {
const departmentId = values.groupId === '' ? values.departmentId : values.groupId;
const input: CreateUserInput = {
name: values.name.trim(),
phone: values.phone.trim(),
account: values.account.trim(),
password: values.password,
departmentId,
roleIds: values.roleIds,
};
const remark = values.remark.trim();
if (remark !== '') {
input.remark = remark;
}
return input;
}
/** 新建用户表单:与后端共用 createUserInputSchema 做同源校验 */
export function useNewUserForm(): NewUserFormState {
const [values, setValues] = useState<NewUserFormValues>(initialValues);
const [errors, setErrors] = useState<Partial<Record<NewUserField, string>>>({});
const setField = <K extends keyof NewUserFormValues>(
field: K,
value: NewUserFormValues[K],
): void => {
setValues((current) => ({ ...current, [field]: value }));
if (field in errors) {
setErrors((current) => ({ ...current, [field]: undefined }));
}
};
const validate = (): CreateUserInput | null => {
const base = toInput(values);
const parsed = createUserInputSchema.safeParse(base);
if (parsed.success) {
setErrors({});
return parsed.data;
}
const next: Partial<Record<NewUserField, string>> = {};
for (const issue of parsed.error.issues) {
const field = issue.path[0];
if (typeof field === 'string' && field in base) {
next[field as NewUserField] = issue.message;
}
}
setErrors(next);
return null;
};
return {
values,
errors,
setField,
regeneratePassword: () => {
setField('password', generateInitialPassword());
},
validate,
};
}
@@ -0,0 +1,48 @@
import type { UserDetail, UserSummary } from '@lingxi/shared';
import { describe, expect, it } from 'vitest';
import { displayPhone, formatDateTime, userInitial, userOrgLabel } from './user-display';
const base: UserSummary = {
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
name: '陈默',
phoneMasked: '138****2046',
account: 'chen.mo',
departmentId: '01HZZZZZZZZZZZZZZZZZZZZZZ1',
departmentName: '内容生产中心',
groupId: null,
groupName: '素材一组',
roles: [],
status: 'active',
lastLoginAt: '2026-11-02T09:41:00.000Z',
createdAt: '2026-09-30T18:40:00.000Z',
};
const detail: UserDetail = {
...base,
phone: '13800002046',
mustChangePassword: false,
updatedAt: '2026-11-02T09:41:00.000Z',
};
describe('成员展示策略', () => {
it('列表手机号始终掩码,详情仅在 user:manage 时展示全号', () => {
expect(displayPhone(base, false)).toBe('138****2046');
expect(displayPhone(base, true)).toBe('138****2046');
expect(displayPhone(detail, true)).toBe('13800002046');
expect(displayPhone(detail, false)).toBe('138****2046');
});
it('空时间显示占位符,非法时间不崩溃', () => {
expect(formatDateTime(null)).toBe('—');
expect(formatDateTime(undefined)).toBe('—');
expect(formatDateTime('not-a-date')).toBe('—');
expect(formatDateTime('2026-11-02T09:41:00.000Z')).toMatch(/2026-11-02/);
});
it('头像取姓氏首字,组织副标题小组优先', () => {
expect(userInitial(' 陈默 ')).toBe('陈');
expect(userOrgLabel(base)).toBe('素材一组');
expect(userOrgLabel({ ...base, groupName: null })).toBe('内容生产中心');
});
});
+47
View File
@@ -0,0 +1,47 @@
import type { UserStatus, UserSummary } from '@lingxi/shared';
export const USER_STATUS_LABEL: Record<UserStatus, string> = {
active: '在职',
disabled: '停用',
};
const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
/** ISO 时间 → `YYYY-MM-DD HH:mm`;空值显示占位符 */
export function formatDateTime(value: string | null | undefined): string {
if (value === null || value === undefined || value === '') {
return '—';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return DATE_TIME_FORMATTER.format(date).replaceAll('/', '-');
}
/** 手机号展示策略:有 user:manage 且后端返回全号时展示全号,否则始终展示掩码 */
export function displayPhone(
user: Pick<UserSummary, 'phoneMasked'> & { phone?: string },
canManage: boolean,
): string {
if (canManage && user.phone !== undefined && user.phone !== '') {
return user.phone;
}
return user.phoneMasked;
}
export function userInitial(name: string): string {
return name.trim().slice(0, 1);
}
/** 列表/详情副标题:小组优先,无小组时展示部门 */
export function userOrgLabel(user: Pick<UserSummary, 'departmentName' | 'groupName'>): string {
return user.groupName ?? user.departmentName;
}
@@ -0,0 +1,121 @@
import type { DepartmentNode } from '@lingxi/shared';
import { describe, expect, it } from 'vitest';
import {
childDepartments,
DEFAULT_USER_LIST_FILTERS,
departmentNameOf,
parseUserSortValue,
toDepartmentOptions,
topLevelDepartments,
toRoleOptions,
toUserListQueryInput,
} from './user-filters';
const ROOT_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const GROUP_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
const OTHER_ROOT_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ3';
const departments: DepartmentNode[] = [
{
id: ROOT_ID,
name: '内容生产中心',
parentId: null,
leader: null,
memberCount: 19,
createdAt: '2026-09-01T00:00:00.000Z',
},
{
id: GROUP_ID,
name: '素材一组',
parentId: ROOT_ID,
leader: null,
memberCount: 9,
createdAt: '2026-09-01T00:00:00.000Z',
},
{
id: OTHER_ROOT_ID,
name: '交付中心',
parentId: null,
leader: null,
memberCount: 14,
createdAt: '2026-09-01T00:00:00.000Z',
},
];
describe('用户列表筛选参数', () => {
it('排序值拆成 sort/order', () => {
expect(parseUserSortValue('last_login:desc')).toEqual({ sort: 'last_login', order: 'desc' });
expect(parseUserSortValue('created_at:asc')).toEqual({ sort: 'created_at', order: 'asc' });
});
it('默认筛选:在职 + 最近登录倒序 + 每页 10 条', () => {
expect(toUserListQueryInput(DEFAULT_USER_LIST_FILTERS)).toEqual({
limit: 10,
status: 'active',
sort: 'last_login',
order: 'desc',
});
});
it('关键词 trim,部门/角色与游标按需带上', () => {
expect(
toUserListQueryInput(
{
keyword: ' 陈默 ',
departmentId: ROOT_ID,
roleId: GROUP_ID,
status: 'all',
sort: 'created_at:asc',
},
'cursor-1',
),
).toEqual({
limit: 10,
status: 'all',
sort: 'created_at',
order: 'asc',
keyword: '陈默',
department_id: ROOT_ID,
role_id: GROUP_ID,
cursor: 'cursor-1',
});
});
it('部门两级拆分与选项生成', () => {
expect(topLevelDepartments(departments).map((node) => node.name)).toEqual([
'内容生产中心',
'交付中心',
]);
expect(childDepartments(departments, ROOT_ID).map((node) => node.name)).toEqual(['素材一组']);
expect(childDepartments(departments, OTHER_ROOT_ID)).toEqual([]);
expect(departmentNameOf(departments, GROUP_ID)).toBe('素材一组');
expect(departmentNameOf(departments, 'missing')).toBeNull();
expect(toDepartmentOptions(departments)).toEqual([
{ label: '内容生产中心(19 人)', value: ROOT_ID },
{ label: '交付中心(14 人)', value: OTHER_ROOT_ID },
]);
});
it('角色选项标注内置角色', () => {
expect(
toRoleOptions([
{
id: ROOT_ID,
code: 'super_admin',
name: '超级管理员',
isSystem: true,
},
{
id: GROUP_ID,
code: 'designer',
name: '设计师',
isSystem: false,
},
]),
).toEqual([
{ label: '超级管理员(内置)', value: ROOT_ID },
{ label: '设计师', value: GROUP_ID },
]);
});
});
+117
View File
@@ -0,0 +1,117 @@
import type { DepartmentNode, RoleSummary, UserListStatus } from '@lingxi/shared';
import type { UserListQueryInput } from '@/api/users';
/** 排序值:`<字段>:<方向>`,与后端 query 的 sort/order 一一对应 */
export type UserSortValue =
'last_login:desc' | 'last_login:asc' | 'created_at:desc' | 'created_at:asc';
export const USER_SORT_OPTIONS: readonly { value: UserSortValue; label: string }[] = [
{ value: 'last_login:desc', label: '最近登录 · 新→旧' },
{ value: 'last_login:asc', label: '最近登录 · 旧→新' },
{ value: 'created_at:desc', label: '创建时间 · 新→旧' },
{ value: 'created_at:asc', label: '创建时间 · 旧→新' },
];
export const USER_STATUS_OPTIONS: readonly { value: UserListStatus; label: string }[] = [
{ value: 'all', label: '全部' },
{ value: 'active', label: '在职' },
{ value: 'disabled', label: '停用' },
];
export const USER_LIST_PAGE_SIZE = 10;
/** 列表筛选状态(keyword 为输入值,其余为已生效值) */
export interface UserListFilters {
keyword: string;
departmentId: string;
roleId: string;
status: UserListStatus;
sort: UserSortValue;
}
export const DEFAULT_USER_LIST_FILTERS: UserListFilters = {
keyword: '',
departmentId: '',
roleId: '',
status: 'active',
sort: 'last_login:desc',
};
/** `last_login:desc` → `{ sort: 'last_login', order: 'desc' }` */
export function parseUserSortValue(value: UserSortValue): {
sort: 'last_login' | 'created_at';
order: 'asc' | 'desc';
} {
const [sort, order] = value.split(':');
return {
sort: sort === 'created_at' ? 'created_at' : 'last_login',
order: order === 'asc' ? 'asc' : 'desc',
};
}
/** 筛选状态 → GET /users query(空值不传,部门/角色为空表示不限) */
export function toUserListQueryInput(
filters: UserListFilters,
cursor?: string,
): UserListQueryInput {
const { sort, order } = parseUserSortValue(filters.sort);
const query: UserListQueryInput = {
limit: USER_LIST_PAGE_SIZE,
status: filters.status,
sort,
order,
};
if (cursor !== undefined && cursor !== '') {
query.cursor = cursor;
}
if (filters.keyword.trim() !== '') {
query.keyword = filters.keyword.trim();
}
if (filters.departmentId !== '') {
query.department_id = filters.departmentId;
}
if (filters.roleId !== '') {
query.role_id = filters.roleId;
}
return query;
}
/** 一级部门(父级为 null):列表筛选与新建表单的「所属部门」 */
export function topLevelDepartments(nodes: readonly DepartmentNode[]): DepartmentNode[] {
return nodes.filter((node) => node.parentId === null);
}
/** 某部门下的小组;部门直属时返回空数组 */
export function childDepartments(
nodes: readonly DepartmentNode[],
departmentId: string,
): DepartmentNode[] {
return nodes.filter((node) => node.parentId === departmentId);
}
/** 部门 id → 名称(新建/编辑回显用) */
export function departmentNameOf(
nodes: readonly DepartmentNode[],
departmentId: string,
): string | null {
return nodes.find((node) => node.id === departmentId)?.name ?? null;
}
/** Select 选项:一级部门(含人数) */
export function toDepartmentOptions(
nodes: readonly DepartmentNode[],
): { label: string; value: string }[] {
return topLevelDepartments(nodes).map((node) => ({
label: `${node.name}(${String(node.memberCount)} 人)`,
value: node.id,
}));
}
/** Select 选项:角色 */
export function toRoleOptions(roles: readonly RoleSummary[]): { label: string; value: string }[] {
return roles.map((role) => ({
label: role.isSystem ? `${role.name}(内置)` : role.name,
value: role.id,
}));
}
@@ -0,0 +1,18 @@
import { passwordSchema } from '@lingxi/shared';
import { describe, expect, it } from 'vitest';
import { generateInitialPassword } from './user-new-password';
describe('初始密码生成', () => {
it('生成的密码符合共享密码策略', () => {
for (let index = 0; index < 200; index += 1) {
const password = generateInitialPassword();
expect(passwordSchema.safeParse(password).success, password).toBe(true);
}
});
it('多次生成结果不同(随机性)', () => {
const samples = new Set(Array.from({ length: 20 }, () => generateInitialPassword()));
expect(samples.size).toBeGreaterThan(1);
});
});
@@ -0,0 +1,18 @@
const UPPER = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
const LOWER = 'abcdefghijkmnpqrstuvwxyz';
const DIGITS = '23456789';
const SYMBOLS = '@#$';
function pick(source: string): string {
return source.charAt(Math.floor(Math.random() * source.length));
}
/**
* 生成符合密码策略的初始密码:8–64 位、同时包含字母与数字。
* 管理员可重新生成后通过短信/线下方式交给成员,首次登录强制修改。
*/
export function generateInitialPassword(): string {
const head = pick(UPPER) + pick(LOWER) + pick(DIGITS);
const tail = [pick(LOWER), pick(DIGITS), pick(UPPER), pick(SYMBOLS), pick(DIGITS), pick(LOWER)];
return `${head}Lx${tail.join('')}`;
}
+49 -1
View File
@@ -1,5 +1,11 @@
/* eslint-disable react-refresh/only-export-components -- 路由配置模块,lazy 组件仅作为路由元素,不做热更新边界 */
import { Permission } from '@lingxi/shared';
import { PageSkeleton } from '@lingxi/ui';
import { lazy, type ReactNode, Suspense } from 'react';
import { createBrowserRouter } from 'react-router';
import { RequireAuth } from '@/components/auth/RequireAuth';
import { RequirePermission } from '@/components/auth/RequirePermission';
import { AppLayout } from '@/layout/AppLayout';
import { ForbiddenPage } from '@/pages/errors/ForbiddenPage';
import { NotFoundPage } from '@/pages/errors/NotFoundPage';
@@ -7,6 +13,20 @@ import { ServerErrorPage } from '@/pages/errors/ServerErrorPage';
import { UnauthorizedPage } from '@/pages/errors/UnauthorizedPage';
import { ROUTES } from '@/routes';
const UsersPage = lazy(async () => {
const module = await import('@/pages/users/UsersPage');
return { default: module.UsersPage };
});
const UserNewPage = lazy(async () => {
const module = await import('@/pages/users/UserNewPage');
return { default: module.UserNewPage };
});
function withSkeleton(node: ReactNode): ReactNode {
return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>;
}
export const router = createBrowserRouter([
{
path: ROUTES.login,
@@ -16,7 +36,11 @@ export const router = createBrowserRouter([
},
{
path: '/',
element: <AppLayout />,
element: (
<RequireAuth>
<AppLayout />
</RequireAuth>
),
children: [
{
index: true,
@@ -24,6 +48,30 @@ export const router = createBrowserRouter([
Component: (await import('@/pages/home/HomePage')).default,
}),
},
{
path: 'users',
element: (
<RequirePermission permission={Permission.USER_VIEW}>
{withSkeleton(<UsersPage />)}
</RequirePermission>
),
},
{
path: 'users/new',
element: (
<RequirePermission permission={Permission.USER_MANAGE}>
{withSkeleton(<UserNewPage />)}
</RequirePermission>
),
},
{
path: 'users/:id',
element: (
<RequirePermission permission={Permission.USER_VIEW}>
{withSkeleton(<UsersPage />)}
</RequirePermission>
),
},
{
path: ':moduleKey/:itemKey?',
lazy: async () => ({
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { loginPath, ROUTES, sanitizeRedirect, userDetailPath } from './routes';
describe('路由常量与登录回跳', () => {
it('用户路由按附录 C 使用 /users', () => {
expect(ROUTES.users).toBe('/users');
expect(ROUTES.userNew).toBe('/users/new');
expect(userDetailPath('01HZZZZZZZZZZZZZZZZZZZZZZZ')).toBe('/users/01HZZZZZZZZZZZZZZZZZZZZZZZ');
});
it('sanitizeRedirect 只接受站内路径', () => {
expect(sanitizeRedirect('/users/new')).toBe('/users/new');
expect(sanitizeRedirect(' /users?status=active ')).toBe('/users?status=active');
expect(sanitizeRedirect('https://evil.example.com')).toBeNull();
expect(sanitizeRedirect('//evil.example.com')).toBeNull();
expect(sanitizeRedirect('javascript:alert(1)')).toBeNull();
expect(sanitizeRedirect('/login?redirect=/users')).toBeNull();
expect(sanitizeRedirect(null)).toBeNull();
});
it('loginPath 编码 redirect 参数', () => {
expect(loginPath()).toBe('/login');
expect(loginPath('/users?status=active')).toBe('/login?redirect=%2Fusers%3Fstatus%3Dactive');
expect(loginPath('https://evil.example.com')).toBe('/login');
});
});
+32
View File
@@ -2,6 +2,9 @@
export const ROUTES = {
home: '/',
login: '/login',
users: '/users',
userNew: '/users/new',
userDetail: '/users/:id',
unauthorized: '/401',
forbidden: '/403',
notFound: '/404',
@@ -9,3 +12,32 @@ export const ROUTES = {
} as const;
export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];
/** 用户详情深链:`/users/{id}` */
export function userDetailPath(userId: string): string {
return `${ROUTES.users}/${encodeURIComponent(userId)}`;
}
/**
* 校验回跳地址:只允许站内绝对路径(以单个 `/` 开头),
* 拒绝协议相对地址(`//evil.com`)与外部 URL,避免开放重定向。
*/
export function sanitizeRedirect(value: string | null | undefined): string | null {
if (value === null || value === undefined) {
return null;
}
const trimmed = value.trim();
if (!trimmed.startsWith('/') || trimmed.startsWith('//')) {
return null;
}
if (trimmed.startsWith(ROUTES.login)) {
return null;
}
return trimmed;
}
/** 登录页地址;带 redirect 时回跳原路径(仅接受站内相对路径) */
export function loginPath(redirect?: string): string {
const target = sanitizeRedirect(redirect);
return target === null ? ROUTES.login : `${ROUTES.login}?redirect=${encodeURIComponent(target)}`;
}
+88
View File
@@ -0,0 +1,88 @@
import type { MeResponse } from '@lingxi/shared';
import { beforeEach, describe, expect, it } from 'vitest';
import { authUserFromMe, DEMO_USER, isDemoSession, useAuthStore } from './auth-store';
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
const GROUP_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ3';
const me: MeResponse = {
user: {
id: USER_ID,
name: '沈知远',
phoneMasked: '133****0071',
account: 'shen.zhiyuan',
departmentId: GROUP_ID,
departmentName: '平台技术部',
groupId: null,
groupName: '部门直属',
roles: [{ id: ROLE_ID, code: 'super_admin', name: '超级管理员', isSystem: true }],
status: 'active',
lastLoginAt: '2026-11-02T10:02:00.000Z',
createdAt: '2026-09-01T00:00:00.000Z',
},
roles: [{ id: ROLE_ID, code: 'super_admin', name: '超级管理员', isSystem: true }],
permissions: ['user:view', 'user:manage'],
dataScope: { type: 'all' },
mustChangePassword: false,
};
describe('会话状态', () => {
beforeEach(() => {
window.localStorage.clear();
useAuthStore.getState().clearSession();
});
it('/me 同步用户、权限与首登改密标记', () => {
useAuthStore.getState().setSession({
accessToken: 'token',
refreshToken: 'refresh',
user: { id: USER_ID, name: '临时', account: 'tmp' },
permissions: [],
});
useAuthStore.getState().syncFromMe({ ...me, mustChangePassword: true });
const state = useAuthStore.getState();
expect(state.user?.name).toBe('沈知远');
expect(state.user?.roleName).toBe('超级管理员');
expect(state.permissions).toEqual(['user:view', 'user:manage']);
expect(state.mustChangePassword).toBe(true);
useAuthStore.getState().markPasswordChanged();
expect(useAuthStore.getState().mustChangePassword).toBe(false);
});
it('会话持久化与登出清理', () => {
useAuthStore.getState().setSession({
accessToken: 'token',
user: authUserFromMe(me),
permissions: me.permissions,
});
expect(window.localStorage.getItem('lingxi.auth.session')).toContain('token');
useAuthStore.getState().clearSession();
expect(window.localStorage.getItem('lingxi.auth.session')).toBeNull();
expect(useAuthStore.getState().user).toBeNull();
});
it('刷新令牌只更新 token,不动用户信息', () => {
useAuthStore.getState().setSession({
accessToken: 'old',
refreshToken: 'refresh-old',
user: authUserFromMe(me),
permissions: me.permissions,
});
useAuthStore.getState().setTokens({ accessToken: 'new', refreshToken: 'refresh-new' });
expect(useAuthStore.getState().accessToken).toBe('new');
expect(useAuthStore.getState().refreshToken).toBe('refresh-new');
expect(useAuthStore.getState().user?.id).toBe(USER_ID);
});
it('演示会话识别与演示权限写入', () => {
useAuthStore.getState().signInDemo();
const state = useAuthStore.getState();
expect(isDemoSession(state.accessToken)).toBe(true);
expect(state.user).toEqual(DEMO_USER);
expect(state.permissions.length).toBeGreaterThan(0);
expect(isDemoSession('real-token')).toBe(false);
});
});
+59 -8
View File
@@ -1,10 +1,10 @@
import { PERMISSION_CODES } from '@lingxi/shared';
import { type MeResponse, PERMISSION_CODES } from '@lingxi/shared';
import { create } from 'zustand';
export interface AuthUser {
id: string;
name: string;
email: string;
account: string;
roleName?: string;
}
@@ -13,6 +13,7 @@ export interface AuthSession {
refreshToken: string | null;
user: AuthUser | null;
permissions: string[];
mustChangePassword: boolean;
}
export interface SetSessionInput {
@@ -20,29 +21,38 @@ export interface SetSessionInput {
refreshToken?: string | null;
user: AuthUser;
permissions?: readonly string[];
mustChangePassword?: boolean;
}
export interface AuthState extends AuthSession {
setSession: (input: SetSessionInput) => void;
setTokens: (tokens: { accessToken: string; refreshToken?: string | null }) => void;
/** /me 拉取成功后刷新用户与权限(token 不变) */
syncFromMe: (me: MeResponse) => void;
clearSession: () => void;
/** 首次登录强制改密成功后清除标记 */
markPasswordChanged: () => void;
signInDemo: () => void;
hasPermission: (code: string) => boolean;
}
const STORAGE_KEY = 'lingxi.auth.session';
/** 演示会话令牌:仅当 `VITE_PUBLIC_DEMO_SESSION === 'true'` 时写入,守卫据此跳过 /me 请求 */
export const DEMO_ACCESS_TOKEN = 'demo-access-token';
const EMPTY_SESSION: AuthSession = {
accessToken: null,
refreshToken: null,
user: null,
permissions: [],
mustChangePassword: false,
};
export const DEMO_USER: AuthUser = {
id: '01J0DEMO000000000000000000',
name: '灵犀管理员',
email: 'admin@lingxi.local',
account: 'admin@lingxi.local',
roleName: '平台管理员',
};
@@ -55,7 +65,7 @@ function isAuthUser(value: unknown): value is AuthUser {
isRecord(value) &&
typeof value.id === 'string' &&
typeof value.name === 'string' &&
typeof value.email === 'string'
typeof value.account === 'string'
);
}
@@ -66,10 +76,16 @@ function isAuthSession(value: unknown): value is AuthSession {
(typeof value.refreshToken === 'string' || value.refreshToken === null) &&
(isAuthUser(value.user) || value.user === null) &&
Array.isArray(value.permissions) &&
value.permissions.every((permission) => typeof permission === 'string')
value.permissions.every((permission) => typeof permission === 'string') &&
(value.mustChangePassword === undefined || typeof value.mustChangePassword === 'boolean')
);
}
/** 兼容旧版本地会话(无 mustChangePassword 字段) */
function normalizeSession(session: AuthSession): AuthSession {
return { ...session, mustChangePassword: session.mustChangePassword };
}
function readPersistedSession(): AuthSession {
if (typeof window === 'undefined') {
return EMPTY_SESSION;
@@ -80,7 +96,7 @@ function readPersistedSession(): AuthSession {
return EMPTY_SESSION;
}
const parsed: unknown = JSON.parse(raw);
return isAuthSession(parsed) ? parsed : EMPTY_SESSION;
return isAuthSession(parsed) ? normalizeSession(parsed) : EMPTY_SESSION;
} catch {
return EMPTY_SESSION;
}
@@ -101,6 +117,22 @@ function persistSession(session: AuthSession): void {
}
}
/** /me 响应 → 顶栏展示所需的轻量用户 */
export function authUserFromMe(me: MeResponse): AuthUser {
return {
id: me.user.id,
name: me.user.name,
account: me.user.account,
roleName: me.roles[0]?.name,
};
}
/** 是否为演示会话(无后端联调时仍可浏览页面) */
export function isDemoSession(accessToken: string | null): boolean {
return accessToken === DEMO_ACCESS_TOKEN;
}
/* eslint-disable-next-line max-lines-per-function -- 会话状态机集中定义,便于统一审阅持久化与权限映射 */
export const useAuthStore = create<AuthState>()((set, get) => ({
...readPersistedSession(),
setSession: (input) => {
@@ -109,6 +141,7 @@ export const useAuthStore = create<AuthState>()((set, get) => ({
refreshToken: input.refreshToken ?? null,
user: input.user,
permissions: [...(input.permissions ?? PERMISSION_CODES)],
mustChangePassword: input.mustChangePassword === true,
};
persistSession(session);
set(session);
@@ -123,16 +156,34 @@ export const useAuthStore = create<AuthState>()((set, get) => ({
persistSession(session);
set({ accessToken: session.accessToken, refreshToken: session.refreshToken });
},
syncFromMe: (me) => {
const current = get();
const session: AuthSession = {
...current,
user: authUserFromMe(me),
permissions: [...me.permissions],
mustChangePassword: me.mustChangePassword,
};
persistSession(session);
set(session);
},
clearSession: () => {
persistSession(EMPTY_SESSION);
set(EMPTY_SESSION);
set({ ...EMPTY_SESSION, permissions: [] });
},
markPasswordChanged: () => {
const current = get();
const session: AuthSession = { ...current, mustChangePassword: false };
persistSession(session);
set({ mustChangePassword: false });
},
signInDemo: () => {
const session: AuthSession = {
accessToken: 'demo-access-token',
accessToken: DEMO_ACCESS_TOKEN,
refreshToken: 'demo-refresh-token',
user: DEMO_USER,
permissions: [...PERMISSION_CODES],
mustChangePassword: false,
};
persistSession(session);
set(session);
+331 -89
View File
@@ -1,157 +1,357 @@
/* 方向 A · 云白蓝:登录页与业务页 */
/* ---------- 登录页 ---------- */
/* ---------- 登录页(方案 B:左右分屏) ---------- */
.lx-login {
position: relative;
display: flex;
min-height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 96px var(--lx-space-6) 72px;
background:
radial-gradient(circle at 50% 0%, #eef5ff 0%, transparent 46%), var(--lx-color-bg-app);
background: var(--lx-color-bg-app);
}
.lx-login__topbar {
position: absolute;
top: 0;
right: 0;
left: 0;
.lx-login__aside {
position: relative;
display: flex;
height: 72px;
align-items: center;
justify-content: space-between;
padding: 0 var(--lx-space-8);
width: 560px;
flex: 0 0 560px;
flex-direction: column;
overflow: hidden;
padding: 52px 56px 36px;
border-right: 1px solid var(--lx-color-border);
background: var(--lx-color-bg-panel);
}
/* 极淡蓝色同心圆 + 网格装饰(纯 CSS,无位图资源) */
.lx-login__aside::before {
position: absolute;
inset: 0;
background:
repeating-radial-gradient(
circle at 82% 6%,
rgba(22, 119, 255, 0.062) 0 1px,
rgba(22, 119, 255, 0) 1px 36px
),
radial-gradient(720px 520px at 96% -10%, rgba(22, 119, 255, 0.055), transparent 62%);
content: '';
pointer-events: none;
}
.lx-login__aside::after {
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, rgba(22, 119, 255, 0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(22, 119, 255, 0.05) 1px, transparent 1px);
background-size: 44px 44px;
content: '';
mask-image: radial-gradient(ellipse 74% 62% at 78% 0%, #000 0%, transparent 72%);
pointer-events: none;
}
.lx-login__aside > * {
position: relative;
z-index: 1;
}
.lx-login__brand {
display: flex;
align-items: center;
gap: 10px;
gap: 11px;
}
.lx-login__mark {
display: inline-flex;
width: 34px;
height: 34px;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
border-radius: 8px;
background: linear-gradient(135deg, #1677ff, #4096ff);
color: #ffffff;
border-radius: 9px;
background: linear-gradient(135deg, var(--lx-color-brand), var(--lx-color-brand-hover));
color: var(--lx-color-on-brand);
font-size: 18px;
font-weight: 700;
}
.lx-login__brand-text {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.lx-login__wordmark {
font-size: var(--lx-font-size-lg);
font-size: 17px;
font-weight: 600;
color: var(--lx-color-text-primary);
}
.lx-login__brand-sub {
font-size: 11px;
margin-top: 1px;
font-size: 10px;
color: var(--lx-color-text-tertiary);
letter-spacing: 0.06em;
}
.lx-login__badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 10px;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-sm);
background: var(--lx-color-bg-panel);
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
.lx-login__pitch {
margin-top: auto;
margin-bottom: auto;
padding-top: 18px;
}
.lx-login__badge::before {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--lx-color-success);
content: '';
}
.lx-login__panel {
width: 100%;
max-width: 400px;
padding: 32px;
border: 1px solid var(--lx-color-border);
border-radius: 8px;
background: var(--lx-color-bg-panel);
box-shadow: 0 8px 28px rgba(31, 35, 41, 0.08);
}
.lx-login__form {
display: flex;
flex-direction: column;
gap: var(--lx-space-5);
}
.lx-login__form-head h2 {
font-size: 24px;
.lx-login__headline {
text-wrap: pretty;
font-size: 27px;
font-weight: 600;
line-height: 1.3;
line-height: 1.42;
letter-spacing: 0.01em;
color: var(--lx-color-text-primary);
}
.lx-login__form-head p {
margin-top: 6px;
.lx-login__desc {
text-wrap: pretty;
max-width: 404px;
margin-top: 16px;
font-size: var(--lx-font-size-sm);
line-height: 1.85;
color: var(--lx-color-text-secondary);
}
.lx-login__capabilities {
display: flex;
max-width: 430px;
flex-direction: column;
gap: var(--lx-space-4);
margin-top: 28px;
}
.lx-login__capability {
display: flex;
align-items: flex-start;
gap: 12px;
}
.lx-login__capability-icon {
display: inline-flex;
width: 32px;
height: 32px;
flex: 0 0 32px;
align-items: center;
justify-content: center;
margin-top: 1px;
border-radius: 8px;
background: var(--lx-color-brand-soft);
color: var(--lx-color-brand);
}
.lx-login__capability-copy {
display: flex;
min-width: 0;
flex-direction: column;
}
.lx-login__capability-title {
font-size: var(--lx-font-size-sm);
font-weight: 500;
line-height: 19px;
color: var(--lx-color-text-primary);
}
.lx-login__capability-desc {
margin-top: 1px;
font-size: var(--lx-font-size-xs);
line-height: 18px;
color: var(--lx-color-text-tertiary);
}
.lx-login__form form {
/* 素材墙:同一支片子的四种规格,用 CSS 渐变示意 */
.lx-login__samples {
display: flex;
align-items: flex-end;
gap: 10px;
margin-top: 24px;
}
.lx-login__sample {
display: flex;
flex-direction: column;
gap: 5px;
margin: 0;
}
.lx-login__sample-thumb {
display: flex;
align-items: flex-end;
justify-content: flex-start;
padding: 6px;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-sm);
background:
radial-gradient(circle at 30% 24%, rgba(255, 255, 255, 0.9), transparent 46%),
linear-gradient(150deg, #fde8c8, #f7c98b 55%, #eeb26a);
}
.lx-login__sample-thumb--1 {
width: 50px;
aspect-ratio: 9 / 16;
}
.lx-login__sample-thumb--2 {
width: 52px;
aspect-ratio: 1 / 1;
}
.lx-login__sample-thumb--3 {
width: 88px;
aspect-ratio: 16 / 9;
}
.lx-login__sample-thumb--4 {
width: 66px;
aspect-ratio: 4 / 3;
}
.lx-login__sample-ratio {
border-radius: 3px;
background: rgba(31, 35, 41, 0.55);
color: var(--lx-color-text-inverse);
font-size: 10px;
line-height: 14px;
padding: 0 4px;
}
.lx-login__sample-label {
font-size: 10px;
color: var(--lx-color-text-tertiary);
}
.lx-login__aside-foot {
font-size: 11px;
color: var(--lx-color-text-tertiary);
}
.lx-login__main {
position: relative;
display: flex;
flex: 1;
min-width: 0;
align-items: center;
justify-content: center;
}
.lx-login__env {
position: absolute;
top: 32px;
right: 44px;
display: inline-flex;
height: 24px;
align-items: center;
gap: 6px;
padding: 0 9px;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-sm);
background: var(--lx-color-bg-panel);
font-size: 11px;
color: var(--lx-color-text-secondary);
}
.lx-login__env-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--lx-color-success);
}
.lx-login__card {
width: 400px;
padding: 30px 32px 26px;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-lg);
background: var(--lx-color-bg-panel);
box-shadow: var(--lx-shadow-panel);
}
.lx-login__card-head h2 {
font-size: 24px;
font-weight: 600;
line-height: 1.32;
color: var(--lx-color-text-primary);
}
.lx-login__card-head p {
margin-top: 6px;
margin-bottom: 22px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
}
.lx-login__card form {
display: flex;
flex-direction: column;
gap: var(--lx-space-4);
}
.lx-login__form-meta {
.lx-login__alert {
display: flex;
align-items: flex-start;
gap: 7px;
padding: 9px 11px;
border: 1px solid color-mix(in srgb, var(--lx-color-danger) 24%, white);
border-radius: var(--lx-radius-md);
background: color-mix(in srgb, var(--lx-color-danger) 9%, white);
color: color-mix(in srgb, var(--lx-color-danger) 82%, black);
font-size: var(--lx-font-size-xs);
line-height: 18px;
}
.lx-login__alert--success {
border-color: color-mix(in srgb, var(--lx-color-success) 26%, white);
background: color-mix(in srgb, var(--lx-color-success) 10%, white);
color: color-mix(in srgb, var(--lx-color-success) 78%, black);
}
.lx-login__alert--warn {
border-color: color-mix(in srgb, var(--lx-color-warning) 26%, white);
background: color-mix(in srgb, var(--lx-color-warning) 10%, white);
color: color-mix(in srgb, var(--lx-color-warning) 78%, black);
}
.lx-login__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--lx-space-3);
font-size: var(--lx-font-size-xs);
}
.lx-login__eye {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--lx-radius-sm);
color: var(--lx-color-text-tertiary);
}
.lx-login__form-foot {
display: flex;
align-items: flex-start;
gap: 6px;
padding-top: var(--lx-space-4);
border-top: 1px solid var(--lx-color-border);
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
.lx-login__eye:hover {
color: var(--lx-color-text-secondary);
background: var(--lx-color-bg-hover);
}
.lx-login__form-foot-dot {
width: 5px;
height: 5px;
flex: 0 0 5px;
margin-top: 6px;
border-radius: 50%;
background: var(--lx-color-border-strong);
.lx-login__help {
margin-top: 14px;
font-size: 11px;
line-height: 17px;
text-align: center;
color: var(--lx-color-text-tertiary);
}
.lx-login__footer {
position: absolute;
right: 0;
bottom: 0;
left: 0;
right: 44px;
bottom: 26px;
left: 44px;
display: flex;
height: 56px;
align-items: center;
justify-content: space-between;
padding: 0 var(--lx-space-8);
font-size: var(--lx-font-size-xs);
font-size: 11px;
color: var(--lx-color-text-tertiary);
}
@@ -160,6 +360,25 @@
gap: var(--lx-space-4);
}
@media (width <= 1180px) {
.lx-login__aside {
width: 440px;
flex-basis: 440px;
padding: 44px 40px 32px;
}
.lx-login__headline {
text-wrap: pretty;
font-size: 24px;
}
}
@media (width <= 900px) {
.lx-login__aside {
display: none;
}
}
/* ---------- 工作台 ---------- */
.lx-home {
@@ -549,3 +768,26 @@
align-items: flex-start;
}
}
/* 登录页:账号停用等重定向原因提示 */
.lx-login__notice {
width: 100%;
max-width: 400px;
margin-bottom: var(--lx-space-3);
padding: 10px 12px;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-md);
background: var(--lx-color-bg-app);
color: var(--lx-color-text-primary);
font-size: 12px;
line-height: 1.6;
display: flex;
align-items: flex-start;
gap: 8px;
}
.lx-login__notice svg {
flex: none;
margin-top: 2px;
color: var(--lx-color-warning);
}
+958
View File
@@ -0,0 +1,958 @@
/* A1 · 用户与组织(方案 B:主从分栏 + 分屏登录) */
/* ---------- 页面骨架 ---------- */
.lx-page--flush {
display: flex;
flex-direction: column;
overflow: hidden;
}
.lx-users,
.lx-new-user {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: var(--lx-space-4);
}
.lx-users__header {
display: flex;
flex: 0 0 auto;
align-items: flex-start;
justify-content: space-between;
gap: var(--lx-space-4);
}
.lx-users__header h1 {
font-size: 20px;
font-weight: 600;
line-height: 28px;
color: var(--lx-color-text-primary);
}
.lx-users__header p {
margin-top: 2px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
}
.lx-users__split {
display: flex;
min-height: 0;
flex: 1;
gap: var(--lx-space-4);
}
/* ---------- 筛选条 ---------- */
.lx-user-filters {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--lx-space-3);
padding: 12px var(--lx-space-4);
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-lg);
background: var(--lx-color-bg-panel);
box-shadow: var(--lx-shadow-panel);
}
.lx-user-filters__keyword {
width: 252px;
flex: 0 0 252px;
}
.lx-user-filters__select {
width: 168px;
flex: 0 0 168px;
}
.lx-user-filters__control {
width: 100%;
}
.lx-seg {
border: 1px solid var(--lx-color-border);
display: flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: var(--lx-radius-md);
background: var(--lx-color-bg-hover);
}
.lx-seg__item {
height: 28px;
padding: 0 12px;
border-radius: var(--lx-radius-sm);
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
}
.lx-seg__item:hover {
color: var(--lx-color-text-primary);
}
.lx-seg__item.is-active {
background: var(--lx-color-bg-panel);
color: var(--lx-color-text-primary);
font-weight: 500;
box-shadow: var(--lx-shadow-panel);
}
/* ---------- 左侧列表 ---------- */
.lx-user-list {
display: flex;
width: 400px;
flex: 0 0 400px;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-lg);
background: var(--lx-color-bg-panel);
box-shadow: var(--lx-shadow-panel);
}
.lx-user-list__head {
display: flex;
height: 48px;
flex: 0 0 48px;
align-items: center;
justify-content: space-between;
padding: 0 var(--lx-space-4);
border-bottom: 1px solid var(--lx-color-border);
}
.lx-user-list__title {
font-size: var(--lx-font-size-sm);
font-weight: 600;
color: var(--lx-color-text-primary);
}
.lx-user-list__sort {
display: flex;
align-items: center;
}
.lx-user-list__body {
min-height: 0;
flex: 1;
overflow-y: auto;
}
.lx-user-list__state {
padding: var(--lx-space-5);
}
.lx-user-list__items {
display: flex;
flex-direction: column;
}
.lx-user-row {
display: flex;
width: 100%;
min-height: 64px;
align-items: center;
gap: 10px;
padding: 8px var(--lx-space-4);
border-bottom: 1px solid var(--lx-color-border);
text-align: left;
transition: background var(--lx-motion-fast) var(--lx-ease-out);
}
.lx-user-row:last-child {
border-bottom: none;
}
.lx-user-row:hover {
background: var(--lx-color-bg-hover);
}
.lx-user-row.is-selected {
background: var(--lx-color-brand-soft);
box-shadow: inset 2px 0 0 var(--lx-color-brand);
}
.lx-user-row__avatar {
display: inline-flex;
width: 32px;
height: 32px;
flex: 0 0 32px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--lx-color-brand-soft);
color: var(--lx-color-brand);
font-size: var(--lx-font-size-xs);
font-weight: 500;
}
.lx-user-row__avatar.is-disabled {
background: var(--lx-color-bg-hover);
color: var(--lx-color-text-tertiary);
}
.lx-user-row__main {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 2px;
}
.lx-user-row__name {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--lx-font-size-sm);
font-weight: 500;
color: var(--lx-color-text-primary);
}
.lx-user-row.is-selected .lx-user-row__name {
color: var(--lx-color-brand);
}
.lx-user-row__phone {
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
font-variant-numeric: tabular-nums;
}
.lx-user-row__side {
display: flex;
width: 142px;
flex: 0 0 142px;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.lx-user-row__org {
overflow: hidden;
max-width: 142px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.lx-user-row__roles {
display: inline-flex;
gap: 4px;
}
.lx-role-tag {
display: inline-flex;
height: 20px;
align-items: center;
padding: 0 6px;
border-radius: var(--lx-radius-sm);
background: var(--lx-color-bg-hover);
color: var(--lx-color-text-secondary);
font-size: 11px;
line-height: 20px;
white-space: nowrap;
}
.lx-role-tag--more {
color: var(--lx-color-text-tertiary);
}
.lx-status-tag {
display: inline-flex;
height: 22px;
align-items: center;
gap: 5px;
padding: 0 7px;
border-radius: var(--lx-radius-sm);
font-size: 11px;
line-height: 22px;
white-space: nowrap;
}
.lx-status-tag.is-active {
background: color-mix(in srgb, var(--lx-color-success) 12%, white);
color: color-mix(in srgb, var(--lx-color-success) 82%, black);
}
.lx-status-tag.is-disabled {
background: var(--lx-color-bg-hover);
color: var(--lx-color-text-secondary);
}
.lx-status-tag__dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
}
.lx-status-dot {
display: inline-flex;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--lx-color-success);
}
.lx-status-dot.is-disabled {
background: var(--lx-color-border-strong);
}
.lx-status-dot .lx-status-tag__dot {
display: none;
}
.lx-user-list__footer {
display: flex;
height: 48px;
flex: 0 0 48px;
align-items: center;
justify-content: space-between;
padding: 0 var(--lx-space-4);
border-top: 1px solid var(--lx-color-border);
}
.lx-user-list__range {
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
font-variant-numeric: tabular-nums;
}
.lx-user-list__pager {
display: flex;
gap: var(--lx-space-2);
}
/* ---------- 右侧详情 ---------- */
.lx-user-detail {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-lg);
background: var(--lx-color-bg-panel);
box-shadow: var(--lx-shadow-panel);
}
.lx-user-detail__empty {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
}
.lx-user-detail__head {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--lx-space-4);
padding: var(--lx-space-4) var(--lx-space-5);
border-bottom: 1px solid var(--lx-color-border);
}
.lx-user-detail__avatar {
display: inline-flex;
width: 48px;
height: 48px;
flex: 0 0 48px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--lx-color-brand-soft);
color: var(--lx-color-brand);
font-size: 17px;
font-weight: 600;
}
.lx-user-detail__avatar.is-disabled {
background: var(--lx-color-bg-hover);
color: var(--lx-color-text-secondary);
}
.lx-user-detail__ident {
min-width: 0;
flex: 1;
}
.lx-user-detail__name {
display: inline-flex;
align-items: center;
gap: var(--lx-space-2);
}
.lx-user-detail__name h2 {
font-size: var(--lx-font-size-lg);
font-weight: 600;
line-height: 24px;
color: var(--lx-color-text-primary);
}
.lx-user-detail__meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
font-variant-numeric: tabular-nums;
}
.lx-user-detail__dot {
color: var(--lx-color-border-strong);
}
.lx-user-detail__actions {
display: flex;
flex: 0 0 auto;
gap: var(--lx-space-2);
}
.lx-user-detail__tabs {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.lx-user-detail__tabs .semi-tabs-content {
min-height: 0;
flex: 1;
overflow-y: auto;
}
.lx-user-detail__tabs .semi-tabs-pane {
padding: var(--lx-space-5);
}
.lx-user-detail__state {
padding: var(--lx-space-5);
}
/* Tab 内容 */
.lx-detail-tab {
display: flex;
flex-direction: column;
gap: var(--lx-space-5);
}
.lx-detail-tab__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--lx-space-4);
}
.lx-detail-tab__hint {
font-size: var(--lx-font-size-xs);
line-height: 18px;
color: var(--lx-color-text-tertiary);
}
.lx-detail-section {
display: flex;
flex-direction: column;
gap: var(--lx-space-3);
}
.lx-detail-section__title {
font-size: var(--lx-font-size-sm);
font-weight: 600;
color: var(--lx-color-text-primary);
}
.lx-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--lx-space-3) var(--lx-space-6);
}
.lx-kvi {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.lx-kvi__label {
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
}
.lx-kvi__value {
font-size: var(--lx-font-size-sm);
color: var(--lx-color-text-primary);
font-variant-numeric: tabular-nums;
word-break: break-all;
}
.lx-role-chips {
display: flex;
flex-wrap: wrap;
gap: var(--lx-space-2);
}
.lx-role-chip {
display: inline-flex;
height: 30px;
align-items: center;
gap: 6px;
padding: 0 12px;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-md);
background: var(--lx-color-bg-subtle);
font-size: var(--lx-font-size-sm);
color: var(--lx-color-text-primary);
}
.lx-role-chip__badge {
padding: 0 5px;
border-radius: var(--lx-radius-sm);
background: var(--lx-color-brand-soft);
color: var(--lx-color-brand);
font-size: 10px;
line-height: 16px;
}
.lx-customer-list {
display: flex;
flex-direction: column;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-md);
overflow: hidden;
}
.lx-customer-item {
display: flex;
height: 50px;
align-items: center;
gap: var(--lx-space-3);
padding: 0 var(--lx-space-4);
border-bottom: 1px solid var(--lx-color-border);
}
.lx-customer-item:last-child {
border-bottom: none;
}
.lx-customer-item__icon {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border-radius: var(--lx-radius-sm);
background: var(--lx-color-brand-soft);
color: var(--lx-color-brand);
}
.lx-customer-item__name {
flex: 1;
font-size: var(--lx-font-size-sm);
color: var(--lx-color-text-primary);
}
.lx-timeline {
display: flex;
flex-direction: column;
gap: 0;
padding-left: var(--lx-space-3);
border-left: 1px solid var(--lx-color-border);
}
.lx-timeline__item {
position: relative;
display: flex;
gap: var(--lx-space-3);
padding-bottom: var(--lx-space-4);
}
.lx-timeline__dot {
position: absolute;
top: 6px;
left: calc(-1 * var(--lx-space-3) - 5px);
width: 9px;
height: 9px;
border: 2px solid var(--lx-color-border-strong);
border-radius: 50%;
background: var(--lx-color-bg-panel);
}
.lx-timeline__dot.is-latest {
border-color: var(--lx-color-brand);
}
.lx-timeline__main {
display: flex;
flex-direction: column;
gap: 2px;
}
.lx-timeline__title {
font-size: var(--lx-font-size-sm);
color: var(--lx-color-text-primary);
}
.lx-timeline__meta {
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
font-variant-numeric: tabular-nums;
}
/* ---------- 弹窗 ---------- */
.lx-confirm-dialog {
display: flex;
flex-direction: column;
gap: var(--lx-space-4);
}
.lx-confirm-dialog__object {
display: flex;
align-items: center;
gap: var(--lx-space-3);
padding: var(--lx-space-3);
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-md);
background: var(--lx-color-bg-subtle);
}
.lx-confirm-dialog__object-icon {
display: inline-flex;
width: 36px;
height: 36px;
flex: 0 0 36px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--lx-color-brand-soft);
color: var(--lx-color-brand);
font-size: var(--lx-font-size-sm);
font-weight: 500;
}
.lx-confirm-dialog__object-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.lx-confirm-dialog__object-title {
font-size: var(--lx-font-size-sm);
font-weight: 500;
color: var(--lx-color-text-primary);
}
.lx-confirm-dialog__object-meta {
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
font-variant-numeric: tabular-nums;
}
.lx-confirm-dialog__warn {
display: flex;
align-items: flex-start;
gap: var(--lx-space-2);
padding: 10px 12px;
border-radius: var(--lx-radius-md);
background: color-mix(in srgb, var(--lx-color-danger) 8%, white);
color: color-mix(in srgb, var(--lx-color-danger) 80%, black);
font-size: var(--lx-font-size-xs);
line-height: 18px;
}
.lx-confirm-dialog__warn.is-info {
background: var(--lx-color-brand-soft);
color: var(--lx-color-brand);
}
.lx-confirm-dialog__warn ul {
display: flex;
flex-direction: column;
gap: 2px;
}
.lx-confirm-dialog__ack {
padding-top: var(--lx-space-3);
border-top: 1px solid var(--lx-color-border);
font-size: var(--lx-font-size-xs);
}
.lx-check-list {
display: flex;
flex-direction: column;
gap: 6px;
padding: var(--lx-space-3);
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-md);
}
.lx-check-list__item {
display: flex;
align-items: center;
gap: var(--lx-space-2);
font-size: var(--lx-font-size-sm);
color: var(--lx-color-text-primary);
}
.lx-edit-dialog {
display: flex;
flex-direction: column;
gap: var(--lx-space-3);
}
.lx-role-picker {
display: flex;
flex-wrap: wrap;
gap: var(--lx-space-2);
}
.lx-role-picker__item {
display: inline-flex;
height: 30px;
align-items: center;
gap: 6px;
padding: 0 11px;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-md);
background: var(--lx-color-bg-panel);
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
transition:
border-color var(--lx-motion-fast) var(--lx-ease-out),
background var(--lx-motion-fast) var(--lx-ease-out);
}
.lx-role-picker__item:hover {
border-color: var(--lx-color-border-strong);
color: var(--lx-color-text-primary);
}
.lx-role-picker__item.is-active {
border-color: var(--lx-color-brand);
background: var(--lx-color-brand-soft);
color: var(--lx-color-brand);
font-weight: 500;
}
.lx-role-picker__badge {
color: var(--lx-color-text-tertiary);
font-size: 10px;
}
/* ---------- 新建用户 ---------- */
.lx-new-user__form {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: var(--lx-space-4);
padding: var(--lx-space-5);
overflow-y: auto;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-lg);
background: var(--lx-color-bg-panel);
box-shadow: var(--lx-shadow-panel);
}
.lx-new-user__section {
display: flex;
flex-direction: column;
gap: var(--lx-space-3);
padding-bottom: var(--lx-space-4);
border-bottom: 1px solid var(--lx-color-border);
}
.lx-new-user__section:last-of-type {
border-bottom: none;
}
.lx-new-user__section-title {
display: flex;
align-items: center;
gap: var(--lx-space-2);
font-size: var(--lx-font-size-sm);
font-weight: 600;
color: var(--lx-color-text-primary);
}
.lx-new-user__section-index {
display: inline-flex;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--lx-color-brand-soft);
color: var(--lx-color-brand);
font-size: 11px;
}
.lx-new-user__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--lx-space-3) var(--lx-space-5);
}
.lx-new-user__full,
.lx-new-user__roles {
grid-column: 1 / -1;
}
.lx-new-user__password {
display: flex;
gap: var(--lx-space-2);
}
.lx-new-user__password .semi-input-wrapper {
flex: 1;
}
.lx-new-user__note {
font-size: var(--lx-font-size-xs);
line-height: 18px;
color: var(--lx-color-text-tertiary);
}
.lx-new-user__footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--lx-space-2);
padding-top: var(--lx-space-3);
border-top: 1px solid var(--lx-color-border);
}
/* ---------- 通用字段与认证态 ---------- */
.lx-field {
margin-bottom: 0;
}
.lx-field__error {
margin-top: 2px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-danger);
}
.lx-field__required {
margin-left: 3px;
color: var(--lx-color-danger);
}
.lx-field__optional {
margin-left: 6px;
font-size: 11px;
font-weight: 400;
color: var(--lx-color-text-tertiary);
}
.lx-auth-loading {
display: flex;
min-height: 100vh;
align-items: center;
justify-content: center;
padding: var(--lx-space-8);
background: var(--lx-color-bg-app);
}
.lx-must-change {
display: flex;
flex-direction: column;
gap: var(--lx-space-3);
}
.lx-must-change__hint {
display: flex;
align-items: flex-start;
gap: var(--lx-space-2);
padding: 10px 12px;
border-radius: var(--lx-radius-md);
background: var(--lx-color-brand-soft);
color: var(--lx-color-brand);
font-size: var(--lx-font-size-xs);
line-height: 18px;
}
.lx-must-change__hint.is-muted {
background: var(--lx-color-bg-hover);
color: var(--lx-color-text-secondary);
}
.lx-must-change__error {
font-size: var(--lx-font-size-xs);
color: var(--lx-color-danger);
}
.lx-must-change__footer {
display: flex;
justify-content: flex-end;
}
/* ---------- 响应式:1280 / 1024 ---------- */
@media (width <= 1280px) {
.lx-user-list {
width: 340px;
flex-basis: 340px;
}
.lx-user-filters__keyword {
width: 200px;
flex-basis: 200px;
}
.lx-user-filters__select {
width: 148px;
flex-basis: 148px;
}
.lx-user-detail__head,
.lx-user-detail__tabs .semi-tabs-pane {
padding-right: var(--lx-space-4);
padding-left: var(--lx-space-4);
}
}
@media (width <= 1080px) {
.lx-user-filters {
flex-wrap: wrap;
}
.lx-user-list {
width: 300px;
flex-basis: 300px;
}
.lx-user-row__side {
width: 116px;
flex-basis: 116px;
}
.lx-kv-grid,
.lx-new-user__grid {
grid-template-columns: minmax(0, 1fr);
}
.lx-user-detail__meta {
flex-wrap: wrap;
}
}
@media (prefers-reduced-motion: reduce) {
.lx-user-row,
.lx-role-picker__item {
transition: none;
}
}