| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407 |
- 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<void>();
- private readonly topicPool = inject(TopicPoolService);
- private readonly templates = inject(TemplateService);
- readonly topics = signal<TopicIdea[]>([]);
- readonly searchTerm = signal('');
- readonly statusFilter = signal<TopicStatusFilter>('all');
- readonly sourceFilter = signal<TopicSourceFilter>('all');
- readonly editingTopicId = signal<string>('');
- readonly selectedTopicIds = signal<string[]>([]);
- readonly selectedTemplateId = signal<string>('');
- readonly activeGroupKey = signal<string>('');
- readonly editDraft = signal<TopicEditDraft>({
- 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<TopicGroup[]>(() => this.groupTopics(this.filteredTopics()));
- readonly activeGroup = computed<TopicGroup | null>(() => 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<void> {
- 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<TopicEditDraft>): 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<TopicIdea['sourceType'], string> = {
- viral_analysis: '爆款分析',
- daily_report: '监测日报',
- assistant: 'AI 助手',
- manual: '手动创建',
- ip_operator: 'IP操盘',
- };
- return map[source] || source;
- }
- statusLabel(status: TopicIdea['status']): string {
- const map: Record<TopicIdea['status'], string> = {
- 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<string, TopicIdea[]>();
- 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;
- }
- }
|