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:
张翔
2026-07-07 06:53:58 +08:00
parent 829d83522c
commit b5245f9aa2
87 changed files with 25659 additions and 0 deletions
@@ -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>
);
}
+239
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+104
View File
@@ -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>
);
}
+177
View File
@@ -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>
);
}
+93
View File
@@ -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>
);
}
+152
View File
@@ -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>
);
}