feat(cms): 添加 CMS 内容管理系统与 Admin 管理后台
- 新增 Prisma + SQLite 数据库模型 (Category, Content, Media, User 等) - 新增 Admin 管理后台 (认证、内容管理、媒体管理) - 新增 CMS API 路由 (CRUD, 草稿/发布, 重新验证) - 新增 CMS 内容版本的历史归档页面 - 新增 components/cms 内容渲染组件 - 新增 components/admin 管理后台 UI 组件 - 更新 Contact API 路由
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
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 modelLabel = MODEL_LABELS[modelCode] || modelCode;
|
||||
|
||||
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 || {});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
}, [user, authLoading, router, modelCode, itemId, isNew]);
|
||||
|
||||
const handleFieldChange = (name: string, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!title.trim()) {
|
||||
alert('请输入标题');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (isNew) {
|
||||
await adminApi.createItem({
|
||||
modelId: model?.id,
|
||||
modelCode,
|
||||
title: title.trim(),
|
||||
slug: slug.trim() || undefined,
|
||||
status,
|
||||
data: formData,
|
||||
});
|
||||
} else {
|
||||
await adminApi.updateItem(itemId, {
|
||||
title: title.trim(),
|
||||
slug: slug.trim() || undefined,
|
||||
status,
|
||||
data: formData,
|
||||
});
|
||||
}
|
||||
router.push(`/admin/content/${modelCode}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderField = (field: FieldDef, prefix = '') => {
|
||||
const key = prefix + field.name;
|
||||
const value = formData[field.name];
|
||||
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<div key={key}>
|
||||
<label 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
|
||||
type="text"
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => handleFieldChange(field.name, e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<div key={key}>
|
||||
<label 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
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => handleFieldChange(field.name, e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<div key={key}>
|
||||
<label 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
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3">
|
||||
<input
|
||||
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>
|
||||
{field.description && (
|
||||
<span className="text-xs text-gray-400">{field.description}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
case 'dropdown':
|
||||
return (
|
||||
<div key={key}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{field.label}
|
||||
</label>
|
||||
<select
|
||||
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 'image':
|
||||
return (
|
||||
<div key={key}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
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 (
|
||||
<div key={key} className="border border-gray-200 rounded-lg p-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
{field.label}
|
||||
</label>
|
||||
{field.fields?.map((subField) => renderField(subField, `${field.name}.`))}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div key={key}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
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={() => 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>
|
||||
<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>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg px-4 py-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<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">
|
||||
标题 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slug */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 状态 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">状态</label>
|
||||
<select
|
||||
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 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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
'use client';
|
||||
|
||||
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 { Plus, Edit, Trash2, Search, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
const MODEL_LABELS: Record<string, string> = {
|
||||
news: '新闻资讯',
|
||||
'case-study': '案例研究',
|
||||
service: '服务管理',
|
||||
product: '产品管理',
|
||||
solution: '解决方案',
|
||||
'hero-banner': 'Hero Banner',
|
||||
'stat-item': '数据指标',
|
||||
};
|
||||
|
||||
interface ContentItem {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
data: Record<string, unknown>;
|
||||
sortOrder: number;
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default function ContentListPage() {
|
||||
const params = useParams();
|
||||
const modelCode = params.modelCode as string;
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [items, setItems] = useState<ContentItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const pageSize = 10;
|
||||
|
||||
const modelLabel = MODEL_LABELS[modelCode] || modelCode;
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await adminApi.getItems({
|
||||
modelCode,
|
||||
page,
|
||||
pageSize,
|
||||
...(search ? { search } : {}),
|
||||
});
|
||||
const data = res as { items: ContentItem[]; total: number };
|
||||
setItems(data?.items || []);
|
||||
setTotal(data?.total || 0);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [modelCode, page, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
if (user) fetchItems();
|
||||
}, [user, authLoading, router, fetchItems]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除此内容吗?')) return;
|
||||
try {
|
||||
await adminApi.deleteItem(id);
|
||||
fetchItems();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
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">{modelLabel}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">共 {total} 条内容</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push(`/admin/content/${modelCode}/new`)}
|
||||
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"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
新增
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
placeholder={`搜索${modelLabel}...`}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg px-4 py-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase">标题</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase">Slug</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase">状态</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase">更新时间</th>
|
||||
<th className="text-right px-4 py-3 text-xs font-medium text-gray-500 uppercase">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-12 text-center text-gray-400">
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-gray-300 border-t-gray-900 rounded-full" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-12 text-center text-gray-400">
|
||||
暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm font-medium text-gray-900">{item.title}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.slug || '-'}
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
item.status === 'published'
|
||||
? 'bg-green-50 text-green-700'
|
||||
: item.status === 'draft'
|
||||
? 'bg-yellow-50 text-yellow-700'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{item.status === 'published' ? '已发布' : item.status === 'draft' ? '草稿' : '已归档'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{item.updatedAt ? new Date(item.updatedAt).toLocaleDateString('zh-CN') : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => router.push(`/admin/content/${modelCode}/${item.id}`)}
|
||||
className="p-1.5 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="p-1.5 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200">
|
||||
<span className="text-sm text-gray-500">
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="p-1.5 rounded hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="p-1.5 rounded hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { AuthProvider } from '@/components/admin/auth-context';
|
||||
import { AdminLayout } from '@/components/admin/admin-layout';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export default function AdminRootLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const isLoginPage = pathname === '/admin/login';
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
{isLoginPage ? (
|
||||
children
|
||||
) : (
|
||||
<AdminLayout>{children}</AdminLayout>
|
||||
)}
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/admin/auth-context';
|
||||
import { Eye, EyeOff, LogIn } from 'lucide-react';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
router.push('/admin');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Novalon CMS</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">内容管理系统</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5"
|
||||
>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg px-4 py-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent transition-shadow"
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2.5 pr-10 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent transition-shadow"
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 bg-gray-900 text-white rounded-lg py-2.5 text-sm font-medium hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
|
||||
) : (
|
||||
<LogIn className="w-4 h-4" />
|
||||
)}
|
||||
{loading ? '登录中...' : '登 录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
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 { Upload, Trash2, Copy, Check, Image, File } from 'lucide-react';
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function MediaPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [items, setItems] = useState<MediaItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fetchMedia = async () => {
|
||||
try {
|
||||
const res = await adminApi.getMedia({ pageSize: 100 });
|
||||
const data = (res as { items: MediaItem[] });
|
||||
setItems(data?.items || []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
if (user) fetchMedia();
|
||||
}, [user, authLoading, router]);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
await adminApi.uploadMedia(file);
|
||||
await fetchMedia();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除此文件吗?')) return;
|
||||
try {
|
||||
await adminApi.deleteMedia(id);
|
||||
await fetchMedia();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyUrl = (url: string, id: string) => {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const isImage = (mimeType: string) => mimeType.startsWith('image/');
|
||||
|
||||
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">
|
||||
<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">共 {items.length} 个文件</p>
|
||||
</div>
|
||||
<label 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 cursor-pointer transition-colors">
|
||||
<Upload className="w-4 h-4" />
|
||||
{uploading ? '上传中...' : '上传文件'}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white rounded-lg border border-gray-200 overflow-hidden group hover:shadow-md transition-shadow"
|
||||
>
|
||||
{isImage(item.mimeType) ? (
|
||||
<div className="aspect-square bg-gray-100 flex items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-square bg-gray-100 flex items-center justify-center">
|
||||
<File className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-2">
|
||||
<p className="text-xs text-gray-700 truncate" title={item.name}>
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{formatSize(item.size)}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => handleCopyUrl(item.url, item.id)}
|
||||
className="flex-1 flex items-center justify-center gap-1 py-1.5 text-xs text-gray-500 hover:bg-gray-50 transition-colors"
|
||||
title="复制链接"
|
||||
>
|
||||
{copiedId === item.id ? (
|
||||
<Check className="w-3 h-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3" />
|
||||
)}
|
||||
复制
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(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="删除"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/admin/auth-context';
|
||||
import { adminApi } from '@/lib/admin-api';
|
||||
import { FileText, Briefcase, Box, Layers, Image, BarChart3, Newspaper, Settings } from 'lucide-react';
|
||||
|
||||
const modelCards = [
|
||||
{ code: 'news', name: '新闻资讯', icon: Newspaper, color: 'bg-blue-50 text-blue-600' },
|
||||
{ code: 'case-study', name: '案例研究', icon: Briefcase, color: 'bg-amber-50 text-amber-600' },
|
||||
{ code: 'service', name: '服务管理', icon: Settings, color: 'bg-green-50 text-green-600' },
|
||||
{ code: 'product', name: '产品管理', icon: Box, color: 'bg-purple-50 text-purple-600' },
|
||||
{ code: 'solution', name: '解决方案', icon: Layers, color: 'bg-teal-50 text-teal-600' },
|
||||
{ code: 'hero-banner', name: 'Hero Banner', icon: Image, color: 'bg-rose-50 text-rose-600' },
|
||||
{ code: 'stat-item', name: '数据指标', icon: BarChart3, color: 'bg-indigo-50 text-indigo-600' },
|
||||
];
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [stats, setStats] = useState({ models: 0, items: 0, zones: 0 });
|
||||
const [fetching, setFetching] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
if (user) {
|
||||
adminApi.getStats().then((s) => setStats(s)).catch(console.error).finally(() => setFetching(false));
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
if (loading || (!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">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">仪表盘</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">欢迎回来,{user?.nickname || user?.username}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: '内容模型', value: stats.models, icon: FileText },
|
||||
{ label: '内容条目', value: stats.items, icon: Layers },
|
||||
{ label: '页面区域', value: stats.zones, icon: Briefcase },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{stat.label}</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">
|
||||
{fetching ? '-' : stat.value}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center">
|
||||
<stat.icon className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-sm font-semibold text-gray-900 mb-4">内容管理</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{modelCards.map((card) => (
|
||||
<button
|
||||
key={card.code}
|
||||
onClick={() => router.push(`/admin/content/${card.code}`)}
|
||||
className="flex flex-col items-center gap-2 p-4 rounded-lg border border-gray-200 hover:border-gray-300 hover:bg-gray-50 transition-colors text-center"
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg ${card.color} flex items-center justify-center`}>
|
||||
<card.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-700">{card.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/admin/auth-context';
|
||||
import { adminApi } from '@/lib/admin-api';
|
||||
import { Save } from 'lucide-react';
|
||||
|
||||
interface ZoneItem {
|
||||
itemId: string;
|
||||
sortOrder: number;
|
||||
variant: string;
|
||||
}
|
||||
|
||||
interface Zone {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
pageCode: string;
|
||||
zoneKey: string;
|
||||
allowedModels: string[];
|
||||
items: ZoneItem[];
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export default function ZonesPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [zones, setZones] = useState<Zone[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingZone, setEditingZone] = useState<Zone | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
if (user) {
|
||||
adminApi.getZones().then((res) => {
|
||||
setZones((res as Zone[]) || []);
|
||||
}).catch(console.error).finally(() => setLoading(false));
|
||||
}
|
||||
}, [user, authLoading, router]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editingZone) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await adminApi.saveZone({
|
||||
id: editingZone.id,
|
||||
code: editingZone.code,
|
||||
name: editingZone.name,
|
||||
pageCode: editingZone.pageCode,
|
||||
zoneKey: editingZone.zoneKey,
|
||||
allowedModels: editingZone.allowedModels,
|
||||
items: editingZone.items,
|
||||
settings: editingZone.settings,
|
||||
});
|
||||
setEditingZone(null);
|
||||
// 重新加载
|
||||
const res = await adminApi.getZones();
|
||||
setZones((res as Zone[]) || []);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">页面区域配置</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">管理系统各页面的内容区域布局</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{zones.map((zone) => (
|
||||
<div key={zone.id} className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">{zone.name}</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Code: {zone.code} | 页面: {zone.pageCode || '-'} | 区域: {zone.zoneKey || '-'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
允许模型: {zone.allowedModels?.join(', ') || '-'} | 内容数: {zone.items?.length || 0}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEditingZone(editingZone?.id === zone.id ? null : zone)}
|
||||
className="text-sm text-gray-600 hover:text-gray-900 px-3 py-1.5 rounded border border-gray-300 hover:border-gray-400 transition-colors"
|
||||
>
|
||||
{editingZone?.id === zone.id ? '取消编辑' : '编辑'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editingZone?.id === zone.id && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingZone.name}
|
||||
onChange={(e) => setEditingZone({ ...editingZone, name: e.target.value })}
|
||||
className="w-full px-3 py-1.5 border border-gray-300 rounded text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">页面标识</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingZone.pageCode}
|
||||
onChange={(e) => setEditingZone({ ...editingZone, pageCode: e.target.value })}
|
||||
className="w-full px-3 py-1.5 border border-gray-300 rounded text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setEditingZone(null)}
|
||||
className="text-sm text-gray-600 px-4 py-1.5 rounded border border-gray-300 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 text-sm bg-gray-900 text-white px-4 py-1.5 rounded hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user