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:
@@ -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"
|
||||
|
||||
@@ -4,6 +4,17 @@ import { useEffect, useState, 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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Plus, Edit, Trash2, Search, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
const MODEL_LABELS: Record<string, string> = {
|
||||
@@ -39,6 +50,8 @@ export default function ContentListPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const pageSize = 10;
|
||||
|
||||
const modelLabel = MODEL_LABELS[modelCode] || modelCode;
|
||||
@@ -71,13 +84,23 @@ export default function ContentListPage() {
|
||||
if (user) fetchItems();
|
||||
}, [user, authLoading, router, fetchItems]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除此内容吗?')) return;
|
||||
const openDeleteDialog = (id: string) => {
|
||||
setPendingDeleteId(id);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!pendingDeleteId) return;
|
||||
try {
|
||||
await adminApi.deleteItem(id);
|
||||
await adminApi.deleteItem(pendingDeleteId);
|
||||
toast.success('删除成功');
|
||||
fetchItems();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '删除失败');
|
||||
const message = err instanceof Error ? err.message : '删除失败';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setPendingDeleteId(null);
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -194,7 +217,7 @@ export default function ContentListPage() {
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(item.id)}
|
||||
onClick={() => openDeleteDialog(item.id)}
|
||||
className="p-1.5 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
@@ -234,6 +257,27 @@ export default function ContentListPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
此操作不可撤销,确定要删除这条{modelLabel}吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setPendingDeleteId(null)}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,17 @@ import { useEffect, useState, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/admin/auth-context';
|
||||
import { adminApi } from '@/lib/admin-api';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Upload, Trash2, Copy, Check, Image, File } from 'lucide-react';
|
||||
|
||||
interface MediaItem {
|
||||
@@ -22,6 +33,8 @@ export default function MediaPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fetchMedia = async () => {
|
||||
@@ -51,22 +64,34 @@ export default function MediaPage() {
|
||||
setUploading(true);
|
||||
try {
|
||||
await adminApi.uploadMedia(file);
|
||||
toast.success('文件上传成功');
|
||||
await fetchMedia();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '上传失败');
|
||||
const message = err instanceof Error ? err.message : '上传失败';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除此文件吗?')) return;
|
||||
const openDeleteDialog = (id: string) => {
|
||||
setPendingDeleteId(id);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!pendingDeleteId) return;
|
||||
try {
|
||||
await adminApi.deleteMedia(id);
|
||||
await adminApi.deleteMedia(pendingDeleteId);
|
||||
toast.success('文件删除成功');
|
||||
await fetchMedia();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '删除失败');
|
||||
const message = err instanceof Error ? err.message : '删除失败';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setPendingDeleteId(null);
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -154,7 +179,7 @@ export default function MediaPage() {
|
||||
复制
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(item.id)}
|
||||
onClick={() => openDeleteDialog(item.id)}
|
||||
className="flex-1 flex items-center justify-center gap-1 py-1.5 text-xs text-gray-500 hover:bg-red-50 hover:text-red-500 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
@@ -167,11 +192,44 @@ export default function MediaPage() {
|
||||
</div>
|
||||
|
||||
{items.length === 0 && !loading && (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<Image className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p>暂无文件,点击上方按钮上传</p>
|
||||
<div className="text-center py-16 bg-white rounded-lg border border-dashed border-gray-300">
|
||||
<Image className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-1">暂无媒体文件</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">上传图片、文档等资源,即可在内容编辑中使用</p>
|
||||
<label className="inline-flex items-center gap-2 bg-gray-900 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-gray-800 cursor-pointer transition-colors">
|
||||
<Upload className="w-4 h-4" />
|
||||
上传文件
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
此操作不可撤销,确定要删除该媒体文件吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setPendingDeleteId(null)}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/admin/auth-context';
|
||||
import { adminApi } from '@/lib/admin-api';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import { Shield, Check, X, Save, Loader2, Lock } from 'lucide-react';
|
||||
|
||||
interface Role {
|
||||
code: string;
|
||||
name: string;
|
||||
builtin: boolean;
|
||||
}
|
||||
|
||||
interface Model {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
type PermissionAction = 'create' | 'read' | 'update' | 'delete' | 'publish';
|
||||
|
||||
const ACTION_LABELS: Record<PermissionAction, string> = {
|
||||
create: '创建',
|
||||
read: '查看',
|
||||
update: '编辑',
|
||||
delete: '删除',
|
||||
publish: '发布/审核',
|
||||
};
|
||||
|
||||
const ACTION_ORDER: PermissionAction[] = ['create', 'read', 'update', 'delete', 'publish'];
|
||||
|
||||
export default function RolesPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [models, setModels] = useState<Model[]>([]);
|
||||
const [permissions, setPermissions] = useState<Set<string>>(new Set());
|
||||
const [selectedRole, setSelectedRole] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const fetchRoles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await adminApi.getRoles();
|
||||
setRoles(data.roles || []);
|
||||
setModels(data.models || []);
|
||||
|
||||
const permSet = new Set<string>();
|
||||
(data.permissions || []).forEach((p) => {
|
||||
permSet.add(`${p.roleCode}:${p.modelCode}:${p.action}`);
|
||||
});
|
||||
setPermissions(permSet);
|
||||
|
||||
if (data.roles?.length > 0 && !selectedRole) {
|
||||
// 默认选中第一个非 super_admin 角色,方便配置
|
||||
const defaultRole = data.roles.find((r) => r.code !== 'super_admin') || data.roles[0];
|
||||
if (defaultRole) {
|
||||
setSelectedRole(defaultRole.code);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedRole]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
if (user) fetchRoles();
|
||||
}, [user, authLoading, router, fetchRoles]);
|
||||
|
||||
const selectedRoleInfo = roles.find((r) => r.code === selectedRole);
|
||||
const isEditable = selectedRoleInfo && selectedRoleInfo.code !== 'super_admin';
|
||||
|
||||
const togglePermission = (modelCode: string, action: PermissionAction) => {
|
||||
if (!isEditable) return;
|
||||
const key = `${selectedRole}:${modelCode}:${action}`;
|
||||
setPermissions((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const hasPermission = (roleCode: string, modelCode: string, action: PermissionAction) => {
|
||||
return permissions.has(`${roleCode}:${modelCode}:${action}`);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!isEditable) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const rolePerms: Array<{ modelCode: string; action: PermissionAction }> = [];
|
||||
models.forEach((m) => {
|
||||
ACTION_ORDER.forEach((action) => {
|
||||
if (hasPermission(selectedRole, m.code, action)) {
|
||||
rolePerms.push({ modelCode: m.code, action });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await adminApi.updateRolePermissions(selectedRole, rolePerms);
|
||||
toast.success('权限已保存');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '保存失败';
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || (!user && typeof window !== 'undefined')) {
|
||||
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">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">角色权限</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">按角色配置内容模型的操作权限</p>
|
||||
</div>
|
||||
{isEditable && (
|
||||
<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 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
保存权限
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg px-4 py-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Role list */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden lg:col-span-1">
|
||||
<div className="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<h2 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
角色列表
|
||||
</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{loading ? (
|
||||
<div className="p-8 flex justify-center">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-gray-300 border-t-gray-900 rounded-full" />
|
||||
</div>
|
||||
) : roles.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-gray-400">暂无角色</div>
|
||||
) : (
|
||||
roles.map((role) => (
|
||||
<button
|
||||
key={role.code}
|
||||
onClick={() => setSelectedRole(role.code)}
|
||||
className={`w-full text-left px-4 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors ${
|
||||
selectedRole === role.code ? 'bg-gray-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${selectedRole === role.code ? 'text-gray-900' : 'text-gray-700'}`}>
|
||||
{role.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{role.code}</p>
|
||||
</div>
|
||||
{role.code === 'super_admin' && (
|
||||
<Lock className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permission matrix */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden lg:col-span-3">
|
||||
<div className="px-4 py-3 border-b border-gray-200 bg-gray-50 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-900">
|
||||
{selectedRoleInfo ? `${selectedRoleInfo.name} 权限矩阵` : '权限矩阵'}
|
||||
</h2>
|
||||
{selectedRoleInfo?.code === 'super_admin' && (
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" />
|
||||
内置超级管理员拥有所有权限,不可修改
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedRoleInfo || models.length === 0 ? (
|
||||
<div className="p-12 text-center text-sm text-gray-400">暂无数据</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px]">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase sticky left-0 bg-white min-w-[160px]">
|
||||
内容模型
|
||||
</th>
|
||||
{ACTION_ORDER.map((action) => (
|
||||
<th
|
||||
key={action}
|
||||
className="text-center px-2 py-3 text-xs font-medium text-gray-500 uppercase w-[1%] whitespace-nowrap"
|
||||
>
|
||||
{ACTION_LABELS[action]}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{models.map((model) => (
|
||||
<tr key={model.code} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3 sticky left-0 bg-white">
|
||||
<p className="text-sm font-medium text-gray-900">{model.name}</p>
|
||||
<code className="text-xs text-gray-400">{model.code}</code>
|
||||
</td>
|
||||
{ACTION_ORDER.map((action) => {
|
||||
const enabled = hasPermission(selectedRole, model.code, action);
|
||||
return (
|
||||
<td key={action} className="px-2 py-3 text-center">
|
||||
<button
|
||||
onClick={() => togglePermission(model.code, action)}
|
||||
disabled={!isEditable}
|
||||
className={`inline-flex items-center justify-center w-8 h-8 rounded-md transition-colors ${
|
||||
enabled
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'bg-gray-100 text-gray-300'
|
||||
} ${
|
||||
isEditable
|
||||
? 'hover:opacity-80 cursor-pointer'
|
||||
: 'cursor-not-allowed'
|
||||
}`}
|
||||
title={enabled ? '已授权' : '未授权'}
|
||||
>
|
||||
{enabled ? <Check className="w-4 h-4" /> : <X className="w-4 h-4" />}
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/admin/auth-context';
|
||||
import { adminApi } from '@/lib/admin-api';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import { Save } from 'lucide-react';
|
||||
|
||||
interface ZoneItem {
|
||||
@@ -58,11 +59,13 @@ export default function ZonesPage() {
|
||||
settings: editingZone.settings,
|
||||
});
|
||||
setEditingZone(null);
|
||||
toast.success('页面区域保存成功');
|
||||
// 重新加载
|
||||
const res = await adminApi.getZones();
|
||||
setZones((res as Zone[]) || []);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '保存失败');
|
||||
const message = err instanceof Error ? err.message : '保存失败';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user