forked from lunanting/cangku
本次将此前被列入退役名单的运营分析相关模块恢复为可用,并新增「模块化库存监控」
一级菜单及其两个子页面。
主要变更:
1. 恢复启用:商品监控、激励看板、营销活动、推广模块、商品分析
- retiredFeatures 移出对应退役名单与清表语句,改为正常保留
- 补齐后端路由、前端页面与 API 封装、侧边栏配置、桌面客户端能力项
- 同步更新 retiredFeatures / retiredRoutes / retiredNavigation / desktopSessionService 测试
2. 新增「模块化库存监控」一级菜单
- 配件库存监控:仅取「简尚-崔桥工厂半成品包装项目仓」SKU 的实时库存、
当日入库/出库、近 7 日日均发货量、库存可用天数与预警(库存可用天数 ≤ 7 预警)
- MKH-BOM资料:按商品编码展示配件明细、数量、仓位、库存,支持 A4 纵向打印
3. 新增产品资料系统数据库(PostgreSQL)连接模块:读取系统库 database_configs 中
name = product_information_database 的配置,凭据只从配置读取,不落代码
4. 新增系统设置页配置项:商品标签配置、部门商品标签配置、部门SKU负责人配置
5. 旧 /api/dashboard 接口迁移至 /api/productAnalysis,仪表盘预览页改用新接口,
相应移除 dashboard 路由模块及其测试
6. 修复 retiredRoutes 测试在 Windows 下挂起超时的问题:该测试用
parent.filename.endsWith('/api/planningUpload.js') 判断是否注入 mock,
而 Windows 的 filename 为反斜杠路径,导致判断恒为 false、mock 全部失效、
真实模块去连中央系统库而挂起(Linux CI 正常)。改为平台无关的路径匹配。
7. .gitignore 忽略本地验证产物(outputs/、.git-status.txt)
验证:
- 前端:lint / test(16 项)/ build(含 planning entry 校验)全部通过
- 后端:语法检查 149 个文件通过;测试 38/39 通过
- 已知未通过项:resilientSecretStore.test.js 断言密钥备份文件权限为 0o600,
Windows 下 chmod 无法表达该权限(实际为 0o666),属既有的平台相关失败,
与本次改动无关,本次未修改该文件
- 说明:本机 frontend/ 下存在未纳入版本库的旧依赖备份目录 node_modules_old_*,
会污染 ESLint 扫描(十余万条第三方报错),故 lint 步骤以
--ignore-pattern node_modules_old_*/** 排除后执行,其余步骤与仓库 check 一致
未提交内容:本地验证截图 outputs/ 与 .git-status.txt 已被 .gitignore 排除。
1863 lines
71 KiB
JavaScript
1863 lines
71 KiB
JavaScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||
import {
|
||
Tabs, Form, Input, Button, Switch, Select, InputNumber, DatePicker,
|
||
message, Typography, Row, Col, Space, Divider, Table, Modal, Tag, Popconfirm, Upload, Progress, Alert, Segmented
|
||
} from 'antd';
|
||
import {
|
||
UserOutlined, TeamOutlined, SafetyCertificateOutlined, AppstoreOutlined,
|
||
DatabaseOutlined, CloudServerOutlined,
|
||
SaveOutlined, ReloadOutlined, ApiOutlined, AimOutlined,
|
||
EditOutlined, DeleteOutlined, PlusOutlined, GlobalOutlined,
|
||
UploadOutlined, PictureOutlined, FieldTimeOutlined, CloudUploadOutlined, DownloadOutlined,
|
||
RobotOutlined, KeyOutlined, TagsOutlined
|
||
} from '@ant-design/icons';
|
||
import { SycmCard, SycmPage } from '../../components/Common';
|
||
import { getRedisConfig, updateRedisConfig, switchRedisRuntimeProfile, testRedisConfig, getWorkOrderConfig, updateWorkOrderConfig, testWorkOrderConfig, getSystemConfig, updateSystemConfig, uploadLogo, startBackfill, getBackfillStatus, getS3Config, updateS3Config, testS3Config, downloadProductCodeImportTemplate, downloadProductCodeBatchImportTemplate, importProductCodeWorkbook, deleteProductCodeItems, deleteSelectedProductCodeItems } from '../../api/admin';
|
||
import { getKpiTargetsByPage, updateKpiTarget, createKpiTarget, deleteKpiTarget } from '../../api/kpiTargets';
|
||
import { getProductCodeItems } from '../../api/productCodeBoard';
|
||
import dayjs from 'dayjs';
|
||
import AuthSettings from './AuthSettings';
|
||
import UserManagement from './UserManagement';
|
||
import DepartmentManagement from './DepartmentManagement';
|
||
import PermissionManagement from './PermissionManagement';
|
||
import SidebarSettings from './SidebarSettings';
|
||
import DatabaseSettings from './DatabaseSettings';
|
||
import ApiKeySettings from './ApiKeySettings';
|
||
import SelectionFissionAiSettings from './SelectionFissionAiSettings';
|
||
import ProductTagSettings from './ProductTagSettings';
|
||
import DepartmentTagSettings, { DepartmentOwnerSettings } from './DepartmentTagSettings';
|
||
import './style.css';
|
||
|
||
const { Title, Text } = Typography;
|
||
const { Option } = Select;
|
||
const SETTINGS_TAB_KEYS = new Set([
|
||
'site', 'sidebar', 'auth', 'databases', 'redis', 'workOrderSystem', 'apiKeys',
|
||
'selectionFissionAi', 'alibaba1688ImageSearch',
|
||
'productCodeImport', 'kpiTargets', 'productTags', 'backfill', 'user', 'dept',
|
||
'permission', 's3',
|
||
]);
|
||
|
||
const QINIU_REGION_OPTIONS = [
|
||
{ value: 'cn-east-1', zone: 'z0', endpoint: 'https://s3.cn-east-1.qiniucs.com', label: '华东-浙江一区 / z0(cn-east-1)' },
|
||
{ value: 'cn-east-2', zone: 'cn-east-2', endpoint: 'https://s3.cn-east-2.qiniucs.com', label: '华东-浙江2(cn-east-2)' },
|
||
{ value: 'cn-north-1', zone: 'z1', endpoint: 'https://s3.cn-north-1.qiniucs.com', label: '华北-河北 / z1(cn-north-1)' },
|
||
{ value: 'cn-south-1', zone: 'z2', endpoint: 'https://s3.cn-south-1.qiniucs.com', label: '华南-广东 / z2(cn-south-1)' },
|
||
{ value: 'us-north-1', zone: 'na0', endpoint: 'https://s3.us-north-1.qiniucs.com', label: '北美-洛杉矶 / na0(us-north-1)' },
|
||
{ value: 'ap-southeast-1', zone: 'as0', endpoint: 'https://s3.ap-southeast-1.qiniucs.com', label: '亚太-新加坡 / as0(ap-southeast-1)' },
|
||
];
|
||
|
||
const OBJECT_STORAGE_CLASS_OPTIONS = [
|
||
{ value: 'STANDARD', label: '标准存储(STANDARD)' },
|
||
{ value: 'LINE', label: '低频存储(LINE)' },
|
||
{ value: 'INTELLIGENT_TIERING', label: '智能分层(INTELLIGENT_TIERING)' },
|
||
{ value: 'GLACIER_IR', label: '归档直读(GLACIER_IR)' },
|
||
{ value: 'GLACIER', label: '归档存储(GLACIER)' },
|
||
{ value: 'DEEP_ARCHIVE', label: '深度归档(DEEP_ARCHIVE)' },
|
||
];
|
||
|
||
const replaceUrlProtocol = (value, protocol) => {
|
||
const normalized = String(value || '').trim();
|
||
if (!normalized) return normalized;
|
||
if (/^https?:\/\//i.test(normalized)) {
|
||
return normalized.replace(/^https?/i, protocol);
|
||
}
|
||
return `${protocol}://${normalized}`;
|
||
};
|
||
|
||
const saveBlobAsFile = (blob, fileName) => {
|
||
const url = window.URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = fileName;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
window.URL.revokeObjectURL(url);
|
||
};
|
||
|
||
const escapeCsvCell = (value) => {
|
||
const text = String(value ?? '');
|
||
if (/[",\r\n]/.test(text)) {
|
||
return `"${text.replace(/"/g, '""')}"`;
|
||
}
|
||
return text;
|
||
};
|
||
|
||
const buildImportErrorReportFileName = () => {
|
||
const now = new Date();
|
||
const pad = (value) => String(value).padStart(2, '0');
|
||
return `测款导入失败明细-${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}.csv`;
|
||
};
|
||
|
||
const downloadImportErrorReport = (details) => {
|
||
const headers = Array.isArray(details?.headers) ? details.headers : [];
|
||
const rowErrors = Array.isArray(details?.rowErrors) ? details.rowErrors : [];
|
||
if (!rowErrors.length) {
|
||
return;
|
||
}
|
||
|
||
const csvRows = [
|
||
['工作表', details?.sheetName || ''],
|
||
['失败行数', rowErrors.length],
|
||
[],
|
||
['Excel行号', '错误原因', ...headers],
|
||
...rowErrors.map((item) => [
|
||
item?.excelRowNumber || '',
|
||
item?.errorMessage || '',
|
||
...headers.map((header) => item?.rowData?.[header] ?? ''),
|
||
]),
|
||
];
|
||
const csvContent = `\uFEFF${csvRows.map((row) => row.map(escapeCsvCell).join(',')).join('\r\n')}`;
|
||
saveBlobAsFile(new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }), buildImportErrorReportFileName());
|
||
};
|
||
|
||
const buildProductCodeDeleteOption = (row = {}) => {
|
||
const productCode = String(row.productCode || '').trim();
|
||
if (!productCode) {
|
||
return null;
|
||
}
|
||
|
||
const detailParts = [row.productName, row.colorSpec].filter(Boolean);
|
||
|
||
return {
|
||
value: productCode,
|
||
label: detailParts.length ? `${productCode} | ${detailParts.join(' | ')}` : productCode,
|
||
};
|
||
};
|
||
|
||
const mergeProductCodeDeleteOptions = (currentOptions = [], rows = []) => {
|
||
const optionMap = new Map();
|
||
|
||
currentOptions.forEach((option) => {
|
||
if (option?.value) {
|
||
optionMap.set(option.value, option);
|
||
}
|
||
});
|
||
|
||
rows.forEach((row) => {
|
||
const option = buildProductCodeDeleteOption(row);
|
||
if (option) {
|
||
optionMap.set(option.value, option);
|
||
}
|
||
});
|
||
|
||
return Array.from(optionMap.values());
|
||
};
|
||
|
||
const Settings = () => {
|
||
const [activeTab, setActiveTab] = useState(() => {
|
||
const requestedTab = new URLSearchParams(window.location.search).get('tab');
|
||
return SETTINGS_TAB_KEYS.has(requestedTab) ? requestedTab : 'site';
|
||
});
|
||
|
||
const handleTabChange = (nextTab) => {
|
||
setActiveTab(nextTab);
|
||
const url = new URL(window.location.href);
|
||
if (nextTab === 'site') {
|
||
url.searchParams.delete('tab');
|
||
} else {
|
||
url.searchParams.set('tab', nextTab);
|
||
}
|
||
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`);
|
||
};
|
||
|
||
const ProductCodeImportSettings = () => {
|
||
const [downloading, setDownloading] = useState(false);
|
||
const [downloadingBatchTemplate, setDownloadingBatchTemplate] = useState(false);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [deleting, setDeleting] = useState(false);
|
||
const [deletingSelected, setDeletingSelected] = useState(false);
|
||
const [searchingDeleteCodes, setSearchingDeleteCodes] = useState(false);
|
||
const [deleteCodeOptions, setDeleteCodeOptions] = useState([]);
|
||
const [selectedDeleteCodes, setSelectedDeleteCodes] = useState([]);
|
||
const [importNotice, setImportNotice] = useState(null);
|
||
const [latestResult, setLatestResult] = useState(null);
|
||
const importInputRef = useRef(null);
|
||
const deleteCodeSearchTimerRef = useRef(null);
|
||
|
||
useEffect(() => () => {
|
||
if (deleteCodeSearchTimerRef.current) {
|
||
clearTimeout(deleteCodeSearchTimerRef.current);
|
||
}
|
||
}, []);
|
||
|
||
const handleDownloadTemplate = async () => {
|
||
try {
|
||
setDownloading(true);
|
||
const blob = await downloadProductCodeImportTemplate();
|
||
saveBlobAsFile(blob, '测款数据导入模板.xlsx');
|
||
message.success('测款数据导入模板已下载');
|
||
} catch (error) {
|
||
message.error(error.response?.data?.error || error.message || '下载测款导入模板失败');
|
||
} finally {
|
||
setDownloading(false);
|
||
}
|
||
};
|
||
|
||
const handleDownloadBatchTemplate = async () => {
|
||
try {
|
||
setDownloadingBatchTemplate(true);
|
||
const blob = await downloadProductCodeBatchImportTemplate();
|
||
saveBlobAsFile(blob, '测款批量新增模板.xlsx');
|
||
message.success('测款批量新增模板已下载');
|
||
} catch (error) {
|
||
message.error(error.response?.data?.error || error.message || '下载测款批量新增模板失败');
|
||
} finally {
|
||
setDownloadingBatchTemplate(false);
|
||
}
|
||
};
|
||
|
||
const handleImport = async (file) => {
|
||
try {
|
||
setUploading(true);
|
||
const res = await importProductCodeWorkbook(file);
|
||
if (!res?.success) {
|
||
throw new Error(res?.error || '导入测款数据失败');
|
||
}
|
||
const data = res.data || {};
|
||
setLatestResult({
|
||
fileName: file.name,
|
||
importedAt: new Date().toISOString(),
|
||
...data,
|
||
});
|
||
setImportNotice({
|
||
type: 'success',
|
||
title: '测款数据导入成功',
|
||
fileName: file.name,
|
||
happenedAt: new Date().toISOString(),
|
||
detail: `新增 ${data.insertedRows || 0} 条,更新 ${data.updatedRows || 0} 条,自动补齐商品编码 ${data.generatedProductCodeCount || 0} 条。`,
|
||
reportDetails: null,
|
||
});
|
||
message.success(res.message || '测款数据导入成功');
|
||
} catch (error) {
|
||
const responseData = error.response?.data || {};
|
||
const reportDetails = Array.isArray(responseData?.details?.rowErrors)
|
||
? responseData.details
|
||
: null;
|
||
const detailText = reportDetails
|
||
? `${responseData?.error || error.message || '导入测款数据失败'};共 ${reportDetails.rowErrors.length} 行存在问题,可下载失败明细。`
|
||
: (responseData?.error || error.message || '导入测款数据失败');
|
||
setImportNotice({
|
||
type: 'error',
|
||
title: '测款数据导入失败',
|
||
fileName: file?.name || '',
|
||
happenedAt: new Date().toISOString(),
|
||
detail: detailText,
|
||
reportDetails,
|
||
});
|
||
message.error(detailText);
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
|
||
return false;
|
||
};
|
||
|
||
const triggerImportSelect = () => {
|
||
if (uploading) {
|
||
return;
|
||
}
|
||
importInputRef.current?.click();
|
||
};
|
||
|
||
const handleImportFileChange = (event) => {
|
||
const file = event.target.files?.[0];
|
||
event.target.value = '';
|
||
if (!file) {
|
||
return;
|
||
}
|
||
void handleImport(file);
|
||
};
|
||
|
||
const handleDeleteAll = async () => {
|
||
try {
|
||
setDeleting(true);
|
||
const res = await deleteProductCodeItems();
|
||
if (!res?.success) {
|
||
throw new Error(res?.error || '删除测款数据失败');
|
||
}
|
||
setLatestResult(null);
|
||
setSelectedDeleteCodes([]);
|
||
setDeleteCodeOptions([]);
|
||
message.success(res.message || `已删除 ${res.data?.deletedRows || 0} 条测款数据`);
|
||
} catch (error) {
|
||
message.error(error.response?.data?.error || error.message || '删除测款数据失败');
|
||
throw error;
|
||
} finally {
|
||
setDeleting(false);
|
||
}
|
||
};
|
||
|
||
const fetchDeleteCodeOptions = async (keyword = '') => {
|
||
try {
|
||
setSearchingDeleteCodes(true);
|
||
const res = await getProductCodeItems({
|
||
page: 1,
|
||
pageSize: 20,
|
||
keyword: String(keyword || '').trim() || undefined,
|
||
});
|
||
const rows = Array.isArray(res?.data?.rows) ? res.data.rows : [];
|
||
setDeleteCodeOptions((currentOptions) => {
|
||
const selectedOptions = currentOptions.filter((option) => selectedDeleteCodes.includes(option.value));
|
||
return mergeProductCodeDeleteOptions(selectedOptions, rows);
|
||
});
|
||
} catch (error) {
|
||
message.error(error.response?.data?.error || error.message || '获取商品编码失败');
|
||
} finally {
|
||
setSearchingDeleteCodes(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteCodeSearch = (keyword) => {
|
||
if (deleteCodeSearchTimerRef.current) {
|
||
clearTimeout(deleteCodeSearchTimerRef.current);
|
||
}
|
||
|
||
deleteCodeSearchTimerRef.current = setTimeout(() => {
|
||
void fetchDeleteCodeOptions(keyword);
|
||
}, 250);
|
||
};
|
||
|
||
const handleDeleteCodeOpenChange = (open) => {
|
||
if (open && !deleteCodeOptions.length) {
|
||
void fetchDeleteCodeOptions('');
|
||
}
|
||
};
|
||
|
||
const handleDeleteSelectedCodes = async () => {
|
||
const productCodes = selectedDeleteCodes.filter(Boolean);
|
||
if (!productCodes.length) {
|
||
message.warning('请先选择要删除的商品编码');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setDeletingSelected(true);
|
||
const res = await deleteSelectedProductCodeItems(productCodes);
|
||
if (!res?.success) {
|
||
throw new Error(res?.error || '按编码删除测款数据失败');
|
||
}
|
||
setSelectedDeleteCodes([]);
|
||
setDeleteCodeOptions((currentOptions) => currentOptions.filter((option) => !productCodes.includes(option.value)));
|
||
message.success(res.message || `已删除 ${res.data?.deletedRows || 0} 条测款数据`);
|
||
} catch (error) {
|
||
message.error(error.response?.data?.error || error.message || '按编码删除测款数据失败');
|
||
throw error;
|
||
} finally {
|
||
setDeletingSelected(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="settings-tab-page">
|
||
<SycmCard bodyStyle={{ padding: 16 }}>
|
||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||
<Space align="center">
|
||
<CloudUploadOutlined style={{ color: 'var(--sycm-color-primary)' }} />
|
||
<Title level={5} style={{ margin: 0 }}>测款数据导入</Title>
|
||
</Space>
|
||
{importNotice ? (
|
||
<Alert
|
||
type={importNotice.type}
|
||
showIcon
|
||
closable
|
||
onClose={() => setImportNotice(null)}
|
||
message={importNotice.title}
|
||
description={(
|
||
<Space direction="vertical" size={6}>
|
||
{importNotice.fileName ? <Text>文件:{importNotice.fileName}</Text> : null}
|
||
<Text>{importNotice.detail}</Text>
|
||
{importNotice.type === 'error' && importNotice.reportDetails?.rowErrors?.length ? (
|
||
<Button
|
||
size="small"
|
||
icon={<DownloadOutlined />}
|
||
onClick={() => downloadImportErrorReport(importNotice.reportDetails)}
|
||
>
|
||
下载失败明细
|
||
</Button>
|
||
) : null}
|
||
<Text type="secondary">
|
||
时间:{importNotice.happenedAt ? dayjs(importNotice.happenedAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
|
||
</Text>
|
||
</Space>
|
||
)}
|
||
/>
|
||
) : null}
|
||
<div
|
||
style={{
|
||
padding: 16,
|
||
borderRadius: 'var(--sycm-radius-lg)',
|
||
background: 'var(--sycm-color-primary-bg)',
|
||
border: '1px solid var(--sycm-color-primary-border)',
|
||
}}
|
||
>
|
||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||
<Text strong>导入模板</Text>
|
||
<Text type="secondary">这里可分别下载“测款导入模板”和“测款批量新增模板”;两份模板都会附带“辅助表-勿动”,且长宽高都支持留空。批量新增模板中的商品名称需要按规格逐行填写,同一商品序号下可以不同。</Text>
|
||
<Space size={[10, 10]} wrap>
|
||
<Button
|
||
icon={<DownloadOutlined />}
|
||
onClick={handleDownloadTemplate}
|
||
loading={downloading}
|
||
>
|
||
下载测款导入模板
|
||
</Button>
|
||
<Button
|
||
icon={<DownloadOutlined />}
|
||
onClick={handleDownloadBatchTemplate}
|
||
loading={downloadingBatchTemplate}
|
||
>
|
||
下载测款批量新增模板
|
||
</Button>
|
||
<input
|
||
ref={importInputRef}
|
||
type="file"
|
||
accept=".xlsx,.xls"
|
||
onChange={handleImportFileChange}
|
||
style={{ display: 'none' }}
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
icon={<CloudUploadOutlined />}
|
||
loading={uploading}
|
||
onClick={triggerImportSelect}
|
||
>
|
||
{uploading ? '正在导入...' : '上传并导入测款数据'}
|
||
</Button>
|
||
</Space>
|
||
</Space>
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
padding: 16,
|
||
borderRadius: 8,
|
||
background: 'var(--sycm-color-danger-bg)',
|
||
border: 'var(--sycm-line-width-base) solid var(--sycm-color-danger-border)',
|
||
}}
|
||
>
|
||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||
<Text strong type="danger">删除测款数据</Text>
|
||
<Text type="secondary">清空系统数据库中的测款数据表,不会删除导入模板、运营中心和分类规则。</Text>
|
||
<Popconfirm
|
||
title="确认删除全部测款数据?"
|
||
description="删除后测款页面的现有数据会被清空,下一次新增或导入会重新按当前表内最大序号计算。"
|
||
okText="确认删除"
|
||
cancelText="取消"
|
||
onConfirm={handleDeleteAll}
|
||
disabled={deleting}
|
||
okButtonProps={{ danger: true, loading: deleting }}
|
||
>
|
||
<Button
|
||
danger
|
||
icon={<DeleteOutlined />}
|
||
loading={deleting}
|
||
>
|
||
清空测款数据
|
||
</Button>
|
||
</Popconfirm>
|
||
<Divider style={{ margin: '4px 0', borderColor: 'var(--sycm-color-danger-border)' }} />
|
||
<Text strong type="danger">按编码删除</Text>
|
||
<Text type="secondary">搜索并选择要删除的商品编码,可一次删除多条;系统会自动清理已无商品引用的款式记录。</Text>
|
||
<Select
|
||
mode="multiple"
|
||
allowClear
|
||
showSearch
|
||
maxTagCount="responsive"
|
||
value={selectedDeleteCodes}
|
||
placeholder="搜索商品编码、商品名称或颜色及规格"
|
||
filterOption={false}
|
||
options={deleteCodeOptions}
|
||
loading={searchingDeleteCodes}
|
||
onSearch={handleDeleteCodeSearch}
|
||
onChange={setSelectedDeleteCodes}
|
||
onOpenChange={handleDeleteCodeOpenChange}
|
||
notFoundContent={searchingDeleteCodes ? '正在搜索编码...' : '没有匹配的编码'}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
<Popconfirm
|
||
title={`确认删除已选中的 ${selectedDeleteCodes.length} 个编码?`}
|
||
description="只会删除选中的测款明细,不会清空整张测款表。"
|
||
okText="确认删除"
|
||
cancelText="取消"
|
||
onConfirm={handleDeleteSelectedCodes}
|
||
disabled={!selectedDeleteCodes.length || deletingSelected}
|
||
okButtonProps={{ danger: true, loading: deletingSelected }}
|
||
>
|
||
<Button
|
||
danger
|
||
icon={<DeleteOutlined />}
|
||
loading={deletingSelected}
|
||
disabled={!selectedDeleteCodes.length}
|
||
>
|
||
删除所选编码
|
||
</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
</div>
|
||
|
||
{latestResult ? (
|
||
<Alert
|
||
type="success"
|
||
showIcon
|
||
message="最近一次导入完成"
|
||
description={(
|
||
<Space direction="vertical" size={6}>
|
||
<Text>文件:{latestResult.fileName}</Text>
|
||
<Text>
|
||
工作表:{latestResult.sheetName || '-'};总行数:{latestResult.totalRows || 0};
|
||
新增:{latestResult.insertedRows || 0};更新:{latestResult.updatedRows || 0};
|
||
自动补齐商品编码:{latestResult.generatedProductCodeCount || 0}
|
||
</Text>
|
||
<Text type="secondary">
|
||
时间:{latestResult.importedAt ? dayjs(latestResult.importedAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
|
||
</Text>
|
||
</Space>
|
||
)}
|
||
/>
|
||
) : null}
|
||
|
||
<Table
|
||
size="small"
|
||
pagination={false}
|
||
rowKey="field"
|
||
dataSource={[
|
||
{ field: '运营中心', required: '必填', note: '来自辅助表运营中心列' },
|
||
{ field: '分类', required: '必填', note: '来自辅助表类目列' },
|
||
{ field: '序号 / 款式编码', required: '必填其一', note: '用于还原款式编码和当前序号' },
|
||
{ field: '商品编码', required: '建议填写', note: '为空时系统按款式编码自动补 01、02' },
|
||
{ field: '商品名称、颜色及规格、长、宽、高', required: '必填', note: '用于测款基础资料和体积计算;商品名称按规格逐行填写' },
|
||
]}
|
||
columns={[
|
||
{ title: '字段', dataIndex: 'field', width: 220 },
|
||
{ title: '要求', dataIndex: 'required', width: 120, render: (value) => <Tag color={value === '必填' ? 'red' : 'gold'}>{value}</Tag> },
|
||
{ title: '说明', dataIndex: 'note' },
|
||
]}
|
||
/>
|
||
</Space>
|
||
</SycmCard>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const Placeholder = ({ title }) => (
|
||
<SycmCard bodyStyle={{ padding: 16 }}>
|
||
<div style={{ textAlign: 'center', color: 'var(--sycm-text-tertiary)' }}>
|
||
<Text style={{ color: 'var(--sycm-text-tertiary)', fontSize: 15 }}>{title} 正在开发中,敬请期待...</Text>
|
||
</div>
|
||
</SycmCard>
|
||
);
|
||
|
||
const RedisSettings = () => {
|
||
const [redisForm] = Form.useForm();
|
||
const [redisLoading, setRedisLoading] = useState(false);
|
||
const [redisTesting, setRedisTesting] = useState(false);
|
||
const [redisRuntimeProfile, setRedisRuntimeProfile] = useState('stored');
|
||
const [redisEditProfile, setRedisEditProfile] = useState('default');
|
||
const [redisRuntimeSwitching, setRedisRuntimeSwitching] = useState('');
|
||
|
||
const applyRedisFormConfig = useCallback((res = {}) => {
|
||
const displayConfig = res.effective || res;
|
||
setRedisRuntimeProfile(res.runtimeProfile || displayConfig.source || 'stored');
|
||
if (displayConfig.profileKey === 'changzhou' || displayConfig.source === 'changzhou') {
|
||
setRedisEditProfile('changzhou');
|
||
} else if (displayConfig.profileKey === 'default' || displayConfig.source === 'database') {
|
||
setRedisEditProfile('default');
|
||
} else if (displayConfig.profileKey === 'env' || displayConfig.source === 'env') {
|
||
setRedisEditProfile('env');
|
||
}
|
||
redisForm.setFieldsValue({
|
||
host: displayConfig.host || '',
|
||
port: displayConfig.port || 6379,
|
||
db: displayConfig.db || 0,
|
||
password: '',
|
||
});
|
||
}, [redisForm]);
|
||
|
||
const fetchRedis = useCallback(async () => {
|
||
try {
|
||
const res = await getRedisConfig();
|
||
applyRedisFormConfig(res);
|
||
} catch (error) {
|
||
message.error(error.response?.data?.error || '获取 Redis 配置失败');
|
||
}
|
||
}, [applyRedisFormConfig]);
|
||
|
||
useEffect(() => {
|
||
fetchRedis();
|
||
}, [fetchRedis]);
|
||
|
||
const handleSaveRedis = async () => {
|
||
try {
|
||
if (redisEditProfile === 'env') {
|
||
message.warning('.env 兜底配置不写入系统库,请切换到默认缓存库或常州缓存库后保存。');
|
||
return;
|
||
}
|
||
const values = await redisForm.validateFields();
|
||
setRedisLoading(true);
|
||
const res = await updateRedisConfig({ ...values, profileKey: redisEditProfile });
|
||
message.success(res.message || 'Redis 配置已保存');
|
||
fetchRedis();
|
||
} catch (error) {
|
||
if (!error?.errorFields) {
|
||
message.error(error.response?.data?.error || '保存 Redis 配置失败');
|
||
}
|
||
} finally {
|
||
setRedisLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleTestRedis = async () => {
|
||
try {
|
||
const values = await redisForm.validateFields();
|
||
if (!values.host) {
|
||
message.warning('测试连接前请填写 Redis 主机地址');
|
||
return;
|
||
}
|
||
|
||
setRedisTesting(true);
|
||
const res = await testRedisConfig({ ...values, profileKey: redisEditProfile });
|
||
message.success(res.message || 'Redis 连接成功');
|
||
} catch (error) {
|
||
if (!error?.errorFields) {
|
||
message.error(error.response?.data?.message || error.response?.data?.error || 'Redis 连接失败');
|
||
}
|
||
} finally {
|
||
setRedisTesting(false);
|
||
}
|
||
};
|
||
|
||
const handleSwitchRedisProfile = async (profile) => {
|
||
try {
|
||
setRedisRuntimeSwitching(profile);
|
||
const res = await switchRedisRuntimeProfile({
|
||
profile,
|
||
});
|
||
setRedisRuntimeProfile(res.runtimeProfile || profile);
|
||
if (res.effective) {
|
||
if (res.effective.profileKey === 'changzhou' || res.effective.source === 'changzhou') {
|
||
setRedisEditProfile('changzhou');
|
||
} else if (res.effective.profileKey === 'default' || res.effective.source === 'database') {
|
||
setRedisEditProfile('default');
|
||
} else if (res.effective.profileKey === 'env' || res.effective.source === 'env') {
|
||
setRedisEditProfile('env');
|
||
}
|
||
redisForm.setFieldsValue({
|
||
host: res.effective.host || '',
|
||
port: res.effective.port || 6379,
|
||
db: res.effective.db || 0,
|
||
password: '',
|
||
});
|
||
}
|
||
message.success(res.message || 'Redis 运行态已切换');
|
||
} catch (error) {
|
||
message.error(error.response?.data?.error || '切换 Redis 运行态失败');
|
||
} finally {
|
||
setRedisRuntimeSwitching('');
|
||
}
|
||
};
|
||
|
||
const isAutoRuntimeRedis = redisRuntimeProfile === 'stored';
|
||
const isDefaultRuntimeRedis = redisRuntimeProfile === 'default';
|
||
const isLocalRuntimeRedis = redisRuntimeProfile === 'changzhou' || redisRuntimeProfile === 'changzhou_local';
|
||
const isEnvRuntimeRedis = redisRuntimeProfile === 'env';
|
||
const redisEditProfileName = redisEditProfile === 'changzhou'
|
||
? '常州缓存库'
|
||
: redisEditProfile === 'env'
|
||
? '.env 兜底'
|
||
: '默认缓存库';
|
||
|
||
return (
|
||
<div className="settings-tab-page">
|
||
<SycmCard bodyStyle={{ padding: 16 }}>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: 16,
|
||
padding: 'var(--sycm-space-sm) var(--sycm-module-padding)',
|
||
marginBottom: 'var(--sycm-page-section-gap)',
|
||
border: 'var(--sycm-line-width-base) solid var(--sycm-border-card)',
|
||
borderRadius: 'var(--sycm-radius-lg)',
|
||
background: isLocalRuntimeRedis ? 'var(--sycm-color-success-bg)' : 'var(--sycm-color-primary-soft)',
|
||
flexWrap: 'wrap',
|
||
}}
|
||
>
|
||
<Space size={[10, 10]} wrap>
|
||
<Button
|
||
type={isAutoRuntimeRedis ? 'primary' : 'default'}
|
||
loading={redisRuntimeSwitching === 'stored'}
|
||
onClick={() => handleSwitchRedisProfile('stored')}
|
||
>
|
||
自动选择
|
||
</Button>
|
||
<Button
|
||
type={isDefaultRuntimeRedis ? 'primary' : 'default'}
|
||
loading={redisRuntimeSwitching === 'default'}
|
||
onClick={() => handleSwitchRedisProfile('default')}
|
||
>
|
||
默认缓存库
|
||
</Button>
|
||
<Button
|
||
type={isLocalRuntimeRedis ? 'primary' : 'default'}
|
||
loading={redisRuntimeSwitching === 'changzhou'}
|
||
onClick={() => handleSwitchRedisProfile('changzhou')}
|
||
style={{ borderRadius: 'var(--sycm-radius-lg)', background: isLocalRuntimeRedis ? 'var(--sycm-color-success)' : undefined, borderColor: isLocalRuntimeRedis ? 'var(--sycm-color-success)' : undefined }}
|
||
>
|
||
常州缓存库
|
||
</Button>
|
||
<Button
|
||
type={isEnvRuntimeRedis ? 'primary' : 'default'}
|
||
loading={redisRuntimeSwitching === 'env'}
|
||
onClick={() => handleSwitchRedisProfile('env')}
|
||
>
|
||
.env 兜底
|
||
</Button>
|
||
<Tag color="blue">当前编辑:{redisEditProfileName}</Tag>
|
||
<Tag color="green">常州识别 IP:192.168.150.234</Tag>
|
||
</Space>
|
||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||
保存会写入系统库对应配置;自动选择只决定运行优先级,不改变已保存配置。
|
||
</Text>
|
||
</div>
|
||
|
||
<Form form={redisForm} layout="vertical" size="large">
|
||
<Row gutter={[16, 0]}>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item label="主机地址" name="host">
|
||
<Input placeholder="例如: 180.184.103.18" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item label="端口" name="port" rules={[{ required: true, message: '请输入端口' }]}>
|
||
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item label="数据库编号 DB" name="db" rules={[{ required: true, message: '请输入数据库编号' }]}>
|
||
<InputNumber style={{ width: '100%' }} min={0} />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item label="密码" name="password" extra="留空表示沿用当前已保存密码;保存会写入当前编辑的缓存库配置">
|
||
<Input.Password placeholder="请输入 Redis 密码" />
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
|
||
<div style={{ display: 'flex', gap: 16, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||
<Button onClick={fetchRedis} icon={<ReloadOutlined />}>
|
||
刷新
|
||
</Button>
|
||
<Button onClick={handleTestRedis} loading={redisTesting} icon={<ApiOutlined />}>
|
||
测试连接
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
onClick={handleSaveRedis}
|
||
loading={redisLoading}
|
||
disabled={isLocalRuntimeRedis}
|
||
icon={<SaveOutlined />}
|
||
>
|
||
保存系统库配置
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</SycmCard>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const WorkOrderSystemSettings = () => {
|
||
const [workOrderForm] = Form.useForm();
|
||
const [workOrderLoading, setWorkOrderLoading] = useState(false);
|
||
const [workOrderTesting, setWorkOrderTesting] = useState(false);
|
||
const [workOrderConfig, setWorkOrderConfig] = useState({});
|
||
const [workOrderTestResult, setWorkOrderTestResult] = useState(null);
|
||
|
||
const fetchWorkOrder = useCallback(async () => {
|
||
try {
|
||
const res = await getWorkOrderConfig();
|
||
setWorkOrderConfig(res || {});
|
||
workOrderForm.setFieldsValue({
|
||
enabled: res?.enabled !== false,
|
||
baseUrl: res?.baseUrl || 'https://www.jiansun.vip:26788',
|
||
tokenPath: res?.tokenPath || '/api/auth/accessToken',
|
||
appKey: res?.appKey || '',
|
||
signKey: '',
|
||
appSecret: '',
|
||
});
|
||
} catch (error) {
|
||
message.error(error.response?.data?.error || '获取工单系统配置失败');
|
||
}
|
||
}, [workOrderForm]);
|
||
|
||
useEffect(() => {
|
||
fetchWorkOrder();
|
||
}, [fetchWorkOrder]);
|
||
|
||
const handleSaveWorkOrder = async () => {
|
||
try {
|
||
const values = await workOrderForm.validateFields();
|
||
setWorkOrderLoading(true);
|
||
const res = await updateWorkOrderConfig(values);
|
||
setWorkOrderConfig(res || {});
|
||
workOrderForm.setFieldsValue({
|
||
signKey: '',
|
||
appSecret: '',
|
||
});
|
||
message.success(res.message || '工单系统配置已保存');
|
||
await fetchWorkOrder();
|
||
} catch (error) {
|
||
if (!error?.errorFields) {
|
||
message.error(error.response?.data?.error || '保存工单系统配置失败');
|
||
}
|
||
} finally {
|
||
setWorkOrderLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleTestWorkOrder = async () => {
|
||
try {
|
||
const values = await workOrderForm.validateFields();
|
||
setWorkOrderTesting(true);
|
||
const res = await testWorkOrderConfig(values);
|
||
setWorkOrderTestResult(res);
|
||
message.success(res.message || '工单系统连接成功');
|
||
} catch (error) {
|
||
if (!error?.errorFields) {
|
||
const errorMessage = error.response?.data?.message || error.response?.data?.error || '工单系统连接失败';
|
||
setWorkOrderTestResult({ success: false, message: errorMessage });
|
||
message.error(errorMessage);
|
||
}
|
||
} finally {
|
||
setWorkOrderTesting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="settings-tab-page">
|
||
<SycmCard bodyStyle={{ padding: 16 }}>
|
||
<Form
|
||
form={workOrderForm}
|
||
layout="vertical"
|
||
size="large"
|
||
initialValues={{
|
||
enabled: true,
|
||
baseUrl: 'https://www.jiansun.vip:26788',
|
||
tokenPath: '/api/auth/accessToken',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', background: 'var(--sycm-color-primary-bg)', borderRadius: 'var(--sycm-radius-lg)', marginBottom: 16 }}>
|
||
<Space size={[8, 8]} wrap>
|
||
<Text strong>启用工单系统连接</Text>
|
||
<Tag color={workOrderConfig.signKeyConfigured ? 'green' : 'orange'}>
|
||
签名密钥{workOrderConfig.signKeyConfigured ? '已配置' : '未配置'}
|
||
</Tag>
|
||
<Tag color={workOrderConfig.hasCompleteAppAuth ? 'green' : 'default'}>
|
||
AppToken{workOrderConfig.hasCompleteAppAuth ? '可用' : '待配置'}
|
||
</Tag>
|
||
</Space>
|
||
<Form.Item name="enabled" valuePropName="checked" noStyle>
|
||
<Switch />
|
||
</Form.Item>
|
||
</div>
|
||
|
||
<Row gutter={[16, 0]}>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item
|
||
label="接口地址"
|
||
name="baseUrl"
|
||
rules={[{ required: true, message: '请输入工单系统接口地址' }]}
|
||
>
|
||
<Input placeholder="https://www.jiansun.vip:26788" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item
|
||
label="AppToken 路径"
|
||
name="tokenPath"
|
||
rules={[{ required: true, message: '请输入 AppToken 路径' }]}
|
||
>
|
||
<Input placeholder="/api/auth/accessToken" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item
|
||
label="签名密钥"
|
||
name="signKey"
|
||
rules={[{ required: !workOrderConfig.signKeyConfigured, message: '请输入工单系统签名密钥' }]}
|
||
extra={workOrderConfig.signKeyConfigured ? `留空表示沿用当前密钥:${workOrderConfig.maskedSignKey || '已保存'}` : '用于 HMACSHA256 签名,保存后不会回显明文'}
|
||
>
|
||
<Input.Password placeholder="请输入签名密钥" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item
|
||
label="AppKey"
|
||
name="appKey"
|
||
extra="后端调用工单业务接口时用于换取 AppToken"
|
||
>
|
||
<Input placeholder="请输入 AppKey" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item
|
||
label="AppSecret"
|
||
name="appSecret"
|
||
extra={workOrderConfig.appSecretConfigured ? `留空表示沿用当前密钥:${workOrderConfig.maskedAppSecret || '已保存'}` : '未填写时只能测试接口地址连通性,不能测试 AppToken'}
|
||
>
|
||
<Input.Password placeholder="请输入 AppSecret" />
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
|
||
{workOrderTestResult && (
|
||
<Alert
|
||
type={workOrderTestResult.success ? 'success' : 'error'}
|
||
showIcon
|
||
style={{ marginBottom: 18 }}
|
||
message={workOrderTestResult.message}
|
||
description={
|
||
workOrderTestResult.responseTime
|
||
? `响应耗时 ${workOrderTestResult.responseTime} ms`
|
||
: undefined
|
||
}
|
||
/>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', gap: 16, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||
<Button onClick={fetchWorkOrder} icon={<ReloadOutlined />}>
|
||
刷新
|
||
</Button>
|
||
<Button onClick={handleTestWorkOrder} loading={workOrderTesting} icon={<ApiOutlined />}>
|
||
测试连接
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
onClick={handleSaveWorkOrder}
|
||
loading={workOrderLoading}
|
||
icon={<SaveOutlined />}
|
||
>
|
||
保存配置
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</SycmCard>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const S3Settings = () => {
|
||
const [s3Form] = Form.useForm();
|
||
const [s3Loading, setS3Loading] = useState(false);
|
||
const [s3Testing, setS3Testing] = useState(false);
|
||
const [s3HasSecret, setS3HasSecret] = useState(false);
|
||
const s3Provider = Form.useWatch('provider', s3Form);
|
||
|
||
const fetchS3 = useCallback(async () => {
|
||
try {
|
||
const res = await getS3Config();
|
||
setS3HasSecret(Boolean(res.hasSecret));
|
||
s3Form.setFieldsValue({
|
||
provider: res.provider || 'minio',
|
||
endpoint: res.endpoint || '',
|
||
region: res.region || 'auto',
|
||
zone: res.zone || '',
|
||
protocol: res.protocol || 'https',
|
||
storageClass: res.storageClass || 'STANDARD',
|
||
bucket: res.bucket || '',
|
||
accessKeyId: res.accessKeyId || '',
|
||
secretAccessKey: '',
|
||
customDomain: res.customDomain || '',
|
||
});
|
||
} catch (error) {
|
||
message.error(error.response?.data?.error || '获取对象存储配置失败');
|
||
}
|
||
}, [s3Form]);
|
||
|
||
useEffect(() => {
|
||
fetchS3();
|
||
}, [fetchS3]);
|
||
|
||
const handleSaveS3 = async () => {
|
||
try {
|
||
const values = await s3Form.validateFields();
|
||
setS3Loading(true);
|
||
const res = await updateS3Config(values);
|
||
message.success(res.message || '对象存储配置已保存');
|
||
fetchS3();
|
||
} catch (error) {
|
||
if (!error?.errorFields) {
|
||
message.error(error.response?.data?.error || '保存对象存储配置失败');
|
||
}
|
||
} finally {
|
||
setS3Loading(false);
|
||
}
|
||
};
|
||
|
||
const handleTestS3 = async () => {
|
||
try {
|
||
const values = await s3Form.validateFields();
|
||
if (!values.secretAccessKey && !s3HasSecret) {
|
||
message.warning('测试连接前请填写 Secret Access Key');
|
||
return;
|
||
}
|
||
|
||
setS3Testing(true);
|
||
const res = await testS3Config(values);
|
||
message.success(res.message || '对象存储连接成功');
|
||
} catch (error) {
|
||
if (!error?.errorFields) {
|
||
message.error(error.response?.data?.message || error.response?.data?.error || '对象存储连接失败');
|
||
}
|
||
} finally {
|
||
setS3Testing(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="settings-tab-page">
|
||
<SycmCard bodyStyle={{ padding: 16 }}>
|
||
<Form form={s3Form} layout="vertical" size="large">
|
||
<Row gutter={[16, 0]}>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item label="对象存储提供商" name="provider" rules={[{ required: true, message: '请选择对象存储提供商' }]}>
|
||
<Select
|
||
onChange={(provider) => {
|
||
if (provider === 'qiniu') {
|
||
s3Form.setFieldsValue({
|
||
endpoint: 'https://s3.cn-east-1.qiniucs.com',
|
||
region: 'cn-east-1',
|
||
zone: 'z0',
|
||
protocol: 'https',
|
||
storageClass: 'STANDARD',
|
||
});
|
||
}
|
||
}}
|
||
options={[
|
||
{ value: 'minio', label: 'MinIO / 自建 S3' },
|
||
{ value: 'qiniu', label: '七牛云 Kodo(S3 兼容)' },
|
||
{ value: 'aliyun', label: '阿里云 OSS 兼容模式' },
|
||
{ value: 'aws', label: 'AWS S3' },
|
||
{ value: 'custom', label: '自定义 S3 兼容服务' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item label="访问协议" name="protocol" rules={[{ required: true, message: '请选择访问协议' }]}>
|
||
<Segmented
|
||
block
|
||
options={[
|
||
{ label: 'HTTPS', value: 'https' },
|
||
{ label: 'HTTP', value: 'http' },
|
||
]}
|
||
onChange={(protocol) => {
|
||
s3Form.setFieldsValue({
|
||
endpoint: replaceUrlProtocol(s3Form.getFieldValue('endpoint'), protocol),
|
||
customDomain: replaceUrlProtocol(s3Form.getFieldValue('customDomain'), protocol),
|
||
});
|
||
}}
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item label="Region" name="region" rules={[{ required: true, message: '请输入 Region' }]}>
|
||
{s3Provider === 'qiniu' ? (
|
||
<Select
|
||
options={QINIU_REGION_OPTIONS}
|
||
onChange={(region) => {
|
||
const option = QINIU_REGION_OPTIONS.find((item) => item.value === region);
|
||
if (option) {
|
||
const protocol = s3Form.getFieldValue('protocol') || 'https';
|
||
s3Form.setFieldsValue({
|
||
zone: option.zone,
|
||
endpoint: replaceUrlProtocol(option.endpoint, protocol),
|
||
});
|
||
}
|
||
}}
|
||
/>
|
||
) : (
|
||
<Input placeholder="MinIO 推荐填写 auto" />
|
||
)}
|
||
</Form.Item>
|
||
</Col>
|
||
{s3Provider === 'qiniu' ? (
|
||
<Col xs={24} md={12}>
|
||
<Form.Item label="七牛 Zone" name="zone">
|
||
<Input disabled />
|
||
</Form.Item>
|
||
</Col>
|
||
) : null}
|
||
<Col xs={24} md={12}>
|
||
<Form.Item label="Endpoint" name="endpoint" rules={[{ required: true, message: '请输入 Endpoint' }]}>
|
||
<Input placeholder="例如 https://s3.cn-east-1.qiniucs.com" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item label="存储类型" name="storageClass" rules={[{ required: true, message: '请选择存储类型' }]}>
|
||
<Select options={OBJECT_STORAGE_CLASS_OPTIONS} />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item label="Bucket(存储空间名)" name="bucket" rules={[{ required: true, message: '请输入存储空间名' }]}>
|
||
<Input placeholder="例如 gongxiangpan-sc" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item label="Access Key ID" name="accessKeyId" rules={[{ required: true, message: '请输入 Access Key ID' }]}>
|
||
<Input placeholder="请输入 Access Key ID" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item
|
||
label="Secret Access Key"
|
||
name="secretAccessKey"
|
||
extra={s3HasSecret ? '密钥已保存;留空表示继续使用当前密钥。' : '首次配置时必须填写 Secret Access Key。'}
|
||
>
|
||
<Input.Password placeholder={s3HasSecret ? '已安全保存' : '请输入 Secret Access Key'} />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={24}>
|
||
<Form.Item
|
||
label="图片访问基础地址"
|
||
name="customDomain"
|
||
extra="公开 Bucket 或 CDN 的访问域名;上传后的文件 URL 会优先使用该地址。"
|
||
>
|
||
<Input placeholder="例如 https://scqny.jiansun.vip" />
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
|
||
<div style={{ display: 'flex', gap: 16, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||
<Button onClick={fetchS3} icon={<ReloadOutlined />}>
|
||
刷新
|
||
</Button>
|
||
<Button onClick={handleTestS3} loading={s3Testing} icon={<ApiOutlined />}>
|
||
测试连接
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
onClick={handleSaveS3}
|
||
loading={s3Loading}
|
||
icon={<SaveOutlined />}
|
||
>
|
||
保存配置
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</SycmCard>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const BackfillSettings = () => {
|
||
const [range, setRange] = useState([dayjs().startOf('month'), dayjs()]);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [job, setJob] = useState(null);
|
||
const [status, setStatus] = useState(null);
|
||
const [error, setError] = useState(null);
|
||
|
||
useEffect(() => {
|
||
let timer = null;
|
||
if (job?.id) {
|
||
timer = setInterval(async () => {
|
||
try {
|
||
const res = await getBackfillStatus({ jobId: job.id });
|
||
if (res?.success && res.job) {
|
||
setStatus(res.job);
|
||
if (res.job.status !== 'running') {
|
||
clearInterval(timer);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
clearInterval(timer);
|
||
setError(err?.message || '\u83b7\u53d6\u56de\u6eaf\u72b6\u6001\u5931\u8d25');
|
||
}
|
||
}, 2000);
|
||
}
|
||
return () => {
|
||
if (timer) clearInterval(timer);
|
||
};
|
||
}, [job]);
|
||
|
||
const handleStart = async () => {
|
||
try {
|
||
setSubmitting(true);
|
||
setError(null);
|
||
const [start, end] = range || [];
|
||
if (!start || !end) {
|
||
message.error('\u8bf7\u9009\u62e9\u56de\u6eaf\u8d77\u6b62\u65e5\u671f');
|
||
return;
|
||
}
|
||
const res = await startBackfill({
|
||
startDate: start.format('YYYY-MM-DD'),
|
||
endDate: end.format('YYYY-MM-DD'),
|
||
});
|
||
if (res?.success) {
|
||
setJob(res.job);
|
||
setStatus(res.job);
|
||
message.success('\u56de\u6eaf\u4efb\u52a1\u5df2\u542f\u52a8');
|
||
} else {
|
||
message.error(res?.error || '\u56de\u6eaf\u542f\u52a8\u5931\u8d25');
|
||
}
|
||
} catch (err) {
|
||
message.error(err?.message || '\u56de\u6eaf\u542f\u52a8\u5931\u8d25');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const percent = status?.percent ?? 0;
|
||
|
||
return (
|
||
<div className="settings-tab-page">
|
||
<SycmCard bodyStyle={{ padding: 16 }}>
|
||
<Row gutter={[12, 12]} align="middle">
|
||
<Col xs={24} sm={16}>
|
||
<DatePicker.RangePicker
|
||
value={range}
|
||
onChange={setRange}
|
||
style={{ width: '100%' }}
|
||
allowClear={false}
|
||
/>
|
||
</Col>
|
||
<Col xs={24} sm={8}>
|
||
<Button
|
||
type="primary"
|
||
block
|
||
loading={submitting || status?.status === 'running'}
|
||
onClick={handleStart}
|
||
>
|
||
{'\u5f00\u59cb\u56de\u6eaf'}
|
||
</Button>
|
||
</Col>
|
||
</Row>
|
||
|
||
{error && (
|
||
<Alert style={{ marginTop: 16 }} type="error" message={error} showIcon />
|
||
)}
|
||
|
||
{status && (
|
||
<div style={{ marginTop: 16 }}>
|
||
<Progress percent={percent} status={status.status === 'failed' ? 'exception' : 'active'} />
|
||
<Row gutter={[12, 8]} style={{ marginTop: 8 }}>
|
||
<Col xs={24} sm={12}>
|
||
<Text>{'\u72b6\u6001'}:{status.status}</Text>
|
||
</Col>
|
||
<Col xs={24} sm={12}>
|
||
<Text>{'\u8fdb\u5ea6'}:{status.completedDays}/{status.totalDays}</Text>
|
||
</Col>
|
||
<Col xs={24} sm={12}>
|
||
<Text>{'\u5f53\u524d\u65e5\u671f'}:{status.currentDate || '-'}</Text>
|
||
</Col>
|
||
<Col xs={24} sm={12}>
|
||
<Text>
|
||
{'\u5f53\u524d\u4efb\u52a1'}:{status.currentTask || '-'}
|
||
{Number.isFinite(status.currentStep) && Number.isFinite(status.totalStepsPerDay)
|
||
? `(\u7b2c ${status.currentStep}/${status.totalStepsPerDay} \u6b65)`
|
||
: ''}
|
||
</Text>
|
||
</Col>
|
||
<Col xs={24} sm={12}>
|
||
<Text>{'\u5269\u4f59\u4efb\u52a1'}:{status.remainingTasks ?? 0}</Text>
|
||
</Col>
|
||
<Col xs={24} sm={12}>
|
||
<Text>{'\u603b\u4efb\u52a1'}:{status.completedTasks ?? 0}/{status.totalTasks ?? 0}</Text>
|
||
</Col>
|
||
<Col xs={24} sm={12}>
|
||
<Text>{'\u5f00\u59cb\u65f6\u95f4'}:{status.startedAt || '-'}</Text>
|
||
</Col>
|
||
<Col xs={24} sm={12}>
|
||
<Text>{'\u7ed3\u675f\u65f6\u95f4'}:{status.finishedAt || '-'}</Text>
|
||
</Col>
|
||
{status.error && (
|
||
<Col xs={24}>
|
||
<Text type="danger">{'\u9519\u8bef'}:{status.error}</Text>
|
||
</Col>
|
||
)}
|
||
</Row>
|
||
</div>
|
||
)}
|
||
</SycmCard>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const SiteSettings = () => {
|
||
const [siteForm] = Form.useForm();
|
||
const [siteLoading, setSiteLoading] = useState(false);
|
||
const [logoPreview, setLogoPreview] = useState('');
|
||
const [uploading, setUploading] = useState(false);
|
||
|
||
const fetchSiteConfig = useCallback(async () => {
|
||
try {
|
||
const res = await getSystemConfig();
|
||
if (res) {
|
||
siteForm.setFieldsValue({
|
||
siteName: res.site_name || '拼多多自动化运营平台',
|
||
siteDescription: res.site_description || '数据分析平台',
|
||
});
|
||
if (res.logo) {
|
||
setLogoPreview(res.logo);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('获取网站配置失败:', error);
|
||
}
|
||
}, [siteForm]);
|
||
|
||
useEffect(() => {
|
||
fetchSiteConfig();
|
||
}, [fetchSiteConfig]);
|
||
|
||
const handleSaveSite = async (values) => {
|
||
try {
|
||
setSiteLoading(true);
|
||
const siteName = values.siteName.trim();
|
||
const siteDescription = values.siteDescription?.trim() || '';
|
||
const savedConfig = await updateSystemConfig({
|
||
site_name: siteName,
|
||
site_description: siteDescription,
|
||
});
|
||
|
||
siteForm.setFieldsValue({
|
||
siteName: savedConfig?.site_name || siteName,
|
||
siteDescription: savedConfig?.site_description ?? siteDescription,
|
||
});
|
||
window.dispatchEvent(new CustomEvent('site-config-updated', {
|
||
detail: {
|
||
siteName: savedConfig?.site_name || siteName,
|
||
siteDescription: savedConfig?.site_description ?? siteDescription,
|
||
},
|
||
}));
|
||
message.success('网站配置已保存');
|
||
} catch (error) {
|
||
message.error('保存失败: ' + error.message);
|
||
} finally {
|
||
setSiteLoading(false);
|
||
}
|
||
};
|
||
|
||
// 将图片转为Base64
|
||
const getBase64 = (file) => {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.readAsDataURL(file);
|
||
reader.onload = () => resolve(reader.result);
|
||
reader.onerror = error => reject(error);
|
||
});
|
||
};
|
||
|
||
const handleLogoUpload = async (info) => {
|
||
const file = info.file;
|
||
if (!file) return;
|
||
|
||
// 验证文件类型
|
||
const isImage = file.type.startsWith('image/');
|
||
if (!isImage) {
|
||
message.error('请上传图片文件');
|
||
return;
|
||
}
|
||
|
||
// 验证文件大小(最大2MB)
|
||
const isLt2M = file.size / 1024 / 1024 < 2;
|
||
if (!isLt2M) {
|
||
message.error('图片大小不能超过 2MB');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setUploading(true);
|
||
const base64 = await getBase64(file);
|
||
|
||
// 上传到后端
|
||
const res = await uploadLogo(base64);
|
||
const nextLogo = res.logo || base64;
|
||
|
||
setLogoPreview(nextLogo);
|
||
updateFavicon(nextLogo);
|
||
window.dispatchEvent(new CustomEvent('site-config-updated', {
|
||
detail: {
|
||
siteName: siteForm.getFieldValue('siteName'),
|
||
logo: nextLogo,
|
||
},
|
||
}));
|
||
message.success('Logo 已更新');
|
||
} catch (error) {
|
||
message.error('上传失败: ' + error.message);
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
};
|
||
|
||
// 更新浏览器favicon
|
||
const updateFavicon = (logoUrl) => {
|
||
let link = document.querySelector("link[rel*='icon']");
|
||
if (!link) {
|
||
link = document.createElement('link');
|
||
link.rel = 'icon';
|
||
document.head.appendChild(link);
|
||
}
|
||
link.href = logoUrl;
|
||
};
|
||
|
||
return (
|
||
<div className="settings-tab-page">
|
||
<Row gutter={[18, 18]}>
|
||
<Col xs={24} xl={9}>
|
||
<SycmCard
|
||
bodyStyle={{
|
||
padding: 16,
|
||
textAlign: 'center',
|
||
height: '100%',
|
||
}}
|
||
>
|
||
<Title level={5} style={{ marginBottom: 16 }}>
|
||
<PictureOutlined style={{ marginRight: 8 }} />
|
||
网站 Logo
|
||
</Title>
|
||
<div style={{ marginBottom: 16 }}>
|
||
{logoPreview ? (
|
||
<img
|
||
src={logoPreview}
|
||
alt="Logo"
|
||
style={{
|
||
maxWidth: 120,
|
||
maxHeight: 120,
|
||
borderRadius: 'var(--sycm-radius-lg)',
|
||
border: '1px solid var(--sycm-border-base)',
|
||
padding: 8,
|
||
background: 'var(--sycm-bg-container)'
|
||
}}
|
||
/>
|
||
) : (
|
||
<div style={{
|
||
width: 120,
|
||
height: 120,
|
||
margin: '0 auto',
|
||
borderRadius: 'var(--sycm-radius-lg)',
|
||
border: '1px dashed var(--sycm-border-strong)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: 'var(--sycm-text-tertiary)',
|
||
background: 'var(--sycm-bg-container)'
|
||
}}>
|
||
<div>
|
||
<PictureOutlined style={{ fontSize: 24 }} />
|
||
<div style={{ marginTop: 8, fontSize: 12 }}>暂无 Logo</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<Upload
|
||
showUploadList={false}
|
||
beforeUpload={() => false}
|
||
onChange={handleLogoUpload}
|
||
accept="image/*"
|
||
>
|
||
<Button
|
||
icon={<UploadOutlined />}
|
||
loading={uploading}
|
||
style={{ borderRadius: 8 }}
|
||
>
|
||
上传新 Logo
|
||
</Button>
|
||
</Upload>
|
||
<div style={{ marginTop: 12, color: 'var(--sycm-text-tertiary)', fontSize: 12 }}>
|
||
支持 JPG、PNG、GIF 格式,建议尺寸 120x120,最大 2MB
|
||
</div>
|
||
</SycmCard>
|
||
</Col>
|
||
<Col xs={24} xl={15}>
|
||
<SycmCard bodyStyle={{ padding: 16 }}>
|
||
<Form
|
||
form={siteForm}
|
||
layout="vertical"
|
||
onFinish={handleSaveSite}
|
||
initialValues={{ siteName: '拼多多自动化运营平台', siteDescription: '数据分析平台' }}
|
||
size="large"
|
||
>
|
||
<Form.Item
|
||
label="网站名称"
|
||
name="siteName"
|
||
normalize={(value) => value?.replace(/^\s+/, '')}
|
||
rules={[
|
||
{ required: true, whitespace: true, message: '请输入网站名称' },
|
||
{ max: 50, message: '网站名称不能超过 50 个字符' },
|
||
]}
|
||
extra="将显示在浏览器标签页和登录页面"
|
||
>
|
||
<Input placeholder="例如:拼多多自动化运营平台" />
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
label="网站描述"
|
||
name="siteDescription"
|
||
extra="简短描述网站功能"
|
||
>
|
||
<Input placeholder="例如: 数据分析平台" />
|
||
</Form.Item>
|
||
|
||
<Divider />
|
||
|
||
<div style={{ display: 'flex', gap: 16, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||
<Button
|
||
onClick={() => siteForm.resetFields()}
|
||
>
|
||
重置
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
htmlType="submit"
|
||
icon={<SaveOutlined />}
|
||
loading={siteLoading}
|
||
>
|
||
保存设置
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</SycmCard>
|
||
</Col>
|
||
</Row>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// KPI目标值配置组件
|
||
const KpiTargetsSettings = () => {
|
||
const [selectedPage, setSelectedPage] = useState('customerService');
|
||
const [targets, setTargets] = useState([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [modalVisible, setModalVisible] = useState(false);
|
||
const [editingTarget, setEditingTarget] = useState(null);
|
||
const [targetForm] = Form.useForm();
|
||
|
||
const pageNames = {
|
||
customerService: '客服分析',
|
||
warehouse: '仓库分析',
|
||
logistics: '快递分析',
|
||
supplier: '供应商分析',
|
||
procurement: '采购分析',
|
||
productDesign: '开发分析',
|
||
region: '地区分析',
|
||
summary: '监控大厅',
|
||
};
|
||
|
||
const fetchTargets = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
const res = await getKpiTargetsByPage(selectedPage);
|
||
if (res.success) {
|
||
setTargets(res.data || []);
|
||
}
|
||
} catch (error) {
|
||
console.error('获取KPI目标值失败:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [selectedPage]);
|
||
|
||
useEffect(() => {
|
||
fetchTargets();
|
||
}, [fetchTargets]);
|
||
|
||
const handleEdit = (record) => {
|
||
setEditingTarget(record);
|
||
targetForm.setFieldsValue({
|
||
kpiName: record.kpiName,
|
||
unit: record.unit,
|
||
direction: record.direction,
|
||
targetGood: record.targetGood,
|
||
targetPass: record.targetPass,
|
||
targetWarn: record.targetWarn,
|
||
isEnabled: record.isEnabled,
|
||
remark: record.remark,
|
||
});
|
||
setModalVisible(true);
|
||
};
|
||
|
||
const handleAdd = () => {
|
||
setEditingTarget(null);
|
||
targetForm.resetFields();
|
||
targetForm.setFieldsValue({ direction: 'lower', isEnabled: true });
|
||
setModalVisible(true);
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
try {
|
||
const values = await targetForm.validateFields();
|
||
if (editingTarget) {
|
||
await updateKpiTarget(editingTarget.id, values);
|
||
message.success('更新成功');
|
||
} else {
|
||
await createKpiTarget({
|
||
pageCode: selectedPage,
|
||
kpiCode: values.kpiCode,
|
||
...values,
|
||
});
|
||
message.success('新增成功');
|
||
}
|
||
setModalVisible(false);
|
||
fetchTargets();
|
||
} catch (error) {
|
||
console.error('保存失败:', error);
|
||
message.error('保存失败');
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id) => {
|
||
try {
|
||
await deleteKpiTarget(id);
|
||
message.success('删除成功');
|
||
fetchTargets();
|
||
} catch {
|
||
message.error('删除失败');
|
||
}
|
||
};
|
||
|
||
const columns = [
|
||
{ title: '指标名称', dataIndex: 'kpiName', width: 140 },
|
||
{ title: '单位', dataIndex: 'unit', width: 80 },
|
||
{ title: '方向', dataIndex: 'direction', width: 100, render: (v) => (
|
||
<Tag color={v === 'lower' ? 'blue' : 'green'}>{v === 'lower' ? '越低越好' : '越高越好'}</Tag>
|
||
)},
|
||
{ title: '优秀线', dataIndex: 'targetGood', width: 100, render: (v) => <span style={{ color: 'var(--sycm-color-success)', fontWeight: 600 }}>{v}</span> },
|
||
{ title: '达标线', dataIndex: 'targetPass', width: 100, render: (v) => <span style={{ color: 'var(--sycm-color-primary)', fontWeight: 600 }}>{v}</span> },
|
||
{ title: '预警线', dataIndex: 'targetWarn', width: 100, render: (v) => <span style={{ color: 'var(--sycm-color-danger)', fontWeight: 600 }}>{v}</span> },
|
||
{ title: '状态', dataIndex: 'isEnabled', width: 80, render: (v) => <Tag color={v ? 'success' : 'default'}>{v ? '启用' : '禁用'}</Tag> },
|
||
{
|
||
title: '操作',
|
||
width: 120,
|
||
render: (_, record) => (
|
||
<Space>
|
||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(record.id)}>
|
||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
)
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="settings-tab-page">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||
<Select
|
||
value={selectedPage}
|
||
onChange={setSelectedPage}
|
||
style={{ width: 200 }}
|
||
size="large"
|
||
>
|
||
{Object.entries(pageNames).map(([code, name]) => (
|
||
<Option key={code} value={code}>{name}</Option>
|
||
))}
|
||
</Select>
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd} style={{ borderRadius: 8 }}>
|
||
新增指标
|
||
</Button>
|
||
</div>
|
||
|
||
<Table
|
||
dataSource={targets}
|
||
columns={columns}
|
||
rowKey="id"
|
||
loading={loading}
|
||
pagination={false}
|
||
size="middle"
|
||
/>
|
||
|
||
<div style={{ marginTop: 24, padding: 16, background: 'var(--sycm-bg-subtle)', borderRadius: 'var(--sycm-radius-lg)' }}>
|
||
<Text type="secondary">
|
||
<strong>说明:</strong>
|
||
<ul style={{ marginTop: 8, paddingLeft: 20 }}>
|
||
<li>优秀线:达到此值显示绿色徽章</li>
|
||
<li>达标线:达到此值显示正常状态</li>
|
||
<li>预警线:超过此值显示红色预警</li>
|
||
<li>方向:指标是越低越好还是越高越好</li>
|
||
</ul>
|
||
</Text>
|
||
</div>
|
||
|
||
<Modal
|
||
title={editingTarget ? '编辑指标目标值' : '新增指标'}
|
||
open={modalVisible}
|
||
onCancel={() => setModalVisible(false)}
|
||
onOk={handleSave}
|
||
width={500}
|
||
>
|
||
<Form form={targetForm} layout="vertical" style={{ marginTop: 24 }}>
|
||
{!editingTarget && (
|
||
<Form.Item label="指标编码" name="kpiCode" rules={[{ required: true, message: '请输入指标编码' }]}>
|
||
<Input placeholder="如 refundRate" />
|
||
</Form.Item>
|
||
)}
|
||
<Form.Item label="指标名称" name="kpiName" rules={[{ required: true, message: '请输入指标名称' }]}>
|
||
<Input placeholder="如 退款率" />
|
||
</Form.Item>
|
||
<Row gutter={16}>
|
||
<Col span={12}>
|
||
<Form.Item label="单位" name="unit">
|
||
<Select>
|
||
<Option value="%">%</Option>
|
||
<Option value="h">h (小时)</Option>
|
||
<Option value="元">元</Option>
|
||
<Option value="次/月">次/月</Option>
|
||
<Option value="个">个</Option>
|
||
<Option value="">无</Option>
|
||
</Select>
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item label="目标方向" name="direction" rules={[{ required: true }]}>
|
||
<Select>
|
||
<Option value="lower">越低越好</Option>
|
||
<Option value="higher">越高越好</Option>
|
||
</Select>
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
<Row gutter={16}>
|
||
<Col span={8}>
|
||
<Form.Item label="优秀线" name="targetGood" rules={[{ required: true, message: '请输入优秀线' }]}>
|
||
<InputNumber style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={8}>
|
||
<Form.Item label="达标线" name="targetPass" rules={[{ required: true, message: '请输入达标线' }]}>
|
||
<InputNumber style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={8}>
|
||
<Form.Item label="预警线" name="targetWarn" rules={[{ required: true, message: '请输入预警线' }]}>
|
||
<InputNumber style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
<Form.Item label="备注" name="remark">
|
||
<Input.TextArea rows={2} />
|
||
</Form.Item>
|
||
<Form.Item label="是否启用" name="isEnabled" valuePropName="checked">
|
||
<Switch />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const items = [
|
||
{
|
||
key: 'site',
|
||
label: <span className="settings-tab-label"><GlobalOutlined /> 网站设置</span>,
|
||
content: <SiteSettings />
|
||
},
|
||
{
|
||
key: 'sidebar',
|
||
label: <span className="settings-tab-label"><AppstoreOutlined /> 侧边栏设置</span>,
|
||
content: <SidebarSettings />
|
||
},
|
||
{
|
||
key: 'auth',
|
||
label: <span className="settings-tab-label"><SafetyCertificateOutlined /> 登录配置</span>,
|
||
content: <AuthSettings />
|
||
},
|
||
{
|
||
key: 'databases',
|
||
label: <span className="settings-tab-label"><DatabaseOutlined /> 数据库配置</span>,
|
||
content: <DatabaseSettings />
|
||
},
|
||
{
|
||
key: 'redis',
|
||
label: <span className="settings-tab-label"><CloudServerOutlined /> Redis 缓存</span>,
|
||
content: <RedisSettings />
|
||
},
|
||
{
|
||
key: 'workOrderSystem',
|
||
label: <span className="settings-tab-label"><ApiOutlined /> 工单系统</span>,
|
||
content: <WorkOrderSystemSettings />
|
||
},
|
||
{
|
||
key: 'apiKeys',
|
||
label: <span className="settings-tab-label"><KeyOutlined /> 自动上架接入</span>,
|
||
content: <ApiKeySettings />
|
||
},
|
||
{
|
||
key: 'selectionFissionAi',
|
||
label: <span className="settings-tab-label"><RobotOutlined /> AI 分析模型</span>,
|
||
content: <SelectionFissionAiSettings />
|
||
},
|
||
{
|
||
key: 'productCodeImport',
|
||
label: <span className="settings-tab-label"><CloudUploadOutlined /> 测款导入</span>,
|
||
content: <ProductCodeImportSettings />
|
||
},
|
||
{
|
||
key: 'kpiTargets',
|
||
label: <span className="settings-tab-label"><AimOutlined /> 目标值配置</span>,
|
||
content: <KpiTargetsSettings />
|
||
},
|
||
{
|
||
key: 'productTags',
|
||
label: <span className="settings-tab-label"><TagsOutlined /> 商品标签配置</span>,
|
||
content: <ProductTagSettings />
|
||
},
|
||
{
|
||
key: 'departmentTags',
|
||
label: <span className="settings-tab-label"><TagsOutlined /> 部门商品标签配置</span>,
|
||
content: <DepartmentTagSettings />
|
||
},
|
||
{
|
||
key: 'departmentOwners',
|
||
label: <span className="settings-tab-label"><TeamOutlined /> 部门SKU负责人配置</span>,
|
||
content: <DepartmentOwnerSettings />
|
||
},
|
||
{
|
||
key: 'backfill',
|
||
label: <span className="settings-tab-label"><FieldTimeOutlined /> {'\u6570\u636e\u56de\u6eaf'}</span>,
|
||
content: <BackfillSettings />
|
||
},
|
||
{
|
||
key: 'user',
|
||
label: <span className="settings-tab-label"><UserOutlined /> 用户管理</span>,
|
||
content: <UserManagement />
|
||
},
|
||
{
|
||
key: 'dept',
|
||
label: <span className="settings-tab-label"><TeamOutlined /> 部门管理</span>,
|
||
content: <DepartmentManagement />
|
||
},
|
||
{
|
||
key: 'permission',
|
||
label: <span className="settings-tab-label"><SafetyCertificateOutlined /> 权限管理</span>,
|
||
content: <PermissionManagement />
|
||
},
|
||
{
|
||
key: 's3',
|
||
label: <span className="settings-tab-label"><CloudServerOutlined /> 对象存储</span>,
|
||
content: <S3Settings />
|
||
},
|
||
];
|
||
|
||
return (
|
||
<SycmPage className="settings-page">
|
||
<Tabs
|
||
className="settings-page__tabs"
|
||
activeKey={activeTab}
|
||
onChange={handleTabChange}
|
||
items={items.map(item => ({
|
||
key: item.key,
|
||
label: item.label,
|
||
children: item.content
|
||
}))}
|
||
type="card"
|
||
/>
|
||
</SycmPage>
|
||
);
|
||
};
|
||
|
||
export default Settings;
|