384 lines
14 KiB
TypeScript
384 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import { api } from '@/lib/api';
|
|
import type { FieldDef, ResourceConfig } from '@/lib/resources';
|
|
import { Button, Card, Empty, Input, Modal, Notice, Select, Textarea } from './ui';
|
|
|
|
const PAGE_SIZE = 20;
|
|
|
|
type Row = Record<string, unknown>;
|
|
type FormState = Record<string, string>;
|
|
|
|
function toInputValue(field: FieldDef, raw: unknown): string {
|
|
if (raw === null || raw === undefined) return '';
|
|
if (field.type === 'date') {
|
|
const d = new Date(String(raw));
|
|
return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10);
|
|
}
|
|
// 金额:库里是分,表单里给元
|
|
if (field.money) {
|
|
const fen = Number(raw);
|
|
if (!Number.isFinite(fen)) return '';
|
|
return (fen / 100).toFixed(2);
|
|
}
|
|
return String(raw);
|
|
}
|
|
|
|
/** 表单值统一按字符串持有,提交时按字段类型转换。 */
|
|
function buildPayload(fields: FieldDef[], form: FormState): Row {
|
|
const payload: Row = {};
|
|
for (const field of fields) {
|
|
const value = form[field.name];
|
|
if (value === undefined) continue;
|
|
if (value === '') {
|
|
payload[field.name] = field.type === 'number' || field.money ? 0 : null;
|
|
} else if (field.money) {
|
|
// 元 → 分,四舍五入到整数分,避免浮点误差(1.2 元 → 120 分)
|
|
payload[field.name] = Math.round(Number(value) * 100);
|
|
} else if (field.type === 'number' || field.type === 'select') {
|
|
payload[field.name] = Number(value);
|
|
} else {
|
|
payload[field.name] = value;
|
|
}
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function displayCell(field: FieldDef, row: Row): string {
|
|
const raw = row[field.name];
|
|
if (raw === null || raw === undefined || raw === '') return '—';
|
|
if (field.money) return `${fenToYuan(raw)}`;
|
|
if (field.type === 'select' && field.options) {
|
|
const hit = field.options.find((o) => o.value === Number(raw));
|
|
return hit ? hit.label : String(raw);
|
|
}
|
|
if (field.type === 'date') return String(raw).slice(0, 10);
|
|
return String(raw);
|
|
}
|
|
|
|
/** 分 → 元。金额一律固定两位小数,不省 .00(1 元显示成 1.00 元) */
|
|
function fenToYuan(fen: unknown): string {
|
|
if (fen === null || fen === undefined || fen === '') return '—';
|
|
const n = Number(fen);
|
|
if (!Number.isFinite(n)) return String(fen);
|
|
return (n / 100).toFixed(2);
|
|
}
|
|
|
|
export function ResourceTable({ config }: { config: ResourceConfig }) {
|
|
const [items, setItems] = useState<Row[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [keyword, setKeyword] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
/** 静态资源域名,配了卡片视图时才需要(用来拼封面图地址) */
|
|
const [staticBase, setStaticBase] = useState('');
|
|
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
const [editing, setEditing] = useState<Row | null>(null);
|
|
const [form, setForm] = useState<FormState>({});
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const tableFields = config.fields.filter((f) => !f.hideInTable);
|
|
|
|
const load = useCallback(
|
|
(nextPage: number, nextKeyword: string) => {
|
|
setLoading(true);
|
|
setError('');
|
|
api
|
|
.listResource(config.key, { page: nextPage, pageSize: PAGE_SIZE, keyword: nextKeyword })
|
|
.then((res) => {
|
|
setItems(res.items as Row[]);
|
|
setTotal(res.total);
|
|
setPage(res.page);
|
|
})
|
|
.catch((err: { message: string }) => setError(err.message))
|
|
.finally(() => setLoading(false));
|
|
},
|
|
[config.key],
|
|
);
|
|
|
|
useEffect(() => {
|
|
setKeyword('');
|
|
load(1, '');
|
|
}, [config.key, load]);
|
|
|
|
useEffect(() => {
|
|
if (!config.card) return;
|
|
api
|
|
.siteInfo()
|
|
.then((site) => setStaticBase(site.staticBaseUrl.replace(/\/+$/, '')))
|
|
.catch(() => {
|
|
// 取不到就只是不显示封面图,不影响列表本身
|
|
});
|
|
}, [config.card]);
|
|
|
|
const openCreate = () => {
|
|
const initial: FormState = {};
|
|
for (const f of config.fields) {
|
|
initial[f.name] = f.type === 'select' ? String(f.options?.[0]?.value ?? 1) : '';
|
|
}
|
|
setForm(initial);
|
|
setEditing(null);
|
|
setFormOpen(true);
|
|
};
|
|
|
|
const openEdit = (row: Row) => {
|
|
const initial: FormState = {};
|
|
for (const f of config.fields) initial[f.name] = toInputValue(f, row[f.name]);
|
|
setForm(initial);
|
|
setEditing(row);
|
|
setFormOpen(true);
|
|
};
|
|
|
|
const save = () => {
|
|
const editable = config.fields.filter((f) => !(editing && f.immutable));
|
|
const payload = buildPayload(editable, form);
|
|
setSaving(true);
|
|
setError('');
|
|
|
|
const action = editing
|
|
? api.updateResource(config.key, Number(editing.id), payload)
|
|
: api.createResource(config.key, payload);
|
|
|
|
action
|
|
.then(() => {
|
|
setFormOpen(false);
|
|
load(page, keyword);
|
|
})
|
|
.catch((err: { message: string }) => setError(err.message))
|
|
.finally(() => setSaving(false));
|
|
};
|
|
|
|
const remove = (row: Row) => {
|
|
if (!window.confirm('确定删除这条记录吗?此操作不可撤销。')) return;
|
|
setError('');
|
|
api
|
|
.deleteResource(config.key, Number(row.id))
|
|
.then(() => load(page, keyword))
|
|
.catch((err: { message: string }) => setError(err.message));
|
|
};
|
|
|
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Notice message={error} />
|
|
|
|
<Card
|
|
title={`${config.title}(共 ${total} 条)`}
|
|
action={
|
|
<div className="flex gap-2">
|
|
<Input value={keyword} onChange={setKeyword} placeholder="搜索…" />
|
|
<Button variant="ghost" onClick={() => load(1, keyword)}>
|
|
搜索
|
|
</Button>
|
|
<Button onClick={openCreate}>新建</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<p className="mb-3 text-xs text-gray-500">{config.description}</p>
|
|
|
|
{loading ? (
|
|
<div className="py-10 text-center text-sm text-gray-400">加载中…</div>
|
|
) : items.length === 0 ? (
|
|
<Empty />
|
|
) : config.card ? (
|
|
/* 卡片视图:短字段挤一行,长字段按标签-值显示(超过两行截断) */
|
|
/*
|
|
两个旋钮:
|
|
minmax(290px) —— 决定一行放几张:约 1500px 宽时正好 5 张
|
|
zoom: 0.8 —— 整张卡(文字/图标/内边距一起)等比缩放
|
|
用 zoom 而不是单独改字号,是因为长宽要一起缩;只改宽度会变成「更窄但一样高」。
|
|
*/
|
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(290px,1fr))] gap-3">
|
|
{items.map((row) => {
|
|
const card = config.card!;
|
|
const coverPath = card.cover(row);
|
|
const coverUrl = coverPath && staticBase ? `${staticBase}/${coverPath}` : '';
|
|
const allFields: FieldDef[] = [
|
|
{ name: 'id', label: 'ID', type: 'number' },
|
|
...config.fields,
|
|
];
|
|
const inline = allFields.filter((f) => card.inlineFields.includes(f.name));
|
|
// 底栏已经单独显示了 footerField,别再在正文里重复一遍
|
|
const rest = allFields.filter(
|
|
(f) => !card.inlineFields.includes(f.name) && f.name !== card.footerField?.name,
|
|
);
|
|
|
|
return (
|
|
<div
|
|
key={String(row.id)}
|
|
style={{ zoom: 0.8 }}
|
|
className="flex flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm transition-shadow hover:shadow-md"
|
|
>
|
|
{coverUrl && (
|
|
/* 9:5 与课程封面原始像素(900×500)一致,配合 contain 保证图片完整不裁剪 */
|
|
<img
|
|
src={coverUrl}
|
|
alt=""
|
|
loading="lazy"
|
|
className="aspect-[9/5] w-full bg-[#15457b] object-contain"
|
|
/>
|
|
)}
|
|
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-gray-100 px-3 py-2 text-xs text-gray-700">
|
|
{inline.map((f) => (
|
|
<span key={f.name}>
|
|
<span className="text-gray-400">{f.label} </span>
|
|
{displayCell(f, row)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
{/* 值一律单行截断(悬停看全文),换行会让卡片高度不可控 */}
|
|
<div className="flex-1 space-y-1 px-3 py-2">
|
|
{rest.map((f) => {
|
|
const text = displayCell(f, row);
|
|
return (
|
|
<div key={f.name} className="flex gap-2 text-xs leading-relaxed">
|
|
<span className="w-14 shrink-0 text-gray-400">{f.label}</span>
|
|
<span className="min-w-0 flex-1 truncate text-gray-700" title={text}>
|
|
{text}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between gap-2 border-t border-gray-100 px-3 py-2">
|
|
{card.footerField ? (
|
|
<span className="text-base font-medium text-gray-900">
|
|
<span className="text-xs text-gray-400">{card.footerField.label} </span>
|
|
{card.footerField.money
|
|
? `${fenToYuan(row[card.footerField.name])} 元`
|
|
: displayCell(
|
|
{ name: card.footerField.name, label: card.footerField.label, type: 'text' },
|
|
row,
|
|
)}
|
|
</span>
|
|
) : (
|
|
<span />
|
|
)}
|
|
<div className="flex gap-2">
|
|
<Button variant="ghost" onClick={() => openEdit(row)}>
|
|
编辑
|
|
</Button>
|
|
<Button variant="danger" onClick={() => remove(row)}>
|
|
删除
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-200 text-left text-xs text-gray-500">
|
|
<th className="px-2 py-2">ID</th>
|
|
{tableFields.map((f) => (
|
|
<th key={f.name} className="px-2 py-2">
|
|
{f.label}
|
|
</th>
|
|
))}
|
|
<th className="px-2 py-2 text-right">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((row) => (
|
|
<tr key={String(row.id)} className="border-b border-gray-100 hover:bg-gray-50">
|
|
<td className="px-2 py-2 text-gray-400">{String(row.id)}</td>
|
|
{tableFields.map((f) => {
|
|
const text = displayCell(f, row);
|
|
return (
|
|
<td key={f.name} className="max-w-[280px] truncate px-2 py-2" title={text}>
|
|
{text}
|
|
</td>
|
|
);
|
|
})}
|
|
<td className="whitespace-nowrap px-2 py-2 text-right">
|
|
<Button variant="ghost" onClick={() => openEdit(row)}>
|
|
编辑
|
|
</Button>{' '}
|
|
<Button variant="danger" onClick={() => remove(row)}>
|
|
删除
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-4 flex items-center justify-between text-sm text-gray-500">
|
|
<span>
|
|
第 {page} / {totalPages} 页
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<Button variant="ghost" disabled={page <= 1} onClick={() => load(page - 1, keyword)}>
|
|
上一页
|
|
</Button>
|
|
<Button variant="ghost" disabled={page >= totalPages} onClick={() => load(page + 1, keyword)}>
|
|
下一页
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Modal
|
|
open={formOpen}
|
|
title={`${editing ? '编辑' : '新建'}${config.title}`}
|
|
onClose={() => setFormOpen(false)}
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={() => setFormOpen(false)}>
|
|
取消
|
|
</Button>
|
|
<Button onClick={save} disabled={saving}>
|
|
{saving ? '保存中…' : '保存'}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-3">
|
|
{config.fields.map((field) => {
|
|
const disabled = Boolean(editing && field.immutable);
|
|
return (
|
|
<div key={field.name}>
|
|
<label className="mb-1 block text-sm text-gray-600">
|
|
{field.label}
|
|
{field.immutable && <span className="ml-2 text-xs text-orange-500">创建后不可修改</span>}
|
|
</label>
|
|
{field.type === 'textarea' ? (
|
|
<Textarea value={form[field.name] ?? ''} onChange={(v) => setForm({ ...form, [field.name]: v })} />
|
|
) : field.type === 'select' ? (
|
|
<Select
|
|
value={form[field.name] ?? ''}
|
|
onChange={(v) => setForm({ ...form, [field.name]: v })}
|
|
options={field.options ?? []}
|
|
/>
|
|
) : (
|
|
<div className={disabled ? 'pointer-events-none opacity-60' : ''}>
|
|
<Input
|
|
value={form[field.name] ?? ''}
|
|
onChange={(v) => setForm({ ...form, [field.name]: v })}
|
|
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
|
|
placeholder={field.placeholder}
|
|
/>
|
|
</div>
|
|
)}
|
|
{field.hint && <p className="mt-1 text-xs text-gray-400">{field.hint}</p>}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|