Merge origin/master into feat/commercial-saas-polish
This commit is contained in:
@@ -36,6 +36,8 @@ interface HistoryEntry {
|
||||
timestamp: number;
|
||||
score: number;
|
||||
grade: string;
|
||||
script?: string;
|
||||
result?: EvalResult;
|
||||
}
|
||||
|
||||
function getGrade(score: number): string {
|
||||
@@ -57,6 +59,8 @@ const TEXT_FILE_EXTENSIONS = [
|
||||
".fountain",
|
||||
".fdx",
|
||||
".rtf",
|
||||
".docx",
|
||||
".doc",
|
||||
".csv",
|
||||
".tsv",
|
||||
".json",
|
||||
@@ -102,7 +106,7 @@ const TEXT_FILE_EXTENSIONS = [
|
||||
] as const;
|
||||
const TEXT_FILE_EXTENSION_SET = new Set<string>(TEXT_FILE_EXTENSIONS);
|
||||
const TEXT_FILE_ACCEPT = TEXT_FILE_EXTENSIONS.join(",");
|
||||
const TEXT_FILE_HINT = "支持常见文本格式:TXT / MD / Fountain / FDX / RTF / JSON / CSV / XML / HTML / YAML / LOG / 字幕等";
|
||||
const TEXT_FILE_HINT = "支持常见文本格式:TXT / MD / DOCX / Fountain / FDX / RTF / JSON / CSV / XML / HTML / YAML / LOG / 字幕等";
|
||||
|
||||
function loadHistory(): HistoryEntry[] {
|
||||
try {
|
||||
@@ -171,6 +175,62 @@ function normalizeUploadedText(raw: string, ext: string): string {
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function extractDocxText(bytes: Uint8Array): Promise<string> {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const entries: Array<{ name: string; offset: number; size: number; compressed: boolean }> = [];
|
||||
let pos = 0;
|
||||
while (pos < bytes.length - 30) {
|
||||
if (view.getUint32(pos, true) !== 0x04034b50) break;
|
||||
const compressed = view.getUint16(pos + 10, true) !== 0;
|
||||
const compressedSize = view.getUint32(pos + 18, true);
|
||||
const fileNameLen = view.getUint16(pos + 26, true);
|
||||
const extraLen = view.getUint16(pos + 28, true);
|
||||
const name = new TextDecoder().decode(bytes.slice(pos + 30, pos + 30 + fileNameLen));
|
||||
const dataStart = pos + 30 + fileNameLen + extraLen;
|
||||
entries.push({ name, offset: dataStart, size: compressedSize, compressed });
|
||||
pos = dataStart + compressedSize;
|
||||
}
|
||||
const docEntry = entries.find((e) => e.name === "word/document.xml");
|
||||
if (!docEntry) return "";
|
||||
const xmlBytes = bytes.slice(docEntry.offset, docEntry.offset + docEntry.size);
|
||||
let xmlText: string;
|
||||
if (docEntry.compressed) {
|
||||
try {
|
||||
const ds = new DecompressionStream("deflate-raw");
|
||||
const writer = ds.writable.getWriter();
|
||||
writer.write(xmlBytes);
|
||||
writer.close();
|
||||
const reader = ds.readable.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
}
|
||||
const totalLen = chunks.reduce((s, c) => s + c.length, 0);
|
||||
const combined = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
for (const c of chunks) { combined.set(c, offset); offset += c.length; }
|
||||
xmlText = new TextDecoder().decode(combined);
|
||||
} catch {
|
||||
xmlText = new TextDecoder().decode(xmlBytes);
|
||||
}
|
||||
} else {
|
||||
xmlText = new TextDecoder().decode(xmlBytes);
|
||||
}
|
||||
const textMatches = xmlText.match(/<w:t[^>]*>([\s\S]*?)<\/w:t>/g);
|
||||
if (!textMatches) return "";
|
||||
const paraMatches = xmlText.match(/<w:p[ >][\s\S]*?<\/w:p>/g);
|
||||
if (paraMatches) {
|
||||
return paraMatches.map((p) => {
|
||||
const tMatches = p.match(/<w:t[^>]*>([\s\S]*?)<\/w:t>/g);
|
||||
if (!tMatches) return "";
|
||||
return tMatches.map((m) => m.replace(/<[^>]+>/g, "").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, "\"")).join("");
|
||||
}).filter(Boolean).join("\n").trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatFileSize(size: number): string {
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
@@ -231,6 +291,7 @@ function ScriptTokensPage() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [activeDim, setActiveDim] = useState<number | null>(null);
|
||||
const [animatedScore, setAnimatedScore] = useState(0);
|
||||
const [activeHistoryIndex, setActiveHistoryIndex] = useState<number>(0);
|
||||
const [history, setHistory] = useState<HistoryEntry[]>(loadHistory);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const scoreFrameRef = useRef<number | null>(null);
|
||||
@@ -260,7 +321,23 @@ function ScriptTokensPage() {
|
||||
const ext = getFileExtension(file.name);
|
||||
const readable = isReadableTextFile(file, ext);
|
||||
setUploadedFile({ name: file.name, size: file.size });
|
||||
if (readable) {
|
||||
if (ext === ".docx") {
|
||||
try {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const text = await extractDocxText(bytes);
|
||||
if (text) {
|
||||
setScript(text);
|
||||
} else {
|
||||
setScript(`[已上传文件:${file.name}]\n\n无法从 DOCX 文件中提取文本,请尝试另存为 TXT 格式后重新上传。`);
|
||||
}
|
||||
} catch {
|
||||
setScript(`[已上传文件:${file.name}]\n\n解析 DOCX 文件失败,请尝试另存为 TXT 格式后重新上传。`);
|
||||
}
|
||||
} else if (ext === ".doc") {
|
||||
const text = await decodeTextFile(file);
|
||||
const cleaned = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "").replace(/\s{3,}/g, "\n\n").trim();
|
||||
setScript(cleaned || `[已上传文件:${file.name}]\n\n无法从 .doc 文件中提取文本,请另存为 .docx 或 .txt 格式。`);
|
||||
} else if (readable) {
|
||||
const text = normalizeUploadedText(await decodeTextFile(file), ext);
|
||||
setScript(text);
|
||||
} else {
|
||||
@@ -286,6 +363,8 @@ function ScriptTokensPage() {
|
||||
timestamp: Date.now(),
|
||||
score: aiResult.totalScore,
|
||||
grade: g,
|
||||
script,
|
||||
result: aiResult,
|
||||
};
|
||||
const updated = [entry, ...loadHistory().filter((h) => h.name !== entry.name || h.score !== entry.score)].sort(
|
||||
(a, b) => b.timestamp - a.timestamp,
|
||||
@@ -298,6 +377,20 @@ function ScriptTokensPage() {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleHistoryClick = (item: HistoryEntry, index: number) => {
|
||||
setActiveHistoryIndex(index);
|
||||
if (item.script) {
|
||||
setScript(item.script);
|
||||
setUploadedFile({ name: `${item.name}.txt`, size: item.script.length });
|
||||
}
|
||||
if (item.result) {
|
||||
setResult(item.result);
|
||||
} else {
|
||||
setResult(null);
|
||||
}
|
||||
setEvalError(null);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setScript("");
|
||||
setResult(null);
|
||||
@@ -434,7 +527,9 @@ function ScriptTokensPage() {
|
||||
<div className="script-eval-v5-history-empty">暂无评测记录</div>
|
||||
) : (
|
||||
history.map((item, i) => (
|
||||
<div key={i} className={`script-eval-v5-history-item${i === 0 ? " is-active" : ""}`}>
|
||||
<div key={i} className={`script-eval-v5-history-item${i === activeHistoryIndex ? " is-active" : ""}`}
|
||||
onClick={() => handleHistoryClick(item, i)} role="button" tabIndex={0}
|
||||
onKeyDown={(e) => { if ((e as React.KeyboardEvent).key === "Enter") handleHistoryClick(item, i); }}>
|
||||
<div className="script-eval-v5-hi-left">
|
||||
<div className="script-eval-v5-hi-name">{item.name}</div>
|
||||
<div className="script-eval-v5-hi-date">{item.date}</div>
|
||||
|
||||
Reference in New Issue
Block a user