forked from lunanting/cangku
refactor: remove store and operations feature suites
This commit is contained in:
parent
1d87c3f94c
commit
36948c98ca
@ -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` 到后端。
|
||||
|
||||
启动时会迁移旧侧栏配置,并删除已退役页面的权限和专属设置;业务数据及历史聊天记录不作数据库删表操作。迁移仅作用于实际配置给此实例的系统数据库。
|
||||
启动时会迁移旧侧栏配置,并删除已退役页面的权限、专属设置和专用数据表。上架测算旧偏好表中的商品分析阈值会先迁移到商品分析专用表;其他保留模块的数据不受影响。迁移仅作用于实际配置给此实例的系统数据库。
|
||||
|
||||
## 参与修改
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
@ -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;
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
@ -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
@ -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
@ -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,
|
||||
};
|
||||
@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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');
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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, '陆南亭');
|
||||
});
|
||||
|
||||
@ -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'] },
|
||||
],
|
||||
});
|
||||
});
|
||||
@ -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' }]);
|
||||
});
|
||||
|
||||
@ -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 })
|
||||
);
|
||||
@ -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 })
|
||||
);
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
@ -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)
|
||||
);
|
||||
@ -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 })
|
||||
);
|
||||
@ -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 },
|
||||
],
|
||||
|
||||
@ -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
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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%;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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%;
|
||||
}
|
||||
}
|
||||
@ -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
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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())}`;
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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;
|
||||
});
|
||||
};
|
||||
@ -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 不可用时仅保留当前会话状态。
|
||||
}
|
||||
};
|
||||
@ -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
@ -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}
|
||||
/>
|
||||
);
|
||||
@ -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;
|
||||
}
|
||||
@ -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: '简尚业务数据库',
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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]);
|
||||
});
|
||||
@ -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'));
|
||||
|
||||
@ -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='种菜资源包';
|
||||
@ -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='单品利润测算历史记录';
|
||||
@ -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='单品利润测算用户个性化默认值';
|
||||
7
sql/create_product_analysis_user_prefs_system_mysql.sql
Normal file
7
sql/create_product_analysis_user_prefs_system_mysql.sql
Normal 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='商品分析用户个性化配置';
|
||||
Loading…
x
Reference in New Issue
Block a user