feat(web): add React workbench administration UI

This commit is contained in:
yi
2026-09-16 19:06:21 +08:00
parent d4b5b3f43a
commit 85f6e3be86
68 changed files with 14263 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
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<string, number[]> = {}) {
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<typeof setTimeout> | 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<AssessmentEstimate>('/training-maps/estimate', JSON.parse(selection)),
new Promise<never>((_, 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 <section className="tm-time-estimate" aria-label="预计完成时间">
<div className="tm-time-heading"><strong>预计检索与方案生成时间</strong><span aria-live="polite">{!selected ? '选择知识库后显示' : loading ? '正在估算…' : estimate ? estimate.total_chunks ? timeRange(estimate.seconds_low, estimate.seconds_high) : '暂无可估算资料' : '暂时无法估算'}</span></div>
{estimate && <>
{estimate.mode !== 'quick' && estimate.total_chunks > 0 && <dl className="tm-estimate-inventory">
{estimate.mode === 'reuse' && <div><dt>预计复用已读</dt><dd>{count(estimate.reusable_files)} 个文件 · {count(estimate.reusable_chunks)} 段</dd></div>}
<div><dt>预计本次新读</dt><dd>{count(estimate.new_chunks ?? estimate.total_chunks)} 段{estimate.new_batches !== undefined ? ` · 约 ${count(estimate.new_batches)} 批` : ''}</dd></div>
{estimate.parallelism !== undefined && <div><dt>阅读并发上限</dt><dd>最多 {count(estimate.parallelism)} 批</dd></div>}
</dl>}
{estimate.mode === 'reuse' && estimate.total_chunks > 0 && <p>{(estimate.reusable_chunks || 0) > 0 ? '以上复用量来自已保存的阅读记录;开始后会核对实际内容,以进度中的复用量为准。' : '当前没有可确认复用的阅读记录,先按全部所选资料需要新读估算;开始后会核对实际可复用内容。'}</p>}
<ol className="tm-time-stages">{estimate.stages.filter(stage => estimate.mode !== 'quick' || stage.id !== 'reading').map(stage => <li key={stage.id}><span>{stage.label}</span><strong>{timeRange(stage.seconds_low, stage.seconds_high)}</strong></li>)}</ol>
<p>{estimate.basis}。</p>
{estimate.total_chunks > 0 && <p>以上估时截止到课程方案生成。确认方案后,每一天导图另需 {timeRange(estimate.per_day_seconds_low, estimate.per_day_seconds_high)}。</p>}
</>}
{error && <p role="status">估时暂不可用:{error}。仍可正常开始评估,页面会自动重试。</p>}
</section>
}
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 <section className={`tm-task-progress${failed ? ' is-paused' : ''}`} aria-label="检索与方案生成进度">
<div className="tm-time-heading"><strong>{complete ? '课程方案评估完成' : failed ? '评估已停止' : stopping ? '正在停止,等待当前批次结束' : '检索与方案生成进度'}</strong><span>{percent === undefined ? (failed ? '已停止' : '处理中') : `${Math.round(percent)}%`}</span></div>
<progress max={100} value={percent ?? (terminal ? 0 : undefined)} aria-label="检索与方案生成整体进度" />
<ol className="tm-live-stages">{phases.map((phase, index) => <li key={phase} className={index < stage ? 'is-complete' : index === stage && !failed ? 'is-current' : ''} aria-current={index === stage && !terminal ? 'step' : undefined}>{index < stage ? '✓ ' : `${index + 1}. `}{phase}</li>)}</ol>
<div className="tm-time-metrics"><span>已用时:{coverage.elapsed_seconds === undefined ? '正在统计' : duration(coverage.elapsed_seconds)}</span>
{!terminal && !stopping && <strong>{coverage.overdue ? '本次处理较久,正在更新预估时间' : `预计剩余:${timeRange(coverage.remaining_seconds_low, coverage.remaining_seconds_high)}`}</strong>}
{complete && <strong>已完成,可查看并确认方案</strong>}
</div>
{!terminal && corpus && reading && parallelism !== undefined && <div className="tm-reading-activity">
<span>{coverage.timing?.active_model_calls !== undefined ? '模型正在处理' : '正在处理'}:{active === undefined ? '正在统计' : `${count(active)} 批`} · 当前并发上限 {count(parallelism)} 批</span>
{coverage.rate_limited && <strong role="status">服务商限流,已自动降低并发</strong>}
{!coverage.rate_limited && active === 0 && <span>{stopping ? '正在收尾' : '正在核对或等待可用阅读名额'}</span>}
</div>}
{!terminal && coverage.stage === 'rechecking' && <p role="status">正在所选范围内查找与本次主题相关的原文,补充到课程评估中。</p>}
{!terminal && !stopping && <p>进度随实际完成的阶段和资料更新;AI 返回方案后才会到 100%。</p>}
</section>
}
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 <section className="tm-task-progress" aria-label="每日导图生成进度"><strong>{label}</strong>
<progress aria-label="等待当天导图生成" />
<div className="tm-time-metrics"><span>已用时:{duration(elapsed)}</span><span>{estimate && elapsed > estimate.per_day_seconds_high ? '模型仍在生成,已超过预估时间' : `本天预计耗时:${timeRange(estimate?.per_day_seconds_low, estimate?.per_day_seconds_high)}`}</span></div>
<p>模型正在组织当天内容与衔接关系,完成后显示导图。</p>
</section>
}