- 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
893 lines
30 KiB
TypeScript
893 lines
30 KiB
TypeScript
'use client';
|
||
|
||
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,
|
||
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: '新闻资讯',
|
||
'case-study': '案例研究',
|
||
service: '服务管理',
|
||
product: '产品管理',
|
||
solution: '解决方案',
|
||
'hero-banner': 'Hero Banner',
|
||
'stat-item': '数据指标',
|
||
};
|
||
|
||
interface FieldDef {
|
||
name: string;
|
||
type: string;
|
||
label: string;
|
||
required?: boolean;
|
||
description?: string;
|
||
placeholder?: string;
|
||
defaultValue?: unknown;
|
||
options?: Array<{ label: string; value: string | number | boolean }>;
|
||
fields?: FieldDef[];
|
||
}
|
||
|
||
interface ModelData {
|
||
id: string;
|
||
code: string;
|
||
name: string;
|
||
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('');
|
||
|
||
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>
|
||
<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"
|
||
>
|
||
{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;
|
||
const itemId = params.itemId as string;
|
||
const isNew = itemId === 'new';
|
||
const { user, loading: authLoading } = useAuth();
|
||
const router = useRouter();
|
||
|
||
const [model, setModel] = useState<ModelData | null>(null);
|
||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||
const [title, setTitle] = useState('');
|
||
const [slug, setSlug] = useState('');
|
||
const [status, setStatus] = useState('draft');
|
||
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');
|
||
return;
|
||
}
|
||
if (!user) return;
|
||
|
||
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 || [];
|
||
const item = items.find((i) => i.id === itemId);
|
||
if (item) {
|
||
setTitle(item.title);
|
||
setSlug(item.slug);
|
||
setStatus(item.status);
|
||
setFormData(item.data || {});
|
||
}
|
||
}
|
||
|
||
// 初始加载完成后,允许后续变更触发自动保存
|
||
setTimeout(() => {
|
||
isInitialMountRef.current = false;
|
||
}, 0);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '加载失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
load();
|
||
}, [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 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;
|
||
}
|
||
|
||
const success = await performSave();
|
||
if (success) {
|
||
toast.success(isNew ? `${modelLabel}创建成功` : `${modelLabel}保存成功`);
|
||
router.push(`/admin/content/${modelCode}`);
|
||
}
|
||
};
|
||
|
||
// 仅保存(不导航)
|
||
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':
|
||
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>
|
||
)}
|
||
<input
|
||
id={key}
|
||
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 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>
|
||
);
|
||
|
||
case 'textarea':
|
||
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>
|
||
)}
|
||
<textarea
|
||
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 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>
|
||
);
|
||
|
||
case 'number':
|
||
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>
|
||
<input
|
||
id={key}
|
||
type="number"
|
||
value={(value as number) ?? ''}
|
||
onChange={(e) => handleFieldChange(field.name, parseFloat(e.target.value) || 0)}
|
||
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>
|
||
);
|
||
|
||
case 'boolean':
|
||
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 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>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
case 'select':
|
||
case 'dropdown':
|
||
return (
|
||
<div key={key}>
|
||
<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"
|
||
>
|
||
<option value="">请选择</option>
|
||
{field.options?.map((opt) => (
|
||
<option key={String(opt.value)} value={String(opt.value)}>
|
||
{opt.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</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}
|
||
error={error}
|
||
/>
|
||
</div>
|
||
);
|
||
|
||
case 'image':
|
||
return (
|
||
<div key={key}>
|
||
<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)}
|
||
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"
|
||
/>
|
||
{!!value && (
|
||
<img
|
||
src={String(value)}
|
||
alt="预览"
|
||
className="mt-2 max-h-32 rounded border border-gray-200"
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
case 'array':
|
||
case 'object':
|
||
return (
|
||
<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}
|
||
</legend>
|
||
{field.fields?.map((subField) => renderField(subField, `${field.name}.`))}
|
||
</fieldset>
|
||
);
|
||
|
||
default:
|
||
return (
|
||
<div key={key}>
|
||
<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)}
|
||
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"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
};
|
||
|
||
if (authLoading || loading) {
|
||
return (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="animate-spin w-8 h-8 border-2 border-gray-900 border-t-transparent rounded-full" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6 max-w-3xl">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<button
|
||
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" />
|
||
</button>
|
||
<div>
|
||
<h1 className="text-xl font-bold text-gray-900">
|
||
{isNew ? `新增${modelLabel}` : `编辑${modelLabel}`}
|
||
</h1>
|
||
<p className="text-sm text-gray-500 mt-1">{model?.name}</p>
|
||
</div>
|
||
</div>
|
||
<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 && (
|
||
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg px-4 py-3 text-sm">
|
||
{error}
|
||
</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">
|
||
{/* 标题 */}
|
||
<div>
|
||
<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);
|
||
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 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 */}
|
||
<div>
|
||
<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);
|
||
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 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>
|
||
|
||
{/* 状态 */}
|
||
<div>
|
||
<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"
|
||
>
|
||
<option value="draft">草稿</option>
|
||
<option value="published">已发布</option>
|
||
<option value="archived">已归档</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* 模型字段 */}
|
||
{model?.fields?.map((field) => renderField(field))}
|
||
</div>
|
||
|
||
{/* 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>
|
||
);
|
||
} |