import { useEffect, useState } from 'react' import { api, localizeError } from '../api' import type { ReadingCoverage } from './TrainingMapCoverage' import './training-map-timing.css' export type AssessmentMode = 'reuse' | 'full' | 'quick' export type AssessmentEstimate = { mode: AssessmentMode; total_chunks: number; total_files: number; estimated_batches: number parallelism?: number; reusable_chunks?: number; reusable_files?: number; reusable_batches?: number new_chunks?: number; new_batches?: number seconds_low: number; seconds_high: number; basis: string stages: { id: string; label: string; seconds_low: number; seconds_high: number }[] per_day_seconds_low: number; per_day_seconds_high: number } const count = (value?: number) => Math.max(0, Number(value) || 0).toLocaleString('zh-CN') function duration(seconds: number): string { const value = Math.max(0, Math.ceil(seconds)) if (value < 60) return `${value} 秒` if (value < 3600) return `${Math.ceil(value / 60)} 分钟` const hours = Math.floor(value / 3600), minutes = Math.ceil((value % 3600) / 60) return minutes === 60 ? `${hours + 1} 小时` : `${hours} 小时${minutes ? ` ${minutes} 分钟` : ''}` } export function timeRange(low?: number, high?: number): string { if (low === undefined || high === undefined || !Number.isFinite(low) || !Number.isFinite(high)) return '正在估算' const first = duration(low), last = duration(Math.max(low, high)) return first === last ? `约 ${first}` : `约 ${first}~${last}` } export function useAssessmentEstimate(ids: number[], mode: AssessmentMode, requestedDays: string, categoryScope: Record = {}) { const selection = JSON.stringify({ kb_ids: [...new Set(ids)].sort((a, b) => a - b), mode, kb_category_ids: categoryScope, requested_days: requestedDays && Number(requestedDays) >= 1 && Number(requestedDays) <= 30 ? Number(requestedDays) : null }) const [result, setResult] = useState<{ key: string; data?: AssessmentEstimate; error?: string } | null>(null) const valid = ids.length > 0 && Object.values(categoryScope).every(values => values.length > 0) useEffect(() => { if (!valid) return let cancelled = false let timer: ReturnType | undefined const refresh = async () => { try { // An estimate is advisory; never let a busy production gateway hold // the form for the full Cloudflare timeout window. const data = await Promise.race([ api.post('/training-maps/estimate', JSON.parse(selection)), new Promise((_, reject) => setTimeout(() => reject(new Error('估时服务繁忙,稍后自动重试')), 8000)), ]) if (cancelled) return setResult({ key: selection, data }) // Automatic background reading can finish while this form stays open. // Keep the reuse count current without forcing the user to reselect libraries. if (mode === 'reuse' && (data.new_chunks ?? data.total_chunks - (data.reusable_chunks || 0)) > 0) timer = setTimeout(refresh, 30000) } catch (error) { if (cancelled) return const message = error instanceof Error && error.message === '估时服务繁忙,稍后自动重试' ? error.message : localizeError(error) setResult({ key: selection, error: /524|超时|timeout/i.test(message) ? '估时服务繁忙,稍后自动重试' : message }) } } timer = setTimeout(refresh, 350) return () => { cancelled = true; if (timer) clearTimeout(timer) } }, [selection, valid]) const current = result?.key === selection ? result : null return { estimate: current?.data, estimateError: current?.error || '', estimateLoading: valid && !current } } export function EstimatePreview({ estimate, loading, error, selected }: { estimate?: AssessmentEstimate; loading: boolean; error: string; selected: boolean }) { return
预计检索与方案生成时间{!selected ? '选择知识库后显示' : loading ? '正在估算…' : estimate ? estimate.total_chunks ? timeRange(estimate.seconds_low, estimate.seconds_high) : '暂无可估算资料' : '暂时无法估算'}
{estimate && <> {estimate.mode !== 'quick' && estimate.total_chunks > 0 &&
{estimate.mode === 'reuse' &&
预计复用已读
{count(estimate.reusable_files)} 个文件 · {count(estimate.reusable_chunks)} 段
}
预计本次新读
{count(estimate.new_chunks ?? estimate.total_chunks)} 段{estimate.new_batches !== undefined ? ` · 约 ${count(estimate.new_batches)} 批` : ''}
{estimate.parallelism !== undefined &&
阅读并发上限
最多 {count(estimate.parallelism)} 批
}
} {estimate.mode === 'reuse' && estimate.total_chunks > 0 &&

{(estimate.reusable_chunks || 0) > 0 ? '以上复用量来自已保存的阅读记录;开始后会核对实际内容,以进度中的复用量为准。' : '当前没有可确认复用的阅读记录,先按全部所选资料需要新读估算;开始后会核对实际可复用内容。'}

}
    {estimate.stages.filter(stage => estimate.mode !== 'quick' || stage.id !== 'reading').map(stage =>
  1. {stage.label}{timeRange(stage.seconds_low, stage.seconds_high)}
  2. )}

{estimate.basis}。

{estimate.total_chunks > 0 &&

以上估时截止到课程方案生成。确认方案后,每一天导图另需 {timeRange(estimate.per_day_seconds_low, estimate.per_day_seconds_high)}。

} } {error &&

估时暂不可用:{error}。仍可正常开始评估,页面会自动重试。

}
} export function AssessmentTiming({ coverage, stopping }: { coverage?: ReadingCoverage; stopping?: boolean }) { if (!coverage?.mode) return null const terminal = ['complete', 'failed', 'cancelled'].includes(coverage.stage) const complete = coverage.stage === 'complete' const failed = coverage.stage === 'failed' || coverage.stage === 'cancelled' const percent = complete ? 100 : coverage.progress_percent === undefined ? undefined : Math.min(99, Math.max(0, coverage.progress_percent)) const corpus = coverage.mode !== 'quick' const reuse = coverage.mode === 'reuse' const phases = reuse ? ['核对资料与已读记录', '复用与补充阅读', '按主题回查原文', '生成课程方案'] : corpus ? ['扫描资料', '分批阅读与汇总', '生成课程方案'] : ['相关资料检索', '生成课程方案'] const stage = complete ? phases.length : coverage.stage === 'planning' ? phases.length - 1 : coverage.stage === 'rechecking' && reuse ? 2 : ['reading', 'reducing', 'summarizing'].includes(coverage.stage) ? 1 : 0 const reading = ['reading', 'reducing', 'summarizing'].includes(coverage.stage) const parallelism = coverage.effective_parallelism ?? coverage.parallelism ?? coverage.estimate?.parallelism const active = coverage.timing?.active_model_calls ?? coverage.active_batches return
{complete ? '课程方案评估完成' : failed ? '评估已停止' : stopping ? '正在停止,等待当前批次结束' : '检索与方案生成进度'}{percent === undefined ? (failed ? '已停止' : '处理中') : `${Math.round(percent)}%`}
    {phases.map((phase, index) =>
  1. {index < stage ? '✓ ' : `${index + 1}. `}{phase}
  2. )}
已用时:{coverage.elapsed_seconds === undefined ? '正在统计' : duration(coverage.elapsed_seconds)} {!terminal && !stopping && {coverage.overdue ? '本次处理较久,正在更新预估时间' : `预计剩余:${timeRange(coverage.remaining_seconds_low, coverage.remaining_seconds_high)}`}} {complete && 已完成,可查看并确认方案}
{!terminal && corpus && reading && parallelism !== undefined &&
{coverage.timing?.active_model_calls !== undefined ? '模型正在处理' : '正在处理'}:{active === undefined ? '正在统计' : `${count(active)} 批`} · 当前并发上限 {count(parallelism)} 批 {coverage.rate_limited && 服务商限流,已自动降低并发} {!coverage.rate_limited && active === 0 && {stopping ? '正在收尾' : '正在核对或等待可用阅读名额'}}
} {!terminal && coverage.stage === 'rechecking' &&

正在所选范围内查找与本次主题相关的原文,补充到课程评估中。

} {!terminal && !stopping &&

进度随实际完成的阶段和资料更新;AI 返回方案后才会到 100%。

}
} export function DayGenerationTiming({ active, label, estimate }: { active: boolean; label: string; estimate?: AssessmentEstimate }) { const [elapsed, setElapsed] = useState(0) useEffect(() => { if (!active) { setElapsed(0); return } const started = Date.now() const timer = setInterval(() => setElapsed(Math.floor((Date.now() - started) / 1000)), 1000) return () => clearInterval(timer) }, [active, label]) if (!active) return null return
{label}
已用时:{duration(elapsed)}{estimate && elapsed > estimate.per_day_seconds_high ? '模型仍在生成,已超过预估时间' : `本天预计耗时:${timeRange(estimate?.per_day_seconds_low, estimate?.per_day_seconds_high)}`}

模型正在组织当天内容与衔接关系,完成后显示导图。

}