fix(cms): resolve admin dogfood findings on auth, content editing, and UX

修复 CMS / Admin 后台 dogfood 专项测试发现的阻塞性与体验性问题:
- 登录后无限重定向(Secure Cookie + Edge Runtime JWT 兼容)
- 内容新增/编辑保存 400/500(默认不加密请求体、自动生成唯一 slug)
- 编辑时提交 status 导致验证错误
- 原生 confirm 阻塞自动化测试,改为 AlertDialog
- 表单字段可访问性(id/htmlFor/fieldset)
- JSON 数组标签字段新增 TagInput 组件
- 操作反馈统一使用 Sonner Toast
- 后台隐藏营销 Cookie 横幅
- 媒体库空状态优化

添加 dogfood-cms-regression 与 dogfood-cms-output 报告、截图及工作流路由测试。
This commit is contained in:
张翔
2026-07-25 08:03:06 +08:00
parent f6f5766bb9
commit d9c0e5f2c1
48 changed files with 1445 additions and 106 deletions
@@ -4,7 +4,8 @@ import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { useAuth } from '@/components/admin/auth-context';
import { adminApi } from '@/lib/admin-api';
import { ArrowLeft, Save } from 'lucide-react';
import { toast } from '@/components/ui/sonner';
import { ArrowLeft, Save, X } from 'lucide-react';
const MODEL_LABELS: Record<string, string> = {
news: '新闻资讯',
@@ -35,6 +36,78 @@ interface ModelData {
fields: FieldDef[];
}
function TagInput({
id,
value,
onChange,
placeholder,
}: {
id: string;
value: unknown;
onChange: (tags: string[]) => void;
placeholder?: string;
}) {
const tags = Array.isArray(value) ? value.filter((t): t is string => typeof t === 'string') : [];
const [input, setInput] = useState('');
const addTag = () => {
const raw = input.trim();
if (!raw) return;
const newTags = raw.split(/[,,]/).map((t) => t.trim()).filter(Boolean);
if (newTags.length === 0) return;
const merged = [...new Set([...tags, ...newTags])];
onChange(merged);
setInput('');
};
const removeTag = (tag: string) => {
onChange(tags.filter((t) => t !== tag));
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
if (e.key === 'Backspace' && !input && tags.length > 0) {
onChange(tags.slice(0, -1));
}
};
return (
<div
id={id}
className="w-full min-h-[42px] px-3 py-2 border border-gray-300 rounded-lg text-sm focus-within:outline-none focus-within:ring-2 focus-within:ring-gray-900 focus-within:border-transparent flex flex-wrap gap-2"
>
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 bg-gray-100 text-gray-700 px-2 py-0.5 rounded text-xs"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="text-gray-400 hover:text-gray-600"
aria-label={`移除标签 ${tag}`}
>
<X className="w-3 h-3" />
</button>
</span>
))}
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={addTag}
placeholder={tags.length === 0 ? placeholder || '输入标签,按回车添加' : ''}
className="flex-1 min-w-[120px] outline-none bg-transparent text-sm"
/>
</div>
);
}
export default function ContentEditorPage() {
const params = useParams();
const modelCode = params.modelCode as string;
@@ -105,7 +178,7 @@ export default function ContentEditorPage() {
const handleSave = async () => {
if (!title.trim()) {
alert('请输入标题');
toast.error('请输入标题');
return;
}
@@ -122,17 +195,21 @@ export default function ContentEditorPage() {
status,
data: formData,
});
toast.success(`${modelLabel}创建成功`);
} else {
// 编辑时不提交 status,状态变更需通过工作流接口处理
await adminApi.updateItem(itemId, {
title: title.trim(),
slug: slug.trim() || undefined,
status,
data: formData,
});
toast.success(`${modelLabel}保存成功`);
}
router.push(`/admin/content/${modelCode}`);
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
const message = err instanceof Error ? err.message : '保存失败';
setError(message);
toast.error(message);
setSaving(false);
}
};
@@ -145,7 +222,7 @@ export default function ContentEditorPage() {
case 'text':
return (
<div key={key}>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor={key} className="block text-sm font-medium text-gray-700 mb-1">
{field.label}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
@@ -153,6 +230,7 @@ export default function ContentEditorPage() {
<p className="text-xs text-gray-400 mb-1.5">{field.description}</p>
)}
<input
id={key}
type="text"
value={(value as string) || ''}
onChange={(e) => handleFieldChange(field.name, e.target.value)}
@@ -165,7 +243,7 @@ export default function ContentEditorPage() {
case 'textarea':
return (
<div key={key}>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor={key} className="block text-sm font-medium text-gray-700 mb-1">
{field.label}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
@@ -173,6 +251,7 @@ export default function ContentEditorPage() {
<p className="text-xs text-gray-400 mb-1.5">{field.description}</p>
)}
<textarea
id={key}
value={(value as string) || ''}
onChange={(e) => handleFieldChange(field.name, e.target.value)}
placeholder={field.placeholder}
@@ -185,11 +264,12 @@ export default function ContentEditorPage() {
case 'number':
return (
<div key={key}>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor={key} className="block text-sm font-medium text-gray-700 mb-1">
{field.label}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
<input
id={key}
type="number"
value={(value as number) ?? ''}
onChange={(e) => handleFieldChange(field.name, parseFloat(e.target.value) || 0)}
@@ -202,12 +282,13 @@ export default function ContentEditorPage() {
return (
<div key={key} className="flex items-center gap-3">
<input
id={key}
type="checkbox"
checked={!!value}
onChange={(e) => handleFieldChange(field.name, e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-gray-900 focus:ring-gray-900"
/>
<label className="text-sm font-medium text-gray-700">{field.label}</label>
<label htmlFor={key} className="text-sm font-medium text-gray-700">{field.label}</label>
{field.description && (
<span className="text-xs text-gray-400">{field.description}</span>
)}
@@ -218,10 +299,11 @@ export default function ContentEditorPage() {
case 'dropdown':
return (
<div key={key}>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor={key} className="block text-sm font-medium text-gray-700 mb-1">
{field.label}
</label>
<select
id={key}
value={(value as string) || ''}
onChange={(e) => handleFieldChange(field.name, e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent"
@@ -236,13 +318,33 @@ export default function ContentEditorPage() {
</div>
);
case 'json':
return (
<div key={key}>
<label htmlFor={key} className="block text-sm font-medium text-gray-700 mb-1">
{field.label}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
{field.description && (
<p className="text-xs text-gray-400 mb-1.5">{field.description}</p>
)}
<TagInput
id={key}
value={value}
onChange={(tags) => handleFieldChange(field.name, tags)}
placeholder={field.placeholder}
/>
</div>
);
case 'image':
return (
<div key={key}>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor={key} className="block text-sm font-medium text-gray-700 mb-1">
{field.label}
</label>
<input
id={key}
type="text"
value={(value as string) || ''}
onChange={(e) => handleFieldChange(field.name, e.target.value)}
@@ -262,21 +364,22 @@ export default function ContentEditorPage() {
case 'array':
case 'object':
return (
<div key={key} className="border border-gray-200 rounded-lg p-4">
<label className="block text-sm font-medium text-gray-700 mb-3">
<fieldset key={key} className="border border-gray-200 rounded-lg p-4">
<legend className="text-sm font-medium text-gray-700 mb-3 px-1">
{field.label}
</label>
</legend>
{field.fields?.map((subField) => renderField(subField, `${field.name}.`))}
</div>
</fieldset>
);
default:
return (
<div key={key}>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor={key} className="block text-sm font-medium text-gray-700 mb-1">
{field.label}
</label>
<input
id={key}
type="text"
value={(value as string) || ''}
onChange={(e) => handleFieldChange(field.name, e.target.value)}
@@ -333,10 +436,11 @@ export default function ContentEditorPage() {
<div className="bg-white rounded-lg border border-gray-200 p-6 space-y-5">
{/* 标题 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
标题 <span className="text-red-500">*</span>
</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
@@ -347,8 +451,9 @@ export default function ContentEditorPage() {
{/* Slug */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Slug</label>
<label htmlFor="slug" className="block text-sm font-medium text-gray-700 mb-1">Slug</label>
<input
id="slug"
type="text"
value={slug}
onChange={(e) => setSlug(e.target.value)}
@@ -359,8 +464,9 @@ export default function ContentEditorPage() {
{/* 状态 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">状态</label>
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">状态</label>
<select
id="status"
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent"