|
@@ -0,0 +1,1233 @@
|
|
|
|
|
+import { CommonModule } from '@angular/common';
|
|
|
|
|
+import { Component, EventEmitter, Injector, OnInit, Output, computed, inject, signal } from '@angular/core';
|
|
|
|
|
+import { FormsModule } from '@angular/forms';
|
|
|
|
|
+import { CreationBrief } from '../../models/creation-brief.model';
|
|
|
|
|
+import { ViralAnalysis } from '../../models/douyin-insight.model';
|
|
|
|
|
+import {
|
|
|
|
|
+ BenchmarkAccount,
|
|
|
|
|
+ IpBenchmarkEvidence,
|
|
|
|
|
+ IpEvidenceFetchJob,
|
|
|
|
|
+ IpEvidencePolishSuggestion,
|
|
|
|
|
+ IpOperatorBridgeRecord,
|
|
|
|
|
+ IpOperatorAuditEvent,
|
|
|
|
|
+ IpOperatorPlan,
|
|
|
|
|
+ IpOperatorProfile,
|
|
|
|
|
+ IpOperatorStage,
|
|
|
|
|
+ IpPlanQualityAudit,
|
|
|
|
|
+ IpScript,
|
|
|
|
|
+ IpScriptQualityLevel,
|
|
|
|
|
+ IpSupplementInput,
|
|
|
|
|
+ IpTopic,
|
|
|
|
|
+ IpTopicQualityLevel,
|
|
|
|
|
+ IpGenerationFailureCode,
|
|
|
|
|
+ IpGenerationSection,
|
|
|
|
|
+} from '../../models/ip-operator.model';
|
|
|
|
|
+import { AuthCreditService } from '../../services/auth-credit.service';
|
|
|
|
|
+import { CreationBriefService } from '../../services/creation-brief.service';
|
|
|
|
|
+import { IpOperatorAuthRequiredError, IpOperatorService } from '../../services/ip-operator.service';
|
|
|
|
|
+
|
|
|
|
|
+interface ProfileDraft {
|
|
|
|
|
+ id?: string;
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ stage: IpOperatorStage;
|
|
|
|
|
+ identity: string;
|
|
|
|
|
+ industry: string;
|
|
|
|
|
+ targetAudience: string;
|
|
|
|
|
+ audiencePainPointsText: string;
|
|
|
|
|
+ productsOrServices: string;
|
|
|
|
|
+ personalStoriesText: string;
|
|
|
|
|
+ expertiseText: string;
|
|
|
|
|
+ expressionStyleText: string;
|
|
|
|
|
+ boundariesText: string;
|
|
|
|
|
+ goalsText: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface BenchmarkDraft {
|
|
|
|
|
+ id?: string;
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ url: string;
|
|
|
|
|
+ bio: string;
|
|
|
|
|
+ reasonToBenchmark: string;
|
|
|
|
|
+ viralTitle: string;
|
|
|
|
|
+ viralUrl: string;
|
|
|
|
|
+ performance: string;
|
|
|
|
|
+ perceivedHook: string;
|
|
|
|
|
+ notes: string;
|
|
|
|
|
+ conversionNotes: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface SupplementDraft {
|
|
|
|
|
+ type: string;
|
|
|
|
|
+ title: string;
|
|
|
|
|
+ content: string;
|
|
|
|
|
+ relatedMissingInputId: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const QUESTIONNAIRE = [
|
|
|
|
|
+ '你现在最希望通过 IP 内容实现什么目标?',
|
|
|
|
|
+ '你最想服务哪类人?',
|
|
|
|
|
+ '这类人现在最痛苦、最焦虑、最想解决的问题是什么?',
|
|
|
|
|
+ '你凭什么能帮助他们?',
|
|
|
|
|
+ '你过去有哪些真实经历可以支撑这个定位?',
|
|
|
|
|
+ '你目前有什么产品、服务或商业承接方式?',
|
|
|
|
|
+ '你最不想被用户误解成什么样的人?',
|
|
|
|
|
+ '你喜欢或擅长哪种表达方式?',
|
|
|
|
|
+ '你有哪些对标账号?',
|
|
|
|
|
+ '你最想借鉴对标账号的什么?',
|
|
|
|
|
+ '你觉得自己和对标账号最大的不同是什么?',
|
|
|
|
|
+ '你目前最缺的是定位、选题、脚本、拍摄还是运营节奏?',
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+@Component({
|
|
|
|
|
+ selector: 'app-ip-operator',
|
|
|
|
|
+ standalone: true,
|
|
|
|
|
+ imports: [CommonModule, FormsModule],
|
|
|
|
|
+ templateUrl: './ip-operator.component.html',
|
|
|
|
|
+ styleUrls: ['./ip-operator.component.css'],
|
|
|
|
|
+})
|
|
|
|
|
+export class IpOperatorComponent implements OnInit {
|
|
|
|
|
+ @Output() navigateToTopicPool = new EventEmitter<void>();
|
|
|
|
|
+ @Output() navigateToTopicVideo = new EventEmitter<void>();
|
|
|
|
|
+ @Output() navigateToDigitalHuman = new EventEmitter<CreationBrief>();
|
|
|
|
|
+ @Output() navigateToLibrary = new EventEmitter<void>();
|
|
|
|
|
+
|
|
|
|
|
+ readonly auth = inject(AuthCreditService);
|
|
|
|
|
+ private readonly ipOperator = inject(IpOperatorService);
|
|
|
|
|
+ private readonly creationBriefs = inject(CreationBriefService);
|
|
|
|
|
+ private readonly injector = inject(Injector);
|
|
|
|
|
+
|
|
|
|
|
+ readonly profiles = signal<IpOperatorProfile[]>([]);
|
|
|
|
|
+ readonly activePlan = signal<IpOperatorPlan | null>(null);
|
|
|
|
|
+ readonly auditTrail = signal<IpOperatorAuditEvent[]>([]);
|
|
|
|
|
+ readonly step = signal(1);
|
|
|
|
|
+ readonly isGenerating = signal(false);
|
|
|
|
|
+ readonly generationError = signal('');
|
|
|
|
|
+ readonly storageMode = signal<'local' | 'temporary'>('local');
|
|
|
|
|
+ readonly lastSyncedTopicId = signal('');
|
|
|
|
|
+ readonly resultView = signal<'overview' | 'topics' | 'scripts' | 'plan'>('overview');
|
|
|
|
|
+ readonly exportMenuOpen = signal(false);
|
|
|
|
|
+ readonly copyMessage = signal('');
|
|
|
|
|
+ readonly regeneratingStage = signal<IpGenerationSection | ''>('');
|
|
|
|
|
+ readonly materialSyncMessage = signal('');
|
|
|
|
|
+ readonly productionEntryMessage = signal('');
|
|
|
|
|
+ readonly supplementMessage = signal('');
|
|
|
|
|
+ readonly evidenceUrl = signal('');
|
|
|
|
|
+ readonly evidenceBenchmarkId = signal('');
|
|
|
|
|
+ readonly evidenceMessage = signal('');
|
|
|
|
|
+ readonly evidenceLoading = signal(false);
|
|
|
|
|
+ readonly savedAnalyses = signal<ViralAnalysis[]>([]);
|
|
|
|
|
+ readonly savedAnalysisId = signal('');
|
|
|
|
|
+ readonly savedAnalysisMessage = signal('');
|
|
|
|
|
+ readonly savedAnalysisLoading = signal(false);
|
|
|
|
|
+ readonly supplementDraft = signal<SupplementDraft>({
|
|
|
|
|
+ type: '客户案例',
|
|
|
|
|
+ title: '',
|
|
|
|
|
+ content: '',
|
|
|
|
|
+ relatedMissingInputId: '',
|
|
|
|
|
+ });
|
|
|
|
|
+ readonly supplementTypes = ['个人故事', '客户案例', '客户问题', '产品服务', '对标数据', '评论私信', '专业证明'];
|
|
|
|
|
+
|
|
|
|
|
+ readonly profileDraft = signal<ProfileDraft>(this.emptyProfileDraft());
|
|
|
|
|
+ readonly questionnaireAnswers = signal<string[]>(QUESTIONNAIRE.map(() => ''));
|
|
|
|
|
+ readonly benchmarkDrafts = signal<BenchmarkDraft[]>([this.emptyBenchmarkDraft()]);
|
|
|
|
|
+ readonly hasBenchmarkInput = computed(() => this.benchmarkDrafts().some((item) => this.hasBenchmarkSignal(item)));
|
|
|
|
|
+
|
|
|
|
|
+ readonly canGenerate = computed(() => {
|
|
|
|
|
+ const draft = this.profileDraft();
|
|
|
|
|
+ const hasRequiredProfile = !!(
|
|
|
|
|
+ draft.name.trim()
|
|
|
|
|
+ && draft.identity.trim()
|
|
|
|
|
+ && draft.industry.trim()
|
|
|
|
|
+ && draft.targetAudience.trim()
|
|
|
|
|
+ && draft.audiencePainPointsText.trim()
|
|
|
|
|
+ && draft.personalStoriesText.trim()
|
|
|
|
|
+ && draft.expertiseText.trim()
|
|
|
|
|
+ && draft.goalsText.trim()
|
|
|
|
|
+ );
|
|
|
|
|
+ return hasRequiredProfile && this.hasBenchmarkInput() && !this.isGenerating();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ readonly highPriorityTopics = computed(() => this.activePlan()?.topics.filter((item) => item.priority === 'high') || []);
|
|
|
|
|
+ readonly productionReadyTopics = computed(() => this.activePlan()?.topics.filter((item) => item.qualityLevel === 'make_now') || []);
|
|
|
|
|
+ readonly polishFirstTopics = computed(() => this.activePlan()?.topics.filter((item) => item.qualityLevel === 'polish_first') || []);
|
|
|
|
|
+ readonly materialNeededTopics = computed(() => this.activePlan()?.topics.filter((item) => item.qualityLevel === 'needs_material') || []);
|
|
|
|
|
+ readonly sortedTopics = computed(() => {
|
|
|
|
|
+ const qualityOrder: Record<IpTopicQualityLevel, number> = { make_now: 0, polish_first: 1, needs_material: 2, hold: 3 };
|
|
|
|
|
+ const priorityOrder: Record<string, number> = { high: 0, medium: 1, low: 2 };
|
|
|
|
|
+ return [...(this.activePlan()?.topics || [])].sort((a, b) => {
|
|
|
|
|
+ const byQuality = (qualityOrder[a.qualityLevel || 'hold'] ?? 9) - (qualityOrder[b.qualityLevel || 'hold'] ?? 9);
|
|
|
|
|
+ if (byQuality) return byQuality;
|
|
|
|
|
+ const byPriority = (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9);
|
|
|
|
|
+ return byPriority || a.title.localeCompare(b.title);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ readonly fullScripts = computed(() => (this.activePlan()?.scripts || []).filter((script) => script.type === 'full'));
|
|
|
|
|
+ readonly outlineScripts = computed(() => (this.activePlan()?.scripts || []).filter((script) => script.type !== 'full'));
|
|
|
|
|
+ readonly recordableScripts = computed(() => (this.activePlan()?.scripts || []).filter((script) => script.qualityLevel === 'ready_to_record'));
|
|
|
|
|
+
|
|
|
|
|
+ ngOnInit(): void {
|
|
|
|
|
+ this.reloadState();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ reloadState(): void {
|
|
|
|
|
+ const profiles = this.ipOperator.listProfiles();
|
|
|
|
|
+ this.profiles.set(profiles);
|
|
|
|
|
+ this.auditTrail.set(this.ipOperator.listAuditTrail());
|
|
|
|
|
+ const activeProfileId = this.ipOperator.getActiveProfileId() || profiles[0]?.id || '';
|
|
|
|
|
+ if (activeProfileId) {
|
|
|
|
|
+ const profile = this.ipOperator.getProfile(activeProfileId);
|
|
|
|
|
+ if (profile) this.loadProfile(profile);
|
|
|
|
|
+ const latestPlan = this.ipOperator.listPlans(activeProfileId)[0] || null;
|
|
|
|
|
+ this.activePlan.set(latestPlan);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ startNewProfile(): void {
|
|
|
|
|
+ this.profileDraft.set(this.emptyProfileDraft());
|
|
|
|
|
+ this.questionnaireAnswers.set(QUESTIONNAIRE.map(() => ''));
|
|
|
|
|
+ this.benchmarkDrafts.set([this.emptyBenchmarkDraft()]);
|
|
|
|
|
+ this.activePlan.set(null);
|
|
|
|
|
+ this.step.set(1);
|
|
|
|
|
+ this.generationError.set('');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ loadProfile(profile: IpOperatorProfile): void {
|
|
|
|
|
+ this.profileDraft.set({
|
|
|
|
|
+ id: profile.id,
|
|
|
|
|
+ name: profile.name,
|
|
|
|
|
+ stage: profile.stage,
|
|
|
|
|
+ identity: profile.identity,
|
|
|
|
|
+ industry: profile.industry,
|
|
|
|
|
+ targetAudience: profile.targetAudience,
|
|
|
|
|
+ audiencePainPointsText: profile.audiencePainPoints.join('\n'),
|
|
|
|
|
+ productsOrServices: profile.productsOrServices,
|
|
|
|
|
+ personalStoriesText: profile.personalStories.join('\n'),
|
|
|
|
|
+ expertiseText: profile.expertise.join('\n'),
|
|
|
|
|
+ expressionStyleText: profile.expressionStyle.join('\n'),
|
|
|
|
|
+ boundariesText: profile.boundaries.join('\n'),
|
|
|
|
|
+ goalsText: profile.goals.join('\n'),
|
|
|
|
|
+ });
|
|
|
|
|
+ this.questionnaireAnswers.set(QUESTIONNAIRE.map((question) => profile.questionnaire.find((item) => item.question === question)?.answer || ''));
|
|
|
|
|
+ const benchmarks = this.ipOperator.listBenchmarks(profile.id);
|
|
|
|
|
+ this.benchmarkDrafts.set(benchmarks.length ? benchmarks.map((item) => ({
|
|
|
|
|
+ id: item.id,
|
|
|
|
|
+ name: item.name,
|
|
|
|
|
+ url: item.url || '',
|
|
|
|
|
+ bio: item.bio || '',
|
|
|
|
|
+ reasonToBenchmark: item.reasonToBenchmark,
|
|
|
|
|
+ viralTitle: item.viralExamples[0]?.title || '',
|
|
|
|
|
+ viralUrl: item.viralExamples[0]?.url || '',
|
|
|
|
|
+ performance: item.viralExamples[0]?.performance || '',
|
|
|
|
|
+ perceivedHook: item.viralExamples[0]?.perceivedHook || '',
|
|
|
|
|
+ notes: item.viralExamples[0]?.notes || '',
|
|
|
|
|
+ conversionNotes: item.conversionNotes || '',
|
|
|
|
|
+ })) : [this.emptyBenchmarkDraft()]);
|
|
|
|
|
+ this.ipOperator.setActiveProfileId(profile.id, this.storageMode() === 'temporary');
|
|
|
|
|
+ const latestPlan = this.ipOperator.listPlans(profile.id)[0] || null;
|
|
|
|
|
+ this.activePlan.set(latestPlan);
|
|
|
|
|
+ this.step.set(latestPlan ? (latestPlan.status === 'generating' ? 4 : 5) : 1);
|
|
|
|
|
+ this.auditTrail.set(this.ipOperator.listAuditTrail());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ updateProfileDraft(patch: Partial<ProfileDraft>): void {
|
|
|
|
|
+ this.profileDraft.set({ ...this.profileDraft(), ...patch });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ updateQuestionAnswer(index: number, value: string): void {
|
|
|
|
|
+ const next = [...this.questionnaireAnswers()];
|
|
|
|
|
+ next[index] = value;
|
|
|
|
|
+ this.questionnaireAnswers.set(next);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ updateBenchmark(index: number, patch: Partial<BenchmarkDraft>): void {
|
|
|
|
|
+ const next = [...this.benchmarkDrafts()];
|
|
|
|
|
+ next[index] = { ...next[index], ...patch };
|
|
|
|
|
+ this.benchmarkDrafts.set(next);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ addBenchmark(): void {
|
|
|
|
|
+ this.benchmarkDrafts.set([...this.benchmarkDrafts(), this.emptyBenchmarkDraft()]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ removeBenchmark(index: number): void {
|
|
|
|
|
+ const next = this.benchmarkDrafts().filter((_, i) => i !== index);
|
|
|
|
|
+ this.benchmarkDrafts.set(next.length ? next : [this.emptyBenchmarkDraft()]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ saveProfileAndBenchmarks(): IpOperatorProfile {
|
|
|
|
|
+ const temporary = this.storageMode() === 'temporary';
|
|
|
|
|
+ const profile = this.ipOperator.saveProfile(this.toProfileInput(), { temporary });
|
|
|
|
|
+ this.ipOperator.saveBenchmarks(profile.id, this.toBenchmarks(profile.id), { temporary });
|
|
|
|
|
+ this.reloadState();
|
|
|
|
|
+ return profile;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ generatePlan(): void {
|
|
|
|
|
+ if (!this.canGenerate()) return;
|
|
|
|
|
+ this.isGenerating.set(true);
|
|
|
|
|
+ this.generationError.set('');
|
|
|
|
|
+ let profile: IpOperatorProfile;
|
|
|
|
|
+ try {
|
|
|
|
|
+ profile = this.saveProfileAndBenchmarks();
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ this.isGenerating.set(false);
|
|
|
|
|
+ this.generationError.set(error instanceof IpOperatorAuthRequiredError ? error.message : (error?.message || '保存资料失败'));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const benchmarks = this.ipOperator.listBenchmarks(profile.id);
|
|
|
|
|
+ this.ipOperator.generateFullPlan({
|
|
|
|
|
+ profile,
|
|
|
|
|
+ benchmarks,
|
|
|
|
|
+ storageMode: this.storageMode(),
|
|
|
|
|
+ }).subscribe({
|
|
|
|
|
+ next: (event) => {
|
|
|
|
|
+ if (event.plan) this.activePlan.set(event.plan);
|
|
|
|
|
+ if (event.plan?.status === 'generating') this.step.set(4);
|
|
|
|
|
+ this.auditTrail.set(this.ipOperator.listAuditTrail());
|
|
|
|
|
+ },
|
|
|
|
|
+ error: (error) => {
|
|
|
|
|
+ this.isGenerating.set(false);
|
|
|
|
|
+ this.generationError.set(error?.message || '生成失败,请稍后重试');
|
|
|
|
|
+ this.reloadState();
|
|
|
|
|
+ },
|
|
|
|
|
+ complete: () => {
|
|
|
|
|
+ this.isGenerating.set(false);
|
|
|
|
|
+ this.step.set(5);
|
|
|
|
|
+ this.reloadState();
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ saveTopicField(topic: IpTopic, patch: Partial<IpTopic>): void {
|
|
|
|
|
+ const plan = this.activePlan();
|
|
|
|
|
+ if (!plan) return;
|
|
|
|
|
+ this.activePlan.set(this.ipOperator.updateTopic(plan.id, topic.id, patch));
|
|
|
|
|
+ this.auditTrail.set(this.ipOperator.listAuditTrail());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ saveScriptField(script: IpScript, patch: Partial<IpScript>): void {
|
|
|
|
|
+ const plan = this.activePlan();
|
|
|
|
|
+ if (!plan) return;
|
|
|
|
|
+ this.activePlan.set(this.ipOperator.updateScript(plan.id, script.id, patch));
|
|
|
|
|
+ this.auditTrail.set(this.ipOperator.listAuditTrail());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ syncTopic(topic: IpTopic): void {
|
|
|
|
|
+ const plan = this.activePlan();
|
|
|
|
|
+ if (!plan || plan.status !== 'ready') return;
|
|
|
|
|
+ const synced = this.ipOperator.syncTopicToTopicPool(plan.id, topic.id);
|
|
|
|
|
+ this.lastSyncedTopicId.set(synced.id);
|
|
|
|
|
+ this.reloadState();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ syncHighPriorityTopics(): void {
|
|
|
|
|
+ const plan = this.activePlan();
|
|
|
|
|
+ if (!plan || plan.status !== 'ready') return;
|
|
|
|
|
+ const synced = this.ipOperator.syncAllRecommendedTopics(plan.id);
|
|
|
|
|
+ this.lastSyncedTopicId.set(synced.map((item) => item.id).join(','));
|
|
|
|
|
+ this.reloadState();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ syncMissingMaterials(): void {
|
|
|
|
|
+ const plan = this.activePlan();
|
|
|
|
|
+ if (!plan) return;
|
|
|
|
|
+ const result = this.ipOperator.syncMissingMaterialsToLibrary(plan.id);
|
|
|
|
|
+ this.materialSyncMessage.set(result.total
|
|
|
|
|
+ ? `已同步 ${result.total} 条待补充素材到素材库:新增 ${result.created} 条,已存在 ${result.existing} 条`
|
|
|
|
|
+ : '当前方案没有可同步的待补充素材');
|
|
|
|
|
+ this.auditTrail.set(this.ipOperator.listAuditTrail());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ importFilledMaterials(): void {
|
|
|
|
|
+ const plan = this.activePlan();
|
|
|
|
|
+ if (!plan) return;
|
|
|
|
|
+ const result = this.ipOperator.importFilledAssetsAsSupplements(plan.id);
|
|
|
|
|
+ this.reloadState();
|
|
|
|
|
+ this.supplementMessage.set(result.imported
|
|
|
|
|
+ ? `已导入 ${result.imported} 条已补素材为补充资料,建议重新质量检查。`
|
|
|
|
|
+ : '没有可导入的已补素材。');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async createTopicVideoDraft(topic: IpTopic): Promise<void> {
|
|
|
|
|
+ const plan = this.activePlan();
|
|
|
|
|
+ if (!plan) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { IpOperatorBridgeService } = await import('../../services/ip-operator-bridge.service');
|
|
|
|
|
+ const bridge = new IpOperatorBridgeService(this.creationBriefs);
|
|
|
|
|
+ const entry = bridge.enterTopicToVideo(plan, topic, this.topicScript(topic));
|
|
|
|
|
+ const record = this.ipOperator.recordBridgeAction(plan.id, {
|
|
|
|
|
+ sourceType: 'topic',
|
|
|
|
|
+ sourceId: topic.id,
|
|
|
|
|
+ target: 'topic_to_video',
|
|
|
|
|
+ targetId: entry.brief.id,
|
|
|
|
|
+ title: topic.title,
|
|
|
|
|
+ status: 'created',
|
|
|
|
|
+ message: entry.message,
|
|
|
|
|
+ });
|
|
|
|
|
+ this.productionEntryMessage.set(record.message || entry.message);
|
|
|
|
|
+ this.reloadState();
|
|
|
|
|
+ this.navigateToTopicVideo.emit();
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ this.productionEntryMessage.set(error?.message || '生成主题视频草稿失败');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async createDigitalHumanDraft(script: IpScript): Promise<void> {
|
|
|
|
|
+ const plan = this.activePlan();
|
|
|
|
|
+ if (!plan) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { IpOperatorBridgeService } = await import('../../services/ip-operator-bridge.service');
|
|
|
|
|
+ const bridge = new IpOperatorBridgeService(this.creationBriefs);
|
|
|
|
|
+ const entry = bridge.enterDigitalHuman(plan, script);
|
|
|
|
|
+ const record = this.ipOperator.recordBridgeAction(plan.id, {
|
|
|
|
|
+ sourceType: 'script',
|
|
|
|
|
+ sourceId: script.id,
|
|
|
|
|
+ target: 'digital_human',
|
|
|
|
|
+ targetId: entry.brief.id,
|
|
|
|
|
+ title: script.title || script.id,
|
|
|
|
|
+ status: 'created',
|
|
|
|
|
+ message: entry.message,
|
|
|
|
|
+ });
|
|
|
|
|
+ this.productionEntryMessage.set(record.message || entry.message);
|
|
|
|
|
+ this.reloadState();
|
|
|
|
|
+ this.navigateToDigitalHuman.emit(entry.brief);
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ this.productionEntryMessage.set(error?.message || '生成数字人口播草稿失败');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ openIpMaterialLibrary(): void {
|
|
|
|
|
+ sessionStorage.setItem('videoWorkflow.library.prefilter', JSON.stringify({
|
|
|
|
|
+ source: 'ip_operator',
|
|
|
|
|
+ materialStatus: 'todo',
|
|
|
|
|
+ profileId: this.activePlan()?.profileId || '',
|
|
|
|
|
+ }));
|
|
|
|
|
+ this.navigateToLibrary.emit();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async fetchBenchmarkEvidence(): Promise<void> {
|
|
|
|
|
+ const plan = this.activePlan();
|
|
|
|
|
+ const url = this.evidenceUrl().trim();
|
|
|
|
|
+ if (!plan || !url || this.evidenceLoading()) return;
|
|
|
|
|
+ this.evidenceLoading.set(true);
|
|
|
|
|
+ this.evidenceMessage.set('');
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { IpOperatorEvidenceService } = await import('../../services/ip-operator-evidence.service');
|
|
|
|
|
+ const evidenceService = this.injector.get(IpOperatorEvidenceService);
|
|
|
|
|
+ const result = await new Promise<any>((resolve, reject) => {
|
|
|
|
|
+ evidenceService.fetchEvidenceFromDouyinUrl(plan.id, this.evidenceBenchmarkId() || undefined, url).subscribe({
|
|
|
|
|
+ next: resolve,
|
|
|
|
|
+ error: reject,
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ this.evidenceUrl.set('');
|
|
|
|
|
+ this.evidenceMessage.set(result.evidence
|
|
|
|
|
+ ? `已保存对标证据:${result.evidence.title}`
|
|
|
|
|
+ : `证据采集未写入:${result.job.failureMessage || result.job.status}`);
|
|
|
|
|
+ this.reloadState();
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ const result = error?.result;
|
|
|
|
|
+ this.evidenceMessage.set(result?.job?.failureMessage || error?.message || '对标证据采集失败');
|
|
|
|
|
+ this.reloadState();
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.evidenceLoading.set(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async refreshSavedAnalyses(): Promise<void> {
|
|
|
|
|
+ if (this.savedAnalysisLoading()) return;
|
|
|
|
|
+ this.savedAnalysisLoading.set(true);
|
|
|
|
|
+ this.savedAnalysisMessage.set('');
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { ViralAnalysisService } = await import('../../services/viral-analysis.service');
|
|
|
|
|
+ const viralAnalysis = this.injector.get(ViralAnalysisService);
|
|
|
|
|
+ const analyses = viralAnalysis.listAnalyses();
|
|
|
|
|
+ this.savedAnalyses.set(analyses);
|
|
|
|
|
+ this.savedAnalysisMessage.set(analyses.length ? `已加载 ${analyses.length} 条已保存爆款分析` : '暂无已保存爆款分析');
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ this.savedAnalysisMessage.set(error?.message || '加载已保存爆款分析失败');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.savedAnalysisLoading.set(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async bindSavedAnalysisEvidence(): Promise<void> {
|
|
|
|
|
+ const plan = this.activePlan();
|
|
|
|
|
+ const analysisId = this.savedAnalysisId();
|
|
|
|
|
+ if (!plan || !analysisId || this.savedAnalysisLoading()) return;
|
|
|
|
|
+ this.savedAnalysisLoading.set(true);
|
|
|
|
|
+ this.savedAnalysisMessage.set('');
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { IpOperatorEvidenceService } = await import('../../services/ip-operator-evidence.service');
|
|
|
|
|
+ const evidenceService = this.injector.get(IpOperatorEvidenceService);
|
|
|
|
|
+ const result = evidenceService.bindSavedAnalysis(plan.id, this.evidenceBenchmarkId() || undefined, analysisId);
|
|
|
|
|
+ this.savedAnalysisMessage.set(result.evidence ? `已绑定爆款分析:${result.evidence.title}` : '已绑定分析记录');
|
|
|
|
|
+ this.reloadState();
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ this.savedAnalysisMessage.set(error?.message || '绑定爆款分析失败');
|
|
|
|
|
+ this.reloadState();
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.savedAnalysisLoading.set(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ generateEvidenceSuggestions(evidence: IpBenchmarkEvidence): void {
|
|
|
|
|
+ const plan = this.activePlan();
|
|
|
|
|
+ if (!plan) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const updated = this.ipOperator.generateEvidencePolishSuggestions(plan.id, evidence.id);
|
|
|
|
|
+ this.activePlan.set(updated);
|
|
|
|
|
+ this.evidenceMessage.set('已生成对标证据打磨建议,需手动确认后才会应用。');
|
|
|
|
|
+ this.auditTrail.set(this.ipOperator.listAuditTrail());
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ this.evidenceMessage.set(error?.message || '生成对标证据建议失败');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ applyEvidenceSuggestion(suggestion: IpEvidencePolishSuggestion): void {
|
|
|
|
|
+ const plan = this.activePlan();
|
|
|
|
|
+ if (!plan) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const updated = this.ipOperator.applyEvidencePolishSuggestion(plan.id, suggestion.id);
|
|
|
|
|
+ this.activePlan.set(updated);
|
|
|
|
|
+ this.evidenceMessage.set('已应用对标证据建议,建议重新检查选题/脚本质量。');
|
|
|
|
|
+ this.auditTrail.set(this.ipOperator.listAuditTrail());
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ this.evidenceMessage.set(error?.message || '应用对标证据建议失败');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ dismissEvidenceSuggestion(suggestion: IpEvidencePolishSuggestion): void {
|
|
|
|
|
+ const plan = this.activePlan();
|
|
|
|
|
+ if (!plan) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const updated = this.ipOperator.dismissEvidencePolishSuggestion(plan.id, suggestion.id);
|
|
|
|
|
+ this.activePlan.set(updated);
|
|
|
|
|
+ this.evidenceMessage.set('已忽略该对标证据建议。');
|
|
|
|
|
+ this.auditTrail.set(this.ipOperator.listAuditTrail());
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ this.evidenceMessage.set(error?.message || '忽略对标证据建议失败');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async generateEvidenceBasedSuggestions(): Promise<void> {
|
|
|
|
|
+ const plan = this.activePlan();
|
|
|
|
|
+ if (!plan || this.evidenceLoading()) return;
|
|
|
|
|
+ this.evidenceLoading.set(true);
|
|
|
|
|
+ this.evidenceMessage.set('');
|
|
|
|
|
+ try {
|
|
|
|
|
+ const updated = await new Promise<IpOperatorPlan>((resolve, reject) => {
|
|
|
|
|
+ this.ipOperator.generateEvidenceBasedPolishSuggestions(plan.id).subscribe({
|
|
|
|
|
+ next: resolve,
|
|
|
|
|
+ error: reject,
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ this.activePlan.set(updated);
|
|
|
|
|
+ this.evidenceMessage.set('已基于对标证据生成增强建议,仍需手动确认后应用。');
|
|
|
|
|
+ this.auditTrail.set(this.ipOperator.listAuditTrail());
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ this.evidenceMessage.set(error?.message || '基于对标证据生成增强建议失败');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.evidenceLoading.set(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ updateSupplementDraft(patch: Partial<SupplementDraft>): void {
|
|
|
|
|
+ this.supplementDraft.set({ ...this.supplementDraft(), ...patch });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ addSupplementInput(): void {
|
|
|
|
|
+ const plan = this.activePlan();
|
|
|
|
|
+ const draft = this.supplementDraft();
|
|
|
|
|
+ if (!plan || !draft.title.trim() || !draft.content.trim()) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const updated = this.ipOperator.addSupplementInput(plan.id, {
|
|
|
|
|
+ type: draft.type,
|
|
|
|
|
+ title: draft.title,
|
|
|
|
|
+ content: draft.content,
|
|
|
|
|
+ relatedMissingInputId: draft.relatedMissingInputId || undefined,
|
|
|
|
|
+ });
|
|
|
|
|
+ this.activePlan.set(updated);
|
|
|
|
|
+ this.supplementDraft.set({ type: draft.type, title: '', content: '', relatedMissingInputId: '' });
|
|
|
|
|
+ this.supplementMessage.set('资料已补充,建议重新质量检查或重生成相关模块。');
|
|
|
|
|
+ this.auditTrail.set(this.ipOperator.listAuditTrail());
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ this.supplementMessage.set(error?.message || '补充资料保存失败');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ saveSupplementInput(input: IpSupplementInput, patch: Partial<IpSupplementInput>): void {
|
|
|
|
|
+ const plan = this.activePlan();
|
|
|
|
|
+ if (!plan) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const updated = this.ipOperator.updateSupplementInput(plan.id, input.id, patch);
|
|
|
|
|
+ this.activePlan.set(updated);
|
|
|
|
|
+ this.supplementMessage.set('补充资料已更新,建议重新质量检查。');
|
|
|
|
|
+ this.auditTrail.set(this.ipOperator.listAuditTrail());
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ this.supplementMessage.set(error?.message || '补充资料更新失败');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ deleteSupplementInput(input: IpSupplementInput): void {
|
|
|
|
|
+ const plan = this.activePlan();
|
|
|
|
|
+ if (!plan) return;
|
|
|
|
|
+ const updated = this.ipOperator.deleteSupplementInput(plan.id, input.id);
|
|
|
|
|
+ this.activePlan.set(updated);
|
|
|
|
|
+ this.supplementMessage.set('补充资料已删除。');
|
|
|
|
|
+ this.auditTrail.set(this.ipOperator.listAuditTrail());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ regenerateSection(stage: IpGenerationSection): void {
|
|
|
|
|
+ const plan = this.activePlan();
|
|
|
|
|
+ if (!plan || this.regeneratingStage()) return;
|
|
|
|
|
+ const confirmed = confirm(`确认重新生成「${this.stageLabel(stage)}」?\n\n${this.regenerationPrompt(stage)}`);
|
|
|
|
|
+ if (!confirmed) return;
|
|
|
|
|
+ this.regeneratingStage.set(stage);
|
|
|
|
|
+ this.generationError.set('');
|
|
|
|
|
+ this.ipOperator.regenerateSection(plan.id, stage).subscribe({
|
|
|
|
|
+ next: (event) => {
|
|
|
|
|
+ if (event.plan) this.activePlan.set(event.plan);
|
|
|
|
|
+ this.auditTrail.set(this.ipOperator.listAuditTrail());
|
|
|
|
|
+ },
|
|
|
|
|
+ error: (error) => {
|
|
|
|
|
+ this.generationError.set(error?.message || '局部重新生成失败');
|
|
|
|
|
+ this.regeneratingStage.set('');
|
|
|
|
|
+ this.reloadState();
|
|
|
|
|
+ },
|
|
|
|
|
+ complete: () => {
|
|
|
|
|
+ this.regeneratingStage.set('');
|
|
|
|
|
+ this.reloadState();
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ exportMarkdown(kind: 'full' | 'client' | 'execution' | 'topics'): void {
|
|
|
|
|
+ const plan = this.activePlan();
|
|
|
|
|
+ if (!plan) return;
|
|
|
|
|
+ const profileName = this.profileDraft().name || 'IP操盘方案';
|
|
|
|
|
+ const markdown = this.buildMarkdown(plan, kind);
|
|
|
|
|
+ const normalizedKind = kind === 'topics' ? 'execution' : kind;
|
|
|
|
|
+ const suffix = normalizedKind === 'full' ? '完整方案' : normalizedKind === 'client' ? '客户沟通版' : '执行版';
|
|
|
|
|
+ this.downloadText(`IP操盘方案-${profileName}-${suffix}-${this.timestampForFilename()}.md`, markdown);
|
|
|
|
|
+ this.exportMenuOpen.set(false);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ copyPlanDebugInfo(plan: IpOperatorPlan): void {
|
|
|
|
|
+ const payload = {
|
|
|
|
|
+ planId: plan.id,
|
|
|
|
|
+ profileId: plan.profileId,
|
|
|
|
|
+ status: plan.status,
|
|
|
|
|
+ errorMessage: plan.errorMessage,
|
|
|
|
|
+ generationProgress: plan.generationProgress,
|
|
|
|
|
+ qualityCheck: plan.qualityCheck,
|
|
|
|
|
+ missingInputs: plan.missingInputs,
|
|
|
|
|
+ };
|
|
|
|
|
+ const text = JSON.stringify(payload, null, 2);
|
|
|
|
|
+ if (navigator?.clipboard?.writeText) {
|
|
|
|
|
+ navigator.clipboard.writeText(text)
|
|
|
|
|
+ .then(() => this.copyMessage.set('调试信息已复制'))
|
|
|
|
|
+ .catch(() => this.fallbackCopy(text));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.fallbackCopy(text);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ clearTemporaryData(): void {
|
|
|
|
|
+ this.ipOperator.clearTemporaryData();
|
|
|
|
|
+ this.startNewProfile();
|
|
|
|
|
+ this.auditTrail.set(this.ipOperator.listAuditTrail());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setResultView(view: 'overview' | 'topics' | 'scripts' | 'plan'): void {
|
|
|
|
|
+ this.resultView.set(view);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ planStatusLabel(status: IpOperatorPlan['status']): string {
|
|
|
|
|
+ const map: Record<IpOperatorPlan['status'], string> = {
|
|
|
|
|
+ draft: '草稿',
|
|
|
|
|
+ generating: '生成中',
|
|
|
|
|
+ ready: '可进入生产链路',
|
|
|
|
|
+ needs_input: '需补充资料',
|
|
|
|
|
+ failed: '生成失败',
|
|
|
|
|
+ };
|
|
|
|
|
+ return map[status] || status;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ planStatusHint(plan: IpOperatorPlan): string {
|
|
|
|
|
+ if (plan.status === 'ready') {
|
|
|
|
|
+ return '当前方案已通过质量门槛,可以编辑选题/脚本并同步到选题池。';
|
|
|
|
|
+ }
|
|
|
|
|
+ if (plan.status === 'needs_input') {
|
|
|
|
|
+ return '这是现阶段需要处理的问题:质量门槛未通过。可以先阅读草稿,但建议补充资料后重新生成,暂不进入生产链路。';
|
|
|
|
|
+ }
|
|
|
|
|
+ if (plan.status === 'generating') {
|
|
|
|
|
+ return '方案仍在生成,请等待六阶段完成后再判断内容质量。';
|
|
|
|
|
+ }
|
|
|
|
|
+ if (plan.status === 'failed') {
|
|
|
|
|
+ return '生成中断,已保留完成阶段的内容和错误记录,可补充资料后重试。';
|
|
|
|
|
+ }
|
|
|
|
|
+ return '方案仍是草稿,请完成生成后查看质量检查。';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ planFailureCode(plan: IpOperatorPlan): IpGenerationFailureCode | '' {
|
|
|
|
|
+ return plan.generationProgress.find((item) => item.failureCode)?.failureCode || '';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ planFailureStage(plan: IpOperatorPlan): string {
|
|
|
|
|
+ const failed = plan.generationProgress.find((item) => item.failureCode || item.status === 'failed');
|
|
|
|
|
+ return failed ? failed.label : '生成流程';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ failureMessage(code: IpGenerationFailureCode | ''): string {
|
|
|
|
|
+ const map: Record<IpGenerationFailureCode, string> = {
|
|
|
|
|
+ llm_timeout: '上游模型响应超时。本次已保留完成阶段,建议稍后重试当前阶段或减少输入内容。',
|
|
|
|
|
+ llm_rate_limited: '上游模型当前限流。本次已保留完成阶段,建议稍后重试。',
|
|
|
|
|
+ llm_empty_response: '上游模型没有返回有效内容。建议重试当前阶段。',
|
|
|
|
|
+ llm_malformed_json: '模型返回格式异常,系统无法解析为结构化方案。建议重试当前阶段。',
|
|
|
|
|
+ llm_refusal: '模型拒绝了本次请求。请调整输入资料后重试。',
|
|
|
|
|
+ llm_insufficient_balance: '上游模型账户余额不足。本次生成已保留完成阶段,请充值后重新执行质量检查。',
|
|
|
|
|
+ llm_upstream_error: '上游模型服务返回异常。本次生成已保留完成阶段,请稍后重试或复制调试信息排查。',
|
|
|
|
|
+ quality_gate_failed: '当前方案没有通过质量门槛。请补充资料后重新质量检查。',
|
|
|
|
|
+ storage_quota_exceeded: '本地存储空间不足。请清理旧方案或改用临时模式后重试。',
|
|
|
|
|
+ unknown: '生成失败原因暂未分类。请复制调试信息排查,已完成阶段不会被清空。',
|
|
|
|
|
+ };
|
|
|
|
|
+ return code ? map[code] : '生成失败原因暂未分类。请复制调试信息排查,已完成阶段不会被清空。';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ failureNextAction(code: IpGenerationFailureCode | ''): string {
|
|
|
|
|
+ if (code === 'llm_insufficient_balance') return '充值或恢复上游账户余额后,优先点击“重新质量检查”。';
|
|
|
|
|
+ if (code === 'llm_malformed_json' || code === 'llm_empty_response' || code === 'llm_timeout' || code === 'llm_rate_limited') {
|
|
|
|
|
+ return '建议重试失败阶段;重试前不需要重新录入已完成资料。';
|
|
|
|
|
+ }
|
|
|
|
|
+ if (code === 'quality_gate_failed') return '先补充待补资料,再重新质量检查。';
|
|
|
|
|
+ if (code === 'storage_quota_exceeded') return '先清理本地旧方案,避免继续写入失败。';
|
|
|
|
|
+ return '先复制调试信息确认失败原因,再决定补资料或重试。';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ priorityLabel(priority: IpTopic['priority']): string {
|
|
|
|
|
+ if (priority === 'high') return '高';
|
|
|
|
|
+ if (priority === 'medium') return '中';
|
|
|
|
|
+ return '低';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ trafficLayerLabel(layer: IpTopic['trafficLayer']): string {
|
|
|
|
|
+ if (layer === 'broad') return '泛流量';
|
|
|
|
|
+ if (layer === 'vertical') return '垂直流量';
|
|
|
|
|
+ return '转化型';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ topicQualityLabel(level?: IpTopicQualityLevel): string {
|
|
|
|
|
+ const map: Record<IpTopicQualityLevel, string> = {
|
|
|
|
|
+ make_now: '可立即制作',
|
|
|
|
|
+ polish_first: '先打磨',
|
|
|
|
|
+ needs_material: '需补素材',
|
|
|
|
|
+ hold: '暂缓',
|
|
|
|
|
+ };
|
|
|
|
|
+ return map[level || 'hold'];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ topicQualityClass(level?: IpTopicQualityLevel): string {
|
|
|
|
|
+ return `is-${level || 'hold'}`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ scriptQualityLabel(level?: IpScriptQualityLevel): string {
|
|
|
|
|
+ const map: Record<IpScriptQualityLevel, string> = {
|
|
|
|
|
+ ready_to_record: '可拍摄',
|
|
|
|
|
+ polish_first: '先打磨',
|
|
|
|
|
+ needs_material: '需素材',
|
|
|
|
|
+ outline_only: '仅大纲',
|
|
|
|
|
+ };
|
|
|
|
|
+ return map[level || 'outline_only'];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ scriptQualityClass(level?: IpScriptQualityLevel): string {
|
|
|
|
|
+ return `is-${level || 'outline_only'}`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ qualityAudit(plan: IpOperatorPlan): IpPlanQualityAudit {
|
|
|
|
|
+ return plan.qualityAudit || this.ipOperator.buildPlanQualityAudit(plan);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ stageLabel(stage: IpGenerationSection): string {
|
|
|
|
|
+ const map: Record<IpGenerationSection, string> = {
|
|
|
|
|
+ intake_summary: '资料归纳',
|
|
|
|
|
+ diagnosis: '定位诊断',
|
|
|
|
|
+ benchmark: '对标拆解',
|
|
|
|
|
+ opportunities: '机会地图',
|
|
|
|
|
+ topics_scripts_plan: '选题脚本计划',
|
|
|
|
|
+ quality_check: '质量检查',
|
|
|
|
|
+ };
|
|
|
|
|
+ return map[stage];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ regenerationPrompt(stage: IpGenerationSection): string {
|
|
|
|
|
+ const map: Record<IpGenerationSection, string> = {
|
|
|
|
|
+ intake_summary: '会基于当前档案、轻问卷和对标账号重新归纳资料缺口。后续定位仍需你手动触发重生成。',
|
|
|
|
|
+ diagnosis: '会基于最新档案和问卷重新判断定位,可能影响后续机会地图和选题判断。',
|
|
|
|
|
+ benchmark: '会基于当前定位和对标账号重新分析可借鉴点、不可照搬点和迁移方向。',
|
|
|
|
|
+ opportunities: '会基于当前定位和对标拆解重新计算内容机会方向,不会自动覆盖选题和脚本。',
|
|
|
|
|
+ topics_scripts_plan: '会生成新选题、脚本和测试计划;已手动编辑过的选题和脚本会优先保留。',
|
|
|
|
|
+ quality_check: '只重新判断当前方案质量,不改变定位、对标、选题、脚本和测试计划正文。',
|
|
|
|
|
+ };
|
|
|
|
|
+ return map[stage];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ lastRegenerationText(plan: IpOperatorPlan, stage: IpGenerationSection): string {
|
|
|
|
|
+ const note = [...(plan.regenerationNotes || [])]
|
|
|
|
|
+ .filter((item) => item.stage === stage)
|
|
|
|
|
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0];
|
|
|
|
|
+ if (!note) return '尚未局部重生成';
|
|
|
|
|
+ const status = note.status === 'failed' ? '失败' : '完成';
|
|
|
|
|
+ const preserved = note.preservedUserEdits ? `,保留 ${note.preservedUserEdits} 处编辑` : '';
|
|
|
|
|
+ return `最近 ${note.createdAt.slice(5, 16).replace('T', ' ')} ${status}${preserved}`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ recentRegenerationNotes(plan: IpOperatorPlan) {
|
|
|
|
|
+ return [...(plan.regenerationNotes || [])]
|
|
|
|
|
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
|
|
|
|
|
+ .slice(0, 5);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ recentBridgeRecords(plan: IpOperatorPlan): IpOperatorBridgeRecord[] {
|
|
|
|
|
+ return [...(plan.bridgeRecords || [])]
|
|
|
|
|
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
|
|
|
|
|
+ .slice(0, 6);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ recentEvidenceJobs(plan: IpOperatorPlan): IpEvidenceFetchJob[] {
|
|
|
|
|
+ return [...(plan.evidenceFetchJobs || [])]
|
|
|
|
|
+ .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
|
|
|
|
|
+ .slice(0, 4);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ recentBenchmarkEvidence(plan: IpOperatorPlan): IpBenchmarkEvidence[] {
|
|
|
|
|
+ return [...(plan.benchmarkEvidence || [])]
|
|
|
|
|
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
|
|
|
|
|
+ .slice(0, 4);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ recentEvidenceSuggestions(plan: IpOperatorPlan): IpEvidencePolishSuggestion[] {
|
|
|
|
|
+ return [...(plan.evidenceSuggestions || [])]
|
|
|
|
|
+ .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
|
|
|
|
|
+ .slice(0, 6);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ suggestionTargetLabel(target: IpEvidencePolishSuggestion['targetType']): string {
|
|
|
|
|
+ const map: Record<IpEvidencePolishSuggestion['targetType'], string> = {
|
|
|
|
|
+ opportunity: '机会地图',
|
|
|
|
|
+ topic: '选题',
|
|
|
|
|
+ script: '脚本',
|
|
|
|
|
+ };
|
|
|
|
|
+ return map[target];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ benchmarkOptions(plan: IpOperatorPlan): BenchmarkAccount[] {
|
|
|
|
|
+ return this.ipOperator.listBenchmarks(plan.profileId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ evidenceStrengthLabel(strength: IpEvidenceFetchJob['evidenceStrength']): string {
|
|
|
|
|
+ const map: Record<IpEvidenceFetchJob['evidenceStrength'], string> = {
|
|
|
|
|
+ none: '无证据',
|
|
|
|
|
+ weak: '弱证据',
|
|
|
|
|
+ medium: '中证据',
|
|
|
|
|
+ strong: '强证据',
|
|
|
|
|
+ };
|
|
|
|
|
+ return map[strength];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ evidenceJobStatusLabel(status: IpEvidenceFetchJob['status']): string {
|
|
|
|
|
+ const map: Record<IpEvidenceFetchJob['status'], string> = {
|
|
|
|
|
+ idle: '待开始',
|
|
|
|
|
+ fetching_detail: '抓取详情',
|
|
|
|
|
+ fetching_comments: '抓取评论',
|
|
|
|
|
+ analyzing: '分析中',
|
|
|
|
|
+ completed: '已完成',
|
|
|
|
|
+ partial: '部分完成',
|
|
|
|
|
+ failed: '失败',
|
|
|
|
|
+ };
|
|
|
|
|
+ return map[status];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ bridgeTargetLabel(target: IpOperatorBridgeRecord['target']): string {
|
|
|
|
|
+ const map: Record<IpOperatorBridgeRecord['target'], string> = {
|
|
|
|
|
+ topic_to_video: '主题视频',
|
|
|
|
|
+ digital_human: '数字人口播',
|
|
|
|
|
+ supplement_input: '补充资料',
|
|
|
|
|
+ topic_pool: '选题池',
|
|
|
|
|
+ };
|
|
|
|
|
+ return map[target];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ bridgeStatusLabel(status: IpOperatorBridgeRecord['status']): string {
|
|
|
|
|
+ const map: Record<IpOperatorBridgeRecord['status'], string> = {
|
|
|
|
|
+ created: '已创建',
|
|
|
|
|
+ skipped: '已跳过',
|
|
|
|
|
+ failed: '失败',
|
|
|
|
|
+ };
|
|
|
|
|
+ return map[status];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ displayText(value: unknown): string {
|
|
|
|
|
+ if (value === null || value === undefined || value === '') return '待补充';
|
|
|
|
|
+ if (Array.isArray(value)) return value.map((item) => this.displayText(item)).filter(Boolean).join(';') || '待补充';
|
|
|
|
|
+ if (typeof value === 'object') {
|
|
|
|
|
+ const obj = value as Record<string, unknown>;
|
|
|
|
|
+ const preferred = ['description', 'suggestion', 'issue', 'reason', 'content', 'text', 'type', 'priority']
|
|
|
|
|
+ .map((key) => obj[key])
|
|
|
|
|
+ .filter((item) => item !== undefined && item !== null && item !== '');
|
|
|
|
|
+ if (preferred.length) return preferred.map((item) => this.displayText(item)).join(':');
|
|
|
|
|
+ return Object.entries(obj)
|
|
|
|
|
+ .map(([key, item]) => `${key}:${this.displayText(item)}`)
|
|
|
|
|
+ .join(';');
|
|
|
|
|
+ }
|
|
|
|
|
+ return String(value);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ firstItems<T>(items: T[] | undefined, count: number): T[] {
|
|
|
|
|
+ return (items || []).slice(0, count);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private buildMarkdown(plan: IpOperatorPlan, kind: 'full' | 'client' | 'execution' | 'topics'): string {
|
|
|
|
|
+ if (kind === 'client') return this.buildClientMarkdown(plan);
|
|
|
|
|
+ if (kind === 'execution' || kind === 'topics') return this.buildExecutionMarkdown(plan);
|
|
|
|
|
+ return this.buildFullMarkdown(plan);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private buildFullMarkdown(plan: IpOperatorPlan): string {
|
|
|
|
|
+ const lines: string[] = [];
|
|
|
|
|
+ const profileName = this.profileDraft().name || '未命名 IP';
|
|
|
|
|
+ lines.push(`# ${profileName} IP 起步方案`);
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ lines.push(this.markdownStatusLine(plan));
|
|
|
|
|
+ lines.push(`- 方案状态:${this.planStatusLabel(plan.status)}`);
|
|
|
|
|
+ lines.push(`- 更新时间:${this.displayText(plan.updatedAt)}`);
|
|
|
|
|
+ if (plan.qualityCheck) lines.push(`- 质量分:${plan.qualityCheck.overallScore}/100`);
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+
|
|
|
|
|
+ this.appendDiagnosisMarkdown(lines, plan, 1);
|
|
|
|
|
+ this.appendBenchmarkMarkdown(lines, plan, 2);
|
|
|
|
|
+ this.appendOpportunityMarkdown(lines, plan, 3);
|
|
|
|
|
+ this.appendTopicsMarkdown(lines, plan, 4, true);
|
|
|
|
|
+ this.appendTestPlanMarkdown(lines, plan);
|
|
|
|
|
+ this.appendQualityMarkdown(lines, plan);
|
|
|
|
|
+ this.appendSupplementMarkdown(lines, plan);
|
|
|
|
|
+ return `${lines.join('\n').trim()}\n`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private buildClientMarkdown(plan: IpOperatorPlan): string {
|
|
|
|
|
+ const lines: string[] = [];
|
|
|
|
|
+ const profileName = this.profileDraft().name || '未命名 IP';
|
|
|
|
|
+ lines.push(`# ${profileName} IP 起步沟通版`);
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ lines.push(this.markdownStatusLine(plan));
|
|
|
|
|
+ if (plan.qualityCheck) lines.push(`- 当前质量分:${plan.qualityCheck.overallScore}/100`);
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ this.appendDiagnosisMarkdown(lines, plan, 1);
|
|
|
|
|
+ this.appendOpportunityMarkdown(lines, plan, 2);
|
|
|
|
|
+ this.appendTopicsMarkdown(lines, plan, 3, false, 6);
|
|
|
|
|
+ this.appendTestPlanMarkdown(lines, plan, 4);
|
|
|
|
|
+ if (plan.missingInputs.length) {
|
|
|
|
|
+ lines.push('## 5. 需要继续补充的资料');
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ for (const item of plan.missingInputs) {
|
|
|
|
|
+ lines.push(`- ${this.displayText(item.type)}:${this.displayText(item.description)}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ }
|
|
|
|
|
+ return `${lines.join('\n').trim()}\n`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private buildExecutionMarkdown(plan: IpOperatorPlan): string {
|
|
|
|
|
+ const lines: string[] = [];
|
|
|
|
|
+ const profileName = this.profileDraft().name || '未命名 IP';
|
|
|
|
|
+ lines.push(`# ${profileName} IP 内容执行版`);
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ lines.push(this.markdownStatusLine(plan));
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ this.appendTopicsMarkdown(lines, plan, 1, true);
|
|
|
|
|
+ this.appendFullScriptsMarkdown(lines, plan, 2);
|
|
|
|
|
+ this.appendOutlinesMarkdown(lines, plan, 3);
|
|
|
|
|
+ this.appendTestPlanMarkdown(lines, plan, 4);
|
|
|
|
|
+ return `${lines.join('\n').trim()}\n`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private appendDiagnosisMarkdown(lines: string[], plan: IpOperatorPlan, index: number): void {
|
|
|
|
|
+ lines.push(`## ${index}. 定位诊断`);
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ lines.push(`定位一句话:${this.displayText(plan.diagnosis?.positioningStatement)}`);
|
|
|
|
|
+ lines.push(`目标用户:${this.displayText(plan.diagnosis?.targetAudience?.summary)}`);
|
|
|
|
|
+ lines.push(`市场机会:${this.displayText(plan.diagnosis?.marketOpportunity)}`);
|
|
|
|
|
+ lines.push(`差异化:${this.displayText(plan.diagnosis?.differentiation)}`);
|
|
|
|
|
+ lines.push(`变现路径:${this.displayText(plan.diagnosis?.monetizationPath)}`);
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private appendBenchmarkMarkdown(lines: string[], plan: IpOperatorPlan, index: number): void {
|
|
|
|
|
+ lines.push(`## ${index}. 对标拆解`);
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ for (const item of plan.benchmarkAnalyses) {
|
|
|
|
|
+ lines.push(`### ${this.displayText(item.accountName)}(${item.fitScore}分)`);
|
|
|
|
|
+ lines.push(`- 定位:${this.displayText(item.positioning)}`);
|
|
|
|
|
+ lines.push(`- 可借鉴:${this.displayText(item.borrowablePoints)}`);
|
|
|
|
|
+ lines.push(`- 不可照搬:${this.displayText(item.nonCopyablePoints)}`);
|
|
|
|
|
+ lines.push(`- 迁移方向:${this.displayText(item.migrationDirection)}`);
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private appendOpportunityMarkdown(lines: string[], plan: IpOperatorPlan, index: number): void {
|
|
|
|
|
+ lines.push(`## ${index}. 机会地图`);
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ for (const item of plan.opportunityMap) {
|
|
|
|
|
+ lines.push(`### ${this.displayText(item.type)}(${item.fitScore}分)`);
|
|
|
|
|
+ lines.push(`- 理由:${this.displayText(item.reason)}`);
|
|
|
|
|
+ lines.push(`- 痛点:${this.displayText(item.audiencePainPoint)}`);
|
|
|
|
|
+ lines.push(`- 所需素材:${this.displayText(item.requiredMaterials)}`);
|
|
|
|
|
+ lines.push(`- 风险边界:${this.displayText(item.riskBoundary)}`);
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private appendTopicsMarkdown(lines: string[], plan: IpOperatorPlan, index: number, includeScript: boolean, limit = 12): void {
|
|
|
|
|
+ lines.push(`## ${index}. 选题与脚本`);
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ for (const topic of this.sortedTopicsForPlan(plan).slice(0, limit)) {
|
|
|
|
|
+ const script = plan.scripts.find((item) => item.topicId === topic.id);
|
|
|
|
|
+ lines.push(`### ${topic.title}`);
|
|
|
|
|
+ lines.push(`- 栏目:${this.displayText(topic.column)}`);
|
|
|
|
|
+ lines.push(`- 流量层级:${this.trafficLayerLabel(topic.trafficLayer)}`);
|
|
|
|
|
+ lines.push(`- 优先级:${this.priorityLabel(topic.priority)}`);
|
|
|
|
|
+ lines.push(`- 内容目标:${this.displayText(topic.contentGoal)}`);
|
|
|
|
|
+ lines.push(`- 适配理由:${this.displayText(topic.fitReason)}`);
|
|
|
|
|
+ lines.push(`- 所需素材:${this.displayText(topic.requiredMaterials)}`);
|
|
|
|
|
+ if (includeScript && script) {
|
|
|
|
|
+ lines.push(`- 开头钩子:${this.displayText(script.hook)}`);
|
|
|
|
|
+ lines.push(`- 观点:${this.displayText(script.viewpoint)}`);
|
|
|
|
|
+ if (script.fullScript) lines.push(`- 完整口播:${this.displayText(script.fullScript)}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private appendFullScriptsMarkdown(lines: string[], plan: IpOperatorPlan, index: number): void {
|
|
|
|
|
+ const scripts = plan.scripts.filter((script) => script.type === 'full');
|
|
|
|
|
+ lines.push(`## ${index}. 3 条完整口播`);
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ for (const script of scripts) {
|
|
|
|
|
+ lines.push(`### ${this.displayText(script.title || script.id)}`);
|
|
|
|
|
+ lines.push(`- 开头钩子:${this.displayText(script.hook)}`);
|
|
|
|
|
+ lines.push(`- 痛点:${this.displayText(script.painPoint)}`);
|
|
|
|
|
+ lines.push(`- 观点:${this.displayText(script.viewpoint)}`);
|
|
|
|
|
+ lines.push(`- 完整口播:${this.displayText(script.fullScript)}`);
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private appendOutlinesMarkdown(lines: string[], plan: IpOperatorPlan, index: number): void {
|
|
|
|
|
+ const scripts = plan.scripts.filter((script) => script.type !== 'full');
|
|
|
|
|
+ lines.push(`## ${index}. 9 条脚本大纲`);
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ for (const script of scripts) {
|
|
|
|
|
+ lines.push(`### ${this.displayText(script.title || script.id)}`);
|
|
|
|
|
+ lines.push(`- 钩子:${this.displayText(script.hook)}`);
|
|
|
|
|
+ lines.push(`- 痛点:${this.displayText(script.painPoint)}`);
|
|
|
|
|
+ lines.push(`- 观点:${this.displayText(script.viewpoint)}`);
|
|
|
|
|
+ lines.push(`- 案例/方法:${this.displayText(script.caseOrMethod)}`);
|
|
|
|
|
+ lines.push(`- 结尾:${this.displayText(script.closingCta)}`);
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private appendTestPlanMarkdown(lines: string[], plan: IpOperatorPlan, index?: number): void {
|
|
|
|
|
+ lines.push(index ? `## ${index}. 测试计划` : '## 测试计划');
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ for (const day of plan.sevenDayTestPlan) {
|
|
|
|
|
+ lines.push(`### 第 ${day.day} 天:${this.displayText(day.topicTitle)}`);
|
|
|
|
|
+ lines.push(`- 测试目标:${this.displayText(day.testGoal)}`);
|
|
|
|
|
+ lines.push(`- 拍摄重点:${this.displayText(day.shootingFocus)}`);
|
|
|
|
|
+ lines.push(`- 观察指标:${this.displayText(day.observeMetrics)}`);
|
|
|
|
|
+ lines.push(`- 复盘问题:${this.displayText(day.reviewQuestions)}`);
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private appendQualityMarkdown(lines: string[], plan: IpOperatorPlan): void {
|
|
|
|
|
+ lines.push('## 质量检查与待补充资料');
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ if (plan.qualityCheck) {
|
|
|
|
|
+ lines.push(`- 阻断问题:${this.displayText(plan.qualityCheck.blockingIssues)}`);
|
|
|
|
|
+ lines.push(`- 改进建议:${this.displayText(plan.qualityCheck.improvementSuggestions)}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const item of plan.missingInputs) {
|
|
|
|
|
+ lines.push(`- ${this.displayText(item.type)}:${this.displayText(item.description)}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private appendSupplementMarkdown(lines: string[], plan: IpOperatorPlan): void {
|
|
|
|
|
+ if (!plan.supplementInputs?.length) return;
|
|
|
|
|
+ lines.push('## 已补充资料');
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ for (const item of plan.supplementInputs) {
|
|
|
|
|
+ lines.push(`### ${this.displayText(item.title)}`);
|
|
|
|
|
+ lines.push(`- 类型:${this.displayText(item.type)}`);
|
|
|
|
|
+ lines.push(this.displayText(item.content));
|
|
|
|
|
+ lines.push('');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private markdownStatusLine(plan: IpOperatorPlan): string {
|
|
|
|
|
+ if (plan.status === 'failed') return '> 状态标注:生成失败草稿。内容仅供排查和继续打磨,不建议直接交付执行。';
|
|
|
|
|
+ if (plan.status === 'needs_input') return '> 状态标注:待完善草稿。需补充资料并重新质量检查后再进入生产链路。';
|
|
|
|
|
+ if (plan.status === 'ready') return '> 状态标注:可继续打磨方案。已通过当前质量门槛,可进入选题池继续制作。';
|
|
|
|
|
+ return '> 状态标注:草稿。';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private sortedTopicsForPlan(plan: IpOperatorPlan): IpTopic[] {
|
|
|
|
|
+ const priorityOrder: Record<string, number> = { high: 0, medium: 1, low: 2 };
|
|
|
|
|
+ return [...(plan.topics || [])].sort((a, b) => {
|
|
|
|
|
+ const byPriority = (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9);
|
|
|
|
|
+ return byPriority || a.title.localeCompare(b.title);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private timestampForFilename(): string {
|
|
|
|
|
+ const d = new Date();
|
|
|
|
|
+ const pad = (value: number) => String(value).padStart(2, '0');
|
|
|
|
|
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private downloadText(filename: string, text: string): void {
|
|
|
|
|
+ const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' });
|
|
|
|
|
+ const url = URL.createObjectURL(blob);
|
|
|
|
|
+ const link = document.createElement('a');
|
|
|
|
|
+ link.href = url;
|
|
|
|
|
+ link.download = filename.replace(/[\\/:*?"<>|]/g, '-');
|
|
|
|
|
+ link.click();
|
|
|
|
|
+ URL.revokeObjectURL(url);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private fallbackCopy(text: string): void {
|
|
|
|
|
+ const textarea = document.createElement('textarea');
|
|
|
|
|
+ textarea.value = text;
|
|
|
|
|
+ textarea.style.position = 'fixed';
|
|
|
|
|
+ textarea.style.left = '-9999px';
|
|
|
|
|
+ document.body.appendChild(textarea);
|
|
|
|
|
+ textarea.select();
|
|
|
|
|
+ document.execCommand('copy');
|
|
|
|
|
+ document.body.removeChild(textarea);
|
|
|
|
|
+ this.copyMessage.set('调试信息已复制');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ topicScript(topic: IpTopic): IpScript | null {
|
|
|
|
|
+ return this.activePlan()?.scripts.find((script) => script.topicId === topic.id) || null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ trackByIndex(index: number): number {
|
|
|
|
|
+ return index;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ trackById(_: number, item: { id: string }): string {
|
|
|
|
|
+ return item.id;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ trackByStage(_: number, item: { stage: string }): string {
|
|
|
|
|
+ return item.stage;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private toProfileInput(): Partial<IpOperatorProfile> {
|
|
|
|
|
+ const draft = this.profileDraft();
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: draft.id,
|
|
|
|
|
+ name: draft.name,
|
|
|
|
|
+ stage: draft.stage,
|
|
|
|
|
+ identity: draft.identity,
|
|
|
|
|
+ industry: draft.industry,
|
|
|
|
|
+ targetAudience: draft.targetAudience,
|
|
|
|
|
+ audiencePainPoints: this.toList(draft.audiencePainPointsText),
|
|
|
|
|
+ productsOrServices: draft.productsOrServices,
|
|
|
|
|
+ personalStories: this.toList(draft.personalStoriesText),
|
|
|
|
|
+ expertise: this.toList(draft.expertiseText),
|
|
|
|
|
+ expressionStyle: this.toList(draft.expressionStyleText),
|
|
|
|
|
+ boundaries: this.toList(draft.boundariesText),
|
|
|
|
|
+ goals: this.toList(draft.goalsText),
|
|
|
|
|
+ questionnaire: QUESTIONNAIRE.map((question, index) => ({
|
|
|
|
|
+ id: `q_${index + 1}`,
|
|
|
|
|
+ question,
|
|
|
|
|
+ answer: this.questionnaireAnswers()[index] || '',
|
|
|
|
|
+ })),
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private toBenchmarks(profileId: string): BenchmarkAccount[] {
|
|
|
|
|
+ const now = new Date().toISOString();
|
|
|
|
|
+ return this.benchmarkDrafts().filter((item) => this.hasBenchmarkSignal(item)).map((item, index) => ({
|
|
|
|
|
+ id: item.id || `benchmark_${index + 1}`,
|
|
|
|
|
+ profileId,
|
|
|
|
|
+ name: item.name,
|
|
|
|
|
+ url: item.url,
|
|
|
|
|
+ bio: item.bio,
|
|
|
|
|
+ reasonToBenchmark: item.reasonToBenchmark,
|
|
|
|
|
+ conversionNotes: item.conversionNotes,
|
|
|
|
|
+ viralExamples: [{
|
|
|
|
|
+ id: `viral_${index + 1}`,
|
|
|
|
|
+ title: item.viralTitle,
|
|
|
|
|
+ url: item.viralUrl,
|
|
|
|
|
+ performance: item.performance,
|
|
|
|
|
+ perceivedHook: item.perceivedHook,
|
|
|
|
|
+ notes: item.notes,
|
|
|
|
|
+ }],
|
|
|
|
|
+ createdAt: now,
|
|
|
|
|
+ updatedAt: now,
|
|
|
|
|
+ }));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private emptyProfileDraft(): ProfileDraft {
|
|
|
|
|
+ return {
|
|
|
|
|
+ name: '',
|
|
|
|
|
+ stage: 'new',
|
|
|
|
|
+ identity: '',
|
|
|
|
|
+ industry: '',
|
|
|
|
|
+ targetAudience: '',
|
|
|
|
|
+ audiencePainPointsText: '',
|
|
|
|
|
+ productsOrServices: '',
|
|
|
|
|
+ personalStoriesText: '',
|
|
|
|
|
+ expertiseText: '',
|
|
|
|
|
+ expressionStyleText: '',
|
|
|
|
|
+ boundariesText: '',
|
|
|
|
|
+ goalsText: '',
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private emptyBenchmarkDraft(): BenchmarkDraft {
|
|
|
|
|
+ return {
|
|
|
|
|
+ name: '',
|
|
|
|
|
+ url: '',
|
|
|
|
|
+ bio: '',
|
|
|
|
|
+ reasonToBenchmark: '',
|
|
|
|
|
+ viralTitle: '',
|
|
|
|
|
+ viralUrl: '',
|
|
|
|
|
+ performance: '',
|
|
|
|
|
+ perceivedHook: '',
|
|
|
|
|
+ notes: '',
|
|
|
|
|
+ conversionNotes: '',
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private hasBenchmarkSignal(item: BenchmarkDraft): boolean {
|
|
|
|
|
+ return !!(
|
|
|
|
|
+ item.name.trim()
|
|
|
|
|
+ || item.url.trim()
|
|
|
|
|
+ || item.viralUrl.trim()
|
|
|
|
|
+ || item.reasonToBenchmark.trim()
|
|
|
|
|
+ || item.viralTitle.trim()
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private toList(value: string): string[] {
|
|
|
|
|
+ return value.split(/\r?\n|,|、/).map((item) => item.trim()).filter(Boolean);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ protected readonly questionnaire = QUESTIONNAIRE;
|
|
|
|
|
+}
|