topic-pool.component.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. import { ChangeDetectionStrategy, Component, EventEmitter, OnInit, Output, computed, inject, signal } from '@angular/core';
  2. import { CommonModule } from '@angular/common';
  3. import { FormsModule } from '@angular/forms';
  4. import { TopicIdea } from '../../models/douyin-insight.model';
  5. import { TopicPoolService } from '../../services/topic-pool.service';
  6. import { TemplateService } from '../../services/template.service';
  7. type TopicStatusFilter = TopicIdea['status'] | 'all';
  8. type TopicSourceFilter = TopicIdea['sourceType'] | 'all';
  9. interface TopicEditDraft {
  10. title: string;
  11. angle: string;
  12. tagsText: string;
  13. hook: string;
  14. outline: string;
  15. shortOutline: string;
  16. }
  17. interface TopicGroup {
  18. key: string;
  19. sourceType: TopicIdea['sourceType'];
  20. sourceVideoId: string;
  21. sourceTitle: string;
  22. sourceSummary: string;
  23. topics: TopicIdea[];
  24. primary: TopicIdea;
  25. directionCount: number;
  26. updatedAt: string;
  27. tags: string[];
  28. }
  29. @Component({
  30. selector: 'app-topic-pool',
  31. standalone: true,
  32. imports: [CommonModule, FormsModule],
  33. changeDetection: ChangeDetectionStrategy.OnPush,
  34. templateUrl: './topic-pool.component.html',
  35. styleUrls: ['./topic-pool.component.css'],
  36. })
  37. export class TopicPoolComponent implements OnInit {
  38. @Output() navigateToPipeline = new EventEmitter<{ tab: 'topic-to-video' | 'digital-human'; topic: TopicIdea }>();
  39. @Output() sourceVideoOpen = new EventEmitter<{ awemeId: string; topic: TopicIdea }>();
  40. @Output() navigateToBatch = new EventEmitter<void>();
  41. private readonly topicPool = inject(TopicPoolService);
  42. private readonly templates = inject(TemplateService);
  43. readonly topics = signal<TopicIdea[]>([]);
  44. readonly searchTerm = signal('');
  45. readonly statusFilter = signal<TopicStatusFilter>('all');
  46. readonly sourceFilter = signal<TopicSourceFilter>('all');
  47. readonly editingTopicId = signal<string>('');
  48. readonly selectedTopicIds = signal<string[]>([]);
  49. readonly selectedTemplateId = signal<string>('');
  50. readonly activeGroupKey = signal<string>('');
  51. readonly editDraft = signal<TopicEditDraft>({
  52. title: '',
  53. angle: '',
  54. tagsText: '',
  55. hook: '',
  56. outline: '',
  57. shortOutline: '',
  58. });
  59. readonly stats = computed(() => {
  60. const topics = this.topics();
  61. return {
  62. all: topics.filter((item) => item.status !== 'archived').length,
  63. idea: topics.filter((item) => item.status === 'idea').length,
  64. scriptReady: topics.filter((item) => item.status === 'script_ready').length,
  65. generating: topics.filter((item) => item.status === 'generating').length,
  66. completed: topics.filter((item) => item.status === 'completed').length,
  67. };
  68. });
  69. readonly selectedCount = computed(() => this.selectedTopicIds().length);
  70. readonly topicTemplates = computed(() => this.templates.listByPipeline('topic-to-video'));
  71. readonly filteredTopics = computed(() => {
  72. const keyword = this.searchTerm().trim().toLowerCase();
  73. const status = this.statusFilter();
  74. const source = this.sourceFilter();
  75. return this.topics().filter((topic) => {
  76. if (status !== 'all' && topic.status !== status) return false;
  77. if (status === 'all' && topic.status === 'archived') return false;
  78. if (source !== 'all' && topic.sourceType !== source) return false;
  79. if (!keyword) return true;
  80. const haystack = [
  81. topic.title,
  82. topic.angle,
  83. topic.hook || '',
  84. topic.outline || '',
  85. topic.sourceTitle || '',
  86. topic.sourceSummary || '',
  87. ...(topic.sourceEvidence || []),
  88. ...(topic.sourceVideoIds || []),
  89. ...(topic.tags || []),
  90. ].join(' ').toLowerCase();
  91. return haystack.includes(keyword);
  92. });
  93. });
  94. readonly topicGroups = computed<TopicGroup[]>(() => this.groupTopics(this.filteredTopics()));
  95. readonly activeGroup = computed<TopicGroup | null>(() => this.topicGroups().find((group) => group.key === this.activeGroupKey()) || null);
  96. ngOnInit(): void {
  97. this.refresh();
  98. this.refreshFromCloud();
  99. }
  100. refresh(): void {
  101. this.topics.set(this.topicPool.listTopics());
  102. }
  103. async refreshFromCloud(): Promise<void> {
  104. try {
  105. this.topics.set(await this.topicPool.refreshFromCloud());
  106. } catch (err) {
  107. console.warn('[TopicPoolComponent] 云端选题同步失败,继续使用本地数据:', err);
  108. }
  109. }
  110. setStatusFilter(status: TopicStatusFilter): void {
  111. this.statusFilter.set(status);
  112. }
  113. setSourceFilter(source: TopicSourceFilter): void {
  114. this.sourceFilter.set(source);
  115. }
  116. updateStatus(topic: TopicIdea, status: TopicIdea['status']): void {
  117. this.topicPool.updateTopic(topic.id, { status });
  118. this.refresh();
  119. }
  120. archive(topic: TopicIdea): void {
  121. this.topicPool.archiveTopic(topic.id);
  122. this.refresh();
  123. }
  124. setRecommended(topic: TopicIdea): void {
  125. const key = this.topicGroupKey(topic);
  126. const groupTopics = this.topics().filter((item) => this.topicGroupKey(item) === key);
  127. for (const item of groupTopics) {
  128. this.topicPool.updateTopic(item.id, { isRecommended: item.id === topic.id });
  129. }
  130. this.refresh();
  131. }
  132. toggleTopicSelection(topic: TopicIdea, checked: boolean): void {
  133. const ids = this.selectedTopicIds();
  134. this.selectedTopicIds.set(checked
  135. ? Array.from(new Set([...ids, topic.id]))
  136. : ids.filter((id) => id !== topic.id));
  137. }
  138. isSelected(topic: TopicIdea): boolean {
  139. return this.selectedTopicIds().includes(topic.id);
  140. }
  141. openGroupDetail(group: TopicGroup): void {
  142. this.activeGroupKey.set(group.key);
  143. }
  144. closeGroupDetail(): void {
  145. this.activeGroupKey.set('');
  146. this.cancelEdit();
  147. }
  148. sendSelectedToBatch(): void {
  149. const selected = this.topics().filter((topic) => this.selectedTopicIds().includes(topic.id));
  150. if (!selected.length) return;
  151. const payload = selected.map((topic) => ({
  152. id: topic.id,
  153. title: topic.title,
  154. angle: topic.angle,
  155. hook: topic.hook || '',
  156. outline: topic.fullOutline || topic.outline || '',
  157. tags: topic.tags || [],
  158. }));
  159. localStorage.setItem('videoWorkflow.batchProduction.importTopics', JSON.stringify(payload));
  160. this.navigateToBatch.emit();
  161. }
  162. startEdit(topic: TopicIdea): void {
  163. this.editingTopicId.set(topic.id);
  164. this.editDraft.set({
  165. title: topic.title,
  166. angle: topic.angle,
  167. tagsText: (topic.tags || []).join(','),
  168. hook: topic.hook || '',
  169. outline: topic.outline || '',
  170. shortOutline: topic.shortOutline || '',
  171. });
  172. }
  173. cancelEdit(): void {
  174. this.editingTopicId.set('');
  175. }
  176. saveEdit(topic: TopicIdea): void {
  177. const draft = this.editDraft();
  178. this.topicPool.updateTopic(topic.id, {
  179. title: draft.title.trim() || topic.title,
  180. angle: draft.angle.trim() || topic.angle,
  181. tags: this.parseTags(draft.tagsText),
  182. hook: draft.hook.trim(),
  183. outline: draft.outline.trim(),
  184. fullOutline: draft.outline.trim(),
  185. shortOutline: draft.shortOutline.trim(),
  186. });
  187. this.editingTopicId.set('');
  188. this.refresh();
  189. }
  190. updateEditDraft(patch: Partial<TopicEditDraft>): void {
  191. this.editDraft.set({ ...this.editDraft(), ...patch });
  192. }
  193. openFirstSourceVideo(topic: TopicIdea): void {
  194. const awemeId = topic.sourceVideoIds?.[0] || '';
  195. if (!awemeId) return;
  196. this.closeGroupDetail();
  197. this.sourceVideoOpen.emit({ awemeId, topic });
  198. }
  199. openGroupSourceVideo(group: TopicGroup): void {
  200. this.openFirstSourceVideo(group.primary);
  201. }
  202. useForTopicVideo(topic: TopicIdea): void {
  203. this.persistSelectedTopic(topic);
  204. this.closeGroupDetail();
  205. this.navigateToPipeline.emit({ tab: 'topic-to-video', topic });
  206. }
  207. useForDigitalHuman(topic: TopicIdea): void {
  208. this.persistSelectedTopic(topic);
  209. this.closeGroupDetail();
  210. this.navigateToPipeline.emit({ tab: 'digital-human', topic });
  211. }
  212. groupSourceLabel(group: TopicGroup): string {
  213. return group.sourceType === 'viral_analysis' ? '爆款来源视频' : this.sourceLabel(group.sourceType);
  214. }
  215. groupStatusLabel(group: TopicGroup): string {
  216. const statuses = group.topics.map((topic) => topic.status);
  217. if (statuses.includes('generating')) return '生成中';
  218. if (statuses.includes('completed')) return '有成片';
  219. if (statuses.includes('script_ready')) return '脚本就绪';
  220. if (statuses.includes('idea')) return '待筛选';
  221. return this.statusLabel(group.primary.status);
  222. }
  223. sourceLabel(source: TopicIdea['sourceType']): string {
  224. const map: Record<TopicIdea['sourceType'], string> = {
  225. viral_analysis: '爆款分析',
  226. daily_report: '监测日报',
  227. assistant: 'AI 助手',
  228. manual: '手动创建',
  229. ip_operator: 'IP操盘',
  230. };
  231. return map[source] || source;
  232. }
  233. statusLabel(status: TopicIdea['status']): string {
  234. const map: Record<TopicIdea['status'], string> = {
  235. idea: '待写脚本',
  236. script_ready: '脚本已就绪',
  237. generating: '生成中',
  238. completed: '已完成',
  239. archived: '已归档',
  240. };
  241. return map[status] || status;
  242. }
  243. confidenceLabel(confidence?: TopicIdea['confidence']): string {
  244. if (confidence === 'high') return '高置信';
  245. if (confidence === 'medium') return '中置信';
  246. if (confidence === 'low') return '低置信';
  247. return '未标记';
  248. }
  249. topicVariantLabel(topic: TopicIdea): string {
  250. if (!topic.variantIndex || !topic.variantTotal || topic.variantTotal <= 1) return '';
  251. return `同源方向 ${topic.variantIndex}/${topic.variantTotal}`;
  252. }
  253. recommendedLabel(topic: TopicIdea, group: TopicGroup): string {
  254. return topic.id === group.primary.id ? '主推' : '';
  255. }
  256. shortOutline(topic: TopicIdea): string {
  257. return topic.shortOutline || this.firstLines(topic.outline || topic.fullOutline || '', 4);
  258. }
  259. compactOutline(topic: TopicIdea): string[] {
  260. return this.shortOutline(topic)
  261. .split(/\r?\n/)
  262. .map((line) => line.trim())
  263. .filter(Boolean)
  264. .slice(0, 2);
  265. }
  266. compactTags(group: TopicGroup): string[] {
  267. return group.tags.filter((tag) => !/^方向\d+\/\d+$/.test(tag)).slice(0, 4);
  268. }
  269. fullOutline(topic: TopicIdea): string {
  270. return topic.fullOutline || topic.outline || '';
  271. }
  272. hasProductionDetail(topic: TopicIdea): boolean {
  273. return !!(this.fullOutline(topic) || topic.sourceEvidence?.length || topic.sourceSummary);
  274. }
  275. formatTime(value: string): string {
  276. if (!value) return '';
  277. const date = new Date(value);
  278. if (Number.isNaN(date.getTime())) return '';
  279. const pad = (n: number) => n.toString().padStart(2, '0');
  280. return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
  281. }
  282. trackByTopic(_: number, topic: TopicIdea): string {
  283. return topic.id;
  284. }
  285. trackByGroup(_: number, group: TopicGroup): string {
  286. return group.key;
  287. }
  288. private persistSelectedTopic(topic: TopicIdea): void {
  289. localStorage.setItem('videoWorkflow.topicPool.selectedTopic', JSON.stringify(topic));
  290. const templateId = this.selectedTemplateId();
  291. if (templateId) {
  292. sessionStorage.setItem('t2v.prefillTemplateId', templateId);
  293. } else {
  294. sessionStorage.removeItem('t2v.prefillTemplateId');
  295. }
  296. }
  297. private parseTags(value: string): string[] {
  298. return String(value || '')
  299. .split(/[,,\s]+/)
  300. .map((item) => item.trim())
  301. .filter((item, index, list) => !!item && list.indexOf(item) === index)
  302. .slice(0, 12);
  303. }
  304. private firstLines(value: string, count: number): string {
  305. return String(value || '')
  306. .split(/\r?\n/)
  307. .map((line) => line.trim())
  308. .filter(Boolean)
  309. .slice(0, count)
  310. .join('\n');
  311. }
  312. private groupTopics(topics: TopicIdea[]): TopicGroup[] {
  313. const map = new Map<string, TopicIdea[]>();
  314. for (const topic of topics) {
  315. const key = this.topicGroupKey(topic);
  316. map.set(key, [...(map.get(key) || []), topic]);
  317. }
  318. return Array.from(map.entries())
  319. .map(([key, items]) => {
  320. const sorted = [...items].sort((a, b) => {
  321. if (!!b.isRecommended !== !!a.isRecommended) return Number(!!b.isRecommended) - Number(!!a.isRecommended);
  322. return Number(a.variantIndex || 999) - Number(b.variantIndex || 999)
  323. || Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
  324. });
  325. const primary = sorted.find((item) => item.isRecommended) || sorted[0];
  326. return {
  327. key,
  328. sourceType: primary.sourceType,
  329. sourceVideoId: primary.sourceVideoIds?.[0] || '',
  330. sourceTitle: this.groupTitle(sorted, primary),
  331. sourceSummary: primary.sourceSummary || primary.angle || primary.title,
  332. topics: sorted,
  333. primary,
  334. directionCount: sorted.length,
  335. updatedAt: sorted.reduce((latest, item) => Date.parse(item.updatedAt) > Date.parse(latest) ? item.updatedAt : latest, primary.updatedAt),
  336. tags: Array.from(new Set(sorted.flatMap((item) => item.tags || []))).slice(0, 8),
  337. };
  338. })
  339. .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
  340. }
  341. private topicGroupKey(topic: TopicIdea): string {
  342. if (topic.sourceType === 'viral_analysis' && topic.sourceVideoIds?.[0]) {
  343. return `viral:${topic.sourceVideoIds[0]}`;
  344. }
  345. return `${topic.sourceType}:${topic.sourceVideoIds?.[0] || topic.id}`;
  346. }
  347. private groupTitle(topics: TopicIdea[], primary: TopicIdea): string {
  348. const sourceTitle = topics.map((item) => item.sourceTitle).find(Boolean);
  349. if (sourceTitle) return sourceTitle;
  350. const videoId = primary.sourceVideoIds?.[0];
  351. if (videoId && primary.sourceType === 'viral_analysis') return `来源视频 ${videoId}`;
  352. return primary.title;
  353. }
  354. }