379 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { 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;