import { ChangeDetectionStrategy, Component, EventEmitter, OnInit, Output, computed, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { TopicIdea } from '../../models/douyin-insight.model'; import { TopicPoolService } from '../../services/topic-pool.service'; import { TemplateService } from '../../services/template.service'; type TopicStatusFilter = TopicIdea['status'] | 'all'; type TopicSourceFilter = TopicIdea['sourceType'] | 'all'; interface TopicEditDraft { title: string; angle: string; tagsText: string; hook: string; outline: string; shortOutline: string; } interface TopicGroup { key: string; sourceType: TopicIdea['sourceType']; sourceVideoId: string; sourceTitle: string; sourceSummary: string; topics: TopicIdea[]; primary: TopicIdea; directionCount: number; updatedAt: string; tags: string[]; } @Component({ selector: 'app-topic-pool', standalone: true, imports: [CommonModule, FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './topic-pool.component.html', styleUrls: ['./topic-pool.component.css'], }) export class TopicPoolComponent implements OnInit { @Output() navigateToPipeline = new EventEmitter<{ tab: 'topic-to-video' | 'digital-human'; topic: TopicIdea }>(); @Output() sourceVideoOpen = new EventEmitter<{ awemeId: string; topic: TopicIdea }>(); @Output() navigateToBatch = new EventEmitter(); private readonly topicPool = inject(TopicPoolService); private readonly templates = inject(TemplateService); readonly topics = signal([]); readonly searchTerm = signal(''); readonly statusFilter = signal('all'); readonly sourceFilter = signal('all'); readonly editingTopicId = signal(''); readonly selectedTopicIds = signal([]); readonly selectedTemplateId = signal(''); readonly activeGroupKey = signal(''); readonly editDraft = signal({ title: '', angle: '', tagsText: '', hook: '', outline: '', shortOutline: '', }); readonly stats = computed(() => { const topics = this.topics(); return { all: topics.filter((item) => item.status !== 'archived').length, idea: topics.filter((item) => item.status === 'idea').length, scriptReady: topics.filter((item) => item.status === 'script_ready').length, generating: topics.filter((item) => item.status === 'generating').length, completed: topics.filter((item) => item.status === 'completed').length, }; }); readonly selectedCount = computed(() => this.selectedTopicIds().length); readonly topicTemplates = computed(() => this.templates.listByPipeline('topic-to-video')); readonly filteredTopics = computed(() => { const keyword = this.searchTerm().trim().toLowerCase(); const status = this.statusFilter(); const source = this.sourceFilter(); return this.topics().filter((topic) => { if (status !== 'all' && topic.status !== status) return false; if (status === 'all' && topic.status === 'archived') return false; if (source !== 'all' && topic.sourceType !== source) return false; if (!keyword) return true; const haystack = [ topic.title, topic.angle, topic.hook || '', topic.outline || '', topic.sourceTitle || '', topic.sourceSummary || '', ...(topic.sourceEvidence || []), ...(topic.sourceVideoIds || []), ...(topic.tags || []), ].join(' ').toLowerCase(); return haystack.includes(keyword); }); }); readonly topicGroups = computed(() => this.groupTopics(this.filteredTopics())); readonly activeGroup = computed(() => this.topicGroups().find((group) => group.key === this.activeGroupKey()) || null); ngOnInit(): void { this.refresh(); this.refreshFromCloud(); } refresh(): void { this.topics.set(this.topicPool.listTopics()); } async refreshFromCloud(): Promise { try { this.topics.set(await this.topicPool.refreshFromCloud()); } catch (err) { console.warn('[TopicPoolComponent] 云端选题同步失败,继续使用本地数据:', err); } } setStatusFilter(status: TopicStatusFilter): void { this.statusFilter.set(status); } setSourceFilter(source: TopicSourceFilter): void { this.sourceFilter.set(source); } updateStatus(topic: TopicIdea, status: TopicIdea['status']): void { this.topicPool.updateTopic(topic.id, { status }); this.refresh(); } archive(topic: TopicIdea): void { this.topicPool.archiveTopic(topic.id); this.refresh(); } setRecommended(topic: TopicIdea): void { const key = this.topicGroupKey(topic); const groupTopics = this.topics().filter((item) => this.topicGroupKey(item) === key); for (const item of groupTopics) { this.topicPool.updateTopic(item.id, { isRecommended: item.id === topic.id }); } this.refresh(); } toggleTopicSelection(topic: TopicIdea, checked: boolean): void { const ids = this.selectedTopicIds(); this.selectedTopicIds.set(checked ? Array.from(new Set([...ids, topic.id])) : ids.filter((id) => id !== topic.id)); } isSelected(topic: TopicIdea): boolean { return this.selectedTopicIds().includes(topic.id); } openGroupDetail(group: TopicGroup): void { this.activeGroupKey.set(group.key); } closeGroupDetail(): void { this.activeGroupKey.set(''); this.cancelEdit(); } sendSelectedToBatch(): void { const selected = this.topics().filter((topic) => this.selectedTopicIds().includes(topic.id)); if (!selected.length) return; const payload = selected.map((topic) => ({ id: topic.id, title: topic.title, angle: topic.angle, hook: topic.hook || '', outline: topic.fullOutline || topic.outline || '', tags: topic.tags || [], })); localStorage.setItem('videoWorkflow.batchProduction.importTopics', JSON.stringify(payload)); this.navigateToBatch.emit(); } startEdit(topic: TopicIdea): void { this.editingTopicId.set(topic.id); this.editDraft.set({ title: topic.title, angle: topic.angle, tagsText: (topic.tags || []).join(','), hook: topic.hook || '', outline: topic.outline || '', shortOutline: topic.shortOutline || '', }); } cancelEdit(): void { this.editingTopicId.set(''); } saveEdit(topic: TopicIdea): void { const draft = this.editDraft(); this.topicPool.updateTopic(topic.id, { title: draft.title.trim() || topic.title, angle: draft.angle.trim() || topic.angle, tags: this.parseTags(draft.tagsText), hook: draft.hook.trim(), outline: draft.outline.trim(), fullOutline: draft.outline.trim(), shortOutline: draft.shortOutline.trim(), }); this.editingTopicId.set(''); this.refresh(); } updateEditDraft(patch: Partial): void { this.editDraft.set({ ...this.editDraft(), ...patch }); } openFirstSourceVideo(topic: TopicIdea): void { const awemeId = topic.sourceVideoIds?.[0] || ''; if (!awemeId) return; this.closeGroupDetail(); this.sourceVideoOpen.emit({ awemeId, topic }); } openGroupSourceVideo(group: TopicGroup): void { this.openFirstSourceVideo(group.primary); } useForTopicVideo(topic: TopicIdea): void { this.persistSelectedTopic(topic); this.closeGroupDetail(); this.navigateToPipeline.emit({ tab: 'topic-to-video', topic }); } useForDigitalHuman(topic: TopicIdea): void { this.persistSelectedTopic(topic); this.closeGroupDetail(); this.navigateToPipeline.emit({ tab: 'digital-human', topic }); } groupSourceLabel(group: TopicGroup): string { return group.sourceType === 'viral_analysis' ? '爆款来源视频' : this.sourceLabel(group.sourceType); } groupStatusLabel(group: TopicGroup): string { const statuses = group.topics.map((topic) => topic.status); if (statuses.includes('generating')) return '生成中'; if (statuses.includes('completed')) return '有成片'; if (statuses.includes('script_ready')) return '脚本就绪'; if (statuses.includes('idea')) return '待筛选'; return this.statusLabel(group.primary.status); } sourceLabel(source: TopicIdea['sourceType']): string { const map: Record = { viral_analysis: '爆款分析', daily_report: '监测日报', assistant: 'AI 助手', manual: '手动创建', ip_operator: 'IP操盘', }; return map[source] || source; } statusLabel(status: TopicIdea['status']): string { const map: Record = { idea: '待写脚本', script_ready: '脚本已就绪', generating: '生成中', completed: '已完成', archived: '已归档', }; return map[status] || status; } confidenceLabel(confidence?: TopicIdea['confidence']): string { if (confidence === 'high') return '高置信'; if (confidence === 'medium') return '中置信'; if (confidence === 'low') return '低置信'; return '未标记'; } topicVariantLabel(topic: TopicIdea): string { if (!topic.variantIndex || !topic.variantTotal || topic.variantTotal <= 1) return ''; return `同源方向 ${topic.variantIndex}/${topic.variantTotal}`; } recommendedLabel(topic: TopicIdea, group: TopicGroup): string { return topic.id === group.primary.id ? '主推' : ''; } shortOutline(topic: TopicIdea): string { return topic.shortOutline || this.firstLines(topic.outline || topic.fullOutline || '', 4); } compactOutline(topic: TopicIdea): string[] { return this.shortOutline(topic) .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .slice(0, 2); } compactTags(group: TopicGroup): string[] { return group.tags.filter((tag) => !/^方向\d+\/\d+$/.test(tag)).slice(0, 4); } fullOutline(topic: TopicIdea): string { return topic.fullOutline || topic.outline || ''; } hasProductionDetail(topic: TopicIdea): boolean { return !!(this.fullOutline(topic) || topic.sourceEvidence?.length || topic.sourceSummary); } formatTime(value: string): string { if (!value) return ''; const date = new Date(value); if (Number.isNaN(date.getTime())) return ''; const pad = (n: number) => n.toString().padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; } trackByTopic(_: number, topic: TopicIdea): string { return topic.id; } trackByGroup(_: number, group: TopicGroup): string { return group.key; } private persistSelectedTopic(topic: TopicIdea): void { localStorage.setItem('videoWorkflow.topicPool.selectedTopic', JSON.stringify(topic)); const templateId = this.selectedTemplateId(); if (templateId) { sessionStorage.setItem('t2v.prefillTemplateId', templateId); } else { sessionStorage.removeItem('t2v.prefillTemplateId'); } } private parseTags(value: string): string[] { return String(value || '') .split(/[,,\s]+/) .map((item) => item.trim()) .filter((item, index, list) => !!item && list.indexOf(item) === index) .slice(0, 12); } private firstLines(value: string, count: number): string { return String(value || '') .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .slice(0, count) .join('\n'); } private groupTopics(topics: TopicIdea[]): TopicGroup[] { const map = new Map(); for (const topic of topics) { const key = this.topicGroupKey(topic); map.set(key, [...(map.get(key) || []), topic]); } return Array.from(map.entries()) .map(([key, items]) => { const sorted = [...items].sort((a, b) => { if (!!b.isRecommended !== !!a.isRecommended) return Number(!!b.isRecommended) - Number(!!a.isRecommended); return Number(a.variantIndex || 999) - Number(b.variantIndex || 999) || Date.parse(b.updatedAt) - Date.parse(a.updatedAt); }); const primary = sorted.find((item) => item.isRecommended) || sorted[0]; return { key, sourceType: primary.sourceType, sourceVideoId: primary.sourceVideoIds?.[0] || '', sourceTitle: this.groupTitle(sorted, primary), sourceSummary: primary.sourceSummary || primary.angle || primary.title, topics: sorted, primary, directionCount: sorted.length, updatedAt: sorted.reduce((latest, item) => Date.parse(item.updatedAt) > Date.parse(latest) ? item.updatedAt : latest, primary.updatedAt), tags: Array.from(new Set(sorted.flatMap((item) => item.tags || []))).slice(0, 8), }; }) .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)); } private topicGroupKey(topic: TopicIdea): string { if (topic.sourceType === 'viral_analysis' && topic.sourceVideoIds?.[0]) { return `viral:${topic.sourceVideoIds[0]}`; } return `${topic.sourceType}:${topic.sourceVideoIds?.[0] || topic.id}`; } private groupTitle(topics: TopicIdea[], primary: TopicIdea): string { const sourceTitle = topics.map((item) => item.sourceTitle).find(Boolean); if (sourceTitle) return sourceTitle; const videoId = primary.sourceVideoIds?.[0]; if (videoId && primary.sourceType === 'viral_analysis') return `来源视频 ${videoId}`; return primary.title; } }