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 @@
@@ -48,8 +81,12 @@ function onOpenIpBrain() {
label="添加IP大脑"
class="w-full"
size="small"
+ :disabled="workflow.ipBenchmarkCount >= 5"
@click="onOpenIpBrain"
/>
+
+ 已达上限(5/5)
+
@@ -59,17 +96,32 @@ function onOpenIpBrain() {
暂无对标
-
-
@@ -81,16 +133,72 @@ function onOpenIpBrain() {
>
请先选择一个对标
-
+
采集完成后将显示视频标题
-
- -
- {{ index + 1 }}. {{ topic.title }}
+
+ -
+ {{ index + 1 }}. {{ topic.original || topic.title }}
+
+
+
+ {{ ipTopicRewriteProgress }}
+
+
+ 请先选择一个对标
+
+
+ 点击右侧刷新按钮,根据选题库一键生成仿写标题
+
+
+ -
+ {{ index + 1 }}. {{ topic.rewritten }}
+
+
+
+ {{ topicFeedback.message }}
+
+
+
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: "快手" },