Files
lislgosms/src/apps/admin/AdminDrainageFieldsPage.tsx
T

591 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}