|
|
@@ -42,6 +42,12 @@ import { AssetRemixComponent } from './pages/pipelines/asset-remix/asset-remix.c
|
|
|
|
|
|
import { TopicToVideoComponent } from './pages/pipelines/topic-to-video/topic-to-video.component';
|
|
|
import { ImageGenerationComponent } from './pages/pipelines/image-generation/image-generation.component';
|
|
|
+import { TopicPoolComponent } from './pages/topic-pool/topic-pool.component';
|
|
|
+import { AnalysisHistoryComponent } from './pages/analysis-history/analysis-history.component';
|
|
|
+import { DailyReportHistoryComponent } from './pages/daily-report-history/daily-report-history.component';
|
|
|
+import { TemplatesComponent } from './pages/templates/templates.component';
|
|
|
+import { BatchProductionComponent } from './pages/batch-production/batch-production.component';
|
|
|
+import { RetrospectiveComponent } from './pages/retrospective/retrospective.component';
|
|
|
|
|
|
import { DraftsComponent } from './pages/drafts/drafts.component';
|
|
|
import { LibraryComponent } from './pages/library/library.component';
|
|
|
@@ -70,6 +76,10 @@ import { CostEstimatorService } from './services/cost-estimator.service';
|
|
|
import { GenerationTaskService } from './services/generation-task.service';
|
|
|
import { GenerationTaskCost, GenerationTaskStatus, GenerationTaskStep } from './models/generation-task.model';
|
|
|
import { userFailureSuggestion, userFriendlyError } from './services/user-message.util';
|
|
|
+import { ViralAnalysisService } from './services/viral-analysis.service';
|
|
|
+import { TopicPoolService } from './services/topic-pool.service';
|
|
|
+import { DailyReport, TopicIdea, ViralAnalysis } from './models/douyin-insight.model';
|
|
|
+import { DailyReportService } from './services/daily-report.service';
|
|
|
|
|
|
|
|
|
|
|
|
@@ -169,6 +179,10 @@ interface Task {
|
|
|
|
|
|
creditStatus?: GenerationTaskCost['status'];
|
|
|
|
|
|
+ costLines?: GenerationTaskCost['lines'];
|
|
|
+
|
|
|
+ refundReason?: string;
|
|
|
+
|
|
|
operation?: string;
|
|
|
|
|
|
pipelineId?: string;
|
|
|
@@ -630,6 +644,10 @@ interface MonitorWork {
|
|
|
|
|
|
downloadProgress?: number;
|
|
|
|
|
|
+ isNew?: boolean;
|
|
|
+
|
|
|
+ firstSeenAt?: string;
|
|
|
+
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -678,6 +696,12 @@ interface MonitorAuthor {
|
|
|
|
|
|
loadingWorks: boolean;
|
|
|
|
|
|
+ lastCheckedAt?: string;
|
|
|
+
|
|
|
+ lastSeenAwemeIds: string[];
|
|
|
+
|
|
|
+ newWorksCount: number;
|
|
|
+
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -712,6 +736,12 @@ interface MonitorAuthor {
|
|
|
|
|
|
TopicToVideoComponent,
|
|
|
ImageGenerationComponent,
|
|
|
+ TopicPoolComponent,
|
|
|
+ AnalysisHistoryComponent,
|
|
|
+ DailyReportHistoryComponent,
|
|
|
+ TemplatesComponent,
|
|
|
+ BatchProductionComponent,
|
|
|
+ RetrospectiveComponent,
|
|
|
|
|
|
DraftsComponent,
|
|
|
|
|
|
@@ -756,6 +786,9 @@ export class App {
|
|
|
private readonly voiceApi = inject(VoiceApiService);
|
|
|
private readonly costEstimator = inject(CostEstimatorService);
|
|
|
private readonly generationTasks = inject(GenerationTaskService);
|
|
|
+ private readonly viralAnalysis = inject(ViralAnalysisService);
|
|
|
+ private readonly topicPool = inject(TopicPoolService);
|
|
|
+ private readonly dailyReports = inject(DailyReportService);
|
|
|
|
|
|
|
|
|
protected readonly title = signal('抖音AI视频生成系统');
|
|
|
@@ -764,7 +797,7 @@ export class App {
|
|
|
|
|
|
// 当前激活的标签页
|
|
|
|
|
|
- currentTab: 'home' | 'search' | 'monitor' | 'tasks' | 'history' | 'results' | 'videos' | 'voice-synthesis' | 'pipelines' | 'digital-human' | 'image-generation' | 'video-generation' | 'image-to-video' | 'action-transfer' | 'asset-remix' | 'topic-to-video' | 'drafts' | 'library' | 'login' | 'register' | 'user-center' | 'admin-users' = 'home';
|
|
|
+ currentTab: 'home' | 'search' | 'monitor' | 'topic-pool' | 'retrospective' | 'analysis-history' | 'daily-reports' | 'tasks' | 'history' | 'results' | 'videos' | 'voice-synthesis' | 'pipelines' | 'batch-production' | 'digital-human' | 'image-generation' | 'video-generation' | 'image-to-video' | 'action-transfer' | 'asset-remix' | 'topic-to-video' | 'drafts' | 'library' | 'templates' | 'login' | 'register' | 'user-center' | 'admin-users' = 'home';
|
|
|
|
|
|
authModalOpen = false;
|
|
|
authModalFeature = '该功能';
|
|
|
@@ -775,7 +808,7 @@ export class App {
|
|
|
readonly sidebarPipelines = getSidebarPipelines();
|
|
|
|
|
|
private readonly TAB_STORAGE_KEY = 'tiktok.currentTab';
|
|
|
- private readonly AUTH_REQUIRED_TABS = new Set(['drafts', 'library', 'videos', 'monitor']);
|
|
|
+ private readonly AUTH_REQUIRED_TABS = new Set(['drafts', 'videos', 'monitor', 'analysis-history', 'daily-reports']);
|
|
|
|
|
|
|
|
|
|
|
|
@@ -811,6 +844,16 @@ export class App {
|
|
|
|
|
|
hasMore: boolean = false;
|
|
|
|
|
|
+ isViralAnalysisOpen: boolean = false;
|
|
|
+
|
|
|
+ isAnalyzingViralVideo: boolean = false;
|
|
|
+
|
|
|
+ viralAnalysisError: string = '';
|
|
|
+
|
|
|
+ currentViralAnalysis: ViralAnalysis | null = null;
|
|
|
+
|
|
|
+ viralAnalysisSourceTitle: string = '';
|
|
|
+
|
|
|
monitorUniqueIdInput: string = '';
|
|
|
|
|
|
monitorAuthors: MonitorAuthor[] = [];
|
|
|
@@ -821,6 +864,12 @@ export class App {
|
|
|
|
|
|
monitorAddError: string = '';
|
|
|
|
|
|
+ isDailyReportOpen: boolean = false;
|
|
|
+
|
|
|
+ currentDailyReport: DailyReport | null = null;
|
|
|
+
|
|
|
+ isDailyReportGenerating: boolean = false;
|
|
|
+
|
|
|
private readonly voiceApiToken: string = 'Bearer r:f0333969e312a40e4703e8fe4ed1c600';
|
|
|
|
|
|
private readonly voiceTtsBaseUrl: string = 'https://server.fmode.cn/api/volcengine/tts';
|
|
|
@@ -1629,9 +1678,19 @@ export class App {
|
|
|
this.auth.session$.subscribe(() => {
|
|
|
void this.applyDraftStorageForCurrentUser();
|
|
|
this.restorePersistedMonitorState();
|
|
|
+ void this.restoreLatestDailyReport();
|
|
|
this.refreshIncompleteMonitorAuthors();
|
|
|
});
|
|
|
|
|
|
+ this.generationTasks.tasks$.subscribe((tasks) => {
|
|
|
+ const generated = tasks.map(task => this.generationTaskToLegacyTask(task));
|
|
|
+ const manualOnly = this.activeTasks.filter(task => !tasks.some(item => item.id === task.id));
|
|
|
+ this.activeTasks = [...generated, ...manualOnly]
|
|
|
+ .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
|
|
|
+ this.updateTaskStats();
|
|
|
+ this.refreshView();
|
|
|
+ });
|
|
|
+
|
|
|
this.initializeDefaults();
|
|
|
|
|
|
this.restoreTheme();
|
|
|
@@ -1641,6 +1700,7 @@ export class App {
|
|
|
this.restoreClonedTimbreList();
|
|
|
|
|
|
this.restorePersistedMonitorState();
|
|
|
+ void this.restoreLatestDailyReport();
|
|
|
|
|
|
this.refreshIncompleteMonitorAuthors();
|
|
|
|
|
|
@@ -2078,7 +2138,7 @@ export class App {
|
|
|
|
|
|
const savedTab = localStorage.getItem(this.TAB_STORAGE_KEY);
|
|
|
|
|
|
- const allowed = ['home','search','monitor','tasks','history','results','videos','voice-synthesis','pipelines','digital-human','image-generation','video-generation','image-to-video','action-transfer','asset-remix','topic-to-video','drafts','library','login','register','user-center','admin-users'];
|
|
|
+ const allowed = ['home','search','monitor','tasks','history','results','videos','voice-synthesis','pipelines','batch-production','digital-human','image-generation','video-generation','image-to-video','action-transfer','asset-remix','topic-to-video','drafts','library','templates','topic-pool','retrospective','analysis-history','daily-reports','login','register','user-center','admin-users'];
|
|
|
|
|
|
// 旧 tab → 新页面的迁移映射(M4/M5/M6 重组后)
|
|
|
// 'history' 完整功能被「我的创作」覆盖;'results' 被「素材库」替代。
|
|
|
@@ -2174,7 +2234,7 @@ export class App {
|
|
|
|
|
|
|
|
|
|
|
|
- setCurrentTab(tab: 'home' | 'search' | 'monitor' | 'tasks' | 'history' | 'results' | 'videos' | 'voice-synthesis' | 'pipelines' | 'digital-human' | 'image-generation' | 'video-generation' | 'image-to-video' | 'action-transfer' | 'asset-remix' | 'topic-to-video' | 'drafts' | 'library' | 'login' | 'register' | 'user-center' | 'admin-users'): void {
|
|
|
+ setCurrentTab(tab: 'home' | 'search' | 'monitor' | 'topic-pool' | 'retrospective' | 'analysis-history' | 'daily-reports' | 'tasks' | 'history' | 'results' | 'videos' | 'voice-synthesis' | 'pipelines' | 'batch-production' | 'digital-human' | 'image-generation' | 'video-generation' | 'image-to-video' | 'action-transfer' | 'asset-remix' | 'topic-to-video' | 'drafts' | 'library' | 'templates' | 'login' | 'register' | 'user-center' | 'admin-users'): void {
|
|
|
|
|
|
if (tab === 'admin-users' && !this.auth.isAdmin) {
|
|
|
tab = this.auth.isLoggedIn ? 'user-center' : 'login';
|
|
|
@@ -2258,10 +2318,50 @@ export class App {
|
|
|
/** 助手内回复中的「直达 X 模式」按钮被点击 */
|
|
|
onAssistantSuggestion(s: AssistantSuggestion): void {
|
|
|
if (!s?.tab) return;
|
|
|
+ if (s.payload?.['saveTopic']) {
|
|
|
+ this.saveAssistantSuggestionToTopicPool(s);
|
|
|
+ return;
|
|
|
+ }
|
|
|
// 复用首屏跳转通道:含 payload 时同样走 sessionStorage 预填
|
|
|
this.onHomeNavigate({ tab: s.tab, payload: s.payload });
|
|
|
}
|
|
|
|
|
|
+ private saveAssistantSuggestionToTopicPool(s: AssistantSuggestion): void {
|
|
|
+ if (!this.auth.isLoggedIn) {
|
|
|
+ this.auth.requestLogin('保存选题');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const payload = s.payload || {};
|
|
|
+ const title = String(payload['title'] || payload['topic'] || s.label || '').trim();
|
|
|
+ const angle = String(payload['angle'] || payload['description'] || payload['topic'] || title).trim();
|
|
|
+ const tagsValue = payload['tags'];
|
|
|
+ const tags = Array.isArray(tagsValue)
|
|
|
+ ? tagsValue.map((tag) => String(tag || '').trim()).filter(Boolean)
|
|
|
+ : String(tagsValue || '').split(/[,,\s]+/).map((tag) => tag.trim()).filter(Boolean);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const topic = this.topicPool.saveTopic({
|
|
|
+ title: title || 'AI 助手选题',
|
|
|
+ angle: angle || title || '来自 AI 助手的选题建议',
|
|
|
+ sourceType: 'assistant',
|
|
|
+ sourceVideoIds: [],
|
|
|
+ sourceAuthorIds: [],
|
|
|
+ tags: Array.from(new Set(['AI助手', ...tags])).slice(0, 12),
|
|
|
+ audience: String(payload['audience'] || ''),
|
|
|
+ hook: String(payload['hook'] || title || ''),
|
|
|
+ outline: String(payload['outline'] || payload['scriptFrame'] || ''),
|
|
|
+ status: 'idea',
|
|
|
+ pipelineTarget: 'topic_video',
|
|
|
+ confidence: 'medium',
|
|
|
+ });
|
|
|
+ this.setCurrentTab('topic-pool');
|
|
|
+ this.showToast(`已保存选题:${topic.title}`, 'success', 3000);
|
|
|
+ } catch (err: any) {
|
|
|
+ this.showToast(err?.message || '保存选题失败,请稍后重试', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 「我的创作」页里点「继续编辑」/「再做一版」时调用。
|
|
|
* 此时 PipelineSessionService.active 已是目标 draft,
|
|
|
@@ -2280,7 +2380,46 @@ export class App {
|
|
|
this.setCurrentTab('drafts');
|
|
|
}
|
|
|
|
|
|
+ onLibraryRetrospective(ev: { assetKey: string }): void {
|
|
|
+ try {
|
|
|
+ localStorage.setItem('videoWorkflow.retrospective.selectedAssetKey', ev.assetKey);
|
|
|
+ } catch {}
|
|
|
+ this.setCurrentTab('retrospective');
|
|
|
+ }
|
|
|
+
|
|
|
/** 首屏组件输出:可能带 payload(如预填的主题) */
|
|
|
+ onTopicPoolNavigate(ev: { tab: 'topic-to-video' | 'digital-human'; topic: TopicIdea }): void {
|
|
|
+ if (ev.tab === 'topic-to-video') {
|
|
|
+ try {
|
|
|
+ sessionStorage.setItem('t2v.prefillTopic', ev.topic.title || ev.topic.angle);
|
|
|
+ } catch {}
|
|
|
+ }
|
|
|
+ this.setCurrentTab(ev.tab);
|
|
|
+ this.showToast(`已带入选题:${ev.topic.title}`, 'success', 3000);
|
|
|
+ }
|
|
|
+
|
|
|
+ onTopicSourceVideoOpen(ev: { awemeId: string; topic: TopicIdea }): void {
|
|
|
+ const video: VideoInfo = {
|
|
|
+ aweme_id: ev.awemeId,
|
|
|
+ desc: ev.topic.title || ev.topic.angle || ev.awemeId,
|
|
|
+ cover_url: '',
|
|
|
+ cover_candidates: [],
|
|
|
+ duration: 0,
|
|
|
+ author: {
|
|
|
+ nickname: '来源视频',
|
|
|
+ sec_uid: ev.topic.sourceAuthorIds?.[0] || '',
|
|
|
+ },
|
|
|
+ statistics: {
|
|
|
+ play_count: 0,
|
|
|
+ digg_count: 0,
|
|
|
+ comment_count: 0,
|
|
|
+ share_count: 0,
|
|
|
+ },
|
|
|
+ text_extra: ev.topic.tags || [],
|
|
|
+ };
|
|
|
+ this.viewVideoDetail(video);
|
|
|
+ }
|
|
|
+
|
|
|
onHomeNavigate(ev: { tab: string; payload?: any }): void {
|
|
|
this.setCurrentTab(ev.tab as any);
|
|
|
// 主题预填:暂存到 sessionStorage,topic-to-video 组件初始化时自取
|
|
|
@@ -2636,6 +2775,8 @@ export class App {
|
|
|
generationStatus: task.generationStatus || this.legacyStatusToGenerationStatus(task.status),
|
|
|
estimatedCredits,
|
|
|
creditStatus: task.creditStatus || (estimatedCredits > 0 ? 'estimated' : 'unknown'),
|
|
|
+ costLines: task.costLines,
|
|
|
+ refundReason: task.refundReason,
|
|
|
error_message: normalizedError,
|
|
|
failureSuggestion: task.failureSuggestion || (task.error_message ? userFailureSuggestion(task.error_message) : undefined),
|
|
|
retryable: task.retryable ?? (task.status === 'failed' || task.status === 'timeout'),
|
|
|
@@ -2657,6 +2798,8 @@ export class App {
|
|
|
reservedCredits: task.cost.reservedCredits,
|
|
|
creditReservationId: task.cost.reservationId,
|
|
|
creditStatus: task.cost.status,
|
|
|
+ costLines: task.cost.lines,
|
|
|
+ refundReason: task.cost.refundReason,
|
|
|
operation: task.operation,
|
|
|
pipelineId: task.pipelineId,
|
|
|
generationStatus: task.status,
|
|
|
@@ -2725,6 +2868,31 @@ export class App {
|
|
|
|
|
|
// 保存任务到后端(返回 Promise,确保 POST 完成后再允许 PUT)
|
|
|
|
|
|
+ getTaskCostLines(task: Task): NonNullable<GenerationTaskCost['lines']> {
|
|
|
+ return Array.isArray(task.costLines) ? task.costLines : [];
|
|
|
+ }
|
|
|
+
|
|
|
+ getTaskCostLineText(line: NonNullable<GenerationTaskCost['lines']>[number]): string {
|
|
|
+ const quantity = line.quantity ? ` x ${line.quantity}${line.unit || ''}` : '';
|
|
|
+ return `${line.label}${quantity}: ${Math.max(0, Math.ceil(Number(line.credits || 0)))} 积分`;
|
|
|
+ }
|
|
|
+
|
|
|
+ getTaskStepDurationText(step: GenerationTaskStep): string {
|
|
|
+ if (!step.startedAt) return '';
|
|
|
+ const end = step.finishedAt || Date.now();
|
|
|
+ const seconds = Math.max(1, Math.round((end - step.startedAt) / 1000));
|
|
|
+ if (seconds < 60) return `${seconds}s`;
|
|
|
+ const minutes = Math.floor(seconds / 60);
|
|
|
+ const rest = seconds % 60;
|
|
|
+ return rest ? `${minutes}m ${rest}s` : `${minutes}m`;
|
|
|
+ }
|
|
|
+
|
|
|
+ getTaskExternalIds(task: Task): Array<{ key: string; value: string }> {
|
|
|
+ return Object.entries(task.externalTaskIds || {})
|
|
|
+ .filter(([, value]) => !!value)
|
|
|
+ .map(([key, value]) => ({ key, value }));
|
|
|
+ }
|
|
|
+
|
|
|
saveTask(task: Task): void {
|
|
|
|
|
|
task = this.hydrateTaskForGenerationCenter(task);
|
|
|
@@ -3766,6 +3934,168 @@ export class App {
|
|
|
|
|
|
}
|
|
|
|
|
|
+ getHomeMonitorSummary(): { authorCount: number; workCount: number; newWorkCount: number; lastCheckedAt?: string; hasDailyReport?: boolean; dailyReportDate?: string } {
|
|
|
+
|
|
|
+ const workCount = this.getTotalMonitorWorksCount();
|
|
|
+
|
|
|
+ const newWorkCount = this.monitorAuthors.reduce((total, author) => total + Number(author.newWorksCount || 0), 0);
|
|
|
+
|
|
|
+ const lastCheckedAt = this.monitorAuthors
|
|
|
+ .map((author) => author.lastCheckedAt || '')
|
|
|
+ .filter(Boolean)
|
|
|
+ .sort()
|
|
|
+ .pop();
|
|
|
+
|
|
|
+ return {
|
|
|
+ authorCount: this.monitorAuthors.length,
|
|
|
+ workCount,
|
|
|
+ newWorkCount,
|
|
|
+ lastCheckedAt,
|
|
|
+ hasDailyReport: !!this.currentDailyReport,
|
|
|
+ dailyReportDate: this.currentDailyReport?.date,
|
|
|
+ };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ generateMonitorDailyReport(): void {
|
|
|
+
|
|
|
+ if (this.monitorAuthors.length === 0) {
|
|
|
+
|
|
|
+ this.showToast('请先添加需要监测的博主', 'warn');
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.getTotalMonitorWorksCount() === 0) {
|
|
|
+
|
|
|
+ this.showToast('请先加载博主作品后再生成日报', 'warn');
|
|
|
+
|
|
|
+ return;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ this.isDailyReportGenerating = true;
|
|
|
+
|
|
|
+ this.dailyReports.generateMonitorReportWithComments(this.monitorAuthors).subscribe({
|
|
|
+ next: (report) => {
|
|
|
+ this.currentDailyReport = report;
|
|
|
+ this.isDailyReportOpen = true;
|
|
|
+ this.showToast('监测日报已生成', 'success');
|
|
|
+ this.refreshView();
|
|
|
+ },
|
|
|
+ error: (err: any) => {
|
|
|
+ this.isDailyReportGenerating = false;
|
|
|
+ this.showToast(err?.message || '生成日报失败,请稍后重试', 'error');
|
|
|
+ this.refreshView();
|
|
|
+ },
|
|
|
+ complete: () => {
|
|
|
+ this.isDailyReportGenerating = false;
|
|
|
+ this.refreshView();
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ closeDailyReport(): void {
|
|
|
+
|
|
|
+ this.isDailyReportOpen = false;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ openSavedDailyReport(report: DailyReport): void {
|
|
|
+
|
|
|
+ this.currentDailyReport = report;
|
|
|
+ this.isDailyReportOpen = true;
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ private async restoreLatestDailyReport(): Promise<void> {
|
|
|
+
|
|
|
+ if (!this.auth.isLoggedIn) {
|
|
|
+ this.currentDailyReport = null;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const reports = await this.dailyReports.refreshReportsFromCloud();
|
|
|
+ this.currentDailyReport = reports[0] || this.dailyReports.listReports()[0] || null;
|
|
|
+ this.refreshView();
|
|
|
+ } catch {
|
|
|
+ this.currentDailyReport = this.dailyReports.listReports()[0] || null;
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ saveDailyReportTopics(): void {
|
|
|
+
|
|
|
+ const report = this.currentDailyReport;
|
|
|
+
|
|
|
+ if (!report || !report.topTopics?.length) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ const saved = report.topTopics.map((topic) => this.topicPool.saveTopic({
|
|
|
+
|
|
|
+ title: topic.title,
|
|
|
+
|
|
|
+ angle: topic.angle,
|
|
|
+
|
|
|
+ sourceType: 'daily_report',
|
|
|
+
|
|
|
+ sourceVideoIds: topic.sourceVideoIds,
|
|
|
+
|
|
|
+ sourceAuthorIds: topic.sourceAuthorIds,
|
|
|
+
|
|
|
+ tags: topic.tags,
|
|
|
+
|
|
|
+ audience: topic.audience,
|
|
|
+
|
|
|
+ hook: topic.hook,
|
|
|
+
|
|
|
+ outline: topic.outline,
|
|
|
+
|
|
|
+ status: 'idea',
|
|
|
+
|
|
|
+ pipelineTarget: topic.pipelineTarget,
|
|
|
+
|
|
|
+ confidence: topic.confidence,
|
|
|
+
|
|
|
+ }));
|
|
|
+
|
|
|
+ this.showToast(`已保存 ${saved.length} 个日报选题`, 'success');
|
|
|
+
|
|
|
+ } catch (err: any) {
|
|
|
+
|
|
|
+ this.showToast(err?.message || '保存日报选题失败,请稍后重试', 'error');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ openDailyReportTopic(topic: TopicIdea): void {
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ sessionStorage.setItem('t2v.prefillTopic', topic.title || topic.angle);
|
|
|
+
|
|
|
+ } catch {}
|
|
|
+
|
|
|
+ this.closeDailyReport();
|
|
|
+
|
|
|
+ this.setCurrentTab('topic-to-video');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
|
|
|
|
|
|
reloadMonitorAuthorWorks(author: MonitorAuthor): void {
|
|
|
@@ -4664,11 +4994,25 @@ export class App {
|
|
|
|
|
|
|
|
|
|
|
|
+ const previousSeenIds = new Set([...(author.lastSeenAwemeIds || []), ...author.works.map(work => work.id)]);
|
|
|
+ const now = new Date().toISOString();
|
|
|
+ const nextWorks = mappedWorks.map(work => ({
|
|
|
+ ...work,
|
|
|
+ isNew: !append && previousSeenIds.size > 0 && !previousSeenIds.has(work.id),
|
|
|
+ firstSeenAt: previousSeenIds.has(work.id) ? work.firstSeenAt : now,
|
|
|
+ }));
|
|
|
+
|
|
|
author.works = append
|
|
|
|
|
|
- ? [...author.works, ...mappedWorks.filter(item => !author.works.some(existing => existing.id === item.id))]
|
|
|
+ ? [...author.works, ...nextWorks.filter(item => !author.works.some(existing => existing.id === item.id))]
|
|
|
+
|
|
|
+ : nextWorks;
|
|
|
+
|
|
|
+ author.newWorksCount = append ? (author.newWorksCount || 0) : nextWorks.filter(work => work.isNew).length;
|
|
|
|
|
|
- : mappedWorks;
|
|
|
+ author.lastCheckedAt = now;
|
|
|
+
|
|
|
+ author.lastSeenAwemeIds = Array.from(new Set(author.works.map(work => work.id).filter(Boolean)));
|
|
|
|
|
|
author.worksCursor = parsed.cursor;
|
|
|
|
|
|
@@ -4908,7 +5252,13 @@ export class App {
|
|
|
|
|
|
hasMoreWorks: false,
|
|
|
|
|
|
- loadingWorks: false
|
|
|
+ loadingWorks: false,
|
|
|
+
|
|
|
+ lastCheckedAt: '',
|
|
|
+
|
|
|
+ lastSeenAwemeIds: [],
|
|
|
+
|
|
|
+ newWorksCount: 0
|
|
|
|
|
|
};
|
|
|
|
|
|
@@ -5136,11 +5486,21 @@ export class App {
|
|
|
|
|
|
createTime: work.createTime ? work.createTime.toISOString() : null,
|
|
|
|
|
|
+ firstSeenAt: work.firstSeenAt || null,
|
|
|
+
|
|
|
+ isNew: !!work.isNew,
|
|
|
+
|
|
|
isDownloading: false,
|
|
|
|
|
|
downloadProgress: 0
|
|
|
|
|
|
- }))
|
|
|
+ })),
|
|
|
+
|
|
|
+ lastCheckedAt: author.lastCheckedAt || '',
|
|
|
+
|
|
|
+ lastSeenAwemeIds: author.lastSeenAwemeIds || [],
|
|
|
+
|
|
|
+ newWorksCount: author.newWorksCount || 0
|
|
|
|
|
|
};
|
|
|
|
|
|
@@ -5202,6 +5562,10 @@ export class App {
|
|
|
|
|
|
createTime: work?.createTime ? new Date(work.createTime) : undefined,
|
|
|
|
|
|
+ firstSeenAt: String(work?.firstSeenAt || ''),
|
|
|
+
|
|
|
+ isNew: !!work?.isNew,
|
|
|
+
|
|
|
type: work?.type === 'image' ? 'image' : 'video',
|
|
|
|
|
|
statistics: {
|
|
|
@@ -5228,7 +5592,13 @@ export class App {
|
|
|
|
|
|
hasMoreWorks: !!author?.hasMoreWorks,
|
|
|
|
|
|
- loadingWorks: false
|
|
|
+ loadingWorks: false,
|
|
|
+
|
|
|
+ lastCheckedAt: String(author?.lastCheckedAt || ''),
|
|
|
+
|
|
|
+ lastSeenAwemeIds: Array.isArray(author?.lastSeenAwemeIds) ? author.lastSeenAwemeIds.map((id: any) => String(id)).filter(Boolean) : [],
|
|
|
+
|
|
|
+ newWorksCount: Number(author?.newWorksCount || 0)
|
|
|
|
|
|
};
|
|
|
|
|
|
@@ -5484,6 +5854,196 @@ export class App {
|
|
|
|
|
|
// 下载抖音视频到本地
|
|
|
|
|
|
+ analyzeSearchVideo(video: VideoInfo): void {
|
|
|
+
|
|
|
+ this.startViralAnalysis({
|
|
|
+ awemeId: video.aweme_id,
|
|
|
+ source: 'search',
|
|
|
+ video: {
|
|
|
+ awemeId: video.aweme_id,
|
|
|
+ desc: video.desc,
|
|
|
+ authorName: video.author?.nickname || '',
|
|
|
+ authorId: video.author?.sec_uid || '',
|
|
|
+ diggCount: video.statistics?.digg_count || 0,
|
|
|
+ commentCount: video.statistics?.comment_count || 0,
|
|
|
+ shareCount: video.statistics?.share_count || 0,
|
|
|
+ playCount: video.statistics?.play_count || 0,
|
|
|
+ createTime: video.create_time ? new Date(video.create_time * 1000).toISOString() : undefined,
|
|
|
+ coverUrl: video.cover_url || '',
|
|
|
+ },
|
|
|
+ }, video.desc || video.aweme_id);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ analyzeMonitorWork(author: MonitorAuthor, work: MonitorWork): void {
|
|
|
+
|
|
|
+ this.startViralAnalysis({
|
|
|
+ awemeId: work.id,
|
|
|
+ source: 'monitor',
|
|
|
+ video: {
|
|
|
+ awemeId: work.id,
|
|
|
+ desc: work.desc,
|
|
|
+ authorName: author.nickname,
|
|
|
+ authorId: author.secUserId || author.uid || '',
|
|
|
+ diggCount: work.statistics?.digg_count || 0,
|
|
|
+ commentCount: work.statistics?.comment_count || 0,
|
|
|
+ shareCount: work.statistics?.share_count || 0,
|
|
|
+ playCount: work.statistics?.play_count || 0,
|
|
|
+ createTime: work.createTime?.toISOString(),
|
|
|
+ coverUrl: work.coverUrl || '',
|
|
|
+ },
|
|
|
+ }, work.desc || work.id);
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ closeViralAnalysis(): void {
|
|
|
+
|
|
|
+ if (this.isAnalyzingViralVideo) return;
|
|
|
+
|
|
|
+ this.isViralAnalysisOpen = false;
|
|
|
+
|
|
|
+ this.viralAnalysisError = '';
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ openSavedViralAnalysis(analysis: ViralAnalysis): void {
|
|
|
+
|
|
|
+ this.currentViralAnalysis = analysis;
|
|
|
+
|
|
|
+ this.viralAnalysisSourceTitle = analysis.videoSnapshot.desc || analysis.awemeId;
|
|
|
+
|
|
|
+ this.viralAnalysisError = '';
|
|
|
+
|
|
|
+ this.isAnalyzingViralVideo = false;
|
|
|
+
|
|
|
+ this.isViralAnalysisOpen = true;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ saveCurrentViralAnalysisToTopics(): void {
|
|
|
+
|
|
|
+ const analysis = this.currentViralAnalysis;
|
|
|
+
|
|
|
+ if (!analysis) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+
|
|
|
+ const angles = analysis.analysis.reusableAngles.length
|
|
|
+
|
|
|
+ ? analysis.analysis.reusableAngles
|
|
|
+
|
|
|
+ : [analysis.analysis.summary || analysis.videoSnapshot.desc || '爆款拆解选题'];
|
|
|
+
|
|
|
+ const saved = angles.slice(0, 3).map((angle) => this.topicPool.saveTopic({
|
|
|
+
|
|
|
+ title: angle,
|
|
|
+
|
|
|
+ angle,
|
|
|
+
|
|
|
+ sourceType: 'viral_analysis',
|
|
|
+
|
|
|
+ sourceVideoIds: [analysis.awemeId],
|
|
|
+
|
|
|
+ sourceAuthorIds: analysis.videoSnapshot.authorId ? [analysis.videoSnapshot.authorId] : [],
|
|
|
+
|
|
|
+ tags: ['爆款分析'],
|
|
|
+
|
|
|
+ audience: '',
|
|
|
+
|
|
|
+ hook: analysis.analysis.openingPattern,
|
|
|
+
|
|
|
+ outline: analysis.analysis.reusableFrame,
|
|
|
+
|
|
|
+ status: 'idea',
|
|
|
+
|
|
|
+ pipelineTarget: 'topic_video',
|
|
|
+
|
|
|
+ confidence: analysis.confidence,
|
|
|
+
|
|
|
+ }));
|
|
|
+
|
|
|
+ this.currentViralAnalysis = this.viralAnalysis.markTopicsSaved(analysis.id, saved.map((item) => item.id));
|
|
|
+
|
|
|
+ this.showToast(`已保存 ${saved.length} 个选题`, 'success');
|
|
|
+
|
|
|
+ } catch (err: any) {
|
|
|
+
|
|
|
+ this.showToast(err?.message || '保存选题失败,请稍后重试', 'error');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ useViralAnalysisForTopicVideo(): void {
|
|
|
+
|
|
|
+ const analysis = this.currentViralAnalysis;
|
|
|
+
|
|
|
+ if (!analysis) return;
|
|
|
+
|
|
|
+ const angle = analysis.analysis.reusableAngles[0] || analysis.analysis.summary || analysis.videoSnapshot.desc;
|
|
|
+
|
|
|
+ this.showToast(`已整理选题:${angle}`, 'success');
|
|
|
+
|
|
|
+ this.setCurrentTab('topic-to-video');
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ private startViralAnalysis(input: Parameters<ViralAnalysisService['analyzeVideo']>[0], sourceTitle: string): void {
|
|
|
+
|
|
|
+ this.isViralAnalysisOpen = true;
|
|
|
+
|
|
|
+ this.isAnalyzingViralVideo = true;
|
|
|
+
|
|
|
+ this.viralAnalysisError = '';
|
|
|
+
|
|
|
+ this.currentViralAnalysis = null;
|
|
|
+
|
|
|
+ this.viralAnalysisSourceTitle = sourceTitle || input.awemeId;
|
|
|
+
|
|
|
+ this.viralAnalysis.analyzeVideo(input).subscribe({
|
|
|
+
|
|
|
+ next: (analysis) => {
|
|
|
+
|
|
|
+ this.currentViralAnalysis = analysis;
|
|
|
+
|
|
|
+ this.isAnalyzingViralVideo = false;
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ error: (err: any) => {
|
|
|
+
|
|
|
+ this.viralAnalysisError = this.getErrorText(err, '爆款分析失败,请稍后重试');
|
|
|
+
|
|
|
+ this.isAnalyzingViralVideo = false;
|
|
|
+
|
|
|
+ this.showToast(this.viralAnalysisError, 'error');
|
|
|
+
|
|
|
+ this.refreshView();
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
downloadVideo(video: VideoInfo): void {
|
|
|
|
|
|
if (video.isDownloading) return;
|