forked from lunanting/cangku
379 lines
15 KiB
JavaScript
379 lines
15 KiB
JavaScript
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;
|