Files
novalon-website/src/app/admin/content/[modelCode]/[itemId]/page.tsx
T
zhangxiang c480772aec 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
2026-07-31 23:05:24 +08:00

893 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}