新增 labels 下的韩泰 EOT 标注工作台

This commit is contained in:
Xin Wang
2026-10-09 13:12:52 +08:00
parent 1ab51c8bc1
commit 138b08a8e4
23 changed files with 4326 additions and 0 deletions
@@ -0,0 +1,21 @@
.panel { margin-top: 18px; overflow: hidden; }
.heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 16px 19px 12px; }
.heading h2 { margin: 0 0 4px; color: #52663f; font-size: 14px; }
.heading p { margin: 0; color: #748167; font-size: 11px; }
.heading h2 { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
.details > summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; cursor: pointer; padding: 0 19px 15px; color: #597448; font-size: 12px; }
.details > summary::after { content: "+"; font-size: 18px; }
.details[open] > summary::after { content: "−"; }
.details > summary > span { margin-left: auto; color: #748167; font-size: 11px; }
.note { margin: 0 19px 12px; padding: 9px 12px; background: #faf5eb; border: 1px solid #eadbc5; border-radius: 7px; color: #89704e; font-size: 11px; }
.list { max-height: 380px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #d3ddc6 transparent; }
.record { padding: 13px 19px 16px; border-top: 1px solid #e8ecdf; }
.recordHeading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; color: #748167; font-size: 11px; }
.recordHeading strong { font-weight: 600; color: #647a50; }
.fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 24px; margin: 0; }
.fields > div { min-width: 0; }
.wide { grid-column: 1 / -1; }
.fields dt { margin-bottom: 4px; color: #748167; font-size: 11px; }
.fields dd { margin: 0; color: #4e603f; font-size: 13px; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; }
.source { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 4px 14px; padding: 10px 19px; border-top: 1px solid #e8ecdf; background: #fafbf5; color: #748167; font-size: 11px; overflow-wrap: anywhere; }
@media (max-width: 600px) { .fields { grid-template-columns: minmax(0, 1fr); } }
@@ -0,0 +1,33 @@
"use client";
import type { CallReference } from "@/lib/call-reference";
import styles from "./call-reference.module.css";
export default function CallReferencePanel({ references }: { references: CallReference[] }) {
if (!references.length) return null;
const source = references[0].source;
const sourceName = source.file.split("/").at(-1);
return <section className={`panel ${styles.panel}`} aria-label="原表校对参考">
<div className={styles.heading}>
<div><h2>原表校对参考 <span className="count-badge">{references.length} 个片段</span></h2><p>原表片段没有时间对齐,需回听录音定位后填写 EOT。</p></div>
</div>
<details className={styles.details} key={references[0].call_id}>
<summary>查看这通录音的原表片段 <span>供回听核对</span></summary>
<p className={styles.note}>原表的抢话结论保留作参考。第 5 列没有表头,有值时按原样显示;EOT 标签需针对具体判断时刻重新核对。</p>
<div className={styles.list}>
{references.map((reference, index) => <article key={`${reference.source.sheet}-${reference.source.row}`} className={styles.record}>
<div className={styles.recordHeading}><strong>片段 {String(index + 1).padStart(2, "0")}</strong><span>原表第 {reference.source.row} 行</span></div>
<dl className={styles.fields}>
{reference.fields.map((field, fieldIndex) => <div key={fieldIndex} className={field.label.includes("说话") || field.value.length > 40 ? styles.wide : undefined}>
<dt>{field.label}</dt><dd>{field.value || "未填写"}</dd>
</div>)}
</dl>
</article>)}
</div>
</details>
<div className={styles.source}>
<span>{sourceName}</span><span>{source.sheet} · 第 {references.map(row => row.source.row).join("、")} 行</span>
</div>
</section>;
}
@@ -0,0 +1,116 @@
.timeline {
overflow: hidden;
color: #333c34;
background: #fffefb;
border: 1px solid #e5e8dd;
border-radius: 16px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 22px 15px;
}
.toolbarTitle, .tools, .zoom, .bottomBar, .timeReadout, .decisionControls, .decisionControls > label {
display: flex;
align-items: center;
}
.toolbarTitle { gap: 14px; }
.title { font-size: 14px; font-weight: 650; letter-spacing: .01em; }
.duration { color: #6c776b; font-family: var(--font-geist-mono, monospace); font-size: 12px; }
.duration > span { margin-left: 5px; font-family: inherit; font-size: 10px; color: #90978d; }
.tools { gap: 13px; }
.tools button { font: inherit; cursor: pointer; }
.tools button:disabled { opacity: .35; cursor: default; }
.locate { display: flex; align-items: center; gap: 5px; border: 0; background: none; color: #728064; font-size: 11px !important; }
.locate > span { font-size: 20px; line-height: 1; }
.zoom { height: 28px; gap: 3px; border: 1px solid #e7e9e0; border-radius: 7px; background: #fafbf5; }
.zoom button { display: grid; place-items: center; width: 28px; height: 26px; border: 0; background: none; color: #606b57; font-size: 17px; }
.zoom button:not(:disabled):hover { background: #edf0e6; }
.zoom > span { width: 30px; text-align: center; font-size: 11px; color: #646d5c; }
.trackLayout { display: grid; grid-template-columns: 88px minmax(0, 1fr); border-block: 1px solid #ecede5; }
.trackNames { background: #fbfcf7; border-right: 1px solid #e9ebe2; font-size: 11px; color: #828b7b; user-select: none; }
.trackNames > div { padding-left: 21px; display: flex; align-items: center; gap: 7px; border-bottom: 1px solid #eceee6; }
.rulerName { height: 32px; font-size: 10px; }
.waveName { height: 76px; }
.speechName { height: 48px; }
.markerName { height: 44px; border-bottom: 0 !important; }
.agentDot, .userDot, .playheadDot, .decisionDot { display: inline-block; flex-shrink: 0; width: 6px; height: 6px; border-radius: 50%; }
.agentDot { background: #b2bca8; }
.userDot { background: #d2b48d; }
.playheadDot { background: #566653; }
.decisionDot { background: #d66d40; }
.viewport { overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; scrollbar-color: #cbd1c0 #f8faf2; }
.viewport::-webkit-scrollbar { height: 7px; }
.viewport::-webkit-scrollbar-thumb { background: #cbd1c0; border-radius: 4px; }
.viewport::-webkit-scrollbar-track { background: #f8faf2; }
.canvas { width: var(--timeline-width); height: 248px; position: relative; outline: none; touch-action: pan-y; cursor: crosshair; }
.canvas:focus-visible { box-shadow: inset 0 0 0 2px #9aa987; }
.ruler { position: relative; height: 32px; background: #fbfcf7; border-bottom: 1px solid #eceee6; overflow: hidden; }
.tick { position: absolute; top: 0; height: 32px; padding-top: 8px; padding-left: 6px; color: #8b9384; font-family: var(--font-geist-mono, monospace); font-size: 10px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.tick::after { content: ""; position: absolute; left: 0; bottom: 0; width: 1px; height: 5px; background: #cbd1c1; }
.waveTrack { height: 76px; position: relative; background: #fdfef9; border-bottom: 1px solid #eceee6; }
.waveform { display: block; width: 100%; height: 76px; color: #a3ad98; pointer-events: none; }
.waveEmpty { position: sticky; left: 0; height: 100%; display: flex; align-items: center; justify-content: center; gap: 9px; color: #929b88; font-size: 11px; }
.waveEmpty svg { color: #b2baa9; }
.speechTrack { position: relative; height: 48px; border-bottom: 1px solid #eceee6; background: #fffefb; }
.speechBlock { position: absolute; top: 9px; height: 29px; overflow: hidden; border: 1px solid; border-radius: 5px; transition: filter .12s; }
.speechBlock:hover { filter: brightness(.96); }
.speechBlock > span { display: block; overflow: hidden; padding: 6px 9px; white-space: nowrap; text-overflow: ellipsis; font-size: 11px; line-height: 15px; }
.agentBlock { background: #eef2e5; border-color: #dfe6d2; color: #697854; }
.userBlock { background: #f8f0e5; border-color: #eadcc6; color: #8d7353; }
.annotationTrack { height: 44px; position: relative; background: #fbfcf7; }
.annotation { z-index: 4; position: absolute; top: 13px; transform: translateX(-50%); display: grid; place-items: center; width: 22px; height: 23px; border-radius: 5px; border: 1px solid; cursor: pointer; font-family: var(--font-geist-mono, monospace); font-size: 11px; font-weight: 650; }
.annotation:hover { filter: brightness(.94); }
.annotationStem { position: absolute; width: 1px; height: 11px; top: -12px; left: 50%; background: currentColor; opacity: .4; }
.positive { background: #e8efdb; color: #62764a; border-color: #d4dfc2; }
.negative { background: #f6e4d9; color: #ab7150; border-color: #edcfbc; }
.pending { background: #f0f1eb; color: #87917c; border-color: #dce0d3; }
.selectedLabel { position: absolute; z-index: 3; top: 13px; transform: translateX(-50%); padding: 4px 9px; border: 1px solid #efc7b4; border-radius: 5px; background: #fcf0e8; color: #b86038; font-size: 10px; white-space: nowrap; }
.playhead { position: absolute; z-index: 5; top: 25px; bottom: 0; width: 1px; background: #6b7a5c; pointer-events: none; }
.playhead > span { position: absolute; top: -5px; left: -3px; width: 7px; height: 7px; border-radius: 50%; background: #6b7a5c; }
.decisionMarker { position: absolute; z-index: 6; top: 0; bottom: 0; width: 0; pointer-events: none; }
.decisionLine { position: absolute; top: 23px; bottom: 0; left: -1px; border-left: 1.5px dashed #d66d40; }
.decisionHandle { position: absolute; left: -14px; top: 1px; display: grid; place-items: center; width: 28px; height: 30px; padding: 0; border: 0; background: transparent; cursor: ew-resize; pointer-events: auto; touch-action: none; }
.decisionHandle > span { width: 14px; height: 19px; background: #d66d40; clip-path: polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%); border-radius: 2px; transition: transform .15s; }
.decisionHandle:hover > span { transform: scale(1.13); }
.decisionHandle:focus-visible { outline: 2px solid #b95b32; outline-offset: 1px; border-radius: 4px; background: #faeadf; }
.annotation:focus-visible, .tools button:focus-visible { outline: 2px solid #758962; outline-offset: 3px; }
.bottomBar { justify-content: space-between; gap: 12px; padding: 11px 22px 7px; }
.timeReadout { gap: 6px; color: #8b9382; font-size: 10px; }
.timeReadout strong { margin-left: 3px; font-family: var(--font-geist-mono, monospace); color: #66735b; font-size: 11px; font-weight: 500; font-variant-numeric: tabular-nums; }
.help { font-size: 10px; color: #9aa18f; }
.decisionControls { gap: 12px; padding: 5px 22px 15px; }
.decisionControls > label:first-child { gap: 6px; color: #947158; font-size: 10px; white-space: nowrap; }
.decisionControls > input { flex: 1; min-width: 40px; height: 16px; accent-color: #d66d40; cursor: pointer; }
.decisionControls > input:disabled { opacity: .35; cursor: default; }
.numberInput { width: 99px; gap: 4px; padding: 4px 7px; border: 1px solid #e7e8df; border-radius: 6px; background: #fcfcf7; }
.numberInput:focus-within { border-color: #d6a58b; box-shadow: 0 0 0 2px #f9eadf; }
.numberInput input { width: 100%; min-width: 0; border: 0; background: transparent; outline: none; color: #8c6952; text-align: right; font-family: var(--font-geist-mono, monospace); font-size: 11px; -moz-appearance: textfield; }
.numberInput input::-webkit-inner-spin-button, .numberInput input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.numberInput span { color: #a6ad9d; font-size: 10px; }
@media (max-width: 640px) {
.toolbar { gap: 7px; padding: 14px 12px 12px; }
.toolbarTitle { align-items: flex-start; flex-direction: column; gap: 3px; }
.title { font-size: 12px; }
.duration { font-size: 10px; }
.tools { gap: 6px; }
.locate { padding: 0; font-size: 10px !important; }
.trackLayout { grid-template-columns: 65px minmax(0, 1fr); }
.trackNames > div { padding-left: 10px; gap: 5px; font-size: 10px; }
.bottomBar { padding-inline: 12px; }
.help { max-width: 160px; text-align: right; font-size: 9px; }
.decisionControls { gap: 8px; padding-inline: 12px; }
.numberInput { width: 80px; }
}
@media (prefers-reduced-motion: reduce) {
.speechBlock, .decisionHandle > span { transition: none; }
}
+359
View File
@@ -0,0 +1,359 @@
"use client";
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
} from "react";
import styles from "./timeline.module.css";
export interface TimelineTranscript {
role: "agent" | "user";
start_ms: number;
end_ms: number;
transcript: string;
}
export interface TimelineAnnotation {
id: string;
decision_ms: number;
can_reply_now: "1" | "0" | "";
reason?: string;
}
export interface TimelineProps {
durationMs: number;
currentMs: number;
decisionMs: number | null;
transcripts: TimelineTranscript[];
annotations: TimelineAnnotation[];
selectedId: string | null;
waveform: number[] | null;
readOnly?: boolean;
onSeek: (ms: number) => void;
onDecisionChange: (ms: number) => void;
onSelectAnnotation: (id: string) => void;
}
const ZOOM_LEVELS = [1, 2, 4, 8];
const TICK_STEPS = [100, 200, 500, 1_000, 2_000, 5_000, 10_000, 15_000, 30_000, 60_000, 120_000, 300_000, 600_000];
function finite(value: number, fallback = 0) {
return Number.isFinite(value) ? value : fallback;
}
function timeLabel(ms: number, showMilliseconds = false) {
const bounded = Math.max(0, Math.round(ms));
const minutes = Math.floor(bounded / 60_000);
const seconds = Math.floor((bounded % 60_000) / 1_000);
const fraction = String(bounded % 1_000).padStart(3, "0");
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}${showMilliseconds ? `.${fraction}` : ""}`;
}
function annotationLabel(value: TimelineAnnotation["can_reply_now"], reason?: string) {
return value === "1" ? "可以接话" : value === "0" ? "继续等待" : reason ? "待仲裁" : "待标注";
}
export default function Timeline({
durationMs,
currentMs,
decisionMs,
transcripts,
annotations,
selectedId,
waveform,
readOnly = false,
onSeek,
onDecisionChange,
onSelectAnnotation,
}: TimelineProps) {
const helpId = useId();
const rangeId = useId();
const viewportRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef<{ kind: "seek" | "decision"; pointerId: number } | null>(null);
const zoomAnchorRef = useRef<number | null>(null);
const [viewportWidth, setViewportWidth] = useState(800);
const [zoom, setZoom] = useState(1);
const duration = Math.max(
1_000,
finite(durationMs),
...transcripts.map((item) => finite(item.end_ms)),
...annotations.map((item) => finite(item.decision_ms)),
finite(decisionMs ?? 0),
);
const canvasWidth = Math.max(360, viewportWidth) * zoom;
const clamp = (value: number) => Math.round(Math.max(0, Math.min(duration, finite(value))));
const current = clamp(currentMs);
const decision = decisionMs === null ? null : clamp(decisionMs);
const position = (ms: number) => (clamp(ms) / duration) * canvasWidth;
const selectedAnnotation = annotations.find((item) => item.id === selectedId);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const updateSize = () => setViewportWidth(viewport.clientWidth);
updateSize();
const observer = new ResizeObserver(updateSize);
observer.observe(viewport);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (zoomAnchorRef.current === null || !viewportRef.current) return;
viewportRef.current.scrollLeft = zoomAnchorRef.current * canvasWidth - viewportWidth / 2;
zoomAnchorRef.current = null;
}, [canvasWidth, viewportWidth]);
const ticks = useMemo(() => {
const desiredStep = (duration / canvasWidth) * 90;
const step = TICK_STEPS.find((item) => item >= desiredStep) ?? Math.ceil(desiredStep / 600_000) * 600_000;
const result = [];
for (let time = 0; time <= duration; time += step) result.push({ time, label: timeLabel(time, step < 1_000) });
return result;
}, [duration, canvasWidth]);
const waveformPath = useMemo(() => {
if (!waveform?.length) return "";
// Each visible bar is the maximum of a real sample bucket. No generated peaks.
const barCount = Math.min(waveform.length, Math.ceil(canvasWidth / 3), 3_200);
const bucketSize = waveform.length / barCount;
let path = "";
for (let bar = 0; bar < barCount; bar++) {
const start = Math.floor(bar * bucketSize);
const end = Math.min(waveform.length, Math.ceil((bar + 1) * bucketSize));
let peak = 0;
for (let index = start; index < end; index++) peak = Math.max(peak, Math.min(1, Math.abs(finite(waveform[index]))));
const x = ((bar + 0.5) / barCount) * canvasWidth;
const halfHeight = Math.max(0.75, peak * 28);
path += `M${x.toFixed(2)},${(38 - halfHeight).toFixed(2)}v${(halfHeight * 2).toFixed(2)}`;
}
return path;
}, [waveform, canvasWidth]);
function changeZoom(nextZoom: number) {
const viewport = viewportRef.current;
if (viewport) zoomAnchorRef.current = (viewport.scrollLeft + viewport.clientWidth / 2) / canvasWidth;
setZoom(nextZoom);
}
function locateDecision() {
if (decision === null || !viewportRef.current) return;
viewportRef.current.scrollTo({ left: position(decision) - viewportWidth / 2, behavior: "smooth" });
}
function pointerTime(event: PointerEvent<HTMLElement>) {
const bounds = canvasRef.current?.getBoundingClientRect();
if (!bounds) return current;
return clamp(((event.clientX - bounds.left) / bounds.width) * duration);
}
function beginDrag(event: PointerEvent<HTMLElement>, kind: "seek" | "decision") {
if (event.button !== 0 || (kind === "decision" && (decision === null || readOnly))) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
event.currentTarget.setPointerCapture(event.pointerId);
draggingRef.current = { kind, pointerId: event.pointerId };
if (kind === "seek") onSeek(pointerTime(event));
}
function moveDrag(event: PointerEvent<HTMLElement>) {
const drag = draggingRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
event.stopPropagation();
const ms = pointerTime(event);
if (drag.kind === "seek") onSeek(ms);
else onDecisionChange(ms);
// Keep the pointer near the visible edge usable while a zoomed track scrolls.
const viewport = viewportRef.current;
if (viewport) {
const bounds = viewport.getBoundingClientRect();
if (event.clientX > bounds.right - 24) viewport.scrollLeft += 14;
else if (event.clientX < bounds.left + 24) viewport.scrollLeft -= 14;
}
}
function endDrag(event: PointerEvent<HTMLElement>) {
if (draggingRef.current?.pointerId !== event.pointerId) return;
draggingRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
}
function keyboardMove(event: KeyboardEvent<HTMLElement>, kind: "seek" | "decision") {
if (kind === "decision" && readOnly) return;
const base = kind === "seek" ? current : decision;
if (base === null) return;
const increment = event.shiftKey ? 1_000 : event.altKey ? 10 : 100;
let next: number | null = null;
if (["ArrowLeft", "ArrowDown", "-"].includes(event.key)) next = base - increment;
if (["ArrowRight", "ArrowUp", "+", "="].includes(event.key)) next = base + increment;
if (event.key === "Home") next = 0;
if (event.key === "End") next = duration;
if (next === null) return;
event.preventDefault();
event.stopPropagation();
if (kind === "seek") onSeek(clamp(next));
else onDecisionChange(clamp(next));
}
const trackStyle = { "--timeline-width": `${canvasWidth}px` } as CSSProperties;
return (
<section className={styles.timeline} aria-label="对话时间轴">
<div className={styles.toolbar}>
<div className={styles.toolbarTitle}>
<span className={styles.title}>对话时间轴</span>
<span className={styles.duration}>{timeLabel(duration)} <span>总时长</span></span>
</div>
<div className={styles.tools}>
<button className={styles.locate} type="button" onClick={locateDecision} disabled={decision === null} title="将当前判断点移到视图中央">
<span aria-hidden="true">⌖</span> 定位判断点
</button>
<div className={styles.zoom} aria-label="时间轴缩放">
<button type="button" aria-label="缩小时间轴" disabled={zoom === 1} onClick={() => changeZoom(ZOOM_LEVELS[ZOOM_LEVELS.indexOf(zoom) - 1])}>−</button>
<span aria-live="polite">{zoom}×</span>
<button type="button" aria-label="放大时间轴" disabled={zoom === 8} onClick={() => changeZoom(ZOOM_LEVELS[ZOOM_LEVELS.indexOf(zoom) + 1])}>+</button>
</div>
</div>
</div>
<div className={styles.trackLayout}>
<div className={styles.trackNames} aria-hidden="true">
<div className={styles.rulerName}>时间</div>
<div className={styles.waveName}>录音</div>
<div className={styles.speechName}><span className={styles.agentDot} />机器人</div>
<div className={styles.speechName}><span className={styles.userDot} />用户</div>
<div className={styles.markerName}>判断点</div>
</div>
<div className={styles.viewport} ref={viewportRef}>
<div
className={styles.canvas}
style={trackStyle}
ref={canvasRef}
tabIndex={0}
role="group"
aria-label={`播放位置 ${timeLabel(current, true)}:点击或拖动时间轴定位,方向键微调,Home 到开头,End 到结尾`}
onPointerDown={(event) => beginDrag(event, "seek")}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onLostPointerCapture={() => { draggingRef.current = null; }}
onKeyDown={(event) => keyboardMove(event, "seek")}
>
<div className={styles.ruler} aria-hidden="true">
{ticks.map((tick) => <span key={tick.time} className={styles.tick} style={{ left: position(tick.time) }}>{tick.label}</span>)}
</div>
<div className={styles.waveTrack}>
{waveformPath ? (
<svg className={styles.waveform} viewBox={`0 0 ${canvasWidth} 76`} preserveAspectRatio="none" role="img" aria-label="录音真实振幅波形">
<path d={waveformPath} fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
) : (
<div className={styles.waveEmpty} style={{ width: viewportWidth }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 10v4m4-7v10m4-13v16m4-13v10m4-7v4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>
<span>载入录音后显示波形</span>
</div>
)}
</div>
{(["agent", "user"] as const).map((role) => (
<div className={styles.speechTrack} key={role} aria-label={role === "agent" ? "机器人发言" : "用户发言"}>
{transcripts.filter((item) => item.role === role).map((item, index) => {
const start = position(item.start_ms);
const end = position(item.end_ms);
return <span
key={`${role}-${item.start_ms}-${index}`}
className={`${styles.speechBlock} ${role === "agent" ? styles.agentBlock : styles.userBlock}`}
style={{ left: start, width: Math.max(3, end - start) }}
title={`${role === "agent" ? "机器人" : "用户"} · ${timeLabel(item.start_ms, true)}—${timeLabel(item.end_ms, true)}\n${item.transcript}`}
><span>{item.transcript}</span></span>;
})}
</div>
))}
<div className={styles.annotationTrack} aria-label="已保存的 EOT 标注">
{annotations.filter((item) => item.id !== selectedId).map((item, index) => (
<button
type="button"
key={item.id}
className={`${styles.annotation} ${item.can_reply_now === "1" ? styles.positive : item.can_reply_now === "0" ? styles.negative : styles.pending}`}
style={{ left: position(item.decision_ms) }}
title={`标注 ${index + 1} · ${timeLabel(item.decision_ms, true)} · ${annotationLabel(item.can_reply_now, item.reason)}`}
aria-label={`选择 ${timeLabel(item.decision_ms, true)} 的标注,${annotationLabel(item.can_reply_now, item.reason)}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onSelectAnnotation(item.id); }}
onKeyDown={(event) => event.stopPropagation()}
><span className={styles.annotationStem} aria-hidden="true" /><span>{item.can_reply_now === "" ? "·" : item.can_reply_now}</span></button>
))}
{decision !== null && <span className={styles.selectedLabel} style={{ left: position(decision) }}>
{selectedAnnotation ? annotationLabel(selectedAnnotation.can_reply_now, selectedAnnotation.reason) : "当前判断"}
</span>}
</div>
<div className={styles.playhead} style={{ left: position(current) }} aria-hidden="true"><span /></div>
{decision !== null && <div className={styles.decisionMarker} style={{ left: position(decision) }}>
<div className={styles.decisionLine} aria-hidden="true" />
<button
type="button"
className={styles.decisionHandle}
title={readOnly ? "当前判断时间(查看模式)" : "拖动调整判断时间;方向键微调 100 ms,Shift 微调 1 秒"}
aria-label={`当前判断点 ${timeLabel(decision, true)}${readOnly ? ",查看模式" : ",拖动或使用方向键调整"}`}
aria-describedby={helpId}
aria-disabled={readOnly}
style={readOnly ? { cursor: "default" } : undefined}
onPointerDown={(event) => { event.stopPropagation(); if (!readOnly) beginDrag(event, "decision"); }}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onLostPointerCapture={(event) => { event.stopPropagation(); draggingRef.current = null; }}
onKeyDown={(event) => { event.stopPropagation(); keyboardMove(event, "decision"); }}
><span aria-hidden="true" /></button>
</div>}
</div>
</div>
</div>
<div className={styles.bottomBar}>
<div className={styles.timeReadout}><span className={styles.playheadDot} /> 播放 <strong>{timeLabel(current, true)}</strong></div>
<span id={helpId} className={styles.help}>{readOnly ? "点击定位播放 · 选择标记查看详情" : "点击定位播放 · 拖动橙色标记调整判断点"}</span>
</div>
<div className={styles.decisionControls}>
<label htmlFor={rangeId}><span className={styles.decisionDot} />判断时间</label>
<input
id={rangeId}
type="range"
min={0}
max={duration}
step={1}
value={decision ?? 0}
disabled={decision === null || readOnly}
aria-valuetext={decision === null ? "未选择判断点" : timeLabel(decision, true)}
onChange={(event) => onDecisionChange(clamp(Number(event.currentTarget.value)))}
/>
<label className={styles.numberInput}>
<input
type="number"
aria-label="判断时间,单位毫秒"
min={0}
max={duration}
step={1}
value={decision ?? ""}
placeholder="—"
disabled={decision === null || readOnly}
onChange={(event) => { if (event.currentTarget.value !== "") onDecisionChange(clamp(Number(event.currentTarget.value))); }}
/>
<span>ms</span>
</label>
</div>
</section>
);
}