四端上传

This commit is contained in:
tianmo committed 2026-09-29 15:25:40 +08:00
1 parent 25bbb5f9c8
commit c6e2cb4d5b
276 files changed
+17812 -3

No files matched your search

@@ -0,0 +1,264 @@
'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);
}
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' ? 0 : null;
} 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.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);
}
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 [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]);
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 />
) : (
<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>
);
}