[优化] go 引用调整

This commit is contained in:
ryan
2026-06-06 10:26:20 +08:00
parent ee1110b752
commit 3cfefb4367
552 changed files with 1642 additions and 2185 deletions
@@ -0,0 +1,43 @@
import { apiRequest } from '@/lib/api/client';
import type {
ManageUserAction,
ManageUserResult,
UserItem,
UserMutationPayload,
} from '@/features/users/types';
export function getUsers(page: number) {
return apiRequest<UserItem[]>(`/user/?p=${page}`);
}
export function searchUsers(keyword: string) {
return apiRequest<UserItem[]>(
`/user/search?keyword=${encodeURIComponent(keyword)}`,
);
}
export function getUser(id: number) {
return apiRequest<UserItem>(`/user/${id}`);
}
export function createUser(payload: UserMutationPayload) {
return apiRequest<void>('/user/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateUser(payload: UserMutationPayload & { id: number }) {
return apiRequest<void>('/user/update', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function manageUser(username: string, action: ManageUserAction) {
return apiRequest<ManageUserResult>('/user/manage', {
method: 'POST',
body: JSON.stringify({ username, action }),
});
}
@@ -0,0 +1,613 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { useAuth } from '@/components/providers/auth-provider';
import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
createUser,
getUser,
getUsers,
manageUser,
searchUsers,
updateUser,
} from '@/features/users/api/users';
import type {
ManageUserAction,
UserItem,
UserMutationPayload,
} from '@/features/users/types';
import {
DangerButton,
PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
const ITEMS_PER_PAGE = 10;
const usersListQueryKey = ['users', 'list'] as const;
const userSchema = z.object({
username: z
.string()
.trim()
.min(1, '请输入用户名')
.max(12, '用户名不能超过 12 个字符'),
display_name: z.string().trim().max(20, '显示名称不能超过 20 个字符'),
password: z
.string()
.max(20, '密码不能超过 20 个字符')
.refine(
(value) => value.length === 0 || value.length >= 8,
'密码至少需要 8 个字符',
),
});
type UserFormValues = z.infer<typeof userSchema>;
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
const defaultValues: UserFormValues = {
username: '',
display_name: '',
password: '',
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function getRoleMeta(role: number) {
if (role >= 100) {
return { label: '超级管理员', variant: 'warning' as const };
}
if (role >= 10) {
return { label: '管理员', variant: 'info' as const };
}
return { label: '普通用户', variant: 'success' as const };
}
function getStatusMeta(status: number) {
if (status === 1) {
return { label: '已激活', variant: 'success' as const };
}
return { label: '已封禁', variant: 'danger' as const };
}
function toPayload(values: UserFormValues): UserMutationPayload {
return {
username: values.username.trim(),
display_name: values.display_name.trim(),
password: values.password,
};
}
export function UsersPage() {
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
const [page, setPage] = useState(0);
const [searchInput, setSearchInput] = useState('');
const [searchKeyword, setSearchKeyword] = useState('');
const [editingUserId, setEditingUserId] = useState<number | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema),
defaultValues,
});
const isAdmin = (currentUser?.role ?? 0) >= 10;
const isRoot = (currentUser?.role ?? 0) >= 100;
const usersQuery = useQuery({
queryKey: [...usersListQueryKey, page],
queryFn: () => getUsers(page),
enabled: isAdmin && searchKeyword.length === 0,
});
const searchQuery = useQuery({
queryKey: [...usersListQueryKey, 'search', searchKeyword],
queryFn: () => searchUsers(searchKeyword),
enabled: isAdmin && searchKeyword.length > 0,
});
const editingUserQuery = useQuery({
queryKey: ['users', 'detail', editingUserId],
queryFn: () => getUser(editingUserId as number),
enabled: isAdmin && editingUserId !== null,
});
useEffect(() => {
if (editingUserQuery.data) {
form.reset({
username: editingUserQuery.data.username,
display_name: editingUserQuery.data.display_name || '',
password: '',
});
}
}, [editingUserQuery.data, form]);
const saveMutation = useMutation({
mutationFn: async (values: UserFormValues) => {
if (!editingUserId && values.password.length === 0) {
throw new Error('创建用户时必须填写密码。');
}
const payload = toPayload(values);
if (editingUserId) {
await updateUser({ id: editingUserId, ...payload });
return '用户信息已更新。';
}
await createUser(payload);
return '用户已创建。';
},
onSuccess: async (message) => {
setFeedback({ tone: 'success', message });
setEditingUserId(null);
setIsEditorOpen(false);
form.reset(defaultValues);
await Promise.all([
queryClient.invalidateQueries({ queryKey: usersListQueryKey }),
queryClient.invalidateQueries({ queryKey: ['users', 'detail'] }),
]);
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const manageMutation = useMutation({
mutationFn: async ({
username,
action,
}: {
username: string;
action: ManageUserAction;
}) => {
await manageUser(username, action);
return action;
},
onSuccess: async (action) => {
const actionLabel = {
promote: '提升权限',
demote: '降级权限',
delete: '删除用户',
disable: '禁用用户',
enable: '启用用户',
}[action];
setFeedback({ tone: 'success', message: `${actionLabel}操作已完成。` });
await Promise.all([
queryClient.invalidateQueries({ queryKey: usersListQueryKey }),
queryClient.invalidateQueries({ queryKey: ['users', 'detail'] }),
]);
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const activeQuery = searchKeyword.length > 0 ? searchQuery : usersQuery;
const users = useMemo(() => activeQuery.data ?? [], [activeQuery.data]);
const handleSearchSubmit = () => {
setFeedback(null);
setPage(0);
setSearchKeyword(searchInput.trim());
};
const handleResetSearch = () => {
setSearchInput('');
setSearchKeyword('');
setPage(0);
setFeedback(null);
};
const handleResetForm = () => {
setEditingUserId(null);
setIsEditorOpen(false);
setFeedback(null);
form.reset(defaultValues);
};
const handleCreate = () => {
setEditingUserId(null);
setFeedback(null);
form.reset(defaultValues);
setIsEditorOpen(true);
};
const handleEdit = (targetUser: UserItem) => {
setFeedback(null);
setEditingUserId(targetUser.id);
setIsEditorOpen(true);
};
const handleManage = (targetUser: UserItem, action: ManageUserAction) => {
const actionText = {
promote: '提升',
demote: '降级',
delete: '删除',
disable: '禁用',
enable: '启用',
}[action];
if (
action === 'delete' &&
!window.confirm(`确认删除用户“${targetUser.username}”吗?`)
) {
return;
}
if (
(action === 'disable' || action === 'enable') &&
!window.confirm(`确认${actionText}用户“${targetUser.username}”吗?`)
) {
return;
}
setFeedback(null);
manageMutation.mutate({ username: targetUser.username, action });
};
const handleSubmit = form.handleSubmit((values) => {
setFeedback(null);
saveMutation.mutate(values);
});
if (!isAdmin) {
return (
<div className="space-y-6">
<PageHeader
title="用户管理"
description="阶段 4 已迁移用户管理页面,但当前账户没有管理员权限。"
/>
<EmptyState
title="权限不足"
description="用户列表、编辑与账户管理动作仅对管理员开放,请使用管理员账号登录后继续。"
/>
</div>
);
}
return (
<>
<div className="space-y-6">
<PageHeader
title="用户管理"
description="支持搜索、分页、创建、编辑以及启用、封禁、权限调整等常见账户管理动作。"
action={
<PrimaryButton type="button" onClick={handleCreate}>
新建用户
</PrimaryButton>
}
/>
{feedback ? (
<InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null}
<AppCard
title="用户列表"
action={
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={() =>
void queryClient.invalidateQueries({
queryKey: usersListQueryKey,
})
}
>
刷新
</SecondaryButton>
</div>
}
>
<div className="space-y-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex w-full flex-col gap-3 md:flex-row">
<ResourceInput
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="搜索用户 ID、用户名、显示名称或邮箱地址"
className="md:max-w-xl"
/>
<div className="flex flex-wrap gap-2">
<PrimaryButton type="button" onClick={handleSearchSubmit}>
搜索
</PrimaryButton>
<SecondaryButton type="button" onClick={handleResetSearch}>
清空
</SecondaryButton>
</div>
</div>
{searchKeyword.length === 0 ? (
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={() => setPage((value) => Math.max(value - 1, 0))}
disabled={page === 0 || activeQuery.isLoading}
>
上一页
</SecondaryButton>
<SecondaryButton
type="button"
onClick={() => setPage((value) => value + 1)}
disabled={
activeQuery.isLoading || users.length < ITEMS_PER_PAGE
}
>
下一页
</SecondaryButton>
</div>
) : null}
</div>
{activeQuery.isLoading ? (
<LoadingState />
) : activeQuery.isError ? (
<ErrorState
title="用户列表加载失败"
description={getErrorMessage(activeQuery.error)}
/>
) : users.length === 0 ? (
<EmptyState
title="暂无用户"
description="当前条件下没有可展示的用户记录。"
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">用户</th>
<th className="px-3 py-3 font-medium">邮箱</th>
<th className="px-3 py-3 font-medium">角色</th>
<th className="px-3 py-3 font-medium">状态</th>
<th className="px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{users.map((targetUser) => {
const roleMeta = getRoleMeta(targetUser.role);
const statusMeta = getStatusMeta(targetUser.status);
const canManage =
(currentUser?.role ?? 0) > targetUser.role;
return (
<tr key={targetUser.id} className="align-top">
<td className="px-3 py-4">
<div className="space-y-1">
<p className="font-medium text-[var(--foreground-primary)]">
{targetUser.username}
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
显示名称:{targetUser.display_name || '未设置'}
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
ID:{targetUser.id}
</p>
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{targetUser.email || '未绑定'}
</td>
<td className="px-3 py-4">
<StatusBadge
label={roleMeta.label}
variant={roleMeta.variant}
/>
</td>
<td className="px-3 py-4">
<StatusBadge
label={statusMeta.label}
variant={statusMeta.variant}
/>
</td>
<td className="px-3 py-4">
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={() => handleEdit(targetUser)}
className="px-3 py-2 text-xs"
disabled={!canManage}
>
编辑
</SecondaryButton>
<SecondaryButton
type="button"
onClick={() =>
handleManage(
targetUser,
targetUser.status === 1
? 'disable'
: 'enable',
)
}
className="px-3 py-2 text-xs"
disabled={
!canManage || manageMutation.isPending
}
>
{targetUser.status === 1 ? '禁用' : '启用'}
</SecondaryButton>
<SecondaryButton
type="button"
onClick={() =>
handleManage(
targetUser,
targetUser.role >= 10
? 'demote'
: 'promote',
)
}
className="px-3 py-2 text-xs"
disabled={
!canManage ||
manageMutation.isPending ||
(!isRoot && targetUser.role < 10)
}
>
{targetUser.role >= 10 ? '降级' : '提升'}
</SecondaryButton>
<DangerButton
type="button"
onClick={() =>
handleManage(targetUser, 'delete')
}
className="px-3 py-2 text-xs"
disabled={
!canManage || manageMutation.isPending
}
>
删除
</DangerButton>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</AppCard>
</div>
<AppModal
isOpen={isEditorOpen}
onClose={handleResetForm}
title={editingUserId ? '编辑用户' : '创建新用户'}
description={
editingUserId
? '可更新用户名、显示名称与密码。留空密码表示不修改。'
: '新建用户默认创建为普通用户。'
}
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={handleResetForm}
disabled={saveMutation.isPending}
>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="user-editor-form"
disabled={saveMutation.isPending}
>
{saveMutation.isPending
? '保存中...'
: editingUserId
? '保存修改'
: '创建用户'}
</PrimaryButton>
</div>
}
>
{editingUserId && editingUserQuery.isLoading ? (
<LoadingState />
) : editingUserId && editingUserQuery.isError ? (
<ErrorState
title="用户详情加载失败"
description={getErrorMessage(editingUserQuery.error)}
/>
) : (
<form
id="user-editor-form"
className="space-y-5"
onSubmit={handleSubmit}
>
<ResourceField
label="用户名"
error={form.formState.errors.username?.message}
>
<ResourceInput
placeholder="请输入用户名"
{...form.register('username')}
/>
</ResourceField>
<ResourceField
label="显示名称"
error={form.formState.errors.display_name?.message}
>
<ResourceInput
placeholder="请输入显示名称"
{...form.register('display_name')}
/>
</ResourceField>
<ResourceField
label={editingUserId ? '新密码' : '密码'}
hint={
editingUserId
? '留空表示保持原密码不变。'
: '密码长度需为 8 到 20 个字符。'
}
error={form.formState.errors.password?.message}
>
<ResourceInput
type="password"
placeholder="请输入密码"
{...form.register('password')}
/>
</ResourceField>
{editingUserId && editingUserQuery.data ? (
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
绑定邮箱
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{editingUserQuery.data.email || '未绑定'}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
第三方账号
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
GitHub:{editingUserQuery.data.github_id || '未绑定'}
</p>
<p className="mt-1 text-sm text-[var(--foreground-primary)]">
微信:{editingUserQuery.data.wechat_id || '未绑定'}
</p>
</div>
</div>
) : null}
</form>
)}
</AppModal>
</>
);
}
@@ -0,0 +1,29 @@
export interface UserItem {
id: number;
username: string;
display_name: string;
role: number;
status: number;
email?: string;
github_id?: string;
wechat_id?: string;
}
export interface UserMutationPayload {
id?: number;
username: string;
display_name: string;
password: string;
}
export type ManageUserAction =
| 'promote'
| 'demote'
| 'delete'
| 'disable'
| 'enable';
export interface ManageUserResult {
role: number;
status: number;
}