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:
2026-09-28 10:48:07 +08:00
parent 040951c0a3
commit a366bd1400
224 changed files with 8938 additions and 5579 deletions
+29 -11
View File
@@ -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}