591 lines
21 KiB
TypeScript
591 lines
21 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import { ArrowDown, ArrowUp, Database, Edit3, FileCheck2, Link2, Plus, Search, Trash2 } from 'lucide-react';
|
||
import { Breadcrumb, Button, Input, Modal, Select, Textarea, Tag } from '@/components/ui';
|
||
import { adminApi, type CommonReportField, type DictionaryItem } from '@/api/adminApi';
|
||
|
||
type DrainageField = DictionaryItem & {
|
||
code?: string;
|
||
name?: string;
|
||
fieldType?: string;
|
||
required?: boolean;
|
||
description?: string | null;
|
||
usageCount?: number;
|
||
commonUsageCount?: number;
|
||
};
|
||
|
||
type ReportFieldType = 'string' | 'image' | 'file';
|
||
|
||
const typeOptions = [
|
||
{ label: '全部类型', value: 'all' },
|
||
{ label: '字符串', value: 'string' },
|
||
{ label: '图片', value: 'image' },
|
||
{ label: '文件', value: 'file' },
|
||
];
|
||
|
||
const typeLabels: Record<string, string> = { string: '字符串', image: '图片', file: '文件' };
|
||
|
||
export function AdminDrainageFieldsPage() {
|
||
const [fields, setFields] = useState<DrainageField[]>([]);
|
||
const [commonFields, setCommonFields] = useState<CommonReportField[]>([]);
|
||
const [keyword, setKeyword] = useState('');
|
||
const [appliedKeyword, setAppliedKeyword] = useState('');
|
||
const [type, setType] = useState('all');
|
||
const [appliedType, setAppliedType] = useState('all');
|
||
const [creating, setCreating] = useState(false);
|
||
const [editingField, setEditingField] = useState<DrainageField | null>(null);
|
||
const [fieldSaving, setFieldSaving] = useState(false);
|
||
const [code, setCode] = useState('');
|
||
const [name, setName] = useState('');
|
||
const [fieldType, setFieldType] = useState<ReportFieldType>('string');
|
||
const [description, setDescription] = useState('');
|
||
const [error, setError] = useState('');
|
||
const [deleteTarget, setDeleteTarget] = useState<DrainageField | null>(null);
|
||
const [configuringCommon, setConfiguringCommon] = useState(false);
|
||
const [editingCommonId, setEditingCommonId] = useState<string>();
|
||
const [commonSaving, setCommonSaving] = useState(false);
|
||
const [commonFieldId, setCommonFieldId] = useState('');
|
||
const [commonReportType, setCommonReportType] = useState<'signature' | 'drainage'>('signature');
|
||
const [commonRequired, setCommonRequired] = useState(false);
|
||
const [commonDeleteTarget, setCommonDeleteTarget] = useState<CommonReportField | null>(null);
|
||
const [commonOrderingId, setCommonOrderingId] = useState<string>();
|
||
const codeError = code && !/^[A-Za-z0-9]+$/.test(code) ? '字段代码只能包含阿拉伯数字和英文大小写字母' : '';
|
||
|
||
function loadData() {
|
||
Promise.all([adminApi.listDrainageFields(), adminApi.listCommonReportFields()])
|
||
.then(([items, commonItems]) => {
|
||
setFields(items as DrainageField[]);
|
||
setCommonFields(commonItems);
|
||
setError('');
|
||
})
|
||
.catch((failure: Error) => setError(failure.message || '报备字段加载失败'));
|
||
}
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
}, []);
|
||
|
||
const filteredFields = useMemo(
|
||
() =>
|
||
fields.filter((field) => {
|
||
const matchesKeyword =
|
||
!appliedKeyword ||
|
||
[field.code, field.name, field.fieldType, field.description].some((value) =>
|
||
String(value ?? '').includes(appliedKeyword),
|
||
);
|
||
const matchesType = appliedType === 'all' || field.fieldType === appliedType;
|
||
return matchesKeyword && matchesType;
|
||
}),
|
||
[appliedKeyword, appliedType, fields],
|
||
);
|
||
|
||
function closeFieldModal() {
|
||
setCreating(false);
|
||
setEditingField(null);
|
||
setCode('');
|
||
setName('');
|
||
setFieldType('string');
|
||
setDescription('');
|
||
}
|
||
|
||
function openFieldModal(field?: DrainageField) {
|
||
setEditingField(field ?? null);
|
||
setCode(field?.code ?? '');
|
||
setName(field?.name ?? '');
|
||
setFieldType((field?.fieldType as ReportFieldType | undefined) ?? 'string');
|
||
setDescription(field?.description ?? '');
|
||
setError('');
|
||
setCreating(true);
|
||
}
|
||
|
||
function saveField() {
|
||
if (fieldSaving) return;
|
||
setFieldSaving(true);
|
||
const request = editingField
|
||
? adminApi.updateDrainageField(editingField.id, { code, name, fieldType, description })
|
||
: adminApi.createDrainageField({ code, name, fieldType, description, status: 'active' });
|
||
request
|
||
.then(() => {
|
||
closeFieldModal();
|
||
loadData();
|
||
})
|
||
.catch((failure: Error) => setError(failure.message || '报备字段保存失败'))
|
||
.finally(() => setFieldSaving(false));
|
||
}
|
||
|
||
function deleteField() {
|
||
if (!deleteTarget || (deleteTarget.usageCount ?? 0) > 0 || (deleteTarget.commonUsageCount ?? 0) > 0) return;
|
||
adminApi
|
||
.deleteDrainageField(deleteTarget.id)
|
||
.then(() => {
|
||
setDeleteTarget(null);
|
||
loadData();
|
||
})
|
||
.catch((failure: Error) => setError(failure.message || '报备字段删除失败'));
|
||
}
|
||
|
||
function createCommonField() {
|
||
if (!commonFieldId || commonSaving) return;
|
||
setCommonSaving(true);
|
||
setError('');
|
||
const body = { drainageFieldId: commonFieldId, reportType: commonReportType, required: commonRequired };
|
||
const request = editingCommonId
|
||
? adminApi.updateCommonReportField(editingCommonId, body)
|
||
: adminApi.createCommonReportField(body);
|
||
request
|
||
.then(() => {
|
||
setCommonFieldId('');
|
||
setCommonReportType('signature');
|
||
setCommonRequired(false);
|
||
setConfiguringCommon(false);
|
||
loadData();
|
||
})
|
||
.catch((failure: Error) => setError(failure.message || '通用字段配置失败'))
|
||
.finally(() => setCommonSaving(false));
|
||
}
|
||
|
||
function openCommonField(field?: CommonReportField) {
|
||
setEditingCommonId(field?.id);
|
||
setCommonFieldId(field?.drainageFieldId ?? '');
|
||
setCommonReportType(field?.reportType ?? 'signature');
|
||
setCommonRequired(field?.required ?? false);
|
||
setError('');
|
||
setConfiguringCommon(true);
|
||
}
|
||
|
||
function deleteCommonField() {
|
||
if (!commonDeleteTarget) return;
|
||
adminApi
|
||
.deleteCommonReportField(commonDeleteTarget.id)
|
||
.then(() => {
|
||
setCommonDeleteTarget(null);
|
||
loadData();
|
||
})
|
||
.catch((failure: Error) => setError(failure.message || '通用字段删除失败'));
|
||
}
|
||
|
||
async function moveCommonField(field: CommonReportField, direction: -1 | 1) {
|
||
if (commonOrderingId) return;
|
||
const group = commonFields.filter((item) => item.reportType === field.reportType);
|
||
const currentIndex = group.findIndex((item) => item.id === field.id);
|
||
const targetIndex = currentIndex + direction;
|
||
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= group.length) return;
|
||
const ids = group.map((item) => item.id);
|
||
[ids[currentIndex], ids[targetIndex]] = [ids[targetIndex], ids[currentIndex]];
|
||
setCommonOrderingId(field.id);
|
||
setError('');
|
||
try {
|
||
await adminApi.reorderCommonReportFields({ reportType: field.reportType, ids });
|
||
loadData();
|
||
} catch (failure) {
|
||
setError(failure instanceof Error ? failure.message : '通用字段顺序调整失败');
|
||
} finally {
|
||
setCommonOrderingId(undefined);
|
||
}
|
||
}
|
||
|
||
const signatureCommon = commonFields.filter((field) => field.reportType === 'signature');
|
||
const drainageCommon = commonFields.filter((field) => field.reportType === 'drainage');
|
||
const referencedCount = fields.filter(
|
||
(field) => (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0,
|
||
).length;
|
||
|
||
return (
|
||
<section className="page-stack admin-system-page admin-drainage-page">
|
||
<div className="page-heading">
|
||
<div>
|
||
<Breadcrumb items={['基础配置', '报备字段库']} />
|
||
<h1>报备字段库</h1>
|
||
<p>统一维护签名和引流信息的资料字段,并配置全平台通用报备要求。</p>
|
||
</div>
|
||
</div>
|
||
{error ? <p className="form-error">{error}</p> : null}
|
||
|
||
<div className="admin-drainage-summary">
|
||
<article>
|
||
<span>
|
||
<Database size={18} />
|
||
</span>
|
||
<div>
|
||
<strong>{fields.length}</strong>
|
||
<p>字段总数</p>
|
||
</div>
|
||
</article>
|
||
<article>
|
||
<span>
|
||
<FileCheck2 size={18} />
|
||
</span>
|
||
<div>
|
||
<strong>{commonFields.length}</strong>
|
||
<p>通用字段配置</p>
|
||
</div>
|
||
</article>
|
||
<article>
|
||
<span>
|
||
<Link2 size={18} />
|
||
</span>
|
||
<div>
|
||
<strong>{referencedCount}</strong>
|
||
<p>已被引用字段</p>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<div className="surface admin-drainage-toolbar">
|
||
<Input
|
||
onChange={(event) => setKeyword(event.target.value)}
|
||
placeholder="搜索字段名称、代码或描述..."
|
||
prefix={<Search size={16} />}
|
||
value={keyword}
|
||
/>
|
||
<Select onChange={(event) => setType(event.target.value)} options={typeOptions} value={type} />
|
||
<div className="admin-system-toolbar__actions">
|
||
<Button
|
||
icon={<Search size={16} />}
|
||
onClick={() => {
|
||
setAppliedKeyword(keyword.trim());
|
||
setAppliedType(type);
|
||
}}
|
||
>
|
||
查询
|
||
</Button>
|
||
<Button
|
||
onClick={() => {
|
||
setKeyword('');
|
||
setType('all');
|
||
setAppliedKeyword('');
|
||
setAppliedType('all');
|
||
}}
|
||
variant="ghost"
|
||
>
|
||
重置
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="surface admin-drainage-section">
|
||
<div className="admin-drainage-section__heading">
|
||
<div>
|
||
<h2>通用字段配置</h2>
|
||
<p>企业新增或编辑签名、引流信息时必须按这里的配置填写,通道字段也可以直接引用。</p>
|
||
</div>
|
||
<Button icon={<Plus size={16} />} onClick={() => openCommonField()} size="sm" variant="secondary">
|
||
配置通用字段
|
||
</Button>
|
||
</div>
|
||
<div className="admin-drainage-common-grid">
|
||
<CommonFieldGroup
|
||
fields={signatureCommon}
|
||
label="签名报备资料"
|
||
onEdit={openCommonField}
|
||
onDelete={setCommonDeleteTarget}
|
||
onMove={moveCommonField}
|
||
orderingId={commonOrderingId}
|
||
tone="info"
|
||
/>
|
||
<CommonFieldGroup
|
||
fields={drainageCommon}
|
||
label="引流信息报备资料"
|
||
onEdit={openCommonField}
|
||
onDelete={setCommonDeleteTarget}
|
||
onMove={moveCommonField}
|
||
orderingId={commonOrderingId}
|
||
tone="warning"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="surface admin-drainage-section">
|
||
<div className="admin-drainage-section__heading">
|
||
<div>
|
||
<h2>字段定义</h2>
|
||
<p>共 {filteredFields.length} 个结果。已被通道或通用配置引用的字段不能删除。</p>
|
||
</div>
|
||
<Button icon={<Plus size={16} />} onClick={() => openFieldModal()} size="sm">
|
||
添加字段
|
||
</Button>
|
||
</div>
|
||
{filteredFields.length ? (
|
||
<div className="admin-drainage-field-grid">
|
||
{filteredFields.map((field) => {
|
||
const locked = (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0;
|
||
return (
|
||
<article className="admin-drainage-field-card" key={field.id}>
|
||
<div className="admin-drainage-field-card__top">
|
||
<span className="admin-drainage-type">{typeLabels[field.fieldType ?? ''] ?? field.fieldType}</span>
|
||
<div className="admin-drainage-field-card__actions">
|
||
<Button
|
||
aria-label={`编辑${field.name}`}
|
||
icon={<Edit3 size={14} />}
|
||
iconOnly
|
||
onClick={() => openFieldModal(field)}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
编辑
|
||
</Button>
|
||
<Button
|
||
aria-label={`删除${field.name}`}
|
||
disabled={locked}
|
||
icon={<Trash2 size={14} />}
|
||
iconOnly
|
||
onClick={() => setDeleteTarget(field)}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
删除
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<h3>{field.name ?? '-'}</h3>
|
||
<code>{field.code}</code>
|
||
<p>{field.description || '暂无字段说明'}</p>
|
||
<div className="admin-drainage-field-card__meta">
|
||
<span>
|
||
通道引用 <strong>{field.usageCount ?? 0}</strong>
|
||
</span>
|
||
<span>
|
||
通用配置 <strong>{field.commonUsageCount ?? 0}</strong>
|
||
</span>
|
||
</div>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="admin-drainage-empty">没有符合筛选条件的字段</div>
|
||
)}
|
||
</div>
|
||
|
||
<Modal
|
||
footer={
|
||
<>
|
||
<Button disabled={commonSaving} onClick={() => setConfiguringCommon(false)} variant="ghost">
|
||
取消
|
||
</Button>
|
||
<Button disabled={!commonFieldId || commonSaving} onClick={createCommonField}>
|
||
{commonSaving ? '保存中...' : '保存'}
|
||
</Button>
|
||
</>
|
||
}
|
||
onClose={() => setConfiguringCommon(false)}
|
||
open={configuringCommon}
|
||
title={editingCommonId ? '修改通用字段配置' : '配置通用字段'}
|
||
>
|
||
<div className="admin-system-modal-form">
|
||
<Select
|
||
label="报备字段"
|
||
onChange={(event) => setCommonFieldId(event.target.value)}
|
||
options={[
|
||
{ label: '请选择字段', value: '' },
|
||
...fields
|
||
.filter((field) => field.status !== 'deleted')
|
||
.map((field) => ({ label: `${field.name}(${field.code})`, value: field.id })),
|
||
]}
|
||
value={commonFieldId}
|
||
/>
|
||
<Select
|
||
label="资料用途"
|
||
onChange={(event) => setCommonReportType(event.target.value as 'signature' | 'drainage')}
|
||
options={[
|
||
{ label: '签名报备资料', value: 'signature' },
|
||
{ label: '引流信息报备资料', value: 'drainage' },
|
||
]}
|
||
value={commonReportType}
|
||
/>
|
||
<Select
|
||
label="是否必填"
|
||
onChange={(event) => setCommonRequired(event.target.value === 'true')}
|
||
options={[
|
||
{ label: '选填', value: 'false' },
|
||
{ label: '必填', value: 'true' },
|
||
]}
|
||
value={String(commonRequired)}
|
||
/>
|
||
<p>修改后用于后续新增、编辑时的资料要求;历史报备资料和要求快照保持不变。</p>
|
||
{error ? <p className="form-error">{error}</p> : null}
|
||
</div>
|
||
</Modal>
|
||
|
||
<Modal
|
||
footer={
|
||
<>
|
||
<Button disabled={fieldSaving} onClick={closeFieldModal} variant="ghost">
|
||
取消
|
||
</Button>
|
||
<Button disabled={!code || !name || Boolean(codeError) || fieldSaving} onClick={saveField}>
|
||
{fieldSaving ? '保存中...' : '保存'}
|
||
</Button>
|
||
</>
|
||
}
|
||
onClose={closeFieldModal}
|
||
open={creating}
|
||
title={editingField ? '编辑报备字段' : '添加报备字段'}
|
||
>
|
||
<div className="admin-system-modal-form">
|
||
<Input
|
||
disabled={Boolean(
|
||
editingField && ((editingField.usageCount ?? 0) > 0 || (editingField.commonUsageCount ?? 0) > 0),
|
||
)}
|
||
error={codeError}
|
||
label="字段代码"
|
||
onChange={(event) => setCode(event.target.value)}
|
||
placeholder="仅允许数字和英文字母"
|
||
value={code}
|
||
/>
|
||
<Input label="字段名称" onChange={(event) => setName(event.target.value)} value={name} />
|
||
<Select
|
||
disabled={Boolean(
|
||
editingField && ((editingField.usageCount ?? 0) > 0 || (editingField.commonUsageCount ?? 0) > 0),
|
||
)}
|
||
label="字段类型"
|
||
onChange={(event) => setFieldType(event.target.value as ReportFieldType)}
|
||
options={typeOptions.filter((option) => option.value !== 'all')}
|
||
value={fieldType}
|
||
/>
|
||
{editingField && ((editingField.usageCount ?? 0) > 0 || (editingField.commonUsageCount ?? 0) > 0) ? (
|
||
<p className="admin-system-modal-form__wide">
|
||
该字段已被引用,为保护现有通道映射和历史资料,只能修改字段名称和描述。
|
||
</p>
|
||
) : null}
|
||
<Textarea
|
||
className="admin-system-modal-form__wide"
|
||
label="描述"
|
||
onChange={(event) => setDescription(event.target.value)}
|
||
rows={4}
|
||
value={description}
|
||
/>
|
||
</div>
|
||
</Modal>
|
||
{deleteTarget ? (
|
||
<Modal
|
||
footer={
|
||
<>
|
||
<Button onClick={() => setDeleteTarget(null)} variant="ghost">
|
||
取消
|
||
</Button>
|
||
<Button onClick={deleteField} variant="danger">
|
||
确认删除
|
||
</Button>
|
||
</>
|
||
}
|
||
onClose={() => setDeleteTarget(null)}
|
||
open
|
||
title="删除报备字段"
|
||
>
|
||
<p>确认删除“{deleteTarget.name}”吗?未被通道使用的字段将从数据库中永久删除。</p>
|
||
</Modal>
|
||
) : null}
|
||
{commonDeleteTarget ? (
|
||
<Modal
|
||
footer={
|
||
<>
|
||
<Button onClick={() => setCommonDeleteTarget(null)} variant="ghost">
|
||
取消
|
||
</Button>
|
||
<Button onClick={deleteCommonField} variant="danger">
|
||
确认删除
|
||
</Button>
|
||
</>
|
||
}
|
||
onClose={() => setCommonDeleteTarget(null)}
|
||
open
|
||
title="删除通用字段配置"
|
||
>
|
||
<p>
|
||
确认删除“{String(commonDeleteTarget.drainageField.name ?? commonDeleteTarget.drainageField.code)}”的
|
||
{commonDeleteTarget.reportType === 'signature' ? '签名报备' : '引流信息报备'}
|
||
通用配置吗?字段库定义和历史报备资料不会删除。
|
||
</p>
|
||
</Modal>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function CommonFieldGroup({
|
||
fields,
|
||
label,
|
||
onEdit,
|
||
onDelete,
|
||
onMove,
|
||
orderingId,
|
||
tone,
|
||
}: {
|
||
fields: CommonReportField[];
|
||
label: string;
|
||
onEdit: (field: CommonReportField) => void;
|
||
onDelete: (field: CommonReportField) => void;
|
||
onMove: (field: CommonReportField, direction: -1 | 1) => void;
|
||
orderingId?: string;
|
||
tone: 'info' | 'warning';
|
||
}) {
|
||
return (
|
||
<section className="admin-drainage-common-group">
|
||
<div className="admin-drainage-common-group__title">
|
||
<Tag tone={tone}>{label}</Tag>
|
||
<span>{fields.length} 项</span>
|
||
</div>
|
||
{fields.length ? (
|
||
<div className="admin-drainage-common-list">
|
||
{fields.map((field, index) => (
|
||
<div key={field.id}>
|
||
<div>
|
||
<strong>{String(field.drainageField.name ?? field.drainageField.code)}</strong>
|
||
<span>
|
||
{String(field.drainageField.code)} · {typeLabels[String(field.drainageField.fieldType ?? '')] ?? '-'}
|
||
</span>
|
||
</div>
|
||
<Tag tone={field.required ? 'warning' : 'neutral'}>{field.required ? '必填' : '选填'}</Tag>
|
||
<div className="admin-drainage-common-order">
|
||
<Button
|
||
aria-label={`上移通用字段${field.drainageField.name}`}
|
||
disabled={index === 0 || Boolean(orderingId)}
|
||
icon={<ArrowUp size={14} />}
|
||
iconOnly
|
||
onClick={() => onMove(field, -1)}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
上移
|
||
</Button>
|
||
<Button
|
||
aria-label={`下移通用字段${field.drainageField.name}`}
|
||
disabled={index === fields.length - 1 || Boolean(orderingId)}
|
||
icon={<ArrowDown size={14} />}
|
||
iconOnly
|
||
onClick={() => onMove(field, 1)}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
下移
|
||
</Button>
|
||
</div>
|
||
<Button
|
||
aria-label={`修改通用字段${field.drainageField.name}`}
|
||
icon={<Edit3 size={14} />}
|
||
onClick={() => onEdit(field)}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
修改
|
||
</Button>
|
||
<Button
|
||
aria-label="删除通用字段"
|
||
icon={<Trash2 size={14} />}
|
||
iconOnly
|
||
onClick={() => onDelete(field)}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
删除
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="admin-drainage-common-empty">暂未配置字段</p>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|