Files
ai/user-app/src/components/production/SplusProductionPipeline.vue
T

1378 lines
72 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import {
type FlowTestGenerationPlanCompileResult,
type ProductionQualityIssue,
type ProductionStagePromptPreview,
type SafeProductionContract,
type SafeProductionContractReview,
type SafeProductionSourceSnapshot,
type SafeProject,
type SplusSceneScriptReviewReport,
UserApiClient
} from '../../api/client';
const props = defineProps<{
token: string | null;
project: SafeProject;
}>();
const emit = defineEmits<{ (event: 'changed'): void }>();
const client = new UserApiClient(props.token);
const snapshots = ref<SafeProductionSourceSnapshot[]>([]);
const contracts = ref<SafeProductionContract[]>([]);
const selectedStage = ref<ProductionStagePromptPreview['stage']>('source');
const episodeNumber = ref(1);
const promptPreview = ref<ProductionStagePromptPreview | null>(null);
const importedJson = ref('');
const loading = ref('');
const errorMessage = ref('');
const successMessage = ref('');
const auditContract = ref<SafeProductionContract | null>(null);
const auditReviews = ref<SafeProductionContractReview[]>([]);
const auditCompareContractId = ref('');
const auditReviewerNotes = ref('');
const flowTestCompileResult = ref<FlowTestGenerationPlanCompileResult | null>(null);
const latestSnapshot = computed(() => snapshots.value[0] ?? null);
const confirmedSource = computed(() => latestConfirmed('source_analysis'));
const confirmedAdaptation = computed(() => latestConfirmed('adaptation_bible'));
const episodePlans = computed(() => {
const byEpisode = new Map<number, SafeProductionContract>();
contracts.value
.filter((item) => item.contract_type === 'episode_plan')
.sort((left, right) => right.version - left.version)
.forEach((item) => {
const number = Number(item.payload_json.episode_number ?? 0);
if (number > 0 && !byEpisode.has(number)) byEpisode.set(number, item);
});
return [...byEpisode.values()].sort((left, right) => Number(left.payload_json.episode_number) - Number(right.payload_json.episode_number));
});
const confirmedEpisodePlans = computed(() => episodePlans.value.filter((item) => item.status === 'confirmed'));
const sceneScripts = computed(() => {
const byEpisode = new Map<number, SafeProductionContract>();
contracts.value
.filter((item) => item.contract_type === 'scene_script')
.sort((left, right) => right.version - left.version)
.forEach((item) => {
const number = Number(item.payload_json.episode_number ?? 0);
if (number > 0 && !byEpisode.has(number)) byEpisode.set(number, item);
});
return [...byEpisode.values()].sort((left, right) => Number(left.payload_json.episode_number) - Number(right.payload_json.episode_number));
});
const assetPlans = computed(() => {
const byEpisode = new Map<number, SafeProductionContract>();
contracts.value
.filter((item) => item.contract_type === 'asset_plan')
.sort((left, right) => right.version - left.version)
.forEach((item) => {
const number = Number(item.payload_json.episode_number ?? 0);
if (number > 0 && !byEpisode.has(number)) byEpisode.set(number, item);
});
return [...byEpisode.values()].sort((left, right) => Number(left.payload_json.episode_number) - Number(right.payload_json.episode_number));
});
const sceneGeographies = computed(() => {
const byEpisode = new Map<number, SafeProductionContract>();
contracts.value
.filter((item) => item.contract_type === 'scene_geography')
.sort((left, right) => right.version - left.version)
.forEach((item) => {
const number = Number(item.payload_json.episode_number ?? 0);
if (number > 0 && !byEpisode.has(number)) byEpisode.set(number, item);
});
return [...byEpisode.values()].sort((left, right) => Number(left.payload_json.episode_number) - Number(right.payload_json.episode_number));
});
const selectedContractType = computed<SafeProductionContract['contract_type']>(() => (
selectedStage.value === 'source'
? 'source_analysis'
: selectedStage.value === 'adaptation'
? 'adaptation_bible'
: selectedStage.value === 'episode'
? 'episode_plan'
: selectedStage.value === 'script'
? 'scene_script'
: selectedStage.value === 'assets' ? 'asset_plan' : 'scene_geography'
));
const selectedLatestContract = computed(() => {
if (selectedContractType.value === 'episode_plan') {
return episodePlans.value.find((item) => Number(item.payload_json.episode_number) === episodeNumber.value) ?? null;
}
if (selectedContractType.value === 'scene_script') {
return sceneScripts.value.find((item) => Number(item.payload_json.episode_number) === episodeNumber.value) ?? null;
}
if (selectedContractType.value === 'asset_plan') {
return assetPlans.value.find((item) => Number(item.payload_json.episode_number) === episodeNumber.value) ?? null;
}
if (selectedContractType.value === 'scene_geography') {
return sceneGeographies.value.find((item) => Number(item.payload_json.episode_number) === episodeNumber.value) ?? null;
}
return latestOfType(selectedContractType.value);
});
const selectedReleasedEpisodePlan = computed(() => episodePlans.value.find((item) => (
Number(item.payload_json.episode_number) === episodeNumber.value &&
item.status === 'confirmed' &&
item.downstream_status === 'released'
)) ?? null);
const selectedConfirmedSceneScript = computed(() => sceneScripts.value.find((item) => (
Number(item.payload_json.episode_number) === episodeNumber.value && item.status === 'confirmed'
)) ?? null);
const selectedConfirmedAssetPlan = computed(() => assetPlans.value.find((item) => (
Number(item.payload_json.episode_number) === episodeNumber.value && item.status === 'confirmed'
)) ?? null);
const selectedConfirmedSceneGeography = computed(() => sceneGeographies.value.find((item) => (
Number(item.payload_json.episode_number) === episodeNumber.value && item.status === 'confirmed'
)) ?? null);
const canPreview = computed(() => {
if (selectedStage.value === 'source') return Boolean(latestSnapshot.value);
if (selectedStage.value === 'adaptation') return Boolean(confirmedSource.value);
if (selectedStage.value === 'episode') {
return Boolean(confirmedAdaptation.value && (episodeNumber.value === 1 || confirmedEpisodePlans.value.some((item) => Number(item.payload_json.episode_number) === episodeNumber.value - 1)));
}
if (selectedStage.value === 'script') return Boolean(selectedReleasedEpisodePlan.value);
if (selectedStage.value === 'assets') return Boolean(selectedConfirmedSceneScript.value);
return Boolean(selectedConfirmedAssetPlan.value);
});
const continuityBatch = computed(() => confirmedEpisodePlans.value.slice(0, 3));
const auditVersionOptions = computed(() => {
if (!auditContract.value) return [];
const episode = Number(auditContract.value.payload_json.episode_number ?? 0);
return contracts.value
.filter((item) => (
item.contract_type === 'scene_script' &&
item.id !== auditContract.value?.id &&
Number(item.payload_json.episode_number ?? 0) === episode
))
.sort((left, right) => right.version - left.version);
});
const auditCompareContract = computed(() => contracts.value.find((item) => item.id === auditCompareContractId.value) ?? null);
const auditAiReviews = computed(() => auditReviews.value
.filter((item) => item.reviewer_type === 'scene_script_splus_ai_reviewer_v1')
.sort((left, right) => right.review_no - left.review_no));
const latestAuditAiReport = computed<SplusSceneScriptReviewReport | null>(() => {
const value = auditAiReviews.value[0]?.delta_json;
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const report = value as Partial<SplusSceneScriptReviewReport>;
return report.schema_version === 'splus_scene_script_review_v1'
? report as SplusSceneScriptReviewReport
: null;
});
const auditVersionComparison = computed(() => (
auditContract.value && auditCompareContract.value
? compareScriptVersions(auditContract.value, auditCompareContract.value)
: []
));
const REVIEW_DIMENSION_LABELS: Record<string, string> = {
source_fidelity: '原著忠实度',
dramatic_structure: '戏剧结构',
opening_hook: '开场钩子',
pacing: '节奏',
character_consistency: '人物一致性',
dialogue_performance: '对白可演性',
visual_executability: '画面可执行性',
duration_feasibility: '时长可行性',
continuity: '连续性',
production_readiness: '生产就绪度'
};
watch(() => props.token, (token) => client.setToken(token));
watch(() => props.project.id, () => void load());
watch(selectedStage, () => {
promptPreview.value = null;
importedJson.value = selectedLatestContract.value ? JSON.stringify(selectedLatestContract.value.payload_json, null, 2) : '';
});
watch(episodeNumber, () => {
if (
selectedStage.value !== 'episode'
&& selectedStage.value !== 'script'
&& selectedStage.value !== 'assets'
&& selectedStage.value !== 'geography'
) return;
promptPreview.value = null;
importedJson.value = selectedLatestContract.value ? JSON.stringify(selectedLatestContract.value.payload_json, null, 2) : '';
});
onMounted(() => void load());
function latestOfType(type: SafeProductionContract['contract_type']) {
return contracts.value
.filter((item) => item.contract_type === type)
.sort((left, right) => right.version - left.version)[0] ?? null;
}
function latestConfirmed(type: SafeProductionContract['contract_type']) {
return contracts.value
.filter((item) => item.contract_type === type && item.status === 'confirmed')
.sort((left, right) => right.version - left.version)[0] ?? null;
}
async function load() {
loading.value = 'load';
errorMessage.value = '';
try {
const [snapshotResult, contractResult] = await Promise.all([
client.listProductionSourceSnapshots(props.project.id),
client.listProductionContracts(props.project.id)
]);
snapshots.value = snapshotResult.source_snapshots;
contracts.value = contractResult.contracts;
if (!importedJson.value && selectedLatestContract.value) {
importedJson.value = JSON.stringify(selectedLatestContract.value.payload_json, null, 2);
}
} catch (error) {
errorMessage.value = readableError(error);
} finally {
loading.value = '';
}
}
async function createSnapshot() {
await run('snapshot', async () => {
const result = await client.createProductionSourceSnapshot(props.project.id);
successMessage.value = result.reused ? '已复用内容相同的不可变快照。' : '不可变原文快照已创建。';
await load();
});
}
async function previewPrompt() {
await run('preview', async () => {
promptPreview.value = await client.previewProductionStagePrompt(props.project.id, selectedStage.value, {
source_snapshot_id: selectedStage.value === 'source' ? latestSnapshot.value?.id : undefined,
upstream_contract_id: selectedStage.value === 'adaptation'
? confirmedSource.value?.id
: selectedStage.value === 'episode'
? confirmedAdaptation.value?.id
: selectedStage.value === 'script'
? selectedReleasedEpisodePlan.value?.id
: selectedStage.value === 'assets'
? selectedConfirmedSceneScript.value?.id
: selectedStage.value === 'geography' ? selectedConfirmedAssetPlan.value?.id : undefined,
episode_number: selectedStage.value === 'episode' ? episodeNumber.value : undefined
});
successMessage.value = '阶段 Prompt 已生成,可复制到已选文本模型测试。';
});
}
async function copyPrompt() {
if (!promptPreview.value?.prompt) return;
await navigator.clipboard.writeText(promptPreview.value.prompt);
successMessage.value = 'Prompt 已复制。';
}
async function saveImportedContract() {
await run('save', async () => {
let payload: Record<string, unknown>;
try {
const parsed = JSON.parse(importedJson.value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not-object');
payload = parsed;
} catch {
throw new Error('导入内容必须是合法 JSON 对象。');
}
const result = await client.saveProductionContract(props.project.id, selectedContractType.value, {
payload,
source_snapshot_id: selectedStage.value === 'source' ? latestSnapshot.value?.id : undefined,
parent_contract_id: selectedStage.value === 'adaptation'
? confirmedSource.value?.id
: selectedStage.value === 'episode'
? confirmedAdaptation.value?.id
: selectedStage.value === 'script'
? selectedReleasedEpisodePlan.value?.id
: selectedStage.value === 'assets'
? selectedConfirmedSceneScript.value?.id
: selectedStage.value === 'geography' ? selectedConfirmedAssetPlan.value?.id : undefined
});
successMessage.value = result.quality.hard_gate_status === 'pass'
? `v${result.contract.version} 已保存并通过硬门。`
: `v${result.contract.version} 已保存为草稿,请按阻断项修复。`;
await load();
emit('changed');
});
}
async function review(contract: SafeProductionContract) {
await run(`review-${contract.id}`, async () => {
const result = await client.reviewProductionContract(contract.id);
successMessage.value = result.quality.hard_gate_status === 'pass' ? '审核通过。' : '审核发现阻断项。';
await load();
});
}
async function confirm(contract: SafeProductionContract) {
await run(`confirm-${contract.id}`, async () => {
await client.confirmProductionContract(contract.id);
successMessage.value = `已确认 ${contractLabel(contract.contract_type)} v${contract.version}`;
await load();
emit('changed');
});
}
async function checkContinuity() {
if (continuityBatch.value.length !== 3) return;
await run('continuity', async () => {
const result = await client.checkProductionEpisodeContinuity(
props.project.id,
continuityBatch.value.map((item) => item.id)
);
successMessage.value = result.passed ? '连续 3 集状态检查通过。' : '连续性检查未通过,请查看阻断项。';
await load();
});
}
async function release(contract: SafeProductionContract) {
await run(`release-${contract.id}`, async () => {
await client.releaseProductionEpisodePlan(contract.id);
successMessage.value = `${contract.payload_json.episode_number} 集已批准进入剧本阶段。`;
await load();
emit('changed');
});
}
async function compileFlowTestPlans() {
const contract = selectedConfirmedSceneGeography.value;
if (!contract) return;
const confirmed = window.confirm([
'只编译流程联调 Generation Plan,不调用付费图片或视频模型。',
'已确认场景地理合同会锁定站位、轴线与动作向量;缺失资产仍会继续阻断正式生成。',
'确定继续吗?'
].join('\n'));
if (!confirmed) return;
await run('flow-test-plans', async () => {
flowTestCompileResult.value = await client.compileFlowTestGenerationPlans(
contract.id,
'用户明确选择先跑通完整流程;当前素材仅用于联调,所有缺失项继续阻断正式生成。'
);
successMessage.value = `已冻结 ${flowTestCompileResult.value.generation_plan_count} 个联调计划,未发生付费 AI 调用。`;
await load();
emit('changed');
});
}
async function openScriptAudit(contract: SafeProductionContract) {
auditContract.value = contract;
auditReviews.value = [];
auditCompareContractId.value = '';
await run(`audit-open-${contract.id}`, async () => {
await refreshAuditDetail(contract.id);
auditCompareContractId.value = auditVersionOptions.value[0]?.id ?? '';
});
}
function closeScriptAudit() {
auditContract.value = null;
auditReviews.value = [];
auditCompareContractId.value = '';
auditReviewerNotes.value = '';
}
async function refreshAuditDetail(contractId: string) {
const result = await client.getProductionContract(contractId);
auditContract.value = result.contract;
auditReviews.value = result.reviews;
}
async function runAuditHardGate() {
if (!auditContract.value) return;
const contractId = auditContract.value.id;
await run(`audit-hard-gate-${contractId}`, async () => {
await client.reviewProductionContract(contractId);
await load();
await refreshAuditDetail(contractId);
successMessage.value = '确定性结构硬门已重新检查。';
});
}
async function runAiScriptReview() {
if (!auditContract.value) return;
const contractId = auditContract.value.id;
const confirmed = window.confirm([
'确定调用文本模型进行 S+ 专业剧本审稿吗?',
'本次会产生一次真实 AI 调用和相应费用;AI 只审核,不会自动改写或确认剧本。'
].join('\n'));
if (!confirmed) return;
await run(`audit-ai-${contractId}`, async () => {
const notes = auditReviewerNotes.value.split('\n').map((item) => item.trim()).filter(Boolean);
const result = await client.aiReviewProductionSceneScript(contractId, { reviewer_notes: notes });
await refreshAuditDetail(contractId);
successMessage.value = result.report.splus_gate.passed
? `S+ AI 审稿通过:${result.report.overall_score} 分。`
: `S+ AI 审稿完成:${result.report.overall_score} 分,尚需修订。`;
});
}
async function confirmAuditedScript() {
if (!auditContract.value || !latestAuditAiReport.value?.splus_gate.passed) return;
const contractId = auditContract.value.id;
await run(`audit-confirm-${contractId}`, async () => {
await client.confirmProductionContract(contractId);
await load();
await refreshAuditDetail(contractId);
successMessage.value = 'S+ 场景剧本已确认,可以进入资产计划。';
emit('changed');
});
}
async function copyAuditScript() {
if (!auditContract.value) return;
await navigator.clipboard.writeText(scriptMarkdown(auditContract.value));
successMessage.value = '完整审核稿已复制。';
}
function downloadAuditScript() {
if (!auditContract.value) return;
const episode = Number(auditContract.value.payload_json.episode_number ?? 0);
downloadMarkdown(
`${safeFilename(props.project.title)}_第${episode}集_S+剧本_v${auditContract.value.version}.md`,
scriptMarkdown(auditContract.value, latestAuditAiReport.value)
);
}
function downloadConfirmedScripts() {
const scripts = contracts.value
.filter((item) => item.contract_type === 'scene_script' && item.status === 'confirmed')
.sort((left, right) => Number(left.payload_json.episode_number ?? 0) - Number(right.payload_json.episode_number ?? 0));
if (scripts.length === 0) {
errorMessage.value = '当前没有已确认的场景剧本。';
return;
}
const body = [
`# ${props.project.title} - S+完整剧本审核稿`,
'',
`- 导出时间:${new Date().toLocaleString('zh-CN')}`,
`- 共 ${scripts.length}`,
'',
...scripts.flatMap((contract) => [scriptMarkdown(contract), '', '---', ''])
].join('\n');
downloadMarkdown(`${safeFilename(props.project.title)}_S+完整剧本审核稿.md`, body);
successMessage.value = `已导出 ${scripts.length} 集已确认剧本。`;
}
function downloadMarkdown(filename: string, content: string) {
const blob = new Blob([`\uFEFF${content}`], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function safeFilename(value: string | null | undefined) {
return String(value ?? '').replace(/[\\/:*?"<>|]+/g, '_').trim() || '未命名项目';
}
function scriptMarkdown(contract: SafeProductionContract, report: SplusSceneScriptReviewReport | null = null) {
const payload = contract.payload_json;
const episode = Number(payload.episode_number ?? 0);
const scenes = Array.isArray(payload.scenes) ? payload.scenes.map(objectRecord) : [];
const lines = [
`# ${props.project.title} - 第${episode}集场景剧本`,
'',
`- 合同版本:v${contract.version}`,
`- 合同ID${contract.id}`,
`- 状态:${statusLabel(contract.status)}`,
`- 目标时长:${durationText(Number(payload.target_duration_ms ?? 0))}`,
`- 剧本总时长:${durationText(Number(payload.total_duration_estimate_ms ?? 0))}`,
`- 对白预算:${durationText(Number(payload.dialogue_duration_estimate_ms ?? 0))}`,
`- 结构硬门:${contract.quality_result_json?.hard_gate_status === 'pass' ? '通过' : '未通过'}`,
''
];
scenes.forEach((scene, index) => {
const actions = Array.isArray(scene.action_beats) ? scene.action_beats.map(objectRecord) : [];
const dialogues = Array.isArray(scene.dialogue_beats) ? scene.dialogue_beats.map(objectRecord) : [];
const objectives = Array.isArray(scene.character_objectives) ? scene.character_objectives.map(objectRecord) : [];
const tactics = Array.isArray(scene.tactics) ? scene.tactics.map(objectRecord) : [];
lines.push(
`## 场${index + 1}${String(scene.id ?? '')}`,
'',
`**地点/时间:** ${String(scene.location_asset_ref ?? '-')} / ${String(scene.time_of_day ?? '-')}`,
'',
`**时长:** ${durationText(Number(scene.estimated_duration_ms ?? 0))}`,
'',
`**出场人物:** ${Array.isArray(scene.active_characters) ? scene.active_characters.join('、') : '-'}`,
'',
`**场景目标:** ${String(scene.scene_goal ?? '')}`,
'',
`**阻力:** ${String(scene.obstacle ?? '')}`,
'',
`**潜台词:** ${String(scene.subtext ?? '')}`,
'',
`**场尾转折:** ${String(scene.turn ?? '')}`,
''
);
if (objectives.length > 0) {
lines.push('### 人物目标', '');
objectives.forEach((item) => lines.push(`- ${String(item.character_id ?? '')}${String(item.objective ?? '')}`));
lines.push('');
}
if (tactics.length > 0) {
lines.push('### 表演策略', '');
tactics.forEach((item) => lines.push(`- ${String(item.character_id ?? '')}${String(item.tactic ?? '')}`));
lines.push('');
}
if (actions.length > 0) {
lines.push('### 动作', '');
actions.forEach((item, actionIndex) => lines.push(
`${actionIndex + 1}. **${String(item.character_id ?? '')}** ${String(item.action ?? '')}`,
` - 触发:${String(item.trigger ?? '')}`,
` - 结果:${String(item.result ?? '')}`,
` - 时长:${durationText(Number(item.estimated_duration_ms ?? 0))}`
));
lines.push('');
}
if (dialogues.length > 0) {
lines.push('### 对白', '');
dialogues.forEach((item) => lines.push(
`**${String(item.character_id ?? '未知角色')}${durationText(Number(item.estimated_duration_ms ?? 0))}**`,
'',
`> ${String(item.line ?? '')}`,
'',
`意图:${String(item.intention ?? '')} `,
`潜台词:${String(item.subtext ?? '')} `,
`反应对象:${String(item.reaction_target ?? '-')}`,
''
));
}
});
if (report) {
lines.push(
'## S+ AI审稿结论',
'',
`- 综合分:${report.overall_score}/${report.splus_gate.threshold}`,
`- 结论:${report.splus_gate.passed ? 'S+通过' : '需要修订'}`,
`- 摘要:${report.reviewer_summary}`,
''
);
if (report.issues.length > 0) {
lines.push('### 审稿问题', '');
report.issues.forEach((issue) => lines.push(
`- [${issue.severity}] ${issue.field_path}${issue.message}`,
` - 证据:${issue.evidence}`,
` - 修复:${issue.repair_instruction}`
));
}
}
return lines.join('\n');
}
function compareScriptVersions(current: SafeProductionContract, previous: SafeProductionContract) {
const currentMetrics = scriptMetrics(current);
const previousMetrics = scriptMetrics(previous);
const changes = [
`对比 v${previous.version} → v${current.version}`,
`场景数:${previousMetrics.sceneCount}${currentMetrics.sceneCount}`,
`总时长:${durationText(previousMetrics.totalDuration)}${durationText(currentMetrics.totalDuration)}`,
`对白条数:${previousMetrics.dialogueCount}${currentMetrics.dialogueCount}`,
`对白预算:${durationText(previousMetrics.dialogueDuration)}${durationText(currentMetrics.dialogueDuration)}`
];
const currentScenes = new Map(currentMetrics.scenes.map((scene) => [scene.id, scene.signature]));
const previousScenes = new Map(previousMetrics.scenes.map((scene) => [scene.id, scene.signature]));
const added = [...currentScenes.keys()].filter((id) => !previousScenes.has(id));
const removed = [...previousScenes.keys()].filter((id) => !currentScenes.has(id));
const changed = [...currentScenes.keys()].filter((id) => previousScenes.has(id) && previousScenes.get(id) !== currentScenes.get(id));
if (added.length > 0) changes.push(`新增场景:${added.join('、')}`);
if (removed.length > 0) changes.push(`删除场景:${removed.join('、')}`);
if (changed.length > 0) changes.push(`内容有变化:${changed.join('、')}`);
if (added.length === 0 && removed.length === 0 && changed.length === 0) changes.push('场景正文没有变化。');
return changes;
}
function scriptMetrics(contract: SafeProductionContract) {
const payload = contract.payload_json;
const scenes = Array.isArray(payload.scenes) ? payload.scenes.map(objectRecord) : [];
return {
sceneCount: scenes.length,
totalDuration: Number(payload.total_duration_estimate_ms ?? 0),
dialogueDuration: Number(payload.dialogue_duration_estimate_ms ?? 0),
dialogueCount: scenes.reduce((sum, scene) => sum + (Array.isArray(scene.dialogue_beats) ? scene.dialogue_beats.length : 0), 0),
scenes: scenes.map((scene, index) => ({
id: String(scene.id ?? `scene-${index + 1}`),
signature: JSON.stringify(scene)
}))
};
}
async function run(key: string, action: () => Promise<void>) {
if (loading.value) return;
loading.value = key;
errorMessage.value = '';
successMessage.value = '';
try {
await action();
} catch (error) {
errorMessage.value = readableError(error);
} finally {
loading.value = '';
}
}
function qualityIssues(contract: SafeProductionContract): ProductionQualityIssue[] {
return contract.quality_result_json?.hard_gate_issues ?? [];
}
function contractLabel(type: SafeProductionContract['contract_type']) {
return type === 'source_analysis'
? '原著分析'
: type === 'adaptation_bible'
? '改编圣经'
: type === 'episode_plan'
? '分集计划'
: type === 'scene_script'
? '场景剧本'
: type === 'asset_plan' ? '资产需求计划' : '场景地理与调度';
}
function statusLabel(status: string) {
const labels: Record<string, string> = {
draft: '待修复', validated: '已过硬门', confirmed: '已确认', superseded: '旧版本', blocked: '未放行', eligible: '可进入剧本', released: '已放行'
};
return labels[status] ?? status;
}
function summaryLines(contract: SafeProductionContract) {
const payload = contract.payload_json;
if (contract.contract_type === 'source_analysis') {
return [String(payload.premise ?? '')];
}
if (contract.contract_type === 'adaptation_bible') {
return [String(payload.central_dramatic_question ?? ''), String(payload.main_arc ?? '')];
}
if (contract.contract_type === 'scene_script') {
const scenes = Array.isArray(payload.scenes) ? payload.scenes : [];
return [
`${String(payload.episode_number ?? '-')} 集 · ${scenes.length} 场 · ${durationText(Number(payload.total_duration_estimate_ms ?? 0))}`,
`对白预算:${durationText(Number(payload.dialogue_duration_estimate_ms ?? 0))}`,
...scenes.map((scene, index) => {
const item = objectRecord(scene);
return `${index + 1} · ${durationText(Number(item.estimated_duration_ms ?? 0))}${String(item.scene_goal ?? '')}`;
})
];
}
if (contract.contract_type === 'asset_plan') {
const requirements = Array.isArray(payload.requirements) ? payload.requirements.map(objectRecord) : [];
const createCount = requirements.filter((item) => item.reuse_decision === 'create').length;
const reuseCount = requirements.filter((item) => item.reuse_decision === 'reuse').length;
const blockerCount = Array.isArray(payload.blocking_requirement_refs) ? payload.blocking_requirement_refs.length : 0;
return [
`${String(payload.episode_number ?? '-')} 集 · ${requirements.length} 项资产需求`,
`新建 ${createCount} · 复用 ${reuseCount} · 付费拆镜前阻断 ${blockerCount}`,
`画幅 ${String(objectRecord(payload.format).orientation ?? '-')}`
];
}
if (contract.contract_type === 'scene_geography') {
const scenes = Array.isArray(payload.scene_geographies) ? payload.scene_geographies.map(objectRecord) : [];
const cameraCount = scenes.reduce((sum, scene) => sum + (Array.isArray(scene.camera_positions) ? scene.camera_positions.length : 0), 0);
const vectorCount = scenes.reduce((sum, scene) => sum + (Array.isArray(scene.action_vectors) ? scene.action_vectors.length : 0), 0);
return [
`${String(payload.episode_number ?? '-')} 集 · ${scenes.length} 场空间合同`,
`批准机位 ${cameraCount} · 动作向量 ${vectorCount}`,
'米制右手坐标 · 16:9 · 分镜前硬门'
];
}
const opening = payload.opening_hook as Record<string, unknown> | undefined;
const ending = payload.ending_hook as Record<string, unknown> | undefined;
return [
`目标:${String(payload.protagonist_goal ?? '')}`,
`阻力:${String(payload.obstacle ?? '')}`,
`开场:${String(opening?.viewer_question ?? '')}`,
`转折:${String(payload.midpoint_change ?? '')}`,
`不可逆变化:${String(payload.irreversible_change ?? '')}`,
`集尾:${String(ending?.next_episode_question ?? '')}`
];
}
function objectRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function sceneRows(contract: SafeProductionContract) {
if (contract.contract_type !== 'scene_script' || !Array.isArray(contract.payload_json.scenes)) return [];
return contract.payload_json.scenes.map((scene, index) => {
const item = objectRecord(scene);
const actionBeats = Array.isArray(item.action_beats) ? item.action_beats.map(objectRecord) : [];
const dialogueBeats = Array.isArray(item.dialogue_beats) ? item.dialogue_beats.map(objectRecord) : [];
const objectives = Array.isArray(item.character_objectives) ? item.character_objectives.map(objectRecord) : [];
const tactics = Array.isArray(item.tactics) ? item.tactics.map(objectRecord) : [];
return {
id: String(item.id ?? `scene-${index + 1}`),
number: index + 1,
location: String(item.location_asset_ref ?? '-'),
time: String(item.time_of_day ?? '-'),
duration: durationText(Number(item.estimated_duration_ms ?? 0)),
goal: String(item.scene_goal ?? ''),
obstacle: String(item.obstacle ?? ''),
subtext: String(item.subtext ?? ''),
turn: String(item.turn ?? ''),
characters: Array.isArray(item.active_characters) ? item.active_characters.map(String) : [],
objectives: objectives.map((entry) => `${String(entry.character_id ?? '')}${String(entry.objective ?? '')}`),
tactics: tactics.map((entry) => `${String(entry.character_id ?? '')}${String(entry.tactic ?? '')}`),
actions: actionBeats.map((beat) => String(beat.action ?? '')).filter(Boolean),
actionDetails: actionBeats.map((beat) => ({
id: String(beat.id ?? ''),
character: String(beat.character_id ?? ''),
action: String(beat.action ?? ''),
trigger: String(beat.trigger ?? ''),
result: String(beat.result ?? ''),
duration: durationText(Number(beat.estimated_duration_ms ?? 0))
})),
dialogues: dialogueBeats.map((beat) => ({
id: String(beat.id ?? ''),
speaker: String(beat.character_id ?? '未知角色'),
line: String(beat.line ?? ''),
intention: String(beat.intention ?? ''),
subtext: String(beat.subtext ?? ''),
reactionTarget: String(beat.reaction_target ?? '-'),
duration: durationText(Number(beat.estimated_duration_ms ?? 0))
})).filter((beat) => beat.line)
};
});
}
function assetRequirementRows(contract: SafeProductionContract) {
if (contract.contract_type !== 'asset_plan' || !Array.isArray(contract.payload_json.requirements)) return [];
return contract.payload_json.requirements.map((requirement) => {
const item = objectRecord(requirement);
return {
id: String(item.id ?? ''),
kind: String(item.kind ?? ''),
name: String(item.name ?? ''),
decision: String(item.reuse_decision ?? ''),
priority: String(item.priority ?? ''),
scenes: Array.isArray(item.applies_to_scene_ids) ? item.applies_to_scene_ids.map(String) : [],
brief: String(item.visual_brief ?? ''),
locks: Array.isArray(item.continuity_locks) ? item.continuity_locks.map(String) : [],
deliverables: Array.isArray(item.deliverables) ? item.deliverables.map(String) : [],
acceptance: Array.isArray(item.acceptance_criteria) ? item.acceptance_criteria.map(String) : [],
candidates: Array.isArray(item.candidate_refs) ? item.candidate_refs.map((candidate) => {
const ref = objectRecord(candidate);
return `${String(ref.entity_type ?? '')}:${String(ref.id ?? '')} · ${String(ref.reason ?? '')}`;
}) : []
};
});
}
function geographyRows(contract: SafeProductionContract) {
if (contract.contract_type !== 'scene_geography' || !Array.isArray(contract.payload_json.scene_geographies)) return [];
return contract.payload_json.scene_geographies.map((scene) => {
const item = objectRecord(scene);
const axis = objectRecord(item.primary_axis);
const zones = Array.isArray(item.zones) ? item.zones.map(objectRecord) : [];
const placements = Array.isArray(item.placements) ? item.placements.map(objectRecord) : [];
const cameras = Array.isArray(item.camera_positions) ? item.camera_positions.map(objectRecord) : [];
const vectors = Array.isArray(item.action_vectors) ? item.action_vectors.map(objectRecord) : [];
return {
id: String(item.scene_id ?? ''),
location: String(item.location_requirement_ref ?? ''),
layout: String(item.world_layout ?? ''),
axis: `${String(axis.id ?? '')}${String(axis.screen_left_ref ?? '')} 左 / ${String(axis.screen_right_ref ?? '')}`,
crossing: String(axis.crossing_policy ?? ''),
zones: zones.map((zone) => `${String(zone.id ?? '')} · ${String(zone.name ?? '')} · ${String(zone.bounds ?? '')}`),
placements: placements.map((placement) => `${String(placement.subject_ref ?? '')}${String(placement.zone_id ?? '')} · 画面${String(placement.screen_side ?? '')} · ${String(placement.vertical_relation ?? '')}`),
cameras: cameras.map((camera) => `${String(camera.id ?? '')} · ${camera.allowed ? '允许' : '禁用'} · ${String(camera.purpose ?? '')}`),
vectors: vectors.map((vector) => `${String(vector.id ?? '')}${String(vector.source_ref ?? '')}${String(vector.target_ref ?? '')} · ${String(vector.screen_direction ?? '')}`),
forbidden: Array.isArray(item.forbidden_outcomes) ? item.forbidden_outcomes.map(String) : [],
acceptance: Array.isArray(item.acceptance_criteria) ? item.acceptance_criteria.map(String) : []
};
});
}
function durationText(milliseconds: number) {
if (!Number.isFinite(milliseconds) || milliseconds <= 0) return '0秒';
const seconds = milliseconds / 1000;
return `${Number.isInteger(seconds) ? seconds : seconds.toFixed(1)}`;
}
function readableError(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
</script>
<template>
<section class="splus-pipeline" data-studio-step="story">
<header class="pipeline-head">
<div>
<span class="pipeline-kicker">S+ Structured Pipeline</span>
<h2>原著分析 改编圣经 分集计划 场景剧本 资产计划 场景地理 镜头执行</h2>
<p>合同版本是唯一事实源未通过硬门或未确认的上游不能进入下一阶段</p>
</div>
<div class="engine-state">
<strong>{{ project.engine_version }}</strong>
<span>{{ project.production_lifecycle }}</span>
</div>
</header>
<div v-if="errorMessage" class="pipeline-alert danger">{{ errorMessage }}</div>
<div v-if="successMessage" class="pipeline-alert success">{{ successMessage }}</div>
<div class="review-export-row">
<div>
<strong>S+ 剧本审核稿</strong>
<span>完整场景剧本以已确认 scene_script 合同为准</span>
</div>
<button type="button" class="pipeline-button" :disabled="Boolean(loading)" @click="downloadConfirmedScripts">
导出全部已确认剧本
</button>
</div>
<div class="snapshot-row">
<div>
<span>不可变原文快照</span>
<strong v-if="latestSnapshot">v{{ latestSnapshot.snapshot_version }} · {{ latestSnapshot.character_count }} </strong>
<strong v-else>尚未创建</strong>
<small v-if="latestSnapshot">SHA-256 {{ latestSnapshot.content_hash.slice(0, 16) }}</small>
</div>
<button type="button" class="pipeline-button primary" :disabled="Boolean(loading)" @click="createSnapshot">
{{ latestSnapshot ? '检查并创建新快照' : '创建原文快照' }}
</button>
</div>
<div class="stage-switch" role="tablist">
<button type="button" :class="{ active: selectedStage === 'source' }" @click="selectedStage = 'source'">1 原著分析</button>
<button type="button" :class="{ active: selectedStage === 'adaptation' }" @click="selectedStage = 'adaptation'">2 改编圣经</button>
<button type="button" :class="{ active: selectedStage === 'episode' }" @click="selectedStage = 'episode'">3 分集计划</button>
<button type="button" :class="{ active: selectedStage === 'script' }" @click="selectedStage = 'script'">4 场景剧本</button>
<button type="button" :class="{ active: selectedStage === 'assets' }" @click="selectedStage = 'assets'">5 资产计划</button>
<button type="button" :class="{ active: selectedStage === 'geography' }" @click="selectedStage = 'geography'">6 场景地理</button>
</div>
<div class="stage-workspace">
<div class="stage-toolbar">
<label v-if="selectedStage === 'episode' || selectedStage === 'script' || selectedStage === 'assets' || selectedStage === 'geography'">
<span>集号</span>
<input v-model.number="episodeNumber" type="number" min="1" max="999">
</label>
<button type="button" class="pipeline-button" :disabled="Boolean(loading) || !canPreview" @click="previewPrompt">生成阶段 Prompt</button>
<button type="button" class="pipeline-icon-button" title="复制 Prompt" :disabled="!promptPreview" @click="copyPrompt">复制</button>
</div>
<p v-if="!canPreview" class="stage-blocked">请先完成并确认上游合同</p>
<textarea v-if="promptPreview" v-model="promptPreview.prompt" class="prompt-output" readonly rows="9"></textarea>
<label class="json-import">
<span>导入结构化 JSON</span>
<textarea v-model="importedJson" rows="12" placeholder="粘贴模型输出的单个 JSON 对象"></textarea>
</label>
<button type="button" class="pipeline-button primary" :disabled="Boolean(loading) || !importedJson.trim()" @click="saveImportedContract">
校验并保存新版本
</button>
</div>
<section v-if="selectedConfirmedSceneGeography" class="flow-test-panel">
<div>
<span class="pipeline-kicker">Step 7 · Execution Dry Run</span>
<h3>编译流程联调 Generation Plan</h3>
<p>读取已确认场景地理已导入资产和镜头执行草案只冻结可审计请求快照越轴站位或动作向量冲突会在付费生成前阻断</p>
</div>
<button
type="button"
class="pipeline-button primary"
:disabled="Boolean(loading)"
@click="compileFlowTestPlans"
>{{ project.production_lifecycle === 'flow_test_generation_plans_frozen' ? '重新编译联调修订版' : '编译联调计划' }}</button>
<div v-if="flowTestCompileResult" class="flow-test-result">
<div><span>不可变计划</span><strong>{{ flowTestCompileResult.generation_plan_count }} </strong></div>
<div><span>角色母版</span><strong>{{ flowTestCompileResult.character_identity_bindings.filter((item) => item.asset_id).length }}/{{ flowTestCompileResult.character_identity_bindings.length }}</strong></div>
<div><span>正式阻断项</span><strong>{{ flowTestCompileResult.missing_requirements.length }}</strong></div>
<div><span>付费调用</span><strong>0</strong></div>
<details>
<summary>查看绑定与缺失项</summary>
<p v-for="binding in flowTestCompileResult.character_identity_bindings" :key="binding.requirement_id">
{{ binding.requirement_name }}{{ binding.asset_id ? `素材 #${binding.asset_id}` : '未绑定' }}
</p>
<p v-for="item in flowTestCompileResult.missing_requirements" :key="String(item.requirement_id)">
{{ String(item.requirement_id ?? '') }}{{ String(item.label ?? '') }}
</p>
</details>
</div>
</section>
<div class="contract-list">
<article v-for="contract in contracts" :key="contract.id" class="contract-row">
<div class="contract-meta">
<span>{{ contractLabel(contract.contract_type) }} · v{{ contract.version }}</span>
<strong>{{ statusLabel(contract.status) }} · {{ statusLabel(contract.downstream_status) }}</strong>
<small>{{ contract.scope_key }} · 质量 {{ contract.quality_result_json?.weighted_score ?? '-' }}</small>
</div>
<div class="contract-summary">
<p v-for="line in summaryLines(contract)" :key="line">{{ line }}</p>
<details v-if="contract.contract_type === 'scene_script'" class="scene-script-detail">
<summary>展开完整场景剧本</summary>
<section v-for="scene in sceneRows(contract)" :key="scene.id" class="scene-script-section">
<header>
<strong> {{ scene.number }} · {{ scene.duration }}</strong>
<span>{{ scene.time }} · {{ scene.location }}</span>
</header>
<dl>
<dt>目标</dt><dd>{{ scene.goal }}</dd>
<dt>阻力</dt><dd>{{ scene.obstacle }}</dd>
<dt>转折</dt><dd>{{ scene.turn }}</dd>
</dl>
<div v-if="scene.actions.length" class="scene-beats">
<strong>动作</strong>
<ol><li v-for="action in scene.actions" :key="action">{{ action }}</li></ol>
</div>
<div v-if="scene.dialogues.length" class="scene-dialogue">
<strong>对白</strong>
<blockquote v-for="dialogue in scene.dialogues" :key="dialogue.id">
<span>{{ dialogue.speaker }} · {{ dialogue.duration }}</span>
<p>{{ dialogue.line }}</p>
<small>意图{{ dialogue.intention }}</small>
</blockquote>
</div>
</section>
</details>
<details v-if="contract.contract_type === 'asset_plan'" class="scene-script-detail asset-plan-detail">
<summary>展开完整资产需求</summary>
<section v-for="requirement in assetRequirementRows(contract)" :key="requirement.id" class="scene-script-section asset-requirement-section">
<header>
<strong>{{ requirement.name }}</strong>
<span>{{ requirement.kind }} · {{ requirement.decision }} · {{ requirement.priority }}</span>
</header>
<p>{{ requirement.brief }}</p>
<small>场景{{ requirement.scenes.join('、') || '未绑定' }}</small>
<div class="asset-columns">
<div><strong>连续性锁</strong><ul><li v-for="item in requirement.locks" :key="item">{{ item }}</li></ul></div>
<div><strong>交付物</strong><ul><li v-for="item in requirement.deliverables" :key="item">{{ item }}</li></ul></div>
<div><strong>验收</strong><ul><li v-for="item in requirement.acceptance" :key="item">{{ item }}</li></ul></div>
</div>
<div v-if="requirement.candidates.length" class="asset-candidates">
<strong>仅供人工比对的历史候选</strong>
<p v-for="candidate in requirement.candidates" :key="candidate">{{ candidate }}</p>
</div>
</section>
</details>
<details v-if="contract.contract_type === 'scene_geography'" class="scene-script-detail asset-plan-detail">
<summary>展开场景地理与人物调度</summary>
<section v-for="scene in geographyRows(contract)" :key="scene.id" class="scene-script-section asset-requirement-section">
<header>
<strong>{{ scene.id }}</strong>
<span>{{ scene.location }}</span>
</header>
<p>{{ scene.layout }}</p>
<dl>
<dt>主轴</dt><dd>{{ scene.axis }}</dd>
<dt>越轴政策</dt><dd>{{ scene.crossing }}</dd>
</dl>
<div class="asset-columns">
<div><strong>空间区</strong><ul><li v-for="item in scene.zones" :key="item">{{ item }}</li></ul></div>
<div><strong>主体站位</strong><ul><li v-for="item in scene.placements" :key="item">{{ item }}</li></ul></div>
<div><strong>批准机位</strong><ul><li v-for="item in scene.cameras" :key="item">{{ item }}</li></ul></div>
<div><strong>动作向量</strong><ul><li v-for="item in scene.vectors" :key="item">{{ item }}</li></ul></div>
<div><strong>禁止结果</strong><ul><li v-for="item in scene.forbidden" :key="item">{{ item }}</li></ul></div>
<div><strong>验收条件</strong><ul><li v-for="item in scene.acceptance" :key="item">{{ item }}</li></ul></div>
</div>
</section>
</details>
<ul v-if="qualityIssues(contract).length">
<li v-for="issue in qualityIssues(contract)" :key="`${issue.code}-${issue.field_path}`">
{{ issue.field_path }}{{ issue.message }}
</li>
</ul>
</div>
<div class="contract-actions">
<button
v-if="contract.contract_type === 'scene_script'"
type="button"
class="pipeline-button primary"
:disabled="Boolean(loading)"
@click="openScriptAudit(contract)"
>打开S+审核台</button>
<button type="button" class="pipeline-button" :disabled="Boolean(loading)" @click="review(contract)">结构审核</button>
<button v-if="contract.status === 'validated' && contract.contract_type !== 'scene_script'" type="button" class="pipeline-button primary" :disabled="Boolean(loading)" @click="confirm(contract)">确认</button>
<button
v-if="contract.contract_type === 'episode_plan' && contract.status === 'confirmed' && contract.downstream_status === 'eligible'"
type="button"
class="pipeline-button primary"
:disabled="Boolean(loading)"
@click="release(contract)"
>批准进剧本</button>
</div>
</article>
<div v-if="contracts.length === 0" class="pipeline-empty">暂无结构化合同</div>
</div>
<footer class="continuity-bar">
<div>
<strong>连续三集状态检查</strong>
<span>{{ continuityBatch.length }}/3 集已确认</span>
</div>
<button type="button" class="pipeline-button primary" :disabled="Boolean(loading) || continuityBatch.length !== 3" @click="checkContinuity">
检查连续性
</button>
</footer>
<div v-if="auditContract" class="script-audit-overlay" @click.self="closeScriptAudit">
<section class="script-audit-workspace" role="dialog" aria-modal="true" aria-label="S+剧本审核台">
<header class="script-audit-head">
<div>
<span class="pipeline-kicker">S+ Script Review</span>
<h2>{{ project.title }} · {{ auditContract.payload_json.episode_number }}</h2>
<p>场景剧本 v{{ auditContract.version }} · 合同 {{ auditContract.id }} · {{ statusLabel(auditContract.status) }}</p>
</div>
<button type="button" class="audit-close-button" title="关闭审核台" @click="closeScriptAudit">×</button>
</header>
<div class="audit-toolbar">
<button type="button" class="pipeline-button" :disabled="Boolean(loading)" @click="copyAuditScript">复制审核稿</button>
<button type="button" class="pipeline-button" :disabled="Boolean(loading)" @click="downloadAuditScript">下载 Markdown</button>
<button type="button" class="pipeline-button" :disabled="Boolean(loading)" @click="runAuditHardGate">重新检查结构</button>
<button
type="button"
class="pipeline-button primary"
:disabled="Boolean(loading) || auditContract.quality_result_json?.hard_gate_status !== 'pass'"
@click="runAiScriptReview"
>运行S+ AI审稿</button>
<button
v-if="auditContract.status === 'validated'"
type="button"
class="pipeline-button approve"
:disabled="Boolean(loading) || !latestAuditAiReport?.splus_gate.passed"
@click="confirmAuditedScript"
>确认S+剧本</button>
</div>
<div class="audit-summary-strip">
<div><span>目标时长</span><strong>{{ durationText(Number(auditContract.payload_json.target_duration_ms ?? 0)) }}</strong></div>
<div><span>对白预算</span><strong>{{ durationText(Number(auditContract.payload_json.dialogue_duration_estimate_ms ?? 0)) }}</strong></div>
<div><span>结构硬门</span><strong>{{ auditContract.quality_result_json?.hard_gate_status === 'pass' ? '通过' : '阻断' }}</strong></div>
<div><span>S+终审</span><strong>{{ latestAuditAiReport ? `${latestAuditAiReport.overall_score}` : '未审' }}</strong></div>
</div>
<div class="audit-layout">
<aside class="audit-review-panel">
<section>
<h3>专业审稿要求</h3>
<textarea
v-model="auditReviewerNotes"
rows="4"
placeholder="可选:每行一条本次特别关注的问题,例如对白是否像人物本人。"
></textarea>
<small>AI审稿会产生真实文本模型费用只审核不会自动改写</small>
</section>
<section>
<h3>版本对比</h3>
<select v-model="auditCompareContractId">
<option value="">没有可对比版本</option>
<option v-for="version in auditVersionOptions" :key="version.id" :value="version.id">
v{{ version.version }} · {{ statusLabel(version.status) }}
</option>
</select>
<ul v-if="auditVersionComparison.length" class="audit-compact-list">
<li v-for="line in auditVersionComparison" :key="line">{{ line }}</li>
</ul>
</section>
<section>
<h3>结构硬门</h3>
<p v-if="auditContract.quality_result_json?.hard_gate_status === 'pass'" class="audit-pass-text">
结构来源时长和连续性检查通过
</p>
<ul v-else class="audit-issue-list">
<li v-for="issue in qualityIssues(auditContract)" :key="`${issue.code}-${issue.field_path}`">
<strong>{{ issue.field_path }}</strong>
<span>{{ issue.message }}</span>
</li>
</ul>
</section>
<section v-if="latestAuditAiReport" class="ai-review-result">
<div class="ai-review-verdict" :class="{ pass: latestAuditAiReport.splus_gate.passed }">
<strong>{{ latestAuditAiReport.overall_score }}</strong>
<span>/ {{ latestAuditAiReport.splus_gate.threshold }} · {{ latestAuditAiReport.splus_gate.passed ? 'S+通过' : '需要修订' }}</span>
</div>
<p>{{ latestAuditAiReport.reviewer_summary }}</p>
<h3>十维评分</h3>
<dl class="dimension-score-list">
<template v-for="(score, dimension) in latestAuditAiReport.dimension_scores" :key="dimension">
<dt>{{ REVIEW_DIMENSION_LABELS[String(dimension)] ?? dimension }}</dt>
<dd><span :style="{ width: `${score}%` }"></span><strong>{{ score }}</strong></dd>
</template>
</dl>
<h3 v-if="latestAuditAiReport.strengths.length">成立之处</h3>
<ul v-if="latestAuditAiReport.strengths.length" class="audit-compact-list strengths">
<li v-for="item in latestAuditAiReport.strengths" :key="item">{{ item }}</li>
</ul>
<h3 v-if="latestAuditAiReport.issues.length">阻断与修订</h3>
<article v-for="issue in latestAuditAiReport.issues" :key="`${issue.code}-${issue.field_path}`" class="ai-review-issue">
<header><strong>{{ issue.severity }}</strong><span>{{ issue.field_path }}</span></header>
<p>{{ issue.message }}</p>
<small>证据{{ issue.evidence }}</small>
<small>修复{{ issue.repair_instruction }}</small>
</article>
<h3 v-if="latestAuditAiReport.splus_gate.blocking_reasons.length">未放行原因</h3>
<ul v-if="latestAuditAiReport.splus_gate.blocking_reasons.length" class="audit-compact-list">
<li v-for="reason in latestAuditAiReport.splus_gate.blocking_reasons" :key="reason">{{ reason }}</li>
</ul>
<small class="audit-history-note">已保留 {{ auditAiReviews.length }} AI 审稿记录</small>
</section>
<section v-else class="audit-empty-review">
<h3>S+ AI终审</h3>
<p>尚未运行专业审稿结构分不等于艺术质量分</p>
</section>
</aside>
<main class="audit-script-document">
<header>
<span>FINAL SCENE SCRIPT</span>
<h1>{{ auditContract.payload_json.episode_number }}集完整场景剧本</h1>
<p>所有动作对白和时长均读取合同 v{{ auditContract.version }}不是二次摘要</p>
</header>
<article v-for="scene in sceneRows(auditContract)" :key="scene.id" class="audit-scene">
<header>
<div><span> {{ scene.number }}</span><h2>{{ scene.time }} · {{ scene.location }}</h2></div>
<strong>{{ scene.duration }}</strong>
</header>
<p class="audit-cast">出场{{ scene.characters.join('、') || '无' }}</p>
<dl class="audit-scene-contract">
<dt>目标</dt><dd>{{ scene.goal }}</dd>
<dt>阻力</dt><dd>{{ scene.obstacle }}</dd>
<dt>潜台词</dt><dd>{{ scene.subtext }}</dd>
<dt>转折</dt><dd>{{ scene.turn }}</dd>
</dl>
<div v-if="scene.objectives.length || scene.tactics.length" class="audit-performance-grid">
<div><strong>人物目标</strong><p v-for="item in scene.objectives" :key="item">{{ item }}</p></div>
<div><strong>表演策略</strong><p v-for="item in scene.tactics" :key="item">{{ item }}</p></div>
</div>
<section v-if="scene.actionDetails.length" class="audit-action-section">
<h3>动作</h3>
<ol>
<li v-for="action in scene.actionDetails" :key="action.id">
<header><strong>{{ action.character }}</strong><span>{{ action.duration }}</span></header>
<p>{{ action.action }}</p>
<small>触发{{ action.trigger }}</small>
<small>结果{{ action.result }}</small>
</li>
</ol>
</section>
<section v-if="scene.dialogues.length" class="audit-dialogue-section">
<h3>对白</h3>
<blockquote v-for="dialogue in scene.dialogues" :key="dialogue.id">
<header><strong>{{ dialogue.speaker }}</strong><span>{{ dialogue.duration }}</span></header>
<p>{{ dialogue.line }}</p>
<footer>
<span>意图{{ dialogue.intention }}</span>
<span>潜台词{{ dialogue.subtext }}</span>
<span>反应对象{{ dialogue.reactionTarget }}</span>
</footer>
</blockquote>
</section>
</article>
</main>
</div>
</section>
</div>
</section>
</template>
<style scoped>
.splus-pipeline {
width: 100%;
padding: 24px;
border: 1px solid #d9e0e4;
border-radius: 8px;
background: #f7f9fa;
color: #182026;
}
.pipeline-head,
.snapshot-row,
.continuity-bar,
.stage-toolbar,
.contract-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.pipeline-head { align-items: flex-start; border-bottom: 1px solid #d9e0e4; padding-bottom: 18px; }
.pipeline-head h2 { margin: 4px 0 6px; font-size: 22px; letter-spacing: 0; }
.pipeline-head p { margin: 0; color: #59666e; }
.pipeline-kicker { color: #007d72; font-size: 12px; font-weight: 800; text-transform: uppercase; }
.engine-state { min-width: 150px; text-align: right; }
.engine-state strong, .engine-state span { display: block; }
.engine-state span { margin-top: 4px; color: #66747c; font-size: 12px; }
.pipeline-alert { margin-top: 14px; padding: 10px 12px; border-left: 3px solid; background: #fff; }
.pipeline-alert.danger { border-color: #b42318; color: #8a1c13; }
.pipeline-alert.success { border-color: #087a55; color: #075c41; }
.review-export-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 0; border-bottom: 1px solid #d9e0e4; }
.review-export-row strong, .review-export-row span { display: block; }
.review-export-row span { margin-top: 3px; color: #66747c; font-size: 12px; }
.snapshot-row { padding: 18px 0; border-bottom: 1px solid #d9e0e4; }
.snapshot-row div span, .snapshot-row div strong, .snapshot-row div small { display: block; }
.snapshot-row div span, .snapshot-row div small { color: #66747c; font-size: 12px; }
.snapshot-row div strong { margin: 4px 0; }
.stage-switch { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 2px; margin-top: 18px; border-bottom: 1px solid #bfc9ce; }
.stage-switch button { padding: 11px; border: 0; background: transparent; color: #5b6870; font-weight: 700; cursor: pointer; }
.stage-switch button.active { color: #005c55; box-shadow: inset 0 -3px #00a695; }
.stage-workspace { padding: 18px 0 22px; border-bottom: 1px solid #d9e0e4; }
.flow-test-panel { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 16px; align-items: start; padding: 20px 0; border-bottom: 1px solid #d9e0e4; }
.flow-test-panel h3 { margin: 4px 0 6px; font-size: 18px; letter-spacing: 0; }
.flow-test-panel p { margin: 0; color: #59666e; line-height: 1.5; }
.flow-test-result { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid #c8d3d7; background: #fff; }
.flow-test-result > div { padding: 12px; border-right: 1px solid #d9e0e4; }
.flow-test-result > div:last-of-type { border-right: 0; }
.flow-test-result span, .flow-test-result strong { display: block; }
.flow-test-result span { color: #66747c; font-size: 12px; }
.flow-test-result strong { margin-top: 4px; font-size: 18px; }
.flow-test-result details { grid-column: 1 / -1; padding: 12px; border-top: 1px solid #d9e0e4; }
.flow-test-result summary { color: #006b62; font-weight: 800; cursor: pointer; }
.flow-test-result details p { margin-top: 7px; font-size: 12px; }
.stage-toolbar { justify-content: flex-start; }
.stage-toolbar label { display: flex; align-items: center; gap: 8px; }
.stage-toolbar input { width: 72px; padding: 8px; border: 1px solid #b9c5ca; border-radius: 4px; }
.pipeline-button, .pipeline-icon-button { min-height: 36px; padding: 8px 13px; border: 1px solid #9daab0; border-radius: 4px; background: #fff; color: #1d2930; font-weight: 700; cursor: pointer; }
.pipeline-button.primary { border-color: #087a55; background: #087a55; color: #fff; }
.pipeline-button.approve { border-color: #9a5d08; background: #9a5d08; color: #fff; }
.pipeline-button:disabled, .pipeline-icon-button:disabled { opacity: .45; cursor: not-allowed; }
.stage-blocked { color: #9a3412; font-size: 13px; }
.prompt-output, .json-import textarea { width: 100%; box-sizing: border-box; margin-top: 12px; padding: 12px; border: 1px solid #b9c5ca; border-radius: 4px; background: #fff; color: #25323a; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; resize: vertical; }
.json-import { display: block; margin: 16px 0 10px; font-weight: 700; }
.json-import span { display: block; }
.contract-list { padding-top: 8px; }
.contract-row { align-items: flex-start; padding: 16px 0; border-bottom: 1px solid #d9e0e4; }
.contract-meta { width: 190px; flex: 0 0 190px; }
.contract-meta span, .contract-meta strong, .contract-meta small { display: block; }
.contract-meta strong { margin: 5px 0; color: #005c55; }
.contract-meta small { color: #66747c; }
.contract-summary { min-width: 0; flex: 1; }
.contract-summary p { margin: 0 0 5px; line-height: 1.45; }
.contract-summary ul { margin: 8px 0 0; padding-left: 18px; color: #9a3412; }
.scene-script-detail { margin-top: 12px; border-top: 1px solid #cbd5d9; }
.scene-script-detail summary { padding: 11px 0; color: #006b62; font-weight: 800; cursor: pointer; }
.scene-script-section { padding: 14px 0; border-top: 1px solid #d9e0e4; }
.scene-script-section header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.scene-script-section header span { color: #66747c; font-size: 12px; text-align: right; overflow-wrap: anywhere; }
.scene-script-section dl { display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 6px 10px; margin: 0; }
.asset-requirement-section > p { margin: 0 0 8px; line-height: 1.55; }
.asset-requirement-section > small { color: #66747c; }
.asset-columns { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 12px; }
.asset-columns strong { color: #314047; font-size: 13px; }
.asset-columns ul { margin: 6px 0 0; padding-left: 18px; color: #526169; }
.asset-candidates { margin-top: 12px; padding: 10px 12px; border-left: 3px solid #c47b16; background: #fff8eb; }
.asset-candidates p { margin: 5px 0 0; color: #74460a; font-size: 12px; }
.scene-script-section dt { color: #66747c; font-size: 12px; font-weight: 800; }
.scene-script-section dd { margin: 0; line-height: 1.55; }
.scene-beats, .scene-dialogue { margin-top: 12px; }
.scene-beats ol { margin: 7px 0 0; padding-left: 20px; color: #34434b; }
.scene-beats li { margin-bottom: 5px; line-height: 1.5; }
.scene-dialogue blockquote { margin: 8px 0 0; padding: 9px 12px; border-left: 3px solid #00a695; background: #eef5f4; }
.scene-dialogue blockquote span, .scene-dialogue blockquote small { display: block; color: #5d6a71; font-size: 12px; }
.scene-dialogue blockquote p { margin: 4px 0; color: #17242a; font-weight: 700; }
.contract-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px; width: 210px; }
.continuity-bar { padding-top: 18px; }
.continuity-bar div strong, .continuity-bar div span { display: block; }
.continuity-bar div span { color: #66747c; font-size: 12px; margin-top: 3px; }
.pipeline-empty { padding: 24px 0; text-align: center; color: #66747c; }
.script-audit-overlay { position: fixed; inset: 0; z-index: 1200; padding: 22px; background: rgb(11 18 22 / 68%); overflow: auto; }
.script-audit-workspace { width: min(1480px, 100%); min-height: calc(100vh - 44px); margin: 0 auto; border: 1px solid #b8c3c8; border-radius: 8px; background: #f4f6f7; box-shadow: 0 18px 55px rgb(0 0 0 / 25%); overflow: hidden; }
.script-audit-head { position: sticky; top: 0; z-index: 4; display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; padding: 20px 24px; border-bottom: 1px solid #b8c3c8; background: #15252b; color: #fff; }
.script-audit-head h2 { margin: 4px 0 3px; font-size: 22px; letter-spacing: 0; }
.script-audit-head p { margin: 0; color: #c9d3d7; }
.audit-close-button { width: 40px; height: 40px; flex: 0 0 40px; border: 1px solid #708087; border-radius: 4px; background: transparent; color: #fff; font-size: 28px; line-height: 1; cursor: pointer; }
.audit-toolbar { display: flex; flex-wrap: wrap; gap: 8px; padding: 14px 24px; border-bottom: 1px solid #cad2d6; background: #fff; }
.audit-summary-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-bottom: 1px solid #cad2d6; background: #edf1f2; }
.audit-summary-strip div { min-width: 0; padding: 12px 24px; border-right: 1px solid #cad2d6; }
.audit-summary-strip div:last-child { border-right: 0; }
.audit-summary-strip span, .audit-summary-strip strong { display: block; }
.audit-summary-strip span { color: #69767d; font-size: 12px; }
.audit-summary-strip strong { margin-top: 4px; font-size: 18px; }
.audit-layout { display: grid; grid-template-columns: minmax(300px, 390px) minmax(0, 1fr); align-items: start; }
.audit-review-panel { position: sticky; top: 91px; max-height: calc(100vh - 113px); padding: 20px; border-right: 1px solid #cad2d6; overflow: auto; }
.audit-review-panel > section { padding: 15px 0; border-bottom: 1px solid #d5dcdf; }
.audit-review-panel > section:first-child { padding-top: 0; }
.audit-review-panel h3 { margin: 0 0 9px; font-size: 14px; letter-spacing: 0; }
.audit-review-panel textarea, .audit-review-panel select { width: 100%; box-sizing: border-box; padding: 9px 10px; border: 1px solid #aebbc1; border-radius: 4px; background: #fff; color: #1f2b31; }
.audit-review-panel small { display: block; margin-top: 7px; color: #69767d; line-height: 1.45; }
.audit-compact-list { margin: 9px 0 0; padding-left: 18px; color: #4d5a61; }
.audit-compact-list li { margin-bottom: 6px; line-height: 1.45; }
.audit-compact-list.strengths { color: #116149; }
.audit-pass-text { margin: 0; color: #087a55; font-weight: 700; }
.audit-issue-list { margin: 0; padding: 0; list-style: none; }
.audit-issue-list li { padding: 8px 0; border-bottom: 1px solid #e0e5e7; }
.audit-issue-list strong, .audit-issue-list span { display: block; }
.audit-issue-list span { margin-top: 3px; color: #8a1c13; }
.ai-review-verdict { display: flex; align-items: baseline; gap: 6px; padding: 12px; border-left: 4px solid #c14b32; background: #fff1ed; }
.ai-review-verdict.pass { border-color: #087a55; background: #edf8f3; }
.ai-review-verdict strong { font-size: 34px; line-height: 1; }
.ai-review-result > p { line-height: 1.55; }
.dimension-score-list { display: grid; grid-template-columns: 120px minmax(0, 1fr); align-items: center; gap: 7px 10px; margin: 0; }
.dimension-score-list dt { color: #516067; font-size: 12px; }
.dimension-score-list dd { position: relative; height: 18px; margin: 0; background: #dbe2e5; overflow: hidden; }
.dimension-score-list dd span { display: block; height: 100%; background: #177f75; }
.dimension-score-list dd strong { position: absolute; inset: 0 5px 0 auto; line-height: 18px; color: #111b20; font-size: 11px; }
.ai-review-issue { margin-top: 9px; padding: 10px 12px; border: 1px solid #e0b7ad; border-radius: 4px; background: #fff8f6; }
.ai-review-issue header { display: flex; justify-content: space-between; gap: 8px; }
.ai-review-issue header strong { color: #a12c1a; text-transform: uppercase; }
.ai-review-issue header span { color: #69767d; font-size: 11px; text-align: right; overflow-wrap: anywhere; }
.ai-review-issue p { margin: 7px 0; line-height: 1.45; }
.ai-review-issue small { color: #5d696f; }
.audit-history-note { padding-top: 9px; }
.audit-empty-review p { margin: 0; color: #69767d; }
.audit-script-document { min-width: 0; padding: 34px clamp(24px, 5vw, 72px) 80px; background: #fff; }
.audit-script-document > header { padding-bottom: 22px; border-bottom: 2px solid #202b30; }
.audit-script-document > header > span { color: #9a5d08; font-size: 12px; font-weight: 900; }
.audit-script-document > header h1 { margin: 6px 0; font-size: 28px; letter-spacing: 0; }
.audit-script-document > header p { margin: 0; color: #66747c; }
.audit-scene { padding: 28px 0; border-bottom: 1px solid #cbd3d6; }
.audit-scene > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
.audit-scene > header span { color: #9a5d08; font-size: 12px; font-weight: 900; }
.audit-scene > header h2 { margin: 4px 0 0; font-size: 19px; letter-spacing: 0; overflow-wrap: anywhere; }
.audit-scene > header > strong { flex: 0 0 auto; color: #006b62; }
.audit-cast { color: #536169; font-weight: 700; }
.audit-scene-contract { display: grid; grid-template-columns: 62px minmax(0, 1fr); gap: 7px 12px; margin: 15px 0; }
.audit-scene-contract dt { color: #6a767c; font-size: 12px; font-weight: 800; }
.audit-scene-contract dd { margin: 0; line-height: 1.55; }
.audit-performance-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; padding: 14px 0; border-top: 1px solid #e0e5e7; border-bottom: 1px solid #e0e5e7; }
.audit-performance-grid strong { font-size: 13px; }
.audit-performance-grid p { margin: 6px 0 0; color: #4f5d64; line-height: 1.45; }
.audit-action-section, .audit-dialogue-section { margin-top: 20px; }
.audit-action-section h3, .audit-dialogue-section h3 { margin: 0 0 10px; font-size: 14px; letter-spacing: 0; }
.audit-action-section ol { margin: 0; padding-left: 22px; }
.audit-action-section li { margin-bottom: 13px; padding-left: 4px; }
.audit-action-section li header, .audit-dialogue-section blockquote header { display: flex; justify-content: space-between; gap: 12px; }
.audit-action-section li p { margin: 5px 0; line-height: 1.55; }
.audit-action-section li small { display: block; color: #6b777d; }
.audit-dialogue-section blockquote { margin: 12px 0 0; padding: 15px 18px; border-left: 4px solid #177f75; background: #f2f7f6; }
.audit-dialogue-section blockquote p { margin: 12px 0; font-size: 18px; font-weight: 750; line-height: 1.65; }
.audit-dialogue-section blockquote footer { display: grid; gap: 4px; color: #657279; font-size: 12px; }
@media (max-width: 760px) {
.splus-pipeline { padding: 16px; }
.pipeline-head, .snapshot-row, .contract-row, .continuity-bar, .review-export-row { align-items: stretch; flex-direction: column; }
.engine-state { text-align: left; }
.contract-meta, .contract-actions { width: 100%; flex-basis: auto; }
.contract-actions { justify-content: flex-start; }
.stage-switch button { padding: 10px 4px; font-size: 12px; }
.flow-test-panel { grid-template-columns: 1fr; }
.flow-test-result { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.flow-test-result > div { border-bottom: 1px solid #d9e0e4; }
.asset-columns { grid-template-columns: 1fr; }
.scene-script-section header { align-items: flex-start; flex-direction: column; }
.scene-script-section header span { text-align: left; }
.script-audit-overlay { padding: 0; }
.script-audit-workspace { min-height: 100vh; border: 0; border-radius: 0; }
.script-audit-head { padding: 16px; }
.script-audit-head h2 { font-size: 18px; }
.audit-toolbar { padding: 12px 16px; }
.audit-summary-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.audit-summary-strip div { padding: 10px 16px; border-bottom: 1px solid #cad2d6; }
.audit-layout { display: block; }
.audit-review-panel { position: static; max-height: none; border-right: 0; border-bottom: 1px solid #cad2d6; }
.audit-script-document { padding: 28px 18px 64px; }
.audit-script-document > header h1 { font-size: 23px; }
.audit-performance-grid { grid-template-columns: 1fr; }
}
</style>