diff --git a/package-lock.json b/package-lock.json index f64aed6..e5dd8ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,8 @@ "monaco-editor": "^0.55.1", "monaco-editor-vue3": "^1.0.5", "pinia": "^3.0.3", + "primeicons": "^7.0.0", "primevue": "^4.3.6", - "vite-plugin-monaco-editor": "^1.1.0", "vue": "^3.5.17", "vue-router": "^4.5.1" }, @@ -26,7 +26,8 @@ "autoprefixer": "^10.4.21", "postcss": "^8.5.6", "tailwindcss": "^4.1.11", - "vite": "^6.0.3" + "vite": "^6.0.3", + "vite-plugin-monaco-editor": "^1.1.0" } }, "node_modules/@babel/helper-string-parser": { @@ -2246,6 +2247,11 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, + "node_modules/primeicons": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz", + "integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==" + }, "node_modules/primevue": { "version": "4.5.5", "resolved": "https://registry.npmjs.org/primevue/-/primevue-4.5.5.tgz", @@ -2494,6 +2500,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/vite-plugin-monaco-editor/-/vite-plugin-monaco-editor-1.1.0.tgz", "integrity": "sha512-IvtUqZotrRoVqwT0PBBDIZPNraya3BxN/bfcNfnxZ5rkJiGcNtO5eAOWWSgT7zullIAEqQwxMU83yL9J5k7gww==", + "dev": true, "peerDependencies": { "monaco-editor": ">=0.33.0" } diff --git a/package.json b/package.json index 9a1f65b..796792c 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "monaco-editor": "^0.55.1", "monaco-editor-vue3": "^1.0.5", "pinia": "^3.0.3", + "primeicons": "^7.0.0", "primevue": "^4.3.6", "vue": "^3.5.17", "vue-router": "^4.5.1" diff --git a/src/components/workflow/AddIpBrainDialog.vue b/src/components/workflow/AddIpBrainDialog.vue index 1747386..d4fda63 100644 --- a/src/components/workflow/AddIpBrainDialog.vue +++ b/src/components/workflow/AddIpBrainDialog.vue @@ -4,8 +4,12 @@ import { storeToRefs } from "pinia"; import { useWorkflowStore } from "../../stores/workflow.js"; const workflow = useWorkflowStore(); -const { ipBrainModalVisible, ipBrainProfileUrl, ipBrainCollecting } = - storeToRefs(workflow); +const { + ipBrainModalVisible, + ipBrainProfileUrl, + ipBrainCollecting, + ipBrainCollectProgress, +} = storeToRefs(workflow); const feedback = ref({ severity: "", message: "" }); @@ -13,10 +17,11 @@ const tipItems = [ "输入账号主页或分享链接后点击「开始采集」按钮", "系统会自动打开浏览器并抓取数据,请保持网络连接正常", "无需任何手动操作,等待采集完成即可", - "采集完成后会自动将 5 个视频标题添加到选题库中", + "采集完成后会自动将最多 6 个视频标题添加到选题库中", ]; function onCancel() { + if (ipBrainCollecting.value) return; feedback.value = { severity: "", message: "" }; workflow.closeIpBrainDialog(); } @@ -24,7 +29,9 @@ function onCancel() { async function onStart() { feedback.value = { severity: "", message: "" }; const result = await workflow.startIpBrainCollect(); - if (!result.ok) { + if (result.ok) { + feedback.value = { severity: "success", message: result.message }; + } else { feedback.value = { severity: "error", message: result.message }; } } @@ -38,10 +45,11 @@ async function onStart() { :style="{ width: '600px' }" :draggable="false" class="ip-brain-dialog" + :closable="!ipBrainCollecting" >
+ + {{ ipBrainCollectProgress }} + +
diff --git a/src/components/workflow/LegalCheckDialog.vue b/src/components/workflow/LegalCheckDialog.vue new file mode 100644 index 0000000..7d3ed85 --- /dev/null +++ b/src/components/workflow/LegalCheckDialog.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/src/components/workflow/Step01IpLearning.vue b/src/components/workflow/Step01IpLearning.vue index 4d2b904..ed295b4 100644 --- a/src/components/workflow/Step01IpLearning.vue +++ b/src/components/workflow/Step01IpLearning.vue @@ -1,4 +1,5 @@ diff --git a/src/components/workflow/StepScriptEditor.vue b/src/components/workflow/StepScriptEditor.vue index 1ae1fc7..f88d1ce 100644 --- a/src/components/workflow/StepScriptEditor.vue +++ b/src/components/workflow/StepScriptEditor.vue @@ -2,21 +2,39 @@ import { ref } from "vue"; import { storeToRefs } from "pinia"; import { useWorkflowStore } from "../../stores/workflow.js"; +import LegalCheckDialog from "./LegalCheckDialog.vue"; const workflow = useWorkflowStore(); -const { scriptContent, editLanguage, editWordCount, scriptGenerating } = - storeToRefs(workflow); +const { + scriptContent, + editLanguage, + editWordCount, + scriptGenerating, + legalChecking, +} = storeToRefs(workflow); const feedback = ref({ severity: "", message: "" }); +function showFeedback(result) { + if (!result?.message) return; + feedback.value = { + severity: result.ok ? (result.warn ? "warn" : "success") : "error", + message: result.message, + }; +} + async function onGenerateScript() { feedback.value = { severity: "", message: "" }; - const result = await workflow.generateScript(); - if (result.ok) { - feedback.value = { severity: "success", message: result.message }; - } else { - feedback.value = { severity: "error", message: result.message }; - } + showFeedback(await workflow.generateScript()); +} + +async function onLegalCheck() { + feedback.value = { severity: "", message: "" }; + showFeedback(await workflow.runLegalCheck()); +} + +function onLegalFeedback(result) { + showFeedback(result); } @@ -53,18 +71,31 @@ async function onGenerateScript() { label="撰写文案" size="small" :loading="scriptGenerating" - :disabled="scriptGenerating" + :disabled="scriptGenerating || legalChecking" @click="onGenerateScript" /> -

{{ feedback.message }}

+ + diff --git a/src/main.js b/src/main.js index 4dfff1a..00b0304 100644 --- a/src/main.js +++ b/src/main.js @@ -27,6 +27,7 @@ import DashboardCard from "./components/dashboard/DashboardCard.vue"; import App from "./App.vue"; import router from "./router"; import { useAuthStore } from "./stores/auth.js"; +import "primeicons/primeicons.css"; import "./styles/main.css"; const app = createApp(App); diff --git a/src/stores/workflow.js b/src/stores/workflow.js index 36348b8..d66ff2b 100644 --- a/src/stores/workflow.js +++ b/src/stores/workflow.js @@ -20,6 +20,9 @@ import { const PIPELINE_SCRIPT = "douyin_pipeline.js"; const SCRIPT_GENERATE = "script_generate.js"; +const SCRIPT_LEGAL_CHECK = "script_legal_check.js"; +const IP_BRAIN_COLLECT = "ip_brain_collect.js"; +const IP_TOPIC_REWRITE = "ip_topic_rewrite.js"; const NODEJS_EVENT = "nodejs:event"; @@ -52,12 +55,26 @@ export const useWorkflowStore = defineStore("workflow", { ipBrainCollecting: false, + ipBrainCollectProgress: "", + ipBenchmarks: [], + /** 当前选中对标的选题(与 benchmark.topicLibrary 同步引用) */ + ipTopics: [], ipBrainSelectedBenchmarkId: null, + ipBrainSelectedTopicId: null, + + ipTopicsRewriting: false, + + ipTopicRewriteProgress: "", + + /** 选题库标题仿写提示词(对齐 zhenqianba topicRewritePrompt) */ + topicRewritePrompt: + "请仿写以下标题:{{content}}。要求保持原意和风格,但用不同的表达方式,更加吸引人。", + videoLinkModalVisible: false, videoShareText: "", @@ -66,6 +83,13 @@ export const useWorkflowStore = defineStore("workflow", { scriptGenerating: false, + legalChecking: false, + + legalModalVisible: false, + + /** @type {null | { hasViolations: boolean, risks: Array<{word:string,recommendation?:string,reason?:string}>, analysis: string, highlightedText: string, fixedText: string, totalCount: number }} */ + legalReport: null, + /** 流水线进度文案(来自 quickjs:event progress) */ rewriteProgress: "", @@ -200,6 +224,23 @@ export const useWorkflowStore = defineStore("workflow", { state.ipBenchmarks.find((b) => b.id === state.ipBrainSelectedBenchmarkId) ?? null, + selectedTopic: (state) => { + + if (!state.ipBrainSelectedTopicId) return null; + + return ( + + state.ipTopics.find((t) => t.id === state.ipBrainSelectedTopicId) ?? null + + ); + + }, + + /** 已有仿写标题的选题(用于「改写后」列表) */ + rewrittenTopics: (state) => + + state.ipTopics.filter((t) => String(t.rewritten || "").trim()), + }, @@ -224,75 +265,63 @@ export const useWorkflowStore = defineStore("workflow", { async startIpBrainCollect() { - const url = this.ipBrainProfileUrl?.trim(); - - if (!url) { - - return { ok: false, message: "请输入账号主页或分享链接" }; - - } - - if (this.ipBenchmarks.length >= 5) { - - return { ok: false, message: "最多添加 5 个对标账号" }; - - } - - - - this.ipBrainCollecting = true; - - try { - - // TODO: 对接后端采集 API / Tauri 浏览器自动化 - - await new Promise((r) => setTimeout(r, 800)); - - - - const benchmark = { - - id: `bm_${Date.now()}`, - - url, - - label: truncateUrl(url), - - }; - - this.ipBenchmarks.push(benchmark); - - this.ipBrainSelectedBenchmarkId = benchmark.id; - - this.ipTopics = Array.from({ length: 5 }, (_, i) => ({ - - id: `topic_${Date.now()}_${i}`, - - title: `示例视频标题 ${i + 1}(待对接真实采集)`, - - })); - - this.ipBrainProfileUrl = ""; - - this.ipBrainModalVisible = false; - - return { ok: true, message: "采集完成" }; - - } finally { - - this.ipBrainCollecting = false; - - } + return executeIpBrainCollect(this); }, - - selectBenchmark(id) { this.ipBrainSelectedBenchmarkId = id; - this.ipTopics = []; + const bm = this.ipBenchmarks.find((b) => b.id === id); + + this.ipTopics = bm?.topicLibrary ? [...bm.topicLibrary] : []; + + this.ipBrainSelectedTopicId = null; + + }, + + selectRewrittenTopic(topicId) { + + const topic = this.ipTopics.find((t) => t.id === topicId); + + if (!topic?.rewritten?.trim()) return; + + this.ipBrainSelectedTopicId = topicId; + + }, + + async rewriteIpTopics() { + + return executeIpTopicRewrite(this); + + }, + + removeBenchmark(id) { + + const idx = this.ipBenchmarks.findIndex((b) => b.id === id); + + if (idx < 0) return; + + this.ipBenchmarks.splice(idx, 1); + + if (this.ipBrainSelectedBenchmarkId === id) { + + const next = this.ipBenchmarks[0]; + + if (next) this.selectBenchmark(next.id); + + else { + + this.ipBrainSelectedBenchmarkId = null; + + this.ipTopics = []; + + this.ipBrainSelectedTopicId = null; + + } + + } }, @@ -326,6 +355,24 @@ export const useWorkflowStore = defineStore("workflow", { }, + async runLegalCheck() { + + return executeLegalCheck(this); + + }, + + applyLegalFix() { + + return applyLegalFixToScript(this); + + }, + + closeLegalModal() { + + this.legalModalVisible = false; + + }, + resetAll() { this.$reset(); @@ -440,6 +487,171 @@ export async function executeVideoRewrite(store) { } } +/** + * IP 大脑采集(对齐 Electron ipAgent:analyzeDouyinAccount + fi) + * @param {ReturnType} store + */ +export async function executeIpBrainCollect(store) { + const url = store.ipBrainProfileUrl?.trim(); + if (!url) { + return { ok: false, message: "请输入账号主页或分享链接" }; + } + if (store.ipBenchmarks.length >= 5) { + return { ok: false, message: "最多添加 5 个对标账号" }; + } + + store.ipBrainCollecting = true; + store.ipBrainCollectProgress = "正在打开浏览器并采集数据,请稍候..."; + + let unlisten = null; + try { + try { + unlisten = await listen(NODEJS_EVENT, (e) => { + const p = e.payload; + if ( + p?.type === "progress" && + p.scriptName === IP_BRAIN_COLLECT && + typeof p.status === "string" + ) { + store.ipBrainCollectProgress = p.status; + } + }); + } catch { + /* 非 Tauri */ + } + + const result = await invoke("run_nodejs_script", { + scriptName: IP_BRAIN_COLLECT, + params: { url }, + }); + + if (!result?.success) { + return { + ok: false, + message: String(result?.error || "采集失败,请检查链接与 Chrome/Edge 是否已安装"), + }; + } + + const titles = Array.isArray(result.videoTitles) ? result.videoTitles : []; + const accountName = String(result.accountName || "").trim(); + const pageUrl = String(result.pageUrl || url).trim(); + const label = accountName || truncateUrl(pageUrl); + + const topicLibrary = titles.map((title, i) => ({ + id: `topic_${Date.now()}_${i}`, + original: title, + title, + rewritten: "", + isRewriting: false, + })); + + const benchmark = { + id: `bm_${Date.now()}`, + url: pageUrl, + name: accountName, + label, + topicLibrary, + }; + + store.ipBenchmarks.push(benchmark); + store.selectBenchmark(benchmark.id); + store.ipBrainProfileUrl = ""; + store.ipBrainModalVisible = false; + + return { + ok: true, + message: `采集完成!已添加「${label}」,共 ${titles.length} 个视频标题`, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err || "未知错误"); + return { ok: false, message: `采集失败: ${msg}` }; + } finally { + if (typeof unlisten === "function") unlisten(); + store.ipBrainCollecting = false; + store.ipBrainCollectProgress = ""; + } +} + +function syncBenchmarkTopicLibrary(store) { + const bm = store.ipBenchmarks.find( + (b) => b.id === store.ipBrainSelectedBenchmarkId, + ); + if (bm) { + bm.topicLibrary = store.ipTopics.map((t) => ({ ...t })); + } +} + +/** + * 选题库一键仿写标题(对齐 Electron ipAgent:rewriteTitles) + * @param {ReturnType} store + */ +export async function executeIpTopicRewrite(store) { + if (!store.ipBrainSelectedBenchmarkId) { + return { ok: false, message: "请先选择一个对标" }; + } + if (!store.ipTopics.length) { + return { ok: false, message: "选题库为空,请先采集对标账号" }; + } + + const cfgReady = await ensureLlmConfigReady(); + if (!cfgReady.ok) { + return { ok: false, message: cfgReady.message }; + } + + const originals = store.ipTopics.map((t) => + String(t.original || t.title || "").trim(), + ).filter(Boolean); + if (!originals.length) { + return { ok: false, message: "没有可仿写的原始标题" }; + } + + const bm = store.selectedBenchmark; + const accountName = bm?.name || bm?.label || ""; + + store.ipTopicsRewriting = true; + store.ipTopicRewriteProgress = "正在一键仿写标题..."; + + try { + const result = await invoke("run_nodejs_script", { + scriptName: IP_TOPIC_REWRITE, + params: { + titles: originals, + accountName, + rewritePrompt: store.topicRewritePrompt?.trim() || null, + }, + }); + + if (!result?.success || !Array.isArray(result.rewrittenTitles)) { + return { + ok: false, + message: String(result?.error || "标题仿写失败,请重试"), + }; + } + + result.rewrittenTitles.forEach((rewritten, i) => { + const topic = store.ipTopics[i]; + if (!topic) return; + topic.rewritten = String(rewritten || "").trim(); + topic.isRewriting = false; + }); + syncBenchmarkTopicLibrary(store); + + const first = store.rewrittenTopics[0]; + store.ipBrainSelectedTopicId = first?.id ?? null; + + return { + ok: true, + message: `已生成 ${result.rewrittenTitles.length} 条仿写标题`, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err || "未知错误"); + return { ok: false, message: `标题仿写失败: ${msg}` }; + } finally { + store.ipTopicsRewriting = false; + store.ipTopicRewriteProgress = ""; + } +} + const DEFAULT_SCRIPT_PROMPT = `请基于以下视频原文案,创作一个新的仿写文案: 原文案:{{content}} @@ -526,6 +738,128 @@ export async function executeGenerateScript(store) { } } +/** + * 高亮原文中的违禁词(对齐 zhenqianba Ci) + * @param {string} text + * @param {Array<{ word: string }>} risks + */ +export function highlightLegalViolations(text, risks) { + let html = String(text || ""); + const sorted = [...(risks || [])].sort( + (a, b) => (b.word?.length || 0) - (a.word?.length || 0), + ); + for (const item of sorted) { + const word = item?.word; + if (!word) continue; + const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + html = html.replace( + new RegExp(escaped, "g"), + `${word}`, + ); + } + return html; +} + +/** + * AI 法务审核(对齐 zhenqianba _i / handleLegalCheck) + * @param {ReturnType} store + */ +export async function executeLegalCheck(store) { + const content = String(store.scriptContent || "").trim(); + if (!content) { + return { ok: false, message: "请先输入文案内容" }; + } + + const cfgReady = await ensureLlmConfigReady(); + if (!cfgReady.ok) { + return { ok: false, message: cfgReady.message }; + } + + store.legalChecking = true; + store.legalModalVisible = true; + store.legalReport = null; + + try { + const result = await invoke("run_nodejs_script", { + scriptName: SCRIPT_LEGAL_CHECK, + params: { content }, + }); + + if (!result?.success) { + store.legalModalVisible = false; + return { + ok: false, + message: String(result?.error || "AI 审核请求失败"), + }; + } + + const risks = Array.isArray(result.risks) ? result.risks : []; + const hasViolations = + result.hasViolations === true || risks.length > 0; + + store.legalReport = { + hasViolations, + risks, + analysis: result.analysis || "", + fixedText: result.fixedText || "", + highlightedText: highlightLegalViolations(content, risks), + totalCount: risks.length, + }; + + if (!hasViolations) { + return { ok: true, message: "未检测到违规内容" }; + } + const words = [...new Set(risks.map((r) => r.word).filter(Boolean))]; + return { + ok: true, + message: `检测到 ${risks.length} 处违规内容${words.length ? `:${words.join("、")}` : ""}`, + warn: true, + }; + } catch (err) { + store.legalModalVisible = false; + const msg = err instanceof Error ? err.message : String(err || "未知错误"); + return { ok: false, message: `检测失败:${msg}` }; + } finally { + store.legalChecking = false; + } +} + +/** + * 采用优化文案或按建议替换违禁词(对齐 zhenqianba $i) + * @param {ReturnType} store + */ +export function applyLegalFixToScript(store) { + const report = store.legalReport; + if (!report) { + return { ok: false, message: "暂无审核结果" }; + } + + if (report.fixedText?.trim()) { + store.scriptContent = report.fixedText.trim(); + store.legalModalVisible = false; + return { ok: true, message: "已采用 AI 优化后的文案" }; + } + + const risks = report.risks || []; + if (risks.length === 0) { + return { ok: false, message: "没有需要修正的内容" }; + } + + let text = String(store.scriptContent || ""); + const sorted = [...risks].sort( + (a, b) => (b.word?.length || 0) - (a.word?.length || 0), + ); + for (const item of sorted) { + const word = item?.word; + if (!word) continue; + const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + text = text.replace(new RegExp(escaped, "g"), item.recommendation || ""); + } + store.scriptContent = text; + store.legalModalVisible = false; + return { ok: true, message: "已自动修正所有违规内容" }; +} + const UNSUPPORTED_PLATFORM_RULES = [ { pattern: /kuaishou\.com|ksurl\.cn|gifshow\.com/i, name: "快手" },