1378 lines
72 KiB
Vue
1378 lines
72 KiB
Vue
<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>
|