This commit is contained in:
2026-04-14 11:14:48 +08:00
parent 325243c52e
commit 72a874516b

View File

@@ -1,4 +1,4 @@
import { FileTextOutlined, UploadOutlined } from '@ant-design/icons'; import { DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
import { import {
Button, Button,
Checkbox, Checkbox,
@@ -10,14 +10,49 @@ import {
Modal, Modal,
message, message,
Select, Select,
Space,
Switch, Switch,
Table, Table,
Tag, Tag,
Upload,
} from 'antd'; } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
const EditableForm = ({ value = [], onChange, schema }: any) => {
// 兼容性处理:从 columns 中提取字段定义
const fields = schema?.columns || [];
// 假设 form 模式操作的是数组的第一个对象
const data = value[0] || {};
const handleFieldChange = (dataIndex: string, val: any) => {
const newData = [{ ...data, [dataIndex]: val }];
onChange?.(newData); // 保持数据结构依然是 [{...}]
};
return (
<div
style={{
padding: '16px',
background: '#fafafa',
borderRadius: '8px',
border: '1px solid #f0f0f0',
}}
>
{fields.map((col: any) => (
<div key={col.dataIndex} style={{ marginBottom: 12 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: '#333', marginBottom: 4 }}>{col.title}</div>
<Input
placeholder={col.placeholder}
value={data[col.dataIndex]}
onChange={(e) => handleFieldChange(col.dataIndex, e.target.value)}
/>
</div>
))}
</div>
);
};
// --- 复杂类型:可编辑表格组件 (用于分段价格等 array + table 模式) --- // --- 复杂类型:可编辑表格组件 (用于分段价格等 array + table 模式) ---
interface EditableTableProps { interface EditableTableProps {
value?: any[]; value?: any[];
@@ -31,19 +66,18 @@ interface EditableTableProps {
}; };
} }
const EditableTable = ({ value = [], onChange, schema }: EditableTableProps) => { const EditableTable = ({ value = [], onChange, schema }: EditableTableProps) => {
const columns = (schema?.columns || []).map((col: any) => ({ // --- 1. 字段定义 ---
const baseColumns = (schema?.columns || []).map((col: any) => ({
title: col.title, title: col.title,
dataIndex: col.dataIndex, dataIndex: col.dataIndex,
render: (text: any, record: any, index: number) => { render: (text: any, record: any, index: number) => {
const inputProps = { const inputProps = {
value: text, value: text,
// 这里 val 可能是 Event 对象也可能是数值antd 的 InputNumber 直接返回 val
// 普通 Input 返回 e.target.value
onChange: (e: any) => { onChange: (e: any) => {
const val = e?.target ? e.target.value : e; const val = e?.target ? e.target.value : e;
const newData = [...value]; const newData = [...value];
newData[index] = { ...newData[index], [col.dataIndex]: val }; newData[index] = { ...newData[index], [col.dataIndex]: val };
onChange?.(newData); // 使用可选链调用,安全消灭波浪线 onChange?.(newData);
}, },
}; };
@@ -51,25 +85,51 @@ const EditableTable = ({ value = [], onChange, schema }: EditableTableProps) =>
}, },
})); }));
// --- 2. 操作列 (删除) ---
const columns = [
{
title: '操作',
width: 70,
align: 'center' as const,
render: (_: any, __: any, index: number) => (
<Button
type='link'
danger
size='small'
icon={<DeleteOutlined />}
onClick={() => {
const newData = [...value];
newData.splice(index, 1);
onChange?.(newData);
}}
/>
),
},
...baseColumns,
];
return ( return (
<div style={{ border: '1px solid #f0f0f0', padding: 8, borderRadius: 4 }}> <div style={{ border: '1px solid #f0f0f0', padding: 12, borderRadius: 6, background: '#fff' }}>
{/* --- 3. 顶部操作栏 (全部对齐左侧) --- */}
<Space style={{ marginBottom: 12 }}>
<Button type='primary' size='small' icon={<span>+ </span>} onClick={() => onChange?.([...value, {}])}>
</Button>
<Button type='default' danger size='small' disabled={value.length === 0} onClick={() => onChange?.([])}>
</Button>
<span style={{ marginLeft: 8, color: '#999', fontSize: '12px' }}> {value.length} </span>
</Space>
{/* --- 4. 表格主体 --- */}
<Table <Table
dataSource={value} dataSource={value}
columns={columns} columns={columns}
pagination={false} pagination={false}
size='small' size='small'
// 显式声明 i 为 number消灭 rowKey 处的波浪线
rowKey={(_: any, i: any) => i.toString()} rowKey={(_: any, i: any) => i.toString()}
scroll={{ y: 300 }}
/> />
<Button
type='dashed'
block
// 关键点:这里也需要使用 ?. 确保 onChange 存在时才调用
onClick={() => onChange?.([...value, {}])}
style={{ marginTop: 8 }}
>
</Button>
</div> </div>
); );
}; };
@@ -110,12 +170,12 @@ const ConfigList = () => {
label: '通知方式', label: '通知方式',
type: 'array', type: 'array',
value: [1, 2], value: [1, 2],
array_config: '{"mode":"checkbox"}', array_config: '{"mode":"checkbox", "multiple":"true"}',
options: '[{"label":"短信","value":1},{"label":"邮件","value":2}]', options: '[{"label":"短信","value":1},{"label":"邮件","value":2}]',
}, },
{ id: 8, config_key: 'birth_date', label: '日期', type: 'date', value: '2026-01-01' }, { id: 8, config_key: 'birth_date', label: '日期', type: 'date', value: '2026-01-01' },
{ id: 9, config_key: 'expire_time', label: '时间', type: 'datetime', value: '2026-01-01 12:00:00' }, { id: 9, config_key: 'expire_time', label: '时间', type: 'datetime', value: '2026-01-01 12:00:00' },
{ id: 10, config_key: 'logo', label: 'Logo', type: 'file', value: [{ url: '/logo.png', name: 'logo.png' }] }, //{ id: 10, config_key: 'logo', label: 'Logo', type: 'file', value: [{ url: '/logo.png', name: 'logo.png' }] },
{ {
id: 11, id: 11,
config_key: 'price', config_key: 'price',
@@ -130,6 +190,45 @@ const ConfigList = () => {
schema: schema:
'{"columns":[{"title":"最小","dataIndex":"min","type":"int"},{"title":"最大","dataIndex":"max","type":"int"},{"title":"价格","dataIndex":"price","type":"int"}]}', '{"columns":[{"title":"最小","dataIndex":"min","type":"int"},{"title":"最大","dataIndex":"max","type":"int"},{"title":"价格","dataIndex":"price","type":"int"}]}',
}, },
{
id: 12,
config_key: 'admin_info',
label: '管理员信息',
type: 'array',
value: [{ name: '张三', phone: '李四' }],
array_config: '{"mode":"form"}',
schema:
'{"columns":[{"title":"管理员姓名","dataIndex":"name","type":"string","placeholder":"请输入姓名"},{"title":"管理员手机号","dataIndex":"phone","type":"string","placeholder":"请输入手机号"}]}',
},
{
id: 13,
config_key: 'audit_status',
label: '审核状态',
type: 'select',
value: 1,
options: '[{"label":"待审核","value":1},{"label":"通过","value":2},{"label":"驳回","value":3}]',
props: '{"placeholder":"请选择状态"}',
},
{
id: 14,
config_key: 'user_roles',
label: '关联角色',
type: 'array',
value: ['admin'], // 初始选中的值
array_config: '{"mode":"select", "multiple": true}', // 核心标志位
options:
'[{"label":"超级管理员","value":"admin"},{"label":"运营","value":"editor"},{"label":"财务","value":"finance"}]',
},
{
id: 15,
config_key: 'single_check',
label: '单选确认',
type: 'array',
value: [1],
// 增加 single 属性
array_config: '{"mode":"checkbox", "multiple":"false"}',
options: '[{"label":"选项A","value":1},{"label":"选项B","value":2}]',
},
]; ];
setData( setData(
@@ -153,7 +252,7 @@ const ConfigList = () => {
const { type, props, options, array_config, schema } = item; const { type, props, options, array_config, schema } = item;
// 1. 获取该配置项当前的实时值 (用于 tags 模式下合并临时选项) // 1. 获取该配置项当前的实时值 (用于 tags 模式下合并临时选项)
const currentValue = form.getFieldValue(item.config_key) || []; //const currentValue = form.getFieldValue(item.config_key) || [];
switch (type) { switch (type) {
case 'string': case 'string':
@@ -162,18 +261,20 @@ const ConfigList = () => {
return <Input.TextArea {...props} />; return <Input.TextArea {...props} />;
case 'int': case 'int':
return <InputNumber style={{ width: '100%' }} {...props} />; return <InputNumber style={{ width: '100%' }} {...props} />;
case 'select':
return <Select {...props} options={options} style={{ width: '100%' }} allowClear />;
case 'bool': case 'bool':
return <Switch />; return <Switch />;
case 'date': case 'date':
return <DatePicker style={{ width: '100%' }} format='YYYY-MM-DD' />; return <DatePicker style={{ width: '100%' }} format='YYYY-MM-DD' />;
case 'datetime': case 'datetime':
return <DatePicker showTime style={{ width: '100%' }} format='YYYY-MM-DD HH:mm:ss' />; return <DatePicker showTime style={{ width: '100%' }} format='YYYY-MM-DD HH:mm:ss' />;
case 'file': // case 'file':
return ( // return (
<Upload listType='picture' defaultFileList={form.getFieldValue(item.config_key)} beforeUpload={() => false}> // <Upload listType='picture' defaultFileList={form.getFieldValue(item.config_key)} beforeUpload={() => false}>
<Button icon={<UploadOutlined />}></Button> // <Button icon={<UploadOutlined />}>选择文件</Button>
</Upload> // </Upload>
); // );
case 'array': { case 'array': {
const mode = array_config?.mode; const mode = array_config?.mode;
@@ -184,43 +285,93 @@ const ConfigList = () => {
return <EditableTable {...props} value={currentValue} schema={schema} />; return <EditableTable {...props} value={currentValue} schema={schema} />;
} }
// 2. 处理复选框模式 (Checkbox) if (mode === 'form') {
if (mode === 'checkbox') {
return <Checkbox.Group options={options} />;
}
// 3. 处理下拉选择与标签模式 (Select / Tags)
if (mode === 'select' || mode === 'tags') {
// 【关键】始终以原始 options 为基准,防止预设选项丢失
const mergedOptions = [...(options || [])];
// 如果是 tags 模式,需要把当前已输入但不在 options 里的值临时加入下拉列表
// 这样可以保证“已选即显示”,但不会影响原始 options 的持久性
if (mode === 'tags' && Array.isArray(currentValue)) {
currentValue.forEach((v) => {
const exists = mergedOptions.some((opt) => opt.value === v);
if (!exists) {
// 如果是用户新输入的,动态补全到当前渲染列表里
mergedOptions.push({ label: String(v), value: v });
}
});
}
return ( return (
<Select <EditableForm
{...props} schema={schema} // 传入包含 columns 的 schema
// 模式判定tags 优先,其次看是否是多选 select value={currentValue} // 这里的 currentValue 预期是 [{...}]
mode={mode === 'tags' ? 'tags' : array_config.multiple ? 'multiple' : undefined} onChange={(val: any) => {
style={{ width: '100%' }} form.setFieldsValue({ [item.config_key]: val });
placeholder='请选择或输入' }}
options={mergedOptions}
optionFilterProp='label'
// 允许清空
allowClear
/> />
); );
} }
// 2. 处理复选框模式 (Checkbox)
if (mode === 'checkbox') {
//return <Checkbox.Group options={options} />;
return (
<Checkbox.Group
options={options}
value={currentValue}
onChange={(checkedValues) => {
if (array_config?.multiple === 'false') {
if (checkedValues.length === 0) return;
const nextValue = [checkedValues[checkedValues.length - 1]];
form.setFieldValue(item.config_key, nextValue);
} else {
// 普通多选模式
form.setFieldValue(item.config_key, checkedValues);
}
}}
/>
);
}
// 3. 处理下拉选择与标签模式 (Select / Tags)
// if (mode === 'select' || mode === 'tags') {
// // 【关键】始终以原始 options 为基准,防止预设选项丢失
// const mergedOptions = [...(options || [])];
// // 如果是 tags 模式,需要把当前已输入但不在 options 里的值临时加入下拉列表
// // 这样可以保证“已选即显示”,但不会影响原始 options 的持久性
// if (mode === 'tags' && Array.isArray(currentValue)) {
// currentValue.forEach((v) => {
// const exists = mergedOptions.some((opt) => opt.value === v);
// if (!exists) {
// // 如果是用户新输入的,动态补全到当前渲染列表里
// mergedOptions.push({ label: String(v), value: v });
// }
// });
// }
// return (
// <Select
// {...props}
// // 模式判定tags 优先,其次看是否是多选 select
// mode={mode === 'tags' ? 'tags' : array_config.multiple ? 'multiple' : undefined}
// style={{ width: '100%' }}
// placeholder='请选择或输入'
// options={mergedOptions}
// allowClear
// />
// );
// }
if (mode === 'select' || mode === 'tags') {
// 1. 不再使用 mergedOptions 逻辑,直接使用原始预设的 options
// 因为不允许输入新值,所以不需要把 currentValue 里的值往 options 里塞
const selectOptions = options || [];
// 2. 判定是否为多选模式
// 即使 mode 是 'tags',我们也将其转为 'multiple',以达到“标签化显示但禁止输入”的效果
const isMultiple = mode === 'tags' || array_config?.multiple;
return (
<Select
{...props}
// 关键修改:禁止使用 'tags',改用 'multiple'
mode={isMultiple ? 'multiple' : undefined}
style={{ width: '100%' }}
// 修改 Placeholder 提示,避免误导用户可以输入
placeholder={isMultiple ? '请选择(多选)' : '请选择'}
options={selectOptions}
allowClear
// 开启搜索,方便在长列表中查找,但 multiple 模式下搜索非选项内容无法回车添加
showSearch
/>
);
}
// 如果没有任何匹配的模式,默认返回普通 Select // 如果没有任何匹配的模式,默认返回普通 Select
return <Select options={options} style={{ width: '100%' }} />; return <Select options={options} style={{ width: '100%' }} />;
} }
@@ -252,7 +403,75 @@ const ConfigList = () => {
render: (v: any, record: any) => { render: (v: any, record: any) => {
if (record.type === 'bool') return v ? '是' : '否'; if (record.type === 'bool') return v ? '是' : '否';
if (record.type === 'file') return <FileTextOutlined />; if (record.type === 'file') return <FileTextOutlined />;
return JSON.stringify(v); if (record.type === 'select' && record.options) {
const option = record.options.find((opt: any) => opt.value === v);
return option ? <Tag color='blue'>{option.label}</Tag> : v;
}
if (record.array_config?.mode === 'table' && Array.isArray(v)) {
return (
<div style={{ fontSize: '12px', color: '#666' }}>
{v.length > 0 ? `${v.length} 条记录` : <span style={{ color: '#ccc' }}></span>}
{/* 也可以选择渲染前两条记录作为预览 */}
</div>
);
}
const presetColors = ['magenta', 'red', 'volcano', 'orange', 'gold', 'lime', 'green', 'cyan', 'blue', 'purple'];
if (
(record.array_config?.mode === 'checkbox' ||
record.array_config?.mode === 'select' ||
record.array_config?.mode === 'tags') &&
Array.isArray(v)
) {
// 必须加上 return否则 render 函数没有返回值
return (
<Space size={[0, 4]} wrap>
{v.map((val: any, index: number) => {
const option = record.options?.find((opt: any) => opt.value === val);
const label = option ? option.label : val;
const color = presetColors[index % presetColors.length];
return (
<Tag
key={val}
color={color}
style={{
borderRadius: '12px',
marginRight: '2px',
}}
>
{label}
</Tag>
);
})}
</Space>
);
}
if (record.array_config?.mode === 'form' && Array.isArray(v) && v.length > 0) {
const data = v[0]; // 获取数组中的对象
const fields = record.schema?.columns || []; // 获取定义的字段标题
return (
<div
style={{
background: '#f5f5f5',
padding: '8px 12px',
borderRadius: '6px',
display: 'inline-block',
minWidth: '180px',
border: '1px solid #eee',
}}
>
{fields.map((col: any) => (
<div key={col.dataIndex} style={{ fontSize: '12px', lineHeight: '1.8' }}>
<span style={{ color: '#8c8c8c', marginRight: '8px' }}>{col.title}:</span>
<span style={{ color: '#333', fontWeight: 500 }}>{data[col.dataIndex] || '-'}</span>
</div>
))}
</div>
);
}
return typeof v === 'object' ? JSON.stringify(v) : String(v);
}, },
}, },
{ {