yanhui 445347e664 feat: 启用运营分析模块,新增模块化库存监控与 MKH-BOM 资料
本次将此前被列入退役名单的运营分析相关模块恢复为可用,并新增「模块化库存监控」
一级菜单及其两个子页面。

主要变更:
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 排除。
2026-09-15 16:53:12 +08:00

1863 lines
71 KiB
JavaScript
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 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;