'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; type FormState = Record; 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([]); 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(null); const [form, setForm] = useState({}); 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 (
} >

{config.description}

{loading ? (
加载中…
) : items.length === 0 ? ( ) : config.card ? ( /* 卡片视图:短字段挤一行,长字段按标签-值显示(超过两行截断) */ /* 两个旋钮: minmax(290px) —— 决定一行放几张:约 1500px 宽时正好 5 张 zoom: 0.8 —— 整张卡(文字/图标/内边距一起)等比缩放 用 zoom 而不是单独改字号,是因为长宽要一起缩;只改宽度会变成「更窄但一样高」。 */
{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 (
{coverUrl && ( /* 9:5 与课程封面原始像素(900×500)一致,配合 contain 保证图片完整不裁剪 */ )}
{inline.map((f) => ( {f.label} {displayCell(f, row)} ))}
{/* 值一律单行截断(悬停看全文),换行会让卡片高度不可控 */}
{rest.map((f) => { const text = displayCell(f, row); return (
{f.label} {text}
); })}
{card.footerField ? ( {card.footerField.label} {card.footerField.money ? `${fenToYuan(row[card.footerField.name])} 元` : displayCell( { name: card.footerField.name, label: card.footerField.label, type: 'text' }, row, )} ) : ( )}
); })}
) : (
{tableFields.map((f) => ( ))} {items.map((row) => ( {tableFields.map((f) => { const text = displayCell(f, row); return ( ); })} ))}
ID {f.label} 操作
{String(row.id)} {text} {' '}
)}
第 {page} / {totalPages} 页
setFormOpen(false)} footer={ <> } >
{config.fields.map((field) => { const disabled = Boolean(editing && field.immutable); return (
{field.type === 'textarea' ? (