feat(admin): enhance admin dashboard, user management, and content editor UX
- Dashboard: add stats API with content status distribution, recent notifications, and recent content updates; display active users, pending reviews, unread counts - User management: full CRUD with role assignment, search, pagination, delete dialog - Notification center: list with unread filter, mark as read, mark all as read, pagination, and auto-refresh unread count - Content editor: form validation (required fields, slug format, blur-triggered errors), auto-save with 3s debounce and status indicator, publish confirmation dialog, unsaved changes warning on leave - Admin layout: add navigation links for user management, roles, and notifications - admin-api: make request() method public for custom API calls - gitignore: add reports/mutation/ to exclude mutation test output
This commit is contained in:
@@ -1,11 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useAuth } from '@/components/admin/auth-context';
|
||||
import { adminApi } from '@/lib/admin-api';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import { ArrowLeft, Save, X } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
X,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Cloud,
|
||||
CloudOff,
|
||||
Loader2,
|
||||
Send,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
const MODEL_LABELS: Record<string, string> = {
|
||||
news: '新闻资讯',
|
||||
@@ -36,16 +57,21 @@ interface ModelData {
|
||||
fields: FieldDef[];
|
||||
}
|
||||
|
||||
// 自动保存延迟(毫秒)
|
||||
const AUTO_SAVE_DELAY = 3000;
|
||||
|
||||
function TagInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
error,
|
||||
}: {
|
||||
id: string;
|
||||
value: unknown;
|
||||
onChange: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
}) {
|
||||
const tags = Array.isArray(value) ? value.filter((t): t is string => typeof t === 'string') : [];
|
||||
const [input, setInput] = useState('');
|
||||
@@ -75,39 +101,54 @@ function TagInput({
|
||||
};
|
||||
|
||||
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}`}
|
||||
<div>
|
||||
<div
|
||||
id={id}
|
||||
className={`w-full min-h-[42px] px-3 py-2 border 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 ${
|
||||
error ? 'border-red-300 bg-red-50' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{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"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
{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>
|
||||
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({ error }: { error?: string }) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContentEditorPage() {
|
||||
const params = useParams();
|
||||
const modelCode = params.modelCode as string;
|
||||
@@ -124,9 +165,139 @@ export default function ContentEditorPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 自动保存状态
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'unsaved'>('idle');
|
||||
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isInitialMountRef = useRef(true);
|
||||
const hasUnsavedChangesRef = useRef(false);
|
||||
|
||||
// 发布确认对话框
|
||||
const [publishDialogOpen, setPublishDialogOpen] = useState(false);
|
||||
const [publishPending, setPublishPending] = useState(false);
|
||||
|
||||
const modelLabel = MODEL_LABELS[modelCode] || modelCode;
|
||||
|
||||
// 表单验证
|
||||
const validate = useCallback((): Record<string, string> => {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!title.trim()) {
|
||||
errors.title = '标题不能为空';
|
||||
}
|
||||
if (slug && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
|
||||
errors.slug = 'Slug 格式不正确,请使用小写字母、数字和连字符';
|
||||
}
|
||||
// 验证模型必填字段
|
||||
if (model) {
|
||||
model.fields.forEach((field) => {
|
||||
if (field.required) {
|
||||
const value = formData[field.name];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
errors[field.name] = `${field.label} 为必填项`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}, [title, slug, formData, model]);
|
||||
|
||||
// 实际保存操作
|
||||
const performSave = useCallback(async (newStatus?: string): Promise<boolean> => {
|
||||
const targetStatus = newStatus ?? status;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (isNew) {
|
||||
await adminApi.createItem({
|
||||
modelId: model?.id,
|
||||
modelCode,
|
||||
title: title.trim(),
|
||||
slug: slug.trim() || undefined,
|
||||
status: targetStatus,
|
||||
data: formData,
|
||||
});
|
||||
// 新建完成后不设置 autoSaveStatus,因为会导航离开
|
||||
return true;
|
||||
} else {
|
||||
await adminApi.updateItem(itemId, {
|
||||
title: title.trim(),
|
||||
slug: slug.trim() || undefined,
|
||||
status: targetStatus,
|
||||
data: formData,
|
||||
});
|
||||
setLastSaved(new Date());
|
||||
setAutoSaveStatus('saved');
|
||||
hasUnsavedChangesRef.current = false;
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '保存失败';
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
setAutoSaveStatus('unsaved');
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [isNew, itemId, model, modelCode, title, slug, status, formData]);
|
||||
|
||||
// 自动保存(仅编辑已有内容时)
|
||||
const triggerAutoSave = useCallback(() => {
|
||||
if (isNew) return;
|
||||
const errors = validate();
|
||||
if (Object.keys(errors).length > 0) {
|
||||
// 有验证错误时不自动保存,但标记为未保存
|
||||
setAutoSaveStatus('unsaved');
|
||||
return;
|
||||
}
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
autoSaveTimerRef.current = setTimeout(() => {
|
||||
setAutoSaveStatus('saving');
|
||||
performSave().catch(() => {
|
||||
// 错误已在 performSave 中处理
|
||||
});
|
||||
}, AUTO_SAVE_DELAY);
|
||||
}, [isNew, validate, performSave]);
|
||||
|
||||
// 标题/Slug/表单数据变更时触发自动保存
|
||||
useEffect(() => {
|
||||
if (isInitialMountRef.current) {
|
||||
isInitialMountRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (isNew) return;
|
||||
setAutoSaveStatus('unsaved');
|
||||
hasUnsavedChangesRef.current = true;
|
||||
triggerAutoSave();
|
||||
}, [title, slug, formData, isNew, triggerAutoSave]);
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 页面离开提醒
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (hasUnsavedChangesRef.current && !isNew) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
};
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [isNew]);
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
router.push('/admin/login');
|
||||
@@ -136,21 +307,18 @@ export default function ContentEditorPage() {
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
// 获取模型定义
|
||||
const modelsRes = await adminApi.getModels();
|
||||
const models = (modelsRes as ModelData[]) || [];
|
||||
const found = models.find((m) => m.code === modelCode);
|
||||
if (!found) throw new Error('模型未找到');
|
||||
setModel(found);
|
||||
|
||||
// 初始化默认值
|
||||
const defaults: Record<string, unknown> = {};
|
||||
found.fields.forEach((f) => {
|
||||
if (f.defaultValue !== undefined) defaults[f.name] = f.defaultValue;
|
||||
});
|
||||
setFormData(defaults);
|
||||
|
||||
// 如果是编辑,加载现有数据
|
||||
if (!isNew) {
|
||||
const itemsRes = await adminApi.getItems({ modelCode, page: 1, pageSize: 100 });
|
||||
const items = (itemsRes as { items: Array<{ id: string; title: string; slug: string; status: string; data: Record<string, unknown> }> }).items || [];
|
||||
@@ -162,6 +330,11 @@ export default function ContentEditorPage() {
|
||||
setFormData(item.data || {});
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载完成后,允许后续变更触发自动保存
|
||||
setTimeout(() => {
|
||||
isInitialMountRef.current = false;
|
||||
}, 0);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
@@ -173,50 +346,98 @@ export default function ContentEditorPage() {
|
||||
}, [user, authLoading, router, modelCode, itemId, isNew]);
|
||||
|
||||
const handleFieldChange = (name: string, value: unknown) => {
|
||||
// 清除该字段的验证错误
|
||||
setValidationErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!title.trim()) {
|
||||
toast.error('请输入标题');
|
||||
// 字段失焦验证
|
||||
const handleFieldBlur = (fieldName: string) => {
|
||||
if (!model) return;
|
||||
const field = model.fields.find((f) => f.name === fieldName);
|
||||
if (!field?.required) return;
|
||||
const value = formData[fieldName];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
setValidationErrors((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: `${field.label} 为必填项`,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 保存并返回列表
|
||||
const handleSaveAndBack = async () => {
|
||||
const errors = validate();
|
||||
setValidationErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
toast.error('请修正表单中的错误');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (isNew) {
|
||||
await adminApi.createItem({
|
||||
modelId: model?.id,
|
||||
modelCode,
|
||||
title: title.trim(),
|
||||
slug: slug.trim() || undefined,
|
||||
status,
|
||||
data: formData,
|
||||
});
|
||||
toast.success(`${modelLabel}创建成功`);
|
||||
} else {
|
||||
// 编辑时不提交 status,状态变更需通过工作流接口处理
|
||||
await adminApi.updateItem(itemId, {
|
||||
title: title.trim(),
|
||||
slug: slug.trim() || undefined,
|
||||
data: formData,
|
||||
});
|
||||
toast.success(`${modelLabel}保存成功`);
|
||||
}
|
||||
const success = await performSave();
|
||||
if (success) {
|
||||
toast.success(isNew ? `${modelLabel}创建成功` : `${modelLabel}保存成功`);
|
||||
router.push(`/admin/content/${modelCode}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '保存失败';
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 仅保存(不导航)
|
||||
const handleSaveOnly = async () => {
|
||||
const errors = validate();
|
||||
setValidationErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
toast.error('请修正表单中的错误');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNew) {
|
||||
// 新建内容直接保存并跳转
|
||||
const success = await performSave();
|
||||
if (success) {
|
||||
toast.success(`${modelLabel}创建成功`);
|
||||
router.push(`/admin/content/${modelCode}`);
|
||||
}
|
||||
} else {
|
||||
await performSave();
|
||||
if (autoSaveStatus === 'saved') {
|
||||
toast.success('已保存');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 点击发布按钮
|
||||
const handlePublishClick = () => {
|
||||
const errors = validate();
|
||||
setValidationErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
toast.error('请修正表单中的错误后再发布');
|
||||
return;
|
||||
}
|
||||
setPublishDialogOpen(true);
|
||||
};
|
||||
|
||||
// 确认发布
|
||||
const handlePublishConfirm = async () => {
|
||||
setPublishDialogOpen(false);
|
||||
setPublishPending(true);
|
||||
const success = await performSave('published');
|
||||
setPublishPending(false);
|
||||
if (success) {
|
||||
toast.success(`${modelLabel}已发布`);
|
||||
if (isNew) {
|
||||
router.push(`/admin/content/${modelCode}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderField = (field: FieldDef, prefix = '') => {
|
||||
const key = prefix + field.name;
|
||||
const value = formData[field.name];
|
||||
const error = validationErrors[field.name];
|
||||
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
@@ -234,9 +455,13 @@ export default function ContentEditorPage() {
|
||||
type="text"
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => handleFieldChange(field.name, e.target.value)}
|
||||
onBlur={() => handleFieldBlur(field.name)}
|
||||
placeholder={field.placeholder}
|
||||
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"
|
||||
className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent ${
|
||||
error ? 'border-red-300 bg-red-50' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
<FieldError error={error} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -254,10 +479,14 @@ export default function ContentEditorPage() {
|
||||
id={key}
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => handleFieldChange(field.name, e.target.value)}
|
||||
onBlur={() => handleFieldBlur(field.name)}
|
||||
placeholder={field.placeholder}
|
||||
rows={4}
|
||||
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 resize-y"
|
||||
className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent resize-y ${
|
||||
error ? 'border-red-300 bg-red-50' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
<FieldError error={error} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -273,8 +502,11 @@ export default function ContentEditorPage() {
|
||||
type="number"
|
||||
value={(value as number) ?? ''}
|
||||
onChange={(e) => handleFieldChange(field.name, parseFloat(e.target.value) || 0)}
|
||||
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"
|
||||
className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent ${
|
||||
error ? 'border-red-300 bg-red-50' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
<FieldError error={error} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -333,6 +565,7 @@ export default function ContentEditorPage() {
|
||||
value={value}
|
||||
onChange={(tags) => handleFieldChange(field.name, tags)}
|
||||
placeholder={field.placeholder}
|
||||
error={error}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -404,7 +637,12 @@ export default function ContentEditorPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push(`/admin/content/${modelCode}`)}
|
||||
onClick={() => {
|
||||
if (hasUnsavedChangesRef.current && !isNew) {
|
||||
if (!confirm('有未保存的更改,确定要离开吗?')) return;
|
||||
}
|
||||
router.push(`/admin/content/${modelCode}`);
|
||||
}}
|
||||
className="p-2 rounded hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
@@ -416,14 +654,79 @@ export default function ContentEditorPage() {
|
||||
<p className="text-sm text-gray-500 mt-1">{model?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 bg-gray-900 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 自动保存状态指示器 */}
|
||||
{!isNew && (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{autoSaveStatus === 'saving' && (
|
||||
<span className="text-amber-600 flex items-center gap-1">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
保存中...
|
||||
</span>
|
||||
)}
|
||||
{autoSaveStatus === 'saved' && (
|
||||
<span className="text-green-600 flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
已保存
|
||||
{lastSaved && (
|
||||
<span className="text-gray-400">
|
||||
{lastSaved.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{autoSaveStatus === 'unsaved' && (
|
||||
<span className="text-amber-600 flex items-center gap-1">
|
||||
<CloudOff className="w-3 h-3" />
|
||||
未保存
|
||||
</span>
|
||||
)}
|
||||
{autoSaveStatus === 'idle' && (
|
||||
<span className="text-gray-400 flex items-center gap-1">
|
||||
<Cloud className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 仅保存按钮 */}
|
||||
{!isNew && (
|
||||
<button
|
||||
onClick={handleSaveOnly}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
保存
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 发布按钮 */}
|
||||
{status !== 'published' && (
|
||||
<button
|
||||
onClick={handlePublishClick}
|
||||
disabled={saving || publishPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-700 text-white rounded-lg text-sm font-medium hover:bg-green-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{publishPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4" />
|
||||
)}
|
||||
发布
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 保存并返回 */}
|
||||
<button
|
||||
onClick={handleSaveAndBack}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 bg-gray-900 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? '保存中...' : isNew ? '创建' : '保存并返回'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -432,6 +735,21 @@ export default function ContentEditorPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 验证错误汇总 */}
|
||||
{Object.keys(validationErrors).length > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-amber-700 text-sm font-medium mb-1">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
请修正以下错误
|
||||
</div>
|
||||
<ul className="list-disc list-inside text-xs text-amber-600 space-y-0.5">
|
||||
{Object.entries(validationErrors).map(([field, msg]) => (
|
||||
<li key={field}>{msg}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 space-y-5">
|
||||
{/* 标题 */}
|
||||
@@ -443,10 +761,25 @@ export default function ContentEditorPage() {
|
||||
id="title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value);
|
||||
setValidationErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.title;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!title.trim()) {
|
||||
setValidationErrors((prev) => ({ ...prev, title: '标题不能为空' }));
|
||||
}
|
||||
}}
|
||||
placeholder="请输入标题"
|
||||
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"
|
||||
className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent ${
|
||||
validationErrors.title ? 'border-red-300 bg-red-50' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
<FieldError error={validationErrors.title} />
|
||||
</div>
|
||||
|
||||
{/* Slug */}
|
||||
@@ -456,10 +789,28 @@ export default function ContentEditorPage() {
|
||||
id="slug"
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSlug(e.target.value);
|
||||
setValidationErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.slug;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (slug && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
|
||||
setValidationErrors((prev) => ({
|
||||
...prev,
|
||||
slug: 'Slug 格式不正确,请使用小写字母、数字和连字符',
|
||||
}));
|
||||
}
|
||||
}}
|
||||
placeholder="URL 友好标识(留空自动生成)"
|
||||
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"
|
||||
className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent ${
|
||||
validationErrors.slug ? 'border-red-300 bg-red-50' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
<FieldError error={validationErrors.slug} />
|
||||
</div>
|
||||
|
||||
{/* 状态 */}
|
||||
@@ -481,17 +832,62 @@ export default function ContentEditorPage() {
|
||||
{model?.fields?.map((field) => renderField(field))}
|
||||
</div>
|
||||
|
||||
{/* Bottom save */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 bg-gray-900 text-white rounded-lg px-6 py-2.5 text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
{/* Bottom actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
{modelCode} / {isNew ? '新建' : itemId}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 仅保存按钮(底部) */}
|
||||
{!isNew && (
|
||||
<button
|
||||
onClick={handleSaveOnly}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2.5 border border-gray-300 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
保存
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 保存并返回(底部) */}
|
||||
<button
|
||||
onClick={handleSaveAndBack}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 bg-gray-900 text-white rounded-lg px-6 py-2.5 text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? '保存中...' : isNew ? '创建' : '保存并返回'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 发布确认对话框 */}
|
||||
<AlertDialog open={publishDialogOpen} onOpenChange={setPublishDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认发布</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
将 <strong>{title || '无标题'}</strong> 发布为公开内容。发布后所有用户均可访问该内容。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{Object.keys(validationErrors).length > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 text-xs text-amber-700">
|
||||
注意:表单中存在 {Object.keys(validationErrors).length} 个错误,发布可能会失败。
|
||||
</div>
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handlePublishConfirm}
|
||||
className="bg-green-700 hover:bg-green-600 text-white"
|
||||
>
|
||||
确认发布
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user