refactor: remove store and operations feature suites

This commit is contained in:
lunanting 2026-09-12 17:50:26 +08:00
parent 1d87c3f94c
commit 36948c98ca
68 changed files with 123 additions and 19185 deletions

View File

@ -2,9 +2,9 @@
公开的拼多多业务精简版。欢迎所有人克隆、修改、分发代码并提交 Pull Request,代码按 MIT 许可证开放。
保留商品监控、仪表盘、激励看板、营销活动、推广、商品分析、店铺体检、客服质检、上架测算、AI 买家秀和客户端管理等现有模块。部分菜单仍沿用原项目的占位页面。
保留商品监控、仪表盘、激励看板、营销活动、推广、商品分析、工单以及在线客户端与执行任务等现有模块。部分菜单仍沿用原项目的占位页面。
已删除市场数据整组页面、Claw 对话、skills 管理、平台切换与其他平台演示页,以及对应路由、页面资源、专属配置接口和权限项。首页为仪表盘。保留业务仍使用的模型配置、商品数据查询和自动上架公共服务继续可用;Claw 补全与图搜备用通道已经移除,缺失资料需使用业务接口或手工补充。
已删除市场数据、店铺管理和运营工具整组页面、Claw 对话、skills 管理、平台切换与其他平台演示页,以及对应路由、页面资源、专属配置接口和权限项。首页为仪表盘。保留业务仍使用的模型配置、商品数据查询、桌面会话和自动上架公共服务继续可用;Claw 补全与图搜备用通道已经移除,缺失资料需使用业务接口或手工补充。
## 本地启动
@ -27,7 +27,7 @@ npm --prefix frontend run check
前端构建输出位于 `frontend/dist`。部署时需为单页应用配置路由回退,并代理 `/api` 与 `/cubejs-api` 到后端。
启动时会迁移旧侧栏配置,并删除已退役页面的权限和专属设置;业务数据及历史聊天记录不作数据库删表操作。迁移仅作用于实际配置给此实例的系统数据库。
启动时会迁移旧侧栏配置,并删除已退役页面的权限、专属设置和专用数据表。上架测算旧偏好表中的商品分析阈值会先迁移到商品分析专用表;其他保留模块的数据不受影响。迁移仅作用于实际配置给此实例的系统数据库。
## 参与修改

View File

@ -1,14 +1,14 @@
const { isRetiredSystemConfigKey, pruneRetiredSidebarConfig } = require('../services/retiredFeatures');
const {
isRetiredDatabaseConfigName,
isRetiredSystemConfigKey,
pruneRetiredSidebarConfig,
} = require('../services/retiredFeatures');
// 管理员 API
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const dayjs = require('dayjs');
const fs = require('fs');
const multer = require('multer');
const os = require('os');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const { requireRole } = require('../middleware/auth');
const database = require('../config/database');
@ -25,17 +25,7 @@ const {
normalizeStorageClass,
testObjectStorageConnection,
uploadBase64ImageToDefaultS3,
uploadFileToDefaultS3,
} = require('../services/objectStorage');
const {
compareVersions,
getLatestDesktopRelease,
listDesktopReleases,
normalizeArchitecture,
normalizePlatform,
publishDesktopRelease,
validateVersion,
} = require('../services/desktopReleaseService');
const { listOnlineDesktopUsers } = require('../services/desktopPresenceService');
const {
getSystemDbConfig,
@ -129,44 +119,6 @@ const productCodeImportUpload = multer({
},
});
const desktopReleaseTempDir = path.join(os.tmpdir(), 'maixiang-desktop-releases');
fs.mkdirSync(desktopReleaseTempDir, { recursive: true });
const desktopReleaseUpload = multer({
dest: desktopReleaseTempDir,
limits: {
fileSize: 300 * 1024 * 1024,
files: 1,
},
fileFilter: (_req, file, callback) => {
if (/\.(exe|dmg)$/i.test(String(file.originalname || ''))) {
callback(null, true);
return;
}
callback(new Error('仅支持上传 Windows(.exe)或 macOS(.dmg)安装包'));
},
});
const uploadDesktopReleaseInstaller = (req, res, next) => {
desktopReleaseUpload.single('installer')(req, res, (error) => {
if (!error) return next();
if (error.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ success: false, error: '安装包不能超过 300 MB' });
}
return res.status(400).json({ success: false, error: error.message || '安装包上传失败' });
});
};
async function sha256File(filePath) {
const hash = crypto.createHash('sha256');
await new Promise((resolve, reject) => {
const stream = fs.createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('error', reject);
stream.on('end', resolve);
});
return hash.digest('hex');
}
const runBackfill = async (job) => {
try {
const totalStepsPerDay = 21;
@ -208,93 +160,6 @@ router.get('/desktop-presence', async (_req, res) => {
}
});
router.get('/desktop-releases', async (req, res) => {
try {
const releases = await listDesktopReleases(req.query.limit);
res.json({
success: true,
data: releases.map((release) => ({
...release,
downloadUrl: `/api/desktop-client/releases/${release.id}/download`,
})),
});
} catch (error) {
console.error('获取桌面客户端发布记录失败:', error);
res.status(500).json({ success: false, error: '获取桌面客户端发布记录失败' });
}
});
router.post('/desktop-releases', uploadDesktopReleaseInstaller, async (req, res) => {
const tempFilePath = req.file?.path;
try {
if (!req.file) {
return res.status(400).json({ success: false, error: '请选择需要发布的桌面客户端安装包' });
}
const version = validateVersion(req.body?.version);
const originalName = String(req.file.originalname || '');
const platform = normalizePlatform(req.body?.platform, originalName);
const architecture = normalizeArchitecture(req.body?.architecture, originalName, platform);
const expectedExtension = platform === 'darwin' ? '.dmg' : '.exe';
if (!originalName.toLowerCase().endsWith(expectedExtension)) {
return res.status(400).json({
success: false,
error: platform === 'darwin' ? 'macOS 安装包必须为 .dmg 格式' : 'Windows 安装包必须为 .exe 格式',
});
}
const latest = await getLatestDesktopRelease(platform, architecture);
if (latest && compareVersions(version, latest.version) < 0) {
return res.status(409).json({
success: false,
error: `发布版本 v${version} 低于当前平台最新版本 v${latest.version}`,
});
}
const fileName = platform === 'darwin'
? `pdd-auto-sale-${version}-${architecture}.dmg`
: `pdd-auto-sale-${version}-setup.exe`;
const objectKey = `desktop-client/${platform}/${version}/${Date.now()}-${fileName}`;
const mimeType = platform === 'darwin'
? 'application/x-apple-diskimage'
: 'application/vnd.microsoft.portable-executable';
const [sha256, uploadResult] = await Promise.all([
sha256File(tempFilePath),
uploadFileToDefaultS3(tempFilePath, {
key: objectKey,
mimeType,
}),
]);
const release = await publishDesktopRelease({
version,
platform,
architecture,
releaseNotes: String(req.body?.releaseNotes || '').trim(),
objectKey: uploadResult.key,
fileName,
fileSize: req.file.size,
sha256,
publishedByUserId: Number(req.user?.sub || req.user?.id || 0) || null,
publishedByName: req.user?.name || req.user?.username || req.user?.email || '',
});
res.status(201).json({
success: true,
message: `${platform === 'darwin' ? 'macOS' : 'Windows'} 客户端 v${version} 已发布`,
data: {
...release,
downloadUrl: `/api/desktop-client/releases/${release.id}/download`,
},
});
} catch (error) {
console.error('发布桌面客户端失败:', error);
const status = /版本号格式/.test(error.message) ? 400 : 500;
res.status(status).json({ success: false, error: error.message || '发布桌面客户端失败' });
} finally {
if (tempFilePath) {
await fs.promises.unlink(tempFilePath).catch(() => {});
}
}
});
router.get('/cache-performance', async (_req, res) => {
const dataPayloadStats = getStatsSnapshot();
res.json({
@ -1622,12 +1487,10 @@ router.post('/work-order-config/test', async (req, res) => {
const DEFAULT_DATABASE_CONFIG_NAME = 'cloud_database';
const CLOUD_DATABASE_PUBLIC_CONFIG_NAME = 'cloud_database_public';
const PLANNING_DATABASE_CONFIG_NAME = 'planning_database';
const AI_BUYER_SHOW_DATABASE_CONFIG_NAME = 'ai_buyer_show_database';
const PROTECTED_DATABASE_CONFIG_NAMES = new Set([
DEFAULT_DATABASE_CONFIG_NAME,
CLOUD_DATABASE_PUBLIC_CONFIG_NAME,
PLANNING_DATABASE_CONFIG_NAME,
AI_BUYER_SHOW_DATABASE_CONFIG_NAME,
]);
function normalizeDatabaseConfigName(rawName) {
@ -1640,6 +1503,11 @@ function normalizeDatabaseConfigName(rawName) {
error.statusCode = 400;
throw error;
}
if (isRetiredDatabaseConfigName(configName)) {
const error = new Error('该功能已下线,不能创建对应数据库配置');
error.statusCode = 410;
throw error;
}
return configName;
}

View File

@ -1,397 +0,0 @@
const express = require('express');
const multer = require('multer');
const axios = require('axios');
const {
buildDefaultPrompt,
createAiBuyerShowJob,
generateAiBuyerShowReviews,
getAiBuyerShowHistoryDetail,
getAiBuyerShowJob,
getQueueStats,
getWorkerRuntimeStatus,
listAiBuyerShowHistory,
listAiBuyerShowJobs,
testAiBuyerShowDatabase,
} = require('../services/aiBuyerShowService');
const { buildBuyerShowPrompt } = require('../services/aiBuyerShowReviewService');
const {
createSeedPack,
deleteSeedPack,
getSeedPackDownload,
listSeedPacks,
MAX_SEED_PACK_FILE_BYTES,
} = require('../services/aiBuyerShowSeedPackService');
const router = express.Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 20 * 1024 * 1024,
files: 10,
},
fileFilter: (_req, file, callback) => {
if (String(file.mimetype || '').startsWith('image/')) {
callback(null, true);
return;
}
callback(new Error('仅支持上传图片文件'));
},
});
const uploadFields = upload.fields([
{ name: 'productImages', maxCount: 5 },
{ name: 'sceneReferenceImages', maxCount: 4 },
]);
function runUpload(req, res, next) {
uploadFields(req, res, (error) => {
if (!error) {
next();
return;
}
if (error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') {
res.status(413).json({ success: false, error: '图片不能超过 20MB' });
return;
}
res.status(400).json({ success: false, error: error.message || '图片上传失败' });
});
}
const seedPackUpload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: MAX_SEED_PACK_FILE_BYTES,
files: 1,
},
fileFilter: (_req, file, callback) => {
const isZip = String(file.mimetype || '') === 'application/zip'
|| String(file.mimetype || '') === 'application/x-zip-compressed'
|| /\.zip$/i.test(String(file.originalname || ''));
if (isZip) {
callback(null, true);
return;
}
callback(new Error('种菜资源包仅支持上传 ZIP 文件'));
},
}).single('file');
function runSeedPackUpload(req, res, next) {
seedPackUpload(req, res, (error) => {
if (!error) {
next();
return;
}
if (error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') {
res.status(413).json({ success: false, error: '种菜资源包 ZIP 不能超过 200MB' });
return;
}
res.status(400).json({ success: false, error: error.message || '种菜资源包上传失败' });
});
}
function normalizeList(value) {
const raw = Array.isArray(value) ? value : String(value || '').split(/[\n,,]+/);
return Array.from(new Set(raw.map((item) => String(item || '').trim()).filter(Boolean)));
}
function isAllowedImageUrl(value) {
try {
const parsed = new URL(String(value || ''));
return ['http:', 'https:'].includes(parsed.protocol) && (
parsed.hostname.endsWith('aliyuncs.com')
|| parsed.hostname.endsWith('volces.com')
|| parsed.hostname.endsWith('jiansuntuzhi.cn')
|| parsed.hostname.endsWith('yydd.ai')
|| parsed.hostname.endsWith('jiansun.vip')
);
} catch (_) {
return false;
}
}
router.get('/runtime', async (_req, res) => {
try {
const databaseStatus = await testAiBuyerShowDatabase();
const queue = databaseStatus.ok ? await getQueueStats() : null;
const worker = databaseStatus.ok ? await getWorkerRuntimeStatus(queue || {}) : {
enabled: false,
hasApiKey: false,
status: 'database_unavailable',
source: 'job-table-observation',
lastActivityAt: null,
lastError: '',
};
res.json({
success: true,
data: {
database: databaseStatus,
queue,
worker,
},
});
} catch (error) {
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || 'AI买家秀运行状态查询失败',
data: {
database: {
ok: false,
missingTables: [],
},
},
});
}
});
router.post('/prompt-preview', express.json({ limit: '1mb' }), async (req, res) => {
try {
const body = req.body || {};
res.json({
success: true,
data: {
prompt: body.review
? buildBuyerShowPrompt({
review: body.review,
analysis: body.analysis || {},
style: body.buyerShowStyle,
})
: buildDefaultPrompt(body),
},
});
} catch (error) {
res.status(500).json({ success: false, error: error.message || '提示词预览失败' });
}
});
router.post('/reviews', runUpload, async (req, res) => {
try {
const body = req.body || {};
const result = await generateAiBuyerShowReviews({
productName: body.productName || body.spu,
spu: body.spu || body.productName,
scene: body.scene,
userType: body.userType,
sellingPoints: body.sellingPoints,
useEmoji: body.useEmoji,
reviewTotal: body.reviewTotal,
productImageFiles: req.files?.productImages || [],
}, req.user || {});
res.json({ success: true, data: result });
} catch (error) {
console.error('[ai-buyer-show] generate reviews failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || '攻心评价生成失败',
});
}
});
router.post('/jobs', runUpload, async (req, res) => {
try {
const body = req.body || {};
const result = await createAiBuyerShowJob({
productName: body.productName || body.spu,
spu: body.spu || body.productName,
scene: body.scene,
userType: body.userType,
sellingPoints: body.sellingPoints,
reviewText: body.reviewText,
generationId: body.generationId,
reviewRecordId: body.reviewRecordId,
prompt: body.prompt,
negativePrompt: body.negativePrompt,
buyerShowStyle: body.buyerShowStyle,
imageCount: 5,
aspectRatio: body.aspectRatio,
imageSize: body.imageSize,
productImageUrls: normalizeList(body.productImageUrls),
sceneReferenceImageUrls: normalizeList(body.sceneReferenceImageUrls),
productImageFiles: req.files?.productImages || [],
sceneReferenceImageFiles: req.files?.sceneReferenceImages || [],
}, req.user || {});
res.json({ success: true, data: result });
} catch (error) {
console.error('[ai-buyer-show] create job failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || 'AI买家秀任务创建失败',
});
}
});
router.get('/jobs', async (req, res) => {
try {
const result = await listAiBuyerShowJobs({
page: req.query.page,
pageSize: req.query.pageSize,
spu: req.query.spu,
includeTotal: req.query.includeTotal !== '0',
});
res.json({ success: true, data: result });
} catch (error) {
console.error('[ai-buyer-show] list jobs failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || 'AI买家秀任务列表查询失败',
});
}
});
router.get('/history', async (req, res) => {
try {
const data = await listAiBuyerShowHistory({
page: req.query.page,
pageSize: req.query.pageSize,
keyword: req.query.keyword,
});
res.set('Cache-Control', 'private, no-store');
res.json({ success: true, data });
} catch (error) {
console.error('[ai-buyer-show] list history failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || '历史生成记录加载失败',
});
}
});
router.get('/history/:id', async (req, res) => {
try {
const data = await getAiBuyerShowHistoryDetail(req.params.id);
if (!data) {
res.status(404).json({ success: false, error: '历史生成记录不存在' });
return;
}
res.set('Cache-Control', 'private, no-store');
res.json({ success: true, data });
} catch (error) {
console.error('[ai-buyer-show] get history failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || '历史生成详情加载失败',
});
}
});
router.get('/image-proxy', async (req, res) => {
const imageUrl = String(req.query.url || '').trim();
if (!isAllowedImageUrl(imageUrl)) {
res.status(400).json({ success: false, error: '不支持的图片地址' });
return;
}
try {
const response = await axios.get(imageUrl, {
responseType: 'stream',
timeout: 120000,
maxContentLength: 30 * 1024 * 1024,
headers: { 'User-Agent': 'Mozilla/5.0' },
});
res.set('Content-Type', response.headers['content-type'] || 'application/octet-stream');
res.set('Cache-Control', 'private, max-age=300');
response.data.pipe(res);
} catch (error) {
res.status(error.response?.status || 502).json({
success: false,
error: error.message || '图片下载失败',
});
}
});
router.get('/jobs/:jobId', async (req, res) => {
try {
const job = await getAiBuyerShowJob(req.params.jobId);
if (!job) {
res.status(404).json({ success: false, error: '任务不存在' });
return;
}
res.json({ success: true, data: job });
} catch (error) {
console.error('[ai-buyer-show] get job failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || 'AI买家秀任务查询失败',
});
}
});
router.get('/queue-stats', async (req, res) => {
try {
const data = await getQueueStats(req.query.jobId || '');
res.json({ success: true, data });
} catch (error) {
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || 'AI买家秀队列统计查询失败',
});
}
});
router.post('/seed-packs', runSeedPackUpload, async (req, res) => {
try {
const body = req.body || {};
const data = await createSeedPack({
shopName: body.shopName,
productId: body.productId,
skuName: body.skuName,
fileName: body.fileName || req.file?.originalname,
reviewCount: body.reviewCount,
file: req.file,
}, req.user || {});
res.json({ success: true, data });
} catch (error) {
console.error('[ai-buyer-show] create seed pack failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || '种菜资源包上传失败',
});
}
});
router.get('/seed-packs', async (req, res) => {
try {
const data = await listSeedPacks({
page: req.query.page,
pageSize: req.query.pageSize,
keyword: req.query.keyword,
});
res.set('Cache-Control', 'private, no-store');
res.json({ success: true, data });
} catch (error) {
console.error('[ai-buyer-show] list seed packs failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || '种菜资源包列表查询失败',
});
}
});
router.get('/seed-packs/:id/download', async (req, res) => {
try {
const data = await getSeedPackDownload(req.params.id);
res.json({ success: true, data });
} catch (error) {
console.error('[ai-buyer-show] get seed pack download failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || '种菜资源包下载信息获取失败',
});
}
});
router.delete('/seed-packs/:id', async (req, res) => {
try {
const data = await deleteSeedPack(req.params.id);
res.json({ success: true, data });
} catch (error) {
console.error('[ai-buyer-show] delete seed pack failed:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || '种菜资源包删除失败',
});
}
});
module.exports = router;

View File

@ -1,93 +0,0 @@
'use strict';
const express = require('express');
const {
getGoods,
getChatDataMeta,
getCustomers,
getMessages,
getProductProfile,
startAnalysisJob,
getAnalysisJob,
runAnalysis,
listHistory,
getHistoryDetail,
} = require('../services/csQualityService');
const router = express.Router();
function sendError(res, error, fallback = '客服质检请求失败') {
const status = Number(error?.statusCode) || (error?.message?.includes('不能超过30天') ? 400 : 500);
console.error('[CsQuality]', {
message: error?.message || fallback,
code: error?.code || null,
status,
stack: error?.stack || null,
});
res.status(status).json({ success: false, error: error.message || fallback });
}
router.get('/goods', async (req, res) => {
try {
res.json({ success: true, data: await getGoods(req.query) });
} catch (error) { sendError(res, error, '加载商品聊天列表失败'); }
});
router.get('/data-meta', async (_req, res) => {
try {
res.json({ success: true, data: await getChatDataMeta() });
} catch (error) { sendError(res, error, '加载聊天数据日期范围失败'); }
});
router.get('/goods/:goodsId/profile', async (req, res) => {
try {
res.json({ success: true, data: await getProductProfile({ ...req.query, goodsId: req.params.goodsId }) });
} catch (error) { sendError(res, error, '加载商品表现失败'); }
});
router.get('/customers', async (req, res) => {
try {
res.json({ success: true, data: await getCustomers(req.query) });
} catch (error) { sendError(res, error, '加载客户列表失败'); }
});
router.get('/messages', async (req, res) => {
try {
res.json({ success: true, data: await getMessages(req.query) });
} catch (error) { sendError(res, error, '加载聊天内容失败'); }
});
router.post('/analysis', async (req, res) => {
try {
res.json({ success: true, data: await runAnalysis(req.body || {}, req.user || {}) });
} catch (error) { sendError(res, error, 'AI质检失败'); }
});
router.post('/analysis-jobs', async (req, res) => {
try {
const data = await startAnalysisJob(req.body || {}, req.user || {});
res.status(202).json({ success: true, data });
} catch (error) { sendError(res, error, '创建AI质检任务失败'); }
});
router.get('/analysis-jobs/:analysisId', async (req, res) => {
try {
res.json({ success: true, data: await getAnalysisJob(req.params.analysisId) });
} catch (error) { sendError(res, error, '查询AI质检进度失败'); }
});
router.get('/analysis-history', async (req, res) => {
try {
res.json({ success: true, data: await listHistory(req.query) });
} catch (error) { sendError(res, error, '加载AI质检历史失败'); }
});
router.get('/analysis-history/:id', async (req, res) => {
try {
const data = await getHistoryDetail(req.params.id);
if (!data) return res.status(404).json({ success: false, error: '历史质检记录不存在' });
return res.json({ success: true, data });
} catch (error) { return sendError(res, error, '加载AI质检历史详情失败'); }
});
module.exports = router;

View File

@ -1,18 +1,6 @@
const express = require('express');
const crypto = require('crypto');
const authMiddleware = require('../middleware/auth');
const {
buildPublicUrl,
getDefaultS3Config,
getObjectFromDefaultS3,
} = require('../services/objectStorage');
const {
compareVersions,
getDesktopReleaseById,
getLatestDesktopRelease,
mapRelease,
normalizeVersion,
} = require('../services/desktopReleaseService');
const {
DesktopSessionError,
createDesktopSession,
@ -24,23 +12,6 @@ const {
const router = express.Router();
function publicRelease(release) {
const mapped = mapRelease(release);
if (!mapped) return null;
return {
id: mapped.id,
version: mapped.version,
releaseNotes: mapped.releaseNotes,
platform: mapped.platform,
architecture: mapped.architecture,
fileName: mapped.fileName,
fileSize: mapped.fileSize,
sha256: mapped.sha256,
publishedAt: mapped.publishedAt,
downloadUrl: `/api/desktop-client/releases/${mapped.id}/download`,
};
}
function requestUserId(req) {
return req.user?.sub ?? req.user?.id;
}
@ -164,68 +135,4 @@ router.post('/session/revoke', authMiddleware, async (req, res) => {
}
});
router.get('/latest', async (req, res) => {
try {
const platform = req.query.platform || 'win32';
const architecture = req.query.architecture || '';
const release = await getLatestDesktopRelease(platform, architecture);
const currentVersion = normalizeVersion(req.query.currentVersion);
const data = publicRelease(release);
res.setHeader('Cache-Control', 'no-store, max-age=0');
res.json({
success: true,
data: data ? {
...data,
currentVersion: currentVersion || null,
updateAvailable: currentVersion ? compareVersions(data.version, currentVersion) > 0 : null,
} : null,
});
} catch (error) {
console.error('获取桌面客户端最新版本失败:', error);
res.status(500).json({ success: false, error: '获取桌面客户端最新版本失败' });
}
});
router.get('/releases/:id/download', async (req, res) => {
try {
const release = await getDesktopReleaseById(Number(req.params.id));
if (!release) {
return res.status(404).json({ success: false, error: '安装包不存在或已下线' });
}
const storageConfig = await getDefaultS3Config();
if (storageConfig?.customDomain) {
return res.redirect(302, buildPublicUrl(storageConfig, release.objectKey));
}
const object = await getObjectFromDefaultS3(release.objectKey, {
range: req.headers.range,
});
const encodedName = encodeURIComponent(release.fileName).replace(/%20/g, '+');
res.status(object.ContentRange ? 206 : 200);
const fallbackContentType = release.platform === 'darwin'
? 'application/x-apple-diskimage'
: 'application/vnd.microsoft.portable-executable';
res.setHeader('Content-Type', object.ContentType || fallbackContentType);
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedName}`);
res.setHeader('Accept-Ranges', object.AcceptRanges || 'bytes');
res.setHeader('Cache-Control', 'private, max-age=3600');
if (object.ContentLength != null) res.setHeader('Content-Length', String(object.ContentLength));
if (object.ContentRange) res.setHeader('Content-Range', object.ContentRange);
if (object.ETag) res.setHeader('ETag', object.ETag);
object.Body.on('error', (error) => {
console.error('桌面客户端安装包下载流中断:', error);
if (!res.headersSent) res.status(502).end();
else res.destroy(error);
});
object.Body.pipe(res);
} catch (error) {
console.error('下载桌面客户端安装包失败:', error);
if (!res.headersSent) {
res.status(502).json({ success: false, error: '安装包下载失败,请稍后重试' });
}
}
});
module.exports = router;

View File

@ -1,833 +0,0 @@
const express = require('express');
const XLSX = require('xlsx');
const database = require('../config/database');
const {
getCompanySkusBySpu,
fetchFreightProfile,
getSelectionFissionProfitPricingContext,
getSelectionFissionProductCodeMeta,
saveSelectionFissionProfitPricingTable,
} = require('../services/planningSelectionFissionService');
const router = express.Router();
const HISTORY_TABLE = 'pricing_calculator_history';
// 运费/售后画像查询设定超时,避免整页等待。
const FREIGHT_PROFILE_TIMEOUT_MS = Math.max(
Number.parseInt(process.env.PRICING_CALC_FREIGHT_TIMEOUT_MS || '30000', 10) || 30000,
5000
);
const MAX_HISTORY_ROWS = 500;
const MAX_PAYLOAD_BYTES = 1024 * 1024;
const CALCULATOR_TYPES = new Set(['company', 'alibaba1688']);
let historyTableReady = null;
async function ensureHistoryTable() {
if (!historyTableReady) {
historyTableReady = (async () => {
// 先查表是否存在:生产/隧道账号通常只有 SELECT/INSERT/UPDATE/DELETE,
// MariaDB 对 CREATE TABLE IF NOT EXISTS 也会校验 CREATE 权限,存在时直接跳过。
const existing = await database.local.query(
`SELECT 1
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = ?
LIMIT 1`,
[HISTORY_TABLE]
);
if (existing.length > 0) {
return;
}
await database.local.query(
`CREATE TABLE IF NOT EXISTS ${HISTORY_TABLE} (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
user_id BIGINT NOT NULL COMMENT '测算人用户ID',
user_name VARCHAR(100) NULL COMMENT '测算人显示名',
spu_code VARCHAR(160) NOT NULL COMMENT '公司SPU',
brand VARCHAR(100) NULL COMMENT '品牌名称',
note VARCHAR(500) NULL COMMENT '备注',
row_count INT NOT NULL DEFAULT 0 COMMENT '测算行数',
payload_json LONGTEXT NOT NULL COMMENT '完整测算快照 JSON',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX idx_pricing_calc_history_user_created (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上架测算历史记录'`
);
})();
}
try {
await historyTableReady;
} catch (error) {
// 建表失败后允许下次请求重试
historyTableReady = null;
throw error;
}
}
function sendError(res, error, fallbackMessage) {
const status = error.status || error.statusCode || 500;
res.status(status).json({
success: false,
error: error.message || fallbackMessage,
});
}
function getRequestUser(req) {
const user = req.user || {};
return {
id: Number(user.sub || user.id || 0) || null,
name: user.name || user.username || '',
isAdmin: user.role === 'admin',
};
}
function requireUserId(req, res) {
const { id } = getRequestUser(req);
if (!id) {
res.status(401).json({ success: false, error: '当前登录状态无效,请重新登录' });
return null;
}
return id;
}
const normalizeText = (value, maxLength) => String(value ?? '').trim().slice(0, maxLength);
const normalizeCalculatorType = (value) => (
CALCULATOR_TYPES.has(value) ? value : 'company'
);
function sanitizeHistoryProduct(input) {
const product = input && typeof input === 'object' ? input : {};
const dimensions = (Array.isArray(product.skuDimensions) ? product.skuDimensions : [])
.slice(0, 4)
.map((dimension) => ({
name: normalizeText(dimension?.name, 120),
values: (Array.isArray(dimension?.values) ? dimension.values : [])
.slice(0, 500)
.map((value) => normalizeText(value, 500))
.filter(Boolean),
}))
.filter((dimension) => dimension.name);
return {
productId: normalizeText(product.productId, 80),
title: normalizeText(product.title, 500),
mainImage: normalizeText(product.mainImage, 2000),
sourceUrl: normalizeText(product.sourceUrl, 2000),
storeName: normalizeText(product.storeName, 300),
category: normalizeText(product.category, 500),
skuDimensions: dimensions,
};
}
function mapSkuRow(row) {
const qgyf = Number(row?.raw?.qgyf);
return {
skuCode: row.skuCode || '',
status: row.saleStatus || '',
stock: row.stock ?? null,
stockWarehouses: Array.isArray(row.stockWarehouses) ? row.stockWarehouses : [],
name: row.skuName || row.productName || '',
image: row.skuImageUrl || '',
size: row.size || '',
color: row.colorName || '',
cost: row.cost ?? null,
minimumPrice: row.minimumPrice ?? null,
salePrice: row?.raw?.sale_price ?? row?.raw?.salePrice ?? null,
// qgyf = 聚水潭接口返回的全国平均运费(按条码)
freight: Number.isFinite(qgyf) ? qgyf : null,
supplier: row.supplierName || '',
category: row.categoryName || '',
lengthCm: row.lengthCm ?? row?.raw?.length_cm ?? row?.raw?.length ?? null,
widthCm: row.widthCm ?? row?.raw?.width_cm ?? row?.raw?.width ?? null,
heightCm: row.heightCm ?? row?.raw?.height_cm ?? row?.raw?.height ?? null,
weight: row.weight ?? row?.raw?.weight ?? row?.raw?.gross_weight ?? null,
};
}
async function fetchFreightProfileBestEffort(spuCode, skuRows, user) {
const emptyProfile = {
nationalFreight: null,
zone1Freight: null,
zone2Freight: null,
zone3Freight: null,
zone4Freight: null,
aftersaleRatio: null,
minimumPricesBySku: {},
raw: null,
};
try {
const result = await Promise.race([
fetchFreightProfile({ spuCode, skuRows }, user),
new Promise((resolve) => {
setTimeout(() => resolve({ ...emptyProfile, warning: '运费/售后信息查询超时,可手动填写运费' }), FREIGHT_PROFILE_TIMEOUT_MS);
}),
]);
return result && typeof result === 'object' ? { ...emptyProfile, ...result } : emptyProfile;
} catch (error) {
return { ...emptyProfile, warning: error.message || '运费/售后信息查询失败' };
}
}
// 拉取 SPU 下全部条码(含销售、退市、停产)+ 运费/售后画像(尽力而为)
// 运费由聚水潭接口 qgyf 字段按条码直出,
// 默认跳过;如需售后占比/最低限价画像,可设 PRICING_CALC_FREIGHT_PROFILE=on 重新开启。
const FREIGHT_PROFILE_ENABLED = String(process.env.PRICING_CALC_FREIGHT_PROFILE || 'off').toLowerCase() === 'on';
function getSelectionSkuCodeSet(selectionContext) {
if (!selectionContext) return null;
const selectedSkuCodes = Array.isArray(selectionContext.skuSceneImages?.selectedSkuCodes)
? selectionContext.skuSceneImages.selectedSkuCodes
: [];
const fallbackSkuCodes = Array.isArray(selectionContext.rows)
? selectionContext.rows.map((row) => row.skuCode)
: [];
return new Set(
(selectedSkuCodes.length > 0 ? selectedSkuCodes : fallbackSkuCodes)
.map((skuCode) => normalizeText(skuCode, 160).toUpperCase())
.filter(Boolean)
);
}
function filterSkuRowsForSelectionContext(skuRows, selectionContext) {
const rows = Array.isArray(skuRows) ? skuRows : [];
const selectedSkuCodes = getSelectionSkuCodeSet(selectionContext);
if (!selectedSkuCodes) return rows;
return rows.filter((row) => selectedSkuCodes.has(
normalizeText(row?.skuCode || row?.sku_code || row?.barcode, 160).toUpperCase()
));
}
router.get('/product-code/meta', async (req, res) => {
const userId = requireUserId(req, res);
if (!userId) return;
try {
res.json({ success: true, data: await getSelectionFissionProductCodeMeta() });
} catch (error) {
sendError(res, error, '公司SKU编码配置加载失败');
}
});
router.get('/spu/:spuCode', async (req, res) => {
const spuCode = normalizeText(req.params.spuCode, 160).toUpperCase();
if (!spuCode) {
return res.status(400).json({ success: false, error: '请输入公司 SPU' });
}
try {
const planId = Number.parseInt(req.query.planId, 10) || 0;
const itemId = Number.parseInt(req.query.itemId, 10) || 0;
if (Boolean(planId) !== Boolean(itemId)) {
return res.status(400).json({ success: false, error: '选品定价上下文缺少方案ID或SPU任务ID' });
}
const skuResult = await getCompanySkusBySpu(spuCode, { includeInactive: true });
const allSkuRows = Array.from(skuResult.rows || []);
if (allSkuRows.length === 0) {
return res.status(404).json({
success: false,
error: `未查询到 SPU「${spuCode}」的条码,请确认 SPU 是否正确`,
});
}
let selectionContext = null;
if (planId && itemId) {
selectionContext = await getSelectionFissionProfitPricingContext(planId, itemId);
if (selectionContext.spuCode.toUpperCase() !== spuCode) {
return res.status(409).json({ success: false, error: `该选品任务绑定的是SPU ${selectionContext.spuCode}` });
}
}
const skuRows = filterSkuRowsForSelectionContext(allSkuRows, selectionContext);
if (selectionContext && skuRows.length === 0) {
return res.status(409).json({
success: false,
error: '选品任务已选 SKU 不在当前 SPU 条码列表中,请返回 SKU 节点重新确认',
});
}
const freightProfile = FREIGHT_PROFILE_ENABLED
? await fetchFreightProfileBestEffort(spuCode, skuRows, req.user)
: { nationalFreight: null, aftersaleRatio: null, minimumPricesBySku: {}, warning: null };
const warnings = [...(skuResult.warnings || [])];
if (freightProfile.warning) {
warnings.push(freightProfile.warning);
}
const savedRowsBySku = new Map((selectionContext?.rows || []).map((row) => [
String(row.skuCode || '').toUpperCase(),
row,
]));
res.json({
success: true,
data: {
spu: spuCode,
source: skuResult.source,
warnings,
freightProfile: {
nationalFreight: freightProfile.nationalFreight ?? null,
zone1Freight: freightProfile.zone1Freight ?? null,
zone2Freight: freightProfile.zone2Freight ?? null,
zone3Freight: freightProfile.zone3Freight ?? null,
zone4Freight: freightProfile.zone4Freight ?? null,
aftersaleRatio: freightProfile.aftersaleRatio ?? null,
minimumPricesBySku: freightProfile.minimumPricesBySku || {},
},
selectionContext: selectionContext ? {
planId: selectionContext.planId,
itemId: selectionContext.itemId,
taskCode: selectionContext.taskCode,
schemeCode: selectionContext.schemeCode,
listedItemId: selectionContext.listedItemId,
pricingTableSavedAt: selectionContext.pricingTableSavedAt,
pricingTableSavedBy: selectionContext.pricingTableSavedBy,
pricingTableVersion: selectionContext.pricingTableVersion,
workflowProfile: selectionContext.workflowProfile,
productCodeStatus: selectionContext.productCodeStatus,
productCodeStyleCode: selectionContext.productCodeStyleCode,
productCodeConfig: selectionContext.productCodeConfig,
productCodeErrorMessage: selectionContext.productCodeErrorMessage,
productCodeRequestedAt: selectionContext.productCodeRequestedAt,
productCodeCompletedAt: selectionContext.productCodeCompletedAt,
} : null,
rows: skuRows.map((skuRow) => {
const mapped = mapSkuRow(skuRow);
const saved = savedRowsBySku.get(String(mapped.skuCode || '').toUpperCase());
return {
...mapped,
savedPricing: saved ? {
price: saved.finalPrice,
groupPrice: saved.groupPrice,
installFee: saved.installationFee,
coupon: saved.discountAmount,
activityDiscount: saved.activityDiscount,
shipping: saved.freightNational,
commission: saved.platformCommissionRatio,
promoRate: saved.promotionRatio,
adFee: saved.marketingAdRatio,
seeding: saved.farmingRatio,
afterSale: saved.aftersaleRatio,
insurance: saved.insuranceRatio,
brandFee: saved.brandUsageRatio,
targetMargin: saved.operatingProfitRatio,
originalSkuName: saved.originalSkuName,
selectedSkuName: saved.selectedSkuName,
internalProductCode: saved.internalProductCode,
productCodeProductName: saved.productCodeProductName,
productCodeColorSpec: saved.productCodeColorSpec,
} : null,
};
}),
},
});
} catch (error) {
sendError(res, error, 'SPU 条码查询失败,请稍后重试');
}
});
// 保存一条测算历史(完整行快照,含输入与公式结果)
router.post('/history', async (req, res) => {
const userId = requireUserId(req, res);
if (!userId) return;
const body = req.body || {};
const spuCode = normalizeText(body.spuCode, 160).toUpperCase();
const calculatorType = normalizeCalculatorType(body.calculatorType);
const rows = Array.isArray(body.rows) ? body.rows : [];
if (!spuCode) {
return res.status(400).json({ success: false, error: '缺少 SPU 编码' });
}
if (rows.length === 0) {
return res.status(400).json({ success: false, error: '测算行为空,无法保存' });
}
if (rows.length > MAX_HISTORY_ROWS) {
return res.status(400).json({ success: false, error: `单条历史最多保存 ${MAX_HISTORY_ROWS} 行` });
}
const payload = {
version: 4,
calculatorType,
spuCode,
isBlackLabelShop: body.isBlackLabelShop === true,
brand: normalizeText(body.brand, 100),
note: normalizeText(body.note, 500),
savedAt: new Date().toISOString(),
summary: body.summary && typeof body.summary === 'object' ? body.summary : null,
freightProfile: body.freightProfile && typeof body.freightProfile === 'object' ? body.freightProfile : null,
product: calculatorType === 'alibaba1688' ? sanitizeHistoryProduct(body.product) : null,
rows,
};
const payloadJson = JSON.stringify(payload);
if (Buffer.byteLength(payloadJson, 'utf8') > MAX_PAYLOAD_BYTES) {
return res.status(400).json({ success: false, error: '测算快照过大,无法保存' });
}
try {
let selectionPricing = null;
const planId = Number.parseInt(body.selectionContext?.planId || body.planId, 10) || 0;
const itemId = Number.parseInt(body.selectionContext?.itemId || body.itemId, 10) || 0;
if (Boolean(planId) !== Boolean(itemId)) {
return res.status(400).json({ success: false, error: '选品定价上下文缺少方案ID或SPU任务ID' });
}
if (planId && itemId) {
// 测算页会展示实时接口中的全部条码;自动上架保存用户保留的任务 SKU 子集。
const pricingContext = await getSelectionFissionProfitPricingContext(planId, itemId);
const listingSkuCodes = new Set((pricingContext.rows || []).map((row) => (
normalizeText(row.skuCode, 160).toUpperCase()
)));
const listingRows = rows.filter((row) => listingSkuCodes.has(
normalizeText(row.skuCode || row.sku || row.barcode, 160).toUpperCase()
));
selectionPricing = await saveSelectionFissionProfitPricingTable(
planId,
itemId,
{
spuCode,
rows: listingRows,
note: payload.note,
productCodeConfig: body.productCodeConfig,
},
req.user,
{ pricingContext }
);
}
let insertId = null;
if (!selectionPricing) {
await ensureHistoryTable();
const inserted = await database.local.insert(HISTORY_TABLE, {
user_id: userId,
user_name: getRequestUser(req).name || null,
spu_code: spuCode,
brand: payload.brand || null,
note: payload.note || null,
row_count: rows.length,
payload_json: payloadJson,
});
insertId = inserted.insertId;
}
res.json({
success: true,
data: {
id: insertId,
spuCode,
rowCount: rows.length,
selectionPricing: selectionPricing ? {
planId: selectionPricing.planId,
itemId: selectionPricing.itemId,
taskCode: selectionPricing.taskCode,
pricingTableSavedAt: selectionPricing.pricingTableSavedAt,
pricingTableVersion: selectionPricing.pricingTableVersion,
productCodeStatus: selectionPricing.productCodeStatus,
productCodeStyleCode: selectionPricing.productCodeStyleCode,
productCodeConfig: selectionPricing.productCodeConfig,
productCodeCompletedAt: selectionPricing.productCodeCompletedAt,
productCodes: (selectionPricing.rows || []).map((row) => ({
skuCode: row.skuCode,
originalSkuName: row.originalSkuName,
selectedSkuName: row.selectedSkuName,
internalProductCode: row.internalProductCode,
productCodeProductName: row.productCodeProductName,
})),
} : null,
},
});
} catch (error) {
sendError(res, error, '保存测算记录失败');
}
});
// 历史列表(管理员可见全部,普通用户仅本人,不含快照正文)
router.get('/history', async (req, res) => {
const userId = requireUserId(req, res);
if (!userId) return;
const { isAdmin } = getRequestUser(req);
const page = Math.max(Number.parseInt(req.query.page, 10) || 1, 1);
const pageSize = Math.min(Math.max(Number.parseInt(req.query.pageSize, 10) || 20, 1), 100);
const spuCode = normalizeText(req.query.spuCode, 160).toUpperCase();
const calculatorType = normalizeText(req.query.calculatorType, 30);
const offset = (page - 1) * pageSize;
try {
await ensureHistoryTable();
const where = [];
const params = [];
if (!isAdmin) {
where.push('user_id = ?');
params.push(userId);
}
if (spuCode) {
where.push('spu_code = ?');
params.push(spuCode);
}
if (calculatorType === 'alibaba1688') {
where.push('payload_json LIKE ?');
params.push('%"calculatorType":"alibaba1688"%');
} else if (calculatorType === 'company') {
// 兼容 version 1-3 的公司品历史,它们还没有 calculatorType 字段。
where.push('payload_json NOT LIKE ?');
params.push('%"calculatorType":"alibaba1688"%');
}
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
const [countRows, list] = await Promise.all([
database.local.query(`SELECT COUNT(*) AS total FROM ${HISTORY_TABLE} ${whereSql}`, params),
database.local.query(
`SELECT id, user_id, user_name, spu_code, brand, note, row_count, created_at, updated_at
FROM ${HISTORY_TABLE}
${whereSql}
ORDER BY id DESC
LIMIT ${pageSize} OFFSET ${offset}`,
params
),
]);
res.json({
success: true,
data: {
list: list.map((row) => ({
id: row.id,
userName: row.user_name || '',
spuCode: row.spu_code,
brand: row.brand || '',
note: row.note || '',
rowCount: row.row_count,
calculatorType: calculatorType === 'alibaba1688' ? 'alibaba1688' : 'company',
createdAt: row.created_at,
updatedAt: row.updated_at,
})),
total: Number(countRows[0]?.total || 0),
page,
pageSize,
},
});
} catch (error) {
sendError(res, error, '查询测算历史失败');
}
});
async function loadOwnHistoryRow(req, res) {
const userId = requireUserId(req, res);
if (!userId) return null;
const { isAdmin } = getRequestUser(req);
const id = Number.parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id <= 0) {
res.status(400).json({ success: false, error: '无效的历史记录 ID' });
return null;
}
await ensureHistoryTable();
// 服务端强制数据边界:管理员可见全部,普通用户的他人记录表现为不存在
const rows = isAdmin
? await database.local.query(`SELECT * FROM ${HISTORY_TABLE} WHERE id = ? LIMIT 1`, [id])
: await database.local.query(
`SELECT * FROM ${HISTORY_TABLE} WHERE id = ? AND user_id = ? LIMIT 1`,
[id, userId]
);
if (rows.length === 0) {
res.status(404).json({ success: false, error: '测算记录不存在或已被删除' });
return null;
}
return rows[0];
}
// 历史详情(仅本人)
router.get('/history/:id', async (req, res) => {
try {
const row = await loadOwnHistoryRow(req, res);
if (!row) return;
let payload = null;
try {
payload = JSON.parse(row.payload_json);
} catch {
payload = null;
}
res.json({
success: true,
data: {
id: row.id,
userName: row.user_name || '',
spuCode: row.spu_code,
brand: row.brand || '',
note: row.note || '',
rowCount: row.row_count,
createdAt: row.created_at,
updatedAt: row.updated_at,
payload,
},
});
} catch (error) {
sendError(res, error, '查询测算记录失败');
}
});
// 删除历史(本人,或管理员可管理全部)
router.delete('/history/:id', async (req, res) => {
try {
const row = await loadOwnHistoryRow(req, res);
if (!row) return;
await database.local.query(
`DELETE FROM ${HISTORY_TABLE} WHERE id = ? AND user_id = ?`,
[row.id, row.user_id]
);
res.json({ success: true, data: { id: row.id } });
} catch (error) {
sendError(res, error, '删除测算记录失败');
}
});
// 导出测算表:按前端给定的列顺序(与页面显示一致)与行数据生成 xlsx 下载
const MAX_EXPORT_ROWS = 2000;
const MAX_EXPORT_COLUMNS = 40;
router.post('/export', async (req, res) => {
const userId = requireUserId(req, res);
if (!userId) return;
const body = req.body || {};
const spuCode = normalizeText(body.spu, 160).toUpperCase() || 'SPU';
const columns = Array.isArray(body.columns) ? body.columns : [];
const rows = Array.isArray(body.rows) ? body.rows : [];
const validColumns = columns
.filter((col) => col && typeof col === 'object')
.slice(0, MAX_EXPORT_COLUMNS)
.map((col) => ({
key: normalizeText(col.key, 60),
label: normalizeText(col.label, 60) || normalizeText(col.key, 60),
percent: col.percent === true,
}))
.filter((col) => col.key);
if (validColumns.length === 0) {
return res.status(400).json({ success: false, error: '缺少导出列配置' });
}
if (rows.length === 0) {
return res.status(400).json({ success: false, error: '导出数据为空' });
}
if (rows.length > MAX_EXPORT_ROWS) {
return res.status(400).json({ success: false, error: `单次最多导出 ${MAX_EXPORT_ROWS} 行` });
}
try {
const aoa = [validColumns.map((col) => col.label)];
rows.forEach((row) => {
aoa.push(validColumns.map((col) => {
const value = row && typeof row === 'object' ? row[col.key] : null;
return value === null || value === undefined ? '' : value;
}));
});
const sheet = XLSX.utils.aoa_to_sheet(aoa);
// 百分比列:保持数值,设置百分比数字格式(0.06 在 Excel 显示为 6.00%)
validColumns.forEach((col, colIndex) => {
if (!col.percent) return;
for (let rowIndex = 1; rowIndex < aoa.length; rowIndex += 1) {
const addr = XLSX.utils.encode_cell({ c: colIndex, r: rowIndex });
const cell = sheet[addr];
if (cell && typeof cell.v === 'number') {
cell.z = '0.00%';
}
}
});
sheet['!cols'] = validColumns.map((col) => ({ wch: Math.max(12, String(col.label).length * 2 + 6) }));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, '上架测算');
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
const stamp = new Date();
const pad = (n) => String(n).padStart(2, '0');
const filename = `上架测算_${spuCode}_${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}-${pad(stamp.getHours())}${pad(stamp.getMinutes())}.xlsx`;
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
res.send(buffer);
} catch (error) {
res.status(500).json({ success: false, error: `导出失败:${error.message}` });
}
});
// ===== 用户个性化默认值(每运营维护自己的配置,无配置时前端用系统默认) =====
const PREFS_TABLE = 'pricing_calculator_user_prefs';
let prefsTableReady = null;
async function ensurePrefsTable() {
if (!prefsTableReady) {
prefsTableReady = (async () => {
const existing = await database.local.query(
`SELECT 1
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = ?
LIMIT 1`,
[PREFS_TABLE]
);
if (existing.length > 0) {
return;
}
await database.local.query(
`CREATE TABLE IF NOT EXISTS ${PREFS_TABLE} (
user_id BIGINT PRIMARY KEY COMMENT '用户ID',
prefs_json VARCHAR(2000) NOT NULL COMMENT '个性化默认值 JSON',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上架测算用户个性化默认值'`
);
})();
}
try {
await prefsTableReady;
} catch (error) {
prefsTableReady = null;
throw error;
}
}
// 允许的偏好字段与取值范围(targetMargin 为小数,0.15 = 15%)
const PREF_FIELD_RANGES = {
coupon: { min: 0, max: 100000 },
activityDiscount: { min: 0.01, max: 1 },
installFee: { min: 0, max: 100000 },
targetMargin: { min: -1, max: 1 },
};
function sanitizePrefs(input) {
const src = input && typeof input === 'object' ? input : {};
const prefs = {};
Object.entries(PREF_FIELD_RANGES).forEach(([key, range]) => {
const num = Number(src[key]);
if (Number.isFinite(num)) {
prefs[key] = Math.min(range.max, Math.max(range.min, num));
}
});
return prefs;
}
const PREFS_PROFILE_KEY = '_profiles';
const PREFS_PROFILES = new Set(['company', 'alibaba1688']);
function normalizePrefsProfile(value) {
return PREFS_PROFILES.has(value) ? value : 'company';
}
function parsePrefsStorage(value) {
try {
const parsed = typeof value === 'string' ? JSON.parse(value || 'null') : value;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function readPrefsProfile(storageValue, profile = 'company') {
const storage = parsePrefsStorage(storageValue);
const normalizedProfile = normalizePrefsProfile(profile);
const source = normalizedProfile === 'company'
? storage
: storage[PREFS_PROFILE_KEY]?.[normalizedProfile];
const prefs = sanitizePrefs(source);
return Object.keys(prefs).length > 0 ? prefs : null;
}
function writePrefsProfile(storageValue, profile, prefs) {
const storage = parsePrefsStorage(storageValue);
const normalizedProfile = normalizePrefsProfile(profile);
if (normalizedProfile === 'company') {
return { ...storage, ...prefs };
}
return {
...storage,
[PREFS_PROFILE_KEY]: {
...(storage[PREFS_PROFILE_KEY] && typeof storage[PREFS_PROFILE_KEY] === 'object'
? storage[PREFS_PROFILE_KEY]
: {}),
[normalizedProfile]: prefs,
},
};
}
function createPrefsPatch(profile, prefs) {
const normalizedProfile = normalizePrefsProfile(profile);
return normalizedProfile === 'company'
? prefs
: { [PREFS_PROFILE_KEY]: { [normalizedProfile]: prefs } };
}
async function persistPrefsProfile(userId, profile, prefs) {
const patch = JSON.stringify(createPrefsPatch(profile, prefs));
try {
// JSON_MERGE_PATCH 在数据库内原子合并,保留另一测算类型的配置,且只需一次网络往返。
await database.local.query(
`INSERT INTO ${PREFS_TABLE} (user_id, prefs_json) VALUES (?, ?)
ON DUPLICATE KEY UPDATE prefs_json = JSON_MERGE_PATCH(
IF(JSON_VALID(prefs_json), prefs_json, '{}'),
VALUES(prefs_json)
)`,
[userId, patch]
);
} catch (error) {
const canFallback = ['ER_SP_DOES_NOT_EXIST', 'ER_PARSE_ERROR', 'ER_NOT_SUPPORTED_YET']
.includes(error.code);
if (!canFallback) throw error;
// 兼容不支持 JSON_MERGE_PATCH 的旧版 MySQL / MariaDB。
const existingRows = await database.local.query(
`SELECT prefs_json FROM ${PREFS_TABLE} WHERE user_id = ? LIMIT 1`,
[userId]
);
const storage = writePrefsProfile(existingRows[0]?.prefs_json, profile, prefs);
await database.local.query(
`INSERT INTO ${PREFS_TABLE} (user_id, prefs_json) VALUES (?, ?)
ON DUPLICATE KEY UPDATE prefs_json = VALUES(prefs_json)`,
[userId, JSON.stringify(storage)]
);
}
}
router.get('/prefs', async (req, res) => {
const userId = requireUserId(req, res);
if (!userId) return;
try {
await ensurePrefsTable();
const rows = await database.local.query(
`SELECT prefs_json FROM ${PREFS_TABLE} WHERE user_id = ? LIMIT 1`,
[userId]
);
const profile = normalizePrefsProfile(req.query.profile);
const prefs = rows.length > 0
? readPrefsProfile(rows[0].prefs_json, profile)
: null;
res.json({ success: true, data: { prefs } });
} catch (error) {
sendError(res, error, '读取个性化配置失败');
}
});
router.put('/prefs', async (req, res) => {
const userId = requireUserId(req, res);
if (!userId) return;
const prefs = sanitizePrefs(req.body?.prefs ?? req.body);
const profile = normalizePrefsProfile(req.body?.profile);
if (Object.keys(prefs).length === 0) {
return res.status(400).json({ success: false, error: '缺少有效的配置项' });
}
try {
await ensurePrefsTable();
await persistPrefsProfile(userId, profile, prefs);
res.json({ success: true, data: { prefs } });
} catch (error) {
sendError(res, error, '保存个性化配置失败');
}
});
module.exports = router;
module.exports._test = {
filterSkuRowsForSelectionContext,
createPrefsPatch,
getSelectionSkuCodeSet,
normalizeCalculatorType,
readPrefsProfile,
sanitizeHistoryProduct,
sanitizePrefs,
writePrefsProfile,
};

View File

@ -1,73 +0,0 @@
'use strict';
const crypto = require('crypto');
const express = require('express');
const pricingCalculatorRoutes = require('./pricingCalculator');
const {
requireDesktopPageAccess,
requireExternalAutomationApiKey,
} = require('../services/externalAutomationAuth');
const router = express.Router();
const MAX_OPERATOR_NAME_LENGTH = 100;
const MAX_SAFE_ID = BigInt(Number.MAX_SAFE_INTEGER);
function normalizeOperatorName(value) {
return String(value || '').trim().slice(0, MAX_OPERATOR_NAME_LENGTH);
}
function decodeOperatorNameHeader(value) {
const encoded = String(value || '').trim();
if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return '';
try {
return Buffer.from(encoded, 'base64url').toString('utf8');
} catch {
return '';
}
}
function desktopPricingUserId(operatorName) {
const normalized = normalizeOperatorName(operatorName).toLocaleLowerCase('zh-CN');
if (!normalized) return null;
const digest = crypto.createHash('sha256').update(`desktop-pricing:${normalized}`, 'utf8').digest();
const value = (digest.readBigUInt64BE(0) % (MAX_SAFE_ID - 1n)) + 1n;
return -Number(value);
}
function requireDesktopPricingOperator(req, res, next) {
if (req.externalApiKey?.authType === 'jwt' && req.user?.name) {
return next();
}
const operatorName = normalizeOperatorName(
decodeOperatorNameHeader(req.get('x-operator-name-encoded'))
|| req.get('x-operator-name')
|| req.query?.operatorName
|| req.body?.operatorName
);
if (!operatorName) {
return res.status(400).json({ success: false, error: '缺少运营花名' });
}
const userId = desktopPricingUserId(operatorName);
req.user = {
id: userId,
sub: userId,
name: operatorName,
username: operatorName,
role: 'operator',
authSource: 'desktop-api-key',
};
return next();
}
router.use(
requireExternalAutomationApiKey,
requireDesktopPageAccess('planning_upload'),
requireDesktopPricingOperator,
pricingCalculatorRoutes
);
module.exports = router;
module.exports.desktopPricingUserId = desktopPricingUserId;
module.exports.requireDesktopPricingOperator = requireDesktopPricingOperator;

View File

@ -9,7 +9,7 @@ const router = express.Router();
const PRODUCT_SNAPSHOT_TABLE = 'op_pdd_sku_dimension_goods_daily';
const PRODUCT_SPREAD_TABLE = 'pdd_ReportGoodsSpread';
const USER_PREFS_TABLE = 'pricing_calculator_user_prefs';
const USER_PREFS_TABLE = 'product_analysis_user_prefs';
const PRODUCT_SIGNAL_PREFS_KEY = 'productAnalysisSignalThresholds';
const PRODUCT_META_CACHE_TTL_MS = 5 * 60 * 1000;
const PRODUCT_SOURCE_CACHE_TTL_MS = Math.max(Number(process.env.PRODUCT_ANALYSIS_SOURCE_CACHE_TTL_MS) || 60 * 1000, 10 * 1000);

View File

@ -1,999 +0,0 @@
const express = require('express');
const jianSunDb = require('../config/jianSunDatabase');
const router = express.Router();
const META_CACHE_TTL_MS = 60 * 1000;
const OVERVIEW_CACHE_TTL_MS = 60 * 1000;
const HOME_SUMMARY_CACHE_TTL_MS = 10 * 60 * 1000;
const HOME_SUMMARY_STALE_TTL_MS = 24 * 60 * 60 * 1000;
const HOME_COMPENSATION_CACHE_TTL_MS = 10 * 60 * 1000;
const HOME_COMPENSATION_SAMPLE_SIZE = 10000;
const metaCache = { value: null, expiresAt: 0, promise: null };
const overviewCache = new Map();
const overviewInFlight = new Map();
const homeSummaryCache = new Map();
const homeSummaryInFlight = new Map();
const homeReviewCache = new Map();
const homeReviewInFlight = new Map();
const homeCompensationCache = new Map();
const homeCompensationSummaryCache = new Map();
const homeCompensationSummaryInFlight = new Map();
const TAB_KEYS = new Set(['overview', 'business', 'after-sales', 'customer-service', 'service-score', 'stars']);
function normalizeDate(value) {
const text = String(value || '').trim();
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : '';
}
function addDays(value, offset) {
const date = new Date(`${value}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + offset);
return date.toISOString().slice(0, 10);
}
function inclusiveDayCount(startDate, endDate) {
const start = new Date(`${startDate}T00:00:00Z`);
const end = new Date(`${endDate}T00:00:00Z`);
return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1;
}
function toNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}
function normalizeCompensationDate(value) {
const text = String(value || '').trim();
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : '';
}
function compensationDate(value) {
const text = String(value || '').trim();
return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : '';
}
async function loadRecentCompensationRows(shopName = '') {
const cacheKey = shopName || '*';
const cached = homeCompensationCache.get(cacheKey);
if (cached?.rows && cached.expiresAt > Date.now()) return cached.rows;
if (cached?.promise) return cached.promise;
const promise = jianSunDb.query(
`SELECT TOP (${HOME_COMPENSATION_SAMPLE_SIZE})
id, chargeAmount, chargeReason, chargeTime, couponDetailId,
goodsId, goodsName, orderSn, shopName
FROM dbo.pdd_abnormal_compensation WITH (NOLOCK)
WHERE (@shopName = '' OR shopName = @shopName)
ORDER BY id DESC`,
{ shopName }
).then((rows) => {
const normalizedRows = rows
.map((row) => ({
id: row.id,
date: compensationDate(row.chargeTime),
amount: toNumber(row.chargeAmount),
reason: String(row.chargeReason || '').trim() || '未分类',
goodsId: String(row.goodsId || '').trim(),
goodsName: String(row.goodsName || '').trim(),
orderSn: String(row.orderSn || '').trim(),
detailId: String(row.couponDetailId || '').trim(),
shopName: String(row.shopName || '').trim(),
}))
.filter((row) => row.date);
homeCompensationCache.set(cacheKey, {
rows: normalizedRows,
expiresAt: Date.now() + HOME_COMPENSATION_CACHE_TTL_MS,
promise: null,
});
if (homeCompensationCache.size > 12) {
const firstKey = homeCompensationCache.keys().next().value;
homeCompensationCache.delete(firstKey);
}
return normalizedRows;
}).finally(() => {
const entry = homeCompensationCache.get(cacheKey);
if (entry?.promise) homeCompensationCache.delete(cacheKey);
});
homeCompensationCache.set(cacheKey, { rows: null, expiresAt: 0, promise });
return promise;
}
async function resolveCompensationDateRange(req, rows) {
const latestAvailableDate = rows.reduce((latest, row) => (row.date > latest ? row.date : latest), '');
const endDate = normalizeCompensationDate(req.query.endDate) || latestAvailableDate;
const startDate = normalizeCompensationDate(req.query.startDate) || (endDate ? addDays(endDate, -29) : '');
if (!startDate || !endDate) {
const error = new Error('赔付数据没有可用日期');
error.statusCode = 404;
throw error;
}
const dayCount = inclusiveDayCount(startDate, endDate);
if (dayCount < 1) {
const error = new Error('赔付开始日期不能晚于结束日期');
error.statusCode = 400;
throw error;
}
if (dayCount > 90) {
const error = new Error('赔付查询日期范围不能超过90天');
error.statusCode = 400;
throw error;
}
return { startDate, endDate, dayCount, latestAvailableDate };
}
function createCompensationGroup(row) {
return {
amount: 0,
recordCount: 0,
orderKeys: new Set(),
...row,
};
}
function addCompensationRecord(group, row) {
group.amount += row.amount;
group.recordCount += 1;
group.orderKeys.add(row.orderSn || row.detailId || String(row.id));
}
function queryHomeCompensationSummary(rows, range) {
const selectedRows = rows.filter((row) => row.date >= range.startDate && row.date <= range.endDate);
const orderKeys = new Set();
const goodsKeys = new Set();
const reasonGroups = new Map();
const goodsGroups = new Map();
let totalAmount = 0;
selectedRows.forEach((row) => {
const orderKey = row.orderSn || row.detailId || String(row.id);
orderKeys.add(orderKey);
if (row.goodsId) goodsKeys.add(row.goodsId);
totalAmount += row.amount;
const reasonGroup = reasonGroups.get(row.reason) || createCompensationGroup({ reason: row.reason });
addCompensationRecord(reasonGroup, row);
reasonGroups.set(row.reason, reasonGroup);
const goodsKey = row.goodsId || `unknown-${row.id}`;
const goodsGroup = goodsGroups.get(goodsKey) || createCompensationGroup({
goodsId: row.goodsId || '未识别商品ID',
goodsName: row.goodsName,
shopName: row.shopName,
});
addCompensationRecord(goodsGroup, row);
if (!goodsGroup.goodsName && row.goodsName) goodsGroup.goodsName = row.goodsName;
if (!goodsGroup.shopName && row.shopName) goodsGroup.shopName = row.shopName;
goodsGroups.set(goodsKey, goodsGroup);
});
const toGroupResult = (group) => ({
...group,
orderCount: group.orderKeys.size,
amount: Number(group.amount.toFixed(2)),
share: totalAmount > 0 ? Number((group.amount * 100 / totalAmount).toFixed(2)) : 0,
orderKeys: undefined,
});
const reasonDistribution = [...reasonGroups.values()]
.map(toGroupResult)
.sort((left, right) => right.amount - left.amount || right.orderCount - left.orderCount)
.slice(0, 6);
const highCompensationGoods = [...goodsGroups.values()]
.map(toGroupResult)
.sort((left, right) => right.amount - left.amount || right.orderCount - left.orderCount)
.slice(0, 6);
return {
...range,
sampledRecordCount: rows.length,
selectedRecordCount: selectedRows.length,
compensationCount: orderKeys.size,
recordCount: selectedRows.length,
totalAmount: Number(totalAmount.toFixed(2)),
averageAmount: orderKeys.size ? Number((totalAmount / orderKeys.size).toFixed(2)) : 0,
affectedGoodsCount: goodsKeys.size,
reasonDistribution,
highCompensationGoods,
};
}
function toNullableNumber(value) {
if (value === null || value === undefined || value === '') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function averageNullable(rows, field) {
const values = rows.map((row) => toNullableNumber(row[field])).filter((value) => value !== null);
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null;
}
function cleanShopName(value) {
const text = String(value || '').trim();
return text || '';
}
function shopFilterSql() {
return '(@shopName IS NULL OR @shopName = \'\' OR shopName = @shopName)';
}
async function loadMeta() {
if (metaCache.value && metaCache.expiresAt > Date.now()) return metaCache.value;
if (metaCache.promise) return metaCache.promise;
metaCache.promise = Promise.all([
jianSunDb.query(
`SELECT
shopName,
CONVERT(varchar(10), MIN(TRY_CONVERT(date, statDate)), 23) AS minDate,
CONVERT(varchar(10), MAX(TRY_CONVERT(date, statDate)), 23) AS latestDate
FROM dbo.pdd_DataNormal
WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> ''
GROUP BY shopName
ORDER BY shopName`
),
jianSunDb.query(
`SELECT
CONVERT(varchar(10), MIN(TRY_CONVERT(date, statDate)), 23) AS minDate,
CONVERT(varchar(10), MAX(TRY_CONVERT(date, statDate)), 23) AS latestDate
FROM dbo.pdd_DataNormal
WHERE statDate IS NOT NULL`
),
]).then(([shopRows, dateRows]) => {
const shops = shopRows
.map((row) => ({
name: cleanShopName(row.shopName),
minDate: normalizeDate(row.minDate),
latestDate: normalizeDate(row.latestDate),
}))
.filter((row) => row.name);
const globalDate = dateRows[0] || {};
const data = {
shops,
minDate: normalizeDate(globalDate.minDate),
latestDate: normalizeDate(globalDate.latestDate),
};
metaCache.value = data;
metaCache.expiresAt = Date.now() + META_CACHE_TTL_MS;
return data;
}).finally(() => {
metaCache.promise = null;
});
return metaCache.promise;
}
async function resolveContext(req) {
const meta = await loadMeta();
const shopName = cleanShopName(req.query.shopName);
const shop = shopName ? meta.shops.find((item) => item.name === shopName) : null;
const requestedDate = normalizeDate(req.query.date);
const date = requestedDate || shop?.latestDate || meta.latestDate;
return { meta, shopName, date };
}
async function queryLatestRows({ table, dateColumn, fields, targetDate, shopName }) {
const rows = await jianSunDb.query(
`WITH ranked AS (
SELECT ${fields},
ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY id DESC) AS rowNumber
FROM dbo.${table}
WHERE ${shopFilterSql()}
AND [${dateColumn}] = @targetDate
)
SELECT * FROM ranked WHERE rowNumber = 1`,
{ targetDate, shopName: shopName || null }
);
return rows;
}
async function queryBusinessSnapshot(targetDate, shopName) {
const rows = await queryLatestRows({
table: 'pdd_DataNormal',
dateColumn: 'statDate',
targetDate,
shopName,
fields: '[id], shopName, statDate, cfmOrdrAmt, payOrdrAup, payUvRto',
});
const sales = rows.reduce((sum, row) => sum + toNumber(row.cfmOrdrAmt), 0);
const averageConversion = averageNullable(rows, 'payUvRto');
const count = rows.length;
return {
shopCount: count,
sales,
averageAov: averageNullable(rows, 'payOrdrAup'),
averageConversion: averageConversion === null ? null : averageConversion * 100,
rows,
};
}
async function queryAfterSalesSnapshot(targetDate, shopName) {
const rows = await queryLatestRows({
table: 'pdd_ShopNormalOverflow',
dateColumn: 'createTime',
targetDate,
shopName,
fields: '[id], shopName, createTime, executedCount, afterSalesCount, waitSellerHandle, platformHandling, waitBuyerHandle, todosCount, todosEndedCount, cfmOrdrCnt',
});
const fields = ['executedCount', 'afterSalesCount', 'waitSellerHandle', 'platformHandling', 'waitBuyerHandle', 'todosCount', 'todosEndedCount', 'cfmOrdrCnt'];
const data = fields.reduce((result, field) => ({ ...result, [field]: rows.reduce((sum, row) => sum + toNumber(row[field]), 0) }), {});
return { ...data, shopCount: rows.length, rows };
}
async function queryCustomerServiceSnapshot(targetDate, shopName) {
const [refundRows, receptionRows] = await Promise.all([
queryLatestRows({
table: 'pdd_DataCustomerService',
dateColumn: 'createTime',
targetDate,
shopName,
fields: '[id], shopName, createTime, dsptRfSucOrdrCnt1m, dsptRfSucRto1m, pltInvlOrdrCnt1m, pltInvlOrdrRto1m, qurfOrdRto1m, avgSucRfProcTime1m, ssslAvgSucRfProcTime1mPct, sucRfOrdrCnt1d, sucRfOrdrAmt1d, rfSucRto1m, avgSlfSucRfProcTime1mMgr, avgSlfSucRfProcTime1mMr',
}),
jianSunDb.query(
`WITH ranked AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName, cs_name ORDER BY id DESC) AS rowNumber
FROM dbo.pdd_customer_overflow
WHERE ${shopFilterSql()}
AND [createTime] = @targetDate
)
SELECT
SUM(ISNULL(consult_user_cnt, 0)) AS consultUserCount,
SUM(ISNULL(inquiry_user, 0)) AS inquiryUserCount,
SUM(ISNULL(final_group_user, 0)) AS finalGroupUserCount,
SUM(ISNULL(cs_sales_amount, 0)) AS csSalesAmount,
SUM(ISNULL(need_manu_reply_consult_user_cnt, 0)) AS needManualReplyCount,
SUM(ISNULL(receive_user_cnt, 0)) AS receiveUserCount,
SUM(ISNULL(delay_reply, 0)) AS delayReplyCount,
AVG(TRY_CONVERT(decimal(18, 8), reply_rate_3_min)) AS replyRate3Min,
AVG(TRY_CONVERT(decimal(18, 8), reply_30s_rate)) AS replyRate30s,
SUM(ISNULL(unsatisfied_serve_score_cnt, 0)) AS unsatisfiedScoreCount,
SUM(ISNULL(complain_user_cnt, 0)) AS complainUserCount,
AVG(TRY_CONVERT(decimal(18, 8), avg_reply_time)) AS averageReplyTime,
SUM(ISNULL(serve_refund_cnt, 0)) AS serviceRefundCount,
COUNT(DISTINCT cs_name) AS customerServiceCount
FROM ranked
WHERE rowNumber = 1 AND cs_name = N'店铺总计'`,
{ targetDate, shopName: shopName || null }
),
]);
const refundFields = [
'dsptRfSucOrdrCnt1m', 'pltInvlOrdrCnt1m', 'sucRfOrdrCnt1d', 'sucRfOrdrAmt1d',
];
const refundTotals = refundFields.reduce((result, field) => ({
...result,
[field]: refundRows.reduce((sum, row) => sum + toNumber(row[field]), 0),
}), {});
const averageFields = [
'dsptRfSucRto1m', 'pltInvlOrdrRto1m', 'qurfOrdRto1m', 'avgSucRfProcTime1m',
'ssslAvgSucRfProcTime1mPct', 'rfSucRto1m', 'avgSlfSucRfProcTime1mMgr', 'avgSlfSucRfProcTime1mMr',
];
const averages = averageFields.reduce((result, field) => ({
...result,
[field]: averageNullable(refundRows, field),
}), {});
return {
...refundTotals,
...averages,
shopCount: refundRows.length,
reception: receptionRows[0] || {},
rows: refundRows,
};
}
async function queryServiceScoreSnapshot(targetDate, shopName) {
const rows = await queryLatestRows({
table: 'pdd_MallServiceScore',
dateColumn: 'createTime',
targetDate,
shopName,
fields: '[id], shopName, createTime, cstmrServScore, attiLmScore, jcfwLmScore, spLmScore, fhLmScore, wlLmScore',
});
const fields = ['cstmrServScore', 'attiLmScore', 'jcfwLmScore', 'spLmScore', 'fhLmScore', 'wlLmScore'];
const values = fields.reduce((result, field) => ({
...result,
[field]: averageNullable(rows, field),
}), {});
return { ...values, shopCount: rows.length, rows };
}
async function queryStarsSnapshot(targetDate, shopName) {
const rows = await queryLatestRows({
table: 'pdd_ShopStars',
dateColumn: 'createTime',
targetDate,
shopName,
fields: '[id], shopName, createTime, mallStarTomms, mallStarTommsChange, mallStarDesc, ptHelpRate1m, aprsIn3minRplyUsrRto1m, rfSignTime1m, rfProcTime1m, dsr3m, posTypeRto1m, seriousInferiorRto1m, adjNfkAvgCfmSignTime1m, lgstMergeFine1m, gmvRk, ptHelpRate1mNextStarRkDesc, ptHelpRate1mNextPctThreshold, aprsIn3minRplyUsrRto1mNextStarRkDesc, aprsIn3minRplyUsrRto1mNextPctThreshold, rfSignTime1mNextStarRkDesc, rfSignTime1mNextPctThreshold, rfProcTime1mNextStarRkDesc, rfProcTime1mNextPctThreshold, dsr3mNextStarRkDesc, dsr3mNextPctThreshold, posTypeRto1mNextStarRkDesc, posTypeRto1mNextPctThreshold, seriousInferiorRto1mNextStarRkDesc, seriousInferiorRto1mNextPctThreshold, adjNfkAvgCfmSignTime1mNextStarRkDesc, adjNfkAvgCfmSignTime1mNextPctThreshold, lgstMergeFine1mNextStarRkDesc, lgstMergeFine1mNextPctThreshold, gmvRkNextStarRkDesc, gmvRkNextPctThreshold, scoreRk',
});
const averageFields = ['mallStarTomms', 'mallStarTommsChange', 'ptHelpRate1m', 'aprsIn3minRplyUsrRto1m', 'rfSignTime1m', 'rfProcTime1m', 'dsr3m', 'posTypeRto1m', 'seriousInferiorRto1m', 'adjNfkAvgCfmSignTime1m', 'lgstMergeFine1m', 'gmvRk'];
const values = averageFields.reduce((result, field) => ({
...result,
[field]: averageNullable(rows, field),
}), {});
const first = rows[0] || {};
const targetFields = [
['ptHelpRate1m', '平台求助率', 'ptHelpRate1mNextStarRkDesc', 'ptHelpRate1mNextPctThreshold'],
['aprsIn3minRplyUsrRto1m', '3分钟人工回复率', 'aprsIn3minRplyUsrRto1mNextStarRkDesc', 'aprsIn3minRplyUsrRto1mNextPctThreshold'],
['rfSignTime1m', '在途订单退款时长', 'rfSignTime1mNextStarRkDesc', 'rfSignTime1mNextPctThreshold'],
['rfProcTime1m', '签收退货后退款时长', 'rfProcTime1mNextStarRkDesc', 'rfProcTime1mNextPctThreshold'],
['dsr3m', '90天用户评价得分', 'dsr3mNextStarRkDesc', 'dsr3mNextPctThreshold'],
['posTypeRto1m', '积极评论率', 'posTypeRto1mNextStarRkDesc', 'posTypeRto1mNextPctThreshold'],
['seriousInferiorRto1m', '严重劣质率', 'seriousInferiorRto1mNextStarRkDesc', 'seriousInferiorRto1mNextPctThreshold'],
['adjNfkAvgCfmSignTime1m', '成团到签收时效', 'adjNfkAvgCfmSignTime1mNextStarRkDesc', 'adjNfkAvgCfmSignTime1mNextPctThreshold'],
['lgstMergeFine1m', '物流综合处罚率', 'lgstMergeFine1mNextStarRkDesc', 'lgstMergeFine1mNextPctThreshold'],
['gmvRk', '店铺活跃度', 'gmvRkNextStarRkDesc', 'gmvRkNextPctThreshold'],
];
const targets = shopName ? targetFields.map(([field, label, statusField, thresholdField]) => ({
field,
label,
value: toNullableNumber(first[field]),
status: first[statusField] || null,
threshold: toNullableNumber(first[thresholdField]),
})) : [];
const targetFailureCount = rows.reduce((count, row) => count + targetFields.filter(([, , statusField]) => includesFailure(row[statusField])).length, 0);
return { ...values, shopCount: rows.length, targets, targetFailureCount, rows };
}
function includesFailure(value) {
const text = String(value || '').trim();
return text.includes('不达标') || text.includes('未达标') || text.includes('下降');
}
function buildIssues({ afterSales, customerService, stars }) {
const issues = [];
const addIssue = (key, tone, title, value, description, source) => {
if (toNumber(value) > 0 || includesFailure(value)) {
issues.push({ key, tone, title, value, description, source });
}
};
addIssue('wait-seller-handle', 'error', '待商家处理售后单', afterSales.waitSellerHandle, '需要优先处理,避免售后时效继续积压', '店铺售后');
addIssue('platform-handling', 'warning', '平台处理中售后单', afterSales.platformHandling, '平台介入中的售后订单需要关注处理结果', '店铺售后');
addIssue('delay-reply', 'warning', '三分钟未回复咨询', customerService.reception.delayReplyCount, '客服响应存在延迟', '客服接待');
addIssue('complaint', 'error', '客服投诉数', customerService.reception.complainUserCount, '存在客户投诉,需要回溯客服接待记录', '客服接待');
addIssue('star-target', 'warning', '星级指标存在未达标项', stars.targetFailureCount, '可在“星级提升”中查看具体指标和下一星级标准', '店铺星级');
return issues;
}
async function queryOverview(context) {
const { shopName, date } = context;
const [business, afterSales, customerService, serviceScore, stars] = await Promise.all([
queryBusinessSnapshot(date, shopName),
queryAfterSalesSnapshot(date, shopName),
queryCustomerServiceSnapshot(date, shopName),
queryServiceScoreSnapshot(date, shopName),
queryStarsSnapshot(date, shopName),
]);
const reception = customerService.reception || {};
return {
date,
shopName: shopName || '全部店铺',
shopCount: business.shopCount,
business: {
sales: business.sales,
averageAov: business.averageAov,
averageConversion: business.averageConversion,
},
afterSales: {
afterSalesCount: afterSales.afterSalesCount,
executedCount: afterSales.executedCount,
waitSellerHandle: afterSales.waitSellerHandle,
platformHandling: afterSales.platformHandling,
waitBuyerHandle: afterSales.waitBuyerHandle,
todosCount: afterSales.todosCount,
todosEndedCount: afterSales.todosEndedCount,
cfmOrdrCnt: afterSales.cfmOrdrCnt,
},
customerService: {
...customerService,
reception: {
consultUserCount: toNumber(reception.consultUserCount),
inquiryUserCount: toNumber(reception.inquiryUserCount),
finalGroupUserCount: toNumber(reception.finalGroupUserCount),
csSalesAmount: toNumber(reception.csSalesAmount),
needManualReplyCount: toNumber(reception.needManualReplyCount),
receiveUserCount: toNumber(reception.receiveUserCount),
delayReplyCount: toNumber(reception.delayReplyCount),
replyRate3Min: toNullableNumber(reception.replyRate3Min),
replyRate30s: toNullableNumber(reception.replyRate30s),
unsatisfiedScoreCount: toNumber(reception.unsatisfiedScoreCount),
complainUserCount: toNumber(reception.complainUserCount),
averageReplyTime: toNullableNumber(reception.averageReplyTime),
serviceRefundCount: toNumber(reception.serviceRefundCount),
customerServiceCount: toNumber(reception.customerServiceCount),
},
},
serviceScore: {
cstmrServScore: serviceScore.cstmrServScore,
attiLmScore: serviceScore.attiLmScore,
jcfwLmScore: serviceScore.jcfwLmScore,
spLmScore: serviceScore.spLmScore,
fhLmScore: serviceScore.fhLmScore,
wlLmScore: serviceScore.wlLmScore,
},
stars: {
mallStarTomms: stars.mallStarTomms,
mallStarTommsChange: stars.mallStarTommsChange,
dsr3m: stars.dsr3m,
ptHelpRate1m: stars.ptHelpRate1m,
aprsIn3minRplyUsrRto1m: stars.aprsIn3minRplyUsrRto1m,
targets: stars.targets,
},
issues: buildIssues({ afterSales, customerService, stars }),
coverage: {
business: business.shopCount > 0,
afterSales: afterSales.shopCount > 0,
customerService: customerService.shopCount > 0 || Boolean(reception.customerServiceCount),
serviceScore: serviceScore.shopCount > 0,
stars: stars.shopCount > 0,
},
};
}
async function queryHomeSummary(context) {
const { shopName, date } = context;
const [afterSales, customerService, serviceScore, stars] = await Promise.all([
queryAfterSalesSnapshot(date, shopName),
queryCustomerServiceSnapshot(date, shopName),
queryServiceScoreSnapshot(date, shopName),
queryStarsSnapshot(date, shopName),
]);
const reception = customerService.reception || {};
const riskItems = [
{
key: 'waitSellerHandle',
label: '待商家处理',
value: toNumber(afterSales.waitSellerHandle),
tone: 'error',
},
{
key: 'delayReplyCount',
label: '三分钟未回复',
value: toNumber(reception.delayReplyCount),
tone: 'warning',
},
{
key: 'complainUserCount',
label: '客服投诉',
value: toNumber(reception.complainUserCount),
tone: 'error',
},
{
key: 'starTargetFailure',
label: '星级未达标项',
value: toNumber(stars.targetFailureCount),
tone: 'warning',
},
];
const riskSignalCount = riskItems.reduce((sum, item) => sum + item.value, 0);
const riskTypeCount = riskItems.filter((item) => item.value > 0).length;
const coverageModules = [
afterSales.shopCount > 0,
customerService.shopCount > 0 || Boolean(reception.customerServiceCount),
serviceScore.shopCount > 0,
stars.shopCount > 0,
].filter(Boolean).length;
return {
date,
shopName: shopName || '全部店铺',
shopCount: Math.max(
toNumber(afterSales.shopCount),
toNumber(customerService.shopCount),
toNumber(serviceScore.shopCount),
toNumber(stars.shopCount)
),
totalShopCount: shopName ? 1 : context.meta.shops.length,
coverageModules,
totalModules: 4,
status: riskSignalCount > 0 ? 'attention' : 'healthy',
riskSignalCount,
riskTypeCount,
averageStar: toNullableNumber(stars.mallStarTomms),
starChange: toNullableNumber(stars.mallStarTommsChange),
evaluationScore: toNullableNumber(stars.dsr3m),
riskItems,
serviceScores: [
{ key: 'cstmrServScore', label: '服务体验', value: toNullableNumber(serviceScore.cstmrServScore) },
{ key: 'spLmScore', label: '商品服务', value: toNullableNumber(serviceScore.spLmScore) },
{ key: 'wlLmScore', label: '物流服务', value: toNullableNumber(serviceScore.wlLmScore) },
{ key: 'fhLmScore', label: '发货服务', value: toNullableNumber(serviceScore.fhLmScore) },
],
};
}
async function resolveReviewDateRange(req) {
const shopName = cleanShopName(req.query.shopName);
let endDate = normalizeDate(req.query.endDate);
if (!endDate) {
const rows = await jianSunDb.query(
`SELECT TOP (1) LEFT(createTime, 10) AS latestDate
FROM dbo.pdd_Evaluation WITH (NOLOCK)
WHERE createTime >= '2000-01-01'
AND createTime < '2100-01-01'
AND descScore BETWEEN 1 AND 5
AND (@shopName = '' OR shopName = @shopName)
ORDER BY createTime DESC`,
{ shopName }
);
endDate = normalizeDate(rows[0]?.latestDate);
}
const startDate = normalizeDate(req.query.startDate) || (endDate ? addDays(endDate, -29) : '');
if (!startDate || !endDate) {
const error = new Error('评价数据没有可用日期');
error.statusCode = 404;
throw error;
}
const dayCount = inclusiveDayCount(startDate, endDate);
if (dayCount < 1) {
const error = new Error('评价开始日期不能晚于结束日期');
error.statusCode = 400;
throw error;
}
if (dayCount > 90) {
const error = new Error('评价查询日期范围不能超过90天');
error.statusCode = 400;
throw error;
}
return { startDate, endDate, dayCount, shopName };
}
async function queryHomeReviewSummary(range) {
const params = {
startDate: range.startDate,
endDateExclusive: addDays(range.endDate, 1),
shopName: range.shopName || '',
};
const [summaryRows, keywordRows] = await Promise.all([
jianSunDb.query(
`SELECT
COUNT_BIG(1) AS totalReviewCount,
SUM(CASE WHEN descScore IN (4, 5) THEN 1 ELSE 0 END) AS goodReviewCount,
SUM(CASE WHEN descScore IN (1, 2, 3) THEN 1 ELSE 0 END) AS badReviewCount,
SUM(CASE WHEN descScore = 1 THEN 1 ELSE 0 END) AS score1Count,
SUM(CASE WHEN descScore = 2 THEN 1 ELSE 0 END) AS score2Count,
SUM(CASE WHEN descScore = 3 THEN 1 ELSE 0 END) AS score3Count,
SUM(CASE WHEN descScore = 4 THEN 1 ELSE 0 END) AS score4Count,
SUM(CASE WHEN descScore = 5 THEN 1 ELSE 0 END) AS score5Count,
AVG(TRY_CONVERT(decimal(18, 6), descScore)) AS averageScore,
COUNT(DISTINCT goodsId) AS reviewedGoodsCount,
COUNT(DISTINCT NULLIF(LTRIM(RTRIM(shopName)), '')) AS reviewedShopCount
FROM dbo.pdd_Evaluation WITH (NOLOCK)
WHERE createTime >= @startDate
AND createTime < @endDateExclusive
AND descScore BETWEEN 1 AND 5
AND (@shopName = '' OR shopName = @shopName)`,
params
),
jianSunDb.query(
`SELECT keywords, COUNT_BIG(1) AS reviewCount
FROM dbo.pdd_Evaluation WITH (NOLOCK)
WHERE createTime >= @startDate
AND createTime < @endDateExclusive
AND descScore BETWEEN 1 AND 3
AND (@shopName = '' OR shopName = @shopName)
AND NULLIF(LTRIM(RTRIM(keywords)), '') IS NOT NULL
GROUP BY keywords`,
params
),
]);
const row = summaryRows[0] || {};
const totalReviewCount = toNumber(row.totalReviewCount);
const goodReviewCount = toNumber(row.goodReviewCount);
const badReviewCount = toNumber(row.badReviewCount);
const scoreCounts = [1, 2, 3, 4, 5].map((score) => ({
score,
count: toNumber(row[`score${score}Count`]),
}));
const negativeKeywordPattern = /差|不符|异味|瑕疵|破损|低|少发|错发|不好|问题|慢|难/;
const badKeywordCounts = new Map();
keywordRows.forEach((item) => {
const weight = toNumber(item.reviewCount);
String(item.keywords || '').split(/[,,]/).forEach((value) => {
const keyword = value.trim();
if (!keyword || !negativeKeywordPattern.test(keyword)) return;
badKeywordCounts.set(keyword, (badKeywordCounts.get(keyword) || 0) + weight);
});
});
const badKeywords = [...badKeywordCounts.entries()]
.map(([keyword, count]) => ({ keyword, count }))
.sort((left, right) => right.count - left.count || left.keyword.localeCompare(right.keyword, 'zh-CN'))
.slice(0, 5);
return {
...range,
totalReviewCount,
goodReviewCount,
goodReviewRate: totalReviewCount > 0 ? goodReviewCount * 100 / totalReviewCount : 0,
badReviewCount,
badReviewRate: totalReviewCount > 0 ? badReviewCount * 100 / totalReviewCount : 0,
averageScore: toNullableNumber(row.averageScore),
reviewedGoodsCount: toNumber(row.reviewedGoodsCount),
reviewedShopCount: toNumber(row.reviewedShopCount),
distribution: scoreCounts.map((item) => ({
...item,
rate: totalReviewCount > 0 ? item.count * 100 / totalReviewCount : 0,
})),
badKeywords,
};
}
async function queryBusinessTab(context) {
const startDate = addDays(context.date, -29);
const rows = await jianSunDb.query(
`WITH ranked AS (
SELECT
statDate,
shopName, cfmOrdrAmt, payOrdrAup, payUvRto,
ROW_NUMBER() OVER (PARTITION BY shopName, statDate ORDER BY id DESC) AS rowNumber
FROM dbo.pdd_DataNormal
WHERE ${shopFilterSql()}
AND statDate BETWEEN @startDate AND @targetDate
)
SELECT statDate,
SUM(TRY_CONVERT(decimal(38, 6), cfmOrdrAmt)) AS sales,
AVG(TRY_CONVERT(decimal(38, 6), payOrdrAup)) AS averageAov,
AVG(TRY_CONVERT(decimal(38, 8), payUvRto)) AS averageConversion,
COUNT(*) AS shopCount
FROM ranked
WHERE rowNumber = 1
GROUP BY statDate
ORDER BY statDate`,
{ startDate, targetDate: context.date, shopName: context.shopName || '' }
);
return {
status: rows.length ? 'ready' : 'empty',
metrics: [
{ key: 'sales', label: '成交额', value: rows.reduce((sum, row) => sum + toNumber(row.sales), 0), unit: 'currency' },
{ key: 'averageAov', label: '平均支付客单价', value: rows.length ? toNumber(rows.at(-1).averageAov) : null, unit: 'currency' },
{ key: 'averageConversion', label: '最新支付转化率', value: rows.length ? toNumber(rows.at(-1).averageConversion) * 100 : null, unit: 'percent' },
],
trend: rows.map((row) => ({ date: row.statDate, sales: toNumber(row.sales), averageAov: toNumber(row.averageAov), averageConversion: toNumber(row.averageConversion) * 100 })),
};
}
async function queryAfterSalesTab(context) {
const rows = await queryLatestRows({
table: 'pdd_ShopNormalOverflow',
dateColumn: 'createTime',
targetDate: context.date,
shopName: context.shopName,
fields: '[id], shopName, createTime, executedCount, afterSalesCount, waitSellerHandle, platformHandling, waitBuyerHandle, todosCount, todosEndedCount, cfmOrdrCnt',
});
return {
status: rows.length ? 'ready' : 'empty',
rows,
columns: [
{ key: 'shopName', title: '店铺' }, { key: 'afterSalesCount', title: '售后总量' }, { key: 'executedCount', title: '已处理' },
{ key: 'waitSellerHandle', title: '待商家处理' }, { key: 'platformHandling', title: '平台处理中' }, { key: 'waitBuyerHandle', title: '待买家处理' },
{ key: 'todosCount', title: '工单总量' }, { key: 'todosEndedCount', title: '已完结工单' },
],
};
}
async function queryCustomerServiceTab(context) {
const rows = await jianSunDb.query(
`WITH ranked AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName, cs_name ORDER BY id DESC) AS rowNumber
FROM dbo.pdd_customer_overflow
WHERE ${shopFilterSql()}
AND [createTime] = @targetDate
)
SELECT
cs_name AS csName,
SUM(ISNULL(consult_user_cnt, 0)) AS consultUserCount,
SUM(ISNULL(inquiry_user, 0)) AS inquiryUserCount,
SUM(ISNULL(final_group_user, 0)) AS finalGroupUserCount,
SUM(ISNULL(cs_sales_amount, 0)) AS csSalesAmount,
SUM(ISNULL(receive_user_cnt, 0)) AS receiveUserCount,
SUM(ISNULL(delay_reply, 0)) AS delayReplyCount,
AVG(TRY_CONVERT(decimal(18, 8), reply_rate_3_min)) AS replyRate3Min,
AVG(TRY_CONVERT(decimal(18, 8), reply_30s_rate)) AS replyRate30s,
SUM(ISNULL(complain_user_cnt, 0)) AS complainUserCount
FROM ranked
WHERE rowNumber = 1 AND ISNULL(cs_name, '') <> N'店铺总计'
GROUP BY cs_name
ORDER BY SUM(ISNULL(cs_sales_amount, 0)) DESC`,
{ targetDate: context.date, shopName: context.shopName || '' }
);
return {
status: rows.length ? 'ready' : 'empty',
rows,
columns: [
{ key: 'csName', title: '客服账号' }, { key: 'consultUserCount', title: '咨询人数' }, { key: 'inquiryUserCount', title: '询单人数' },
{ key: 'finalGroupUserCount', title: '成团人数' }, { key: 'csSalesAmount', title: '客服销售额' }, { key: 'receiveUserCount', title: '人工接待' },
{ key: 'replyRate30s', title: '30秒应答率' }, { key: 'replyRate3Min', title: '3分钟回复率' }, { key: 'complainUserCount', title: '投诉数' },
],
};
}
async function queryServiceScoreTab(context) {
const snapshot = await queryServiceScoreSnapshot(context.date, context.shopName);
return {
status: snapshot.shopCount ? 'ready' : 'empty',
metrics: [
{ key: 'cstmrServScore', label: '消费者服务体验分', value: snapshot.cstmrServScore, unit: 'score' },
{ key: 'attiLmScore', label: '服务态度', value: snapshot.attiLmScore, unit: 'score' },
{ key: 'jcfwLmScore', label: '基础服务', value: snapshot.jcfwLmScore, unit: 'score' },
{ key: 'spLmScore', label: '商品服务', value: snapshot.spLmScore, unit: 'score' },
{ key: 'fhLmScore', label: '发货服务', value: snapshot.fhLmScore, unit: 'score' },
{ key: 'wlLmScore', label: '物流服务', value: snapshot.wlLmScore, unit: 'score' },
],
rows: snapshot.rows,
columns: [
{ key: 'shopName', title: '店铺' }, { key: 'cstmrServScore', title: '消费者服务体验分' }, { key: 'attiLmScore', title: '服务态度' },
{ key: 'jcfwLmScore', title: '基础服务' }, { key: 'spLmScore', title: '商品服务' }, { key: 'fhLmScore', title: '发货服务' }, { key: 'wlLmScore', title: '物流服务' },
],
};
}
async function queryStarsTab(context) {
const snapshot = await queryStarsSnapshot(context.date, context.shopName);
return {
status: snapshot.shopCount ? 'ready' : 'empty',
metrics: [
{ key: 'mallStarTomms', label: '综合体验星级', value: snapshot.mallStarTomms, unit: 'star' },
{ key: 'mallStarTommsChange', label: '星级变化', value: snapshot.mallStarTommsChange, unit: 'starChange' },
{ key: 'dsr3m', label: '90天用户评价得分', value: snapshot.dsr3m, unit: 'score' },
{ key: 'ptHelpRate1m', label: '30天平台求助率', value: snapshot.ptHelpRate1m, unit: 'ratio' },
{ key: 'aprsIn3minRplyUsrRto1m', label: '30天3分钟人工回复率', value: snapshot.aprsIn3minRplyUsrRto1m, unit: 'ratio' },
],
targets: snapshot.targets,
rows: snapshot.rows,
columns: [
{ key: 'shopName', title: '店铺' }, { key: 'mallStarTomms', title: '综合体验星级' }, { key: 'mallStarTommsChange', title: '星级变化' },
{ key: 'dsr3m', title: '90天评价得分' }, { key: 'ptHelpRate1m', title: '平台求助率' }, { key: 'aprsIn3minRplyUsrRto1m', title: '3分钟回复率' },
],
};
}
router.get('/meta', async (_req, res) => {
try {
res.json({ success: true, data: await loadMeta() });
} catch (error) {
console.error('[StoreCheckup] 查询元数据失败:', error.message);
res.status(500).json({ success: false, error: error.message });
}
});
router.get('/overview', async (req, res) => {
try {
const context = await resolveContext(req);
const cacheKey = `${context.shopName || '*'}:${context.date}`;
const cached = overviewCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return res.json({ success: true, data: cached.data });
}
let pending = overviewInFlight.get(cacheKey);
if (!pending) {
pending = queryOverview(context)
.then((data) => {
overviewCache.set(cacheKey, { data, expiresAt: Date.now() + OVERVIEW_CACHE_TTL_MS });
return data;
})
.finally(() => overviewInFlight.delete(cacheKey));
overviewInFlight.set(cacheKey, pending);
}
const data = await pending;
res.json({ success: true, data });
} catch (error) {
console.error('[StoreCheckup] 查询体检概览失败:', error.message);
res.status(500).json({ success: false, error: error.message });
}
});
router.get('/home-summary', async (req, res) => {
try {
const context = await resolveContext(req);
const cacheKey = `${context.shopName || '*'}:${context.date}`;
const cached = homeSummaryCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return res.json({ success: true, data: cached.data });
}
const refreshSummary = () => {
let pending = homeSummaryInFlight.get(cacheKey);
if (pending) return pending;
pending = queryHomeSummary(context)
.then((data) => {
const now = Date.now();
homeSummaryCache.set(cacheKey, {
data,
expiresAt: now + HOME_SUMMARY_CACHE_TTL_MS,
staleUntil: now + HOME_SUMMARY_STALE_TTL_MS,
});
return data;
})
.finally(() => homeSummaryInFlight.delete(cacheKey));
homeSummaryInFlight.set(cacheKey, pending);
return pending;
};
if (cached && cached.staleUntil > Date.now()) {
refreshSummary().catch((error) => {
console.error('[StoreCheckup] 后台刷新首页摘要失败:', error.message);
});
return res.json({ success: true, data: { ...cached.data, refreshing: true } });
}
return res.json({ success: true, data: await refreshSummary() });
} catch (error) {
console.error('[StoreCheckup] 查询首页摘要失败:', error.message);
return res.status(500).json({ success: false, error: error.message });
}
});
router.get('/home-reviews', async (req, res) => {
try {
const range = await resolveReviewDateRange(req);
const cacheKey = `${range.shopName || '*'}:${range.startDate}:${range.endDate}`;
const cached = homeReviewCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return res.json({ success: true, data: cached.data });
}
let pending = homeReviewInFlight.get(cacheKey);
if (!pending) {
pending = queryHomeReviewSummary(range)
.then((data) => {
homeReviewCache.set(cacheKey, { data, expiresAt: Date.now() + HOME_SUMMARY_CACHE_TTL_MS });
return data;
})
.finally(() => homeReviewInFlight.delete(cacheKey));
homeReviewInFlight.set(cacheKey, pending);
}
return res.json({ success: true, data: await pending });
} catch (error) {
console.error('[StoreCheckup] 查询首页评价摘要失败:', error.message);
return res.status(error.statusCode || 500).json({ success: false, error: error.message });
}
});
router.get('/home-compensation', async (req, res) => {
try {
const shopName = cleanShopName(req.query.shopName);
const rows = await loadRecentCompensationRows(shopName);
const range = { ...(await resolveCompensationDateRange(req, rows)), shopName };
const cacheKey = `${shopName || '*'}:${range.startDate}:${range.endDate}`;
const cached = homeCompensationSummaryCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return res.json({ success: true, data: cached.data });
}
let pending = homeCompensationSummaryInFlight.get(cacheKey);
if (!pending) {
pending = Promise.resolve(queryHomeCompensationSummary(rows, range))
.then((data) => {
homeCompensationSummaryCache.set(cacheKey, {
data,
expiresAt: Date.now() + HOME_COMPENSATION_CACHE_TTL_MS,
});
return data;
})
.finally(() => homeCompensationSummaryInFlight.delete(cacheKey));
homeCompensationSummaryInFlight.set(cacheKey, pending);
}
return res.json({ success: true, data: await pending });
} catch (error) {
console.error('[StoreCheckup] 查询首页赔付摘要失败:', error.message);
return res.status(error.statusCode || 500).json({ success: false, error: error.message });
}
});
router.get('/tabs/:tabKey', async (req, res) => {
try {
const tabKey = String(req.params.tabKey || '').trim();
if (!TAB_KEYS.has(tabKey) || tabKey === 'overview') {
return res.status(400).json({ success: false, error: '不支持的店铺体检模块' });
}
const context = await resolveContext(req);
const handlers = {
business: queryBusinessTab,
'after-sales': queryAfterSalesTab,
'customer-service': queryCustomerServiceTab,
'service-score': queryServiceScoreTab,
stars: queryStarsTab,
};
const data = await handlers[tabKey](context);
return res.json({ success: true, data: { ...data, date: context.date, shopName: context.shopName || '全部店铺' } });
} catch (error) {
console.error('[StoreCheckup] 查询体检模块失败:', error.message);
return res.status(500).json({ success: false, error: error.message });
}
});
module.exports = router;

View File

@ -29,11 +29,6 @@ const SIDEBAR_PAGES = [
buildPlanningPage({ pageKey: 'promotion_module', pageName: '推广模块', path: '/planning/operation/promotion-module' }),
buildPlanningPage({ pageKey: 'new_product_test', pageName: '新品测款', path: '/planning/operation/new-product-test' }),
buildPlanningPage({ pageKey: 'product_analysis', pageName: '商品分析', path: '/planning/operation/product-analysis' }),
buildPlanningPage({ pageKey: 'store_checkup', pageName: '店铺体检', path: '/planning/operation/store-checkup' }),
buildPlanningPage({ pageKey: 'review_analysis', pageName: '复盘分析', path: '/planning/operation/review-analysis' }),
buildPlanningPage({ pageKey: 'customer_service_checkup', pageName: '客服质检', path: '/planning/operation/customer-service-checkup' }),
buildPlanningPage({ pageKey: 'ai_buyer_show', pageName: 'AI买家秀', path: '/planning/operation/ai-buyer-show' }),
buildPlanningPage({ pageKey: 'ai_seeding_video', pageName: 'AI种草视频', path: '/planning/operation/ai-seeding-video' }),
buildPlanningPage({ pageKey: 'work_orders', pageName: '工单待办', path: '/planning/operation/work-orders' }),
{
pageKey: 'settings',

View File

@ -19,7 +19,6 @@ const planningSelectionFissionExternalRoutes = require('./api/planningSelectionF
const presaleExternalRoutes = require('./api/presaleExternal');
const delistExternalRoutes = require('./api/delistExternal');
const campaignCloudRoutes = require('./api/campaignCloud');
const pricingCalculatorExternalRoutes = require('./api/pricingCalculatorExternal');
const desktopClientRoutes = require('./api/desktopClient');
const desktopPresenceRoutes = require('./api/desktopPresence');
const desktopExecutionTaskRoutes = require('./api/desktopExecutionTasks');
@ -33,10 +32,6 @@ const productCodeBoardRoutes = require('./api/productCodeBoard');
const productAnalysisRoutes = require('./api/productAnalysis');
const marketingEventsRoutes = require('./api/marketingEvents');
const promotionModuleRoutes = require('./api/promotionModule');
const csQualityRoutes = require('./api/csQuality');
const aiBuyerShowRoutes = require('./api/aiBuyerShow');
const storeCheckupRoutes = require('./api/storeCheckup');
const pricingCalculatorRoutes = require('./api/pricingCalculator');
const productMonitoringRoutes = require('./api/productMonitoring');
const database = require('./config/database');
const cloudDb = require('./config/cloudDatabase');
@ -636,7 +631,6 @@ async function startServer(options = {}) {
cubejsApp.use('/api/external/selection-fission/presale', presaleExternalRoutes);
cubejsApp.use('/api/external/selection-fission/delist', delistExternalRoutes);
cubejsApp.use('/api/campaign', campaignCloudRoutes);
cubejsApp.use('/api/external/selection-fission/pricing-calculator', pricingCalculatorExternalRoutes);
cubejsApp.use('/api/external/selection-fission/1688-products', alibaba1688ProductExternalRoutes);
cubejsApp.use('/api/external/selection-fission', planningSelectionFissionExternalRoutes);
cubejsApp.use('/api/external/presale', presaleExternalRoutes);
@ -655,10 +649,6 @@ async function startServer(options = {}) {
cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes);
cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes);
cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes);
cubejsApp.use('/api/cs-quality', authMiddleware, csQualityRoutes);
cubejsApp.use('/api/ai-buyer-show', authMiddleware, aiBuyerShowRoutes);
cubejsApp.use('/api/store-checkup', authMiddleware, storeCheckupRoutes);
cubejsApp.use('/api/pricing-calculator', authMiddleware, pricingCalculatorRoutes);
cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes);
cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes);
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);

View File

@ -1,375 +0,0 @@
const axios = require('axios');
const database = require('../config/database');
const DEFAULT_REVIEW_TOTAL = 10;
const REVIEW_TOTAL_OPTIONS = [5, 10, 20, 40, 60, 80, 100];
const REVIEW_BATCH_SIZE = 5;
const REVIEW_MAX_CONCURRENCY = 4;
const REVIEW_MAX_REFILL_ATTEMPTS = 3;
const SUPPORTED_SCENES = [
'卧室', '书房', '客厅', '餐厅', '阳台', '入户门', '玄关',
'办公室', '户外', '厨房', '电竞房',
];
const SUPPORTED_USER_TYPES = [
'自己', '妈妈', '爸爸', '儿子', '女儿', '老公', '老婆', '男朋友',
'女朋友', '闺蜜', '朋友', '同事', '室友', '老人', '孩子', '学生',
'上班族', '租房党', '新婚家庭', '宝妈', '宠物家庭', '客户', '领导',
];
function normalizeMultiInput(value) {
const raw = Array.isArray(value) ? value : String(value || '').split(/[,,、\n\r]+/);
return Array.from(new Set(raw.map((item) => String(item || '').trim()).filter(Boolean)));
}
function normalizeBoolean(value, fallback = true) {
if (value === undefined || value === null || value === '') return fallback;
if (typeof value === 'boolean') return value;
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
async function getSystemConfigMap(keys) {
try {
const placeholders = keys.map(() => '?').join(', ');
const rows = await database.local.query(
`SELECT config_key AS configKey, config_value AS configValue
FROM system_config
WHERE config_key IN (${placeholders})`,
keys
);
return new Map(rows.map((row) => [row.configKey, String(row.configValue || '').trim()]));
} catch (_) {
return new Map();
}
}
async function getRuntimeConfig() {
const keys = [
'ai_buyer_show_ark_api_key',
'ai_buyer_show_ark_base_url',
'ai_buyer_show_vision_model',
'ai_buyer_show_review_model',
];
const stored = await getSystemConfigMap(keys);
const apiKey = String(process.env.ARK_API_KEY || stored.get(keys[0]) || '').trim();
if (!apiKey) {
const error = new Error('未配置豆包 ARK_API_KEY,无法生成攻心评价');
error.statusCode = 503;
throw error;
}
const baseUrl = process.env.ARK_BASE_URL
|| stored.get(keys[1])
|| 'https://ark.cn-beijing.volces.com/api/v3';
const visionModel = process.env.VISION_MODEL
|| stored.get(keys[2])
|| 'doubao-1-5-vision-pro-32k-250115';
const reviewModel = process.env.PDD_HEART_REVIEW_MODEL
|| process.env.DOUBAO_TEXT_MODEL
|| stored.get(keys[3])
|| visionModel;
return { apiKey, baseUrl, visionModel, reviewModel };
}
function buildImageDataUrl(buffer, mimeType) {
const type = String(mimeType || '').startsWith('image/') ? mimeType : 'image/jpeg';
return `data:${type};base64,${buffer.toString('base64')}`;
}
function parseJsonContent(content, fallback) {
const trimmed = String(content || '')
.trim()
.replace(/^```json\s*/i, '')
.replace(/^```\s*/i, '')
.replace(/```$/i, '')
.trim();
if (!trimmed) return fallback;
try {
return JSON.parse(trimmed);
} catch (_) {
const candidate = trimmed.match(/\[[\s\S]*\]/)?.[0]
|| trimmed.match(/\{[\s\S]*\}/)?.[0];
if (!candidate) return fallback;
try {
return JSON.parse(candidate);
} catch (_error) {
return fallback;
}
}
}
async function callChatCompletion(messages, modelType, options = {}) {
const config = await getRuntimeConfig();
const model = modelType === 'vision' ? config.visionModel : config.reviewModel;
try {
const response = await axios.post(
`${String(config.baseUrl).replace(/\/$/, '')}/chat/completions`,
{
model,
messages,
temperature: options.temperature ?? 0.68,
...(options.maxTokens ? { max_tokens: options.maxTokens } : {}),
},
{
headers: {
Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
},
timeout: 180000,
}
);
return {
content: response.data?.choices?.[0]?.message?.content || '',
model: response.data?.model || model,
};
} catch (error) {
const detail = error.response?.data?.error?.message
|| error.response?.data?.message
|| error.response?.data?.error
|| error.message
|| 'AI接口调用失败';
const wrapped = new Error(`AI接口调用失败:${detail}`);
wrapped.statusCode = error.response?.status || 502;
throw wrapped;
}
}
async function analyzeImage({ imageBuffer, mimeType, scenes, userTypes, sellingPoints }) {
const prompt = [
'你是拼多多商品评价运营助手。请识别商品图片,并结合运营填写的信息,严格返回 JSON 对象。',
'只返回 JSON,不要 markdown,不要解释。',
'JSON 字段:productCategory、productNameGuess、furnitureColors、visualScene、visualSellingPoints、userPainPoints、emotionalAngles。',
`运营选择场景:${scenes.length ? scenes.join('、') : '未填写,请根据图片判断最合适场景'}`,
`运营选择使用人:${userTypes.length ? userTypes.join('、') : '自己购买给自己使用'}`,
`运营填写卖点:${sellingPoints}`,
].join('\n');
const result = await callChatCompletion([{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: buildImageDataUrl(imageBuffer, mimeType) } },
],
}], 'vision');
return { analysis: parseJsonContent(result.content, {}), model: result.model };
}
function normalizeReviewText(value) {
return String(value || '').replace(/\s+/g, ' ').replace(/^["'“”]+|["'“”]+$/g, '').trim();
}
function stripEmoji(value) {
return String(value || '')
.replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, '')
.replace(/\p{Extended_Pictographic}/gu, '')
.replace(/[\u{FE0E}\u{FE0F}\u{200D}]/gu, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
function normalizeReviewItem(value, useEmoji) {
const source = typeof value === 'string' ? { reviewContent: value } : (value || {});
const reviewContent = normalizeReviewText(
source.reviewContent || source.content || source.text || source.review || source['评价内容']
);
if (!reviewContent) return null;
return {
reviewContent: useEmoji ? reviewContent : stripEmoji(reviewContent),
userType: normalizeReviewText(source.userType || source.user || source.person || source['使用人']),
sellingPoint: normalizeReviewText(source.sellingPoint || source.selling_points || source.point || source['突出卖点']),
scene: normalizeReviewText(source.scene || source.usageScene || source['使用场景']),
};
}
function parseReviews(content, useEmoji) {
const parsed = parseJsonContent(content, null);
const source = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.reviews) ? parsed.reviews : []);
if (source.length) return source.map((item) => normalizeReviewItem(item, useEmoji)).filter(Boolean);
return String(content || '')
.split(/\n+/)
.map((line) => line.replace(/^\s*(\d+[.、)]|[-*])\s*/, ''))
.filter((line) => line.trim().length >= 30)
.map((line) => normalizeReviewItem(line, useEmoji))
.filter(Boolean);
}
function dedupeReviews(reviews) {
const seen = new Set();
return reviews.filter((review) => {
const key = review.reviewContent.replace(/[,。!?,.!?\s]/g, '').slice(0, 80);
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}
function diversifyOpenings(reviews) {
const counts = new Map();
const replacements = {
'说实话': ['其实,', '老实讲,', '用下来,'],
'本来': ['一开始', '之前', '原先'],
'我是': ['家里', '最近', '买回来后'],
};
return reviews.map((review) => {
const opening = Object.keys(replacements).find((item) => review.reviewContent.startsWith(item));
if (!opening) return review;
const used = counts.get(opening) || 0;
counts.set(opening, used + 1);
if (used === 0 && opening !== '我是') return review;
return {
...review,
reviewContent: review.reviewContent.replace(
new RegExp(`^${opening}[,,、\s]*`),
replacements[opening][used % replacements[opening].length]
),
};
});
}
async function generateReviewBatch({ count, batchIndex, scenes, userTypes, sellingPoints, analysis, useEmoji }) {
const colors = normalizeMultiInput(analysis?.furnitureColors || analysis?.colors);
const prompt = [
'生成拼多多家具真实买家评价。',
`1. 本批生成 ${count} 条。`,
'2. 每条 50-200 个中文字符。',
'3. 真实买家口吻,不要广告腔,不要出现“攻心”“卖点”“AI”。',
'4. 角度要有差异,覆盖使用前顾虑、场景、到手体验、细节、家人反馈。',
'5. 不要夸张承诺,不要医疗功效,不要绝对化表达。',
'6. 场景和使用人自然交叉组合。',
`7. 家具颜色只能使用这些图片识别颜色:${colors.length ? colors.join('、') : '未识别到颜色时不要主动描写颜色'}。`,
'8. 文风口语化,像真实用户随手写的评价,不要整齐的产品说明书语气。',
useEmoji
? '9. 允许少量使用 emoji,每条最多 0-2 个,不要每条都必须有。'
: '9. 严禁使用任何 emoji、颜文字或表情符号,一个都不要出现。',
'10. 只返回 JSON 数组,每个元素字段固定:reviewContent、userType、sellingPoint、scene。',
'11. 同一批开头必须分散,尽量不要使用“我是、本来、说实话、一开始、之前、之前还担心”开头。',
`批次:${batchIndex}`,
`可用场景:${scenes.length ? scenes.join('、') : analysis?.visualScene || '按图片判断'}`,
`可用使用人:${userTypes.length ? userTypes.join('、') : '自己购买给自己使用'}`,
`运营填写卖点:${sellingPoints}`,
`图片识别信息:${JSON.stringify(analysis || {})}`,
].join('\n');
const result = await callChatCompletion([{ role: 'user', content: prompt }], 'review', {
maxTokens: Math.min(4096, 240 * count),
});
return { reviews: parseReviews(result.content, useEmoji), prompt, model: result.model };
}
async function runWithLimit(tasks, limit, onResult) {
let cursor = 0;
async function worker() {
while (cursor < tasks.length) {
const index = cursor;
cursor += 1;
const result = await tasks[index]();
onResult(result, index);
}
}
await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, () => worker()));
}
async function generateHeartReviews({
imageBuffer, mimeType, scene, userType, sellingPoints, useEmoji, reviewTotal, onEvent,
}) {
const startedAt = Date.now();
const scenes = normalizeMultiInput(scene).filter((item) => SUPPORTED_SCENES.includes(item));
const userTypes = normalizeMultiInput(userType).filter((item) => SUPPORTED_USER_TYPES.includes(item));
const effectiveUserTypes = userTypes.length ? userTypes : ['自己购买给自己使用'];
const normalizedSellingPoints = String(sellingPoints || '').trim();
const targetTotal = REVIEW_TOTAL_OPTIONS.includes(Number(reviewTotal))
? Number(reviewTotal)
: DEFAULT_REVIEW_TOTAL;
const normalizedUseEmoji = normalizeBoolean(useEmoji, true);
if (!normalizedSellingPoints) {
const error = new Error('请填写商品卖点');
error.statusCode = 400;
throw error;
}
const vision = await analyzeImage({
imageBuffer, mimeType, scenes, userTypes: effectiveUserTypes, sellingPoints: normalizedSellingPoints,
});
onEvent?.({ type: 'analysis', imageAnalysis: vision.analysis });
let reviews = [];
let reviewPrompt = '';
let reviewModel = '';
const append = (batch) => {
if (!reviewPrompt) reviewPrompt = batch.prompt;
if (!reviewModel) reviewModel = batch.model;
reviews = dedupeReviews(diversifyOpenings([...reviews, ...batch.reviews])).slice(0, targetTotal);
onEvent?.({ type: 'batch', total: reviews.length, targetTotal });
};
const makeTasks = (count, prefix) => Array.from({ length: Math.ceil(count / REVIEW_BATCH_SIZE) }, (_, index) => {
const batchCount = Math.min(REVIEW_BATCH_SIZE, count - index * REVIEW_BATCH_SIZE);
return () => generateReviewBatch({
count: batchCount,
batchIndex: `${prefix}${index + 1}`,
scenes: scenes.length ? scenes : [vision.analysis?.visualScene].filter(Boolean),
userTypes: effectiveUserTypes,
sellingPoints: normalizedSellingPoints,
analysis: vision.analysis,
useEmoji: normalizedUseEmoji,
});
});
await runWithLimit(makeTasks(targetTotal, ''), REVIEW_MAX_CONCURRENCY, append);
for (let attempt = 1; reviews.length < targetTotal && attempt <= REVIEW_MAX_REFILL_ATTEMPTS; attempt += 1) {
await runWithLimit(makeTasks(targetTotal - reviews.length, `补充${attempt}-`), REVIEW_MAX_CONCURRENCY, append);
}
if (reviews.length < targetTotal) {
const error = new Error(`AI返回评价数量不足,仅生成 ${reviews.length} 条,请重试`);
error.statusCode = 502;
throw error;
}
return {
scene: scenes.length ? scenes.join('、') : (vision.analysis?.visualScene || 'AI识别场景'),
scenes,
userTypes: effectiveUserTypes,
sellingPoints: normalizedSellingPoints,
useEmoji: normalizedUseEmoji,
imageAnalysis: vision.analysis,
reviewPrompt,
reviews: reviews.slice(0, targetTotal),
total: targetTotal,
targetTotal,
responseMs: Date.now() - startedAt,
models: { vision: vision.model, review: reviewModel },
};
}
function buildBuyerShowPrompt({ review, analysis, style = '真实' }) {
const normalizedStyle = style === '饱和' ? '饱和' : '真实';
const productHints = normalizeMultiInput([
analysis?.productCategory,
analysis?.productNameGuess,
...(Array.isArray(analysis?.visualSellingPoints) ? analysis.visualSellingPoints : []),
]).join('、');
const styleRule = normalizedStyle === '饱和'
? '画面可以更精致、饱满、明亮,接近小红书达人分享风格,但商品主体仍必须真实可信,不能变成棚拍硬广、样板间或3D渲染图。'
: '普通买家用手机随手拍的真实照片,生活气息强,略微随意,普通室内自然光,不要商业摄影感、样板间、卖场图或渲染图。';
return [
`根据上传的商品参考图和下面这条买家评价,生成${normalizedStyle}风格的买家秀图片。系统会分 5 次生成 5 张单图,每次只生成其中一张照片。`,
`当前风格:${normalizedStyle}`,
'硬性要求:必须以参考图里的商品为唯一主体,严格保持商品外观、颜色、材质、比例、结构、边角、五金、纹理、开孔、抽屉、柜门、支脚、轮子等全部产品细节不变。不能改款、变形、新增或删减部件。商品参考图仅用于锁定商品本体,不允许沿用其房间、背景、软装和场景。',
'家具结构数量限制:柜体、柜门、抽屉、层板、支脚、把手、开孔、轮子、拉篮和五金件数量、位置、比例必须与参考图一致。',
'组图要求:5 张结果图必须像同一位买家在同一个房间、同一个时间围绕同一个商品连续拍摄。整体规划为 2 张完整商品照片 + 3 张局部细节照片。禁止拼图、分屏、九宫格和多画面排版。',
`买家评价:${review.reviewContent || ''}`,
`使用人:${review.userType || '按评价自然判断'}`,
`使用场景:${review.scene || '按评价自然判断'}`,
`突出卖点:${review.sellingPoint || '按评价自然提炼'}`,
productHints ? `图片识别到的商品信息:${productHints}` : '',
`${styleRule} 如果上传了场景参考图,必须以它作为唯一主要场景依据,尽量保留房间类型、空间布局、墙面地面、窗户、非主体家具、软装、生活物品、光线氛围和模特姿态;只有冲突或遮挡商品主体的元素才可轻微调整。没有场景参考图时,才根据评价重新构建生活化场景。场景适合时最好需要有中国模特自然出镜,不能露脸,不能遮挡商品。输出 4:3,1K。`,
].filter(Boolean).join('\n');
}
module.exports = {
DEFAULT_REVIEW_TOTAL,
REVIEW_TOTAL_OPTIONS,
SUPPORTED_SCENES,
SUPPORTED_USER_TYPES,
buildBuyerShowPrompt,
generateHeartReviews,
};

View File

@ -1,237 +0,0 @@
const crypto = require('crypto');
const {
getPool,
query,
resolveUser,
} = require('./aiBuyerShowService');
const { uploadBufferToDefaultS3 } = require('./objectStorage');
const SEED_PACK_TABLE = 'pdd_seed_resource_packs';
const MAX_SEED_PACK_FILE_BYTES = 200 * 1024 * 1024;
let tableReady = false;
function normalizeString(value) {
return String(value || '').trim();
}
function normalizeNullableString(value) {
const text = normalizeString(value);
return text || null;
}
// multer/busboy may decode multipart filenames as latin1, garbling non-ASCII
// names; re-encode to recover the original UTF-8 text.
function normalizeUploadFileName(value) {
const text = normalizeString(value);
if (!text) return '';
try {
const decoded = Buffer.from(text, 'latin1').toString('utf8');
return /[\uFFFD]/.test(decoded) ? text : decoded;
} catch (_) {
return text;
}
}
async function ensureSeedPackTable() {
if (tableReady) return;
await query(
`CREATE TABLE IF NOT EXISTS ${SEED_PACK_TABLE} (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '资源包ID',
pack_uid VARCHAR(64) NOT NULL COMMENT '资源包唯一标识',
shop_name VARCHAR(128) NOT NULL COMMENT '店铺名称',
product_id VARCHAR(64) NOT NULL COMMENT '商品ID',
sku_name VARCHAR(255) NULL COMMENT 'SKU名称',
file_name VARCHAR(255) NOT NULL COMMENT 'ZIP文件名',
file_size BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小(字节)',
object_key VARCHAR(512) NOT NULL COMMENT '对象存储Key',
file_url VARCHAR(1024) NULL COMMENT '公开下载地址',
review_count INT NOT NULL DEFAULT 0 COMMENT '包含评价条数',
operator_id VARCHAR(64) NULL COMMENT '上传人ID',
operator_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '上传人',
download_count INT NOT NULL DEFAULT 0 COMMENT '下载次数',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_pack_uid (pack_uid),
KEY idx_seed_pack_shop (shop_name),
KEY idx_seed_pack_product (product_id),
KEY idx_seed_pack_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='种菜资源包'`
);
tableReady = true;
}
function normalizePackRow(row = {}) {
return {
id: Number(row.id),
packUid: row.pack_uid ?? row.packUid ?? '',
shopName: row.shop_name ?? row.shopName ?? '',
productId: row.product_id ?? row.productId ?? '',
skuName: row.sku_name ?? row.skuName ?? '',
fileName: row.file_name ?? row.fileName ?? '',
fileSize: Number(row.file_size ?? row.fileSize ?? 0),
fileUrl: row.file_url ?? row.fileUrl ?? '',
reviewCount: Number(row.review_count ?? row.reviewCount ?? 0),
operatorName: row.operator_name ?? row.operatorName ?? '',
downloadCount: Number(row.download_count ?? row.downloadCount ?? 0),
createdAt: row.created_at || null,
};
}
function validateSeedPackInput({ shopName, productId, file }) {
if (!normalizeString(shopName)) {
const error = new Error('店铺名称不能为空');
error.statusCode = 400;
return error;
}
if (!normalizeString(productId)) {
const error = new Error('商品ID不能为空');
error.statusCode = 400;
return error;
}
if (!file?.buffer || !file.buffer.length) {
const error = new Error('ZIP 文件不能为空');
error.statusCode = 400;
return error;
}
if (file.buffer.length > MAX_SEED_PACK_FILE_BYTES) {
const error = new Error('ZIP 文件不能超过 200MB');
error.statusCode = 413;
return error;
}
return null;
}
async function createSeedPack({ shopName, productId, skuName, fileName, reviewCount, file }, user = {}) {
await ensureSeedPackTable();
const operator = resolveUser(user);
const validationError = validateSeedPackInput({ shopName, productId, file });
if (validationError) throw validationError;
const uploaded = await uploadBufferToDefaultS3(
file.buffer,
'application/zip',
'seed-packs',
'zip'
);
const resolvedFileName = normalizeString(fileName)
|| normalizeUploadFileName(file?.originalname)
|| `种菜资源包_${Date.now()}.zip`;
const packUid = crypto.randomUUID();
const insertRows = await query(
`INSERT INTO ${SEED_PACK_TABLE} (
pack_uid, shop_name, product_id, sku_name, file_name, file_size,
object_key, file_url, review_count, operator_id, operator_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
packUid,
normalizeString(shopName).slice(0, 128),
normalizeString(productId).slice(0, 64),
normalizeNullableString(String(skuName || '').slice(0, 255)),
resolvedFileName.slice(0, 255),
file.buffer.length,
uploaded.key,
uploaded.url || '',
Math.max(Number(reviewCount) || 0, 0),
operator.id || null,
operator.name,
]
);
const rows = await query(
`SELECT * FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`,
[insertRows.insertId]
);
return normalizePackRow(rows[0]);
}
async function listSeedPacks({ page = 1, pageSize = 15, keyword = '' } = {}) {
await ensureSeedPackTable();
const normalizedPage = Math.max(Number(page) || 1, 1);
const normalizedPageSize = Math.min(Math.max(Number(pageSize) || 15, 1), 100);
const offset = (normalizedPage - 1) * normalizedPageSize;
const normalizedKeyword = normalizeString(keyword);
const likeKeyword = `%${normalizedKeyword}%`;
const whereSql = normalizedKeyword
? 'WHERE shop_name LIKE ? OR product_id LIKE ? OR sku_name LIKE ? OR operator_name LIKE ? OR file_name LIKE ?'
: '';
const params = normalizedKeyword ? Array(5).fill(likeKeyword) : [];
const countRows = await query(
`SELECT COUNT(*) AS total FROM ${SEED_PACK_TABLE} ${whereSql}`,
params
);
const rows = await query(
`SELECT * FROM ${SEED_PACK_TABLE}
${whereSql}
ORDER BY id DESC
LIMIT ? OFFSET ?`,
[...params, normalizedPageSize, offset]
);
return {
rows: rows.map(normalizePackRow),
total: Number(countRows[0]?.total || 0),
page: normalizedPage,
pageSize: normalizedPageSize,
};
}
async function getSeedPackDownload(id) {
await ensureSeedPackTable();
const packId = Number(id);
if (!Number.isFinite(packId) || packId <= 0) {
const error = new Error('资源包不存在');
error.statusCode = 404;
throw error;
}
const rows = await query(
`SELECT * FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`,
[packId]
);
const pack = rows[0];
if (!pack) {
const error = new Error('资源包不存在');
error.statusCode = 404;
throw error;
}
await query(
`UPDATE ${SEED_PACK_TABLE} SET download_count = download_count + 1 WHERE id = ?`,
[packId]
);
return {
...normalizePackRow(pack),
downloadCount: Number(pack.downloadCount || 0) + 1,
};
}
async function deleteSeedPack(id) {
await ensureSeedPackTable();
const packId = Number(id);
if (!Number.isFinite(packId) || packId <= 0) {
const error = new Error('资源包不存在');
error.statusCode = 404;
throw error;
}
const rows = await query(
`SELECT id FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`,
[packId]
);
if (!rows.length) {
const error = new Error('资源包不存在');
error.statusCode = 404;
throw error;
}
await query(`DELETE FROM ${SEED_PACK_TABLE} WHERE id = ?`, [packId]);
return { id: packId };
}
module.exports = {
createSeedPack,
deleteSeedPack,
getSeedPackDownload,
listSeedPacks,
MAX_SEED_PACK_FILE_BYTES,
};

File diff suppressed because it is too large Load Diff

View File

@ -1,111 +0,0 @@
'use strict';
const PRE_SALE_LABELS = [
'库存备货:是否有货',
'发货问题:发货地、发货速度、缺货问题、催发货、延期发货',
'批量外贸单:贴唛头',
'快递问题:是否包邮、指定快递、送货上门、物流速度、配送速度、到货时间',
'产品问题:是否防潮/防火、是否有背板/轨道、材质的区别、产地问题、质保时效、尺寸问题、板材厚度、是否支持定制、承重性',
'产品环保问题:检测报告、气味问题',
'价格问题:优惠券问题、买后比价、商品降价',
'下单错误:多拍错拍、地址/电话信息错误',
'决策信息不充分:尺码/尺寸/规格不合适',
'安装问题:安装说明书/安装视频、是否包安装、是否整装发货',
'发票问题:是否有发票、多开/少开发票、催发票',
];
const AFTER_SALE_LABELS = [
'发货履约问题:发货异常、虚假发货',
'物流配送问题:更新超时、丢件、派送超时、送货异常、退货到仓',
'商品交付问题:少发漏发、错发、运输产品破损',
'商品质量问题:产品质量、描述不符',
'安装服务问题:安装咨询、上门安装',
'客服服务问题:回复超时、服务态度极差、承诺未履行、操作错误',
'退换货问题:上门取件',
'平台业务问题:铺货错误、种菜异常',
'其他售后:其他',
];
function buildSystemPrompt() {
return [
'你是拼多多电商客服聊天质检与商品问题分析专家。',
'你的任务是分析一个商品ID在指定日期范围内的全部客服聊天,识别售前和售后诉求,并把结果聚合成可用于产品改进、详情页优化和售后管理的结构化数据。',
'',
'【发言方识别】',
'1. 每条消息已经提供“买家”或“客服”角色,优先使用该角色。',
'2. 客服寒暄、安抚、礼貌语和标准话术不能单独作为证据;客服明确确认已超时、已登记、已拒绝、承诺未履行时可辅助举证。',
'3. 归因以买家诉求为主,不能把客服建议或猜测当成买家问题。',
'',
'【售前/售后判定】',
'1. 出现“订单编号:xxx”“订单号:xxx”或订单卡片后,从该位置开始的后续买家内容按售后语境处理;之前内容独立判断。',
'2. 未出现订单号时,根据语义判断:下单前的购买决策咨询属于售前;已付款、已收货或明确反馈已发生问题属于售后。',
'3. “多久发货”在下单前是售前;“为什么还不发货”或“催发货”通常是售后。',
'4. 同一诉求无法区分时售后优先。否定、假设、举例、转述他人或已经解决的问题不能作为当前问题。',
'',
'【售前标签白名单】',
...PRE_SALE_LABELS,
'售前无法匹配时使用 primary_reason=“未知”、secondary_reason=“其他问题”。',
'',
'【售后标签白名单】',
...AFTER_SALE_LABELS,
'售后无法匹配时使用 primary_reason=“其他售后”、secondary_reason=“其他”。',
'',
'【标签边界】',
'商品与页面描述、宣传或明确承诺不一致,使用“商品质量问题/描述不符”;商品本身缺陷、故障、开裂、变形,使用“商品质量问题/产品质量”;发错型号、款式、颜色或规格,使用“商品交付问题/错发”;缺少商品或配件,使用“商品交付问题/少发漏发”;运输造成磕碰、断裂或包装损坏,使用“商品交付问题/运输产品破损”。',
'',
'【聚合规则】',
'1. 相同一级原因+二级原因的诉求合并为一条结果,统计 occurrence_count 和 affected_customer_count。',
'2. 不同语义的独立问题分别输出,例如少发漏发和运输破损不能合并。',
'3. 每条结果最多保留3条最有代表性的买家原话证据,证据不得改写或拼接。',
'4. affected_conversation_ids 必须列出当前问题涉及的全部会话ID(uid),不得只列关键证据中的客户,也不得填入无关客户。',
'5. 买家消息中的图片URL只提取与当前问题直接相关的图片,每条结果最多5张,必须原样保留。',
'6. product_improvement_areas只能从:产品本体、规格配置、宣传主图、详情页、客服话术、价格策略、库存备货、履约物流、安装服务、售后服务、无需改进 中选择。',
'',
'【严格输出】',
'只能输出合法JSON,不要输出Markdown代码块、解释或额外文字。JSON结构必须是:',
JSON.stringify({
schema_version: '1.0',
conversation_type: '售前|售后|混合|无有效咨询',
summary: {
conversation_count: 0,
pre_sale_conversation_count: 0,
after_sale_conversation_count: 0,
mixed_conversation_count: 0,
main_findings: [],
},
results: [{
consultation_type: '售前|售后',
primary_reason: '一级原因',
secondary_reason: '二级原因',
occurrence_count: 1,
affected_customer_count: 1,
affected_conversation_ids: ['uid'],
buyer_intent: '买家诉求概括',
evidence: [{ conversation_id: 'uid', nickname: '买家昵称', message_time: '消息时间', text: '买家原文' }],
image_urls: [],
product_improvement_areas: [],
improvement_suggestion: '',
confidence: 0.95,
}],
}, null, 2),
].join('\n');
}
function buildUserPrompt({ product = {}, metrics = {}, chatText = '', startDate, endDate }) {
return [
`请分析商品ID:${product.goodsId || ''}`,
`商品名称:${product.goodsName || ''}`,
`所属店铺:${product.shopName || ''}`,
`图片地址:${product.hdThumbUrl || ''}`,
`分析日期:${startDate} 至 ${endDate}`,
`商品表现指标:${JSON.stringify(metrics)}`,
'',
'以下是该商品在指定日期范围内的聊天记录。每条记录已经标注角色,请结合上下文完成售前/售后识别、标签映射和问题聚合。',
chatText,
].join('\n');
}
module.exports = {
buildSystemPrompt,
buildUserPrompt,
};

File diff suppressed because it is too large Load Diff

View File

@ -1,254 +0,0 @@
const database = require('../config/database');
const DESKTOP_RELEASE_CONFIG_KEY = 'desktop_client_releases';
const MAX_RELEASE_HISTORY = 50;
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
const SUPPORTED_PLATFORMS = new Set(['win32', 'darwin']);
const SUPPORTED_ARCHITECTURES = new Set(['x64', 'arm64', 'universal']);
function normalizeVersion(value) {
return String(value || '').trim().replace(/^v/i, '');
}
function versionParts(value) {
const normalized = normalizeVersion(value);
if (!VERSION_PATTERN.test(normalized)) return null;
return normalized.split('.').map((part) => Number(part));
}
function compareVersions(left, right) {
const leftParts = versionParts(left);
const rightParts = versionParts(right);
if (!leftParts || !rightParts) return 0;
for (let index = 0; index < 3; index += 1) {
if (leftParts[index] !== rightParts[index]) {
return leftParts[index] > rightParts[index] ? 1 : -1;
}
}
return 0;
}
function validateVersion(value) {
const version = normalizeVersion(value);
if (!VERSION_PATTERN.test(version)) {
throw new Error('版本号格式必须为 x.y.z,例如 0.0.9');
}
return version;
}
function normalizePlatform(value, fileName = '') {
const normalized = String(value || '').trim().toLowerCase();
if (['darwin', 'mac', 'macos', 'osx'].includes(normalized)) return 'darwin';
if (['win32', 'win', 'windows'].includes(normalized)) return 'win32';
if (String(fileName || '').toLowerCase().endsWith('.dmg')) return 'darwin';
return 'win32';
}
function normalizeArchitecture(value, fileName = '', platform = '') {
const normalized = String(value || '').trim().toLowerCase();
if (SUPPORTED_ARCHITECTURES.has(normalized)) return normalized;
const fileMatch = String(fileName || '').toLowerCase().match(/[-_.](arm64|x64|universal)(?=[-_.]|$)/);
if (fileMatch) return fileMatch[1];
return normalizePlatform(platform, fileName) === 'darwin' ? 'arm64' : 'x64';
}
function normalizeStoredRelease(value) {
if (!value || typeof value !== 'object') return null;
const version = normalizeVersion(value.version);
if (!VERSION_PATTERN.test(version) || !value.objectKey) return null;
const platform = normalizePlatform(value.platform, value.fileName || value.objectKey);
const architecture = normalizeArchitecture(value.architecture, value.fileName || value.objectKey, platform);
const defaultFileName = platform === 'darwin'
? `pdd-auto-sale-${version}-${architecture}.dmg`
: `pdd-auto-sale-${version}-setup.exe`;
return {
id: Number(value.id) || Date.now(),
version,
releaseNotes: String(value.releaseNotes || ''),
objectKey: String(value.objectKey),
platform,
architecture,
fileName: String(value.fileName || defaultFileName),
fileSize: Number(value.fileSize || 0),
sha256: String(value.sha256 || ''),
publishedByUserId: value.publishedByUserId == null ? null : Number(value.publishedByUserId),
publishedByName: String(value.publishedByName || ''),
publishedAt: value.publishedAt || value.createdAt || null,
createdAt: value.createdAt || value.publishedAt || null,
updatedAt: value.updatedAt || value.publishedAt || null,
};
}
function parseReleaseStorage(value) {
try {
const parsed = typeof value === 'string' ? JSON.parse(value || '{}') : value;
const releases = Array.isArray(parsed?.releases)
? parsed.releases.map(normalizeStoredRelease).filter(Boolean)
: [];
const storedLatestVersions = parsed?.latestVersions && typeof parsed.latestVersions === 'object'
? parsed.latestVersions
: {};
const latestVersions = {};
for (const platform of SUPPORTED_PLATFORMS) {
const version = normalizeVersion(storedLatestVersions[platform]);
if (VERSION_PATTERN.test(version)) latestVersions[platform] = version;
}
const legacyLatestVersion = normalizeVersion(parsed?.latestVersion);
if (!latestVersions.win32 && VERSION_PATTERN.test(legacyLatestVersion)) {
latestVersions.win32 = legacyLatestVersion;
}
for (const release of releases) {
if (!latestVersions[release.platform]) latestVersions[release.platform] = release.version;
}
return {
latestVersion: latestVersions.win32 || legacyLatestVersion,
latestVersions,
releases,
};
} catch {
return { latestVersion: '', latestVersions: {}, releases: [] };
}
}
async function loadReleaseStorage() {
const rows = await database.local.query(
'SELECT config_value FROM system_config WHERE config_key = ? LIMIT 1',
[DESKTOP_RELEASE_CONFIG_KEY]
);
return parseReleaseStorage(rows[0]?.config_value);
}
async function saveReleaseStorage(storage) {
const value = JSON.stringify(storage);
const existing = await database.local.query(
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
[DESKTOP_RELEASE_CONFIG_KEY]
);
if (existing.length) {
await database.local.query(
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
[value, 'json', DESKTOP_RELEASE_CONFIG_KEY]
);
return;
}
await database.local.insert('system_config', {
config_key: DESKTOP_RELEASE_CONFIG_KEY,
config_value: value,
config_type: 'json',
});
}
async function ensureDesktopReleaseSchema() {
// Release metadata uses system_config because the runtime account cannot create tables.
}
function mapRelease(release, latestVersion = '') {
const normalized = normalizeStoredRelease(release);
if (!normalized) return null;
return {
...normalized,
isLatest: normalized.version === normalizeVersion(latestVersion),
};
}
async function getLatestDesktopRelease(platform = 'win32', architecture = '') {
const storage = await loadReleaseStorage();
const targetPlatform = normalizePlatform(platform);
const targetArchitecture = architecture
? normalizeArchitecture(architecture, '', targetPlatform)
: '';
const platformReleases = storage.releases
.filter((release) => release.platform === targetPlatform)
.sort((left, right) => (
compareVersions(right.version, left.version)
|| String(right.publishedAt || '').localeCompare(String(left.publishedAt || ''))
));
const latestVersion = storage.latestVersions[targetPlatform] || platformReleases[0]?.version || '';
const versionReleases = platformReleases.filter((release) => release.version === latestVersion);
const latest = (targetArchitecture
? versionReleases.find((release) => release.architecture === targetArchitecture)
|| versionReleases.find((release) => release.architecture === 'universal')
: null)
|| versionReleases[0]
|| platformReleases[0]
|| null;
return latest ? mapRelease(latest, latestVersion || latest.version) : null;
}
async function getDesktopReleaseById(id) {
const storage = await loadReleaseStorage();
const release = storage.releases.find((item) => String(item.id) === String(id));
return release ? mapRelease(release, storage.latestVersions[release.platform]) : null;
}
async function listDesktopReleases(limit = 50) {
const storage = await loadReleaseStorage();
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 100);
return storage.releases
.map((release) => mapRelease(release, storage.latestVersions[release.platform]))
.filter(Boolean)
.sort((left, right) => {
if (left.isLatest !== right.isLatest) return left.isLatest ? -1 : 1;
return String(right.publishedAt || '').localeCompare(String(left.publishedAt || ''));
})
.slice(0, safeLimit);
}
async function publishDesktopRelease(input) {
const version = validateVersion(input.version);
const storage = await loadReleaseStorage();
const platform = normalizePlatform(input.platform, input.fileName || input.objectKey);
const architecture = normalizeArchitecture(input.architecture, input.fileName || input.objectKey, platform);
const existing = storage.releases.find((release) => (
release.version === version
&& release.platform === platform
&& release.architecture === architecture
));
const now = new Date().toISOString();
const release = {
id: existing?.id || Date.now(),
version,
platform,
architecture,
releaseNotes: input.releaseNotes || '',
objectKey: input.objectKey,
fileName: input.fileName,
fileSize: Number(input.fileSize || 0),
sha256: input.sha256,
publishedByUserId: input.publishedByUserId || null,
publishedByName: input.publishedByName || '',
publishedAt: now,
createdAt: existing?.createdAt || now,
updatedAt: now,
};
const releases = [
release,
...storage.releases.filter((item) => !(
item.version === version
&& item.platform === platform
&& item.architecture === architecture
)),
].slice(0, MAX_RELEASE_HISTORY);
const latestVersions = { ...storage.latestVersions, [platform]: version };
await saveReleaseStorage({
latestVersion: latestVersions.win32 || version,
latestVersions,
releases,
});
return mapRelease(release, version);
}
module.exports = {
compareVersions,
ensureDesktopReleaseSchema,
getDesktopReleaseById,
getLatestDesktopRelease,
listDesktopReleases,
mapRelease,
normalizeVersion,
normalizeArchitecture,
normalizePlatform,
parseReleaseStorage,
publishDesktopRelease,
validateVersion,
};

View File

@ -66,7 +66,7 @@ function buildDesktopCapabilities(user) {
const allowed = new Set(Array.isArray(user?.allowedPageKeys) ? user.allowedPageKeys : []);
const isAdmin = user?.role === 'admin';
const canPlan = isAdmin || allowed.has('planning_upload');
const canUseMarket = isAdmin || allowed.has('market_insight') || canPlan;
const canUseMarket = isAdmin || canPlan;
return {
controlCenter: true,
@ -80,7 +80,6 @@ function buildDesktopCapabilities(user) {
marketingEvents: isAdmin || allowed.has('marketing_events'),
promotion: isAdmin || allowed.has('promotion_module'),
productTest: isAdmin || allowed.has('new_product_test'),
reviewAnalysis: isAdmin || allowed.has('review_analysis'),
};
}

View File

@ -3,7 +3,6 @@ const { cleanupRetiredFeatureSettings } = require('./retiredFeatures');
const { ensureDepartmentPagePermissions, ensureSidebarRolePermissions } = require('./pageAccess');
const { repairDuplicateExternalDepartments } = require('./socialAuth');
const { ensureSetupConfigDefaults } = require('./setupWizard');
const { ensureDesktopReleaseSchema } = require('./desktopReleaseService');
const { ensureDesktopPresenceSchema } = require('./desktopPresenceService');
const { ensureDesktopExecutionTaskSchema } = require('./desktopExecutionTaskService');
const { ensureThirdPartySsoSchema } = require('./thirdPartySsoService');
@ -119,6 +118,18 @@ async function ensureTable(tableName, createSql) {
}
}
async function tableExists(tableName) {
const rows = await database.local.query(
`SELECT 1
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = ?
LIMIT 1`,
[tableName]
);
return rows.length > 0;
}
async function indexExists(tableName, indexName) {
const rows = await database.local.query(
`SELECT 1
@ -279,7 +290,27 @@ async function ensureLocalSchema() {
UNIQUE KEY uk_ops_b_period_scope (scope_start, scope_end)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`
);
await ensureDesktopReleaseSchema();
await ensureTable(
'product_analysis_user_prefs',
`CREATE TABLE IF NOT EXISTS product_analysis_user_prefs (
user_id BIGINT PRIMARY KEY COMMENT '用户ID',
prefs_json VARCHAR(2000) NOT NULL COMMENT '个性化配置 JSON',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分析用户个性化配置'`
);
if (await tableExists('pricing_calculator_user_prefs')) {
await database.local.query(
`INSERT INTO product_analysis_user_prefs (user_id, prefs_json, created_at, updated_at)
SELECT user_id, prefs_json, created_at, updated_at
FROM pricing_calculator_user_prefs
WHERE JSON_VALID(prefs_json)
AND JSON_EXTRACT(prefs_json, '$.productAnalysisSignalThresholds') IS NOT NULL
ON DUPLICATE KEY UPDATE
prefs_json = VALUES(prefs_json),
updated_at = VALUES(updated_at)`
);
}
await ensureDesktopPresenceSchema();
await ensureDesktopExecutionTaskSchema();
await ensureThirdPartySsoSchema();

View File

@ -1,14 +1,31 @@
const RETIRED_PAGE_KEYS = ['planning_upload', 'market_insight', 'agent', 'ai_skill_tags'];
const RETIRED_ENTRY_KEYS = new Set(['market-insight', 'agent', 'ai-skill-tags', 'platform-switch']);
const RETIRED_PAGE_KEYS = [
'planning_upload', 'market_insight', 'agent', 'ai_skill_tags',
'store_checkup', 'review_analysis', 'customer_service_checkup',
'pricing_calculator', 'desktop_client_download', 'ai_buyer_show', 'ai_seeding_video',
];
const RETIRED_ENTRY_KEYS = new Set([
'market-insight', 'agent', 'ai-skill-tags', 'platform-switch',
'store-management', 'store-checkup', 'review-analysis', 'customer-service-checkup',
'operation-tools', 'pricing-calculator', 'desktop-client-download', 'ai-buyer-show', 'ai-seeding-video',
'desktop-client-releases',
]);
const RETIRED_CONFIG_KEYS = [
'openclaw_chat_config', 'openclaw_chat_skill_tags',
'selection_similar_products_config', 'alibaba_1688_image_search_config',
'cs_quality_ai_config', 'desktop_client_releases',
'ai_buyer_show_ark_api_key', 'ai_buyer_show_ark_base_url',
'ai_buyer_show_vision_model', 'ai_buyer_show_review_model',
];
const RETIRED_DATABASE_CONFIG_NAMES = ['ai_buyer_show_database'];
function isRetiredSystemConfigKey(key) {
return RETIRED_CONFIG_KEYS.includes(String(key || '').trim());
}
function isRetiredDatabaseConfigName(name) {
return RETIRED_DATABASE_CONFIG_NAMES.includes(String(name || '').trim());
}
function pruneRetiredSidebarConfig(value) {
try {
const config = typeof value === 'string' ? JSON.parse(value) : value;
@ -28,6 +45,9 @@ async function cleanupRetiredFeatureSettings(db) {
await db.query(`DELETE FROM ${table} WHERE page_key IN (${RETIRED_PAGE_KEYS.map(() => '?').join(',')})`, RETIRED_PAGE_KEYS);
}
await db.query(`DELETE FROM system_config WHERE config_key IN (${RETIRED_CONFIG_KEYS.map(() => '?').join(',')})`, RETIRED_CONFIG_KEYS);
await db.query(`DELETE FROM database_configs WHERE name IN (${RETIRED_DATABASE_CONFIG_NAMES.map(() => '?').join(',')})`, RETIRED_DATABASE_CONFIG_NAMES);
await db.query('DROP TABLE IF EXISTS pricing_calculator_history');
await db.query('DROP TABLE IF EXISTS pricing_calculator_user_prefs');
const rows = await db.query("SELECT config_value FROM system_config WHERE config_key = ?", ['sidebar_navigation']);
if (rows.length) {
const current = rows[0].config_value;
@ -36,4 +56,12 @@ async function cleanupRetiredFeatureSettings(db) {
}
}
module.exports = { RETIRED_PAGE_KEYS, RETIRED_CONFIG_KEYS, isRetiredSystemConfigKey, pruneRetiredSidebarConfig, cleanupRetiredFeatureSettings };
module.exports = {
RETIRED_PAGE_KEYS,
RETIRED_CONFIG_KEYS,
RETIRED_DATABASE_CONFIG_NAMES,
isRetiredSystemConfigKey,
isRetiredDatabaseConfigName,
pruneRetiredSidebarConfig,
cleanupRetiredFeatureSettings,
};

View File

@ -1,255 +0,0 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
QWEN_PROVIDER,
AGNES_PROVIDER,
DEFAULT_QWEN_BASE_URL,
DEFAULT_QWEN_MODEL,
DEFAULT_AGNES_BASE_URL,
DEFAULT_AGNES_MODEL,
_test: {
resolveAiConfig,
buildAiRequestPayload,
parseModelJson,
mapChatDataMeta,
aggregateChatDimensionRows,
createChatBatches,
normalizeAnalysis,
mergeBatchAnalysesLocally,
reconcileMergedAnalysis,
sumUsages,
getAiErrorDetail,
isRetryableAiError,
getAiRetryDelayMs,
sanitizePublicError,
},
} = require('../src/services/csQualityService');
test('chat data metadata exposes the actual available date range', () => {
assert.deepEqual(mapChatDataMeta({
minDate: '2026-06-22',
latestDate: '2026-07-31 23:59:59',
messageCount: '792771',
}), {
tableName: 'rpt.pdd_GoodsChatMessages',
minDate: '2026-06-22',
latestDate: '2026-07-31',
messageCount: 792771,
});
});
function message(uid, content, index = 1) {
return {
uid,
nickname: `买家-${uid}`,
msgId: `${uid}-${index}`,
msgTime: `2026-07-30 10:00:${String(index).padStart(2, '0')}`,
directionLabel: '买家',
csname: '客服A',
content,
};
}
function result(overrides = {}) {
return {
consultation_type: '售前',
primary_reason: '产品问题',
secondary_reason: '尺寸问题',
occurrence_count: 1,
affected_customer_count: 1,
affected_conversation_ids: ['u1'],
buyer_intent: '咨询尺寸',
evidence: [{ conversation_id: 'u1', nickname: '买家-u1', message_time: '2026-07-30 10:00:01', text: '多高' }],
image_urls: [],
product_improvement_areas: ['详情页'],
improvement_suggestion: '补充尺寸图',
confidence: 0.9,
...overrides,
};
}
test('customer-service quality defaults to the verified Qwen model and endpoint', () => {
const config = resolveAiConfig({ apiKey: 'test-key' });
assert.equal(QWEN_PROVIDER, 'aliyun-model-studio');
assert.equal(config.provider, QWEN_PROVIDER);
assert.equal(config.baseUrl, DEFAULT_QWEN_BASE_URL);
assert.equal(config.model, DEFAULT_QWEN_MODEL);
assert.equal(config.batchConcurrency, 2);
assert.equal(config.batchMaxChars, 45000);
assert.equal(config.mergeMaxBatches, 8);
assert.equal(config.requestRetryCount, 2);
assert.equal(config.retryBaseDelayMs, 1500);
});
test('customer-service quality resolves the Agnes China endpoint and request format', () => {
const config = resolveAiConfig({
provider: AGNES_PROVIDER,
apiKey: 'test-key',
});
assert.equal(config.provider, AGNES_PROVIDER);
assert.equal(config.baseUrl, DEFAULT_AGNES_BASE_URL);
assert.equal(config.model, DEFAULT_AGNES_MODEL);
assert.equal(config.timeoutMs, 600000);
const payload = buildAiRequestPayload(config, [{ role: 'user', content: 'test' }], 2048);
assert.equal(payload.model, DEFAULT_AGNES_MODEL);
assert.equal(payload.max_tokens, 2048);
assert.equal(payload.temperature, 0);
assert.equal(payload.stream, false);
assert.equal('max_completion_tokens' in payload, false);
assert.equal('response_format' in payload, false);
});
test('Qwen requests retain the provider-specific JSON parameters', () => {
const config = resolveAiConfig({ apiKey: 'test-key' });
const payload = buildAiRequestPayload(config, [{ role: 'user', content: 'test' }], 2048);
assert.equal(payload.max_completion_tokens, 2048);
assert.deepEqual(payload.response_format, { type: 'json_object' });
assert.equal(payload.enable_thinking, false);
assert.equal('max_tokens' in payload, false);
});
test('chat batching keeps every customer conversation intact', () => {
const rows = [
message('u1', '甲'.repeat(700), 1),
message('u1', '乙'.repeat(700), 2),
message('u2', '丙'.repeat(700), 1),
message('u3', '丁'.repeat(700), 1),
];
const batches = createChatBatches(rows, 1300);
assert.equal(batches.length, 3);
assert.deepEqual(batches[0].conversationIds, ['u1']);
assert.equal(batches[0].messageCount, 2);
assert.ok(batches.every((batch) => !(
batch.conversationIds.includes('u1') && batch.conversationIds.length > 1
)));
assert.deepEqual(batches.flatMap((batch) => batch.conversationIds), ['u1', 'u2', 'u3']);
assert.equal(batches[0].startDate, '2026-07-30');
assert.equal(batches[0].endDate, '2026-07-30');
});
test('chat dimension snapshot derives goods and customers in one aggregation', () => {
const snapshot = aggregateChatDimensionRows([
{
goodsId: 'g1', uid: 'u1', csname: '客服A', goodsName: '商品1', shopName: '店铺1',
nickname: '客户1', messageCount: 2, firstMsgTime: '2026-07-29T08:00:00.000Z', lastMsgTime: '2026-07-29T08:01:00.000Z',
},
{
goodsId: 'g1', uid: 'u1', csname: '客服B', goodsName: '商品1', shopName: '店铺1',
nickname: '客户1', messageCount: 3, firstMsgTime: '2026-07-29T08:02:00.000Z', lastMsgTime: '2026-07-29T08:04:00.000Z',
},
{
goodsId: 'g1', uid: 'u2', csname: '客服A', goodsName: '商品1', shopName: '店铺1',
nickname: '客户2', messageCount: 1, firstMsgTime: '2026-07-29T09:00:00.000Z', lastMsgTime: '2026-07-29T09:00:00.000Z',
},
{
goodsId: 'g2', uid: 'u3', csname: '客服C', goodsName: '商品2', shopName: '店铺2',
nickname: '客户3', messageCount: 4, firstMsgTime: '2026-07-29T10:00:00.000Z', lastMsgTime: '2026-07-29T10:03:00.000Z',
},
]);
assert.equal(snapshot.goodsRows.length, 2);
assert.equal(snapshot.goodsRows[0].goodsId, 'g1');
assert.equal(snapshot.goodsRows[0].messageCount, 6);
assert.equal(snapshot.goodsRows[0].userCount, 2);
assert.equal(snapshot.goodsRows[0].csCount, 2);
assert.equal(snapshot.customersByGoods.g1.length, 2);
assert.equal(snapshot.customersByGoods.g1[0].uid, 'u1');
assert.equal(snapshot.customersByGoods.g1[0].messageCount, 5);
assert.equal(snapshot.customersByGoods.g1[0].csname, '客服B');
assert.equal(snapshot.customersByGoods.g1[0].firstMsgTime, '2026-07-29T08:00:00.000Z');
assert.equal(snapshot.customersByGoods.g1[0].lastMsgTime, '2026-07-29T08:04:00.000Z');
});
test('local merge sums disjoint batch counts and deduplicates evidence', () => {
const analyses = [
{
summary: { pre_sale_conversation_count: 1, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: ['尺寸咨询较多'] },
results: [result()],
},
{
summary: { pre_sale_conversation_count: 2, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: ['尺寸咨询较多'] },
results: [result({
occurrence_count: 3,
affected_customer_count: 2,
affected_conversation_ids: ['u1', 'u2'],
evidence: [
{ conversation_id: 'u1', nickname: '买家-u1', message_time: '2026-07-30 10:00:01', text: '多高' },
{ conversation_id: 'u2', nickname: '买家-u2', message_time: '2026-07-30 10:00:02', text: '多宽' },
],
})],
},
];
const merged = mergeBatchAnalysesLocally(analyses, { customerCount: 3, messageCount: 12 });
assert.equal(merged.results.length, 1);
assert.equal(merged.results[0].occurrence_count, 4);
assert.equal(merged.results[0].affected_customer_count, 3);
assert.deepEqual(merged.results[0].affected_conversation_ids, ['u1', 'u2']);
assert.equal(merged.results[0].evidence.length, 2);
assert.equal(merged.summary.conversation_count, 3);
assert.equal(merged.summary.pre_sale_conversation_count, 3);
assert.deepEqual(merged.summary.main_findings, ['尺寸咨询较多']);
});
test('model merge cannot change deterministic counts or drop a label group', () => {
const local = mergeBatchAnalysesLocally([{
summary: { pre_sale_conversation_count: 2, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: [] },
results: [result({ occurrence_count: 4, affected_customer_count: 2 })],
}], { customerCount: 2, messageCount: 8 });
const model = {
schema_version: '1.0',
conversation_type: '售前',
summary: { conversation_count: 99, pre_sale_conversation_count: 99, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: ['优化后的结论'] },
results: [result({ occurrence_count: 99, affected_customer_count: 99, buyer_intent: '优化后的诉求' })],
};
const reconciled = reconcileMergedAnalysis(model, local, { customerCount: 2, messageCount: 8 });
assert.equal(reconciled.results[0].occurrence_count, 4);
assert.equal(reconciled.results[0].affected_customer_count, 2);
assert.equal(reconciled.results[0].buyer_intent, '优化后的诉求');
assert.deepEqual(reconciled.results[0].affected_conversation_ids, ['u1']);
assert.equal(reconciled.summary.conversation_count, 2);
assert.equal(reconciled.summary.pre_sale_conversation_count, 2);
});
test('analysis normalization keeps every valid affected conversation id', () => {
const normalized = normalizeAnalysis({
summary: {},
results: [{
consultation_type: '售前',
primary_reason: '产品问题',
secondary_reason: '尺寸问题',
occurrence_count: 3,
affected_customer_count: 3,
affected_conversation_ids: ['u1', 'u2', 'not-in-batch'],
evidence: [{ conversation_id: 'u3', text: '尺寸是多少' }],
}],
}, { customerCount: 3, messageCount: 10 }, {
allowedConversationIds: ['u1', 'u2', 'u3'],
});
assert.deepEqual(normalized.results[0].affected_conversation_ids, ['u1', 'u2', 'u3']);
assert.equal(normalized.results[0].affected_customer_count, 3);
});
test('Qwen JSON parsing, usage aggregation and error extraction are provider-safe', () => {
assert.deepEqual(parseModelJson('```json\n{"ok":true}\n```'), { ok: true });
assert.deepEqual(sumUsages([
{ prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 },
{ input_tokens: 7, output_tokens: 2 },
]), { prompt_tokens: 17, completion_tokens: 5, total_tokens: 22 });
assert.equal(getAiErrorDetail({ response: { data: { error: { message: 'rate limited' } } } }), 'rate limited');
assert.equal(getAiErrorDetail({ message: 'Bearer sk-secret-example-1234567890 failed' }), 'Bearer [redacted] failed');
assert.equal(isRetryableAiError({ code: 'ETIMEDOUT' }), true);
assert.equal(isRetryableAiError({ response: { status: 429 } }), true);
assert.equal(isRetryableAiError({ response: { status: 401 } }), false);
assert.equal(getAiRetryDelayMs({}, 1, 1500), 1500);
assert.equal(getAiRetryDelayMs({}, 2, 1500), 3000);
assert.equal(getAiRetryDelayMs({ response: { headers: { 'retry-after': '2' } } }, 1, 1500), 2000);
assert.equal(sanitizePublicError('千问模型请求失败:qwen-timeout'), 'AI服务请求失败:AI服务');
assert.equal(sanitizePublicError('https://apihub.agnes-ai.com/v1 failed'), 'https://AI服务/v1 failed');
});

View File

@ -1,60 +0,0 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const {
compareVersions,
normalizeArchitecture,
normalizePlatform,
parseReleaseStorage,
validateVersion,
} = require('../src/services/desktopReleaseService');
test('desktop release versions are compared numerically', () => {
assert.equal(compareVersions('0.0.10', '0.0.9'), 1);
assert.equal(compareVersions('v1.2.3', '1.2.3'), 0);
assert.equal(compareVersions('2.0.0', '10.0.0'), -1);
});
test('desktop release versions require an x.y.z value', () => {
assert.equal(validateVersion('v1.2.3'), '1.2.3');
assert.throws(() => validateVersion('1.2'), /版本号格式/);
});
test('desktop release storage drops invalid records without losing the latest version', () => {
const storage = parseReleaseStorage(JSON.stringify({
latestVersion: '0.0.9',
releases: [
{ id: 9, version: '0.0.9', objectKey: 'desktop-client/0.0.9/setup.exe' },
{ id: 8, version: 'invalid', objectKey: 'desktop-client/invalid/setup.exe' },
],
}));
assert.equal(storage.latestVersion, '0.0.9');
assert.equal(storage.releases.length, 1);
assert.equal(storage.releases[0].version, '0.0.9');
assert.equal(storage.releases[0].platform, 'win32');
assert.equal(storage.releases[0].architecture, 'x64');
assert.equal(storage.latestVersions.win32, '0.0.9');
});
test('desktop release platform and architecture are inferred from installers', () => {
assert.equal(normalizePlatform('', 'pdd-auto-sale-0.0.10-arm64.dmg'), 'darwin');
assert.equal(normalizePlatform('', 'pdd-auto-sale-0.0.10-setup.exe'), 'win32');
assert.equal(normalizeArchitecture('', 'pdd-auto-sale-0.0.10-arm64.dmg', 'darwin'), 'arm64');
assert.equal(normalizeArchitecture('', 'pdd-auto-sale-0.0.10-setup.exe', 'win32'), 'x64');
});
test('desktop release storage keeps Windows and macOS installers for the same version', () => {
const storage = parseReleaseStorage({
latestVersions: { win32: '0.0.10', darwin: '0.0.10' },
releases: [
{ id: 10, version: '0.0.10', objectKey: 'desktop-client/win32/0.0.10/setup.exe', fileName: 'pdd-auto-sale-0.0.10-setup.exe' },
{ id: 11, version: '0.0.10', objectKey: 'desktop-client/darwin/0.0.10/app.dmg', fileName: 'pdd-auto-sale-0.0.10-arm64.dmg' },
],
});
assert.equal(storage.releases.length, 2);
assert.deepEqual(storage.releases.map((release) => release.platform), ['win32', 'darwin']);
assert.equal(storage.latestVersions.win32, '0.0.10');
assert.equal(storage.latestVersions.darwin, '0.0.10');
});

View File

@ -95,7 +95,6 @@ test('desktop capabilities follow resolved page permissions', () => {
marketingEvents: false,
promotion: false,
productTest: false,
reviewAnalysis: false,
});
assert.equal(buildDesktopCapabilities(resolvedUser({ role: 'admin', allowedPageKeys: [] })).promotion, true);
});

View File

@ -5,7 +5,6 @@ const assert = require('node:assert/strict');
const jwt = require('jsonwebtoken');
const presaleRouter = require('../src/api/presaleExternal');
const autoListingRouter = require('../src/api/planningSelectionFissionExternal');
const pricingRouter = require('../src/api/pricingCalculatorExternal');
const alibaba1688Router = require('../src/api/alibaba1688ProductExternal');
const {
requireDesktopPageAccess,
@ -38,40 +37,6 @@ test('presale and auto-listing external APIs use the same request key middleware
assert.equal(getRouteMiddleware(alibaba1688Router, '/:productId'), requireExternalAutomationApiKey);
});
test('desktop pricing uses the shared request key middleware', () => {
const middlewareLayer = pricingRouter.stack.find((item) => item.name === 'requireExternalAutomationApiKey');
assert.equal(middlewareLayer?.handle, requireExternalAutomationApiKey);
});
test('desktop pricing identity is stable, isolated and uses negative safe integers', () => {
const first = pricingRouter.desktopPricingUserId('香茗');
assert.equal(first, pricingRouter.desktopPricingUserId(' 香茗 '));
assert.notEqual(first, pricingRouter.desktopPricingUserId('江陵'));
assert.equal(Number.isSafeInteger(first), true);
assert.equal(first < 0, true);
});
test('desktop pricing accepts a UTF-8 base64url nickname header', () => {
const nickname = '胖大海';
const req = {
get(name) {
return name.toLowerCase() === 'x-operator-name-encoded'
? Buffer.from(nickname, 'utf8').toString('base64url')
: '';
},
query: {},
body: {},
};
let nextCalled = false;
pricingRouter.requireDesktopPricingOperator(req, {}, () => { nextCalled = true; });
assert.equal(nextCalled, true);
assert.equal(req.user.name, nickname);
assert.equal(req.user.id, req.user.sub);
assert.ok(req.user.id < 0);
});
test('shared external auth accepts a valid desktop JWT without an API key', async () => {
const previousSecret = process.env.CUBEJS_SECRET;
process.env.CUBEJS_SECRET = 'desktop-session-test-secret';
@ -178,20 +143,3 @@ test('desktop page access enforces JWT permissions and preserves API key compati
assert.equal(nextCalled, true);
}
});
test('desktop pricing keeps the verified JWT user identity', () => {
const req = {
externalApiKey: { authType: 'jwt' },
user: { sub: '42', id: 42, name: '陆南亭' },
get() { return ''; },
query: {},
body: {},
};
let nextCalled = false;
pricingRouter.requireDesktopPricingOperator(req, {}, () => { nextCalled = true; });
assert.equal(nextCalled, true);
assert.equal(req.user.id, 42);
assert.equal(req.user.name, '陆南亭');
});

View File

@ -1,110 +0,0 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
_test: {
filterSkuRowsForSelectionContext,
createPrefsPatch,
getSelectionSkuCodeSet,
normalizeCalculatorType,
readPrefsProfile,
sanitizeHistoryProduct,
writePrefsProfile,
},
} = require('../src/api/pricingCalculator');
const skuRows = [
{ skuCode: 'SKU-A' },
{ skuCode: 'SKU-B' },
{ skuCode: 'SKU-C' },
];
test('selection pricing only returns explicitly selected SKU rows', () => {
const selectionContext = {
skuSceneImages: { selectedSkuCodes: ['sku-a', 'SKU-C'] },
rows: [{ skuCode: 'SKU-A' }, { skuCode: 'SKU-B' }, { skuCode: 'SKU-C' }],
};
assert.deepEqual(
filterSkuRowsForSelectionContext(skuRows, selectionContext),
[{ skuCode: 'SKU-A' }, { skuCode: 'SKU-C' }]
);
});
test('selection pricing falls back to stored pricing rows for legacy tasks', () => {
const selectionContext = {
skuSceneImages: {},
rows: [{ skuCode: 'SKU-B' }],
};
assert.deepEqual([...getSelectionSkuCodeSet(selectionContext)], ['SKU-B']);
assert.deepEqual(
filterSkuRowsForSelectionContext(skuRows, selectionContext),
[{ skuCode: 'SKU-B' }]
);
});
test('standalone pricing continues to return all SPU SKU rows', () => {
assert.deepEqual(filterSkuRowsForSelectionContext(skuRows, null), skuRows);
});
test('pricing preferences keep legacy company values and isolate 1688 defaults', () => {
const legacy = JSON.stringify({
coupon: 30,
activityDiscount: 0.7,
installFee: 5,
targetMargin: 0.15,
});
const withDropship = writePrefsProfile(legacy, 'alibaba1688', {
coupon: 20,
activityDiscount: 0.6,
installFee: 0,
targetMargin: 0.12,
});
assert.deepEqual(readPrefsProfile(withDropship, 'company'), {
coupon: 30,
activityDiscount: 0.7,
installFee: 5,
targetMargin: 0.15,
});
assert.deepEqual(readPrefsProfile(withDropship, 'alibaba1688'), {
coupon: 20,
activityDiscount: 0.6,
installFee: 0,
targetMargin: 0.12,
});
const updatedCompany = writePrefsProfile(withDropship, 'company', { targetMargin: 0.18 });
assert.equal(readPrefsProfile(updatedCompany, 'company').targetMargin, 0.18);
assert.equal(readPrefsProfile(updatedCompany, 'alibaba1688').targetMargin, 0.12);
assert.deepEqual(createPrefsPatch('company', { targetMargin: 0.15 }), { targetMargin: 0.15 });
assert.deepEqual(createPrefsPatch('alibaba1688', { targetMargin: 0.12 }), {
_profiles: { alibaba1688: { targetMargin: 0.12 } },
});
});
test('1688 history product keeps bounded identity and specification dimensions', () => {
assert.equal(normalizeCalculatorType('alibaba1688'), 'alibaba1688');
assert.equal(normalizeCalculatorType('unknown'), 'company');
assert.deepEqual(sanitizeHistoryProduct({
productId: ' 1073252781818 ',
title: ' 测试商品 ',
skuDimensions: [
{ name: '颜色', values: ['原木色', '胡桃色'] },
{ name: '尺寸', values: ['60cm'] },
],
}), {
productId: '1073252781818',
title: '测试商品',
mainImage: '',
sourceUrl: '',
storeName: '',
category: '',
skuDimensions: [
{ name: '颜色', values: ['原木色', '胡桃色'] },
{ name: '尺寸', values: ['60cm'] },
],
});
});

View File

@ -1,14 +1,26 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { pruneRetiredSidebarConfig, cleanupRetiredFeatureSettings, isRetiredSystemConfigKey } = require('../src/services/retiredFeatures');
const {
pruneRetiredSidebarConfig,
cleanupRetiredFeatureSettings,
isRetiredDatabaseConfigName,
isRetiredSystemConfigKey,
} = require('../src/services/retiredFeatures');
test('retired sidebar cleanup is recursive and idempotent', () => {
const saved = JSON.stringify({ entries: [{ key: 'agent' }, { key: 'manage', children: [{ key: 'ai-skill-tags' }, { key: 'settings', label: '管理设置' }] }] });
const saved = JSON.stringify({ entries: [
{ key: 'agent' },
{ key: 'store-management', children: [{ key: 'store-checkup' }] },
{ key: 'operation-tools', children: [{ key: 'pricing-calculator' }] },
{ key: 'manage', children: [{ key: 'ai-skill-tags' }, { key: 'desktop-client-releases' }, { key: 'settings', label: '管理设置' }] },
] });
const clean = pruneRetiredSidebarConfig(saved);
assert.deepEqual(JSON.parse(clean).entries, [{ key: 'manage', children: [{ key: 'settings', label: '管理设置' }] }]);
assert.equal(pruneRetiredSidebarConfig(clean), clean);
assert.equal(pruneRetiredSidebarConfig('{invalid'), '{invalid');
assert.equal(isRetiredSystemConfigKey('openclaw_chat_skill_tags'), true);
assert.equal(isRetiredSystemConfigKey('cs_quality_ai_config'), true);
assert.equal(isRetiredDatabaseConfigName('ai_buyer_show_database'), true);
assert.equal(isRetiredSystemConfigKey('site_name'), false);
});
@ -19,8 +31,9 @@ test('upgrade removes only retired settings and permission rows', async () => {
return sql.startsWith('SELECT') ? [{ config_value: '{"entries":[{"key":"agent"},{"key":"product-analysis"}]}' }] : [];
} });
const deletes = calls.filter((call) => call.sql.startsWith('DELETE'));
assert.equal(deletes.length, 4);
assert.equal(deletes.length, 5);
assert.ok(deletes.every((call) => call.sql.includes(' WHERE ') && call.params.length > 0));
assert.equal(calls.filter((call) => call.sql.startsWith('DROP TABLE IF EXISTS')).length, 2);
const update = calls.find((call) => call.sql.startsWith('UPDATE'));
assert.deepEqual(JSON.parse(update.params[0]).entries, [{ key: 'product-analysis' }]);
});

View File

@ -1,74 +0,0 @@
import request from './request';
const AI_BUYER_SHOW_TIMEOUT_MS = 5 * 60 * 1000;
export const getAiBuyerShowRuntime = () => (
request.get('/api/ai-buyer-show/runtime', { timeout: 30000 })
);
export const previewAiBuyerShowPrompt = (data) => (
request.post('/api/ai-buyer-show/prompt-preview', data, { timeout: 30000 })
);
export const generateAiBuyerShowReviews = ({ values = {}, productImages = [] }) => {
const formData = new FormData();
Object.entries(values).forEach(([key, value]) => {
if (value !== undefined && value !== null && String(value).trim() !== '') {
formData.append(key, Array.isArray(value) ? value.join(',') : value);
}
});
productImages.forEach((file) => {
const rawFile = file.originFileObj || file;
if (rawFile instanceof Blob) formData.append('productImages', rawFile);
});
return request.post('/api/ai-buyer-show/reviews', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: AI_BUYER_SHOW_TIMEOUT_MS,
maxBodyLength: Infinity,
maxContentLength: Infinity,
});
};
export const createAiBuyerShowJob = ({ values = {}, productImages = [], sceneReferenceImages = [] }) => {
const formData = new FormData();
Object.entries(values).forEach(([key, value]) => {
if (value !== undefined && value !== null && String(value).trim() !== '') {
formData.append(key, value);
}
});
productImages.forEach((file) => {
const rawFile = file.originFileObj || file;
if (rawFile instanceof Blob) formData.append('productImages', rawFile);
});
sceneReferenceImages.forEach((file) => {
const rawFile = file.originFileObj || file;
if (rawFile instanceof Blob) formData.append('sceneReferenceImages', rawFile);
});
return request.post('/api/ai-buyer-show/jobs', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: AI_BUYER_SHOW_TIMEOUT_MS,
maxBodyLength: Infinity,
maxContentLength: Infinity,
});
};
export const getAiBuyerShowJobs = (params) => (
request.get('/api/ai-buyer-show/jobs', { params, timeout: 30000 })
);
export const getAiBuyerShowHistory = (params) => (
request.get('/api/ai-buyer-show/history', { params, timeout: 30000 })
);
export const getAiBuyerShowHistoryDetail = (id) => (
request.get(`/api/ai-buyer-show/history/${id}`, { timeout: 30000 })
);
export const getAiBuyerShowJob = (jobId) => (
request.get(`/api/ai-buyer-show/jobs/${jobId}`, { timeout: 30000 })
);
export const getAiBuyerShowQueueStats = (params) => (
request.get('/api/ai-buyer-show/queue-stats', { params, timeout: 30000 })
);

View File

@ -1,44 +0,0 @@
import request from './request';
export const getCsQualityDataMeta = () => (
request.get('/api/cs-quality/data-meta', { timeout: 120000 })
);
export const getCsQualityGoods = (params) => (
request.get('/api/cs-quality/goods', { params, timeout: 120000 })
);
export const getCsQualityCustomers = (params) => (
request.get('/api/cs-quality/customers', { params, timeout: 120000 })
);
export const getCsQualityMessages = (params) => (
request.get('/api/cs-quality/messages', { params, timeout: 120000 })
);
export const getCsQualityProductProfile = (goodsId, params) => (
request.get(`/api/cs-quality/goods/${encodeURIComponent(goodsId)}/profile`, { params, timeout: 120000 })
);
export const runCsQualityAiAnalysis = (data) => (
request.post('/api/cs-quality/analysis', data, { timeout: 900000 })
);
export const startCsQualityAiAnalysis = (data) => (
request.post('/api/cs-quality/analysis-jobs', data, { timeout: 120000, silentError: true })
);
export const getCsQualityAiAnalysisJob = (analysisId) => (
request.get(`/api/cs-quality/analysis-jobs/${encodeURIComponent(analysisId)}`, {
timeout: 30000,
silentError: true,
})
);
export const getCsQualityAnalysisHistory = (params) => (
request.get('/api/cs-quality/analysis-history', { params, timeout: 120000 })
);
export const getCsQualityAnalysisHistoryDetail = (id) => (
request.get(`/api/cs-quality/analysis-history/${encodeURIComponent(id)}`, { timeout: 120000 })
);

View File

@ -1,34 +0,0 @@
import request from './request';
export const getLatestDesktopRelease = (currentVersion, platform = 'win32', architecture = '') => (
request.get('/api/desktop-client/latest', {
params: {
...(currentVersion ? { currentVersion } : {}),
platform,
...(architecture ? { architecture } : {}),
},
silentError: true,
})
);
export const getDesktopReleases = () => (
request.get('/api/admin/desktop-releases', { silentError: true })
);
export const publishDesktopRelease = ({ version, releaseNotes, installer, platform, architecture, onUploadProgress }) => {
const formData = new FormData();
formData.append('version', version);
formData.append('releaseNotes', releaseNotes || '');
formData.append('platform', platform || 'win32');
formData.append('architecture', architecture || (platform === 'darwin' ? 'arm64' : 'x64'));
formData.append('installer', installer);
return request.post('/api/admin/desktop-releases', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 30 * 60 * 1000,
maxBodyLength: Infinity,
maxContentLength: Infinity,
onUploadProgress,
silentError: true,
});
};

View File

@ -1,79 +0,0 @@
import request from './request';
// 统一处理 { success, data, error } 包裹:业务错误抛出带友好文案的 Error,
// 由页面以行内状态展示,避免 axios 默认的 "Request failed with status code xxx"。
const unwrap = async (promise) => {
try {
const res = await promise;
if (res && res.success === true) {
return res.data;
}
throw new Error(res?.error || '请求失败,请稍后重试');
} catch (error) {
const friendly = error?.response?.data?.error;
if (friendly) {
throw new Error(friendly);
}
throw error;
}
};
// 业务错误(404/400)也以 resolve 返回,交给 unwrap 解析友好文案
const tolerantConfig = { validateStatus: (status) => status < 500 };
export const fetchSpuSkus = (spuCode, params = {}) => unwrap(
request.get(`/api/pricing-calculator/spu/${encodeURIComponent(spuCode)}`, {
...tolerantConfig,
params,
})
);
export const fetchProductCodeMeta = () => unwrap(
request.get('/api/pricing-calculator/product-code/meta', tolerantConfig)
);
export const savePricingHistory = (payload) => unwrap(
request.post('/api/pricing-calculator/history', payload, tolerantConfig)
);
export const fetchPricingHistoryList = (params) => unwrap(
request.get('/api/pricing-calculator/history', { ...tolerantConfig, params })
);
export const fetchPricingHistoryDetail = (id) => unwrap(
request.get(`/api/pricing-calculator/history/${id}`, tolerantConfig)
);
export const deletePricingHistory = (id) => unwrap(
request.delete(`/api/pricing-calculator/history/${id}`, tolerantConfig)
);
// 导出为 xlsx:后端生成文件流。注意 request 的响应拦截器已把返回值解包为响应体本身,
// 成功时是 xlsx 的 Blob;业务错误(400/500)时后端返回 JSON,此处也是 Blob(type 为 application/json),需解析错误文案
export const exportPricingTable = async (payload) => {
const blob = await request.post('/api/pricing-calculator/export', payload, {
responseType: 'blob',
validateStatus: () => true, // 状态码已被拦截器抹掉,靠 Blob 类型区分成功与错误
});
if (blob instanceof Blob && blob.type.includes('json')) {
let friendly = '导出失败,请稍后重试';
try {
const parsed = JSON.parse(await blob.text());
friendly = parsed?.error || friendly;
} catch { /* 保持默认文案 */ }
throw new Error(friendly);
}
return blob;
};
// 个性化默认值:读取/保存当前登录用户的配置(无配置时 prefs 为 null,前端回退系统默认)
export const fetchPricingPrefs = (profile = 'company') => unwrap(
request.get('/api/pricing-calculator/prefs', {
...tolerantConfig,
params: { profile },
})
);
export const savePricingPrefs = (prefs, profile = 'company') => unwrap(
request.put('/api/pricing-calculator/prefs', { prefs, profile }, tolerantConfig)
);

View File

@ -1,23 +0,0 @@
import request from './request';
export const getStoreCheckupMeta = () => request.get('/api/store-checkup/meta');
export const getStoreCheckupOverview = (params) => (
request.get('/api/store-checkup/overview', { params })
);
export const getStoreCheckupHomeSummary = (params, config = {}) => (
request.get('/api/store-checkup/home-summary', { ...config, params })
);
export const getStoreCheckupHomeReviews = (params, config = {}) => (
request.get('/api/store-checkup/home-reviews', { ...config, params })
);
export const getStoreCheckupHomeCompensation = (params, config = {}) => (
request.get('/api/store-checkup/home-compensation', { ...config, params })
);
export const getStoreCheckupTab = (tabKey, params) => (
request.get(`/api/store-checkup/tabs/${tabKey}`, { params })
);

View File

@ -18,29 +18,11 @@ export const DEFAULT_SIDEBAR_CONFIG = {
{ key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 },
],
},
{
key: 'store-management', type: 'group', label: '店铺管理', icon: 'shop', visible: true, order: 80, defaultExpanded: true,
children: [
{ key: 'store-checkup', label: '店铺体检', icon: 'check-circle', routeKey: 'store-checkup', visible: true, order: 10 },
{ key: 'review-analysis', label: '复盘分析', icon: 'history', routeKey: 'review-analysis', visible: true, order: 20 },
{ key: 'customer-service-checkup', label: '客服质检', icon: 'customer-service', routeKey: 'customer-service-checkup', visible: true, order: 30 },
],
},
{
key: 'operation-tools', type: 'group', label: '运营工具', icon: 'tool', visible: true, order: 90, defaultExpanded: true,
children: [
{ key: 'pricing-calculator', label: '上架测算', icon: 'calculator', routeKey: 'pricing-calculator', visible: true, order: 5 },
{ key: 'desktop-client-download', label: '客户端下载', icon: 'desktop', routeKey: 'desktop-client-download', visible: true, order: 8 },
{ key: 'ai-buyer-show', label: 'AI买家秀', icon: 'image', routeKey: 'ai-buyer-show', visible: true, order: 10 },
{ key: 'ai-seeding-video', label: 'AI种草视频', icon: 'video', routeKey: 'ai-seeding-video', visible: true, order: 20 },
],
},
{ key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 },
{
key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true,
children: [
{ key: 'settings', label: '系统设置', icon: 'setting', routeKey: 'settings', visible: true, order: 10, requiresAdmin: true },
{ key: 'desktop-client-releases', label: '客户端发布', icon: 'desktop', routeKey: 'desktop-client-releases', visible: true, order: 20, requiresAdmin: true },
{ key: 'online-desktop-users', label: '在线客户端', icon: 'team', routeKey: 'online-desktop-users', visible: true, order: 30, requiresAdmin: true },
{ key: 'desktop-execution-tasks', label: '执行任务', icon: 'projection', routeKey: 'desktop-execution-tasks', visible: true, order: 40, requiresAdmin: true },
],

View File

@ -2,33 +2,25 @@ import React, { useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import {
AppstoreOutlined,
CalculatorOutlined,
CheckCircleOutlined,
CloudOutlined,
CustomerServiceOutlined,
DashboardOutlined,
DesktopOutlined,
DownOutlined,
ExperimentOutlined,
FundProjectionScreenOutlined,
HistoryOutlined,
LineChartOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
MessageOutlined,
MoonOutlined,
PictureOutlined,
RightOutlined,
RobotOutlined,
SettingOutlined,
ShopOutlined,
ShoppingOutlined,
TableOutlined,
TeamOutlined,
ToolOutlined,
UserOutlined,
VideoCameraOutlined,
SunOutlined,
} from '@ant-design/icons';
import { Dropdown, Switch } from 'antd';
@ -64,26 +56,18 @@ const readSidebarDarkModePreference = () => {
const SIDEBAR_ICON_MAP = {
appstore: AppstoreOutlined,
calculator: CalculatorOutlined,
'check-circle': CheckCircleOutlined,
cloud: CloudOutlined,
'customer-service': CustomerServiceOutlined,
dashboard: DashboardOutlined,
desktop: DesktopOutlined,
experiment: ExperimentOutlined,
history: HistoryOutlined,
image: PictureOutlined,
'line-chart': LineChartOutlined,
message: MessageOutlined,
projection: FundProjectionScreenOutlined,
robot: RobotOutlined,
setting: SettingOutlined,
shop: ShopOutlined,
shopping: ShoppingOutlined,
table: TableOutlined,
team: TeamOutlined,
tool: ToolOutlined,
video: VideoCameraOutlined,
};
const SIDEBAR_ROUTE_MAP = {
@ -94,19 +78,11 @@ const SIDEBAR_ROUTE_MAP = {
matchMode: 'prefix',
},
'promotion-module': { path: '/planning/operation/promotion-module' },
'pricing-calculator': { path: '/planning/operation/pricing-calculator' },
'product-monitoring': { path: '/planning/operation/product-monitoring' },
'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' },
'desktop-client-download': { path: '/planning/operation/desktop-client-download' },
'product-analysis': { path: '/planning/operation/product-analysis' },
'store-checkup': { path: '/planning/operation/store-checkup' },
'review-analysis': { path: '/planning/operation/review-analysis' },
'customer-service-checkup': { path: '/planning/operation/customer-service-checkup' },
'ai-buyer-show': { path: '/planning/operation/ai-buyer-show' },
'ai-seeding-video': { path: '/planning/operation/ai-seeding-video' },
'work-orders': { path: '/planning/operation/work-orders' },
settings: { path: '/settings' },
'desktop-client-releases': { path: '/settings/desktop-client-releases' },
'online-desktop-users': { path: '/settings/online-desktop-users' },
'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' },
};

File diff suppressed because it is too large Load Diff

View File

@ -1,688 +0,0 @@
.ai-buyer-show-page {
min-height: 100%;
padding: 16px 18px 28px;
background: var(--sycm-bg-page-surface, #f5f7fa);
}
.ai-buyer-show-header {
position: sticky;
z-index: 8;
top: 0;
display: flex;
min-height: 58px;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: -16px -18px 16px;
border-bottom: 1px solid var(--sycm-border-light, #e5e7eb);
padding: 10px 18px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
backdrop-filter: blur(10px);
}
.ai-buyer-show-header__copy {
display: flex;
min-width: 0;
align-items: baseline;
gap: 10px;
}
.ai-buyer-show-header__copy h1 {
margin: 0;
color: var(--sycm-text-strong, #111827);
font-size: 18px;
font-weight: 650;
letter-spacing: 0;
line-height: 28px;
}
.ai-buyer-show-header__copy span,
.ai-buyer-show-queue {
overflow: hidden;
color: var(--sycm-text-tertiary, #8a94a6);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-buyer-show-header__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.ai-buyer-show-search {
width: min(340px, 30vw);
}
.ai-buyer-show-library {
min-height: calc(100vh - 130px);
}
.ai-buyer-show-selection-bar {
position: sticky;
z-index: 7;
top: 58px;
display: flex;
min-height: 44px;
align-items: center;
gap: 14px;
margin: -4px 0 12px;
border: 1px solid var(--sycm-border-light, #e5e7eb);
border-radius: 6px;
padding: 6px 10px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
backdrop-filter: blur(8px);
}
.ai-buyer-show-selection-bar > span {
color: var(--sycm-text-tertiary, #8a94a6);
font-size: 12px;
}
.ai-buyer-show-selection-bar .ant-btn {
margin-left: auto;
}
.ai-buyer-show-state {
display: grid;
min-height: 420px;
place-items: center;
}
.ai-buyer-show-waterfall {
column-count: 5;
column-gap: 12px;
}
.ai-buyer-show-card {
position: relative;
display: inline-block;
overflow: hidden;
width: 100%;
margin: 0 0 12px;
break-inside: avoid;
border: 1px solid rgba(15, 23, 42, 0.07);
border-radius: 6px;
background: #fff;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
vertical-align: top;
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
.ai-buyer-show-card.is-selected {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.14);
}
.ai-buyer-show-card__select {
position: absolute;
z-index: 4;
top: 8px;
left: 8px;
display: grid;
width: 28px;
height: 28px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 1px 5px rgba(15, 23, 42, 0.18);
place-items: center;
}
.ai-buyer-show-card__select .ant-checkbox-wrapper {
height: 16px;
line-height: 16px;
}
.ai-buyer-show-card:hover {
border-color: rgba(22, 119, 255, 0.28);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.09);
transform: translateY(-2px);
}
.ai-buyer-show-card__preview {
position: relative;
display: block;
overflow: hidden;
width: 100%;
min-height: 140px;
border: 0;
padding: 0;
background: #eef1f5;
cursor: zoom-in;
}
.ai-buyer-show-card__preview img {
display: block;
width: 100%;
height: auto;
min-height: 140px;
max-height: 360px;
object-fit: cover;
transition: transform 220ms ease;
}
.ai-buyer-show-card:hover .ai-buyer-show-card__preview img {
transform: scale(1.015);
}
.ai-buyer-show-card__count,
.ai-buyer-show-card__status {
position: absolute;
top: 8px;
display: inline-flex;
min-height: 24px;
align-items: center;
gap: 4px;
border-radius: 4px;
padding: 3px 7px;
background: rgba(17, 24, 39, 0.76);
color: #fff;
font-size: 12px;
line-height: 18px;
backdrop-filter: blur(5px);
}
.ai-buyer-show-card__count {
right: 8px;
}
.ai-buyer-show-card__status {
left: 44px;
background: rgba(212, 136, 6, 0.88);
}
.ai-buyer-show-card__body {
padding: 10px 11px 11px;
}
.ai-buyer-show-card__title {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ai-buyer-show-card__title strong {
overflow: hidden;
min-width: 0;
color: var(--sycm-text-strong, #111827);
font-size: 13px;
font-weight: 650;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-buyer-show-card__title span {
flex: 0 0 auto;
color: var(--sycm-text-tertiary, #8a94a6);
font-size: 11px;
}
.ai-buyer-show-card__body p {
display: -webkit-box;
overflow: hidden;
margin: 6px 0 9px;
color: var(--sycm-text-secondary, #596579);
font-size: 12px;
line-height: 18px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.ai-buyer-show-card__meta {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 7px;
color: var(--sycm-text-tertiary, #8a94a6);
font-size: 11px;
}
.ai-buyer-show-card__meta span {
display: inline-flex;
overflow: hidden;
min-width: 0;
align-items: center;
gap: 4px;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-buyer-show-load-more {
display: flex;
min-height: 50px;
align-items: center;
justify-content: center;
color: var(--sycm-text-tertiary, #8a94a6);
font-size: 12px;
}
.ai-buyer-show-create-drawer .ant-drawer-header {
padding: 14px 18px;
}
.ai-buyer-show-create-drawer .ant-drawer-body {
padding: 14px 18px 24px;
background: #f6f8fb;
}
.ai-buyer-show-create-drawer .ant-drawer-footer {
padding: 10px 18px;
}
.ai-buyer-show-drawer-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.ai-buyer-show-current-job {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
border: 1px solid #d9e8ff;
border-radius: 6px;
padding: 10px 12px;
background: #f3f8ff;
}
.ai-buyer-show-current-job > div:first-child {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.ai-buyer-show-current-job strong {
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-buyer-show-form-section {
margin-bottom: 12px;
border: 1px solid var(--sycm-border-light, #e5e7eb);
border-radius: 6px;
padding: 12px;
background: #fff;
}
.ai-buyer-show-form-section:last-child {
margin-bottom: 0;
}
.ai-buyer-show-form-section h3 {
margin: 0 0 11px;
color: var(--sycm-text-strong, #111827);
font-size: 13px;
font-weight: 650;
line-height: 20px;
}
.ai-buyer-show-review-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.ai-buyer-show-review-heading h3 {
margin: 0;
}
.ai-buyer-show-review-heading span {
color: var(--sycm-text-tertiary, #8a94a6);
font-size: 12px;
}
.ai-buyer-show-review-list {
display: flex;
max-height: 330px;
flex-direction: column;
gap: 8px;
overflow-y: auto;
}
.ai-buyer-show-review-item {
display: grid;
width: 100%;
grid-template-columns: 16px minmax(0, 1fr);
gap: 9px;
border: 1px solid var(--sycm-border-light, #e5e7eb);
border-radius: 6px;
padding: 9px 10px;
background: #fff;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.ai-buyer-show-review-item:hover {
border-color: #91caff;
}
.ai-buyer-show-review-item.is-selected {
border-color: #1677ff;
background: #f3f8ff;
box-shadow: 0 0 0 1px rgba(22, 119, 255, 0.08);
}
.ai-buyer-show-review-radio {
width: 14px;
height: 14px;
margin-top: 3px;
border: 1px solid #b8c0cc;
border-radius: 50%;
background: #fff;
}
.ai-buyer-show-review-item.is-selected .ai-buyer-show-review-radio {
border: 4px solid #1677ff;
}
.ai-buyer-show-review-item > span:last-child {
min-width: 0;
}
.ai-buyer-show-review-item strong {
color: var(--sycm-text-strong, #111827);
font-size: 12px;
}
.ai-buyer-show-review-item__heading {
display: flex;
min-height: 22px;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ai-buyer-show-review-item__heading .ant-tag {
margin-inline-end: 0;
font-size: 11px;
line-height: 18px;
}
.ai-buyer-show-review-item em {
display: block;
overflow: hidden;
margin-top: 2px;
color: var(--sycm-text-tertiary, #8a94a6);
font-size: 11px;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-buyer-show-review-item p {
margin: 5px 0 0;
color: var(--sycm-text-secondary, #596579);
font-size: 12px;
line-height: 18px;
}
.ai-buyer-show-review-images {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
margin-top: 8px;
}
.ai-buyer-show-review-images .ant-image {
display: block;
overflow: hidden;
width: 100%;
border: 1px solid var(--sycm-border-light, #e5e7eb);
border-radius: 4px;
background: #eef1f5;
}
.ai-buyer-show-review-images img {
display: block;
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
}
.ai-buyer-show-review-images__empty {
display: block;
margin-top: 6px;
color: var(--sycm-text-tertiary, #8a94a6);
font-size: 11px;
}
.ai-buyer-show-form-section .ant-form-item {
margin-bottom: 11px;
}
.ai-buyer-show-form-section .ant-form-item:last-child {
margin-bottom: 0;
}
.ai-buyer-show-form-section .ant-form-item-label {
padding-bottom: 4px;
}
.ai-buyer-show-form-section .ant-form-item-label > label {
height: 20px;
color: var(--sycm-text-secondary, #596579);
font-size: 12px;
}
.ai-buyer-show-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 12px;
}
.ai-buyer-show-settings-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0 10px;
}
.ai-buyer-show-fixed-setting {
min-width: 0;
padding-bottom: 11px;
}
.ai-buyer-show-fixed-setting > span {
display: block;
height: 24px;
color: var(--sycm-text-secondary, #596579);
font-size: 12px;
line-height: 20px;
}
.ai-buyer-show-fixed-setting > strong {
display: flex;
height: 32px;
align-items: center;
gap: 6px;
border: 1px solid var(--sycm-border-light, #e5e7eb);
border-radius: 6px;
padding: 0 10px;
background: #f7f9fc;
color: var(--sycm-text-strong, #111827);
font-size: 12px;
font-weight: 600;
}
.ai-buyer-show-upload-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.ai-buyer-show-upload-grid .ant-form-item {
min-width: 0;
border: 1px dashed #d7dce5;
border-radius: 6px;
padding: 9px;
background: #fafbfd;
}
.ai-buyer-show-upload-grid .ant-upload-wrapper .ant-upload-list {
gap: 6px;
}
.ai-buyer-show-upload-grid .ant-upload-wrapper .ant-upload-list-picture-card .ant-upload-list-item-container,
.ai-buyer-show-upload-grid .ant-upload-wrapper .ant-upload.ant-upload-select {
width: 66px;
height: 66px;
margin: 0;
}
.ai-buyer-show-preview-modal .ant-modal-body {
max-height: min(76vh, 760px);
overflow-y: auto;
}
.ai-buyer-show-history-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.ai-buyer-show-history-toolbar .ant-input-affix-wrapper {
width: min(380px, 100%);
}
.ai-buyer-show-preview-meta {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-bottom: 8px;
color: var(--sycm-text-tertiary, #8a94a6);
font-size: 12px;
}
.ai-buyer-show-preview-review {
margin: 0 0 12px;
border-left: 3px solid #d7e6ff;
padding: 7px 10px;
background: #f7f9fc;
color: var(--sycm-text-secondary, #596579);
font-size: 12px;
line-height: 19px;
}
.ai-buyer-show-preview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.ai-buyer-show-preview-grid .ant-image {
display: block;
overflow: hidden;
width: 100%;
border: 1px solid var(--sycm-border-light, #e5e7eb);
border-radius: 5px;
background: #eef1f5;
}
.ai-buyer-show-preview-grid img {
display: block;
width: 100%;
aspect-ratio: 4 / 3;
object-fit: contain;
}
@media (max-width: 1600px) {
.ai-buyer-show-waterfall {
column-count: 4;
}
}
@media (max-width: 1180px) {
.ai-buyer-show-waterfall {
column-count: 3;
}
}
@media (max-width: 820px) {
.ai-buyer-show-page {
padding: 12px;
}
.ai-buyer-show-header {
position: static;
align-items: stretch;
flex-direction: column;
margin: -12px -12px 12px;
padding: 11px 12px;
}
.ai-buyer-show-header__actions {
flex-wrap: wrap;
}
.ai-buyer-show-search {
width: 100%;
}
.ai-buyer-show-header__actions > .ant-btn:last-child {
flex: 1 1 auto;
}
.ai-buyer-show-selection-bar {
position: static;
}
.ai-buyer-show-history-drawer .ant-drawer-content-wrapper {
width: 100% !important;
}
.ai-buyer-show-waterfall {
column-count: 2;
column-gap: 10px;
}
.ai-buyer-show-card {
margin-bottom: 10px;
}
.ai-buyer-show-create-drawer .ant-drawer-content-wrapper {
width: 100% !important;
}
}
@media (max-width: 560px) {
.ai-buyer-show-header__copy {
align-items: flex-start;
flex-direction: column;
gap: 0;
}
.ai-buyer-show-waterfall {
column-count: 1;
}
.ai-buyer-show-form-grid,
.ai-buyer-show-upload-grid,
.ai-buyer-show-preview-grid {
grid-template-columns: 1fr;
}
.ai-buyer-show-settings-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ai-buyer-show-review-images {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}

View File

@ -1,378 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Button, DatePicker, Drawer, Image, Progress, Spin, Tag, Typography, message } from 'antd';
import { SafetyCertificateOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import {
getCsQualityAiAnalysisJob,
getCsQualityProductProfile,
startCsQualityAiAnalysis,
} from '../../api/csQuality';
import AiQualityResult from './AiQualityResult';
const { RangePicker } = DatePicker;
const { Text, Title } = Typography;
const ACTIVE_ANALYSIS_STATUSES = new Set(['queued', 'running']);
const ACTIVE_JOB_STORAGE_PREFIX = 'cs-quality-active-job:';
const PROGRESS_HINTS = {
queued: ['正在分配分析任务', '任务状态会持续同步到当前页面'],
preparing: ['正在确认商品信息和聊天周期', '正在检查本次质检所需的数据'],
loading: ['正在统计客户数量与消息数量', '正在读取所选周期内的完整聊天记录'],
grouping: ['正在按客户归整连续对话', '同一客户的上下文会保留在同一分析批次中'],
batching: ['正在根据数据量安排分析批次', '批次准备完成后会逐批更新进度'],
analyzing: [
'正在识别买家诉求、高频问题和对应证据',
'正在逐批检查售前咨询与售后反馈',
'数据量较大时可能需要数分钟,任务仍会在后台继续运行',
'完成后的结果会自动展示,并同步保存到质检历史记录',
],
merging: ['正在合并各批次发现的问题', '正在去重问题分类与聊天证据'],
validating: ['正在复核问题分类和影响客户数', '正在校验诊断结果的完整性'],
saving: ['结果已生成,正在写入质检历史记录', '保存完成后页面会自动展示诊断结果'],
};
function unwrapResponse(response) {
return response?.data?.data || response?.data || response;
}
function sanitizeUserMessage(value, fallback) {
return String(value || fallback || '').trim();
}
function activeJobStorageKey(goodsId) {
return `${ACTIVE_JOB_STORAGE_PREFIX}${goodsId || ''}`;
}
function readActiveJob(goodsId) {
try {
const saved = JSON.parse(window.localStorage.getItem(activeJobStorageKey(goodsId)) || 'null');
if (!saved?.analysisId || String(saved.goodsId) !== String(goodsId)) return null;
return saved;
} catch {
return null;
}
}
function persistActiveJob(goodsId, job) {
if (!goodsId || !job?.analysisId) return;
const range = job.range || {};
try {
window.localStorage.setItem(activeJobStorageKey(goodsId), JSON.stringify({
...job,
goodsId: String(goodsId),
startDate: range.startDate || job.startDate,
endDate: range.endDate || job.endDate,
}));
} catch {
// Progress polling still works when browser storage is unavailable.
}
}
function clearActiveJob(goodsId, analysisId) {
try {
const key = activeJobStorageKey(goodsId);
const saved = readActiveJob(goodsId);
if (!saved || !analysisId || saved.analysisId === analysisId) window.localStorage.removeItem(key);
} catch {
// Ignore storage restrictions after the server-side task has completed.
}
}
function formatElapsed(totalSeconds) {
const seconds = Math.max(0, Number(totalSeconds) || 0);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainder = Math.floor(seconds % 60);
if (hours) return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`;
return `${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`;
}
function getDefaultRange() {
const endDate = dayjs().subtract(1, 'day');
return [endDate.subtract(29, 'day'), endDate];
}
function normalizeRange(value) {
if (!Array.isArray(value) || value.length !== 2 || !value[0] || !value[1]) return getDefaultRange();
if (Math.abs(value[1].startOf('day').diff(value[0].startOf('day'), 'day')) > 29) return [value[1].subtract(29, 'day'), value[1]];
return value;
}
function formatNumber(value, digits = 0) {
const number = Number(value);
if (!Number.isFinite(number)) return '--';
return number.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
const AiQualityDrawer = ({ open, goods, onClose, onCompleted }) => {
const [range, setRange] = useState(getDefaultRange);
const [profile, setProfile] = useState(null);
const [profileLoading, setProfileLoading] = useState(false);
const [analysisJob, setAnalysisJob] = useState(null);
const [analysisResult, setAnalysisResult] = useState(null);
const [analysisError, setAnalysisError] = useState('');
const [pollWarning, setPollWarning] = useState('');
const [hintIndex, setHintIndex] = useState(0);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const onCompletedRef = useRef(onCompleted);
const completedAnalysisRef = useRef('');
useEffect(() => { onCompletedRef.current = onCompleted; }, [onCompleted]);
const analysisLoading = ACTIVE_ANALYSIS_STATUSES.has(analysisJob?.status);
const params = useMemo(() => ({
startDate: range?.[0]?.format('YYYY-MM-DD'),
endDate: range?.[1]?.format('YYYY-MM-DD'),
}), [range]);
const loadProfile = useCallback(async () => {
if (!open || !goods?.goodsId || !params.startDate || !params.endDate) return;
setProfileLoading(true);
try {
const response = await getCsQualityProductProfile(goods.goodsId, params);
const payload = response?.data?.data || response?.data || response;
setProfile(payload);
} catch (error) {
message.error(error.response?.data?.error || error.message || '加载商品表现失败');
setProfile(null);
} finally {
setProfileLoading(false);
}
}, [open, goods?.goodsId, params]);
useEffect(() => {
if (!open) return;
const savedJob = readActiveJob(goods?.goodsId);
const savedRange = savedJob?.startDate && savedJob?.endDate
? [dayjs(savedJob.startDate), dayjs(savedJob.endDate)]
: null;
setRange(savedRange?.every((item) => item.isValid()) ? savedRange : getDefaultRange());
setAnalysisJob(savedJob);
setAnalysisResult(null);
setAnalysisError('');
setPollWarning('');
completedAnalysisRef.current = '';
}, [open, goods?.goodsId]);
useEffect(() => { loadProfile(); }, [loadProfile]);
useEffect(() => {
if (!analysisLoading) return undefined;
const startedAt = Date.parse(analysisJob?.createdAt || '') || Date.now();
const updateElapsed = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
updateElapsed();
const timer = window.setInterval(updateElapsed, 1000);
return () => window.clearInterval(timer);
}, [analysisLoading, analysisJob?.createdAt]);
useEffect(() => {
if (!analysisLoading) return undefined;
setHintIndex(0);
const timer = window.setInterval(() => setHintIndex((current) => current + 1), 8000);
return () => window.clearInterval(timer);
}, [analysisLoading, analysisJob?.progress?.stage]);
useEffect(() => {
const analysisId = analysisJob?.analysisId;
if (!analysisId || !ACTIVE_ANALYSIS_STATUSES.has(analysisJob?.status)) return undefined;
let cancelled = false;
let timer = null;
const poll = async () => {
let nextStatus = analysisJob.status;
try {
const payload = unwrapResponse(await getCsQualityAiAnalysisJob(analysisId));
if (cancelled || !payload) return;
nextStatus = payload.status;
setAnalysisJob(payload);
setPollWarning('');
if (ACTIVE_ANALYSIS_STATUSES.has(payload.status)) {
persistActiveJob(goods?.goodsId, payload);
} else {
clearActiveJob(goods?.goodsId, analysisId);
}
if (payload.status === 'succeeded' && payload.result && completedAnalysisRef.current !== analysisId) {
completedAnalysisRef.current = analysisId;
setAnalysisResult(payload.result);
setProfile((current) => current || {
product: payload.result.product,
metrics: payload.result.metrics,
chatSummary: payload.result.chatSummary,
});
setAnalysisError('');
message.success('AI质检完成');
onCompletedRef.current?.(payload.result);
} else if (payload.status === 'succeeded' && !payload.result) {
setAnalysisError('质检任务已完成,但结果暂时无法读取,请在质检历史记录中查看');
} else if (payload.status === 'failed') {
setAnalysisError(sanitizeUserMessage(payload.errorMessage, 'AI质检未完成,请稍后重试'));
}
} catch {
if (!cancelled) setPollWarning('进度同步暂时中断,系统正在自动重试,后台任务不会停止');
}
if (!cancelled && ACTIVE_ANALYSIS_STATUSES.has(nextStatus)) {
timer = window.setTimeout(poll, 2500);
}
};
timer = window.setTimeout(poll, 700);
return () => {
cancelled = true;
if (timer) window.clearTimeout(timer);
};
}, [analysisJob?.analysisId, analysisJob?.status, goods?.goodsId]);
const handleAnalyze = async () => {
if (!goods?.goodsId || !params.startDate || !params.endDate) return;
const createdAt = new Date().toISOString();
setAnalysisJob({
status: 'queued',
createdAt,
range: params,
progress: { stage: 'queued', current: 0, total: 0, percent: 0, message: '正在创建质检任务' },
});
setAnalysisResult(null);
setAnalysisError('');
setPollWarning('');
completedAnalysisRef.current = '';
try {
const response = await startCsQualityAiAnalysis({
goodsId: goods.goodsId,
goodsName: goods.goodsName,
shopName: goods.shopName,
hdThumbUrl: goods.hdThumbUrl,
...params,
});
const payload = unwrapResponse(response);
setAnalysisJob(payload);
persistActiveJob(goods.goodsId, payload);
} catch (error) {
setAnalysisJob(null);
setAnalysisError(sanitizeUserMessage(error.response?.data?.error || error.message, '创建AI质检任务失败'));
}
};
const product = { ...goods, ...(profile?.product || {}) };
const metrics = profile?.metrics || {};
const chatSummary = profile?.chatSummary || {};
const progress = analysisJob?.progress || {};
const progressPercent = Math.min(100, Math.max(0, Number(progress.percent || 0)));
const progressHints = PROGRESS_HINTS[progress.stage] || PROGRESS_HINTS.analyzing;
const progressHint = pollWarning || progressHints[hintIndex % progressHints.length];
const batchProgress = Number(progress.total) > 0
? `已完成 ${Number(progress.current) || 0} / ${Number(progress.total)} 批`
: '正在准备分析数据';
const metricItems = [
['聊天客户', formatNumber(chatSummary.userCount)],
['聊天消息', formatNumber(chatSummary.messageCount)],
['商品访客', formatNumber(metrics.goodsUv)],
['商品浏览', formatNumber(metrics.goodsPv)],
['支付金额', `¥${formatNumber(metrics.payOrdrAmt, 2)}`],
['支付件数', formatNumber(metrics.payOrdrGoodsQty)],
['商品转化率', `${formatNumber(metrics.goodsVcr, 2)}%`],
['支付转化率', `${formatNumber(metrics.payOrdrRto, 2)}%`],
];
return (
<Drawer
title="商品聊天 AI 质检"
width={940}
open={open}
onClose={onClose}
destroyOnHidden
extra={(
<Button type="primary" icon={<SafetyCertificateOutlined />} loading={analysisLoading} onClick={handleAnalyze}>
{analysisLoading ? '质检进行中' : '开始AI质检'}
</Button>
)}
>
<Spin spinning={profileLoading}>
<section className="csq-ai-section csq-ai-product">
<Image className="csq-ai-product__image" width={104} height={104} src={product.hdThumbUrl} fallback="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" />
<div className="csq-ai-product__content">
<Title level={5}>{product.goodsName || goods?.goodsName || '未获取到商品名称'}</Title>
<div className="csq-ai-product__meta">
<span>商品ID:{goods?.goodsId || '--'}</span>
<span>所属店铺:{product.shopName || goods?.shopName || '--'}</span>
{(product.cate1Name || product.cate2Name || product.cate3Name) && (
<span>类目:{[product.cate1Name, product.cate2Name, product.cate3Name].filter(Boolean).join(' / ')}</span>
)}
</div>
</div>
</section>
<section className="csq-ai-section">
<div className="csq-ai-section__title">
<div><Title level={5}>质检范围</Title><Text type="secondary">一次性分析,最长支持30天</Text></div>
<RangePicker
value={range}
allowClear={false}
disabled={analysisLoading}
onChange={(value) => {
setRange(normalizeRange(value));
setAnalysisJob(null);
setAnalysisResult(null);
setAnalysisError('');
}}
disabledDate={(current, info) => {
if (current?.isAfter(dayjs(), 'day')) return true;
return info?.from ? Math.abs(current.startOf('day').diff(info.from.startOf('day'), 'day')) > 29 : false;
}}
/>
</div>
<div className="csq-ai-metrics">
{metricItems.map(([label, value]) => <div key={label}><span>{label}</span><strong>{value}</strong></div>)}
</div>
<div className="csq-ai-period-note">
<Tag>{params.startDate} 至 {params.endDate}</Tag>
<Text type="secondary">商品表现按所选周期汇总,比例指标按日均值展示。</Text>
</div>
</section>
</Spin>
<section className="csq-ai-section csq-ai-analysis-section">
<div className="csq-ai-section__title"><Title level={5}>诊断结果</Title></div>
{analysisLoading && (
<div className="csq-ai-progress" role="status" aria-live="polite">
<div className="csq-ai-progress__heading">
<div>
<strong>{progress.message || '正在准备分析聊天记录'}</strong>
<Text type="secondary">{batchProgress}</Text>
</div>
<span>{progressPercent}%</span>
</div>
<Progress percent={progressPercent} showInfo={false} status="active" />
<div className="csq-ai-progress__meta">
<Text type="secondary">已用时 {formatElapsed(elapsedSeconds)}</Text>
<Text type={pollWarning ? 'warning' : 'secondary'}>{progressHint}</Text>
</div>
</div>
)}
{analysisError && (
<Alert
className="csq-ai-progress-error"
type="error"
showIcon
message="质检未完成"
description={analysisError}
/>
)}
{!analysisLoading && (
<AiQualityResult
analysis={analysisResult?.analysis || null}
chatContext={{
goodsId: goods?.goodsId,
shopName: product.shopName || goods?.shopName,
startDate: params.startDate,
endDate: params.endDate,
}}
/>
)}
</section>
</Drawer>
);
};
export default AiQualityDrawer;

View File

@ -1,160 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Drawer, Empty, Image, Input, Pagination, Space, Spin, Table, Tag, Typography, message } from 'antd';
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { getCsQualityAnalysisHistory, getCsQualityAnalysisHistoryDetail } from '../../api/csQuality';
import AiQualityResult from './AiQualityResult';
const { Text, Title } = Typography;
const STATUS_MAP = {
succeeded: { color: 'success', text: '已完成' },
queued: { color: 'default', text: '排队中' },
running: { color: 'processing', text: '分析中' },
failed: { color: 'error', text: '失败' },
};
const AiQualityHistoryDrawer = ({ open, onClose, refreshToken = 0 }) => {
const [loading, setLoading] = useState(false);
const [rows, setRows] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [goodsId, setGoodsId] = useState('');
const [shopName, setShopName] = useState('');
const [detailOpen, setDetailOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [detail, setDetail] = useState(null);
const loadHistory = useCallback(async (nextPage = page) => {
if (!open) return;
setLoading(true);
try {
const response = await getCsQualityAnalysisHistory({ page: nextPage, pageSize: 20, goodsId: goodsId.trim(), shopName: shopName.trim() });
const payload = response?.data?.data || response?.data || response;
setRows(Array.isArray(payload.rows) ? payload.rows : []);
setTotal(Number(payload.total || 0));
setPage(Number(payload.page || nextPage));
} catch (error) {
message.error(error.response?.data?.error || error.message || '加载历史记录失败');
setRows([]);
} finally {
setLoading(false);
}
}, [open, page, goodsId, shopName]);
useEffect(() => { if (open) loadHistory(1); }, [open, refreshToken]); // eslint-disable-line react-hooks/exhaustive-deps
const showDetail = async (record) => {
setDetailOpen(true);
setDetailLoading(true);
setDetail(null);
try {
const response = await getCsQualityAnalysisHistoryDetail(record.id);
setDetail(response?.data?.data || response?.data || response);
} catch (error) {
message.error(error.response?.data?.error || error.message || '加载历史详情失败');
} finally {
setDetailLoading(false);
}
};
const columns = useMemo(() => [
{
title: '商品',
key: 'goods',
width: 320,
render: (_, record) => (
<div className="csq-history-product">
<Image width={44} height={44} src={record.productImageUrl} preview={false} />
<div><strong>{record.goodsName || record.goodsId}</strong><span>ID:{record.goodsId}</span><span>{record.shopName || '--'}</span></div>
</div>
),
},
{
title: '聊天周期', key: 'period', width: 190,
render: (_, record) => <div className="csq-history-stack"><span>{dayjs(record.chatStartDate).format('YYYY-MM-DD')} 至 {dayjs(record.chatEndDate).format('YYYY-MM-DD')}</span><span>{record.customerCount || 0} 客户 · {record.messageCount || 0} 消息</span></div>,
},
{ title: '判断', dataIndex: 'conversationType', width: 90, render: (value) => value ? <Tag>{value}</Tag> : '--' },
{
title: '状态', dataIndex: 'status', width: 90,
render: (value, record) => {
const item = STATUS_MAP[value] || { color: 'default', text: value || '--' };
const active = value === 'queued' || value === 'running';
return (
<div className="csq-history-status">
<Tag color={item.color}>{item.text}</Tag>
{active && <span>{Math.min(100, Math.max(0, Number(record.progressPercent || 0)))}%</span>}
</div>
);
},
},
{
title: '操作信息', key: 'operator', width: 180,
render: (_, record) => <div className="csq-history-stack"><span>{record.createdByName || '--'}</span><span>{record.createdAt ? dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss') : '--'}</span></div>,
},
{ title: '', key: 'action', width: 52, fixed: 'right', render: (_, record) => <Button type="text" icon={<EyeOutlined />} title="查看质检结果" onClick={() => showDetail(record)} /> },
], []);
const detailProduct = detail ? {
goodsId: detail.goods_id,
goodsName: detail.goods_name,
shopName: detail.shop_name,
imageUrl: detail.product_image_url,
} : {};
return (
<>
<Drawer title="AI质检历史记录" width={1120} open={open} onClose={onClose} destroyOnHidden extra={<Button icon={<ReloadOutlined />} loading={loading} onClick={() => loadHistory(page)}>刷新</Button>}>
<div className="csq-history-filter">
<Space wrap>
<Input value={goodsId} onChange={(event) => setGoodsId(event.target.value)} placeholder="商品ID" allowClear style={{ width: 180 }} onPressEnter={() => loadHistory(1)} />
<Input value={shopName} onChange={(event) => setShopName(event.target.value)} placeholder="店铺名称" allowClear style={{ width: 220 }} onPressEnter={() => loadHistory(1)} />
<Button type="primary" icon={<SearchOutlined />} onClick={() => loadHistory(1)}>查询</Button>
</Space>
</div>
<Table
size="small"
rowKey="id"
loading={loading}
columns={columns}
dataSource={rows}
pagination={false}
scroll={{ x: 980 }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无AI质检历史记录" /> }}
/>
{total > 20 && <div className="csq-history-pagination"><Pagination current={page} total={total} pageSize={20} showSizeChanger={false} onChange={(value) => loadHistory(value)} /></div>}
</Drawer>
<Drawer title="历史质检详情" width={920} open={detailOpen} onClose={() => setDetailOpen(false)} destroyOnHidden>
<Spin spinning={detailLoading}>
{detail ? (
<>
<section className="csq-ai-section csq-ai-product csq-history-detail-product">
<Image width={88} height={88} src={detailProduct.imageUrl} />
<div className="csq-ai-product__content">
<Title level={5}>{detailProduct.goodsName || detailProduct.goodsId}</Title>
<div className="csq-ai-product__meta">
<span>商品ID:{detailProduct.goodsId}</span><span>所属店铺:{detailProduct.shopName || '--'}</span>
<span>聊天周期:{dayjs(detail.chat_start_date).format('YYYY-MM-DD')} 至 {dayjs(detail.chat_end_date).format('YYYY-MM-DD')}</span>
<span>分析人:{detail.created_by_name || '--'}</span>
</div>
</div>
</section>
{detail.error_message && <div className="csq-error">{detail.error_message}</div>}
<AiQualityResult
analysis={detail.analysis || detail.aiResponse}
chatContext={{
goodsId: detailProduct.goodsId,
shopName: detailProduct.shopName,
startDate: dayjs(detail.chat_start_date).format('YYYY-MM-DD'),
endDate: dayjs(detail.chat_end_date).format('YYYY-MM-DD'),
}}
/>
</>
) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未读取到历史详情" />}
</Spin>
</Drawer>
</>
);
};
export default AiQualityHistoryDrawer;

View File

@ -1,516 +0,0 @@
import React, { useMemo, useRef, useState } from 'react';
import { Alert, Button, Descriptions, Empty, Image, Modal, Spin, Table, Tabs, Tag, Typography } from 'antd';
import { EyeOutlined } from '@ant-design/icons';
import { getCsQualityMessages } from '../../api/csQuality';
const { Paragraph, Text, Title } = Typography;
const TYPE_COLOR = {
售前: 'blue',
售后: 'orange',
混合: 'purple',
无有效咨询: 'default',
};
const IMAGE_URL_PATTERN = /\.(png|jpe?g|gif|webp|bmp|avif)(?:[?#].*)?$/i;
const SUMMARY_BUTTON_STYLE = {
display: 'flex',
minWidth: 0,
height: 'auto',
flexDirection: 'column',
alignItems: 'flex-start',
gap: 4,
padding: 0,
};
function formatNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? number.toLocaleString('zh-CN') : '0';
}
function formatConfidence(value) {
return `${Math.round((Number(value) || 0) * 100)}%`;
}
function getIssueTitle(record) {
return [record?.primary_reason, record?.secondary_reason].filter(Boolean).join(' / ') || '问题明细';
}
function getAffectedConversationIds(record) {
const sourceRows = Array.isArray(record?.sourceRows) ? record.sourceRows : [];
const records = [record, ...sourceRows].filter(Boolean);
return Array.from(new Set(records.flatMap((item) => [
...(Array.isArray(item.affected_conversation_ids) ? item.affected_conversation_ids : []),
...(Array.isArray(item.affectedConversationIds) ? item.affectedConversationIds : []),
...(Array.isArray(item.evidence) ? item.evidence.map((entry) => entry?.conversation_id || entry?.conversationId) : []),
]).map((value) => String(value || '').trim()).filter(Boolean)));
}
function groupChatMessages(rows, conversationIds) {
const allowedIds = new Set(conversationIds);
const groups = new Map();
rows.forEach((row, index) => {
const uid = String(row?.uid || '').trim();
if (!uid || !allowedIds.has(uid)) return;
if (!groups.has(uid)) {
groups.set(uid, {
uid,
nickname: row.nickname || uid,
csname: row.csname || '',
rows: [],
firstIndex: index,
});
}
const group = groups.get(uid);
group.nickname = group.nickname || row.nickname || uid;
group.csname = group.csname || row.csname || '';
group.rows.push(row);
});
return Array.from(groups.values()).sort((left, right) => left.firstIndex - right.firstIndex);
}
function withOccurrenceShare(sourceRows) {
const total = sourceRows.reduce((sum, row) => sum + (Number(row.occurrence_count) || 0), 0);
return sourceRows.map((row) => ({
...row,
occurrence_share: total > 0 ? (Number(row.occurrence_count) || 0) / total : 0,
}));
}
function aggregateIssueTags(sourceRows) {
const groups = new Map();
sourceRows.forEach((row) => {
const key = row.primary_reason || '其他问题';
const group = groups.get(key) || { primary_reason: key, sourceRows: [] };
group.sourceRows.push(row);
groups.set(key, group);
});
const aggregated = Array.from(groups.values()).map((group) => {
const occurrenceCount = group.sourceRows.reduce((sum, row) => sum + (Number(row.occurrence_count) || 0), 0);
const affectedCustomerCount = group.sourceRows.reduce((sum, row) => sum + (Number(row.affected_customer_count) || 0), 0);
const consultationTypes = Array.from(new Set(group.sourceRows.map((row) => row.consultation_type).filter(Boolean)));
const confidenceWeight = group.sourceRows.reduce((sum, row) => sum + ((Number(row.confidence) || 0) * (Number(row.occurrence_count) || 1)), 0);
const confidenceBase = group.sourceRows.reduce((sum, row) => sum + (Number(row.occurrence_count) || 1), 0);
const evidence = group.sourceRows.flatMap((row) => Array.isArray(row.evidence) ? row.evidence : []).filter((item, index, list) => (
list.findIndex((candidate) => `${candidate.conversation_id}|${candidate.message_time}|${candidate.text}` === `${item.conversation_id}|${item.message_time}|${item.text}`) === index
));
const affectedConversationIds = getAffectedConversationIds({ sourceRows: group.sourceRows });
return {
...group,
consultation_type: consultationTypes.length === 1 ? consultationTypes[0] : '混合',
secondary_reason: `${group.sourceRows.length} 个细分问题`,
occurrence_count: occurrenceCount,
affected_customer_count: affectedCustomerCount,
affected_conversation_ids: affectedConversationIds,
confidence: confidenceBase > 0 ? confidenceWeight / confidenceBase : 0,
evidence,
product_improvement_areas: Array.from(new Set(group.sourceRows.flatMap((row) => row.product_improvement_areas || []))),
image_urls: Array.from(new Set(group.sourceRows.flatMap((row) => row.image_urls || []))),
improvement_suggestion: Array.from(new Set(group.sourceRows.map((row) => row.improvement_suggestion).filter(Boolean))).join(';'),
};
});
return withOccurrenceShare(aggregated).sort((left, right) => right.occurrence_count - left.occurrence_count);
}
const AiQualityResult = ({ analysis, chatContext = {} }) => {
const [categoryDrawer, setCategoryDrawer] = useState(null);
const [evidenceRecord, setEvidenceRecord] = useState(null);
const [detailTab, setDetailTab] = useState('all-chats');
const [relatedChats, setRelatedChats] = useState({ loading: false, rows: [], error: '' });
const [expandedCustomerIds, setExpandedCustomerIds] = useState([]);
const chatRequestRef = useRef(0);
const rows = Array.isArray(analysis?.results) ? analysis.results : [];
const summary = analysis?.summary || {};
const activeCategoryDrawer = categoryDrawer?.analysis === analysis ? categoryDrawer : null;
const activeEvidenceRecord = evidenceRecord?.analysis === analysis ? evidenceRecord.record : null;
const relatedConversationIds = useMemo(
() => getAffectedConversationIds(activeEvidenceRecord),
[activeEvidenceRecord]
);
const groupedRelatedChats = useMemo(
() => groupChatMessages(relatedChats.rows, relatedConversationIds),
[relatedChats.rows, relatedConversationIds]
);
const openEvidenceDetail = (record) => {
const requestId = chatRequestRef.current + 1;
chatRequestRef.current = requestId;
setEvidenceRecord({ analysis, record });
setDetailTab('all-chats');
setExpandedCustomerIds([]);
const conversationIds = getAffectedConversationIds(record);
const goodsId = String(chatContext.goodsId || '').trim();
const startDate = String(chatContext.startDate || '').trim();
const endDate = String(chatContext.endDate || '').trim();
if (!goodsId || !startDate || !endDate || conversationIds.length === 0) {
setRelatedChats({
loading: false,
rows: [],
error: conversationIds.length === 0
? '当前质检记录没有保存涉及客户信息,暂无可关联的完整聊天记录。'
: '当前质检记录缺少商品或聊天周期,无法读取完整聊天记录。',
});
return;
}
setRelatedChats({ loading: true, rows: [], error: '' });
getCsQualityMessages({
goodsId,
startDate,
endDate,
shopName: chatContext.shopName || '',
}).then((response) => {
if (chatRequestRef.current !== requestId) return;
const payload = response?.data?.data || response?.data || response;
const nextRows = Array.isArray(payload?.rows) ? payload.rows : [];
const allowedConversationIds = new Set(conversationIds);
const firstAffectedRow = nextRows.find((row) => allowedConversationIds.has(String(row?.uid || '').trim()));
setRelatedChats({
loading: false,
rows: nextRows,
error: '',
});
setExpandedCustomerIds(firstAffectedRow ? [String(firstAffectedRow.uid).trim()] : []);
}).catch((error) => {
if (chatRequestRef.current !== requestId) return;
setRelatedChats({
loading: false,
rows: [],
error: error.response?.data?.error || error.message || '完整聊天记录加载失败',
});
});
};
const closeEvidenceDetail = () => {
chatRequestRef.current += 1;
setEvidenceRecord(null);
setRelatedChats({ loading: false, rows: [], error: '' });
setExpandedCustomerIds([]);
};
const openCategoryDrawer = (title, filterType = '', aggregate = false) => {
const filteredRows = filterType ? rows.filter((row) => row.consultation_type === filterType) : rows;
setCategoryDrawer({
title,
filterType,
aggregate,
analysis,
rows: aggregate ? aggregateIssueTags(filteredRows) : withOccurrenceShare(filteredRows),
});
};
const columns = [
{
title: '类型',
dataIndex: 'consultation_type',
width: 72,
render: (value) => <Tag color={TYPE_COLOR[value] || 'default'}>{value || '--'}</Tag>,
},
{ title: '一级原因', dataIndex: 'primary_reason', width: 130, ellipsis: true },
{ title: '二级原因', dataIndex: 'secondary_reason', width: 150, ellipsis: true },
{
title: '问题次数',
dataIndex: 'occurrence_count',
width: 88,
align: 'right',
render: formatNumber,
},
{
title: '涉及客户',
dataIndex: 'affected_customer_count',
width: 88,
align: 'right',
render: formatNumber,
},
{
title: '置信度',
dataIndex: 'confidence',
width: 86,
align: 'right',
render: formatConfidence,
},
{
title: '操作',
key: 'action',
width: 98,
fixed: 'right',
render: (_value, record) => (
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => openEvidenceDetail(record)}
>
查看明细
</Button>
),
},
];
const categoryColumns = [
{
title: '类型',
dataIndex: 'consultation_type',
width: 72,
render: (value) => <Tag color={TYPE_COLOR[value] || 'default'}>{value || '--'}</Tag>,
},
{
title: '问题标签',
key: 'reason',
render: (_value, record) => <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}><strong>{record.primary_reason || '--'}</strong><Text type="secondary">{record.secondary_reason || '--'}</Text></div>,
},
{ title: '问题次数', dataIndex: 'occurrence_count', width: 96, align: 'right', render: formatNumber },
{ title: '问题占比', dataIndex: 'occurrence_share', width: 96, align: 'right', render: (value) => formatConfidence(value) },
{ title: '涉及客户', dataIndex: 'affected_customer_count', width: 96, align: 'right', render: formatNumber },
{ title: '置信度', dataIndex: 'confidence', width: 86, align: 'right', render: formatConfidence },
{
title: '操作',
key: 'action',
width: 98,
fixed: 'right',
render: (_value, record) => <Button type="link" size="small" icon={<EyeOutlined />} onClick={() => openEvidenceDetail(record)}>聊天明细</Button>,
},
];
if (!analysis) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="请选择日期后开始AI质检" />;
}
const renderAllChats = (record) => {
const messageCount = groupedRelatedChats.reduce((sum, group) => sum + group.rows.length, 0);
const expectedCustomerCount = Number(record.affected_customer_count) || 0;
const hasIncompleteCustomerIds = expectedCustomerCount > relatedConversationIds.length;
return (
<Spin spinning={relatedChats.loading}>
<div className="csq-ai-chat-summary">
<Text type="secondary">
已关联 {formatNumber(groupedRelatedChats.length)} 个客户、{formatNumber(messageCount)} 条完整聊天记录
</Text>
</div>
{hasIncompleteCustomerIds && !relatedChats.loading && (
<Alert
type="warning"
showIcon
message="这条历史质检记录只保存了部分涉及客户"
description={`质检结果记录了 ${formatNumber(expectedCustomerCount)} 个涉及客户,当前可识别 ${formatNumber(relatedConversationIds.length)} 个。重新质检后会保存全部涉及客户。`}
/>
)}
{relatedChats.error && <Alert type="warning" showIcon message={relatedChats.error} />}
{!relatedChats.loading && !relatedChats.error && groupedRelatedChats.length === 0 && (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未读取到涉及客户的聊天记录" />
)}
<div className="csq-ai-chat-collapse">
{groupedRelatedChats.map((group) => (
<details
className="csq-ai-chat-customer"
key={group.uid}
open={expandedCustomerIds.includes(group.uid)}
onToggle={(event) => {
const uid = group.uid;
setExpandedCustomerIds((current) => event.currentTarget.open
? Array.from(new Set([...current, uid]))
: current.filter((value) => value !== uid));
}}
>
<summary className="csq-ai-chat-customer-heading">
<div>
<strong>{group.nickname || group.uid}</strong>
<Text type="secondary">UID:{group.uid}</Text>
</div>
<Tag>{formatNumber(group.rows.length)} 条消息</Tag>
</summary>
<div className="csq-ai-chat-customer-body">
<div className="csq-ai-chat-messages">
{group.rows.map((item, index) => {
const isService = item.directionLabel === '客服';
return (
<div
className={`csq-ai-chat-message csq-ai-chat-message--${isService ? 'service' : 'buyer'}`}
key={`${item.msgId || item.msgTime || index}-${index}`}
>
<div className="csq-ai-chat-message__meta">
<Tag color={isService ? 'green' : 'blue'}>{item.directionLabel || '未标记'}</Tag>
{item.csname ? <span>客服:{item.csname}</span> : null}
<span>{item.msgTime || '--'}</span>
</div>
<Paragraph>{item.content || '--'}</Paragraph>
</div>
);
})}
</div>
</div>
</details>
))}
</div>
</Spin>
);
};
const renderKeyEvidence = (record) => (
<>
{Array.isArray(record.evidence) && record.evidence.length > 0 ? (
<div className="csq-ai-evidence-list">
{record.evidence.map((item, index) => (
<blockquote key={`${item.text}-${index}`}>
<span>{item.nickname || item.conversation_id || '买家'}{item.message_time ? ` · ${item.message_time}` : ''}</span>
<Paragraph>{item.text || '--'}</Paragraph>
</blockquote>
))}
</div>
) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无可展示的关键证据" />}
{Array.isArray(record.image_urls) && record.image_urls.some((url) => IMAGE_URL_PATTERN.test(String(url))) && (
<div className="csq-ai-result__images">
{record.image_urls.filter((url) => IMAGE_URL_PATTERN.test(String(url))).map((url) => <Image key={url} width={72} height={72} src={url} />)}
</div>
)}
</>
);
const renderRecommendation = (record) => (
<div className="csq-ai-result__recommendation">
{Array.isArray(record.product_improvement_areas) && record.product_improvement_areas.length > 0 && (
<div className="csq-ai-result__tags">
<Text strong>影响环节:</Text>
{record.product_improvement_areas.map((item) => <Tag key={item}>{item}</Tag>)}
</div>
)}
{record.improvement_suggestion && <div><Text strong>改进建议:</Text>{record.improvement_suggestion}</div>}
</div>
);
const renderDetailPane = (content, record) => (
<div className="csq-ai-detail-pane">
{content}
{renderRecommendation(record)}
</div>
);
const renderEvidence = (record) => (
<div className="csq-ai-result__detail">
<Descriptions size="small" column={2} bordered>
<Descriptions.Item label="咨询类型"><Tag color={TYPE_COLOR[record.consultation_type] || 'default'}>{record.consultation_type || '--'}</Tag></Descriptions.Item>
<Descriptions.Item label="置信度">{formatConfidence(record.confidence)}</Descriptions.Item>
<Descriptions.Item label="问题次数">{formatNumber(record.occurrence_count)}</Descriptions.Item>
<Descriptions.Item label="涉及客户">{formatNumber(record.affected_customer_count)}</Descriptions.Item>
</Descriptions>
{record.buyer_intent && <div><Text strong>买家诉求:</Text>{record.buyer_intent}</div>}
{Array.isArray(record.sourceRows) && record.sourceRows.length > 0 && (
<div className="csq-ai-result__tags">
<Text strong>细分问题:</Text>
{record.sourceRows.map((item, index) => <Tag key={`${item.secondary_reason}-${index}`}>{item.secondary_reason || '--'} · {formatNumber(item.occurrence_count)} 次</Tag>)}
</div>
)}
<Tabs
activeKey={detailTab}
onChange={setDetailTab}
items={[
{
key: 'all-chats',
label: `全部聊天记录 (${formatNumber(relatedConversationIds.length)})`,
children: renderDetailPane(renderAllChats(record), record),
},
{
key: 'key-evidence',
label: `关键证据 (${formatNumber(record.evidence?.length || 0)})`,
children: renderDetailPane(renderKeyEvidence(record), record),
},
]}
/>
</div>
);
return (
<div className="csq-ai-result">
<div className="csq-ai-result__summary">
<div>
<Text type="secondary">综合判断</Text>
<Tag color={TYPE_COLOR[analysis.conversation_type] || 'default'}>{analysis.conversation_type || '--'}</Tag>
</div>
<div><Text type="secondary">会话数</Text><strong>{formatNumber(summary.conversation_count)}</strong></div>
<Button type="text" style={SUMMARY_BUTTON_STYLE} onClick={() => openCategoryDrawer('售前会话明细', '售前')}>
<Text type="secondary">售前会话</Text><strong>{formatNumber(summary.pre_sale_conversation_count)}</strong>
</Button>
<Button type="text" style={SUMMARY_BUTTON_STYLE} onClick={() => openCategoryDrawer('售后会话明细', '售后')}>
<Text type="secondary">售后会话</Text><strong>{formatNumber(summary.after_sale_conversation_count)}</strong>
</Button>
<Button type="text" style={SUMMARY_BUTTON_STYLE} onClick={() => openCategoryDrawer('问题标签明细', '', true)}>
<Text type="secondary">问题标签</Text><strong>{formatNumber(rows.length)}</strong>
</Button>
</div>
{Array.isArray(summary.main_findings) && summary.main_findings.length > 0 && (
<div className="csq-ai-findings">
<Text strong>主要发现</Text>
<ul>{summary.main_findings.map((item, index) => <li key={`${item}-${index}`}>{item}</li>)}</ul>
</div>
)}
<Table
className="csq-ai-result__table"
size="small"
rowKey={(_record, index) => `quality-result-${index}`}
columns={columns}
dataSource={rows}
pagination={false}
scroll={{ x: 820 }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未识别到有效咨询问题" /> }}
/>
<Modal
title={activeCategoryDrawer?.title || '问题标签明细'}
width={860}
open={Boolean(activeCategoryDrawer)}
onCancel={() => setCategoryDrawer(null)}
footer={null}
destroyOnHidden
styles={{
body: {
maxHeight: 'calc(100vh - 220px)',
overflowY: 'auto',
paddingRight: 4,
},
}}
>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 16, marginBottom: 16, paddingBottom: 12, borderBottom: '1px solid var(--sycm-border-light)', flexWrap: 'wrap' }}>
<Title level={5} style={{ margin: 0 }}>{activeCategoryDrawer?.filterType ? `${activeCategoryDrawer.filterType}问题分类` : '问题分类汇总'}</Title>
<Text type="secondary">
{activeCategoryDrawer?.aggregate
? `共 ${formatNumber(activeCategoryDrawer?.rows?.length || 0)} 类问题,包含 ${formatNumber(rows.length)} 个细分标签`
: `共 ${formatNumber(activeCategoryDrawer?.rows?.length || 0)} 个问题标签`}
</Text>
</div>
<Table
size="small"
rowKey={(_record, index) => `quality-category-${index}`}
columns={categoryColumns}
dataSource={activeCategoryDrawer?.rows || []}
pagination={false}
scroll={{ x: 760 }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="该类会话暂无问题标签" /> }}
/>
</Modal>
<Modal
className="csq-ai-detail-modal"
title={activeEvidenceRecord ? `${getIssueTitle(activeEvidenceRecord)}明细` : '问题明细'}
width={960}
open={Boolean(activeEvidenceRecord)}
onCancel={closeEvidenceDetail}
footer={null}
destroyOnHidden
styles={{
body: {
height: 'min(720px, calc(100vh - 180px))',
overflow: 'hidden',
paddingRight: 4,
},
}}
>
{activeEvidenceRecord ? renderEvidence(activeEvidenceRecord) : null}
</Modal>
</div>
);
};
export default AiQualityResult;

View File

@ -1,634 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Button,
DatePicker,
Empty,
Image,
Input,
Pagination,
Space,
Spin,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd';
import {
DownloadOutlined,
HistoryOutlined,
MessageOutlined,
SafetyCertificateOutlined,
SearchOutlined,
UserOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { SycmBusinessPanel, SycmPage } from '../../components/Common';
import {
getCsQualityDataMeta,
getCsQualityGoods,
getCsQualityCustomers,
getCsQualityMessages,
} from '../../api/csQuality';
import AiQualityDrawer from './AiQualityDrawer';
import AiQualityHistoryDrawer from './AiQualityHistoryDrawer';
import './style.css';
const { RangePicker } = DatePicker;
const { Text, Paragraph } = Typography;
const DEFAULT_DATE = dayjs().subtract(1, 'day');
const DEFAULT_RANGE = [DEFAULT_DATE, DEFAULT_DATE];
const PAGE_SIZE = 20;
const CHAT_IMAGE_URL_RE = /^https?:\/\/\S+$/i;
const CHAT_IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|avif)(?:[?#].*)?$/i;
const CHAT_VIDEO_EXT_RE = /\.(mp4|mov|webm|m4v)(?:[?#].*)?$/i;
function formatNumber(value) {
const num = Number(value);
if (!Number.isFinite(num)) return '0';
return num.toLocaleString('zh-CN');
}
function formatText(value) {
if (value === null || value === undefined || value === '') return '--';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
function buildSingleDayRange(value) {
const date = dayjs(value);
return date.isValid() ? [date, date] : DEFAULT_RANGE;
}
function isServiceDirection(value) {
return String(value || '').includes('客服');
}
function getDirectionTone(value) {
if (isServiceDirection(value)) return 'outbound';
return 'inbound';
}
function getChatImageUrl(value) {
const text = String(value || '').trim();
if (!text || !CHAT_IMAGE_URL_RE.test(text)) return '';
try {
const url = new window.URL(text);
if (CHAT_VIDEO_EXT_RE.test(url.pathname || '')) return '';
if (CHAT_IMAGE_EXT_RE.test(url.pathname || '')) {
return text;
}
} catch { /* 忽略 */ }
return '';
}
function sortChatRows(rows = []) {
return [...rows].sort((left, right) => {
const leftTime = dayjs(left?.msgTime).valueOf();
const rightTime = dayjs(right?.msgTime).valueOf();
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return String(left?.msgTime || '').localeCompare(String(right?.msgTime || ''), 'zh-CN');
});
}
function downloadTextFile(filename, content) {
const blob = new Blob([content], { type: 'text/plain;charset=utf-8;' });
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 CustomerServiceQuality = () => {
const [goodsLoading, setGoodsLoading] = useState(false);
const [customersLoading, setCustomersLoading] = useState(false);
const [messagesLoading, setMessagesLoading] = useState(false);
const [exportingGoodsId, setExportingGoodsId] = useState('');
const [goodsRows, setGoodsRows] = useState([]);
const [customerRows, setCustomerRows] = useState([]);
const [messageRows, setMessageRows] = useState([]);
const [selectedGoods, setSelectedGoods] = useState(null);
const [selectedCustomer, setSelectedCustomer] = useState(null);
const [tableName, setTableName] = useState('');
const [dataMeta, setDataMeta] = useState({ minDate: '', latestDate: '', messageCount: 0 });
const [errorText, setErrorText] = useState('');
const [goodsPage, setGoodsPage] = useState(1);
const [customersPage, setCustomersPage] = useState(1);
const [messagesPage, setMessagesPage] = useState(1);
const [dateRange, setDateRange] = useState(DEFAULT_RANGE);
const [shopName, setShopName] = useState('');
const [goodsId, setGoodsId] = useState('');
const [aiGoods, setAiGoods] = useState(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyRefreshToken, setHistoryRefreshToken] = useState(0);
const goodsRequestRef = useRef(0);
const customersRequestRef = useRef(0);
const messagesRequestRef = useRef(0);
const buildBaseParams = useCallback((filters) => {
const nextFilters = filters || { dateRange, shopName, goodsId };
const params = {};
if (nextFilters.dateRange?.length === 2) {
params.startDate = nextFilters.dateRange[0].format('YYYY-MM-DD');
params.endDate = nextFilters.dateRange[1].format('YYYY-MM-DD');
}
if (nextFilters.shopName) params.shopName = String(nextFilters.shopName).trim();
if (nextFilters.goodsId) params.goodsId = String(nextFilters.goodsId).trim();
return params;
}, [dateRange, shopName, goodsId]);
const loadMessages = useCallback(async (customerRecord, goodsRecord = selectedGoods, filters) => {
if (!goodsRecord?.goodsId || !customerRecord?.uid) return;
const requestId = messagesRequestRef.current + 1;
messagesRequestRef.current = requestId;
setSelectedCustomer(customerRecord);
setMessageRows([]);
setMessagesPage(1);
setMessagesLoading(true);
try {
const response = await getCsQualityMessages({
...buildBaseParams(filters),
goodsId: goodsRecord.goodsId,
uid: customerRecord.uid,
});
if (requestId !== messagesRequestRef.current) return;
const data = response?.data || response;
const payload = data?.data || data;
setMessageRows(Array.isArray(payload.rows) ? payload.rows : []);
setSelectedCustomer(customerRecord);
setMessagesPage(1);
setErrorText('');
} catch (error) {
if (requestId !== messagesRequestRef.current) return;
const msg = error.response?.data?.error || error.message || '加载聊天内容失败';
message.error(msg);
setErrorText(msg);
setMessageRows([]);
} finally {
if (requestId === messagesRequestRef.current) setMessagesLoading(false);
}
}, [selectedGoods, buildBaseParams]);
const loadCustomers = useCallback(async (goodsRecord, filters) => {
if (!goodsRecord?.goodsId) return;
const requestId = customersRequestRef.current + 1;
customersRequestRef.current = requestId;
messagesRequestRef.current += 1;
setCustomersLoading(true);
setMessagesLoading(false);
setSelectedGoods(goodsRecord);
setCustomersPage(1);
setMessagesPage(1);
try {
const baseParams = buildBaseParams(filters);
const response = await getCsQualityCustomers({
...baseParams,
goodsId: goodsRecord.goodsId,
snapshotScopeGoodsId: baseParams.goodsId || undefined,
});
if (requestId !== customersRequestRef.current) return;
const data = response?.data || response;
const payload = data?.data || data;
const rows = Array.isArray(payload.rows) ? payload.rows : [];
setCustomerRows(rows);
setErrorText('');
if (rows[0]) {
await loadMessages(rows[0], goodsRecord, filters);
} else {
setMessageRows([]);
setSelectedCustomer(null);
}
} catch (error) {
if (requestId !== customersRequestRef.current) return;
const msg = error.response?.data?.error || error.message || '加载客户列表失败';
message.error(msg);
setErrorText(msg);
setCustomerRows([]);
setMessageRows([]);
setSelectedCustomer(null);
} finally {
if (requestId === customersRequestRef.current) setCustomersLoading(false);
}
}, [buildBaseParams, loadMessages]);
const loadGoods = useCallback(async (filters) => {
const requestId = goodsRequestRef.current + 1;
goodsRequestRef.current = requestId;
customersRequestRef.current += 1;
messagesRequestRef.current += 1;
setGoodsLoading(true);
setCustomersLoading(false);
setMessagesLoading(false);
try {
const response = await getCsQualityGoods({
...buildBaseParams(filters),
includeInitial: true,
});
if (requestId !== goodsRequestRef.current) return;
const data = response?.data || response;
if (!data?.success && data?.success !== undefined) throw new Error(data?.error || '加载商品聊天列表失败');
const payload = data?.data || data;
const rows = Array.isArray(payload.rows) ? payload.rows : [];
setTableName(payload.tableName || '');
setGoodsRows(rows);
setGoodsPage(1);
setCustomersPage(1);
setMessagesPage(1);
setErrorText('');
if (rows[0]) {
const initial = payload.initial;
const hasInitial = String(initial?.goodsId || '') === String(rows[0].goodsId || '')
&& Array.isArray(initial?.customers);
if (hasInitial) {
const initialCustomers = initial.customers;
const initialCustomer = initialCustomers[0] || null;
setSelectedGoods(rows[0]);
setCustomerRows(initialCustomers);
setSelectedCustomer(initialCustomer);
if (initialCustomer && initial.messagesPrefetched
&& String(initial.customerUid || '') === String(initialCustomer.uid || '')) {
setMessageRows(Array.isArray(initial.messages) ? initial.messages : []);
} else if (initialCustomer) {
await loadMessages(initialCustomer, rows[0], filters);
} else {
setMessageRows([]);
}
} else {
await loadCustomers(rows[0], filters);
}
} else {
setCustomerRows([]);
setMessageRows([]);
setSelectedGoods(null);
setSelectedCustomer(null);
}
} catch (error) {
if (requestId !== goodsRequestRef.current) return;
const msg = error.response?.data?.error || error.message || '加载商品聊天列表失败';
message.error(msg);
setErrorText(msg);
setGoodsRows([]);
setCustomerRows([]);
setMessageRows([]);
setSelectedGoods(null);
setSelectedCustomer(null);
} finally {
if (requestId === goodsRequestRef.current) setGoodsLoading(false);
}
}, [buildBaseParams, loadCustomers, loadMessages]);
useEffect(() => {
let active = true;
const initialize = async () => {
let initialRange = DEFAULT_RANGE;
try {
const response = await getCsQualityDataMeta();
if (!active) return;
const data = response?.data || response;
const payload = data?.data || data;
setDataMeta({
minDate: payload?.minDate || '',
latestDate: payload?.latestDate || '',
messageCount: Number(payload?.messageCount || 0),
});
if (payload?.latestDate) {
initialRange = buildSingleDayRange(payload.latestDate);
setDateRange(initialRange);
}
} catch (error) {
console.warn('[CustomerServiceQuality] 加载聊天数据日期范围失败:', error.message || error);
}
if (active) loadGoods({ dateRange: initialRange });
};
initialize();
return () => { active = false; };
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleQuery = () => loadGoods();
const handleReset = () => {
const next = {
dateRange: dataMeta.latestDate ? buildSingleDayRange(dataMeta.latestDate) : DEFAULT_RANGE,
shopName: '',
goodsId: '',
};
setDateRange(next.dateRange);
setShopName(next.shopName);
setGoodsId(next.goodsId);
loadGoods(next);
};
const handleOpenAiQuality = (goodsRecord, event) => {
event?.stopPropagation?.();
setAiGoods(goodsRecord);
};
const handleDownloadGoodsChat = async (goodsRecord, event) => {
event?.stopPropagation?.();
if (!goodsRecord?.goodsId) return;
setExportingGoodsId(String(goodsRecord.goodsId));
try {
const response = await getCsQualityMessages({
...buildBaseParams(),
goodsId: goodsRecord.goodsId,
});
const data = response?.data || response;
const payload = data?.data || data;
const rows = Array.isArray(payload.rows) ? payload.rows : [];
if (!rows.length) {
message.warning('当前筛选时间段内没有可下载的聊天记录');
return;
}
const sorted = sortChatRows(rows);
const customerMap = new Map();
sorted.forEach((row) => {
const key = String(row?.uid || 'unknown');
if (!customerMap.has(key)) customerMap.set(key, { uid: row?.uid, nickname: row?.nickname, rows: [] });
customerMap.get(key).rows.push(row);
});
const header = [
'商品聊天记录导出',
`商品ID:${goodsRecord.goodsId || '--'}`,
`商品名称:${goodsRecord.goodsName || '--'}`,
`店铺名称:${goodsRecord.shopName || '--'}`,
`时间范围:${dateRange?.[0]?.format('YYYY-MM-DD') || '--'} ~ ${dateRange?.[1]?.format('YYYY-MM-DD') || '--'}`,
`导出时间:${dayjs().format('YYYY-MM-DD HH:mm:ss')}`,
`消息总数:${sorted.length}`,
`客户总数:${customerMap.size}`,
''.padEnd(64, '='),
];
const blocks = Array.from(customerMap.values()).map((c) => {
const blockHeader = [`客户:${c.nickname || '--'} (UID: ${c.uid || '--'})`, `消息数:${c.rows.length}`, ''.padEnd(64, '-')];
const lines = c.rows.map((r) => `[${r?.msgTime || '--'} | ${r?.directionLabel || ''}${r?.csname ? ` | 客服:${r.csname}` : ''}]\r\n${r?.content || '--'}`);
return [...blockHeader, ...lines].join('\r\n');
});
downloadTextFile(`聊天记录_${goodsRecord.goodsId}_${dateRange?.[0]?.format('YYYYMMDD') || 'start'}_${dateRange?.[1]?.format('YYYYMMDD') || 'end'}.txt`, [...header, ...blocks].join('\r\n\r\n'));
message.success(`已下载 ${sorted.length} 条聊天记录`);
} catch (error) {
message.error(error.message || '下载失败');
} finally {
setExportingGoodsId('');
}
};
const goodsColumns = useMemo(() => [
{
title: '商品',
dataIndex: 'goodsName',
key: 'goodsName',
render: (_value, record) => (
<div className="csq-goods-cell">
{record.hdThumbUrl ? (
<img className="csq-goods-thumb" src={record.hdThumbUrl} alt={record.goodsName || ''} />
) : (
<span className="csq-goods-thumb is-empty">暂无</span>
)}
<div className="csq-cell-main">
<span className="csq-cell-primary">{record.goodsName || record.goodsId || '--'}</span>
<span className="csq-cell-secondary">ID:{record.goodsId || '--'}</span>
{record.shopName ? <span className="csq-cell-secondary">{record.shopName}</span> : null}
</div>
</div>
),
},
{
title: '消息',
dataIndex: 'messageCount',
key: 'messageCount',
width: 150,
render: (value, record) => (
<div className="csq-count-cell">
<span className="csq-cell-primary">{formatNumber(value)}</span>
<span className="csq-cell-secondary">{formatNumber(record.userCount)} 客户</span>
<Button
type="link"
size="small"
icon={<DownloadOutlined />}
loading={exportingGoodsId === String(record.goodsId)}
className="csq-download-btn"
onClick={(e) => handleDownloadGoodsChat(record, e)}
>
下载
</Button>
</div>
),
},
{
title: '',
key: 'aiQuality',
width: 48,
align: 'center',
render: (_value, record) => (
<Tooltip title="AI质检">
<Button
type="text"
className="csq-ai-action"
icon={<SafetyCertificateOutlined />}
onClick={(event) => handleOpenAiQuality(record, event)}
/>
</Tooltip>
),
},
], [exportingGoodsId, dateRange, shopName, goodsId]); // eslint-disable-line react-hooks/exhaustive-deps
const customerColumns = useMemo(() => [
{
title: '客户',
dataIndex: 'uid',
key: 'uid',
render: (_value, record) => (
<div className="csq-customer-cell">
<div className="csq-customer-avatar"><UserOutlined /></div>
<div className="csq-cell-main">
<span className="csq-cell-primary">{record.nickname || record.uid || '--'}</span>
<span className="csq-cell-secondary">{record.uid || '--'}</span>
{record.csname ? <span className="csq-cell-secondary">客服:{record.csname}</span> : null}
</div>
</div>
),
},
{
title: '消息',
dataIndex: 'messageCount',
key: 'messageCount',
width: 80,
align: 'right',
render: (value) => <span className="csq-cell-primary">{formatNumber(value)}</span>,
},
], []);
const selectedGoodsTitle = selectedGoods ? `${selectedGoods.goodsId} 的客户` : '第二级:点击商品查看客户';
const selectedCustomerTitle = selectedCustomer ? `${selectedCustomer.nickname || selectedCustomer.uid} 的聊天` : '第三级:点击客户查看聊天';
const pagedGoodsRows = goodsRows.slice((goodsPage - 1) * PAGE_SIZE, goodsPage * PAGE_SIZE);
const pagedCustomerRows = customerRows.slice((customersPage - 1) * PAGE_SIZE, customersPage * PAGE_SIZE);
const pagedMessageRows = messageRows.slice((messagesPage - 1) * PAGE_SIZE, messagesPage * PAGE_SIZE);
return (
<SycmPage className="csq-page">
<div className="csq-panel">
<SycmBusinessPanel className="csq-panel-inner" aria-label="客服质检">
<div className="csq-panel__heading">
<div className="csq-panel__heading-main">
<h2>客服质检</h2>
<span>按商品、客服和消息方向查看聊天记录</span>
</div>
<Button icon={<HistoryOutlined />} onClick={() => setHistoryOpen(true)}>历史记录</Button>
</div>
<div className="csq-filter">
<Space size={12} wrap>
<RangePicker
value={dateRange}
onChange={setDateRange}
allowClear={false}
disabledDate={(current) => (
(dataMeta.minDate && current?.isBefore(dayjs(dataMeta.minDate), 'day'))
|| (dataMeta.latestDate && current?.isAfter(dayjs(dataMeta.latestDate), 'day'))
)}
/>
<Input placeholder="店铺名称" value={shopName} onChange={(e) => setShopName(e.target.value)} onPressEnter={handleQuery} allowClear style={{ width: 180 }} />
<Input placeholder="商品ID" value={goodsId} onChange={(e) => setGoodsId(e.target.value)} onPressEnter={handleQuery} allowClear style={{ width: 160 }} />
<Button type="primary" icon={<SearchOutlined />} loading={goodsLoading} onClick={handleQuery}>查询</Button>
<Button onClick={handleReset}>重置</Button>
</Space>
</div>
{errorText && <div className="csq-error">{errorText}</div>}
<div className="csq-info">
数据来源:{tableName || 'PDD 商品 T-1 聊天记录数据表'};
数据范围:{dataMeta.minDate && dataMeta.latestDate ? `${dataMeta.minDate} 至 ${dataMeta.latestDate}` : '--'};
累计消息 {formatNumber(dataMeta.messageCount)} 条;
当前商品 {goodsRows.length} 个
</div>
<div className="csq-drill-grid">
<div className="csq-drill-card">
<div className="csq-drill-card__header">
<span>第一级:商品ID</span>
<span className="csq-drill-card__extra">{formatNumber(goodsRows.length)} 个商品</span>
</div>
<div className="csq-scroll-area">
<Table
size="small"
rowKey="goodsId"
loading={goodsLoading}
columns={goodsColumns}
dataSource={pagedGoodsRows}
pagination={false}
rowClassName={(record) => selectedGoods?.goodsId === record.goodsId ? 'csq-drill-row csq-drill-row--active' : 'csq-drill-row'}
onRow={(record) => ({ onClick: () => { setCustomersPage(1); loadCustomers(record); } })}
locale={{ emptyText: <Empty description="暂无商品聊天记录" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
/>
</div>
{goodsRows.length > PAGE_SIZE && (
<div className="csq-drill-card__footer">
<Pagination size="small" current={goodsPage} total={goodsRows.length} pageSize={PAGE_SIZE} onChange={setGoodsPage} showSizeChanger={false} />
</div>
)}
</div>
<div className="csq-drill-card">
<div className="csq-drill-card__header">
<span>{selectedGoodsTitle}</span>
<span className="csq-drill-card__extra">{formatNumber(customerRows.length)} 个客户</span>
</div>
{selectedGoods ? (
<div className="csq-scroll-area">
<Spin spinning={customersLoading} className="csq-table-spin">
<Table
size="small"
rowKey="uid"
columns={customerColumns}
dataSource={pagedCustomerRows}
pagination={false}
rowClassName={(record) => selectedCustomer?.uid === record.uid ? 'csq-drill-row csq-drill-row--active' : 'csq-drill-row'}
onRow={(record) => ({ onClick: () => loadMessages(record, selectedGoods) })}
locale={{ emptyText: <Empty description="该商品暂无客户聊天记录" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
/>
</Spin>
</div>
) : (
<div className="csq-scroll-area"><Empty description="先点击左侧商品" image={Empty.PRESENTED_IMAGE_SIMPLE} /></div>
)}
{customerRows.length > PAGE_SIZE && (
<div className="csq-drill-card__footer">
<Pagination size="small" current={customersPage} total={customerRows.length} pageSize={PAGE_SIZE} onChange={setCustomersPage} showSizeChanger={false} />
</div>
)}
</div>
<div className="csq-drill-card csq-message-card">
<div className="csq-drill-card__header">
<span>{selectedCustomerTitle}</span>
<span className="csq-drill-card__extra">{formatNumber(messageRows.length)} 条消息</span>
</div>
{selectedCustomer ? (
<div className="csq-scroll-area csq-message-scroll">
<Spin spinning={messagesLoading}>
<div className="csq-message-list">
{pagedMessageRows.length ? pagedMessageRows.map((item, index) => {
const imageUrl = getChatImageUrl(item.content);
return (
<div className={`csq-message csq-message--${getDirectionTone(item.directionLabel)}`} key={`${item.msgId || item.msgTime || index}-${index}`}>
<div className="csq-message__meta">
<Tag color={isServiceDirection(item.directionLabel) ? 'green' : 'blue'}>{item.directionLabel || '未标记'}</Tag>
{item.csname ? <span className="csq-cell-secondary">客服:{item.csname}</span> : null}
{item.nickname ? <span className="csq-cell-secondary">买家:{item.nickname}</span> : null}
<span className="csq-cell-secondary">{formatText(item.msgTime)}</span>
</div>
<div className={`csq-message__bubble${imageUrl ? ' csq-message__bubble--image' : ''}`}>
{imageUrl ? (
<Image className="csq-message__image" src={imageUrl} alt="聊天图片" preview={{ mask: '预览' }} />
) : (
<>
<MessageOutlined />
<Paragraph style={{ marginBottom: 0 }} ellipsis={{ rows: 4, expandable: true, symbol: '展开' }}>
{formatText(item.content)}
</Paragraph>
</>
)}
</div>
</div>
);
}) : (
<Empty description="暂无聊天内容" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div>
</Spin>
</div>
) : (
<div className="csq-scroll-area"><Empty description="先点击中间客户" image={Empty.PRESENTED_IMAGE_SIMPLE} /></div>
)}
{messageRows.length > PAGE_SIZE && (
<div className="csq-drill-card__footer">
<Pagination size="small" current={messagesPage} total={messageRows.length} pageSize={PAGE_SIZE} onChange={setMessagesPage} showSizeChanger={false} />
</div>
)}
</div>
</div>
</SycmBusinessPanel>
</div>
<AiQualityDrawer
open={Boolean(aiGoods)}
goods={aiGoods}
onClose={() => setAiGoods(null)}
onCompleted={() => setHistoryRefreshToken((value) => value + 1)}
/>
<AiQualityHistoryDrawer
open={historyOpen}
refreshToken={historyRefreshToken}
onClose={() => setHistoryOpen(false)}
/>
</SycmPage>
);
};
export default CustomerServiceQuality;

View File

@ -1,872 +0,0 @@
.csq-page {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
min-height: 0;
max-height: 100vh;
max-height: 100dvh;
box-sizing: border-box;
overflow: hidden;
}
.csq-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.csq-panel-inner {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.csq-panel__heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sycm-space-sm);
padding: var(--sycm-space-sm) var(--sycm-module-padding) 0;
flex: 0 0 auto;
}
.csq-panel__heading-main {
display: flex;
min-width: 0;
align-items: baseline;
gap: var(--sycm-space-sm);
}
.csq-panel__heading h2 {
margin: 0;
color: var(--sycm-text-strong);
font-size: var(--sycm-font-size-title);
font-weight: var(--sycm-font-weight-semibold);
line-height: var(--sycm-line-height-tight);
}
.csq-panel__heading span {
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-caption);
line-height: var(--sycm-line-height-normal);
}
.csq-filter {
display: flex;
padding: var(--sycm-space-xs) var(--sycm-module-padding);
background: var(--sycm-bg-subtle);
flex: 0 0 auto;
}
.csq-error {
margin: 0 var(--sycm-module-padding);
padding: var(--sycm-space-xs) var(--sycm-space-sm);
border: 1px solid var(--sycm-color-danger);
border-radius: var(--sycm-radius-md);
background: var(--sycm-color-danger-bg);
color: var(--sycm-color-danger);
font-size: var(--sycm-font-size-caption);
flex: 0 0 auto;
}
.csq-info {
padding: var(--sycm-space-xxs) var(--sycm-module-padding);
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-caption);
flex: 0 0 auto;
}
.csq-drill-grid {
display: grid;
grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr) minmax(0, 1.35fr);
grid-template-rows: minmax(0, 1fr);
height: calc(100dvh - 146px);
max-height: calc(100dvh - 146px);
gap: var(--sycm-space-sm);
padding: 0 var(--sycm-module-padding) var(--sycm-space-sm);
flex: 0 0 calc(100dvh - 146px);
min-height: 0;
overflow: hidden;
}
.csq-drill-card {
display: flex;
flex-direction: column;
height: 100%;
max-height: 100%;
min-height: 0;
overflow: hidden;
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
border-radius: var(--sycm-radius-lg);
background: var(--sycm-bg-container);
}
.csq-drill-card__header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 40px;
padding: 0 var(--sycm-space-sm);
border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light);
background: var(--sycm-bg-subtle);
color: var(--sycm-text-strong);
font-size: var(--sycm-font-size-caption);
font-weight: var(--sycm-font-weight-semibold);
flex: 0 0 auto;
}
.csq-drill-card__extra {
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-micro);
font-weight: var(--sycm-font-weight-regular);
}
/* 中间可滚动区域 */
.csq-scroll-area {
flex: 1;
min-height: 0;
overscroll-behavior: contain;
overflow-y: scroll;
scrollbar-gutter: stable;
}
.csq-table-spin,
.csq-table-spin > .ant-spin-container {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.csq-table-spin.ant-spin-nested-loading {
min-height: 0;
}
/* 分页固定在底部 */
.csq-drill-card__footer {
display: flex;
min-height: 36px;
align-items: center;
justify-content: flex-end;
flex: 0 0 auto;
border-top: var(--sycm-line-width-base) solid var(--sycm-border-light);
padding: 4px var(--sycm-space-sm);
background: var(--sycm-bg-container);
}
.csq-drill-row {
cursor: pointer;
transition: background-color 0.15s ease, box-shadow 0.15s ease;
}
.csq-drill-row:hover > td {
background: rgba(32, 98, 230, 0.04) !important;
}
.csq-drill-row--active > td {
background: var(--sycm-color-primary-bg) !important;
box-shadow: inset 3px 0 0 var(--sycm-color-primary);
}
.csq-goods-cell,
.csq-customer-cell {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--sycm-space-xs);
align-items: center;
}
.csq-goods-thumb {
display: block;
width: 40px;
height: 40px;
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
border-radius: var(--sycm-radius-sm);
object-fit: cover;
}
.csq-goods-thumb.is-empty {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--sycm-bg-subtle);
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-micro);
}
.csq-cell-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 1px;
line-height: var(--sycm-line-height-normal);
}
.csq-cell-primary {
overflow: hidden;
color: var(--sycm-text-strong);
font-size: var(--sycm-font-size-caption);
font-weight: var(--sycm-font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.csq-cell-secondary {
overflow: hidden;
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-micro);
text-overflow: ellipsis;
white-space: nowrap;
}
.csq-count-cell {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-end;
gap: 1px;
}
.csq-download-btn {
padding-inline: 0;
font-size: var(--sycm-font-size-micro);
}
.csq-ai-action {
color: var(--sycm-color-primary);
}
.csq-ai-action:hover {
background: var(--sycm-color-primary-bg) !important;
color: var(--sycm-color-primary) !important;
}
.csq-customer-avatar {
display: inline-flex;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
border-radius: 12px;
background: var(--sycm-color-primary-bg);
color: var(--sycm-color-primary);
font-size: 16px;
}
.csq-message-card .csq-drill-card__header {
flex: 0 0 auto;
}
.csq-message-list {
display: flex;
flex-direction: column;
gap: var(--sycm-space-sm);
padding: var(--sycm-space-sm);
}
.csq-message-scroll > .ant-spin-nested-loading,
.csq-message-scroll > .ant-spin-nested-loading > .ant-spin-container {
min-height: 100%;
}
.csq-message {
display: flex;
flex-direction: column;
gap: 4px;
}
.csq-message--outbound {
align-items: flex-end;
}
.csq-message--inbound {
align-items: flex-start;
}
.csq-message__meta {
display: flex;
max-width: 86%;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.csq-message__bubble {
display: flex;
max-width: 86%;
gap: 6px;
align-items: flex-start;
padding: var(--sycm-space-xs) var(--sycm-space-sm);
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
border-radius: 12px;
background: var(--sycm-bg-container);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
font-size: var(--sycm-font-size-caption);
line-height: var(--sycm-line-height-normal);
}
.csq-message__bubble--image {
display: block;
padding: 4px;
}
.csq-message__image {
display: block;
width: 100px;
max-width: 100px;
}
.csq-message__image .ant-image-img {
display: block;
width: 100px;
height: 100px;
border-radius: var(--sycm-radius-md);
object-fit: contain;
background: var(--sycm-bg-subtle);
}
.csq-message--outbound .csq-message__bubble {
border-color: rgba(32, 98, 230, 0.18);
background: var(--sycm-color-primary-soft);
}
.csq-message__bubble .anticon {
margin-top: 2px;
color: var(--sycm-color-primary);
}
.csq-ai-section {
padding: 0 0 var(--sycm-space-lg);
margin-bottom: var(--sycm-space-lg);
border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light);
}
.csq-ai-section:last-child {
margin-bottom: 0;
border-bottom: 0;
}
.csq-ai-product {
display: flex;
gap: var(--sycm-space-md);
align-items: center;
}
.csq-ai-product__image,
.csq-history-detail-product .ant-image-img {
flex: 0 0 auto;
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
border-radius: var(--sycm-radius-md);
background: var(--sycm-bg-subtle);
object-fit: contain;
}
.csq-ai-product__content {
min-width: 0;
}
.csq-ai-product__content h5 {
margin: 0 0 var(--sycm-space-xs);
color: var(--sycm-text-strong);
}
.csq-ai-product__meta {
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-caption);
}
.csq-ai-section__title {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sycm-space-md);
margin-bottom: var(--sycm-space-sm);
}
.csq-ai-section__title > div {
display: flex;
align-items: baseline;
gap: var(--sycm-space-sm);
}
.csq-ai-section__title h5 {
margin: 0;
}
.csq-ai-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border-top: var(--sycm-line-width-base) solid var(--sycm-border-light);
border-left: var(--sycm-line-width-base) solid var(--sycm-border-light);
}
.csq-ai-metrics > div {
display: flex;
min-height: 64px;
flex-direction: column;
justify-content: center;
gap: 3px;
padding: var(--sycm-space-xs) var(--sycm-space-sm);
border-right: var(--sycm-line-width-base) solid var(--sycm-border-light);
border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light);
}
.csq-ai-metrics span {
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-micro);
}
.csq-ai-metrics strong {
color: var(--sycm-text-strong);
font-size: var(--sycm-font-size-title);
font-weight: var(--sycm-font-weight-semibold);
}
.csq-ai-period-note {
display: flex;
align-items: center;
gap: var(--sycm-space-sm);
margin-top: var(--sycm-space-sm);
}
.csq-ai-progress {
min-height: 132px;
padding: var(--sycm-space-md) 18px;
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
border-left: 3px solid var(--sycm-color-primary);
background: var(--sycm-bg-subtle);
}
.csq-ai-progress__heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sycm-space-md);
margin-bottom: var(--sycm-space-sm);
}
.csq-ai-progress__heading > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.csq-ai-progress__heading strong {
color: var(--sycm-text-strong);
font-size: var(--sycm-font-size-body);
font-weight: var(--sycm-font-weight-semibold);
line-height: var(--sycm-line-height-normal);
}
.csq-ai-progress__heading > span {
min-width: 42px;
color: var(--sycm-color-primary);
font-size: var(--sycm-font-size-title);
font-variant-numeric: tabular-nums;
font-weight: var(--sycm-font-weight-semibold);
text-align: right;
}
.csq-ai-progress .ant-progress {
margin: 0;
}
.csq-ai-progress__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sycm-space-md);
min-height: 22px;
margin-top: var(--sycm-space-xs);
}
.csq-ai-progress__meta > span:last-child {
min-width: 0;
text-align: right;
}
.csq-ai-progress-error {
margin-bottom: var(--sycm-space-md);
}
.csq-ai-result__summary {
display: grid;
grid-template-columns: 1.35fr repeat(4, 1fr);
gap: var(--sycm-space-sm);
margin-bottom: var(--sycm-space-md);
padding: var(--sycm-space-sm);
background: var(--sycm-bg-subtle);
}
.csq-ai-result__summary > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.csq-ai-result__summary > div:first-child {
flex-direction: row;
align-items: center;
gap: var(--sycm-space-xs);
}
.csq-ai-result__summary strong {
color: var(--sycm-text-strong);
font-size: var(--sycm-font-size-title);
}
.csq-ai-findings {
margin-bottom: var(--sycm-space-md);
padding: var(--sycm-space-sm) var(--sycm-space-md);
border-left: 3px solid var(--sycm-color-primary);
background: var(--sycm-color-primary-bg);
}
.csq-ai-findings ul {
margin: var(--sycm-space-xs) 0 0;
padding-left: 20px;
color: var(--sycm-text-secondary);
}
.csq-ai-result__detail {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
gap: var(--sycm-space-sm);
padding: 0;
color: var(--sycm-text-secondary);
line-height: var(--sycm-line-height-normal);
}
.csq-ai-evidence-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 4px;
}
.csq-ai-evidence-list blockquote {
margin: 0;
padding: 6px 10px;
border-left: 2px solid var(--sycm-border-light);
background: var(--sycm-bg-subtle);
}
.csq-ai-evidence-list blockquote > span {
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-micro);
}
.csq-ai-evidence-list p {
margin: 2px 0 0;
}
.csq-ai-result__detail > .ant-descriptions,
.csq-ai-result__detail > .csq-ai-result__tags,
.csq-ai-result__detail > div:not(.ant-tabs) {
flex: 0 0 auto;
}
.csq-ai-result__detail > .ant-tabs {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.csq-ai-result__detail > .ant-tabs > .ant-tabs-nav {
flex: 0 0 auto;
margin-bottom: 0 !important;
}
.csq-ai-result__detail > .ant-tabs > .ant-tabs-content-holder {
min-height: 0;
flex: 1;
padding-top: var(--sycm-space-sm);
padding-right: 6px;
overflow-y: auto;
scrollbar-gutter: stable;
}
.csq-ai-detail-pane {
display: flex;
min-height: 100%;
flex-direction: column;
gap: var(--sycm-space-md);
}
.csq-ai-chat-summary {
display: flex;
min-height: 30px;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sycm-space-xs);
}
.csq-ai-chat-collapse {
border-top: var(--sycm-line-width-base) solid var(--sycm-border-light);
background: transparent;
}
.csq-ai-chat-customer {
border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light);
}
.csq-ai-chat-customer > summary {
position: relative;
padding: 10px 0;
align-items: center;
cursor: pointer;
list-style: none;
}
.csq-ai-chat-customer > summary::-webkit-details-marker {
display: none;
}
.csq-ai-chat-customer > summary::after {
position: absolute;
top: 50%;
right: 0;
width: 6px;
height: 6px;
border-right: 1px solid var(--sycm-text-tertiary);
border-bottom: 1px solid var(--sycm-text-tertiary);
content: '';
transform: translateY(-65%) rotate(-45deg);
}
.csq-ai-chat-customer[open] > summary::after {
transform: translateY(-75%) rotate(45deg);
}
.csq-ai-chat-customer-body {
padding: 0 0 var(--sycm-space-md);
}
.csq-ai-chat-customer-heading {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: var(--sycm-space-sm);
padding-right: var(--sycm-space-sm);
}
.csq-ai-chat-customer-heading > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.csq-ai-chat-customer-heading strong {
color: var(--sycm-text-strong);
font-size: var(--sycm-font-size-body);
}
.csq-ai-chat-customer-heading .ant-tag {
flex: 0 0 auto;
margin-inline-end: 0;
}
.csq-ai-chat-messages {
display: flex;
flex-direction: column;
gap: 6px;
}
.csq-ai-chat-message {
padding: 8px 10px;
border-left: 2px solid var(--sycm-border-light);
background: var(--sycm-bg-subtle);
}
.csq-ai-chat-message--service {
border-left-color: var(--sycm-color-success);
background: var(--sycm-color-success-bg);
}
.csq-ai-chat-message--buyer {
border-left-color: var(--sycm-color-primary);
background: var(--sycm-color-primary-bg);
}
.csq-ai-chat-message__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-bottom: 4px;
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-micro);
}
.csq-ai-chat-message__meta .ant-tag {
margin-inline-end: 0;
}
.csq-ai-chat-message p {
margin: 0;
color: var(--sycm-text-secondary);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.csq-ai-result__recommendation {
display: flex;
flex-direction: column;
gap: var(--sycm-space-sm);
padding: var(--sycm-space-sm) var(--sycm-space-md);
border-left: 3px solid var(--sycm-color-primary);
background: var(--sycm-bg-subtle);
}
.csq-ai-result__tags,
.csq-ai-result__images {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.csq-ai-result__images .ant-image-img {
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
border-radius: var(--sycm-radius-sm);
object-fit: contain;
}
.csq-history-filter {
margin-bottom: var(--sycm-space-md);
padding-bottom: var(--sycm-space-md);
border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light);
}
.csq-history-product {
display: flex;
align-items: center;
gap: var(--sycm-space-xs);
}
.csq-history-product .ant-image-img {
flex: 0 0 auto;
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
border-radius: var(--sycm-radius-sm);
background: var(--sycm-bg-subtle);
object-fit: contain;
}
.csq-history-product > div,
.csq-history-stack {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.csq-history-product strong {
overflow: hidden;
color: var(--sycm-text-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.csq-history-product span,
.csq-history-stack span {
overflow: hidden;
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-micro);
text-overflow: ellipsis;
white-space: nowrap;
}
.csq-history-pagination {
display: flex;
justify-content: flex-end;
margin-top: var(--sycm-space-md);
}
.csq-history-detail-product {
padding-bottom: var(--sycm-space-md);
}
@media (max-width: 760px) {
.csq-panel__heading-main {
align-items: flex-start;
flex-direction: column;
gap: 2px;
}
.csq-ai-section__title {
align-items: flex-start;
flex-direction: column;
}
.csq-ai-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.csq-ai-result__summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.csq-ai-progress__meta {
align-items: flex-start;
flex-direction: column;
gap: 2px;
}
.csq-ai-progress__meta > span:last-child {
text-align: left;
}
.csq-ai-chat-customer-heading {
align-items: flex-start;
}
}
.csq-history-status {
display: flex;
align-items: center;
gap: 4px;
}
.csq-history-status .ant-tag {
margin-inline-end: 0;
}
.csq-history-status span {
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-micro);
font-variant-numeric: tabular-nums;
}
@media (max-width: 1180px) {
.csq-page {
height: auto;
overflow: auto;
}
.csq-drill-grid {
grid-template-columns: 1fr;
height: auto;
max-height: none;
flex-basis: auto;
overflow: visible;
}
.csq-drill-card {
min-height: 400px;
}
}

View File

@ -1,105 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
AppleOutlined,
DownloadOutlined,
ReloadOutlined,
WindowsOutlined,
} from '@ant-design/icons';
import { Button, Empty, Skeleton } from 'antd';
import { getLatestDesktopRelease } from '../../api/desktopClient';
import { SycmBusinessPanel, SycmPage } from '../../components/Common';
import './style.css';
const formatDateTime = (value) => (
value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--'
);
const DesktopClientDownload = () => {
const [releases, setReleases] = useState({ win32: null, darwin: null });
const [loading, setLoading] = useState(true);
const loadLatest = async () => {
setLoading(true);
try {
const [windowsResult, macResult] = await Promise.allSettled([
getLatestDesktopRelease('', 'win32', 'x64'),
getLatestDesktopRelease('', 'darwin', 'arm64'),
]);
setReleases({
win32: windowsResult.status === 'fulfilled' ? windowsResult.value?.data || null : null,
darwin: macResult.status === 'fulfilled' ? macResult.value?.data || null : null,
});
} catch {
setReleases({ win32: null, darwin: null });
} finally {
setLoading(false);
}
};
useEffect(() => {
loadLatest();
}, []);
return (
<SycmPage className="desktop-client-download-page">
<SycmBusinessPanel
title="多多自动平台"
extra={(
<div className="desktop-client-download-actions">
<Button icon={<ReloadOutlined />} onClick={loadLatest} loading={loading}>
刷新
</Button>
</div>
)}
>
<div className="desktop-client-download-body">
{loading ? (
<Skeleton active paragraph={{ rows: 3 }} />
) : releases.win32 || releases.darwin ? (
<div className="desktop-client-download-platforms">
{[
{ key: 'win32', label: 'Windows', icon: <WindowsOutlined />, release: releases.win32 },
{ key: 'darwin', label: 'macOS', icon: <AppleOutlined />, release: releases.darwin },
].map((item) => (
<article className="desktop-client-download-card" key={item.key}>
<div className="desktop-client-download-summary">
<span className="desktop-client-download-summary__icon">{item.icon}</span>
<div className="desktop-client-download-summary__copy">
<strong>{item.label} {item.release ? `v${item.release.version}` : '暂未发布'}</strong>
<span>
{item.release
? `发布于 ${formatDateTime(item.release.publishedAt || item.release.createdAt)} · ${item.release.architecture || '--'}`
: `尚无可下载的 ${item.label} 安装包`}
</span>
</div>
<Button
type="primary"
icon={<DownloadOutlined />}
href={item.release?.downloadUrl}
disabled={!item.release}
>
下载{item.label}版
</Button>
</div>
{item.release?.releaseNotes ? (
<div className="desktop-client-download-notes">
<span className="desktop-client-download-notes__label">更新说明</span>
<p>{item.release.releaseNotes}</p>
</div>
) : null}
</article>
))}
</div>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂未发布可下载的客户端版本"
/>
)}
</div>
</SycmBusinessPanel>
</SycmPage>
);
};
export default DesktopClientDownload;

View File

@ -1,112 +0,0 @@
.desktop-client-download-actions {
display: flex;
gap: var(--sycm-space-xs);
}
.desktop-client-download-body {
min-width: 0;
padding: var(--sycm-module-padding);
}
.desktop-client-download-platforms {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sycm-page-section-gap);
}
.desktop-client-download-card {
min-width: 0;
padding: var(--sycm-space-md);
border: 1px solid var(--sycm-border-light);
border-radius: var(--sycm-radius-md);
background: var(--sycm-bg-container);
}
.desktop-client-download-summary {
display: flex;
align-items: center;
gap: var(--sycm-space-md);
}
.desktop-client-download-summary__icon {
display: inline-flex;
width: 40px;
height: 40px;
flex: 0 0 40px;
align-items: center;
justify-content: center;
border: 1px solid var(--sycm-border-base);
border-radius: var(--sycm-radius-lg);
background: var(--sycm-bg-subtle);
color: var(--sycm-text-secondary);
font-size: 18px;
}
.desktop-client-download-summary__copy {
display: grid;
min-width: 0;
flex: 1;
gap: 4px;
}
.desktop-client-download-summary > .ant-btn {
flex: 0 0 auto;
}
.desktop-client-download-summary__copy strong {
color: var(--sycm-text-strong);
font-size: var(--sycm-font-size-title);
font-weight: var(--sycm-font-weight-semibold);
font-variant-numeric: tabular-nums;
}
.desktop-client-download-summary__copy span {
overflow: hidden;
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-caption);
text-overflow: ellipsis;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.desktop-client-download-notes {
display: grid;
gap: var(--sycm-space-sm);
margin-top: var(--sycm-module-header-gap);
padding: var(--sycm-space-md);
border: 1px solid var(--sycm-border-light);
border-radius: var(--sycm-radius-lg);
background: var(--sycm-bg-page-soft);
}
.desktop-client-download-notes__label {
color: var(--sycm-text-secondary);
font-size: var(--sycm-font-size-caption);
font-weight: var(--sycm-font-weight-medium);
}
.desktop-client-download-notes p {
margin: 0;
color: var(--sycm-text-primary);
font-size: var(--sycm-font-size-body);
line-height: 22px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@media (max-width: 1000px) {
.desktop-client-download-platforms {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 620px) {
.desktop-client-download-summary {
align-items: flex-start;
flex-wrap: wrap;
}
.desktop-client-download-summary > .ant-btn {
width: 100%;
}
}

View File

@ -1,261 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
AppleOutlined,
CloudUploadOutlined,
DownloadOutlined,
ReloadOutlined,
WindowsOutlined,
} from '@ant-design/icons';
import {
Button,
Input,
Progress,
Typography,
Upload,
message,
} from 'antd';
import {
getDesktopReleases,
publishDesktopRelease,
} from '../../api/desktopClient';
import { SycmPage } from '../../components/Common';
import './style.css';
const { Text, Title } = Typography;
const { TextArea } = Input;
const { Dragger } = Upload;
const formatFileSize = (value) => {
const bytes = Number(value || 0);
if (!bytes) return '--';
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};
const formatDateTime = (value) => (
value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--'
);
const inferVersion = (fileName) => (
String(fileName || '').match(/pdd-auto-sale-(\d+\.\d+\.\d+)(?:-[^.]+)?\.(?:exe|dmg)$/i)?.[1] || ''
);
const inferInstallerMeta = (fileName) => {
const normalized = String(fileName || '').toLowerCase();
const platform = normalized.endsWith('.dmg') ? 'darwin' : 'win32';
const architecture = normalized.match(/[-_.](arm64|x64|universal)(?=[-_.]|$)/)?.[1]
|| (platform === 'darwin' ? 'arm64' : 'x64');
return {
platform,
architecture,
label: platform === 'darwin' ? 'macOS' : 'Windows',
};
};
const platformLabel = (platform, architecture) => (
`${platform === 'darwin' ? 'macOS' : 'Windows'} · ${architecture || (platform === 'darwin' ? 'arm64' : 'x64')}`
);
const DesktopClientReleases = () => {
const [releases, setReleases] = useState([]);
const [loading, setLoading] = useState(true);
const [installer, setInstaller] = useState(null);
const [version, setVersion] = useState('');
const [releaseNotes, setReleaseNotes] = useState('');
const [publishing, setPublishing] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const latestWindows = releases.find((release) => release.platform === 'win32' && release.isLatest) || null;
const latestMac = releases.find((release) => release.platform === 'darwin' && release.isLatest) || null;
const installerMeta = useMemo(() => inferInstallerMeta(installer?.name), [installer]);
const loadReleases = async () => {
setLoading(true);
try {
const response = await getDesktopReleases();
setReleases(response.data || []);
} catch (error) {
message.error(error.response?.data?.error || error.message || '发布记录加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadReleases();
}, []);
const fileList = useMemo(() => (
installer ? [{ uid: installer.uid || installer.name, name: installer.name, status: 'done', originFileObj: installer }] : []
), [installer]);
const handleFile = (file) => {
if (!/\.(exe|dmg)$/i.test(String(file.name || ''))) {
message.warning('请选择 Windows(.exe)或 macOS(.dmg)安装包');
return Upload.LIST_IGNORE;
}
setInstaller(file);
const inferredVersion = inferVersion(file.name);
if (inferredVersion) setVersion(inferredVersion);
return false;
};
const handlePublish = async () => {
if (!installer) {
message.warning('请先选择桌面客户端安装包');
return;
}
if (!/^\d+\.\d+\.\d+$/.test(version.trim())) {
message.warning('版本号格式必须为 x.y.z,例如 0.0.9');
return;
}
setPublishing(true);
setUploadProgress(0);
try {
const response = await publishDesktopRelease({
version: version.trim(),
releaseNotes: releaseNotes.trim(),
installer,
platform: installerMeta.platform,
architecture: installerMeta.architecture,
onUploadProgress: (event) => {
if (event.total) setUploadProgress(Math.round((event.loaded / event.total) * 100));
},
});
message.success(response.message || `桌面客户端 v${version.trim()} 已发布`);
setInstaller(null);
setVersion('');
setReleaseNotes('');
setUploadProgress(100);
await loadReleases();
} catch (error) {
message.error(error.response?.data?.error || error.message || '桌面客户端发布失败');
} finally {
setPublishing(false);
}
};
return (
<SycmPage className="desktop-release-page">
<header className="desktop-release-toolbar">
<div>
<Title level={2}>客户端版本</Title>
<Text type="secondary">Windows 与 macOS 最新安装包</Text>
</div>
<Button
className="desktop-release-refresh"
icon={<ReloadOutlined />}
loading={loading}
onClick={loadReleases}
aria-label="刷新最新安装包"
title="刷新最新安装包"
/>
</header>
<section className="desktop-release-overview-grid">
{[
{ key: 'win32', label: 'Windows', icon: <WindowsOutlined />, release: latestWindows },
{ key: 'darwin', label: 'macOS', icon: <AppleOutlined />, release: latestMac },
].map((item) => (
<article className={`desktop-release-overview desktop-release-overview--${item.key}`} key={item.key}>
<div className="desktop-release-overview__topline">
<span className="desktop-release-overview__icon">{item.icon}</span>
<div>
<span>最新安装包</span>
<strong>{item.label}</strong>
</div>
</div>
<strong className="desktop-release-overview__version">
{item.release ? `v${item.release.version}` : '暂未发布'}
</strong>
<p>{item.release?.releaseNotes || `尚无可下载的 ${item.label} 安装包`}</p>
<div className="desktop-release-overview__meta">
<span>{item.release ? platformLabel(item.release.platform, item.release.architecture) : item.label}</span>
<span>{formatFileSize(item.release?.fileSize)}</span>
<span>{formatDateTime(item.release?.publishedAt)}</span>
</div>
<Button
type="primary"
icon={<DownloadOutlined />}
href={item.release?.downloadUrl}
disabled={!item.release}
>
下载 {item.label}
</Button>
</article>
))}
</section>
<section className="desktop-release-publish-panel">
<div className="desktop-release-section-heading">
<span className="desktop-release-section-heading__icon"><CloudUploadOutlined /></span>
<div>
<Title level={3}>发布最新安装包</Title>
<Text type="secondary">上传后自动替换对应平台的下载入口</Text>
</div>
</div>
<div className="desktop-release-publish-grid">
<Dragger
accept=".exe,.dmg"
maxCount={1}
fileList={fileList}
beforeUpload={handleFile}
onRemove={() => {
setInstaller(null);
return true;
}}
disabled={publishing}
>
<p className="ant-upload-drag-icon"><CloudUploadOutlined /></p>
<p className="ant-upload-text">拖入 Windows 或 macOS 安装包</p>
<p className="ant-upload-hint">支持 .exe、.dmg,最大 300 MB</p>
</Dragger>
<div className="desktop-release-form">
<label>
<span>识别平台</span>
<Input
value={installer ? platformLabel(installerMeta.platform, installerMeta.architecture) : '选择安装包后自动识别'}
disabled
/>
</label>
<label>
<span>版本号</span>
<Input
value={version}
onChange={(event) => setVersion(event.target.value)}
placeholder="例如 0.0.9"
prefix="v"
disabled={publishing}
/>
</label>
<label>
<span>更新说明</span>
<TextArea
value={releaseNotes}
onChange={(event) => setReleaseNotes(event.target.value)}
placeholder="说明本次新增功能、修复内容及操作变化"
autoSize={{ minRows: 4, maxRows: 7 }}
disabled={publishing}
/>
</label>
{publishing ? (
<Progress percent={uploadProgress} status="active" size="small" />
) : null}
<Button
type="primary"
icon={<CloudUploadOutlined />}
loading={publishing}
onClick={handlePublish}
disabled={!installer}
>
发布{installer ? ` ${installerMeta.label}` : ''}最新版
</Button>
</div>
</div>
</section>
</SycmPage>
);
};
export default DesktopClientReleases;

View File

@ -1,299 +0,0 @@
.desktop-release-page {
gap: 18px;
background: var(--sycm-bg-page);
}
.desktop-release-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.desktop-release-toolbar h2 {
margin: 0 0 3px;
color: var(--sycm-text-strong);
font-size: 22px;
line-height: 30px;
letter-spacing: 0;
}
.desktop-release-toolbar .ant-typography-secondary {
font-size: 13px;
}
.desktop-release-refresh.ant-btn {
width: 36px;
height: 36px;
padding: 0;
border-color: transparent;
background: transparent;
box-shadow: none;
}
.desktop-release-overview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.desktop-release-overview {
display: grid;
min-width: 0;
min-height: 222px;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto auto 1fr auto;
gap: 12px 20px;
padding: 22px;
border: 1px solid var(--sycm-border-light);
border-radius: 8px;
background: var(--sycm-bg-container);
box-shadow: var(--sycm-shadow-card);
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
.desktop-release-overview:hover {
border-color: var(--sycm-color-primary-border);
box-shadow: var(--sycm-shadow-card);
transform: translateY(-1px);
}
.desktop-release-overview__topline {
display: flex;
min-width: 0;
align-items: center;
gap: 12px;
}
.desktop-release-overview__topline > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 1px;
}
.desktop-release-overview__topline span:not(.desktop-release-overview__icon) {
color: var(--sycm-text-tertiary);
font-size: 12px;
}
.desktop-release-overview__topline strong {
color: var(--sycm-text-strong);
font-size: 16px;
line-height: 24px;
}
.desktop-release-overview__icon {
display: grid;
width: 42px;
height: 42px;
flex: 0 0 42px;
place-items: center;
border-radius: 8px;
background: #eaf2ff;
color: #287cf3;
font-size: 21px;
}
.desktop-release-overview--darwin .desktop-release-overview__icon {
background: #f1f3f5;
color: #20242a;
}
.desktop-release-overview__version {
align-self: center;
color: var(--sycm-text-strong);
font-size: 26px;
line-height: 34px;
}
.desktop-release-overview > p {
display: -webkit-box;
min-height: 40px;
grid-column: 1 / -1;
margin: 0;
overflow: hidden;
color: var(--sycm-text-secondary);
font-size: 13px;
line-height: 20px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.desktop-release-overview__meta {
display: flex;
min-width: 0;
align-items: center;
gap: 16px;
color: var(--sycm-text-tertiary);
font-size: 12px;
white-space: nowrap;
}
.desktop-release-overview > .ant-btn {
align-self: end;
min-width: 132px;
}
.desktop-release-publish-panel {
padding: 20px 22px 22px;
border: 1px solid var(--sycm-border-light);
border-radius: 8px;
background: var(--sycm-bg-container);
box-shadow: var(--sycm-shadow-card);
}
.desktop-release-section-heading {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.desktop-release-section-heading__icon {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 36px;
place-items: center;
border-radius: 8px;
background: var(--sycm-color-primary-bg);
color: var(--sycm-color-primary);
font-size: 17px;
}
.desktop-release-section-heading h3 {
margin: 0;
color: var(--sycm-text-strong);
font-size: 17px;
line-height: 24px;
letter-spacing: 0;
}
.desktop-release-section-heading .ant-typography-secondary {
font-size: 12px;
}
.desktop-release-publish-grid {
display: grid;
grid-template-columns: minmax(320px, 0.85fr) minmax(420px, 1.15fr);
align-items: stretch;
gap: 22px;
}
.desktop-release-publish-grid .ant-upload-wrapper,
.desktop-release-publish-grid .ant-upload-drag {
height: 100%;
}
.desktop-release-publish-grid .ant-upload-drag {
min-height: 230px;
border-color: var(--sycm-border-base);
border-radius: 8px;
background: var(--sycm-bg-subtle);
}
.desktop-release-publish-grid .ant-upload-drag:hover {
border-color: var(--sycm-color-primary);
background: var(--sycm-color-primary-bg);
}
.desktop-release-publish-grid .ant-upload-drag-icon {
margin-bottom: 8px;
}
.desktop-release-publish-grid .ant-upload-drag-icon .anticon {
color: var(--sycm-color-primary);
font-size: 30px;
}
.desktop-release-publish-grid .ant-upload-text {
margin: 0 0 4px;
color: var(--sycm-text-strong);
font-size: 14px;
font-weight: var(--sycm-font-weight-semibold);
}
.desktop-release-publish-grid .ant-upload-hint {
color: var(--sycm-text-tertiary);
font-size: 12px;
}
.desktop-release-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-content: start;
gap: 14px 16px;
}
.desktop-release-form label {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.desktop-release-form label:nth-of-type(3),
.desktop-release-form .ant-progress,
.desktop-release-form > .ant-btn {
grid-column: 1 / -1;
}
.desktop-release-form label > span {
color: var(--sycm-text-strong);
font-size: 12px;
font-weight: var(--sycm-font-weight-semibold);
}
.desktop-release-form .ant-input,
.desktop-release-form .ant-input-affix-wrapper {
border-radius: 6px;
}
.desktop-release-form > .ant-btn {
justify-self: end;
min-width: 154px;
}
@media (max-width: 1080px) {
.desktop-release-overview-grid,
.desktop-release-publish-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.desktop-release-overview {
grid-template-columns: 1fr;
}
.desktop-release-overview__version,
.desktop-release-overview > p,
.desktop-release-overview__meta,
.desktop-release-overview > .ant-btn {
grid-column: 1;
}
.desktop-release-overview__meta {
flex-wrap: wrap;
white-space: normal;
}
.desktop-release-overview > .ant-btn {
width: 100%;
}
.desktop-release-form {
grid-template-columns: 1fr;
}
.desktop-release-form label,
.desktop-release-form label:nth-of-type(3),
.desktop-release-form > .ant-btn {
grid-column: 1;
}
.desktop-release-form > .ant-btn {
width: 100%;
}
}

View File

@ -1,18 +1,15 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
AppstoreOutlined,
BarChartOutlined,
BulbOutlined,
CalendarOutlined,
CheckCircleOutlined,
LineChartOutlined,
RiseOutlined,
FullscreenOutlined,
LeftOutlined,
RightOutlined,
TableOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { Alert, DatePicker, Select, Skeleton } from 'antd';
import dayjs from 'dayjs';
@ -28,11 +25,6 @@ import {
SycmSparkline,
} from '../../components/Common';
import { getDevelopmentDashboard, getProductAnalysisMeta } from '../../api/productAnalysis';
import {
getStoreCheckupHomeCompensation,
getStoreCheckupHomeReviews,
getStoreCheckupHomeSummary,
} from '../../api/storeCheckup';
import './stylePreview.css';
const overviewMetrics = [
@ -166,256 +158,7 @@ const activateWithKeyboard = (callback) => (event) => {
}
};
const formatStoreScore = (value, digits = 2) => (
value === null || value === undefined || !Number.isFinite(Number(value))
? '暂无'
: Number(value).toFixed(digits)
);
const StoreCheckupSummaryCard = ({ data, loading, error, onOpen }) => {
const hasRisks = Number(data?.riskSignalCount || 0) > 0;
const starChange = Number(data?.starChange);
const hasStarChange = data?.starChange !== null
&& data?.starChange !== undefined
&& Number.isFinite(starChange);
const isFlatStarChange = hasStarChange && Math.abs(starChange) < 0.005;
return (
<article className="preview-store-health-card">
<header className="preview-store-health-card__header">
<div>
<h2>店铺体检</h2>
<span>{data?.date ? `数据截至 ${data.date}` : '经营健康摘要'}</span>
</div>
<button type="button" onClick={onOpen} title="进入店铺体检">
查看详情 <RightOutlined />
</button>
</header>
{loading && !data ? (
<Skeleton active paragraph={{ rows: 9 }} title={false} />
) : error && !data ? (
<div className="preview-store-health-card__error">
<WarningOutlined />
<span>{error}</span>
</div>
) : (
<>
<div className="preview-store-health-card__headline">
<div className="preview-store-health-card__star">
<span>综合体验星级</span>
<div>
<strong>{formatStoreScore(data?.averageStar, 1)}</strong>
<small>星</small>
{hasStarChange ? (
<em className={isFlatStarChange ? 'is-flat' : starChange < 0 ? 'is-down' : 'is-up'}>
{isFlatStarChange ? '持平' : `${starChange > 0 ? '+' : ''}${starChange.toFixed(2)}`}
</em>
) : null}
</div>
</div>
<div className="preview-store-health-card__coverage">
<span>已覆盖店铺</span>
<strong>{numberFormatter.format(Number(data?.shopCount || 0))}</strong>
<em>{data?.coverageModules || 0}/{data?.totalModules || 4} 类数据已接入</em>
</div>
</div>
<div className="preview-store-health-card__section-title">
<span>风险信号</span>
<em className={hasRisks ? 'is-attention' : 'is-healthy'}>
{hasRisks ? `${data?.riskTypeCount || 0} 类 · ${numberFormatter.format(Number(data?.riskSignalCount || 0))} 项` : '状态良好'}
</em>
</div>
<div className="preview-store-health-card__risk-grid">
{(data?.riskItems || []).map((item) => (
<div key={item.key} className={Number(item.value) > 0 ? `is-${item.tone}` : ''}>
<span>{item.label}</span>
<strong>{numberFormatter.format(Number(item.value || 0))}</strong>
</div>
))}
</div>
<div className="preview-store-health-card__section-title">
<span>服务表现</span>
<em>4项核心评分</em>
</div>
<div className="preview-store-health-card__score-grid">
{(data?.serviceScores || []).map((item) => (
<div key={item.key}>
<span>{item.label}</span>
<strong>{formatStoreScore(item.value)}</strong>
</div>
))}
</div>
</>
)}
</article>
);
};
const ReviewReputationSection = ({ data, loading, error }) => {
const distribution = [...(data?.distribution || [])].sort((left, right) => right.score - left.score);
const badKeywordTotal = (data?.badKeywords || []).reduce((sum, item) => sum + Number(item.count || 0), 0);
return (
<section className="preview-review-section" aria-label="评价口碑">
<header className="preview-review-section__header">
<div>
<h2>评价口碑</h2>
<p>真实买家评价的评分表现与差评问题分布</p>
</div>
<span>{data?.startDate && data?.endDate ? `${data.startDate} 至 ${data.endDate}` : '跟随当前统计周期'}</span>
</header>
{loading && !data ? (
<Skeleton active paragraph={{ rows: 5 }} title={false} />
) : error && !data ? (
<Alert type="error" showIcon message={error} />
) : (
<div className="preview-review-section__body">
<div className="preview-review-kpis">
<div className="preview-review-kpis__primary">
<span>好评率</span>
<strong>{Number(data?.goodReviewRate || 0).toFixed(2)}<small>%</small></strong>
<em>4-5 星评价 {numberFormatter.format(Number(data?.goodReviewCount || 0))} 条</em>
</div>
<div className="preview-review-kpis__grid">
<div><span>评价总数</span><strong>{numberFormatter.format(Number(data?.totalReviewCount || 0))}</strong></div>
<div className="is-risk"><span>差评数</span><strong>{numberFormatter.format(Number(data?.badReviewCount || 0))}</strong></div>
<div><span>平均评分</span><strong>{data?.averageScore == null ? '暂无' : Number(data.averageScore).toFixed(2)}</strong></div>
<div><span>覆盖商品</span><strong>{numberFormatter.format(Number(data?.reviewedGoodsCount || 0))}</strong></div>
</div>
</div>
<div className="preview-review-distribution">
<div className="preview-review-subtitle">
<h3>评分分布</h3>
<span>共覆盖 {numberFormatter.format(Number(data?.reviewedShopCount || 0))} 家店铺</span>
</div>
<div className="preview-review-stars">
{distribution.map((item) => (
<div key={item.score}>
<span>{item.score} 星</span>
<div><i style={{ width: `${Math.max(0, Math.min(100, Number(item.rate || 0)))}%` }} /></div>
<strong>{numberFormatter.format(Number(item.count || 0))}</strong>
<em>{Number(item.rate || 0).toFixed(1)}%</em>
</div>
))}
</div>
</div>
<div className="preview-review-keywords">
<div className="preview-review-subtitle">
<h3>差评分布</h3>
<span>1-3 星评价问题 TOP 5</span>
</div>
{(data?.badKeywords || []).length ? (
<div className="preview-review-keyword-list">
{data.badKeywords.map((item, index) => (
<div key={item.keyword}>
<b>{index + 1}</b>
<span>{item.keyword}</span>
<div><i style={{ width: `${badKeywordTotal ? Number(item.count || 0) * 100 / badKeywordTotal : 0}%` }} /></div>
<strong>{numberFormatter.format(Number(item.count || 0))}</strong>
</div>
))}
</div>
) : <div className="preview-review-keywords__empty">当前周期暂无明确差评标签</div>}
</div>
</div>
)}
</section>
);
};
const CompensationRiskSection = ({ data, loading, error }) => {
const reasons = data?.reasonDistribution || [];
const goods = data?.highCompensationGoods || [];
const maxReasonAmount = Math.max(...reasons.map((item) => Number(item.amount || 0)), 1);
const formatCompensationAmount = (value) => `¥${moneyFormatter.format(Number(value || 0))}`;
return (
<section className="preview-compensation-section" aria-label="赔付风险">
<header className="preview-compensation-section__header">
<div>
<h2>赔付风险</h2>
<p>赔付订单、赔付金额及高赔付商品的经营风险分布</p>
</div>
<span>{data?.startDate && data?.endDate ? `${data.startDate} 至 ${data.endDate}` : '跟随当前统计周期'}</span>
</header>
{loading && !data ? (
<Skeleton active paragraph={{ rows: 6 }} title={false} />
) : error && !data ? (
<Alert type="error" showIcon message={error} />
) : (
<div className="preview-compensation-section__body">
<div className="preview-compensation-kpis">
<div className="preview-compensation-kpis__primary">
<span>赔付总额</span>
<strong>{formatCompensationAmount(data?.totalAmount)}</strong>
<em>{numberFormatter.format(Number(data?.selectedRecordCount || 0))} 条赔付记录</em>
</div>
<div className="preview-compensation-kpis__grid">
<div><span>赔付单量</span><strong>{numberFormatter.format(Number(data?.compensationCount || 0))}</strong></div>
<div><span>平均每单</span><strong>{formatCompensationAmount(data?.averageAmount)}</strong></div>
<div><span>涉及商品ID</span><strong>{numberFormatter.format(Number(data?.affectedGoodsCount || 0))}</strong></div>
<div><span>最高商品赔付</span><strong>{formatCompensationAmount(goods[0]?.amount)}</strong></div>
</div>
</div>
<div className="preview-compensation-reasons">
<div className="preview-compensation-subtitle">
<h3>赔付原因分布</h3>
<span>按赔付金额排序</span>
</div>
{reasons.length ? (
<div className="preview-compensation-reason-list">
{reasons.map((item) => (
<div key={item.reason}>
<span title={item.reason}>{item.reason}</span>
<div><i style={{ width: `${Math.min(100, Number(item.amount || 0) * 100 / maxReasonAmount)}%` }} /></div>
<strong>{formatCompensationAmount(item.amount)}</strong>
<em>{numberFormatter.format(Number(item.orderCount || 0))} 单</em>
</div>
))}
</div>
) : <div className="preview-compensation-empty">当前周期暂无赔付记录</div>}
</div>
<div className="preview-compensation-goods">
<div className="preview-compensation-subtitle">
<h3>高赔付商品ID</h3>
<span>按赔付总额 TOP 6</span>
</div>
{goods.length ? (
<div className="preview-compensation-goods-list">
{goods.map((item, index) => (
<div key={item.goodsId}>
<b>{index + 1}</b>
<div className="preview-compensation-goods-list__info">
<strong>{item.goodsId}</strong>
<span title={item.goodsName}>{item.goodsName || '商品名称暂无'}</span>
</div>
<div className="preview-compensation-goods-list__amount">
<strong>{formatCompensationAmount(item.amount)}</strong>
<span>{numberFormatter.format(Number(item.orderCount || 0))} 单</span>
</div>
</div>
))}
</div>
) : <div className="preview-compensation-empty">当前周期暂无高赔付商品</div>}
</div>
</div>
)}
</section>
);
};
const DevelopmentStylePreview = () => {
const navigate = useNavigate();
const [suggestionIndex, setSuggestionIndex] = useState(0);
const [selectedMetricIndex, setSelectedMetricIndex] = useState(0);
const [selectedGrowthIndex, setSelectedGrowthIndex] = useState(0);
@ -431,23 +174,9 @@ const DevelopmentStylePreview = () => {
const [dashboard, setDashboard] = useState(null);
const [dashboardLoading, setDashboardLoading] = useState(true);
const [dashboardError, setDashboardError] = useState('');
const [storeCheckupSummary, setStoreCheckupSummary] = useState(null);
const [storeCheckupLoading, setStoreCheckupLoading] = useState(true);
const [storeCheckupError, setStoreCheckupError] = useState('');
const [reviewSummary, setReviewSummary] = useState(null);
const [reviewLoading, setReviewLoading] = useState(true);
const [reviewError, setReviewError] = useState('');
const [compensationSummary, setCompensationSummary] = useState(null);
const [compensationLoading, setCompensationLoading] = useState(true);
const [compensationError, setCompensationError] = useState('');
const [shopName, setShopName] = useState('');
const [shopOptions, setShopOptions] = useState([{ value: '', label: '全部店铺' }]);
const [shopOptionsLoading, setShopOptionsLoading] = useState(true);
const homePeriodDays = { 日: 1, 周: 7, 月: 30 }[activePeriod] || 7;
const homeDataEndDate = customEndDate || storeCheckupSummary?.date || dashboard?.latestDate || '';
const homeDataStartDate = customStartDate || (
homeDataEndDate ? dayjs(homeDataEndDate).subtract(homePeriodDays - 1, 'day').format('YYYY-MM-DD') : ''
);
useEffect(() => {
let active = true;
@ -486,74 +215,9 @@ const DevelopmentStylePreview = () => {
return () => controller.abort();
}, [activePeriod, customStartDate, customEndDate, shopName]);
useEffect(() => {
const controller = new AbortController();
getStoreCheckupHomeSummary({ shopName: shopName || undefined }, {
signal: controller.signal,
timeout: DASHBOARD_REQUEST_TIMEOUT_MS,
}).then((response) => {
setStoreCheckupSummary(response?.data || null);
}).catch((error) => {
if (error?.code !== 'ERR_CANCELED') {
setStoreCheckupError(error?.response?.data?.error || error.message || '店铺体检摘要加载失败');
}
}).finally(() => {
if (!controller.signal.aborted) setStoreCheckupLoading(false);
});
return () => controller.abort();
}, [shopName]);
useEffect(() => {
if (!homeDataStartDate || !homeDataEndDate) return undefined;
const controller = new AbortController();
getStoreCheckupHomeReviews({
startDate: homeDataStartDate,
endDate: homeDataEndDate,
shopName: shopName || undefined,
}, {
signal: controller.signal,
timeout: DASHBOARD_REQUEST_TIMEOUT_MS,
}).then((response) => {
setReviewSummary(response?.data || null);
}).catch((error) => {
if (error?.code !== 'ERR_CANCELED') {
setReviewError(error?.response?.data?.error || error.message || '评价口碑加载失败');
}
}).finally(() => {
if (!controller.signal.aborted) setReviewLoading(false);
});
return () => controller.abort();
}, [homeDataStartDate, homeDataEndDate, shopName]);
useEffect(() => {
if (!homeDataStartDate || !homeDataEndDate) return undefined;
const controller = new AbortController();
getStoreCheckupHomeCompensation({
startDate: homeDataStartDate,
endDate: homeDataEndDate,
shopName: shopName || undefined,
}, {
signal: controller.signal,
timeout: DASHBOARD_REQUEST_TIMEOUT_MS,
}).then((response) => {
setCompensationSummary(response?.data || null);
}).catch((error) => {
if (error?.code !== 'ERR_CANCELED') {
setCompensationError(error?.response?.data?.error || error.message || '赔付风险加载失败');
}
}).finally(() => {
if (!controller.signal.aborted) setCompensationLoading(false);
});
return () => controller.abort();
}, [homeDataStartDate, homeDataEndDate, shopName]);
const applyDateRange = (startDate, endDate, period) => {
setDashboardLoading(true);
setDashboardError('');
setReviewLoading(true);
setReviewError('');
setCompensationLoading(true);
setCompensationError('');
setActivePeriod(period);
setCustomStartDate(startDate.format('YYYY-MM-DD'));
setCustomEndDate(endDate.format('YYYY-MM-DD'));
@ -575,10 +239,6 @@ const DevelopmentStylePreview = () => {
if (!Array.isArray(value) || value.length !== 2 || !value[0] || !value[1]) {
setDashboardLoading(true);
setDashboardError('');
setReviewLoading(true);
setReviewError('');
setCompensationLoading(true);
setCompensationError('');
setActivePeriod('周');
setPeriodPickerType('周');
setCustomStartDate('');
@ -591,12 +251,6 @@ const DevelopmentStylePreview = () => {
setShopName(value || '');
setDashboardLoading(true);
setDashboardError('');
setStoreCheckupLoading(true);
setStoreCheckupError('');
setReviewLoading(true);
setReviewError('');
setCompensationLoading(true);
setCompensationError('');
};
const displayedDateRange = customStartDate && customEndDate
? [dayjs(customStartDate), dayjs(customEndDate)]
@ -668,15 +322,6 @@ const DevelopmentStylePreview = () => {
{ label: '收藏人数', value: formatDashboardValue(dashboardMetricMap.get('favoriteCount')?.value, 'integer'), sub: '产生收藏行为' },
{ label: '支付买家', value: formatDashboardValue(dashboardMetricMap.get('buyerCount')?.value, 'integer'), sub: '完成支付买家' },
];
const changedMetrics = (dashboard?.metrics || []).filter((metric) => Number.isFinite(Number(metric.change)));
const decliningMetricCount = changedMetrics.filter((metric) => Number(metric.change) < 0).length;
const growingMetricCount = changedMetrics.filter((metric) => Number(metric.change) >= 0).length;
const diagnosisTitle = decliningMetricCount > 0
? `${decliningMetricCount} 项核心指标环比下降`
: '核心经营指标保持稳定';
const diagnosisDescription = decliningMetricCount > 0
? '建议优先检查访客、支付订单和收藏变化,再结合店铺体检中的售后及服务风险定位原因。'
: '当前周期核心经营指标未出现明显下滑,继续关注支付转化和店铺服务风险。';
const suggestion = suggestions[suggestionIndex];
const moveSuggestion = (offset) => {
setSuggestionIndex((current) => (current + offset + suggestions.length) % suggestions.length);
@ -795,28 +440,8 @@ const DevelopmentStylePreview = () => {
</div>
</section>
<aside className="preview-side-column">
<StoreCheckupSummaryCard
data={storeCheckupSummary}
loading={storeCheckupLoading}
error={storeCheckupError}
onOpen={() => navigate('/planning/operation/store-checkup')}
/>
</aside>
</section>
<ReviewReputationSection
data={reviewSummary}
loading={reviewLoading}
error={reviewError}
/>
<CompensationRiskSection
data={compensationSummary}
loading={compensationLoading}
error={compensationError}
/>
<section className="preview-growth-section">
<header className="preview-growth-section__header">
<h2>增长因子</h2>
@ -917,53 +542,6 @@ const DevelopmentStylePreview = () => {
</div>
</section>
<section className="preview-lower-grid">
<article className="preview-card preview-health-risk-card">
<div className="preview-card__header">
<div>
<h2>售后与服务风险</h2>
<p>来自店铺体检最新数据</p>
</div>
<WarningOutlined />
</div>
{storeCheckupLoading && !storeCheckupSummary ? (
<div className="preview-health-risk-loading"><Skeleton active paragraph={{ rows: 4 }} title={false} /></div>
) : (
<div className="preview-health-risk-list">
{(storeCheckupSummary?.riskItems || []).map((item) => (
<div key={item.key}>
<span>{item.label}</span>
<strong className={Number(item.value) > 0 ? `is-${item.tone}` : ''}>{numberFormatter.format(Number(item.value || 0))}</strong>
<em>{Number(item.value) > 0 ? '需关注' : '正常'}</em>
</div>
))}
</div>
)}
</article>
<article className="preview-card preview-diagnosis-card">
<div className="preview-card__header">
<div>
<h2>经营诊断</h2>
<p>经营指标与店铺风险联合判断</p>
</div>
<CheckCircleOutlined />
</div>
<div className="preview-diagnosis-summary">
<span>增长指标 {growingMetricCount}</span>
<span>下滑指标 {decliningMetricCount}</span>
<span>风险类型 {storeCheckupSummary?.riskTypeCount || 0}</span>
<span>覆盖店铺 {storeCheckupSummary?.shopCount || 0}</span>
</div>
<div className="preview-diagnosis-issue">
<span>本周期诊断</span>
<h3>{diagnosisTitle}</h3>
<p>{diagnosisDescription}</p>
<button type="button" onClick={() => navigate('/planning/operation/store-checkup')}>查看店铺体检</button>
</div>
</article>
</section>
<section className="preview-card preview-journey-card">
<div className="preview-card__header">
<div>

File diff suppressed because it is too large Load Diff

View File

@ -1,75 +0,0 @@
import React, { useState } from 'react';
import { Button, Modal } from 'antd';
import { DiscountInput, MoneyInput, PercentInput } from './inputs';
import { SYSTEM_DEFAULTS } from './constants';
const FIELD_DEFS = [
{ key: 'coupon', label: '优惠券金额', Input: MoneyInput },
{ key: 'activityDiscount', label: '活动折扣', Input: DiscountInput },
{ key: 'installFee', label: '安装费', Input: MoneyInput },
{ key: 'targetMargin', label: '营业利润率', Input: PercentInput },
];
const formatDefaultTip = (key, value) => {
if (key === 'targetMargin') return `系统默认 ${Number(value * 100).toFixed(0)}%`;
if (key === 'activityDiscount') return `系统默认 ${value}(即 ${Number(value * 10).toFixed(0)} 折)`;
return `系统默认 ${value}`;
};
// 表单主体:每次打开弹窗时重新挂载,初始值取 个性化配置 > 系统默认
const DefaultsForm = ({ defaults, systemDefaults, saving, onSave }) => {
const [form, setForm] = useState(() => ({ ...systemDefaults, ...(defaults || {}) }));
return (
<div>
<p className="pricing-calc-defaults__tip">
配置保存到你的账号,仅自己生效。保存后页面中仍使用旧默认值的行会自动更新为新值,手动修改过的行不受影响;之后的新的测算也按此填充。
</p>
{FIELD_DEFS.map(({ key, label, Input }) => (
<div key={key} className="pricing-calc-defaults__row">
<span className="pricing-calc-defaults__label">{label}</span>
{React.createElement(Input, {
style: { width: 160 },
value: form[key],
onChange: (next) => setForm((prev) => ({ ...prev, [key]: next })),
})}
<span className="pricing-calc-defaults__hint">
{formatDefaultTip(key, systemDefaults[key])}
</span>
</div>
))}
<div className="pricing-calc-defaults__footer">
<Button onClick={() => setForm({ ...systemDefaults })}>恢复系统默认</Button>
<Button type="primary" loading={saving} onClick={() => onSave(form)}>保存配置</Button>
</div>
</div>
);
};
/**
* 个性化默认值配置弹窗:
* - 每个运营维护自己的默认值,保存到本人账号(服务端按用户隔离)
* - 用于"新测算/新条码行"的默认值填充;已生成的行与历史快照不受影响
*/
const DefaultsConfigModal = ({
open,
onClose,
defaults,
systemDefaults = SYSTEM_DEFAULTS,
saving,
onSave,
title = '默认值配置',
}) => (
<Modal title={title} width={440} open={open} onCancel={onClose} footer={null}>
{open ? (
<DefaultsForm
defaults={defaults}
systemDefaults={systemDefaults}
saving={saving}
onSave={onSave}
/>
) : null}
</Modal>
);
export default DefaultsConfigModal;

View File

@ -1,909 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Button,
Empty,
Image,
Input,
InputNumber,
Modal,
Popover,
Select,
Table,
Tag,
Tooltip,
message,
} from 'antd';
import {
CalculatorOutlined,
ControlOutlined,
DownloadOutlined,
ExportOutlined,
HistoryOutlined,
QuestionCircleOutlined,
ReloadOutlined,
SaveOutlined,
SearchOutlined,
SettingOutlined,
ShopOutlined,
} from '@ant-design/icons';
import { SycmDashboardMetric } from '../../components/Common';
import { getPlanningAlibaba1688ProductDetail } from '../../api/planningUpload';
import {
exportPricingTable,
fetchPricingHistoryDetail,
fetchPricingPrefs,
savePricingHistory,
savePricingPrefs,
} from '../../api/pricingCalculator';
import { computeRow, formatMoney, formatPercent } from './calc';
import { DiscountInput, MoneyInput, PercentInput } from './inputs';
import DefaultsConfigModal from './DefaultsConfigModal';
import FieldConfigDrawer from './FieldConfigDrawer';
import HistoryDrawer from './HistoryDrawer';
import {
applyDropshipDefaultsToRows,
createDropshipRows,
DROPSHIP_FIXED_RATES,
DROPSHIP_SYSTEM_DEFAULTS,
getDropshipDimensions,
getDropshipSpecValue,
mergeDropshipSelectionPricingRows,
parseAlibaba1688ProductId,
} from './dropship';
import {
createDefaultDropshipFieldConfig,
DROPSHIP_FIELD_LABELS,
loadDropshipFieldConfig,
saveDropshipFieldConfig,
} from './dropshipFieldConfig';
import { PERCENT_FIELD_KEYS } from './fieldConfig';
import './style.css';
const DEFAULT_FILTERS = { available: undefined, keyword: '', specs: {} };
const LEGEND_ITEMS = [
{ className: 'is-input', label: '可填写(到手价 / 安装费 / 营业利润率)' },
{ className: 'is-auto', label: '1688 自动获取 / 固定比例' },
{ className: 'is-formula', label: '公式' },
{ className: 'is-pdd', label: '拼多多活动(优惠券 / 折扣可填)' },
{ className: 'is-danger', label: '营业利润额为负(亏本)' },
];
const ProductThumb = ({ url, size = 36 }) => {
const [failedUrl, setFailedUrl] = useState('');
const normalizedUrl = String(url || '').trim().replace(/^\/\//, 'https://');
if (!normalizedUrl || failedUrl === normalizedUrl) {
return (
<span className="pricing-calc-thumb is-empty" style={{ width: size, height: size }}>
无图
</span>
);
}
return (
<Image
className="pricing-calc-thumb"
width={size}
height={size}
style={{ objectFit: 'cover' }}
src={normalizedUrl}
alt="商品图片"
referrerPolicy="no-referrer"
preview={{
src: normalizedUrl,
// 1688 图片有防盗链,预览大图也必须以 no-referrer 请求
imageRender: () => (
<img
referrerPolicy="no-referrer"
src={normalizedUrl}
alt="商品图片预览"
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
/>
),
}}
onError={() => setFailedUrl(normalizedUrl)}
/>
);
};
const AutoHeaderTitle = ({ children, tip }) => (
<span className="pricing-calc-th-title">
{children}
{tip ? (
<Tooltip title={tip}>
<QuestionCircleOutlined />
</Tooltip>
) : null}
</span>
);
const numericSorter = (field) => (leftRow, rightRow) => {
const left = Number.isFinite(leftRow[field]) ? leftRow[field] : Number.NEGATIVE_INFINITY;
const right = Number.isFinite(rightRow[field]) ? rightRow[field] : Number.NEGATIVE_INFINITY;
return left - right;
};
const getFriendlyError = (error, fallback) => (
error?.response?.data?.error || error?.message || fallback
);
const normalizeRestoredRows = (rows, defaults) => (Array.isArray(rows) ? rows : []).map((row, index) => ({
...row,
id: row.id || `1688:restored:${row.skuCode || index}:${index}`,
specs: Array.isArray(row.specs) ? row.specs : [],
specsText: row.specsText || (Array.isArray(row.specs)
? row.specs.map((spec) => `${spec.name}:${spec.value}`).join(' / ')
: ''),
price: row.price ?? row.groupPrice ?? null,
coupon: row.coupon ?? defaults.coupon,
activityDiscount: row.activityDiscount ?? defaults.activityDiscount,
installFee: row.installFee ?? defaults.installFee,
targetMargin: row.targetMargin ?? defaults.targetMargin,
...DROPSHIP_FIXED_RATES,
}));
const DropshipCalculator = ({
embedded = false,
productId: embeddedProductId = '',
selectionPlanId = '',
selectionItemId = '',
selectionRows = [],
onSelectionPricingSaved,
} = {}) => {
const [input, setInput] = useState(embeddedProductId);
const [productDetail, setProductDetail] = useState(null);
const [rows, setRows] = useState([]);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState('');
const [filters, setFilters] = useState(DEFAULT_FILTERS);
const [fieldConfig, setFieldConfig] = useState(loadDropshipFieldConfig);
const [fieldConfigOpen, setFieldConfigOpen] = useState(false);
const [defaults, setDefaults] = useState(null);
const [defaultsLoaded, setDefaultsLoaded] = useState(false);
const [defaultsOpen, setDefaultsOpen] = useState(false);
const [batchDiscount, setBatchDiscount] = useState(null);
const [defaultsSaving, setDefaultsSaving] = useState(false);
const [exporting, setExporting] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
const [restoring, setRestoring] = useState(false);
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [saveNote, setSaveNote] = useState('');
const [saving, setSaving] = useState(false);
const autoLoadedProductRef = React.useRef('');
const isSelectionPricingMode = Boolean(selectionPlanId && selectionItemId && embeddedProductId);
useEffect(() => {
let cancelled = false;
fetchPricingPrefs('alibaba1688')
.then((data) => {
if (!cancelled) setDefaults(data?.prefs || null);
})
.catch(() => {})
.finally(() => {
if (!cancelled) setDefaultsLoaded(true);
});
return () => { cancelled = true; };
}, []);
const effectiveDefaults = useMemo(
() => ({ ...DROPSHIP_SYSTEM_DEFAULTS, ...(defaults || {}) }),
[defaults]
);
const computedRows = useMemo(
() => rows.map((row) => ({ ...row, ...computeRow(row) })),
[rows]
);
const dimensions = useMemo(
() => getDropshipDimensions(productDetail, rows),
[productDetail, rows]
);
const dimensionOptions = useMemo(() => Object.fromEntries(dimensions.map((dimension) => [
dimension,
[...new Set(rows.map((row) => getDropshipSpecValue(row, dimension)).filter(Boolean))]
.map((value) => ({ value, label: value })),
])), [dimensions, rows]);
const filteredRows = useMemo(() => {
const keyword = filters.keyword.trim().toLowerCase();
return computedRows.filter((row) => {
if (filters.available !== undefined && row.available !== filters.available) return false;
if (Object.entries(filters.specs).some(([name, value]) => (
value && getDropshipSpecValue(row, name) !== value
))) return false;
if (keyword && ![row.skuCode, row.name, row.specsText]
.some((value) => String(value || '').toLowerCase().includes(keyword))) return false;
return true;
});
}, [computedRows, filters]);
const summaryMetrics = useMemo(() => {
const margins = filteredRows.map((row) => row.marginRate).filter(Number.isFinite);
const profits = filteredRows.map((row) => row.profitAmount).filter(Number.isFinite);
const average = (values) => (values.length
? values.reduce((sum, value) => sum + value, 0) / values.length
: null);
return [
{ key: 'count', label: '测算 SKU 数', value: `${filteredRows.length}` },
{ key: 'margin', label: '平均毛利率', value: margins.length ? formatPercent(average(margins), 1) : '--' },
{ key: 'profit', label: '平均营业利润额', value: profits.length ? `¥${formatMoney(average(profits))}` : '--' },
{
key: 'negativeProfit',
label: '营业利润额为负(亏本)',
value: `${filteredRows.filter((row) => Number.isFinite(row.profitAmount) && row.profitAmount < 0).length} 行`,
},
];
}, [filteredRows]);
const runSearch = useCallback(async (rawInput) => {
const parsed = parseAlibaba1688ProductId(rawInput);
if (!parsed.productId) {
setSearchError(parsed.error);
return;
}
setSearching(true);
setSearchError('');
try {
const response = await getPlanningAlibaba1688ProductDetail(parsed.productId);
if (response?.success === false) throw new Error(response.error || '1688 商品查询失败');
const detail = response?.data || response;
const nextRows = mergeDropshipSelectionPricingRows(
createDropshipRows(detail, effectiveDefaults, {
useSourceSkuCode: isSelectionPricingMode,
}),
selectionRows
);
if (!nextRows.length) {
throw new Error(`商品「${parsed.productId}」没有可用于测算的 SKU 明细`);
}
setInput(parsed.productId);
setProductDetail(detail);
setRows(nextRows);
setFilters(DEFAULT_FILTERS);
} catch (error) {
setProductDetail(null);
setRows([]);
setSearchError(getFriendlyError(error, '1688 商品查询失败,请稍后重试'));
} finally {
setSearching(false);
}
}, [effectiveDefaults, isSelectionPricingMode, selectionRows]);
useEffect(() => {
const productId = String(embeddedProductId || '').trim();
if (!defaultsLoaded || !productId || autoLoadedProductRef.current === productId) return;
autoLoadedProductRef.current = productId;
setInput(productId);
runSearch(productId);
}, [defaultsLoaded, embeddedProductId, runSearch]);
const updateRow = (id, patch) => {
setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)));
};
// 一键批量设置折扣:作用于当前筛选后的全部行
const applyBatchDiscount = () => {
if (!batchDiscount || batchDiscount <= 0) return;
const ids = new Set(filteredRows.map((row) => row.id));
setRows((current) => current.map((row) => (ids.has(row.id) ? { ...row, activityDiscount: batchDiscount } : row)));
message.success(`已把 ${ids.size} 行的活动折扣统一设置为 ${batchDiscount}(即 ${batchDiscount * 10} 折)`);
};
const resetToHero = () => {
setProductDetail(null);
setRows([]);
setSearchError('');
setFilters(DEFAULT_FILTERS);
};
const applyFilter = (patch) => {
setFilters((current) => ({ ...current, ...patch }));
};
const applySpecFilter = (dimension, value) => {
setFilters((current) => ({
...current,
specs: { ...current.specs, [dimension]: value },
}));
};
const resetFilters = () => {
setFilters(DEFAULT_FILTERS);
};
const handleSaveDefaults = async (form) => {
setDefaultsSaving(true);
try {
const data = await savePricingPrefs(form, 'alibaba1688');
const nextDefaults = { ...DROPSHIP_SYSTEM_DEFAULTS, ...(data?.prefs || form) };
setRows((current) => applyDropshipDefaultsToRows(current, defaults, nextDefaults));
setDefaults(data?.prefs || form);
setDefaultsOpen(false);
message.success('1688 默认值已保存,未手动修改的行已同步更新');
} catch (error) {
message.error(getFriendlyError(error, '保存默认值配置失败'));
} finally {
setDefaultsSaving(false);
}
};
const product = productDetail?.product || productDetail || {};
const productId = String(productDetail?.productId || product.productId || '');
const productTitle = productDetail?.title || product.title || '';
const productMainImage = productDetail?.mainImage || product.mainImage || rows[0]?.imageUrl || '';
const productSourceUrl = productDetail?.sourceUrl || product.sourceUrl || '';
const formulaCell = { className: 'pricing-calc-td--formula' };
const inputCell = { className: 'pricing-calc-td--input' };
const pddCell = { className: 'pricing-calc-td--pdd' };
const pddInputCell = { className: 'pricing-calc-td--pdd-input' };
const autoHeader = { className: 'pricing-calc-th--auto' };
const inputHeader = { className: 'pricing-calc-th--input' };
const formulaHeader = { className: 'pricing-calc-th--formula' };
const pddHeader = { className: 'pricing-calc-th--pdd' };
const columns = [
{
title: '图片',
dataIndex: 'imageUrl',
width: 64,
fixed: 'left',
align: 'center',
render: (value) => <ProductThumb url={value} />,
},
{
title: '规格组合',
dataIndex: 'specsText',
width: 260,
render: (_, record) => (
<div className="pricing-calc-spec-list">
{record.specs.length ? record.specs.map((spec, index) => (
<span key={`${spec.name}-${spec.value}-${index}`} className="pricing-calc-spec-item">
<b>{spec.name}</b>
<span>{spec.value}</span>
</span>
)) : <span className="pricing-calc-text">-</span>}
</div>
),
},
{
title: <AutoHeaderTitle tip="1688 SKU 采购价,自动获取">采购成本</AutoHeaderTitle>,
dataIndex: 'cost',
width: 100,
align: 'right',
sorter: numericSorter('cost'),
onHeaderCell: () => autoHeader,
render: (value) => <span className="pricing-calc-value">{formatMoney(value)}</span>,
},
{
title: <AutoHeaderTitle tip="默认按(采购成本 + 运费)/ 0.55 计算,可填写;利润按优惠后实际到手价计算">到手价</AutoHeaderTitle>,
dataIndex: 'price',
width: 110,
sorter: numericSorter('price'),
onHeaderCell: () => inputHeader,
onCell: () => inputCell,
render: (value, record) => (
<MoneyInput style={{ width: '100%' }} value={value} onChange={(next) => updateRow(record.id, { price: next })} />
),
},
{
title: <AutoHeaderTitle tip="默认 0,可在默认值中配置">安装费</AutoHeaderTitle>,
dataIndex: 'installFee',
width: 96,
onHeaderCell: () => inputHeader,
onCell: () => inputCell,
render: (value, record) => (
<MoneyInput style={{ width: '100%' }} value={value} onChange={(next) => updateRow(record.id, { installFee: next })} />
),
},
{
title: <AutoHeaderTitle tip="1688 SKU 运费,自动获取">运费</AutoHeaderTitle>,
dataIndex: 'shipping',
width: 96,
align: 'right',
sorter: numericSorter('shipping'),
onHeaderCell: () => autoHeader,
render: (value) => <span className="pricing-calc-value">{formatMoney(value)}</span>,
},
{
title: '毛利率',
dataIndex: 'marginRate',
width: 92,
align: 'right',
sorter: numericSorter('marginRate'),
onHeaderCell: () => formulaHeader,
onCell: () => formulaCell,
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
},
{
title: <AutoHeaderTitle tip="1688 代发固定使用非黑标店铺,平台佣金 1.5%">平台佣金</AutoHeaderTitle>,
dataIndex: 'commission',
width: 96,
align: 'right',
onHeaderCell: () => autoHeader,
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
},
{
title: <AutoHeaderTitle tip="毛利率减去固定费率、目标营业利润率及安装费占比">推广占比</AutoHeaderTitle>,
dataIndex: 'promoRate',
width: 104,
align: 'right',
sorter: numericSorter('promoRate'),
onHeaderCell: () => formulaHeader,
onCell: () => formulaCell,
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
},
{
title: <AutoHeaderTitle tip="固定 2.5%">营销广告费</AutoHeaderTitle>,
dataIndex: 'adFee',
width: 100,
align: 'right',
onHeaderCell: () => autoHeader,
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
},
{
title: <AutoHeaderTitle tip="固定 0%">种菜占比</AutoHeaderTitle>,
dataIndex: 'seeding',
width: 96,
align: 'right',
onHeaderCell: () => autoHeader,
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
},
{
title: <AutoHeaderTitle tip="固定 5%">售后费用</AutoHeaderTitle>,
dataIndex: 'afterSale',
width: 96,
align: 'right',
onHeaderCell: () => autoHeader,
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
},
{
title: <AutoHeaderTitle tip="固定 5%">保险费</AutoHeaderTitle>,
dataIndex: 'insurance',
width: 92,
align: 'right',
onHeaderCell: () => autoHeader,
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
},
{
title: <AutoHeaderTitle tip="1688 代发不收取品牌使用费,固定 0%">品牌使用费</AutoHeaderTitle>,
dataIndex: 'brandFee',
width: 100,
align: 'right',
onHeaderCell: () => autoHeader,
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
},
{
title: <AutoHeaderTitle tip="目标营业利润率默认 12%,可填写">营业利润率</AutoHeaderTitle>,
dataIndex: 'targetMargin',
width: 100,
align: 'right',
onHeaderCell: () => inputHeader,
onCell: () => inputCell,
render: (value, record) => (
<PercentInput style={{ width: '100%' }} value={value} onChange={(next) => updateRow(record.id, { targetMargin: next })} />
),
},
{
title: '营业利润额',
dataIndex: 'profitAmount',
width: 110,
align: 'right',
fixed: 'right',
sorter: numericSorter('profitAmount'),
onHeaderCell: () => formulaHeader,
onCell: (record) => ({
className: Number.isFinite(record.profitAmount) && record.profitAmount < 0
? 'pricing-calc-td--danger'
: 'pricing-calc-td--formula',
}),
render: (value) => <span className="pricing-calc-value">{formatMoney(value)}</span>,
},
{
title: <AutoHeaderTitle tip="优惠券金额默认 20,可填写">优惠券金额</AutoHeaderTitle>,
dataIndex: 'coupon',
width: 104,
align: 'right',
onHeaderCell: () => pddHeader,
onCell: () => pddInputCell,
render: (value, record) => (
<MoneyInput style={{ width: '100%' }} value={value} onChange={(next) => updateRow(record.id, { coupon: next })} />
),
},
{
title: <AutoHeaderTitle tip="活动折扣默认 0.6(即 6 折),可填写">活动折扣</AutoHeaderTitle>,
dataIndex: 'activityDiscount',
width: 96,
align: 'right',
onHeaderCell: () => pddHeader,
onCell: () => pddInputCell,
render: (value, record) => (
<DiscountInput style={{ width: '100%' }} value={value} onChange={(next) => updateRow(record.id, { activityDiscount: next })} />
),
},
{
title: <AutoHeaderTitle tip="公式:(到手价 + 优惠券金额) / 活动折扣">拼单价</AutoHeaderTitle>,
dataIndex: 'pddPrice',
width: 100,
align: 'right',
fixed: 'right',
sorter: numericSorter('pddPrice'),
onHeaderCell: () => pddHeader,
onCell: () => pddCell,
render: (value) => <span className="pricing-calc-value">{formatMoney(value)}</span>,
},
{
title: <AutoHeaderTitle tip="拼单价向上取整">最终拼单价</AutoHeaderTitle>,
dataIndex: 'pddFinalPrice',
width: 110,
align: 'right',
fixed: 'right',
sorter: numericSorter('pddFinalPrice'),
onHeaderCell: () => pddHeader,
onCell: () => pddCell,
render: (value) => <span className="pricing-calc-value">{formatMoney(value)}</span>,
},
];
const columnsByKey = new Map(columns.map((column) => [column.dataIndex, {
...column,
key: column.dataIndex,
}]));
const visibleColumns = fieldConfig.order
.filter((key) => !fieldConfig.hidden.includes(key) && columnsByKey.has(key))
.map((key) => columnsByKey.get(key));
const tableScrollX = visibleColumns.reduce((sum, column) => sum + (column.width || 100), 0) + 40;
const handleExport = async () => {
if (!productId || filteredRows.length === 0) {
message.warning('没有可导出的数据');
return;
}
setExporting(true);
try {
const exportColumns = visibleColumns.map((column) => ({
key: column.dataIndex,
label: DROPSHIP_FIELD_LABELS[column.dataIndex] || column.dataIndex,
percent: PERCENT_FIELD_KEYS.has(column.dataIndex),
}));
const exportRows = filteredRows.map((row) => Object.fromEntries(
exportColumns.map(({ key }) => [key, row[key]])
));
const blob = await exportPricingTable({ spu: productId, columns: exportColumns, rows: exportRows });
const stamp = new Date();
const pad = (value) => String(value).padStart(2, '0');
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `1688代发测算_${productId}_${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}-${pad(stamp.getHours())}${pad(stamp.getMinutes())}.xlsx`;
link.click();
URL.revokeObjectURL(link.href);
message.success(`已导出 ${filteredRows.length} 行 × ${exportColumns.length} 列`);
} catch (error) {
message.error(getFriendlyError(error, '导出失败,请稍后重试'));
} finally {
setExporting(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
await savePricingHistory({
calculatorType: 'alibaba1688',
spuCode: productId,
selectionContext: isSelectionPricingMode ? {
planId: selectionPlanId,
itemId: selectionItemId,
workflowProfile: 'alibaba_1688_pending',
} : undefined,
brand: productDetail?.storeName || product.storeName || '',
note: saveNote.trim(),
product: {
productId,
title: productTitle,
mainImage: productMainImage,
sourceUrl: productSourceUrl,
storeName: productDetail?.storeName || product.storeName || '',
category: productDetail?.category || product.category || '',
skuDimensions: product.skuDimensions || [],
},
summary: {
rowCount: computedRows.length,
negativeProfitCount: computedRows.filter((row) => (
Number.isFinite(row.profitAmount) && row.profitAmount < 0
)).length,
},
rows: computedRows,
});
setSaveModalOpen(false);
setHistoryRefreshKey((key) => key + 1);
if (isSelectionPricingMode && typeof onSelectionPricingSaved === 'function') {
await onSelectionPricingSaved();
}
message.success(isSelectionPricingMode ? '1688 定价表已保存' : '1688 代发测算记录已保存,可在测算历史中还原');
} catch (error) {
message.error(getFriendlyError(error, '保存测算记录失败'));
} finally {
setSaving(false);
}
};
const handleRestore = async (record) => {
setRestoring(true);
try {
const detail = await fetchPricingHistoryDetail(record.id);
const payload = detail?.payload;
if (!payload || payload.calculatorType !== 'alibaba1688' || !Array.isArray(payload.rows)) {
throw new Error('该记录不是 1688 代发测算记录,无法在当前页签还原');
}
const restoredProduct = payload.product || { productId: payload.spuCode || detail.spuCode };
setProductDetail({
...restoredProduct,
productId: restoredProduct.productId || payload.spuCode || detail.spuCode,
restoredAt: detail.createdAt,
product: restoredProduct,
});
setRows(normalizeRestoredRows(payload.rows, effectiveDefaults));
setInput(restoredProduct.productId || payload.spuCode || detail.spuCode || '');
setFilters(DEFAULT_FILTERS);
setHistoryOpen(false);
message.success(`已还原商品 ${detail.spuCode} 的测算记录`);
} catch (error) {
message.error(getFriendlyError(error, '还原测算记录失败'));
} finally {
setRestoring(false);
}
};
const renderHero = () => (
<div className="pricing-calc-hero pricing-calc-dropship-hero">
<div className="pricing-calc-hero__icon">
<ShopOutlined />
</div>
<h2 className="pricing-calc-hero__title">1688代发拼多多测算</h2>
<p className="pricing-calc-hero__subtitle">
输入 1688 商品 ID 或商品详情链接,读取全部 SKU 的采购价、规格、图片与运费,按非黑标拼多多店铺规则完成售价与利润测算。
</p>
<Input.Search
className="pricing-calc-hero__search"
size="large"
placeholder="输入商品 ID 或 detail.1688.com/offer/... 链接"
enterButton="开始测算"
loading={searching}
value={input}
readOnly={isSelectionPricingMode}
onChange={(event) => setInput(event.target.value)}
onSearch={runSearch}
/>
{searchError ? (
<Alert className="pricing-calc-hero__error" type="warning" showIcon message={searchError} />
) : null}
<div className="pricing-calc-hero__footer">
<span className="pricing-calc-hero__hint">平台佣金 1.5% · 营销广告 2.5% · 营业利润率默认 12%</span>
{!embedded ? <Button type="link" icon={<HistoryOutlined />} loading={restoring} onClick={() => setHistoryOpen(true)}>
测算历史
</Button> : null}
</div>
</div>
);
const renderResult = () => (
<div>
<div className="pricing-calc-spubar pricing-calc-dropship-spubar">
<div className="pricing-calc-product-summary">
<ProductThumb url={productMainImage} size={52} />
<div className="pricing-calc-product-summary__body">
<Tooltip title={productTitle}>
<strong className="pricing-calc-product-summary__title">{productTitle || '1688 商品'}</strong>
</Tooltip>
<div className="pricing-calc-spubar__meta">
<Tag color="orange" className="pricing-calc-spubar__spu">{productId}</Tag>
<span>{rows.length} 个 SKU</span>
<span>{dimensions.length} 个规格组</span>
{productDetail?.storeName || product.storeName ? (
<span>厂家:{productDetail?.storeName || product.storeName}</span>
) : null}
{productDetail?.restoredAt ? <span>历史还原</span> : null}
<span>佣金 1.5%</span>
<span>种菜 0%</span>
<span>品牌费 0%</span>
</div>
</div>
</div>
<div className="pricing-calc-spubar__content">
<div className="pricing-calc-spubar__controls">
<Input.Search
className="pricing-calc-spubar__search"
placeholder="商品 ID 或 1688 商品链接"
enterButton="测算"
loading={searching}
value={input}
readOnly={isSelectionPricingMode}
onChange={(event) => setInput(event.target.value)}
onSearch={runSearch}
/>
</div>
<div className="pricing-calc-spubar__actions">
{!embedded ? <Button icon={<ReloadOutlined />} onClick={resetToHero}>新的测算</Button> : null}
{!embedded ? <Button icon={<HistoryOutlined />} loading={restoring} onClick={() => setHistoryOpen(true)}>测算历史</Button> : null}
<Popover
trigger="click"
placement="bottomRight"
title="批量设置活动折扣"
content={(
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<InputNumber
min={0.01}
max={1}
step={0.05}
precision={2}
placeholder="如 0.6 = 6 折"
style={{ width: 120 }}
value={batchDiscount}
onChange={(value) => setBatchDiscount(value)}
/>
<Button
type="primary"
size="small"
disabled={!batchDiscount || batchDiscount <= 0 || filteredRows.length === 0}
onClick={applyBatchDiscount}
>
应用到全部行({filteredRows.length})
</Button>
</div>
)}
>
<Button icon={<CalculatorOutlined />}>批量折扣</Button>
</Popover>
<Button icon={<ControlOutlined />} onClick={() => setDefaultsOpen(true)}>默认值</Button>
<Button icon={<DownloadOutlined />} loading={exporting} onClick={handleExport}>导出</Button>
<Button icon={<SettingOutlined />} onClick={() => setFieldConfigOpen(true)}>字段配置</Button>
{productSourceUrl ? (
<Button icon={<ExportOutlined />} href={productSourceUrl} target="_blank" rel="noreferrer">查看 1688 商品</Button>
) : null}
<Button
type="primary"
icon={<SaveOutlined />}
disabled={rows.length === 0}
onClick={() => {
setSaveNote('');
setSaveModalOpen(true);
}}
>
{isSelectionPricingMode ? '保存定价表' : '保存测算记录'}
</Button>
</div>
</div>
<div className="pricing-calc-summary" aria-label="1688 代发定价测算摘要">
{summaryMetrics.map((item) => (
<SycmDashboardMetric key={item.key} label={item.label} value={item.value} help={false} />
))}
</div>
</div>
<div className="pricing-calc-filter">
<span className="pricing-calc-filter__item">
<label>采购状态</label>
<Select
allowClear
style={{ width: 112 }}
placeholder="全部"
value={filters.available}
options={[
{ value: true, label: '可采购' },
{ value: false, label: '不可采购' },
]}
onChange={(value) => applyFilter({ available: value })}
/>
</span>
{dimensions.map((dimension) => (
<span key={dimension} className="pricing-calc-filter__item">
<label>{dimension}</label>
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 150 }}
placeholder="全部"
value={filters.specs[dimension]}
options={dimensionOptions[dimension] || []}
onChange={(value) => applySpecFilter(dimension, value)}
/>
</span>
))}
<Input
allowClear
className="pricing-calc-filter__keyword"
prefix={<SearchOutlined />}
placeholder="SKU 名称 / 规格"
value={filters.keyword}
onChange={(event) => applyFilter({ keyword: event.target.value })}
/>
<Button icon={<ReloadOutlined />} onClick={resetFilters}>重置</Button>
<span className="pricing-calc-legend">
{LEGEND_ITEMS.map((item) => (
<span key={item.className} className="pricing-calc-legend__item">
<i className={`pricing-calc-chip ${item.className}`} />
{item.label}
</span>
))}
</span>
</div>
<Table
className="pricing-calc-table"
rowKey="id"
size="small"
columns={visibleColumns}
dataSource={filteredRows}
scroll={{ x: tableScrollX, scrollToFirstRowOnChange: true }}
pagination={false}
locale={{ emptyText: <Empty description="没有匹配的 SKU,请调整筛选条件" /> }}
/>
</div>
);
return (
<>
{productDetail ? renderResult() : renderHero()}
<Modal
title={isSelectionPricingMode ? '保存 1688 上架定价表' : '保存 1688 代发测算记录'}
open={saveModalOpen}
okText="保存"
cancelText="取消"
confirmLoading={saving}
onOk={handleSave}
onCancel={() => setSaveModalOpen(false)}
>
<p className="pricing-calc-save__hint">
{isSelectionPricingMode
? `将保存商品「${productId}」共 ${rows.length} 行定价,并写回当前上架任务。`
: `将保存商品「${productId}」共 ${rows.length} 行的完整测算快照,仅自己可见。`}
</p>
<Input
value={saveNote}
maxLength={200}
placeholder="备注(可选),如:首轮代发定价"
onChange={(event) => setSaveNote(event.target.value)}
/>
</Modal>
<DefaultsConfigModal
open={defaultsOpen}
onClose={() => setDefaultsOpen(false)}
defaults={defaults}
systemDefaults={DROPSHIP_SYSTEM_DEFAULTS}
saving={defaultsSaving}
onSave={handleSaveDefaults}
title="1688 代发默认值配置"
/>
<FieldConfigDrawer
open={fieldConfigOpen}
onClose={() => setFieldConfigOpen(false)}
config={fieldConfig}
fieldLabels={DROPSHIP_FIELD_LABELS}
createDefaultConfig={createDefaultDropshipFieldConfig}
onChange={(next) => {
setFieldConfig(next);
saveDropshipFieldConfig(next);
}}
/>
<HistoryDrawer
open={historyOpen}
onClose={() => setHistoryOpen(false)}
onRestore={handleRestore}
refreshKey={historyRefreshKey}
calculatorType="alibaba1688"
title="1688 代发测算历史"
identityLabel="商品 ID"
/>
</>
);
};
export default DropshipCalculator;

View File

@ -1,90 +0,0 @@
import React, { useState } from 'react';
import { Button, Checkbox, Drawer } from 'antd';
import { HolderOutlined } from '@ant-design/icons';
import { createDefaultFieldConfig, FIELD_LABELS } from './fieldConfig';
/**
* 字段配置抽屉:
* - 勾选控制列显隐
* - 拖动把手(整行可拖)调整列顺序,表格实时按新顺序渲染
* - 配置即改即存(localStorage),刷新后保持
*/
const FieldConfigDrawer = ({
open,
onClose,
config,
onChange,
fieldLabels = FIELD_LABELS,
createDefaultConfig = createDefaultFieldConfig,
tip = '勾选控制列显示,拖动调整列顺序;配置自动保存在本机。',
}) => {
const [dragIndex, setDragIndex] = useState(null);
const moveField = (from, to) => {
if (from === to) return;
const order = [...config.order];
const [item] = order.splice(from, 1);
order.splice(to, 0, item);
onChange({ ...config, order });
};
const toggleField = (key, checked) => {
const hidden = checked
? config.hidden.filter((item) => item !== key)
: [...config.hidden, key];
onChange({ ...config, hidden });
};
return (
<Drawer
title="字段配置"
width={360}
open={open}
onClose={onClose}
footer={(
<div className="pricing-calc-field-config__footer">
<Button onClick={() => onChange(createDefaultConfig())}>恢复默认</Button>
<Button type="primary" onClick={onClose}>完成</Button>
</div>
)}
>
<p className="pricing-calc-field-config__tip">
{tip}
</p>
<ul className="pricing-calc-field-config__list">
{config.order.map((key, index) => {
const isHidden = config.hidden.includes(key);
return (
<li
key={key}
className={`pricing-calc-field-config__item${index === dragIndex ? ' is-dragging' : ''}${isHidden ? ' is-off' : ''}`}
draggable
onDragStart={(event) => {
event.dataTransfer.effectAllowed = 'move';
setDragIndex(index);
}}
onDragEnter={() => {
if (dragIndex !== null && dragIndex !== index) {
moveField(dragIndex, index);
setDragIndex(index);
}
}}
onDragOver={(event) => event.preventDefault()}
onDragEnd={() => setDragIndex(null)}
>
<HolderOutlined className="pricing-calc-field-config__grip" />
<Checkbox
checked={!isHidden}
onChange={(event) => toggleField(key, event.target.checked)}
>
{fieldLabels[key] || key}
</Checkbox>
</li>
);
})}
</ul>
</Drawer>
);
};
export default FieldConfigDrawer;

View File

@ -1,177 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Drawer, Empty, List, Popconfirm, Spin, Tag, Tooltip, message } from 'antd';
import { DeleteOutlined, HistoryOutlined, RollbackOutlined } from '@ant-design/icons';
import { deletePricingHistory, fetchPricingHistoryList } from '../../api/pricingCalculator';
const PAGE_SIZE = 10;
const getIsAdmin = () => {
try {
const token = localStorage.getItem('token');
if (!token) return false;
const [, payload] = token.split('.');
if (!payload) return false;
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), '=');
return JSON.parse(window.atob(padded))?.role === 'admin';
} catch {
return false;
}
};
const formatTime = (value) => {
const date = value ? new Date(value) : null;
if (!date || Number.isNaN(date.getTime())) return '-';
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
/**
* 测算历史抽屉:服务端已按当前登录用户过滤,这里看到的全部是自己的记录。
* onRestore(record) 由父组件拉取详情并还原测算状态。
*/
const HistoryDrawer = ({
open,
onClose,
onRestore,
refreshKey,
calculatorType = 'company',
title = '测算历史',
identityLabel = 'SPU',
}) => {
const [loading, setLoading] = useState(false);
const [list, setList] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [deletingId, setDeletingId] = useState(null);
const [isAdmin] = useState(getIsAdmin);
const load = useCallback(async (nextPage = 1) => {
setLoading(true);
try {
const data = await fetchPricingHistoryList({
page: nextPage,
pageSize: PAGE_SIZE,
calculatorType,
});
setList(data?.list || []);
setTotal(data?.total || 0);
setPage(data?.page || nextPage);
} catch (error) {
message.error(error.message || '查询测算历史失败');
} finally {
setLoading(false);
}
}, [calculatorType]);
useEffect(() => {
if (open) {
load(1);
}
}, [open, refreshKey, load]);
const handleDelete = async (record) => {
setDeletingId(record.id);
try {
await deletePricingHistory(record.id);
message.success('已删除该测算记录');
// 删除后若当前页已空,回退一页
const nextPage = list.length === 1 && page > 1 ? page - 1 : page;
load(nextPage);
} catch (error) {
message.error(error.message || '删除失败');
} finally {
setDeletingId(null);
}
};
return (
<Drawer
title={(
<span className="pricing-calc-history__title">
<HistoryOutlined />
{title}
<span className="pricing-calc-history__subtitle">
{isAdmin ? '管理员视图 · 显示全部记录' : '仅显示我的测算记录'}
</span>
</span>
)}
width={460}
open={open}
onClose={onClose}
className="pricing-calc-history"
>
<Spin spinning={loading}>
{list.length === 0 && !loading ? (
<Empty description={`暂无${title},完成一次测算后点击「保存测算记录」`} />
) : (
<List
dataSource={list}
pagination={{
current: page,
pageSize: PAGE_SIZE,
total,
size: 'small',
onChange: (nextPage) => load(nextPage),
showTotal: (count) => `共 ${count} 条`,
}}
renderItem={(record) => (
<List.Item
className="pricing-calc-history__item"
actions={[
<Button
key="restore"
size="small"
type="link"
icon={<RollbackOutlined />}
onClick={() => onRestore(record)}
>
还原
</Button>,
<Popconfirm
key="delete"
title="删除该测算记录?"
description="删除后无法恢复。"
okText="删除"
cancelText="取消"
onConfirm={() => handleDelete(record)}
>
<Button
size="small"
type="text"
danger
loading={deletingId === record.id}
icon={<DeleteOutlined />}
aria-label="删除记录"
/>
</Popconfirm>,
]}
>
<List.Item.Meta
title={(
<span className="pricing-calc-history__item-title">
<Tooltip title={`${identityLabel}:${record.spuCode}`}>
<span className="pricing-calc-history__spu">{record.spuCode}</span>
</Tooltip>
{record.brand ? <Tag>{record.brand}</Tag> : null}
<span className="pricing-calc-history__count">{record.rowCount} 行</span>
</span>
)}
description={(
<span className="pricing-calc-history__meta">
<span>{formatTime(record.createdAt)}</span>
{isAdmin && record.userName ? <span>{record.userName}</span> : null}
{record.note ? <span className="pricing-calc-history__note">{record.note}</span> : null}
</span>
)}
/>
</List.Item>
)}
/>
)}
</Spin>
</Drawer>
);
};
export default HistoryDrawer;

View File

@ -1,80 +0,0 @@
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
const RATE_FIELD_KEYS = ['commission', 'adFee', 'seeding', 'afterSale', 'insurance', 'brandFee', 'targetMargin'];
/**
* 核心公式(L=到手价 M=安装费 N=成本 O=运费):
* P 毛利率 = (L - N - O) / L
* R 推广占比 = P - Q - S - T - U - V - W - Y - M / L(纯公式列,只读不可编辑)
* X 营业利润额 = L - M - N - O - (Q + R + S + T + U + V + W) * L
* 拼多多活动:拼单价 = (L + 优惠券金额) / 活动折扣;最终拼单价 = 拼单价向上取整,
* 并写回 groupPrice,保存定价表时作为自动上架 SKU 最终定价
* 输入不完整时返回 null,页面显示 "-" 而不是 NaN。
*/
export const computeRow = (row) => {
// L 到手价:优先 price;兼容自动上架早期数据只有 groupPrice 的情况
const L = row.price ?? row.groupPrice;
const { installFee: M, cost: N, shipping: O } = row;
const result = {
marginRate: null, promoRate: null, promoRateAuto: null, profitAmount: null,
pddPrice: null, pddFinalPrice: null, groupPrice: null,
};
if (!isFiniteNumber(L) || L <= 0) {
return result;
}
// 拼单价只依赖到手价/优惠券/活动折扣,与其他费率无关
if (isFiniteNumber(row.coupon) && isFiniteNumber(row.activityDiscount) && row.activityDiscount > 0) {
result.pddPrice = (L + row.coupon) / row.activityDiscount;
result.pddFinalPrice = Math.ceil(result.pddPrice);
}
// groupPrice 供自动上架保存使用:取最终拼单价,缺优惠券/折扣时兜底到手价
result.groupPrice = result.pddFinalPrice ?? L;
if (!isFiniteNumber(N) || !isFiniteNumber(O)) {
return result;
}
const marginRate = (L - N - O) / L;
result.marginRate = marginRate;
const ratesReady = isFiniteNumber(M) && RATE_FIELD_KEYS.every((key) => isFiniteNumber(row[key]));
if (!ratesReady) {
return result;
}
const promoRateAuto = marginRate
- row.commission
- row.adFee
- row.seeding
- row.afterSale
- row.insurance
- row.brandFee
- row.targetMargin
- M / L;
result.promoRateAuto = promoRateAuto;
result.promoRate = promoRateAuto;
const rateSum = row.commission + promoRateAuto + row.adFee + row.seeding
+ row.afterSale + row.insurance + row.brandFee;
result.profitAmount = L - M - N - O - rateSum * L;
return result;
};
export const toPercentInputValue = (rate) => (
isFiniteNumber(rate) ? Number((rate * 100).toFixed(4)) : null
);
export const formatMoney = (value) => (isFiniteNumber(value) ? value.toFixed(2) : '-');
export const formatPercent = (value, digits = 2) => (
isFiniteNumber(value) ? `${(value * 100).toFixed(digits)}%` : '-'
);
export const formatDateTime = (ts) => {
const num = Number(ts);
if (!Number.isFinite(num) || num <= 0) return '-';
const date = new Date(num);
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};

View File

@ -1,136 +0,0 @@
export const STORAGE_KEYS = {
brands: 'pdd_pricing_brand_configs_v1',
};
// 比例类字段默认值(小数存储,页面以百分比展示)
export const PLATFORM_COMMISSION_RATES = Object.freeze({
standard: 0.015,
blackLabel: 0.06,
});
export const getPlatformCommissionRate = (isBlackLabelShop) => (
isBlackLabelShop ? PLATFORM_COMMISSION_RATES.blackLabel : PLATFORM_COMMISSION_RATES.standard
);
export const inferBlackLabelShop = (rows = []) => {
const commission = rows
.map((row) => row?.commission ?? row?.savedPricing?.commission)
.map((value) => toFiniteNumber(value))
.find((value) => value !== null);
return commission !== undefined
&& Math.abs(commission - PLATFORM_COMMISSION_RATES.blackLabel) < 1e-9;
};
export const DEFAULT_RATES = {
commission: PLATFORM_COMMISSION_RATES.standard, // 平台佣金:黑标店铺 6% / 非黑标 1.5%
adFee: 0.025, // 营销广告费
seeding: 0.005, // 种菜占比
afterSale: 0.05, // 售后费用
insurance: 0.05, // 保险费
brandFee: 0, // 品牌使用费:默认 0,按条码前缀自动判断(见 deriveBrandFromBarcode)
targetMargin: 0.15, // 营业利润率(目标值)
};
// 条码前缀规则:GBGJ- 顾家(品牌使用费 7%)、GBSFY- 索菲亚(6%)、其余简尚(0)
export const BARCODE_BRAND_RULES = [
{ prefix: 'GBGJ-', brand: '顾家', brandFee: 0.07 },
{ prefix: 'GBSFY-', brand: '索菲亚', brandFee: 0.06 },
];
export const deriveBrandFromBarcode = (barcode) => {
const code = String(barcode || '').trim().toUpperCase();
const matched = BARCODE_BRAND_RULES.find((rule) => code.startsWith(rule.prefix));
return matched
? { brand: matched.brand, brandFee: matched.brandFee }
: { brand: '简尚', brandFee: 0 };
};
// 系统兜底默认值(运营未保存个性化默认值配置时使用,见 DefaultsConfigModal)
export const SYSTEM_DEFAULTS = {
coupon: 20, // 优惠券金额
activityDiscount: 0.6, // 活动折扣(即 6 折)
installFee: 0, // 安装费
targetMargin: 0.15, // 营业利润率
};
export const SOURCE_LABELS = {
getJstSkusBySpu: '聚水潭实时接口',
};
export const formatSourceLabel = (source) => {
if (!source) return '-';
return SOURCE_LABELS[source] || `本地快照(${source})`;
};
export const createRowId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const toFiniteNumber = (value) => {
if (value === null || value === undefined || value === '') return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
};
/**
* 由 SPU 条码行生成测算行:
* 条码/图片/尺寸/颜色/成本/公司限价自动填入;运费优先取运费画像的全国运费;
* 售后占比优先取画像值;到手价默认取公司限价(无限价时取成本价的 2 倍,均可改);
* 品牌与品牌使用费按条码前缀自动判断(GBGJ- 顾家 7% / GBSFY- 索菲亚 6% / 其余简尚 0)。
*/
export const createRowFromSku = (sku, { freightProfile, defaults, commission } = {}) => {
const minimumPrices = freightProfile?.minimumPricesBySku || {};
const freightMinimum = minimumPrices[sku.skuCode]
?? minimumPrices[String(sku.skuCode || '').toUpperCase()]
?? minimumPrices[String(sku.skuCode || '').toLowerCase()];
const { brand, brandFee } = deriveBrandFromBarcode(sku.skuCode);
const priceLimit = toFiniteNumber(freightMinimum ?? sku.minimumPrice);
const cost = toFiniteNumber(sku.cost);
return {
id: createRowId(),
barcode: sku.skuCode || '',
skuStatus: sku.status || '',
stock: toFiniteNumber(sku.stock),
stockWarehouses: Array.isArray(sku.stockWarehouses) ? sku.stockWarehouses : [],
originalSkuName: sku.savedPricing?.originalSkuName || sku.name || '',
selectedSkuName: sku.savedPricing?.selectedSkuName || sku.name || '',
name: sku.savedPricing?.selectedSkuName || sku.name || '',
internalProductCode: sku.savedPricing?.internalProductCode || '',
productCodeProductName: sku.savedPricing?.productCodeProductName || '',
productCodeColorSpec: sku.savedPricing?.productCodeColorSpec || '',
imageUrl: sku.image || '',
size: sku.size || '',
color: sku.color || '',
supplier: sku.supplier || '',
category: sku.category || '',
lengthCm: toFiniteNumber(sku.lengthCm),
widthCm: toFiniteNumber(sku.widthCm),
heightCm: toFiniteNumber(sku.heightCm),
weight: toFiniteNumber(sku.weight),
brand,
companyPriceLimit: priceLimit,
salePrice: toFiniteNumber(sku.salePrice),
price: toFiniteNumber(sku.savedPricing?.price)
?? toFiniteNumber(sku.savedPricing?.groupPrice) // 早期自动上架只存了拼单价,兜底还原为到手价
?? priceLimit
?? (cost === null ? null : cost * 2), // L 到手价(默认取公司限价,无限价时取成本价的 2 倍,可改)
// 默认值优先级:选品已存价 > 用户个性化配置 > 系统默认(券 20 / 折扣 0.6 / 安装费 0 / 利润率 15%)
installFee: toFiniteNumber(sku.savedPricing?.installFee) ?? toFiniteNumber(defaults?.installFee) ?? 0,
coupon: toFiniteNumber(sku.savedPricing?.coupon) ?? toFiniteNumber(defaults?.coupon) ?? 20,
activityDiscount: toFiniteNumber(sku.savedPricing?.activityDiscount) ?? toFiniteNumber(defaults?.activityDiscount) ?? 0.6,
cost, // N 成本
// O 运费:优先取接口按条码返回的全国平均运费(qgyf),其次运费画像的全国运费
shipping: toFiniteNumber(sku.savedPricing?.shipping)
?? toFiniteNumber(sku.freight)
?? toFiniteNumber(freightProfile?.nationalFreight),
...DEFAULT_RATES,
commission: toFiniteNumber(commission)
?? toFiniteNumber(sku.savedPricing?.commission)
?? DEFAULT_RATES.commission,
adFee: toFiniteNumber(sku.savedPricing?.adFee) ?? DEFAULT_RATES.adFee,
seeding: toFiniteNumber(sku.savedPricing?.seeding) ?? DEFAULT_RATES.seeding,
afterSale: toFiniteNumber(sku.savedPricing?.afterSale) ?? DEFAULT_RATES.afterSale,
insurance: toFiniteNumber(sku.savedPricing?.insurance) ?? DEFAULT_RATES.insurance,
targetMargin: toFiniteNumber(sku.savedPricing?.targetMargin) ?? toFiniteNumber(defaults?.targetMargin) ?? DEFAULT_RATES.targetMargin,
brandFee: toFiniteNumber(sku.savedPricing?.brandFee) ?? brandFee,
};
};

View File

@ -1,176 +0,0 @@
export const DROPSHIP_SYSTEM_DEFAULTS = Object.freeze({
coupon: 20,
activityDiscount: 0.6,
installFee: 0,
targetMargin: 0.12,
});
export const DROPSHIP_FIXED_RATES = Object.freeze({
commission: 0.015,
adFee: 0.025,
seeding: 0,
afterSale: 0.05,
insurance: 0.05,
brandFee: 0,
});
const RAW_PRODUCT_ID_PATTERN = /^[0-9]{6,80}$/;
const PRODUCT_PATH_PATTERN = /\/offer\/([0-9]{6,80})(?:\.html)?(?:\/|$)/i;
const toFiniteNumber = (value) => {
if (value === null || value === undefined || value === '') return null;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
};
export const parseAlibaba1688ProductId = (value) => {
const input = String(value || '').trim();
if (!input) {
return { productId: '', error: '请输入 1688 商品 ID 或商品链接' };
}
if (RAW_PRODUCT_ID_PATTERN.test(input)) {
return { productId: input, error: '' };
}
let url;
try {
url = new URL(input);
} catch {
return { productId: '', error: '格式不正确,请输入数字商品 ID 或 1688 商品详情链接' };
}
const hostname = url.hostname.toLowerCase();
if (hostname !== '1688.com' && !hostname.endsWith('.1688.com')) {
return { productId: '', error: '仅支持 1688 商品详情链接' };
}
const matched = url.pathname.match(PRODUCT_PATH_PATTERN);
if (!matched) {
return { productId: '', error: '该链接不是 1688 商品详情链接,请使用包含 /offer/商品ID.html 的链接' };
}
return { productId: matched[1], error: '' };
};
const normalizeSpecs = (specs) => (Array.isArray(specs) ? specs : [])
.map((spec) => ({
name: String(spec?.name || '').trim(),
value: String(spec?.value || '').trim(),
}))
.filter((spec) => spec.name && spec.value)
.slice(0, 4);
export const formatDropshipSpecs = (specs) => normalizeSpecs(specs)
.map((spec) => `${spec.name}:${spec.value}`)
.join(' / ');
export const createDropshipRows = (detail, userDefaults = null, options = {}) => {
const product = detail?.product && typeof detail.product === 'object'
? detail.product
: detail;
const productId = String(product?.productId || detail?.productId || '').trim();
const defaults = { ...DROPSHIP_SYSTEM_DEFAULTS, ...(userDefaults || {}) };
const sourceSkus = Array.isArray(product?.skus) && product.skus.length > 0
? product.skus
: (Array.isArray(product?.baseSkuRows) ? product.baseSkuRows : []);
return sourceSkus.map((sku, index) => {
const specs = normalizeSpecs(sku?.specs);
const cost = toFiniteNumber(sku?.price);
const shipping = toFiniteNumber(sku?.freight);
const skuIndex = Number(sku?.index) || index + 1;
const sourceSkuCode = String(
sku?.skuCode || sku?.skuId || sku?.id || `${productId}-SKU-${index + 1}`
).trim();
return {
id: `1688:${productId}:${skuIndex}:${index}`,
skuCode: options.useSourceSkuCode ? sourceSkuCode : String(skuIndex),
skuIndex,
name: String(sku?.name || sku?.selectedText || `SKU ${skuIndex}`).trim(),
specs,
specsText: formatDropshipSpecs(specs),
imageUrl: String(sku?.image || product?.mainImage || detail?.mainImage || '').trim(),
available: sku?.available !== false,
stock: toFiniteNumber(sku?.stock),
stockUnit: String(sku?.stockUnit || '').trim(),
quantity: toFiniteNumber(sku?.quantity) ?? 1,
cost,
price: cost === null || shipping === null
? null
: Number(((cost + shipping) / 0.55).toFixed(2)),
installFee: toFiniteNumber(defaults.installFee),
shipping,
coupon: toFiniteNumber(defaults.coupon),
activityDiscount: toFiniteNumber(defaults.activityDiscount),
targetMargin: toFiniteNumber(defaults.targetMargin),
...DROPSHIP_FIXED_RATES,
};
});
};
export const mergeDropshipSelectionPricingRows = (sourceRows, selectionRows) => {
const savedRows = Array.isArray(selectionRows) ? selectionRows : [];
const savedBySku = new Map(savedRows.map((row) => [
String(row?.skuCode || '').trim().toUpperCase(),
row,
]));
return sourceRows.map((row) => {
const saved = savedBySku.get(String(row.skuCode || '').trim().toUpperCase());
if (!saved) return row;
return {
...row,
skuCode: String(saved.skuCode || row.skuCode),
name: saved.selectedSkuName || saved.originalSkuName || row.name,
imageUrl: saved.skuImageUrl || row.imageUrl,
cost: saved.cost ?? row.cost,
price: saved.finalPrice ?? row.price,
installFee: saved.installationFee ?? row.installFee,
shipping: saved.freightNational ?? row.shipping,
coupon: saved.discountAmount ?? row.coupon,
activityDiscount: saved.activityDiscount ?? row.activityDiscount,
commission: saved.platformCommissionRatio ?? row.commission,
adFee: saved.marketingAdRatio ?? row.adFee,
seeding: saved.farmingRatio ?? row.seeding,
afterSale: saved.aftersaleRatio ?? row.afterSale,
insurance: saved.insuranceRatio ?? row.insurance,
brandFee: saved.brandUsageRatio ?? row.brandFee,
targetMargin: saved.operatingProfitRatio ?? row.targetMargin,
originalSkuName: saved.originalSkuName || row.name,
selectedSkuName: saved.selectedSkuName || saved.originalSkuName || row.name,
};
});
};
export const getDropshipDimensions = (detail, rows = []) => {
const product = detail?.product && typeof detail.product === 'object'
? detail.product
: detail;
const declared = (Array.isArray(product?.skuDimensions) ? product.skuDimensions : [])
.map((dimension) => String(dimension?.name || '').trim())
.filter(Boolean);
const discovered = rows.flatMap((row) => (Array.isArray(row.specs) ? row.specs : []))
.map((spec) => String(spec?.name || '').trim())
.filter(Boolean);
return [...new Set([...declared, ...discovered])].slice(0, 4);
};
export const getDropshipSpecValue = (row, dimensionName) => (
(Array.isArray(row?.specs) ? row.specs : [])
.find((spec) => spec.name === dimensionName)?.value || ''
);
export const applyDropshipDefaultsToRows = (rows, previousDefaults, nextDefaults) => {
const previous = { ...DROPSHIP_SYSTEM_DEFAULTS, ...(previousDefaults || {}) };
const next = { ...DROPSHIP_SYSTEM_DEFAULTS, ...(nextDefaults || {}) };
const keys = ['coupon', 'activityDiscount', 'installFee', 'targetMargin'];
return rows.map((row) => {
const patch = {};
keys.forEach((key) => {
const current = toFiniteNumber(row[key]);
if (current !== null
&& Math.abs(current - previous[key]) < 1e-9
&& Math.abs(next[key] - previous[key]) >= 1e-9) {
patch[key] = next[key];
}
});
return Object.keys(patch).length ? { ...row, ...patch } : row;
});
};

View File

@ -1,72 +0,0 @@
const STORAGE_KEY = 'pricingCalcDropshipFieldConfig.v1';
export const DROPSHIP_DEFAULT_FIELD_ORDER = [
'imageUrl', 'specsText', 'cost', 'price',
'installFee', 'shipping', 'marginRate', 'commission', 'promoRate', 'adFee',
'seeding', 'afterSale', 'insurance', 'brandFee', 'targetMargin', 'profitAmount',
'coupon', 'activityDiscount', 'pddPrice', 'pddFinalPrice',
];
export const DROPSHIP_FIELD_LABELS = {
imageUrl: '图片',
specsText: '规格组合',
cost: '采购成本',
price: '到手价',
installFee: '安装费',
shipping: '运费',
marginRate: '毛利率',
commission: '平台佣金',
promoRate: '推广占比',
adFee: '营销广告费',
seeding: '种菜占比',
afterSale: '售后费用',
insurance: '保险费',
brandFee: '品牌使用费',
targetMargin: '营业利润率',
profitAmount: '营业利润额',
coupon: '优惠券金额',
activityDiscount: '活动折扣',
pddPrice: '拼单价',
pddFinalPrice: '最终拼单价',
};
export const createDefaultDropshipFieldConfig = () => ({
order: [...DROPSHIP_DEFAULT_FIELD_ORDER],
hidden: [],
});
export const normalizeDropshipFieldConfig = (raw) => {
if (!raw || !Array.isArray(raw.order)) return createDefaultDropshipFieldConfig();
const order = [...new Set(raw.order.filter((key) => DROPSHIP_DEFAULT_FIELD_ORDER.includes(key)))];
DROPSHIP_DEFAULT_FIELD_ORDER.forEach((key, defaultIndex) => {
if (order.includes(key)) return;
const previous = DROPSHIP_DEFAULT_FIELD_ORDER
.slice(0, defaultIndex)
.reverse()
.find((candidate) => order.includes(candidate));
if (previous) {
order.splice(order.indexOf(previous) + 1, 0, key);
} else {
order.push(key);
}
});
const hidden = (Array.isArray(raw.hidden) ? raw.hidden : [])
.filter((key) => DROPSHIP_DEFAULT_FIELD_ORDER.includes(key));
return { order, hidden };
};
export const loadDropshipFieldConfig = () => {
try {
return normalizeDropshipFieldConfig(JSON.parse(window.localStorage.getItem(STORAGE_KEY) || 'null'));
} catch {
return createDefaultDropshipFieldConfig();
}
};
export const saveDropshipFieldConfig = (config) => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
} catch {
// localStorage 不可用时仅保留当前会话状态。
}
};

View File

@ -1,93 +0,0 @@
// 字段配置:测算表列的显隐与顺序,持久化到 localStorage(本机偏好,跟随浏览器)
const STORAGE_KEY = 'pricingCalcFieldConfig.v1';
// 全部列均可配置(显隐 + 排序);条码曾是固定首列,向后兼容时特殊处理
export const DEFAULT_FIELD_ORDER = [
'barcode', 'imageUrl', 'skuStatus', 'stock', 'size', 'color', 'brand', 'cost', 'companyPriceLimit',
'price', 'installFee', 'shipping', 'marginRate', 'commission', 'promoRate',
'adFee', 'seeding', 'afterSale', 'insurance', 'brandFee', 'targetMargin',
'profitAmount', 'coupon', 'activityDiscount', 'pddPrice', 'pddFinalPrice',
];
export const FIELD_LABELS = {
barcode: '条码',
imageUrl: '图片',
skuStatus: '条码状态',
stock: '库存',
size: '尺寸',
color: '颜色',
brand: '品牌名称',
cost: '成本',
companyPriceLimit: '公司限价',
price: '到手价',
installFee: '安装费',
shipping: '运费',
marginRate: '毛利率',
commission: '平台佣金',
promoRate: '推广占比',
adFee: '营销广告费',
seeding: '种菜占比',
afterSale: '售后费用',
insurance: '保险费',
brandFee: '品牌使用费',
targetMargin: '营业利润率',
profitAmount: '营业利润额',
coupon: '优惠券金额',
activityDiscount: '活动折扣',
pddPrice: '拼单价',
pddFinalPrice: '最终拼单价',
};
// 页面上以百分比展示的字段:导出 xlsx 时保持数值、设置百分比数字格式(0.06 显示为 6.00%)
export const PERCENT_FIELD_KEYS = new Set([
'marginRate', 'commission', 'promoRate', 'adFee', 'seeding',
'afterSale', 'insurance', 'brandFee', 'targetMargin',
]);
// 合并已保存配置与默认配置:剔除已下线字段;新增字段插入默认相邻位置,
// 同时保留用户已经调整过的现有字段顺序。
export const normalizeFieldConfig = (raw) => {
const saved = raw && Array.isArray(raw.order) ? raw : null;
if (!saved) return createDefaultFieldConfig();
const order = [...new Set(saved.order.filter((key) => DEFAULT_FIELD_ORDER.includes(key)))];
DEFAULT_FIELD_ORDER.forEach((key, defaultIndex) => {
if (order.includes(key)) return;
const previousKey = DEFAULT_FIELD_ORDER
.slice(0, defaultIndex)
.reverse()
.find((candidate) => order.includes(candidate));
if (previousKey) {
order.splice(order.indexOf(previousKey) + 1, 0, key);
return;
}
const nextKey = DEFAULT_FIELD_ORDER
.slice(defaultIndex + 1)
.find((candidate) => order.includes(candidate));
order.splice(nextKey ? order.indexOf(nextKey) : order.length, 0, key);
});
const hidden = (saved && Array.isArray(saved.hidden) ? saved.hidden : [])
.filter((key) => DEFAULT_FIELD_ORDER.includes(key));
return { order, hidden };
};
export const createDefaultFieldConfig = () => ({
order: [...DEFAULT_FIELD_ORDER],
hidden: [],
});
export const loadFieldConfig = () => {
try {
return normalizeFieldConfig(JSON.parse(window.localStorage.getItem(STORAGE_KEY) || 'null'));
} catch {
return createDefaultFieldConfig();
}
};
export const saveFieldConfig = (config) => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
} catch {
// 隐私模式等写入失败时静默降级,仅本次会话生效
}
};

File diff suppressed because it is too large Load Diff

View File

@ -1,46 +0,0 @@
import React from 'react';
import { InputNumber } from 'antd';
import { toPercentInputValue } from './calc';
// 比例字段:页面以百分比数字编辑,内部按小数存储(如 6% ↔ 0.06)
export const PercentInput = ({ value, onChange, ...rest }) => (
<InputNumber
size="small"
min={0}
max={100}
step={0.5}
controls={false}
addonAfter="%"
value={toPercentInputValue(value)}
onChange={(next) => onChange(next === null || next === undefined ? null : Number((next / 100).toFixed(6)))}
{...rest}
/>
);
// 金额字段:保留 2 位小数
export const MoneyInput = ({ value, onChange, ...rest }) => (
<InputNumber
size="small"
min={0}
precision={2}
controls={false}
value={value}
onChange={(next) => onChange(next ?? null)}
{...rest}
/>
);
// 折扣字段:0-1 之间的小数(如 0.6 = 6 折)
export const DiscountInput = ({ value, onChange, ...rest }) => (
<InputNumber
size="small"
min={0.01}
max={1}
step={0.05}
precision={2}
controls={false}
value={value}
onChange={(next) => onChange(next ?? null)}
{...rest}
/>
);

View File

@ -1,774 +0,0 @@
.pricing-calc-panel {
min-height: calc(100vh - 84px);
overflow: hidden;
}
.pricing-calc-mode-tabs > .ant-tabs-nav {
min-height: 52px;
margin-bottom: 0 !important;
padding: 0 var(--sycm-module-padding);
border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light);
}
.pricing-calc-mode-tabs > .ant-tabs-nav::before {
border-bottom: 0;
}
.pricing-calc-mode-tabs > .ant-tabs-nav .ant-tabs-tab {
padding: 14px 0;
}
.pricing-calc-mode-tabs > .ant-tabs-content-holder,
.pricing-calc-mode-tabs > .ant-tabs-content-holder .ant-tabs-tabpane {
min-width: 0;
}
.pricing-calc-mode-tabs__label {
display: inline-flex;
align-items: center;
gap: var(--sycm-space-xs);
white-space: nowrap;
}
.pricing-calc-mode-tabs .pricing-calc-hero {
min-height: calc(100vh - 193px);
}
.pricing-calc-page.is-embedded {
padding: 0;
background: var(--sycm-bg-container, #fff);
}
.pricing-calc-page.is-embedded .pricing-calc-panel {
min-height: 0;
border: 0;
box-shadow: none;
}
/* ===== 空状态 Hero ===== */
.pricing-calc-hero {
display: flex;
min-height: calc(100vh - 140px);
align-items: center;
flex-direction: column;
justify-content: center;
padding: var(--sycm-space-xl) var(--sycm-module-padding);
text-align: center;
}
.pricing-calc-hero.is-embedded-state {
min-height: 320px;
}
.pricing-calc-hero__icon {
display: inline-flex;
width: 56px;
height: 56px;
align-items: center;
justify-content: center;
margin-bottom: var(--sycm-space-md);
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
border-radius: var(--sycm-radius-lg);
background: var(--sycm-bg-subtle);
color: var(--sycm-color-primary);
font-size: 26px;
}
.pricing-calc-hero__title {
margin: 0;
color: var(--sycm-text-strong);
font-size: var(--sycm-font-size-page);
font-weight: var(--sycm-font-weight-semibold);
line-height: var(--sycm-line-height-tight);
}
.pricing-calc-hero__subtitle {
max-width: 520px;
margin: var(--sycm-space-sm) 0 0;
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-caption);
line-height: var(--sycm-line-height-normal);
}
.pricing-calc-hero__search {
width: 480px;
max-width: 100%;
margin-top: var(--sycm-space-lg);
}
.pricing-calc-black-label-toggle {
display: inline-flex;
min-height: var(--sycm-control-height);
align-items: center;
gap: var(--sycm-space-sm);
padding: 0 var(--sycm-space-sm);
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
border-radius: var(--sycm-radius-md);
background: var(--sycm-bg-subtle);
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-caption);
white-space: nowrap;
}
.pricing-calc-black-label-toggle .ant-checkbox-wrapper {
color: var(--sycm-text-primary);
font-weight: var(--sycm-font-weight-medium);
}
.pricing-calc-hero__black-label {
margin-top: var(--sycm-space-sm);
}
.pricing-calc-hero__error {
width: 480px;
max-width: 100%;
margin-top: var(--sycm-space-sm);
text-align: left;
}
.pricing-calc-hero__footer {
display: flex;
width: 480px;
max-width: 100%;
align-items: center;
justify-content: space-between;
margin-top: var(--sycm-space-sm);
}
.pricing-calc-hero__hint {
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-caption);
}
/* ===== 结果区 SPU 摘要条 ===== */
.pricing-calc-spubar {
display: flex;
align-items: stretch;
gap: var(--sycm-space-sm) var(--sycm-space-md);
padding: var(--sycm-space-sm) var(--sycm-module-padding);
}
.pricing-calc-spubar__content {
display: flex;
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
gap: var(--sycm-space-xs);
}
.pricing-calc-spubar__controls {
display: flex;
min-width: 0;
align-items: center;
gap: var(--sycm-space-sm) var(--sycm-space-md);
}
.pricing-calc-spubar__search {
width: 260px;
}
.pricing-calc-spubar__black-label {
flex: none;
}
.pricing-calc-spubar__meta {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sycm-space-xs) var(--sycm-space-md);
color: var(--sycm-text-secondary);
font-size: var(--sycm-font-size-caption);
}
.pricing-calc-spubar__spu {
margin-inline-end: 0;
font-variant-numeric: tabular-nums;
}
.pricing-calc-spubar__actions {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sycm-space-sm);
}
.pricing-calc-dropship-spubar {
flex-wrap: wrap;
}
.pricing-calc-dropship-spubar .pricing-calc-spubar__content {
flex: 1 1 560px;
}
.pricing-calc-dropship-spubar .pricing-calc-summary {
flex-basis: 100%;
}
.pricing-calc-product-summary {
display: flex;
flex: 1 1 360px;
min-width: 280px;
align-items: center;
gap: var(--sycm-space-sm);
}
.pricing-calc-product-summary > .pricing-calc-thumb {
flex: none;
}
.pricing-calc-product-summary__body {
min-width: 0;
}
.pricing-calc-product-summary__title {
display: block;
overflow: hidden;
color: var(--sycm-text-strong);
font-size: var(--sycm-font-size-body);
line-height: var(--sycm-line-height-normal);
text-overflow: ellipsis;
white-space: nowrap;
}
.pricing-calc-warning {
margin: 0 var(--sycm-module-padding) var(--sycm-space-sm);
}
/* ===== 指标 / 筛选 / 图例 ===== */
.pricing-calc-summary {
display: grid;
flex: 0 1 760px;
grid-template-columns: repeat(4, minmax(100px, 1fr));
gap: 1px;
min-width: 520px;
overflow: hidden;
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
border-radius: var(--sycm-radius-md);
background: var(--sycm-border-light);
}
.pricing-calc-summary .sycm-dashboard-metric {
min-width: 0;
min-height: 56px;
padding: 6px 10px;
border-radius: 0;
background: var(--sycm-bg-container);
}
.pricing-calc-summary .sycm-dashboard-metric__label {
font-size: var(--sycm-font-size-caption);
}
.pricing-calc-summary .sycm-dashboard-metric__value {
margin-top: 2px;
font-size: 18px;
line-height: 1.2;
}
@media (max-width: 1365px) {
.pricing-calc-spubar {
flex-wrap: wrap;
}
.pricing-calc-summary {
flex-basis: 100%;
}
}
.pricing-calc-filter {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sycm-space-sm) var(--sycm-space-md);
margin-top: var(--sycm-space-sm);
padding: var(--sycm-space-sm) var(--sycm-module-padding);
border-top: var(--sycm-line-width-base) solid var(--sycm-border-light);
border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light);
background: var(--sycm-bg-subtle);
}
.pricing-calc-filter__item {
display: inline-flex;
align-items: center;
gap: var(--sycm-space-xs);
}
.pricing-calc-filter__item label {
color: var(--sycm-text-secondary);
font-size: var(--sycm-font-size-caption);
font-weight: var(--sycm-font-weight-medium);
white-space: nowrap;
}
.pricing-calc-filter__keyword {
width: 220px;
}
.pricing-calc-legend {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sycm-space-xs) var(--sycm-space-md);
margin-left: auto;
}
.pricing-calc-legend__item {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-caption);
white-space: nowrap;
}
.pricing-calc-chip {
width: 10px;
height: 10px;
border: var(--sycm-line-width-base) solid var(--sycm-border-base);
border-radius: var(--sycm-radius-xs);
background: var(--sycm-bg-container);
}
.pricing-calc-chip.is-input {
border-color: #ffd666;
background: #ffe58f;
}
.pricing-calc-chip.is-auto {
border-color: #ffe3a3;
background: #fff8e6;
}
.pricing-calc-chip.is-formula {
border-color: #fff1b8;
background: #fffbe6;
}
.pricing-calc-chip.is-danger {
border-color: #ffccc7;
background: #fff1f0;
}
/* ===== 测算表 ===== */
.pricing-calc-table .ant-table-wrapper,
.pricing-calc-table .ant-spin-nested-loading,
.pricing-calc-table .ant-spin-container {
min-width: 0;
}
.pricing-calc-table .ant-table {
min-width: 0;
}
.pricing-calc-table .ant-table-cell {
vertical-align: middle;
}
.pricing-calc-table .ant-table-thead > tr > th {
color: var(--sycm-text-secondary);
font-size: var(--sycm-font-size-caption);
font-weight: var(--sycm-font-weight-semibold);
white-space: nowrap;
}
.pricing-calc-table .ant-table-tbody > tr > td {
color: var(--sycm-text-primary);
font-size: var(--sycm-font-size-caption);
}
.pricing-calc-table .ant-table-placeholder .ant-table-cell {
height: 220px;
}
.pricing-calc-table .ant-input-number {
width: 100%;
}
/* 列来源语义着色:浅橙=自动获取/固定比例,浅黄=公式,亮黄=可填写 */
.pricing-calc-table .ant-table-thead > tr > th.pricing-calc-th--auto {
background: #fff8e6;
}
.pricing-calc-table .ant-table-thead > tr > th.pricing-calc-th--input {
background: #ffe58f;
}
.pricing-calc-table .ant-table-thead > tr > th.pricing-calc-th--formula {
background: #fffbe6;
}
.pricing-calc-table .ant-table-tbody > tr > td.pricing-calc-td--input {
background: #fffbe6;
box-shadow: inset 0 0 0 1px #ffe58f;
}
.pricing-calc-table .ant-table-tbody > tr:hover > td.pricing-calc-td--input {
background: #fff3bf;
}
.pricing-calc-table .ant-table-tbody > tr > td.pricing-calc-td--formula {
background: #fffdf2;
}
.pricing-calc-table .ant-table-tbody > tr:hover > td.pricing-calc-td--formula {
background: #fffbe6;
}
.pricing-calc-table .ant-table-tbody > tr > td.pricing-calc-td--danger {
background: #fff1f0;
}
.pricing-calc-table .ant-table-tbody > tr:hover > td.pricing-calc-td--danger {
background: #ffe2e0;
}
/* 拼多多活动列:蓝色高亮(浅蓝=公式,描边蓝=可填写) */
.pricing-calc-chip.is-pdd {
border-color: #91caff;
background: #e6f4ff;
}
.pricing-calc-table .ant-table-thead > tr > th.pricing-calc-th--pdd {
background: #d6e9ff;
}
.pricing-calc-table .ant-table-tbody > tr > td.pricing-calc-td--pdd {
background: #f0f7ff;
}
.pricing-calc-table .ant-table-tbody > tr:hover > td.pricing-calc-td--pdd {
background: #e6f4ff;
}
.pricing-calc-table .ant-table-tbody > tr > td.pricing-calc-td--pdd-input {
background: #e6f4ff;
box-shadow: inset 0 0 0 1px #91caff;
}
.pricing-calc-table .ant-table-tbody > tr:hover > td.pricing-calc-td--pdd-input {
background: #d6e9ff;
}
.pricing-calc-td--danger .pricing-calc-value {
color: var(--sycm-color-danger);
font-weight: var(--sycm-font-weight-semibold);
}
.pricing-calc-value {
font-variant-numeric: tabular-nums;
}
.pricing-calc-text {
color: var(--sycm-text-primary);
}
.pricing-calc-barcode {
color: var(--sycm-text-primary);
font-variant-numeric: tabular-nums;
cursor: default;
}
.pricing-calc-spec-list {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.pricing-calc-spec-item {
display: grid;
grid-template-columns: minmax(48px, auto) minmax(0, 1fr);
gap: var(--sycm-space-xs);
line-height: 1.35;
}
.pricing-calc-spec-item b {
color: var(--sycm-text-tertiary);
font-weight: var(--sycm-font-weight-medium);
white-space: nowrap;
}
.pricing-calc-spec-item span {
overflow-wrap: anywhere;
}
.pricing-calc-th-title {
display: inline-flex;
align-items: center;
gap: 4px;
}
.pricing-calc-th-title .anticon {
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-caption);
}
.pricing-calc-thumb {
display: block;
width: 36px;
height: 36px;
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
border-radius: var(--sycm-radius-sm);
object-fit: cover;
}
.pricing-calc-thumb.is-empty {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: var(--sycm-bg-subtle);
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-micro);
}
/* ===== 保存弹窗 / 历史抽屉 / 品牌抽屉 ===== */
.pricing-calc-save__hint {
margin: 0 0 var(--sycm-space-sm);
color: var(--sycm-text-secondary);
font-size: var(--sycm-font-size-caption);
line-height: var(--sycm-line-height-normal);
}
.pricing-calc-product-code__warning {
margin-bottom: var(--sycm-space-md);
}
.pricing-calc-product-code__form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0 var(--sycm-space-md);
}
.pricing-calc-product-code__mapping {
max-height: 320px;
margin-bottom: var(--sycm-space-md);
overflow: auto;
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
border-radius: var(--sycm-radius-sm);
}
.pricing-calc-product-code__mapping-head,
.pricing-calc-product-code__mapping-row {
display: grid;
grid-template-columns: 150px minmax(150px, 1fr) minmax(220px, 1.35fr) 150px;
gap: var(--sycm-space-sm);
align-items: center;
min-width: 760px;
padding: var(--sycm-space-sm) var(--sycm-space-md);
}
.pricing-calc-product-code__mapping-head {
position: sticky;
top: 0;
z-index: 1;
color: var(--sycm-text-secondary);
background: var(--sycm-bg-subtle);
font-size: var(--sycm-font-size-caption);
font-weight: var(--sycm-font-weight-medium);
}
.pricing-calc-product-code__mapping-row {
border-top: var(--sycm-line-width-base) solid var(--sycm-border-light);
}
.pricing-calc-product-code__mapping-row > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pricing-calc-product-code__mapping-row .is-complete {
color: var(--sycm-success);
font-weight: var(--sycm-font-weight-medium);
}
@media (max-width: 900px) {
.pricing-calc-product-code__form {
grid-template-columns: 1fr;
}
}
.pricing-calc-history__title {
display: inline-flex;
align-items: center;
gap: var(--sycm-space-xs);
}
.pricing-calc-history__subtitle {
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-caption);
font-weight: var(--sycm-font-weight-regular);
}
.pricing-calc-history__item {
align-items: flex-start;
}
.pricing-calc-history__item-title {
display: inline-flex;
align-items: center;
gap: var(--sycm-space-xs);
}
.pricing-calc-history__spu {
font-weight: var(--sycm-font-weight-semibold);
font-variant-numeric: tabular-nums;
}
.pricing-calc-history__count {
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-caption);
}
.pricing-calc-history__meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.pricing-calc-history__note {
color: var(--sycm-text-secondary);
}
.pricing-calc-brand-drawer__hint {
margin: 0 0 var(--sycm-space-sm);
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-caption);
line-height: var(--sycm-line-height-normal);
}
.pricing-calc-brand-drawer__add {
margin-top: var(--sycm-space-sm);
}
@media (max-width: 767px) {
.pricing-calc-hero__search,
.pricing-calc-hero__error,
.pricing-calc-hero__footer {
width: 100%;
}
.pricing-calc-spubar {
align-items: stretch;
flex-direction: column;
}
.pricing-calc-spubar__controls {
align-items: stretch;
flex-direction: column;
}
.pricing-calc-spubar__search {
width: 100%;
}
.pricing-calc-spubar__black-label {
width: 100%;
justify-content: space-between;
}
.pricing-calc-spubar__actions {
flex-wrap: wrap;
}
.pricing-calc-product-summary {
min-width: 0;
}
.pricing-calc-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
min-width: 0;
}
.pricing-calc-filter__keyword {
width: 100%;
}
.pricing-calc-legend {
margin-left: 0;
}
}
/* ===== 字段配置抽屉 ===== */
.pricing-calc-field-config__tip {
margin: 0 0 12px;
color: var(--sycm-color-text-secondary, #8c8c8c);
font-size: 12px;
line-height: 1.6;
}
.pricing-calc-field-config__list {
list-style: none;
margin: 0;
padding: 0;
}
.pricing-calc-field-config__item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
margin-bottom: 8px;
background: var(--sycm-bg-container, #fff);
border: 1px solid var(--sycm-color-border, #f0f0f0);
border-radius: var(--sycm-radius-xs, 6px);
cursor: grab;
transition: box-shadow 0.15s ease, opacity 0.15s ease;
}
.pricing-calc-field-config__item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.pricing-calc-field-config__item.is-dragging {
opacity: 0.45;
cursor: grabbing;
}
.pricing-calc-field-config__item.is-off {
opacity: 0.55;
}
.pricing-calc-field-config__grip {
color: var(--sycm-color-text, #262626);
font-size: 14px;
cursor: grab;
}
.pricing-calc-field-config__footer {
display: flex;
justify-content: space-between;
}
/* ===== 默认值配置弹窗 ===== */
.pricing-calc-defaults__tip {
margin: 0 0 16px;
color: var(--sycm-color-text-secondary, #8c8c8c);
font-size: 12px;
line-height: 1.6;
}
.pricing-calc-defaults__row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.pricing-calc-defaults__label {
width: 80px;
text-align: right;
flex: none;
}
.pricing-calc-defaults__hint {
color: var(--sycm-color-text-secondary, #8c8c8c);
font-size: 12px;
}
.pricing-calc-defaults__footer {
display: flex;
justify-content: space-between;
}

View File

@ -52,14 +52,12 @@ const CORE_CONFIG_NAMES = new Set([
'cloud_database',
'cloud_database_public',
'planning_database',
'ai_buyer_show_database',
]);
const CONFIG_LABELS = {
cloud_database: '默认业务数据库',
cloud_database_public: '公网业务数据库',
planning_database: '企划数据库',
ai_buyer_show_database: 'AI买家秀数据库',
jiansun_database: '简尚业务数据库',
};

View File

@ -1,591 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Button,
DatePicker,
Empty,
Progress,
Segmented,
Select,
Skeleton,
Space,
Table,
Typography,
message,
} from 'antd';
import {
AlertOutlined,
CheckCircleOutlined,
ReloadOutlined,
SearchOutlined,
ShopOutlined,
} from '@ant-design/icons';
import ReactECharts from 'echarts-for-react';
import dayjs from 'dayjs';
import {
getStoreCheckupMeta,
getStoreCheckupOverview,
getStoreCheckupTab,
} from '../../api/storeCheckup';
import {
SycmBusinessPanel,
SycmDashboardMetric,
SycmModuleTabs,
SycmPage,
SycmStatusTag,
SycmToolbar,
} from '../../components/Common';
import {
SYCM_CHART_STORE_COLOR,
sycmEchartsBase,
sycmLineSeriesDefaults,
} from '../../design-system';
import './style.css';
const { Text } = Typography;
const TAB_ITEMS = [
{ key: 'overview', label: '体检概览' },
{ key: 'business', label: '经营表现' },
{ key: 'after-sales', label: '售后退款' },
{ key: 'customer-service', label: '客服接待' },
{ key: 'service-score', label: '服务体验' },
{ key: 'stars', label: '星级提升' },
];
const BUSINESS_CHART_OPTIONS = [
{ label: '成交额', value: 'sales', unit: 'currency' },
{ label: '客单价', value: 'averageAov', unit: 'currency' },
{ label: '支付转化率', value: 'averageConversion', unit: 'percent' },
];
const SERVICE_SCORE_ITEMS = [
['cstmrServScore', '消费者服务体验'],
['attiLmScore', '服务态度'],
['jcfwLmScore', '基础服务'],
['spLmScore', '商品服务'],
['fhLmScore', '发货服务'],
['wlLmScore', '物流服务'],
];
const TARGET_VALUE_TYPES = {
ptHelpRate1m: 'ratio',
aprsIn3minRplyUsrRto1m: 'ratio',
posTypeRto1m: 'ratio',
seriousInferiorRto1m: 'ratio',
lgstMergeFine1m: 'ratio',
};
function hasValue(value) {
return value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value));
}
function formatNumber(value, digits = 0) {
if (!hasValue(value)) return '--';
return new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(Number(value));
}
function formatCurrency(value) {
return hasValue(value) ? `¥${formatNumber(value, 2)}` : '--';
}
function formatPercent(value) {
return hasValue(value) ? `${formatNumber(value, 2)}%` : '--';
}
function formatRatio(value) {
return hasValue(value) ? `${formatNumber(Number(value) * 100, 2)}%` : '--';
}
function formatScore(value) {
return hasValue(value) ? formatNumber(value, 2) : '--';
}
function formatMetricValue(metric) {
if (!metric) return '--';
if (metric.unit === 'currency') return formatCurrency(metric.value);
if (metric.unit === 'percent') return formatPercent(metric.value);
if (metric.unit === 'ratio') return formatRatio(metric.value);
if (metric.unit === 'star') return hasValue(metric.value) ? `${formatNumber(metric.value, 1)} 星` : '--';
if (metric.unit === 'starChange') {
if (!hasValue(metric.value)) return '--';
const value = Number(metric.value);
return `${value > 0 ? '+' : ''}${formatNumber(value, 1)}`;
}
return formatScore(metric.value);
}
function statusTone(status) {
const text = String(status || '');
if (text.includes('不达标') || text.includes('未达标')) return 'error';
if (text.includes('达标')) return 'success';
return 'default';
}
const StoreCheckup = () => {
const [meta, setMeta] = useState({ shops: [], minDate: '', latestDate: '' });
const [filters, setFilters] = useState({ shopName: '', date: '' });
const [appliedFilters, setAppliedFilters] = useState({ shopName: '', date: '' });
const [activeTab, setActiveTab] = useState('overview');
const [overview, setOverview] = useState(null);
const [overviewLoading, setOverviewLoading] = useState(true);
const [tabData, setTabData] = useState(null);
const [tabLoading, setTabLoading] = useState(false);
const [businessChartMetric, setBusinessChartMetric] = useState('sales');
const tabCacheRef = useRef(new Map());
const overviewRequestRef = useRef(0);
const tabRequestRef = useRef(0);
const loadOverview = async (nextFilters, options = {}) => {
const requestId = overviewRequestRef.current + 1;
overviewRequestRef.current = requestId;
if (options.clearCache !== false) tabCacheRef.current.clear();
setOverviewLoading(true);
try {
const response = await getStoreCheckupOverview({
shopName: nextFilters.shopName || undefined,
date: nextFilters.date || undefined,
});
if (requestId !== overviewRequestRef.current) return;
setOverview(response.data || null);
setAppliedFilters(nextFilters);
} catch (error) {
if (requestId !== overviewRequestRef.current) return;
setOverview(null);
message.error(error.response?.data?.error || error.message || '店铺体检加载失败');
} finally {
if (requestId === overviewRequestRef.current) setOverviewLoading(false);
}
};
useEffect(() => {
let active = true;
getStoreCheckupMeta()
.then((response) => {
if (!active) return;
const nextMeta = response.data || { shops: [], minDate: '', latestDate: '' };
const initialFilters = { shopName: '', date: nextMeta.latestDate || '' };
setMeta(nextMeta);
setFilters(initialFilters);
loadOverview(initialFilters);
})
.catch((error) => {
if (!active) return;
setOverviewLoading(false);
message.error(error.response?.data?.error || error.message || '店铺体检初始化失败');
});
return () => {
active = false;
};
}, []);
useEffect(() => {
if (activeTab === 'overview' || !appliedFilters.date) {
setTabData(null);
setTabLoading(false);
return undefined;
}
const cacheKey = `${activeTab}:${appliedFilters.shopName}:${appliedFilters.date}`;
const cached = tabCacheRef.current.get(cacheKey);
if (cached) {
setTabData(cached);
setTabLoading(false);
return undefined;
}
const requestId = tabRequestRef.current + 1;
tabRequestRef.current = requestId;
setTabLoading(true);
getStoreCheckupTab(activeTab, {
shopName: appliedFilters.shopName || undefined,
date: appliedFilters.date,
})
.then((response) => {
if (requestId !== tabRequestRef.current) return;
const data = response.data || null;
tabCacheRef.current.set(cacheKey, data);
setTabData(data);
})
.catch((error) => {
if (requestId !== tabRequestRef.current) return;
setTabData({ status: 'error', message: error.response?.data?.error || error.message || '体检模块加载失败' });
})
.finally(() => {
if (requestId === tabRequestRef.current) setTabLoading(false);
});
return undefined;
}, [activeTab, appliedFilters]);
const handleShopChange = (shopName) => {
const selectedShop = meta.shops.find((item) => item.name === shopName);
setFilters({
shopName: shopName || '',
date: selectedShop?.latestDate || meta.latestDate || '',
});
};
const handleQuery = () => {
loadOverview(filters);
};
const overviewMetrics = [
{ key: 'star', label: filters.shopName ? '综合体验星级' : '平均综合体验星级', value: hasValue(overview?.stars?.mallStarTomms) ? `${formatNumber(overview.stars.mallStarTomms, 1)} 星` : '--' },
{ key: 'score', label: filters.shopName ? '消费者服务体验分' : '平均服务体验分', value: formatScore(overview?.serviceScore?.cstmrServScore) },
{ key: 'sales', label: '成交额', value: formatCurrency(overview?.business?.sales) },
{ key: 'aov', label: filters.shopName ? '支付客单价' : '平均支付客单价', value: formatCurrency(overview?.business?.averageAov) },
{ key: 'conversion', label: filters.shopName ? '支付转化率' : '平均支付转化率', value: formatPercent(overview?.business?.averageConversion) },
{ key: 'issues', label: '待关注问题', value: formatNumber(overview?.issues?.length), footerLabel: '数据日期', footerValue: overview?.date || '--' },
];
const businessChartConfig = BUSINESS_CHART_OPTIONS.find((item) => item.value === businessChartMetric) || BUSINESS_CHART_OPTIONS[0];
const businessChartOption = {
...sycmEchartsBase,
grid: { ...sycmEchartsBase.grid, top: 30, right: 28, bottom: 32, left: 58 },
xAxis: {
...sycmEchartsBase.xAxis,
type: 'category',
boundaryGap: false,
data: (tabData?.trend || []).map((item) => item.date),
},
yAxis: {
...sycmEchartsBase.yAxis,
type: 'value',
axisLabel: {
...sycmEchartsBase.yAxis?.axisLabel,
formatter: (value) => businessChartConfig.unit === 'currency' ? `¥${formatNumber(value)}` : `${formatNumber(value, 1)}%`,
},
},
tooltip: {
...sycmEchartsBase.tooltip,
valueFormatter: (value) => businessChartConfig.unit === 'currency' ? formatCurrency(value) : formatPercent(value),
},
series: [{
...sycmLineSeriesDefaults,
name: businessChartConfig.label,
data: (tabData?.trend || []).map((item) => item[businessChartMetric]),
lineStyle: { width: 2, color: SYCM_CHART_STORE_COLOR },
itemStyle: { color: SYCM_CHART_STORE_COLOR },
areaStyle: { color: 'rgba(47, 93, 246, 0.08)' },
}],
};
const renderOverview = () => {
if (overviewLoading && !overview) return <Skeleton active paragraph={{ rows: 10 }} />;
if (!overview) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前筛选条件暂无体检数据" />;
const issues = overview.issues || [];
const coverageItems = [
['business', '经营数据'], ['afterSales', '售后数据'], ['customerService', '客服数据'],
['serviceScore', '服务体验'], ['stars', '店铺星级'],
];
return (
<div className="store-checkup-overview">
<div className="store-checkup-metrics">
{overviewMetrics.map(({ key, ...metric }) => <SycmDashboardMetric key={key} {...metric} help={false} />)}
</div>
<div className="store-checkup-overview-grid">
<section className="store-checkup-section store-checkup-issues">
<header>
<div>
<h3>优先处理问题</h3>
<span>{issues.length ? `共 ${issues.length} 项需要关注` : '当前数据未发现明确风险项'}</span>
</div>
<SycmStatusTag tone={issues.some((item) => item.tone === 'error') ? 'error' : (issues.length ? 'warning' : 'success')}>
{issues.length ? '需处理' : '状态正常'}
</SycmStatusTag>
</header>
{issues.length ? (
<div className="store-checkup-issue-list">
{issues.map((issue) => (
<div key={issue.key} className={`store-checkup-issue is-${issue.tone}`}>
<span className="store-checkup-issue__icon"><AlertOutlined /></span>
<div>
<strong>{issue.title}</strong>
<p>{issue.description}</p>
</div>
<div className="store-checkup-issue__value">
<strong>{formatNumber(issue.value)}</strong>
<span>{issue.source}</span>
</div>
</div>
))}
</div>
) : (
<div className="store-checkup-healthy-state">
<CheckCircleOutlined />
<span>当前快照中的明确风险项均为零</span>
</div>
)}
</section>
<section className="store-checkup-section store-checkup-coverage">
<header>
<div>
<h3>数据覆盖</h3>
<span>{overview.shopName} · {overview.date}</span>
</div>
</header>
<div className="store-checkup-coverage-list">
{coverageItems.map(([key, label]) => (
<div key={key}>
<span>{label}</span>
<SycmStatusTag tone={overview.coverage?.[key] ? 'success' : 'default'}>
{overview.coverage?.[key] ? '已接入' : '暂无数据'}
</SycmStatusTag>
</div>
))}
</div>
</section>
</div>
<div className="store-checkup-overview-grid is-balanced">
<section className="store-checkup-section">
<header>
<div>
<h3>售后待办</h3>
<span>当前日期的店铺售后状态快照</span>
</div>
</header>
<div className="store-checkup-stat-strip">
<div><span>售后总量</span><strong>{formatNumber(overview.afterSales?.afterSalesCount)}</strong></div>
<div><span>待商家处理</span><strong className="is-risk">{formatNumber(overview.afterSales?.waitSellerHandle)}</strong></div>
<div><span>平台处理中</span><strong>{formatNumber(overview.afterSales?.platformHandling)}</strong></div>
<div><span>工单未完结</span><strong>{formatNumber(Math.max(0, Number(overview.afterSales?.todosCount || 0) - Number(overview.afterSales?.todosEndedCount || 0)))}</strong></div>
</div>
</section>
<section className="store-checkup-section">
<header>
<div>
<h3>客服效率</h3>
<span>按客服账号汇总</span>
</div>
</header>
<div className="store-checkup-stat-strip">
<div><span>咨询人数</span><strong>{formatNumber(overview.customerService?.reception?.consultUserCount)}</strong></div>
<div><span>人工接待</span><strong>{formatNumber(overview.customerService?.reception?.receiveUserCount)}</strong></div>
<div><span>30秒应答率</span><strong>{formatPercent(overview.customerService?.reception?.replyRate30s)}</strong></div>
<div><span>平均回复时长</span><strong>{hasValue(overview.customerService?.reception?.averageReplyTime) ? `${formatNumber(overview.customerService.reception.averageReplyTime, 1)} 分钟` : '--'}</strong></div>
</div>
</section>
</div>
<section className="store-checkup-section store-checkup-score-section">
<header>
<div>
<h3>服务体验结构</h3>
<span>消费者服务体验分及五项拆分</span>
</div>
</header>
<div className="store-checkup-score-grid">
{SERVICE_SCORE_ITEMS.map(([key, label]) => {
const value = overview.serviceScore?.[key];
return (
<div key={key} className="store-checkup-score-item">
<div><span>{label}</span><strong>{formatScore(value)}</strong></div>
<Progress percent={hasValue(value) ? Math.min(Number(value) * 20, 100) : 0} showInfo={false} strokeColor="#2f5df6" trailColor="#edf0f5" />
</div>
);
})}
</div>
</section>
</div>
);
};
const renderMetricGrid = (metrics = []) => (
<div className="store-checkup-tab-metrics">
{metrics.map((metric) => (
<SycmDashboardMetric key={metric.key} label={metric.label} value={formatMetricValue(metric)} help={false} />
))}
</div>
);
const renderTable = (data, rowKey = 'id') => {
const percentKeys = new Set(['ptHelpRate1m', 'aprsIn3minRplyUsrRto1m']);
const directPercentKeys = new Set(['replyRate3Min', 'replyRate30s']);
const currencyKeys = new Set(['csSalesAmount']);
const columns = (data.columns || []).map((column) => ({
title: column.title,
dataIndex: column.key,
key: column.key,
ellipsis: column.key === 'shopName' || column.key === 'csName',
align: column.key === 'shopName' || column.key === 'csName' ? 'left' : 'right',
render: (value) => {
if (percentKeys.has(column.key)) return formatRatio(value);
if (directPercentKeys.has(column.key)) return formatPercent(value);
if (currencyKeys.has(column.key)) return formatCurrency(value);
return hasValue(value) ? formatNumber(value, Number(value) % 1 === 0 ? 0 : 2) : (value || '--');
},
}));
return (
<Table
rowKey={(row, index) => row[rowKey] || row.shopName || row.csName || index}
size="small"
columns={columns}
dataSource={data.rows || []}
pagination={(data.rows || []).length > 20 ? { pageSize: 20, showSizeChanger: false } : false}
scroll={{ x: 'max-content' }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前日期暂无数据" /> }}
/>
);
};
const renderBusinessTab = () => (
<div className="store-checkup-tab-body">
{renderMetricGrid(tabData.metrics)}
<section className="store-checkup-section store-checkup-chart-section">
<header>
<div><h3>近 30 天趋势</h3><span>{tabData.shopName} · 截止 {tabData.date}</span></div>
<Segmented options={BUSINESS_CHART_OPTIONS.map(({ label, value }) => ({ label, value }))} value={businessChartMetric} onChange={setBusinessChartMetric} />
</header>
<ReactECharts option={businessChartOption} style={{ height: 330 }} notMerge lazyUpdate />
</section>
</div>
);
const renderAfterSalesTab = () => {
const totals = (tabData.rows || []).reduce((result, row) => ({
afterSalesCount: result.afterSalesCount + Number(row.afterSalesCount || 0),
waitSellerHandle: result.waitSellerHandle + Number(row.waitSellerHandle || 0),
platformHandling: result.platformHandling + Number(row.platformHandling || 0),
pendingTodos: result.pendingTodos + Math.max(0, Number(row.todosCount || 0) - Number(row.todosEndedCount || 0)),
}), { afterSalesCount: 0, waitSellerHandle: 0, platformHandling: 0, pendingTodos: 0 });
return (
<div className="store-checkup-tab-body">
{renderMetricGrid([
{ key: 'afterSalesCount', label: '售后总量', value: totals.afterSalesCount, unit: 'count' },
{ key: 'waitSellerHandle', label: '待商家处理', value: totals.waitSellerHandle, unit: 'count' },
{ key: 'platformHandling', label: '平台处理中', value: totals.platformHandling, unit: 'count' },
{ key: 'pendingTodos', label: '工单未完结', value: totals.pendingTodos, unit: 'count' },
])}
<section className="store-checkup-section store-checkup-table-section">
<header><div><h3>店铺售后状态</h3><span>{tabData.date} 快照</span></div></header>
{renderTable(tabData)}
</section>
</div>
);
};
const renderCustomerServiceTab = () => {
const reception = overview?.customerService?.reception || {};
return (
<div className="store-checkup-tab-body">
{renderMetricGrid([
{ key: 'consult', label: '咨询人数', value: reception.consultUserCount, unit: 'count' },
{ key: 'inquiry', label: '询单人数', value: reception.inquiryUserCount, unit: 'count' },
{ key: 'group', label: '最终成团人数', value: reception.finalGroupUserCount, unit: 'count' },
{ key: 'sales', label: '客服销售额', value: reception.csSalesAmount, unit: 'currency' },
{ key: 'reply30', label: '30秒应答率', value: reception.replyRate30s, unit: 'percent' },
{ key: 'complaints', label: '投诉数', value: reception.complainUserCount, unit: 'count' },
])}
<section className="store-checkup-section store-checkup-table-section">
<header><div><h3>客服账号表现</h3><span>按客服账号汇总</span></div></header>
{renderTable(tabData, 'csName')}
</section>
</div>
);
};
const renderServiceScoreTab = () => (
<div className="store-checkup-tab-body">
{renderMetricGrid(tabData.metrics)}
<section className="store-checkup-section store-checkup-table-section">
<header><div><h3>店铺服务体验明细</h3><span>{tabData.date} 快照</span></div></header>
{renderTable(tabData)}
</section>
</div>
);
const renderStarsTab = () => {
const targetColumns = [
{ title: '星级指标', dataIndex: 'label', key: 'label' },
{ title: '当前值', dataIndex: 'value', key: 'value', align: 'right', render: (value, row) => TARGET_VALUE_TYPES[row.field] === 'ratio' ? formatRatio(value) : formatScore(value) },
{ title: '下一星级标准', dataIndex: 'threshold', key: 'threshold', align: 'right', render: (value, row) => TARGET_VALUE_TYPES[row.field] === 'ratio' ? formatRatio(value) : formatScore(value) },
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center', render: (value) => <SycmStatusTag tone={statusTone(value)}>{value || '--'}</SycmStatusTag> },
];
return (
<div className="store-checkup-tab-body">
{renderMetricGrid(tabData.metrics)}
{(tabData.targets || []).length ? (
<section className="store-checkup-section store-checkup-table-section">
<header><div><h3>下一星级差距</h3><span>当前指标与平台标准</span></div></header>
<Table rowKey="field" size="small" columns={targetColumns} dataSource={tabData.targets || []} pagination={false} />
</section>
) : null}
{!appliedFilters.shopName && (tabData.rows || []).length ? (
<section className="store-checkup-section store-checkup-table-section">
<header><div><h3>店铺星级明细</h3><span>{tabData.date} 快照</span></div></header>
{renderTable(tabData)}
</section>
) : null}
</div>
);
};
const renderTabContent = () => {
if (activeTab === 'overview') return renderOverview();
if (tabLoading && !tabData) return <Skeleton active paragraph={{ rows: 10 }} />;
if (tabData?.status === 'error') return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={tabData.message || '体检模块加载失败'} />;
if (!tabData || tabData.status === 'empty') return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前筛选条件暂无数据" />;
if (activeTab === 'business') return renderBusinessTab();
if (activeTab === 'after-sales') return renderAfterSalesTab();
if (activeTab === 'customer-service') return renderCustomerServiceTab();
if (activeTab === 'service-score') return renderServiceScoreTab();
return renderStarsTab();
};
return (
<SycmPage className="store-checkup-page">
<SycmBusinessPanel className="store-checkup-filter-panel">
<SycmToolbar>
<div className="store-checkup-filters">
<Select value="pdd" disabled options={[{ value: 'pdd', label: '拼多多' }]} aria-label="平台" />
<Select
showSearch
value={filters.shopName}
options={[{ value: '', label: '全部店铺' }, ...(meta.shops || []).map((shop) => ({ value: shop.name, label: shop.name }))]}
optionFilterProp="label"
onChange={handleShopChange}
aria-label="店铺"
/>
<DatePicker
value={filters.date ? dayjs(filters.date) : null}
format="YYYY-MM-DD"
allowClear={false}
disabledDate={(date) => (
(meta.minDate && date.isBefore(dayjs(meta.minDate), 'day'))
|| (meta.latestDate && date.isAfter(dayjs(meta.latestDate), 'day'))
)}
onChange={(date) => setFilters((current) => ({ ...current, date: date?.format('YYYY-MM-DD') || current.date }))}
aria-label="体检日期"
/>
<div className="store-checkup-date-note">
<ShopOutlined />
<span>{filters.shopName || '全部店铺'}</span>
<Text type="secondary">最新 {filters.shopName ? meta.shops.find((item) => item.name === filters.shopName)?.latestDate : meta.latestDate || '--'}</Text>
</div>
</div>
<Space size={8}>
<Button icon={<ReloadOutlined />} onClick={() => loadOverview(appliedFilters)} loading={overviewLoading}>刷新</Button>
<Button type="primary" icon={<SearchOutlined />} onClick={handleQuery} loading={overviewLoading}>查询</Button>
</Space>
</SycmToolbar>
</SycmBusinessPanel>
<SycmBusinessPanel
title="店铺体检"
extra={<Text type="secondary">{overview?.shopName || '全部店铺'} · 数据日期 {overview?.date || filters.date || '--'}</Text>}
className="store-checkup-content-panel"
>
<SycmModuleTabs items={TAB_ITEMS} activeKey={activeTab} onChange={setActiveTab} />
<div className={`store-checkup-content${overviewLoading && overview ? ' is-refreshing' : ''}`}>
{renderTabContent()}
</div>
</SycmBusinessPanel>
</SycmPage>
);
};
export default StoreCheckup;

View File

@ -1,387 +0,0 @@
.store-checkup-page {
container-name: store-checkup;
container-type: inline-size;
display: flex;
flex-direction: column;
gap: var(--sycm-page-section-gap);
}
.store-checkup-filter-panel .sycm-business-panel__body {
padding: 12px 16px;
}
.store-checkup-filter-panel .sycm-toolbar {
display: grid !important;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center !important;
gap: 12px;
}
.store-checkup-filters {
display: grid;
min-width: 0;
grid-template-columns: 96px minmax(240px, 340px) 150px minmax(240px, 1fr);
align-items: center;
gap: 8px;
}
.store-checkup-filters .ant-select,
.store-checkup-filters .ant-picker {
width: 100%;
}
.store-checkup-date-note {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
color: var(--sycm-text-secondary);
font-size: var(--sycm-font-size-caption);
}
.store-checkup-date-note > span {
overflow: hidden;
color: var(--sycm-text-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.store-checkup-date-note .ant-typography {
flex: 0 0 auto;
margin: 0;
font-size: inherit;
}
.store-checkup-content-panel > .sycm-business-panel__body {
padding: 0;
}
.store-checkup-content-panel .sycm-module-tabs {
border-bottom: 1px solid var(--sycm-border-light);
}
.store-checkup-content {
min-height: 520px;
padding: 16px;
transition: opacity var(--sycm-interaction-duration-fast);
}
.store-checkup-content.is-refreshing {
opacity: 0.62;
pointer-events: none;
}
.store-checkup-overview,
.store-checkup-tab-body {
display: flex;
flex-direction: column;
gap: 16px;
}
.store-checkup-metrics,
.store-checkup-tab-metrics {
display: grid;
overflow: hidden;
border: 1px solid var(--sycm-border-light);
border-radius: var(--sycm-radius-md);
background: var(--sycm-bg-container);
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.store-checkup-tab-metrics {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.store-checkup-metrics .sycm-dashboard-metric,
.store-checkup-tab-metrics .sycm-dashboard-metric {
min-height: 98px;
border: 0;
border-right: 1px solid var(--sycm-border-light);
border-radius: 0;
padding: 14px 16px;
background: var(--sycm-bg-container);
}
.store-checkup-metrics .sycm-dashboard-metric:last-child,
.store-checkup-tab-metrics .sycm-dashboard-metric:last-child {
border-right: 0;
}
.store-checkup-overview-grid {
display: grid;
grid-template-columns: minmax(0, 1.65fr) minmax(280px, 0.75fr);
gap: 16px;
}
.store-checkup-overview-grid.is-balanced {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.store-checkup-section {
min-width: 0;
border: 1px solid var(--sycm-border-light);
border-radius: var(--sycm-radius-md);
background: var(--sycm-bg-container);
}
.store-checkup-section > header {
display: flex;
min-height: 54px;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--sycm-border-light);
}
.store-checkup-section > header h3 {
margin: 0;
color: var(--sycm-text-strong);
font-size: var(--sycm-font-size-body);
font-weight: var(--sycm-font-weight-semibold);
letter-spacing: 0;
}
.store-checkup-section > header span {
display: block;
margin-top: 2px;
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-caption);
}
.store-checkup-issue-list {
display: flex;
flex-direction: column;
}
.store-checkup-issue {
display: grid;
min-height: 62px;
grid-template-columns: 30px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid var(--sycm-border-light);
}
.store-checkup-issue:last-child {
border-bottom: 0;
}
.store-checkup-issue__icon {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--sycm-color-warning-bg);
color: var(--sycm-color-warning);
}
.store-checkup-issue.is-error .store-checkup-issue__icon {
background: var(--sycm-color-danger-bg);
color: var(--sycm-color-danger);
}
.store-checkup-issue strong {
color: var(--sycm-text-strong);
font-size: var(--sycm-font-size-caption);
font-weight: var(--sycm-font-weight-semibold);
}
.store-checkup-issue p {
margin: 3px 0 0;
color: var(--sycm-text-secondary);
font-size: var(--sycm-font-size-caption);
line-height: 18px;
}
.store-checkup-issue__value {
min-width: 76px;
text-align: right;
}
.store-checkup-issue__value strong {
display: block;
font-size: 18px;
}
.store-checkup-issue__value span {
color: var(--sycm-text-tertiary);
font-size: var(--sycm-font-size-caption);
}
.store-checkup-healthy-state {
display: flex;
min-height: 180px;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--sycm-color-success);
}
.store-checkup-healthy-state .anticon {
font-size: 22px;
}
.store-checkup-coverage-list {
display: flex;
flex-direction: column;
padding: 4px 14px;
}
.store-checkup-coverage-list > div {
display: flex;
min-height: 42px;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--sycm-border-light);
color: var(--sycm-text-secondary);
font-size: var(--sycm-font-size-caption);
}
.store-checkup-coverage-list > div:last-child {
border-bottom: 0;
}
.store-checkup-stat-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
padding: 14px 0;
}
.store-checkup-stat-strip > div {
min-width: 0;
padding: 2px 14px;
border-right: 1px solid var(--sycm-border-light);
}
.store-checkup-stat-strip > div:last-child {
border-right: 0;
}
.store-checkup-stat-strip span,
.store-checkup-stat-strip strong {
display: block;
}
.store-checkup-stat-strip span {
overflow: hidden;
color: var(--sycm-text-secondary);
font-size: var(--sycm-font-size-caption);
text-overflow: ellipsis;
white-space: nowrap;
}
.store-checkup-stat-strip strong {
margin-top: 8px;
color: var(--sycm-text-strong);
font-size: 20px;
font-weight: var(--sycm-font-weight-semibold);
}
.store-checkup-stat-strip strong.is-risk {
color: var(--sycm-color-danger);
}
.store-checkup-score-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px 24px;
padding: 16px;
}
.store-checkup-score-item > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
font-size: var(--sycm-font-size-caption);
}
.store-checkup-score-item span {
color: var(--sycm-text-secondary);
}
.store-checkup-score-item strong {
color: var(--sycm-text-strong);
}
.store-checkup-chart-section > .echarts-for-react {
padding: 8px 12px 4px;
}
.store-checkup-table-section .ant-table-wrapper {
border-radius: 0 0 var(--sycm-radius-md) var(--sycm-radius-md);
overflow: hidden;
}
.store-checkup-table-section .ant-table {
border-radius: 0;
}
@container store-checkup (max-width: 1040px) {
.store-checkup-filter-panel .sycm-toolbar {
grid-template-columns: 1fr;
}
.store-checkup-filters {
grid-template-columns: 96px minmax(210px, 1fr) 150px;
}
.store-checkup-date-note {
grid-column: 1 / -1;
}
.store-checkup-metrics {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.store-checkup-metrics .sycm-dashboard-metric:nth-child(3) {
border-right: 0;
}
.store-checkup-metrics .sycm-dashboard-metric:nth-child(-n + 3) {
border-bottom: 1px solid var(--sycm-border-light);
}
.store-checkup-overview-grid,
.store-checkup-overview-grid.is-balanced {
grid-template-columns: 1fr;
}
}
@container store-checkup (max-width: 620px) {
.store-checkup-filters,
.store-checkup-metrics,
.store-checkup-tab-metrics,
.store-checkup-score-grid {
grid-template-columns: 1fr;
}
.store-checkup-filters > * {
grid-column: 1;
}
.store-checkup-content {
padding: 12px;
}
.store-checkup-metrics .sycm-dashboard-metric,
.store-checkup-tab-metrics .sycm-dashboard-metric {
border-right: 0;
border-bottom: 1px solid var(--sycm-border-light);
}
.store-checkup-stat-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
row-gap: 18px;
}
.store-checkup-stat-strip > div:nth-child(2) {
border-right: 0;
}
}

View File

@ -7,20 +7,14 @@ const Login = lazy(() => import('../pages/Login'));
const Install = lazy(() => import('../pages/Install'));
const Setup = lazy(() => import('../pages/Setup'));
const Settings = lazy(() => import('../pages/Settings'));
const DesktopClientReleases = lazy(() => import('../pages/DesktopClientReleases'));
const DesktopClientDownload = lazy(() => import('../pages/DesktopClientDownload'));
const OnlineDesktopUsers = lazy(() => import('../pages/OnlineDesktopUsers'));
const DesktopExecutionTasks = lazy(() => import('../pages/DesktopExecutionTasks'));
const MarketingEvents = lazy(() => import('../pages/MarketingEvents'));
const ProductInformation = lazy(() => import('../pages/ProductInformation'));
const PromotionModule = lazy(() => import('../pages/PromotionModule'));
const AiBuyerShow = lazy(() => import('../pages/AiBuyerShow'));
const OperationPlaceholder = lazy(() => import('../pages/OperationPlaceholder'));
const CustomerServiceQuality = lazy(() => import('../pages/CustomerServiceQuality'));
const DevelopmentStylePreview = lazy(() => import('../pages/DevelopmentStylePreview'));
const ProductAnalysis = lazy(() => import('../pages/ProductAnalysis'));
const StoreCheckup = lazy(() => import('../pages/StoreCheckup'));
const PricingCalculator = lazy(() => import('../pages/PricingCalculator'));
const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncentiveBoard'));
const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring'));
@ -99,22 +93,6 @@ const router = createBrowserRouter([
path: 'planning/operation/promotion-module',
element: renderLazyComponent(PromotionModule),
},
{
path: 'planning/operation/ai-buyer-show',
element: renderLazyComponent(AiBuyerShow),
},
{
path: 'planning/operation/customer-service-checkup',
element: renderLazyComponent(CustomerServiceQuality),
},
{
path: 'planning/operation/store-checkup',
element: renderLazyComponent(StoreCheckup),
},
{
path: 'planning/operation/pricing-calculator',
element: renderLazyComponent(PricingCalculator),
},
{
path: 'planning/operation/ops-b-incentive',
element: renderLazyComponent(OpsBIncentiveBoard),
@ -123,10 +101,14 @@ const router = createBrowserRouter([
path: 'planning/operation/product-monitoring',
element: renderLazyComponent(ProductMonitoring),
},
{
path: 'planning/operation/desktop-client-download',
element: renderLazyComponent(DesktopClientDownload),
},
{ path: 'planning/operation/store-checkup', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/review-analysis', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/customer-service-checkup', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/pricing-calculator', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/desktop-client-download', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/ai-buyer-show', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/ai-seeding-video', element: <Navigate to={HOME_URL} replace /> },
{ path: 'settings/desktop-client-releases', element: <Navigate to={HOME_URL} replace /> },
{
path: 'planning/operation/:featureKey',
element: renderLazyComponent(OperationPlaceholder),
@ -143,10 +125,6 @@ const router = createBrowserRouter([
path: 'settings',
element: renderLazyComponent(Settings),
},
{
path: 'settings/desktop-client-releases',
element: renderLazyComponent(DesktopClientReleases),
},
{
path: 'settings/online-desktop-users',
element: renderLazyComponent(OnlineDesktopUsers),

View File

@ -1,130 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { computeRow } from '../src/pages/PricingCalculator/calc.js';
import {
applyDropshipDefaultsToRows,
createDropshipRows,
DROPSHIP_FIXED_RATES,
getDropshipDimensions,
mergeDropshipSelectionPricingRows,
parseAlibaba1688ProductId,
} from '../src/pages/PricingCalculator/dropship.js';
test('parses raw 1688 IDs and product detail links, but rejects store links', () => {
assert.deepEqual(parseAlibaba1688ProductId('1073252781818'), {
productId: '1073252781818',
error: '',
});
assert.equal(
parseAlibaba1688ProductId('https://detail.1688.com/offer/1073252781818.html?spm=test').productId,
'1073252781818'
);
assert.equal(
parseAlibaba1688ProductId('https://m.1688.com/offer/1009799442964.html').productId,
'1009799442964'
);
assert.equal(parseAlibaba1688ProductId('https://example.1688.com/page/index.html').productId, '');
assert.match(
parseAlibaba1688ProductId('https://example.1688.com/page/index.html').error,
/商品详情链接/
);
});
test('adapts every 1688 specification group and applies the independent rates', () => {
const detail = {
productId: '1073252781818',
product: {
productId: '1073252781818',
mainImage: 'https://img.example.com/main.jpg',
skuDimensions: [
{ name: '颜色', values: ['原木色'] },
{ name: '尺寸', values: ['60cm'] },
],
skus: [{
index: 1,
name: '原木色 / 60cm',
specs: [
{ name: '颜色', value: '原木色' },
{ name: '尺寸', value: '60cm' },
],
price: 180,
freight: 40,
stock: null,
available: true,
}],
},
};
const rows = createDropshipRows(detail);
assert.equal(rows.length, 1);
assert.equal(rows[0].skuCode, '1');
assert.deepEqual(rows[0].specs, [
{ name: '颜色', value: '原木色' },
{ name: '尺寸', value: '60cm' },
]);
assert.equal(rows[0].specsText, '颜色:原木色 / 尺寸:60cm');
assert.equal(rows[0].cost, 180);
assert.equal(rows[0].shipping, 40);
assert.equal(rows[0].price, 400);
assert.equal(rows[0].targetMargin, 0.12);
Object.entries(DROPSHIP_FIXED_RATES).forEach(([key, value]) => {
assert.equal(rows[0][key], value);
});
assert.deepEqual(getDropshipDimensions(detail, rows), ['颜色', '尺寸']);
const computed = computeRow(rows[0]);
assert.equal(Number(computed.profitAmount.toFixed(2)), 48);
const listingRows = createDropshipRows(detail, null, { useSourceSkuCode: true });
assert.equal(listingRows[0].skuCode, '1073252781818-SKU-1');
});
test('changing 1688 defaults only updates rows that still use the previous default', () => {
const rows = [
{ id: 'default', coupon: 20, activityDiscount: 0.6, installFee: 0, targetMargin: 0.12 },
{ id: 'custom', coupon: 10, activityDiscount: 0.6, installFee: 0, targetMargin: 0.2 },
];
const updated = applyDropshipDefaultsToRows(
rows,
{ coupon: 20, activityDiscount: 0.6, installFee: 0, targetMargin: 0.12 },
{ coupon: 25, activityDiscount: 0.65, installFee: 5, targetMargin: 0.14 }
);
assert.deepEqual(updated[0], {
id: 'default', coupon: 25, activityDiscount: 0.65, installFee: 5, targetMargin: 0.14,
});
assert.deepEqual(updated[1], {
id: 'custom', coupon: 10, activityDiscount: 0.65, installFee: 5, targetMargin: 0.2,
});
});
test('merges saved listing prices by the real 1688 SKU code', () => {
const sourceRows = [
{ id: 'a', skuCode: 'SKU-A', cost: 60, price: 100 },
{ id: 'b', skuCode: 'SKU-B', cost: 80, price: 200 },
];
const merged = mergeDropshipSelectionPricingRows(sourceRows, [
{ skuCode: 'sku-b', cost: 168, finalPrice: 288, selectedSkuName: 'B款' },
]);
assert.equal(merged[0].price, 100);
assert.equal(merged[0].cost, 60);
assert.equal(merged[0].skuCode, 'SKU-A');
assert.equal(merged[1].price, 288);
assert.equal(merged[1].cost, 168);
assert.equal(merged[1].selectedSkuName, 'B款');
});
test('does not fall back to row order when listing SKU codes do not match', () => {
const sourceRows = [
{ id: 'a', skuCode: 'SOURCE-A', price: 100 },
{ id: 'b', skuCode: 'SOURCE-B', price: 200 },
];
const merged = mergeDropshipSelectionPricingRows(sourceRows, [
{ skuCode: 'TASK-A', finalPrice: 188 },
{ skuCode: 'TASK-B', finalPrice: 288 },
]);
assert.deepEqual(merged.map((row) => row.skuCode), ['SOURCE-A', 'SOURCE-B']);
assert.deepEqual(merged.map((row) => row.price), [100, 200]);
});

View File

@ -6,11 +6,16 @@ test('legacy saved menus cannot resurrect retired features and keep business cus
const result = parseSidebarConfig(JSON.stringify({ entries: [
{ key: 'market-insight', routeKey: 'market-insight', visible: true },
{ key: 'agent', routeKey: 'agent' },
{ key: 'manage', children: [{ key: 'ai-skill-tags', routeKey: 'ai-skill-tags' }] },
{ key: 'store-management', children: [{ key: 'store-checkup', routeKey: 'store-checkup' }] },
{ key: 'operation-tools', children: [{ key: 'pricing-calculator', routeKey: 'pricing-calculator' }] },
{ key: 'manage', children: [
{ key: 'ai-skill-tags', routeKey: 'ai-skill-tags' },
{ key: 'desktop-client-releases', routeKey: 'desktop-client-releases' },
] },
{ key: 'product-monitoring', routeKey: 'product-monitoring', label: '我的商品监控', order: 1 },
] }));
const entries = result.entries.flatMap((entry) => [entry, ...(entry.children || [])]);
assert.ok(entries.every((entry) => !['market-insight', 'agent', 'ai-skill-tags'].includes(entry.key)));
assert.ok(entries.every((entry) => !['market-insight', 'agent', 'ai-skill-tags', 'store-management', 'operation-tools', 'desktop-client-releases'].includes(entry.key)));
assert.equal(entries.find((entry) => entry.key === 'product-monitoring').label, '我的商品监控');
assert.ok(entries.some((entry) => entry.key === 'settings'));
assert.ok(entries.some((entry) => entry.key === 'product-analysis'));

View File

@ -1,28 +0,0 @@
-- 种菜资源包表
-- 所属数据库:ai_buyer_show_database(AI买家秀业务库)
-- 说明:服务启动后首次访问会自动执行下方建表语句(CREATE TABLE IF NOT EXISTS),
-- 本文件供运维手工初始化或结构核对使用。
-- ZIP 文件本体存放在默认对象存储(s3_configs 中 is_default=1 的配置,
-- 可配置为七牛云 provider),本表只保存对象 Key、下载地址和业务字段。
CREATE TABLE IF NOT EXISTS pdd_seed_resource_packs (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '资源包ID',
pack_uid VARCHAR(64) NOT NULL COMMENT '资源包唯一标识',
shop_name VARCHAR(128) NOT NULL COMMENT '店铺名称',
product_id VARCHAR(64) NOT NULL COMMENT '商品ID',
sku_name VARCHAR(255) NULL COMMENT 'SKU名称',
file_name VARCHAR(255) NOT NULL COMMENT 'ZIP文件名',
file_size BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小(字节)',
object_key VARCHAR(512) NOT NULL COMMENT '对象存储Key',
file_url VARCHAR(1024) NULL COMMENT '公开下载地址',
review_count INT NOT NULL DEFAULT 0 COMMENT '包含评价条数',
operator_id VARCHAR(64) NULL COMMENT '上传人ID',
operator_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '上传人',
download_count INT NOT NULL DEFAULT 0 COMMENT '下载次数',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_pack_uid (pack_uid),
KEY idx_seed_pack_shop (shop_name),
KEY idx_seed_pack_product (product_id),
KEY idx_seed_pack_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='种菜资源包';

View File

@ -1,15 +0,0 @@
-- 单品利润测算 v2:SPU 计算器历史记录表(系统库 shujuku3_system,MariaDB/MySQL)
-- 按用户隔离:所有查询必须带 user_id 条件,服务端强制过滤。
CREATE TABLE IF NOT EXISTS pricing_calculator_history (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
user_id BIGINT NOT NULL COMMENT '测算人用户ID',
user_name VARCHAR(100) NULL COMMENT '测算人显示名',
spu_code VARCHAR(160) NOT NULL COMMENT '公司SPU',
brand VARCHAR(100) NULL COMMENT '品牌名称',
note VARCHAR(500) NULL COMMENT '备注',
row_count INT NOT NULL DEFAULT 0 COMMENT '测算行数',
payload_json LONGTEXT NOT NULL COMMENT '完整测算快照 JSON(输入+费率+公式结果)',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX idx_pricing_calc_history_user_created (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='单品利润测算历史记录';

View File

@ -1,8 +0,0 @@
-- 单品利润测算:用户个性化默认值表(系统库 shujuku3_system,MariaDB/MySQL)
-- 每个运营一份配置(优惠券金额/活动折扣/安装费/营业利润率),按 user_id 隔离。
CREATE TABLE IF NOT EXISTS pricing_calculator_user_prefs (
user_id BIGINT PRIMARY KEY COMMENT '用户ID',
prefs_json VARCHAR(2000) NOT NULL COMMENT '个性化默认值 JSON',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='单品利润测算用户个性化默认值';

View File

@ -0,0 +1,7 @@
-- 商品分析:用户个性化阈值表(系统库,MariaDB/MySQL)
CREATE TABLE IF NOT EXISTS product_analysis_user_prefs (
user_id BIGINT PRIMARY KEY COMMENT '用户ID',
prefs_json VARCHAR(2000) NOT NULL COMMENT '个性化默认值 JSON',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分析用户个性化配置';