fix(qa): 第五轮系统性质量保障缺陷修复 + 单元/集成测试
安全/鉴权:登出清服务端 httpOnly 令牌、登录 IP 限流前置 bcrypt、停用账号 令牌在 6 个内联 role handler 失效、admin/items 存在性 oracle 认证前置、 同意偏好 sanitize fail-closed、api-crypto 按 HTTP 方法判体。 CMS:workflow 状态更新+审计收进交互式 $transaction(失败即回滚)、编辑器 richtext 走 textarea 防换行损毁、array/object 子字段按作用域读写防污染、 about/contact/erp-upgrade 补 ISR revalidate 与路由映射。 UI/a11y:AnimatedCounter 入视口前显起点、CLS 累计上报、MetricCard 动画入 effect 并卸载取消 rAF、后台状态四态映射/搜索防抖+序号守卫/媒体单 input/ 吞错改横幅/表单 label-aria 关联。 新增对应判别性单测与 pagination/rate-limit/client-ip/sanitize/validate-content-data 等模块及 tests-integration 真库集成层。
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/admin/auth-context';
|
||||
import { adminApi } from '@/lib/admin-api';
|
||||
@@ -52,6 +52,8 @@ const ROLE_COLORS: Record<string, string> = {
|
||||
readonly: 'bg-bg-hover text-text-secondary',
|
||||
};
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
export default function UsersPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -59,11 +61,14 @@ export default function UsersPage() {
|
||||
const [users, setUsers] = useState<UserData[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [roles, setRoles] = useState<RoleData[]>([]);
|
||||
const pageSize = 20;
|
||||
// 单调递增的请求序号:只有最新一次请求的响应可以写进 state
|
||||
const fetchSeqRef = useRef(0);
|
||||
|
||||
// Create/Edit dialog state
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -82,7 +87,13 @@ export default function UsersPage() {
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(searchInput), SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput]);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
const seq = ++fetchSeqRef.current;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -98,12 +109,14 @@ export default function UsersPage() {
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}>(`/api/admin/users?${query.toString()}`);
|
||||
if (seq !== fetchSeqRef.current) return;
|
||||
setUsers(data.users || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch (err) {
|
||||
if (seq !== fetchSeqRef.current) return;
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (seq === fetchSeqRef.current) setLoading(false);
|
||||
}
|
||||
}, [page, search]);
|
||||
|
||||
@@ -259,8 +272,8 @@ export default function UsersPage() {
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索用户名、昵称或邮箱..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
value={searchInput}
|
||||
onChange={(e) => { setSearchInput(e.target.value); setPage(1); }}
|
||||
className="w-full pl-10 pr-4 py-2 border border-border-primary rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-border-primary"
|
||||
/>
|
||||
</div>
|
||||
@@ -419,8 +432,9 @@ export default function UsersPage() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">用户名</label>
|
||||
<label htmlFor="admin-user-username" className="block text-sm font-medium text-text-secondary mb-1">用户名</label>
|
||||
<input
|
||||
id="admin-user-username"
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, username: e.target.value }))}
|
||||
@@ -431,10 +445,11 @@ export default function UsersPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">
|
||||
<label htmlFor="admin-user-password" className="block text-sm font-medium text-text-secondary mb-1">
|
||||
{editingUser ? '新密码(留空不修改)' : '密码'}
|
||||
</label>
|
||||
<input
|
||||
id="admin-user-password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, password: e.target.value }))}
|
||||
@@ -445,8 +460,9 @@ export default function UsersPage() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">昵称</label>
|
||||
<label htmlFor="admin-user-nickname" className="block text-sm font-medium text-text-secondary mb-1">昵称</label>
|
||||
<input
|
||||
id="admin-user-nickname"
|
||||
type="text"
|
||||
value={formData.nickname}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, nickname: e.target.value }))}
|
||||
@@ -454,8 +470,9 @@ export default function UsersPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">邮箱</label>
|
||||
<label htmlFor="admin-user-email" className="block text-sm font-medium text-text-secondary mb-1">邮箱</label>
|
||||
<input
|
||||
id="admin-user-email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, email: e.target.value }))}
|
||||
@@ -465,8 +482,9 @@ export default function UsersPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">手机号</label>
|
||||
<label htmlFor="admin-user-phone" className="block text-sm font-medium text-text-secondary mb-1">手机号</label>
|
||||
<input
|
||||
id="admin-user-phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, phone: e.target.value }))}
|
||||
@@ -475,8 +493,8 @@ export default function UsersPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">角色分配</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span id="admin-user-roles-label" className="block text-sm font-medium text-text-secondary mb-2">角色分配</span>
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-labelledby="admin-user-roles-label">
|
||||
{roles.map((r) => (
|
||||
<button
|
||||
key={r.code}
|
||||
|
||||
Reference in New Issue
Block a user