Compare commits

...

11 Commits

Author SHA1 Message Date
ludan 324ebf5ce5 feat: 优化首页入口按钮质感,提升商业产品精致度
- 按钮背景改为微渐变+毛玻璃效果(backdrop-filter)
- 边框改为半透明白色,圆角从8px升级到12px
- 增加内高光+外层深度阴影提升层次感
- 字间距、字重大小幅调整,更精致克制
- hover态增加accent光晕+图标变绿+放大效果
- 主按钮增加渐变绿底+内高光+绿色辉光阴影
- 增加按压态scale(0.97)反馈
- 主按钮图标hover放大1.12倍
2026-06-02 19:09:00 +08:00
ludan 9ababfda46 fix: 修复剧本评分文本框随内容无限下拉导致按钮被挤出视口
- textarea 增加 max-height 限制高度不随内容增长
- textarea 增加 overflow-y: auto 启用内部滚动
- text-shell 同步增加 max-height 约束
2026-06-02 18:18:00 +08:00
stringadmin 94080f30f7 Merge pull request 'feat: 升级剧本评分系统为 DeepSeek V4 多维评分体系' (#4) from feat/script-eval-deepseek-v4 into master
Reviewed-on: #4
2026-06-02 09:03:11 +00:00
stringadmin 7d446dfc5f Merge branch 'master' into feat/script-eval-deepseek-v4 2026-06-02 09:02:46 +00:00
stringadmin d71437b09c Merge pull request 'Fix/ecommerce 502 bug' (#3) from fix/ecommerce-502-bug into master
Reviewed-on: #3
2026-06-02 09:01:38 +00:00
stringadmin f1bfbf8608 Merge branch 'master' into fix/ecommerce-502-bug 2026-06-02 09:01:31 +00:00
stringadmin 94c1453c9b fix: upload-binary Content-Type fix, 429/timeout retry, 120s timeout - Remove Content-Type: application/json from uploadAssetBinary FormData request - Add retryOnTransient for 429 + timeout + signal timed out errors - Increase AI chat timeout from 60s to 120s per call - Apply retry logic to both chat() and visionChat() 2026-06-02 16:58:59 +08:00
stringadmin 9504f8ee87 fix(ecommerce): replace base64 upload with binary blob in video service
runVideoPlan was passing blob URLs as "dataUrl" to uploadAssetWithProgress,
which sent them to /api/oss/upload (base64 path). Blob URLs don't match
DATA_URL_PATTERN regex, causing corrupt 44-byte files on OSS.

Now uses uploadAssetBinary (FormData multipart) via /api/oss/upload-binary,
fetching blob → uploading binary directly, same as EcommercePage path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 16:16:09 +08:00
ludan d1b5d64bc8 feat: 升级剧本评分系统为 DeepSeek V4 多维评分体系
变更内容:
- scriptEvalClient: 替换系统提示词为 DeepSeek V4 完整版(六维评分细则、
  评分类别、评分铁律、等级标准),维度满分调整为 hook 20/plot 20/
  character 18/dialogue 15/visual 15/content 12,总分算法改为直接累加,
  模型使用 qwen3.7-max,增加请求链路 console 日志
- ScriptTokensPage: 同步维度满分和描述(角色塑造 18、内容深度 12),
  增加页面挂载和评测流程的 console 日志
- vite.config: esbuild.drop 移除 "console",保留 "debugger",
  确保开发环境 console 日志正常输出
2026-06-02 16:04:26 +08:00
stringadmin 44c748b0dc feat(ecommerce): use FormData binary upload instead of base64 dataUrl
- Add uploadAssetBinary method to aiGenerationClient (FormData + busboy)
- Replace base64 dataUrl upload in uploadProductImages with direct blob upload
  via /oss/upload-binary multipart endpoint
- This eliminates the DATA_URL_PATTERN regex parsing bug that produced
  44-byte corrupt files on OSS, causing DashScope "image format illegal" errors

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 16:03:50 +08:00
stringadmin 160552b45e fix(ecommerce): 502 bug - vision model upgrade + MIME normalization + fallback
- Upgrade VISION_MODEL to qwen3.7-plus (latest, confirmed working with image_url)
- Add VISION_FALLBACK_MODEL = qwen-vl-plus for retry on "image format" errors
- Normalize upload MIME types: unsupported formats (HEIC/AVIF) fall back to image/png
  to prevent server saving as .bin which DashScope can't read
- Server-side: add image/avif, image/heic, image/heif to MIME_EXTENSIONS

Root cause: DashScope returned "image format is illegal" when uploaded images
had unrecognized MIME types → saved as .bin → DashScope couldn't decode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 15:20:23 +08:00
9 changed files with 263 additions and 112 deletions
+91 -50
View File
@@ -1,7 +1,8 @@
import { buildApiUrl, buildAuthHeaders } from "./serverConnection"; import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
const TEXT_MODEL = "qwen-max"; const TEXT_MODEL = "qwen-max";
const VISION_MODEL = "qwen3.6-plus"; const VISION_MODEL = "qwen3.7-plus";
const VISION_FALLBACK_MODEL = "qwen-vl-plus";
export interface AdVideoUserConfig { export interface AdVideoUserConfig {
platform: string; platform: string;
@@ -107,36 +108,63 @@ interface ChatMessage {
content: string; content: string;
} }
const MAX_RETRIES = 3;
const RETRY_BASE_MS = 2000;
const CHAT_TIMEOUT_MS = 120_000; // 2 minutes per AI call
function isTransientError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return /\b429\b/.test(msg) || msg.includes("signal timed out") || msg.includes("aborted") || msg.includes("timeout");
}
async function retryOnTransient<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await fn();
} catch (err) {
if (signal?.aborted) throw err;
if (attempt === MAX_RETRIES) throw err;
if (!isTransientError(err)) throw err;
const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000;
await new Promise((r) => setTimeout(r, delay));
}
}
throw new Error("unreachable");
}
async function chat( async function chat(
systemPrompt: string, systemPrompt: string,
userContent: string, userContent: string,
options?: { model?: string; signal?: AbortSignal }, options?: { model?: string; signal?: AbortSignal },
): Promise<string> { ): Promise<string> {
const messages: ChatMessage[] = [ return retryOnTransient(async () => {
{ role: "system", content: systemPrompt }, const messages: ChatMessage[] = [
{ role: "user", content: userContent }, { role: "system", content: systemPrompt },
]; { role: "user", content: userContent },
const timeoutSignal = AbortSignal.timeout(60000); ];
const combinedSignal = options?.signal const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
? AbortSignal.any([options.signal, timeoutSignal]) const combinedSignal = options?.signal
: timeoutSignal; ? AbortSignal.any([options.signal, timeoutSignal])
const res = await fetch(buildApiUrl("ai/chat"), { : timeoutSignal;
method: "POST", const res = await fetch(buildApiUrl("ai/chat"), {
headers: buildAuthHeaders(), method: "POST",
body: JSON.stringify({ headers: buildAuthHeaders(),
model: options?.model ?? TEXT_MODEL, body: JSON.stringify({
messages, model: options?.model ?? TEXT_MODEL,
stream: false, messages,
temperature: 0.4, stream: false,
}), temperature: 0.4,
signal: combinedSignal, }),
}); signal: combinedSignal,
if (!res.ok) throw new Error(`AI 调用失败 (${res.status})`); });
const payload = await res.json(); if (!res.ok) throw new Error(`AI 调用失败 (${res.status})`);
const content: string = const payload = await res.json();
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; const content: string =
if (!content) throw new Error("模型未返回有效内容"); payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
return content; if (!content) throw new Error("模型未返回有效内容");
return content;
}, options?.signal);
} }
async function visionChat( async function visionChat(
@@ -149,30 +177,43 @@ async function visionChat(
...imageUrls.map((url) => ({ type: "image_url", image_url: { url } })), ...imageUrls.map((url) => ({ type: "image_url", image_url: { url } })),
{ type: "text", text }, { type: "text", text },
]; ];
const timeoutSignal = AbortSignal.timeout(60000); const messages = [
const combinedSignal = signal { role: "system", content: systemPrompt },
? AbortSignal.any([signal, timeoutSignal]) { role: "user", content },
: timeoutSignal; ];
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST", for (const model of [VISION_MODEL, VISION_FALLBACK_MODEL]) {
headers: buildAuthHeaders(), const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
body: JSON.stringify({ const combinedSignal = signal
model: VISION_MODEL, ? AbortSignal.any([signal, timeoutSignal])
messages: [ : timeoutSignal;
{ role: "system", content: systemPrompt }, try {
{ role: "user", content }, const out = await retryOnTransient(async () => {
], const res = await fetch(buildApiUrl("ai/chat"), {
stream: false, method: "POST",
temperature: 0.3, headers: buildAuthHeaders(),
}), body: JSON.stringify({ model, messages, stream: false, temperature: 0.3 }),
signal: combinedSignal, signal: combinedSignal,
}); });
if (!res.ok) throw new Error(`图片理解调用失败 (${res.status})`); if (!res.ok) {
const payload = await res.json(); const errBody = await res.text().catch(() => "");
const out: string = if (model === VISION_MODEL && errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK");
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; throw new Error(`图片理解调用失败 (${res.status})`);
if (!out) throw new Error("图片理解未返回有效内容"); }
return out; const payload = await res.json();
const result: string =
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!result) throw new Error("图片理解未返回有效内容");
return result;
}, signal);
return out;
} catch (err) {
if (err instanceof Error && err.message === "IMAGE_FORMAT_FALLBACK") continue;
if (model === VISION_MODEL && err instanceof Error && err.message?.includes("图片理解调用失败")) continue;
throw err;
}
}
throw new Error("图片理解调用失败,所有模型均不可用");
} }
const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`; const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`;
+18
View File
@@ -403,6 +403,24 @@ export const aiGenerationClient = {
return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload response failed"); return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload response failed");
}, },
async uploadAssetBinary(blob: Blob, options?: { name?: string; mimeType?: string; scope?: string }): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
const form = new FormData();
form.append("file", blob, options?.name || "upload.png");
if (options?.scope) form.append("scope", options.scope);
if (options?.mimeType) form.append("mimeType", options.mimeType);
// Exclude Content-Type so browser auto-sets multipart/form-data with boundary
const { "Content-Type": _ct, ...authHeaders } = buildAuthHeaders();
const res = await fetch(buildApiUrl("oss/upload-binary"), {
method: "POST",
headers: authHeaders,
body: form,
});
if (!res.ok) {
await throwResponseError(res, "Binary asset upload failed");
}
return readJsonResponse<{ url: string; ossKey?: string }>(res, "Binary asset upload response failed");
},
async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
const res = await fetch(buildApiUrl("oss/upload-by-url"), { const res = await fetch(buildApiUrl("oss/upload-by-url"), {
method: "POST", method: "POST",
+44 -23
View File
@@ -10,39 +10,50 @@ export interface ScriptEvalResult {
suggestions: string[]; suggestions: string[];
} }
const EVAL_SYSTEM_PROMPT = `你是一位专业的剧本评专家。请对用户提供的剧本进行六维评分分析,并以严格的 JSON 格式返回结果 const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分
六个评分维度: 【剧本类型识别】
1. hook(钩子设计,满分20):开篇吸引力、悬念设置、黄金三秒法则 收到剧本后,首先判断类型:AIGC短剧/漫剧(单集5-30分钟,竖屏平台,高密度反转、强节奏)或传统影视剧本(单集40分钟以上,长视频平台,完整起承转合)。类型判定将影响各维度的评价侧重点。
2. character(角色塑造,满分15):人物立体度、动机合理性、弧光设计
3. plot(剧情结构,满分20):起承转合、节奏把控、冲突设计
4. dialogue(台词对白,满分15):语言质感、角色差异化、潜台词
5. visual(画面表现,满分15):镜头感、空间层次、视觉冲击力
6. content(内容深度,满分15):主题表达、情感共鸣、思想内核
请严格按以下 JSON 格式返回(不要包含任何其他文字): 【评分体系(100分制,六个维度)】
1. hook 钩子设计(20分):开篇钩子、集末钩子、场景内钩子、悬念链完整性。短剧前3秒须有即时爆点;长剧第一幕结束前须建立核心悬念。
2. plot 剧情结构(20分):结构框架、节奏控制、冲突设计、逻辑自洽。短剧"每分钟有事件",反转密度加分;长剧需处理好B线C线与主线交织。
3. character 角色塑造(18分):主角弧光、角色辨识度、角色动机、配角质量。短剧角色须在前2分钟建立;长剧需要内在矛盾和多阶段成长。
4. dialogue 台词对白(15分):角色语言区分度、信息传递效率、潜台词与留白、金句与记忆点。
5. visual 画面表现(15分):场景描写质量、视觉叙事技巧、镜头感与节奏、制作可行性。AIGC需考虑AI生成技术边界与一致性。
6. content 内容深度(12分):主题表达、情感共鸣、社会/人性洞察。
【评分铁律】
- 扣分必须明确指出剧本中的具体段落/场景/台词。
- 严禁给出任何维度的满分,必须有扣分理由。
- 优缺点都要充分展开,不可只批不夸或只夸不批。
- 不因题材类型偏见降低评分,不因某一方面出色而抬高其他维度(避免光环效应)。
- 敢于拉开各维度分数差距,避免全部给中等分数。
【等级标准】按总分百分比:S≥90 | A 80-89 | B 70-79 | C 60-69 | D<60。
请严格按以下 JSON 格式返回(不要包含任何其他文字,不要用代码块包裹以外的说明):
{ {
"dimensionScores": { "hook": 数字, "character": 数字, "plot": 数字, "dialogue": 数字, "visual": 数字, "content": 数字 }, "dimensionScores": { "hook": 数字, "plot": 数字, "character": 数字, "dialogue": 数字, "visual": 数字, "content": 数字 },
"summary": "一句话总结评价", "summary": "200-300字综合评价,概括整体质量、市场潜力与目标受众匹配度",
"issues": ["问题1", "问题2", ...], "issues": ["每条指出具体维度的扣分点并引用剧本原文位置", ...],
"highlights": ["亮点1", "亮点2", ...], "highlights": ["核心亮点,引用剧本具体场景", ...],
"suggestions": ["建议1", "建议2", ...] "suggestions": ["按优先级排列的改进建议(最优先/次优先/可优化)", ...]
}`; }`;
const DIMENSION_WEIGHTS: Record<string, { maxScore: number; weight: number }> = { const DIMENSION_WEIGHTS: Record<string, { maxScore: number }> = {
hook: { maxScore: 20, weight: 0.2 }, hook: { maxScore: 20 },
character: { maxScore: 15, weight: 0.15 }, plot: { maxScore: 20 },
plot: { maxScore: 20, weight: 0.2 }, character: { maxScore: 18 },
dialogue: { maxScore: 15, weight: 0.15 }, dialogue: { maxScore: 15 },
visual: { maxScore: 15, weight: 0.15 }, visual: { maxScore: 15 },
content: { maxScore: 15, weight: 0.15 }, content: { maxScore: 12 },
}; };
function computeTotalAndGrade(scores: Record<string, number>): { totalScore: number; grade: string } { function computeTotalAndGrade(scores: Record<string, number>): { totalScore: number; grade: string } {
const totalScore = Math.round( const totalScore = Math.round(
Object.entries(DIMENSION_WEIGHTS).reduce((sum, [key, dim]) => { Object.entries(DIMENSION_WEIGHTS).reduce((sum, [key, dim]) => {
const score = Math.max(0, Math.min(dim.maxScore, scores[key] ?? 0)); return sum + Math.max(0, Math.min(dim.maxScore, scores[key] ?? 0));
return sum + (score / dim.maxScore) * 100 * dim.weight;
}, 0), }, 0),
); );
const grade = totalScore >= 90 ? "S" : totalScore >= 80 ? "A" : totalScore >= 70 ? "B" : totalScore >= 60 ? "C" : "D"; const grade = totalScore >= 90 ? "S" : totalScore >= 80 ? "A" : totalScore >= 70 ? "B" : totalScore >= 60 ? "C" : "D";
@@ -56,6 +67,7 @@ function extractJson(text: string): unknown {
} }
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> { export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
console.log("[API] 发送评测请求,剧本长度:", script.slice(0, 8000).length, "字符");
const res = await fetch(buildApiUrl("ai/chat"), { const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST", method: "POST",
headers: buildAuthHeaders(), headers: buildAuthHeaders(),
@@ -71,11 +83,15 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
signal, signal,
}); });
console.log("[API] 响应状态:", res.status, res.statusText);
if (!res.ok) { if (!res.ok) {
throw new Error(`评测请求失败 (${res.status})`); throw new Error(`评测请求失败 (${res.status})`);
} }
const payload = await res.json(); const payload = await res.json();
console.log("[API] 原始响应体:", payload);
const content: string = payload?.choices?.[0]?.message?.content const content: string = payload?.choices?.[0]?.message?.content
?? payload?.result?.content ?? payload?.result?.content
?? payload?.content ?? payload?.content
@@ -84,7 +100,11 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
if (!content) throw new Error("模型未返回有效内容"); if (!content) throw new Error("模型未返回有效内容");
console.log("[API] 模型返回内容 (前500字符):", content.slice(0, 500));
const parsed = extractJson(content) as Record<string, unknown>; const parsed = extractJson(content) as Record<string, unknown>;
console.log("[API] 解析后的JSON:", parsed);
const dimensionScores: Record<string, number> = {}; const dimensionScores: Record<string, number> = {};
const rawScores = parsed.dimensionScores as Record<string, number> | undefined; const rawScores = parsed.dimensionScores as Record<string, number> | undefined;
if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常"); if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常");
@@ -95,6 +115,7 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
} }
const { totalScore, grade } = computeTotalAndGrade(dimensionScores); const { totalScore, grade } = computeTotalAndGrade(dimensionScores);
console.log("[API] 计算后总分:", totalScore, "等级:", grade);
return { return {
totalScore, totalScore,
+5 -8
View File
@@ -1321,18 +1321,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
}); });
const uploadProductImages = async (): Promise<string[]> => { const uploadProductImages = async (): Promise<string[]> => {
const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
const urls: string[] = []; const urls: string[] = [];
for (const item of productImages) { for (const item of productImages) {
try { try {
const resp = await fetch(item.src); const resp = await fetch(item.src);
const blob = await resp.blob(); const rawBlob = await resp.blob();
const dataUrl = await new Promise<string>((resolve, reject) => { const mimeType = SUPPORTED_IMAGE_TYPES.has(rawBlob.type) ? rawBlob.type : "image/png";
const reader = new FileReader(); const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
reader.onload = () => resolve(String(reader.result)); const { url } = await aiGenerationClient.uploadAssetBinary(blob, { name: item.name, mimeType, scope: "ecommerce-product" });
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
const { url } = await aiGenerationClient.uploadAsset({ dataUrl, name: item.name, mimeType: blob.type });
urls.push(url); urls.push(url);
} catch { } catch {
// skip images that fail to upload // skip images that fail to upload
@@ -9,7 +9,6 @@ import {
type AdVideoUserConfig, type AdVideoUserConfig,
} from "../../api/adVideoPlanClient"; } from "../../api/adVideoPlanClient";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { uploadAssetWithProgress } from "../../api/uploadWithProgress";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel"; import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import type { import type {
@@ -34,12 +33,18 @@ export async function runVideoPlan(
onStepStart("upload"); onStepStart("upload");
const imageUrls: string[] = []; const imageUrls: string[] = [];
for (const dataUrl of imageDataUrls) { const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
const result = await uploadAssetWithProgress( for (const srcUrl of imageDataUrls) {
{ dataUrl, scope: "ecommerce-product", mimeType: "image/png" }, try {
{ signal }, const resp = await fetch(srcUrl);
); const rawBlob = await resp.blob();
imageUrls.push(result.url); const mimeType = SUPPORTED_IMAGE_TYPES.has(rawBlob.type) ? rawBlob.type : "image/png";
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
const result = await aiGenerationClient.uploadAssetBinary(blob, { mimeType, scope: "ecommerce-product" });
imageUrls.push(result.url);
} catch {
// skip images that fail to upload
}
} }
onStepDone("upload"); onStepDone("upload");
@@ -1,5 +1,5 @@
import { CopyOutlined, DownOutlined, DownloadOutlined, FileTextOutlined, ReloadOutlined, TrophyOutlined, UploadOutlined } from "@ant-design/icons"; import { CopyOutlined, DownOutlined, DownloadOutlined, FileTextOutlined, ReloadOutlined, TrophyOutlined, UploadOutlined } from "@ant-design/icons";
import { useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react"; import { useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
import { evaluateScript } from "../../api/scriptEvalClient"; import { evaluateScript } from "../../api/scriptEvalClient";
interface ScoreDimension { interface ScoreDimension {
@@ -35,9 +35,9 @@ const scoreDimensions: ScoreDimension[] = [
{ {
key: "character", key: "character",
label: "角色塑造", label: "角色塑造",
maxScore: 15, maxScore: 18,
weight: 0.15, weight: 0.18,
description: "人物立体度、动机合理性、弧光设计", description: "主角弧光、角色辨识度、动机、配角质量",
}, },
{ {
key: "plot", key: "plot",
@@ -63,9 +63,9 @@ const scoreDimensions: ScoreDimension[] = [
{ {
key: "content", key: "content",
label: "内容深度", label: "内容深度",
maxScore: 15, maxScore: 12,
weight: 0.15, weight: 0.12,
description: "主题表达、情感共鸣、思想内核", description: "主题表达、情感共鸣、社会/人性洞察",
}, },
]; ];
@@ -170,6 +170,10 @@ function ScriptTokensPage() {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
console.log("[剧本评分] 页面已加载,ScriptTokensPage mounted");
}, []);
const hasContent = Boolean(script.trim()); const hasContent = Boolean(script.trim());
const lineNumbers = useMemo(() => { const lineNumbers = useMemo(() => {
const count = Math.min(160, Math.max(10, script.split(/\r\n|\r|\n/).length)); const count = Math.min(160, Math.max(10, script.split(/\r\n|\r|\n/).length));
@@ -202,14 +206,26 @@ function ScriptTokensPage() {
}; };
const handleEvaluate = async () => { const handleEvaluate = async () => {
console.log("[剧本评测] 点击开始评测,hasContent:", hasContent, "script长度:", script.length);
if (!hasContent) return; if (!hasContent) return;
setLoading(true); setLoading(true);
setResult(null); setResult(null);
setEvalError(null); setEvalError(null);
try { try {
console.log("[剧本评测] 开始评测,剧本长度:", script.length, "字符");
const aiResult = await evaluateScript(script); const aiResult = await evaluateScript(script);
console.log("[剧本评测] 评测完成,结果:", {
总分: aiResult.totalScore,
等级: aiResult.grade,
维度得分: aiResult.dimensionScores,
摘要: aiResult.summary,
亮点: aiResult.highlights,
问题: aiResult.issues,
建议: aiResult.suggestions,
});
setResult(aiResult); setResult(aiResult);
} catch (err) { } catch (err) {
console.error("[剧本评测] 评测失败:", err);
setEvalError(err instanceof Error ? err.message : "评测服务暂时不可用,请稍后重试"); setEvalError(err instanceof Error ? err.message : "评测服务暂时不可用,请稍后重试");
} }
setDetailsExpanded(true); setDetailsExpanded(true);
+62 -16
View File
@@ -148,37 +148,83 @@
min-width: 0; min-width: 0;
min-height: 72px; min-height: 72px;
padding: 0 28px; padding: 0 28px;
border: 1px solid var(--border-subtle); border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px; border-radius: 12px;
background: var(--bg-inset); background: linear-gradient(180deg, rgba(20, 23, 26, 0.72) 0%, rgba(15, 17, 19, 0.84) 100%);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 2px 8px rgba(0, 0, 0, 0.28);
color: var(--fg-body); color: var(--fg-body);
cursor: pointer; cursor: pointer;
font-size: 17px; font-size: 16px;
font-weight: 850; font-weight: 700;
transition: border-color 160ms ease, background 160ms ease, color 160ms ease, transform 160ms ease; letter-spacing: 0.03em;
transition:
border-color 240ms ease,
background 240ms ease,
color 240ms ease,
transform 240ms cubic-bezier(0.34, 1.2, 0.64, 1),
box-shadow 240ms ease;
} }
.omni-home__entry .anticon { .omni-home__entry .anticon {
font-size: 18px; font-size: 19px;
transition: color 240ms ease, transform 240ms ease;
} }
.omni-home__entry:hover { .omni-home__entry:hover {
border-color: var(--border-default); border-color: rgba(255, 255, 255, 0.16);
background: var(--bg-hover); background: linear-gradient(180deg, rgba(28, 32, 36, 0.78) 0%, rgba(18, 22, 25, 0.88) 100%);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.06) inset,
0 0 24px rgba(var(--accent-rgb), 0.06),
0 4px 16px rgba(0, 0, 0, 0.36);
color: #ffffff; color: #ffffff;
transform: translateY(-1px); transform: translateY(-2px);
}
.omni-home__entry:hover .anticon {
color: var(--accent);
transform: scale(1.08);
}
.omni-home__entry:active {
transform: translateY(0) scale(0.97);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.02) inset,
0 1px 4px rgba(0, 0, 0, 0.32);
transition-duration: 80ms;
} }
.omni-home__entry--primary { .omni-home__entry--primary {
border-color: var(--accent); border-color: rgba(var(--accent-rgb), 0.48);
background: var(--accent); background: linear-gradient(180deg, rgba(0, 255, 136, 0.22) 0%, rgba(0, 220, 118, 0.14) 100%), var(--accent);
color: var(--dg-button-text, #061014); box-shadow:
0 1px 0 rgba(255, 255, 255, 0.12) inset,
0 0 28px rgba(var(--accent-rgb), 0.18),
0 2px 12px rgba(0, 0, 0, 0.28);
color: #061014;
} }
.omni-home__entry--primary:hover { .omni-home__entry--primary:hover {
border-color: var(--accent-hover, var(--accent)); border-color: rgba(var(--accent-rgb), 0.64);
background: var(--accent-hover, var(--accent)); background: linear-gradient(180deg, rgba(0, 255, 136, 0.28) 0%, rgba(0, 230, 124, 0.18) 100%), var(--accent-hover);
color: var(--dg-button-text, #061014); box-shadow:
0 1px 0 rgba(255, 255, 255, 0.16) inset,
0 0 40px rgba(var(--accent-rgb), 0.28),
0 6px 24px rgba(0, 0, 0, 0.36);
color: #061014;
}
.omni-home__entry--primary .anticon {
color: #061014;
}
.omni-home__entry--primary:hover .anticon {
color: #061014;
transform: scale(1.12);
} }
.omni-home__carousel { .omni-home__carousel {
+7
View File
@@ -3400,6 +3400,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 520px; min-height: 520px;
max-height: 520px;
padding: 18px 22px; padding: 18px 22px;
border: none; border: none;
outline: none; outline: none;
@@ -3409,6 +3410,7 @@
font-size: 14px; font-size: 14px;
line-height: 1.9; line-height: 1.9;
resize: none; resize: none;
overflow-y: auto;
} }
.script-eval-v4-text-input::placeholder { .script-eval-v4-text-input::placeholder {
@@ -4268,6 +4270,11 @@
.script-eval-v4-text-shell, .script-eval-v4-text-shell,
.script-eval-v4-text-input { .script-eval-v4-text-input {
min-height: calc(100vh - 422px); min-height: calc(100vh - 422px);
max-height: calc(100vh - 422px);
}
.script-eval-v4-text-input {
overflow-y: auto;
} }
.script-eval-v4-score-card { .script-eval-v4-score-card {
+1 -1
View File
@@ -22,7 +22,7 @@ export default defineConfig({
host: "127.0.0.1", host: "127.0.0.1",
}, },
esbuild: { esbuild: {
drop: ["console", "debugger"], drop: ["debugger"],
}, },
build: { build: {
sourcemap: "hidden", sourcemap: "hidden",