feat: complete login and user management flow
This commit is contained in:
+73
-6
@@ -1,3 +1,5 @@
|
||||
import { getSessionTenantId, readSession, type LoginSession } from './session';
|
||||
|
||||
type RequestOptions = RequestInit & {
|
||||
tenantId?: string;
|
||||
};
|
||||
@@ -7,8 +9,13 @@ export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a';
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set('Content-Type', 'application/json');
|
||||
if (options.tenantId) {
|
||||
headers.set('x-tenant-id', options.tenantId);
|
||||
const session = readSession();
|
||||
if (session?.accessToken) {
|
||||
headers.set('Authorization', `${session.tokenType} ${session.accessToken}`);
|
||||
}
|
||||
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
|
||||
if (tenantId) {
|
||||
headers.set('x-tenant-id', tenantId);
|
||||
}
|
||||
const response = await fetch(`/api${path}`, { ...options, headers });
|
||||
if (!response.ok) {
|
||||
@@ -83,6 +90,40 @@ export type TenantOption = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CaptchaResponse = {
|
||||
captchaId: string;
|
||||
challenge: string;
|
||||
expiresInSeconds: number;
|
||||
};
|
||||
|
||||
export type ManagedUser = {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
username: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
displayName: string;
|
||||
status: string;
|
||||
failedLoginCount: number;
|
||||
lockedUntil?: string | null;
|
||||
lastLoginAt?: string | null;
|
||||
createdAt: string;
|
||||
tenant?: TenantOption | null;
|
||||
roles: Array<{ role: { code: string; name: string; scope: string } }>;
|
||||
};
|
||||
|
||||
export type UserPayload = {
|
||||
tenantId?: string | null;
|
||||
username?: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
displayName: string;
|
||||
password?: string;
|
||||
status?: string;
|
||||
roleCode: 'platform_admin' | 'enterprise_admin';
|
||||
operatorId?: string;
|
||||
};
|
||||
|
||||
export type DashboardResponse = {
|
||||
taskCount: number;
|
||||
messageStatus: Array<{ status: string; _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }>;
|
||||
@@ -223,7 +264,18 @@ function withQuery(path: string, query: Record<string, string | number | undefin
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
getCaptcha: () => request<CaptchaResponse>('/admin/auth/captcha'),
|
||||
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
|
||||
request<LoginSession>('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listTenants: () => request<TenantOption[]>('/admin/tenants'),
|
||||
listUsers: (query: { tenantId?: string; roleCode?: string } = {}) => request<ManagedUser[]>(withQuery('/admin/users', query)),
|
||||
createUser: (body: UserPayload) => request<ManagedUser>('/admin/users', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateUser: (id: string, body: Omit<UserPayload, 'password'>) => request<ManagedUser>(`/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
changeUserStatus: (id: string, status: string, operatorId?: string) =>
|
||||
request<ManagedUser>(`/admin/users/${id}/status`, { method: 'POST', body: JSON.stringify({ status, operatorId }) }),
|
||||
deleteUser: (id: string, operatorId?: string) => request<ManagedUser>(`/admin/users/${id}`, { method: 'DELETE', body: JSON.stringify({ operatorId }) }),
|
||||
changeUserPassword: (id: string, password: string, operatorId?: string) =>
|
||||
request<ManagedUser>(`/admin/users/${id}/password`, { method: 'POST', body: JSON.stringify({ password, operatorId }) }),
|
||||
getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
||||
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
|
||||
request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
|
||||
@@ -293,12 +345,27 @@ export const adminApi = {
|
||||
};
|
||||
|
||||
export const clientApi = {
|
||||
getDashboard: (tenantId = DEFAULT_CLIENT_TENANT_ID) =>
|
||||
getCaptcha: () => request<CaptchaResponse>('/client/auth/captcha'),
|
||||
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
|
||||
request<LoginSession>('/client/auth/login', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listUsers: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ManagedUser[]>('/client/users', { tenantId }),
|
||||
createUser: (body: UserPayload, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ManagedUser>('/client/users', { method: 'POST', tenantId, body: JSON.stringify(body) }),
|
||||
updateUser: (id: string, body: Omit<UserPayload, 'password'>, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ManagedUser>(`/client/users/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
|
||||
changeUserStatus: (id: string, status: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ManagedUser>(`/client/users/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status, operatorId }) }),
|
||||
deleteUser: (id: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ManagedUser>(`/client/users/${id}`, { method: 'DELETE', tenantId, body: JSON.stringify({ operatorId }) }),
|
||||
changeUserPassword: (id: string, password: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ManagedUser>(`/client/users/${id}/password`, { method: 'POST', tenantId, body: JSON.stringify({ password, operatorId }) }),
|
||||
getDashboard: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<DashboardResponse>('/client/operations/dashboard', { tenantId }),
|
||||
listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = DEFAULT_CLIENT_TENANT_ID) =>
|
||||
listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }),
|
||||
listTransactions: (tenantId = DEFAULT_CLIENT_TENANT_ID) =>
|
||||
listTransactions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<AccountTransaction[]>('/client/billing/transactions', { tenantId }),
|
||||
listOrders: (tenantId = DEFAULT_CLIENT_TENANT_ID) =>
|
||||
listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<RechargeOrder[]>('/client/billing/orders', { tenantId }),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export type Portal = 'admin' | 'client';
|
||||
|
||||
export type SessionUser = {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
tenantName?: string | null;
|
||||
username: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
};
|
||||
|
||||
export type LoginSession = {
|
||||
accessToken: string;
|
||||
tokenType: string;
|
||||
portal: Portal;
|
||||
user: SessionUser;
|
||||
};
|
||||
|
||||
const sessionKey = 'cmpp-auth-session';
|
||||
|
||||
export function readSession(): LoginSession | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(sessionKey);
|
||||
return raw ? JSON.parse(raw) as LoginSession : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSession(session: LoginSession) {
|
||||
window.localStorage.setItem(sessionKey, JSON.stringify(session));
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
window.localStorage.removeItem(sessionKey);
|
||||
}
|
||||
|
||||
export function getSessionTenantId() {
|
||||
return readSession()?.user.tenantId ?? undefined;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { adminApi, clientApi, type CaptchaResponse } from '@/api/adminApi';
|
||||
import { writeSession, type Portal } from '@/api/session';
|
||||
import { Button, Input } from '@/components/ui';
|
||||
|
||||
type LoginPageProps = {
|
||||
portal: Portal;
|
||||
};
|
||||
|
||||
export function LoginPage({ portal }: LoginPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const [login, setLogin] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [captchaText, setCaptchaText] = useState('');
|
||||
const [captcha, setCaptcha] = useState<CaptchaResponse | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isAdmin = portal === 'admin';
|
||||
|
||||
async function refreshCaptcha() {
|
||||
setError('');
|
||||
setCaptchaText('');
|
||||
setCaptcha(await (isAdmin ? adminApi.getCaptcha() : clientApi.getCaptcha()));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCaptcha();
|
||||
}, [portal]);
|
||||
|
||||
async function submit() {
|
||||
if (!captcha) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const session = await (isAdmin ? adminApi.login : clientApi.login)({
|
||||
login,
|
||||
password,
|
||||
captchaId: captcha.captchaId,
|
||||
captchaText,
|
||||
});
|
||||
writeSession(session);
|
||||
navigate(isAdmin ? '/admin' : '/client', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败');
|
||||
await refreshCaptcha();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<section className="login-panel">
|
||||
<div className="login-brand">
|
||||
<span><ShieldCheck size={28} /></span>
|
||||
<div>
|
||||
<h1>{isAdmin ? 'CMPP 运营端' : 'CMPP 客户端'}</h1>
|
||||
<p>{isAdmin ? '平台管理员登录' : '企业管理员登录'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-form">
|
||||
<Input label="邮箱或手机号" onChange={(event) => setLogin(event.target.value)} placeholder="请输入邮箱或手机号" value={login} />
|
||||
<Input label="密码" onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" type="password" value={password} />
|
||||
<div className="login-captcha-row">
|
||||
<Input label="图形验证码" onChange={(event) => setCaptchaText(event.target.value)} placeholder="请输入计算结果" value={captchaText} />
|
||||
<button className="login-captcha" onClick={() => void refreshCaptcha()} type="button">
|
||||
{captcha?.challenge ?? '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
{error ? <p className="login-error">{error}</p> : null}
|
||||
<Button disabled={loading} onClick={submit}>{loading ? '登录中...' : '登录'}</Button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+185
-131
@@ -1,155 +1,166 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { KeyRound, Plus, Search } from 'lucide-react';
|
||||
import { adminApi, type ManagedUser, type TenantOption, type UserPayload } from '@/api/adminApi';
|
||||
import { readSession } from '@/api/session';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
type UserStatus = 'enabled' | 'disabled';
|
||||
|
||||
type AdminUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
account: string;
|
||||
role: string;
|
||||
type UserForm = {
|
||||
tenantId: string;
|
||||
displayName: string;
|
||||
username: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
status: UserStatus;
|
||||
createdAt: string;
|
||||
lastLogin: string;
|
||||
roleCode: 'platform_admin' | 'enterprise_admin';
|
||||
status: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<UserStatus, string> = {
|
||||
enabled: '启用',
|
||||
disabled: '停用',
|
||||
type ConfirmAction = {
|
||||
type: 'status' | 'delete';
|
||||
user: ManagedUser;
|
||||
};
|
||||
|
||||
const statusToneMap: Record<UserStatus, 'success' | 'neutral'> = {
|
||||
enabled: 'success',
|
||||
disabled: 'neutral',
|
||||
const emptyForm: UserForm = {
|
||||
tenantId: '',
|
||||
displayName: '',
|
||||
username: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
roleCode: 'platform_admin',
|
||||
status: 'active',
|
||||
password: '',
|
||||
};
|
||||
|
||||
const initialUsers: AdminUser[] = [
|
||||
{ id: 'USR20260630001', name: '李明', account: 'liming', role: '平台管理员', phone: '13800001234', status: 'enabled', createdAt: '2026-06-01 09:20:10', lastLogin: '2026-06-30 09:15:22' },
|
||||
{ id: 'USR20260630002', name: '张青', account: 'zhangqing', role: '审核专员', phone: '13900005678', status: 'enabled', createdAt: '2026-06-03 14:05:36', lastLogin: '2026-06-29 18:22:11' },
|
||||
{ id: 'USR20260630003', name: '王珊', account: 'wangshan', role: '运营人员', phone: '13755558888', status: 'disabled', createdAt: '2026-06-08 11:12:48', lastLogin: '2026-06-21 10:08:09' },
|
||||
{ id: 'USR20260630004', name: '赵一', account: 'zhaoyi', role: '财务人员', phone: '13677779999', status: 'enabled', createdAt: '2026-06-12 16:30:00', lastLogin: '2026-06-30 08:40:18' },
|
||||
];
|
||||
|
||||
function createUserId() {
|
||||
return `USR${Date.now()}`;
|
||||
}
|
||||
|
||||
type UserFormModalProps = {
|
||||
item?: AdminUser;
|
||||
onClose: () => void;
|
||||
onSubmit: (item: AdminUser) => void;
|
||||
const roleLabel: Record<string, string> = {
|
||||
platform_admin: '平台管理员',
|
||||
enterprise_admin: '企业管理员',
|
||||
};
|
||||
|
||||
function UserFormModal({ item, onClose, onSubmit }: UserFormModalProps) {
|
||||
const [form, setForm] = useState<AdminUser>(() => item ?? {
|
||||
id: createUserId(),
|
||||
name: '',
|
||||
account: '',
|
||||
role: '运营人员',
|
||||
phone: '',
|
||||
status: 'enabled',
|
||||
createdAt: '2026-06-30 10:00:00',
|
||||
lastLogin: '-',
|
||||
});
|
||||
|
||||
function updateField<Key extends keyof AdminUser>(key: Key, value: AdminUser[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
onSubmit(form);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button onClick={handleSubmit}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
title={item ? '编辑用户' : '新增用户'}
|
||||
>
|
||||
<div className="admin-system-modal-form">
|
||||
<Input label="用户姓名" onChange={(event) => updateField('name', event.target.value)} value={form.name} />
|
||||
<Input label="登录账号" onChange={(event) => updateField('account', event.target.value)} value={form.account} />
|
||||
<Select
|
||||
label="角色"
|
||||
onChange={(event) => updateField('role', event.target.value)}
|
||||
options={[
|
||||
{ label: '平台管理员', value: '平台管理员' },
|
||||
{ label: '审核专员', value: '审核专员' },
|
||||
{ label: '运营人员', value: '运营人员' },
|
||||
{ label: '财务人员', value: '财务人员' },
|
||||
]}
|
||||
value={form.role}
|
||||
/>
|
||||
<Input label="手机号码" onChange={(event) => updateField('phone', event.target.value)} value={form.phone} />
|
||||
<Select
|
||||
label="状态"
|
||||
onChange={(event) => updateField('status', event.target.value as UserStatus)}
|
||||
options={[
|
||||
{ label: '启用', value: 'enabled' },
|
||||
{ label: '停用', value: 'disabled' },
|
||||
]}
|
||||
value={form.status}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
function toForm(user?: ManagedUser): UserForm {
|
||||
const roleCode = user?.roles[0]?.role.code === 'enterprise_admin' ? 'enterprise_admin' : 'platform_admin';
|
||||
return user ? {
|
||||
tenantId: user.tenantId ?? '',
|
||||
displayName: user.displayName,
|
||||
username: user.username,
|
||||
email: user.email ?? '',
|
||||
phone: user.phone ?? '',
|
||||
roleCode,
|
||||
status: user.status,
|
||||
password: '',
|
||||
} : emptyForm;
|
||||
}
|
||||
|
||||
export function AdminUsersPage() {
|
||||
const [users, setUsers] = useState(initialUsers);
|
||||
const session = readSession();
|
||||
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
|
||||
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState<UserForm>(emptyForm);
|
||||
const [passwordUser, setPasswordUser] = useState<ManagedUser | null>(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const filteredUsers = useMemo(
|
||||
() => users.filter((user) => [user.name, user.account, user.role, user.phone].some((value) => value.includes(keyword))),
|
||||
[keyword, users],
|
||||
);
|
||||
|
||||
function upsertUser(nextUser: AdminUser) {
|
||||
setUsers((current) => {
|
||||
const exists = current.some((item) => item.id === nextUser.id);
|
||||
if (exists) {
|
||||
return current.map((item) => (item.id === nextUser.id ? nextUser : item));
|
||||
}
|
||||
|
||||
return [nextUser, ...current];
|
||||
});
|
||||
setEditingUser(null);
|
||||
setCreating(false);
|
||||
async function load() {
|
||||
const [nextUsers, nextTenants] = await Promise.all([adminApi.listUsers(), adminApi.listTenants()]);
|
||||
setUsers(nextUsers);
|
||||
setTenants(nextTenants);
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<AdminUser>>>(() => [
|
||||
{ key: 'name', title: '用户姓名', width: '150px', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'account', title: '登录账号', width: '160px', render: (record) => record.account },
|
||||
{ key: 'role', title: '角色', width: '150px', render: (record) => record.role },
|
||||
{ key: 'phone', title: '手机号码', width: '150px', render: (record) => record.phone },
|
||||
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag> },
|
||||
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => record.createdAt },
|
||||
{ key: 'lastLogin', title: '最近登录', width: '190px', render: (record) => record.lastLogin },
|
||||
useEffect(() => {
|
||||
void load().catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
|
||||
}, []);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const value = keyword.trim().toLowerCase();
|
||||
return users.filter((user) => {
|
||||
const target = `${user.displayName} ${user.username} ${user.email ?? ''} ${user.phone ?? ''} ${user.tenant?.name ?? ''} ${roleLabel[user.roles[0]?.role.code] ?? ''}`.toLowerCase();
|
||||
return !value || target.includes(value);
|
||||
});
|
||||
}, [keyword, users]);
|
||||
|
||||
function openCreate() {
|
||||
setForm({ ...emptyForm, tenantId: tenants[0]?.id ?? '' });
|
||||
setCreating(true);
|
||||
}
|
||||
|
||||
function openEdit(user: ManagedUser) {
|
||||
setForm(toForm(user));
|
||||
setEditingUser(user);
|
||||
}
|
||||
|
||||
function updateField<Key extends keyof UserForm>(key: Key, value: UserForm[Key]) {
|
||||
setForm((current) => {
|
||||
const next = { ...current, [key]: value };
|
||||
if (key === 'roleCode' && value === 'platform_admin') {
|
||||
next.tenantId = '';
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
setError('');
|
||||
const body: UserPayload = {
|
||||
tenantId: form.roleCode === 'enterprise_admin' ? form.tenantId : null,
|
||||
username: form.username || form.email || form.phone,
|
||||
email: form.email,
|
||||
phone: form.phone,
|
||||
displayName: form.displayName,
|
||||
status: form.status,
|
||||
roleCode: form.roleCode,
|
||||
operatorId: session?.user.id,
|
||||
};
|
||||
if (creating) {
|
||||
await adminApi.createUser({ ...body, password: form.password });
|
||||
} else if (editingUser) {
|
||||
await adminApi.updateUser(editingUser.id, body);
|
||||
}
|
||||
setCreating(false);
|
||||
setEditingUser(null);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function runConfirm() {
|
||||
if (!confirmAction) return;
|
||||
if (confirmAction.type === 'delete') {
|
||||
await adminApi.deleteUser(confirmAction.user.id, session?.user.id);
|
||||
} else {
|
||||
await adminApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id);
|
||||
}
|
||||
setConfirmAction(null);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
if (!passwordUser) return;
|
||||
await adminApi.changeUserPassword(passwordUser.id, newPassword, session?.user.id);
|
||||
setPasswordUser(null);
|
||||
setNewPassword('');
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<ManagedUser>>>(() => [
|
||||
{ key: 'displayName', title: '用户姓名', width: '140px', render: (record) => <strong>{record.displayName}</strong> },
|
||||
{ key: 'account', title: '邮箱/手机号', width: '230px', render: (record) => <span>{record.email ?? '-'}<br /><small className="muted">{record.phone ?? '-'}</small></span> },
|
||||
{ key: 'role', title: '角色', width: '130px', render: (record) => roleLabel[record.roles[0]?.role.code] ?? record.roles[0]?.role.name ?? '-' },
|
||||
{ key: 'tenant', title: '企业', width: '180px', render: (record) => record.tenant?.name ?? '-' },
|
||||
{ key: 'status', title: '状态', width: '110px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '启用' : '禁用'}</Tag> },
|
||||
{ key: 'lastLoginAt', title: '最近登录', width: '190px', render: (record) => record.lastLoginAt ? new Date(record.lastLoginAt).toLocaleString('zh-CN') : '-' },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '170px',
|
||||
width: '280px',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<div className="admin-system-actions">
|
||||
<Button onClick={() => setEditingUser(record)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button
|
||||
onClick={() => setUsers((current) => current.filter((item) => item.id !== record.id))}
|
||||
size="sm"
|
||||
variant="danger"
|
||||
>
|
||||
删除
|
||||
<Button onClick={() => openEdit(record)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost">改密</Button>
|
||||
<Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant="ghost">
|
||||
{record.status === 'active' ? '禁用' : '启用'}
|
||||
</Button>
|
||||
<Button onClick={() => setConfirmAction({ type: 'delete', user: record })} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -165,16 +176,59 @@ export function AdminUsersPage() {
|
||||
</div>
|
||||
|
||||
<div className="surface admin-system-toolbar">
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索姓名、账号、角色或手机号" prefix={<Search size={16} />} value={keyword} />
|
||||
<Button icon={<Plus size={16} />} onClick={() => setCreating(true)}>新增用户</Button>
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索姓名、邮箱、手机号、角色或企业" prefix={<Search size={16} />} value={keyword} />
|
||||
<Button icon={<Plus size={16} />} onClick={openCreate}>新增用户</Button>
|
||||
</div>
|
||||
|
||||
{error ? <div className="surface empty-state">{error}</div> : null}
|
||||
<div className="surface admin-system-table-card">
|
||||
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
|
||||
</div>
|
||||
|
||||
{creating ? <UserFormModal onClose={() => setCreating(false)} onSubmit={upsertUser} /> : null}
|
||||
{editingUser ? <UserFormModal item={editingUser} onClose={() => setEditingUser(null)} onSubmit={upsertUser} /> : null}
|
||||
{(creating || editingUser) ? (
|
||||
<Modal
|
||||
footer={<><Button onClick={() => { setCreating(false); setEditingUser(null); }} variant="ghost">取消</Button><Button onClick={() => void saveUser()}>保存</Button></>}
|
||||
onClose={() => { setCreating(false); setEditingUser(null); }}
|
||||
open
|
||||
title={creating ? '新增用户' : '编辑用户'}
|
||||
>
|
||||
<div className="admin-system-modal-form">
|
||||
<Input label="用户姓名" onChange={(event) => updateField('displayName', event.target.value)} value={form.displayName} />
|
||||
<Input label="邮箱" onChange={(event) => updateField('email', event.target.value)} value={form.email} />
|
||||
<Input label="手机号" onChange={(event) => updateField('phone', event.target.value)} value={form.phone} />
|
||||
<Input label="登录账号" onChange={(event) => updateField('username', event.target.value)} value={form.username} />
|
||||
<Select
|
||||
label="用户类型"
|
||||
onChange={(event) => updateField('roleCode', event.target.value as UserForm['roleCode'])}
|
||||
options={[{ label: '平台管理员', value: 'platform_admin' }, { label: '企业管理员', value: 'enterprise_admin' }]}
|
||||
value={form.roleCode}
|
||||
/>
|
||||
{form.roleCode === 'enterprise_admin' ? (
|
||||
<Select
|
||||
label="关联企业"
|
||||
onChange={(event) => updateField('tenantId', event.target.value)}
|
||||
options={tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))}
|
||||
value={form.tenantId}
|
||||
/>
|
||||
) : null}
|
||||
{creating ? <Input label="初始密码" onChange={(event) => updateField('password', event.target.value)} type="password" value={form.password} /> : null}
|
||||
<Select label="状态" onChange={(event) => updateField('status', event.target.value)} options={[{ label: '启用', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={form.status} />
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{passwordUser ? (
|
||||
<Modal footer={<><Button onClick={() => setPasswordUser(null)} variant="ghost">取消</Button><Button icon={<KeyRound size={16} />} onClick={() => void savePassword()}>保存</Button></>} onClose={() => setPasswordUser(null)} open title="修改密码">
|
||||
<div className="admin-system-modal-form">
|
||||
<Input label="新密码" onChange={(event) => setNewPassword(event.target.value)} type="password" value={newPassword} />
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{confirmAction ? (
|
||||
<Modal footer={<><Button onClick={() => setConfirmAction(null)} variant="ghost">取消</Button><Button onClick={() => void runConfirm()} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>确认</Button></>} onClose={() => setConfirmAction(null)} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>
|
||||
<p>{confirmAction.type === 'delete' ? `确认删除用户 ${confirmAction.user.displayName}?` : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}?`}</p>
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
+146
-113
@@ -1,95 +1,138 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Edit3, Plus, Search, Trash2, Users } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Edit3, KeyRound, Plus, Search, Trash2, Users } from 'lucide-react';
|
||||
import { clientApi, type ManagedUser, type UserPayload } from '@/api/adminApi';
|
||||
import { readSession } from '@/api/session';
|
||||
import { Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
type UserRole = 'enterprise_admin' | 'user';
|
||||
type UserStatus = 'active' | 'disabled';
|
||||
|
||||
type ClientUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
type UserForm = {
|
||||
displayName: string;
|
||||
username: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
role: UserRole;
|
||||
status: UserStatus;
|
||||
lastLoginAt: string;
|
||||
status: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const roleLabelMap: Record<UserRole, string> = {
|
||||
enterprise_admin: '企业管理员',
|
||||
user: '普通用户',
|
||||
type ConfirmAction = {
|
||||
type: 'status' | 'delete';
|
||||
user: ManagedUser;
|
||||
};
|
||||
|
||||
const usersSeed: ClientUser[] = [
|
||||
{ id: 'USER001', name: '张三', email: 'zhangsan@example.com', phone: '13800138000', role: 'enterprise_admin', status: 'active', lastLoginAt: '2026-03-17 09:15:00' },
|
||||
{ id: 'USER002', name: '李四', email: 'lisi@example.com', phone: '13800138001', role: 'user', status: 'active', lastLoginAt: '2026-03-16 16:45:00' },
|
||||
{ id: 'USER003', name: '王五', email: 'wangwu@example.com', phone: '13800138002', role: 'user', status: 'active', lastLoginAt: '2026-03-15 11:30:00' },
|
||||
{ id: 'USER004', name: '赵六', email: 'zhaoliu@example.com', phone: '13800138003', role: 'user', status: 'disabled', lastLoginAt: '2026-02-20 14:00:00' },
|
||||
{ id: 'USER005', name: '孙七', email: 'sunqi@example.com', phone: '13800138004', role: 'user', status: 'active', lastLoginAt: '2026-03-17 08:00:00' },
|
||||
];
|
||||
|
||||
const emptyUser: ClientUser = {
|
||||
id: 'NEW',
|
||||
name: '',
|
||||
const emptyForm: UserForm = {
|
||||
displayName: '',
|
||||
username: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
lastLoginAt: '-',
|
||||
password: '',
|
||||
};
|
||||
|
||||
function toForm(user?: ManagedUser): UserForm {
|
||||
return user ? {
|
||||
displayName: user.displayName,
|
||||
username: user.username,
|
||||
email: user.email ?? '',
|
||||
phone: user.phone ?? '',
|
||||
status: user.status,
|
||||
password: '',
|
||||
} : emptyForm;
|
||||
}
|
||||
|
||||
export function ClientUsersPage() {
|
||||
const session = readSession();
|
||||
const tenantId = session?.user.tenantId ?? undefined;
|
||||
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [editingUser, setEditingUser] = useState<ClientUser | null>(null);
|
||||
const [draft, setDraft] = useState<ClientUser>(emptyUser);
|
||||
const enterpriseAdmin = usersSeed.find((item) => item.role === 'enterprise_admin');
|
||||
const canSelectEnterpriseAdmin = !enterpriseAdmin || editingUser?.id === enterpriseAdmin.id;
|
||||
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState<UserForm>(emptyForm);
|
||||
const [passwordUser, setPasswordUser] = useState<ManagedUser | null>(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const filteredUsers = usersSeed.filter((item) => {
|
||||
const target = `${item.name} ${item.email} ${item.phone}`;
|
||||
return !keyword || target.toLowerCase().includes(keyword.toLowerCase());
|
||||
});
|
||||
|
||||
function openEditor(user?: ClientUser) {
|
||||
const nextUser = user ?? emptyUser;
|
||||
setEditingUser(nextUser);
|
||||
setDraft(nextUser);
|
||||
async function load() {
|
||||
if (!tenantId) return;
|
||||
setUsers(await clientApi.listUsers(tenantId));
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<ClientUser>>>(() => [
|
||||
{ key: 'name', title: '用户名', width: '120px', render: (record) => <strong className="text-strong">{record.name}</strong> },
|
||||
{ key: 'email', title: '邮箱', render: (record) => <span className="muted">{record.email}</span> },
|
||||
{ key: 'phone', title: '手机号', width: '180px', render: (record) => <span className="muted">{record.phone}</span> },
|
||||
{
|
||||
key: 'role',
|
||||
title: '角色',
|
||||
width: '150px',
|
||||
render: (record) => <Tag tone={record.role === 'enterprise_admin' ? 'info' : 'success'}>{roleLabelMap[record.role]}</Tag>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
width: '130px',
|
||||
render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '正常' : '禁用'}</Tag>,
|
||||
},
|
||||
{ key: 'lastLoginAt', title: '最后登录时间', width: '210px', render: (record) => <span className="muted">{record.lastLoginAt}</span> },
|
||||
useEffect(() => {
|
||||
void load().catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
|
||||
}, [tenantId]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const value = keyword.trim().toLowerCase();
|
||||
return users.filter((item) => {
|
||||
const target = `${item.displayName} ${item.email ?? ''} ${item.phone ?? ''}`.toLowerCase();
|
||||
return !value || target.includes(value);
|
||||
});
|
||||
}, [keyword, users]);
|
||||
|
||||
function openEditor(user?: ManagedUser) {
|
||||
setForm(toForm(user));
|
||||
setEditingUser(user ?? null);
|
||||
setCreating(!user);
|
||||
}
|
||||
|
||||
function updateField<Key extends keyof UserForm>(key: Key, value: UserForm[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
const body: UserPayload = {
|
||||
displayName: form.displayName,
|
||||
username: form.username || form.email || form.phone,
|
||||
email: form.email,
|
||||
phone: form.phone,
|
||||
status: form.status,
|
||||
roleCode: 'enterprise_admin',
|
||||
operatorId: session?.user.id,
|
||||
};
|
||||
if (creating) {
|
||||
await clientApi.createUser({ ...body, password: form.password }, tenantId);
|
||||
} else if (editingUser) {
|
||||
await clientApi.updateUser(editingUser.id, body, tenantId);
|
||||
}
|
||||
setCreating(false);
|
||||
setEditingUser(null);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function runConfirm() {
|
||||
if (!confirmAction) return;
|
||||
if (confirmAction.type === 'delete') {
|
||||
await clientApi.deleteUser(confirmAction.user.id, session?.user.id, tenantId);
|
||||
} else {
|
||||
await clientApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id, tenantId);
|
||||
}
|
||||
setConfirmAction(null);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
if (!passwordUser) return;
|
||||
await clientApi.changeUserPassword(passwordUser.id, newPassword, session?.user.id, tenantId);
|
||||
setPasswordUser(null);
|
||||
setNewPassword('');
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<ManagedUser>>>(() => [
|
||||
{ key: 'name', title: '用户名', width: '140px', render: (record) => <strong className="text-strong">{record.displayName}</strong> },
|
||||
{ key: 'email', title: '邮箱', render: (record) => <span className="muted">{record.email ?? '-'}</span> },
|
||||
{ key: 'phone', title: '手机号', width: '160px', render: (record) => <span className="muted">{record.phone ?? '-'}</span> },
|
||||
{ key: 'role', title: '角色', width: '130px', render: () => <Tag tone="info">企业管理员</Tag> },
|
||||
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '正常' : '禁用'}</Tag> },
|
||||
{ key: 'lastLoginAt', title: '最后登录时间', width: '190px', render: (record) => <span className="muted">{record.lastLoginAt ? new Date(record.lastLoginAt).toLocaleString('zh-CN') : '-'}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '170px',
|
||||
width: '290px',
|
||||
render: (record) => (
|
||||
<div className="inline-actions">
|
||||
<Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button icon={<Trash2 size={15} />} size="sm" variant="danger">删除</Button>
|
||||
<Button icon={<KeyRound size={15} />} onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost">改密</Button>
|
||||
<Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant="ghost">{record.status === 'active' ? '禁用' : '启用'}</Button>
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ type: 'delete', user: record })} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -106,56 +149,46 @@ export function ClientUsersPage() {
|
||||
</div>
|
||||
|
||||
<div className="system-filter-row">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索用户名、邮箱或手机号"
|
||||
prefix={<Search size={16} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索用户名、邮箱或手机号" prefix={<Search size={16} />} value={keyword} />
|
||||
</div>
|
||||
|
||||
{error ? <div className="surface empty-state">{error}</div> : null}
|
||||
<div className="surface system-table-card">
|
||||
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
|
||||
<Pagination total={filteredUsers.length} page={1} />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setEditingUser(null)} variant="secondary">取消</Button>
|
||||
<Button onClick={() => setEditingUser(null)}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setEditingUser(null)}
|
||||
open={Boolean(editingUser)}
|
||||
size="xl"
|
||||
title={editingUser?.id === 'NEW' ? '添加用户' : '编辑用户'}
|
||||
>
|
||||
<div className="system-user-form">
|
||||
<Input label="用户名 *" onChange={(event) => setDraft({ ...draft, name: event.target.value })} placeholder="请输入用户名" value={draft.name} />
|
||||
<Input label="邮箱 *" onChange={(event) => setDraft({ ...draft, email: event.target.value })} placeholder="请输入邮箱" value={draft.email} />
|
||||
<Input label="手机号 *" onChange={(event) => setDraft({ ...draft, phone: event.target.value })} placeholder="请输入手机号" value={draft.phone} />
|
||||
<Select
|
||||
hint={canSelectEnterpriseAdmin ? '企业管理员拥有企业空间最高权限。' : `当前企业管理员为 ${enterpriseAdmin?.name},每个企业仅允许 1 位企业管理员。`}
|
||||
label="角色 *"
|
||||
onChange={(event) => setDraft({ ...draft, role: event.target.value as UserRole })}
|
||||
options={[
|
||||
...(canSelectEnterpriseAdmin ? [{ label: '企业管理员', value: 'enterprise_admin' }] : []),
|
||||
{ label: '普通用户', value: 'user' },
|
||||
]}
|
||||
value={draft.role}
|
||||
/>
|
||||
<Select
|
||||
label="状态 *"
|
||||
onChange={(event) => setDraft({ ...draft, status: event.target.value as UserStatus })}
|
||||
options={[
|
||||
{ label: '正常', value: 'active' },
|
||||
{ label: '禁用', value: 'disabled' },
|
||||
]}
|
||||
value={draft.status}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
{(creating || editingUser) ? (
|
||||
<Modal
|
||||
footer={<><Button onClick={() => { setCreating(false); setEditingUser(null); }} variant="secondary">取消</Button><Button onClick={() => void saveUser()}>保存</Button></>}
|
||||
onClose={() => { setCreating(false); setEditingUser(null); }}
|
||||
open
|
||||
size="xl"
|
||||
title={creating ? '添加用户' : '编辑用户'}
|
||||
>
|
||||
<div className="system-user-form">
|
||||
<Input label="用户名 *" onChange={(event) => updateField('displayName', event.target.value)} placeholder="请输入用户名" value={form.displayName} />
|
||||
<Input label="邮箱 *" onChange={(event) => updateField('email', event.target.value)} placeholder="请输入邮箱" value={form.email} />
|
||||
<Input label="手机号 *" onChange={(event) => updateField('phone', event.target.value)} placeholder="请输入手机号" value={form.phone} />
|
||||
<Input label="登录账号" onChange={(event) => updateField('username', event.target.value)} value={form.username} />
|
||||
{creating ? <Input label="初始密码 *" onChange={(event) => updateField('password', event.target.value)} type="password" value={form.password} /> : null}
|
||||
<Select label="状态 *" onChange={(event) => updateField('status', event.target.value)} options={[{ label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={form.status} />
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{passwordUser ? (
|
||||
<Modal footer={<><Button onClick={() => setPasswordUser(null)} variant="secondary">取消</Button><Button onClick={() => void savePassword()}>保存</Button></>} onClose={() => setPasswordUser(null)} open title="修改密码">
|
||||
<div className="system-user-form">
|
||||
<Input label="新密码" onChange={(event) => setNewPassword(event.target.value)} type="password" value={newPassword} />
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{confirmAction ? (
|
||||
<Modal footer={<><Button onClick={() => setConfirmAction(null)} variant="secondary">取消</Button><Button onClick={() => void runConfirm()} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>确认</Button></>} onClose={() => setConfirmAction(null)} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>
|
||||
<p>{confirmAction.type === 'delete' ? `确认删除用户 ${confirmAction.user.displayName}?` : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}?`}</p>
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,15 +24,23 @@ import {
|
||||
Users,
|
||||
UserX,
|
||||
} from 'lucide-react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { readSession } from '@/api/session';
|
||||
import { AppShell } from '@/layouts/AppShell';
|
||||
|
||||
export function AdminLayout() {
|
||||
const session = readSession();
|
||||
if (session?.portal !== 'admin') {
|
||||
return <Navigate to="/admin/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="CMPP 运营端"
|
||||
subtitle="平台运营管理中心"
|
||||
workspaceName="平台运营工作区"
|
||||
userName="运营"
|
||||
loginPath="/admin/login"
|
||||
userName={session.user.displayName}
|
||||
userRole="平台管理员"
|
||||
auditNotifications={[
|
||||
{ label: '企业认证审核', count: 3, to: '/admin/enterprise-audit' },
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
Search,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { clearSession } from '@/api/session';
|
||||
|
||||
export type ShellNavItem = {
|
||||
label: string;
|
||||
@@ -37,6 +38,7 @@ type AppShellProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
workspaceName: string;
|
||||
loginPath: string;
|
||||
userName: string;
|
||||
userRole: string;
|
||||
navSections: ShellNavSection[];
|
||||
@@ -47,6 +49,7 @@ export function AppShell({
|
||||
title,
|
||||
subtitle,
|
||||
workspaceName,
|
||||
loginPath,
|
||||
userName,
|
||||
userRole,
|
||||
navSections,
|
||||
@@ -56,6 +59,7 @@ export function AppShell({
|
||||
const [closedSections, setClosedSections] = useState<Record<string, boolean>>({});
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const [noticeOpen, setNoticeOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose;
|
||||
const auditTotal = useMemo(
|
||||
() => auditNotifications.reduce((sum, item) => sum + item.count, 0),
|
||||
@@ -205,7 +209,11 @@ export function AppShell({
|
||||
<KeyRound size={16} />
|
||||
修改密码
|
||||
</button>
|
||||
<button onClick={() => setUserMenuOpen(false)} role="menuitem" type="button">
|
||||
<button onClick={() => {
|
||||
clearSession();
|
||||
setUserMenuOpen(false);
|
||||
navigate(loginPath, { replace: true });
|
||||
}} role="menuitem" type="button">
|
||||
<LogOut size={16} />
|
||||
退出登录
|
||||
</button>
|
||||
|
||||
@@ -10,15 +10,23 @@ import {
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { readSession } from '@/api/session';
|
||||
import { AppShell } from '@/layouts/AppShell';
|
||||
|
||||
export function ClientLayout() {
|
||||
const session = readSession();
|
||||
if (session?.portal !== 'client') {
|
||||
return <Navigate to="/client/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="CMPP 客户端"
|
||||
subtitle="短信服务控制台"
|
||||
workspaceName="华东一区客户空间"
|
||||
userName="赵先生"
|
||||
workspaceName={session.user.tenantName ?? '企业客户空间'}
|
||||
loginPath="/client/login"
|
||||
userName={session.user.displayName}
|
||||
userRole="企业管理员"
|
||||
navSections={[
|
||||
{
|
||||
|
||||
@@ -56,6 +56,7 @@ import { ClientSystemLogsPage } from '@/apps/client/ClientSystemLogsPage';
|
||||
import { ClientTemplatesPage } from '@/apps/client/ClientTemplatesPage';
|
||||
import { ClientUplinkMessagesPage } from '@/apps/client/ClientUplinkMessagesPage';
|
||||
import { ClientUsersPage } from '@/apps/client/ClientUsersPage';
|
||||
import { LoginPage } from '@/apps/LoginPage';
|
||||
import { PagePlaceholder } from '@/components/PagePlaceholder';
|
||||
import { AdminLayout } from '@/layouts/AdminLayout';
|
||||
import { ClientLayout } from '@/layouts/ClientLayout';
|
||||
@@ -64,6 +65,8 @@ export function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/client" replace />} />
|
||||
<Route path="/client/login" element={<LoginPage portal="client" />} />
|
||||
<Route path="/admin/login" element={<LoginPage portal="admin" />} />
|
||||
<Route path="/client" element={<ClientLayout />}>
|
||||
<Route index element={<ClientHome />} />
|
||||
<Route path="send" element={<ClientSendPage />} />
|
||||
|
||||
@@ -3856,6 +3856,82 @@ h3 {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(37, 99, 235, 0.08), rgba(16, 185, 129, 0.08)),
|
||||
var(--color-bg);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: min(440px, 100%);
|
||||
padding: 28px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-brand > span {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.login-brand h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.login-brand p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.login-captcha-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 136px;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.login-captcha {
|
||||
height: 40px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
color: var(--color-danger);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.system-page {
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user