11
This commit is contained in:
@@ -24,6 +24,9 @@ const menuItems = computed(() => {
|
||||
if (auth.isAdmin) {
|
||||
items.push({ name: "admin", label: "管理", to: "/admin", icon: "admin" });
|
||||
}
|
||||
if ( auth.isOEM) {
|
||||
items.push({ name: "admin", label: "管理", to: "/oem", icon: "admin" });
|
||||
}
|
||||
if (auth.isAgent) {
|
||||
items.push({ name: "agent", label: "代理", to: "/agent", icon: "agent" });
|
||||
}
|
||||
|
||||
30
src/components/oem/OemSubNav.vue
Normal file
30
src/components/oem/OemSubNav.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const menuItems = [
|
||||
{ name: "admin-overview", label: "概览", to: "/admin" },
|
||||
{ name: "admin-users", label: "用户管理", to: "/admin/users" },
|
||||
{ name: "admin-card-keys", label: "卡密管理", to: "/admin/card-keys" },
|
||||
{ name: "admin-desktop-config", label: "桌面配置", to: "/admin/desktop-config" },
|
||||
];
|
||||
|
||||
const activeName = computed(() => route.name);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="admin-sub-nav" aria-label="管理后台导航">
|
||||
<p class="admin-sub-nav__title">管理后台</p>
|
||||
<RouterLink
|
||||
v-for="item in menuItems"
|
||||
:key="item.name"
|
||||
:to="item.to"
|
||||
class="admin-sub-nav__item"
|
||||
:class="{ 'admin-sub-nav__item--active': activeName === item.name }"
|
||||
>
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -1,9 +1,57 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||
|
||||
const workflow = useWorkflowStore();
|
||||
const { pipInPicture, autoCutBreath, greenScreen } = storeToRefs(workflow);
|
||||
const {
|
||||
pipInPicture,
|
||||
autoCutBreath,
|
||||
greenScreen,
|
||||
videoProcessing,
|
||||
greenScreenBackgroundPath,
|
||||
generatedVideoSrc,
|
||||
generatedVideoPath,
|
||||
} = storeToRefs(workflow);
|
||||
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
|
||||
const hasSourceVideo = computed(() => Boolean(generatedVideoPath.value));
|
||||
const canProcess = computed(
|
||||
() =>
|
||||
hasSourceVideo.value &&
|
||||
!videoProcessing.value &&
|
||||
(autoCutBreath.value || pipInPicture.value || greenScreen.value),
|
||||
);
|
||||
|
||||
const greenScreenFileName = computed(() => {
|
||||
const p = greenScreenBackgroundPath.value;
|
||||
if (!p) return "";
|
||||
return p.split(/[/\\]/).pop() || p;
|
||||
});
|
||||
|
||||
function showFeedback(result) {
|
||||
if (!result?.message) return;
|
||||
feedback.value = {
|
||||
severity: result.ok ? "success" : "error",
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
|
||||
async function onAutoProcess() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.autoProcessVideo());
|
||||
}
|
||||
|
||||
async function onPickGreenScreen() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.chooseGreenScreenBackground());
|
||||
}
|
||||
|
||||
async function onPickPipMaterial() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.choosePipMaterial());
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -13,15 +61,77 @@ const { pipInPicture, autoCutBreath, greenScreen } = storeToRefs(workflow);
|
||||
<Checkbox v-model="pipInPicture" binary />
|
||||
画中画
|
||||
</label>
|
||||
<Button
|
||||
v-if="pipInPicture"
|
||||
label="选择画中画素材"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="w-full"
|
||||
:disabled="videoProcessing"
|
||||
@click="onPickPipMaterial"
|
||||
/>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-200">
|
||||
<Checkbox v-model="autoCutBreath" binary />
|
||||
自动剪气口
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-200">
|
||||
<Checkbox v-model="greenScreen" binary />
|
||||
启动绿幕切换
|
||||
</label>
|
||||
<Button label="自动处理" size="small" class="w-full" disabled />
|
||||
<div v-if="greenScreen" class="flex flex-col gap-1 pl-6">
|
||||
<Button
|
||||
:label="greenScreenFileName ? '重新选择背景图' : '上传替换图片'"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="w-full"
|
||||
:disabled="videoProcessing"
|
||||
@click="onPickGreenScreen"
|
||||
/>
|
||||
<p v-if="greenScreenFileName" class="truncate text-xs text-slate-400">
|
||||
{{ greenScreenFileName }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
label="自动处理"
|
||||
size="small"
|
||||
class="w-full"
|
||||
:loading="videoProcessing"
|
||||
:disabled="!canProcess"
|
||||
@click="onAutoProcess"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!hasSourceVideo"
|
||||
class="mt-2 text-xs text-amber-400/90"
|
||||
>
|
||||
请先在步骤 02 生成口播视频
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="feedback.message"
|
||||
class="mt-2 text-sm"
|
||||
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
|
||||
<div class="dashboard-preview-box mt-3">
|
||||
<div class="mb-2 text-xs text-slate-400">编辑预览</div>
|
||||
<div class="dashboard-aspect-video">
|
||||
<video
|
||||
v-if="generatedVideoSrc"
|
||||
:src="generatedVideoSrc"
|
||||
controls
|
||||
class="h-full w-full rounded object-contain"
|
||||
/>
|
||||
<span v-else class="text-xs text-gray-400">处理后将在此预览</span>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardCard>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||
|
||||
@@ -11,12 +12,56 @@ const {
|
||||
keywordsDescribe,
|
||||
keywordsAction,
|
||||
keywordsEmotion,
|
||||
titleTagsGenerating,
|
||||
scriptContent,
|
||||
} = storeToRefs(workflow);
|
||||
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
|
||||
const canGenerate = computed(
|
||||
() => Boolean(scriptContent.value?.trim()) && !titleTagsGenerating.value,
|
||||
);
|
||||
|
||||
function showFeedback(result) {
|
||||
if (!result?.message) return;
|
||||
feedback.value = {
|
||||
severity: result.ok ? "success" : "error",
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
|
||||
async function onGenerateTitleTags() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.generateTitleTags());
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardCard title="标题标签关键词" step="04">
|
||||
<Button label="生成标题标签关键词" size="small" class="mb-3 w-full" />
|
||||
<Button
|
||||
label="生成标题标签关键词"
|
||||
size="small"
|
||||
class="mb-3 w-full"
|
||||
:loading="titleTagsGenerating"
|
||||
:disabled="!canGenerate"
|
||||
@click="onGenerateTitleTags"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="!scriptContent?.trim()"
|
||||
class="mb-2 text-xs text-amber-400/90"
|
||||
>
|
||||
请先在步骤 01 填写或生成视频文案
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="feedback.message"
|
||||
class="mb-2 text-sm"
|
||||
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="dashboard-field-label mb-1">生成的标题(可编辑)</div>
|
||||
<Textarea
|
||||
|
||||
@@ -1,26 +1,96 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||
import { SUBTITLE_TEMPLATES } from "../../config/subtitleTemplates.js";
|
||||
|
||||
const workflow = useWorkflowStore();
|
||||
const { autoSubtitle, smartSubtitle, bgmEnabled, bgmVolume } = storeToRefs(workflow);
|
||||
const {
|
||||
autoSubtitle,
|
||||
smartSubtitle,
|
||||
bgmEnabled,
|
||||
bgmVolume,
|
||||
subtitleTemplateId,
|
||||
subtitleBgmGenerating,
|
||||
generatedVideoPath,
|
||||
bgmPreviewSrc,
|
||||
} = storeToRefs(workflow);
|
||||
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
const templateDialogVisible = ref(false);
|
||||
|
||||
const templateOptions = SUBTITLE_TEMPLATES.map((t) => ({
|
||||
label: t.label,
|
||||
value: t.id,
|
||||
}));
|
||||
|
||||
const hasSourceVideo = computed(() => Boolean(generatedVideoPath.value));
|
||||
|
||||
const canGenerate = computed(
|
||||
() =>
|
||||
hasSourceVideo.value &&
|
||||
!subtitleBgmGenerating.value &&
|
||||
(autoSubtitle.value || bgmEnabled.value) &&
|
||||
(!bgmEnabled.value || Boolean(workflow.bgmPath)),
|
||||
);
|
||||
|
||||
function showFeedback(result) {
|
||||
if (!result?.message) return;
|
||||
feedback.value = {
|
||||
severity: result.ok ? "success" : "error",
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
|
||||
async function onGenerate() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.generateSubtitleAndBgm());
|
||||
}
|
||||
|
||||
async function onPickBgm() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.chooseBgm());
|
||||
}
|
||||
|
||||
async function onPreviewBgm() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
const result = await workflow.previewBgm();
|
||||
if (result?.message) showFeedback(result);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardCard title="字幕和音乐" step="05" :grow="true">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<label class="flex items-center gap-2 text-sm text-slate-200">
|
||||
<Checkbox v-model="autoSubtitle" binary />
|
||||
自动生成字幕
|
||||
</label>
|
||||
<label class="mt-2 flex items-center gap-2 text-sm">
|
||||
<Checkbox v-model="smartSubtitle" binary />
|
||||
<p class="dashboard-muted mt-1 pl-6 text-xs">
|
||||
提取视频音频并识别,烧录 SRT 字幕到画面
|
||||
</p>
|
||||
|
||||
<label class="mt-2 flex items-center gap-2 text-sm text-slate-200">
|
||||
<Checkbox v-model="smartSubtitle" binary :disabled="!autoSubtitle" />
|
||||
启用智能字幕
|
||||
</label>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<Button label="模板选择" size="small" outlined />
|
||||
<span class="dashboard-muted truncate text-sm">已选模板: 未选择</span>
|
||||
<p class="dashboard-muted mt-1 pl-6 text-xs">
|
||||
用步骤 01 文案替换识别文本,保留语音时间轴
|
||||
</p>
|
||||
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
label="模板选择"
|
||||
size="small"
|
||||
outlined
|
||||
:disabled="!autoSubtitle"
|
||||
@click="templateDialogVisible = true"
|
||||
/>
|
||||
<span class="dashboard-muted truncate text-sm">
|
||||
已选模板: {{ workflow.subtitleTemplateLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<label class="mt-3 flex items-center gap-2 text-sm">
|
||||
|
||||
<label class="mt-3 flex items-center gap-2 text-sm text-slate-200">
|
||||
<Checkbox v-model="bgmEnabled" binary />
|
||||
添加背景音乐
|
||||
</label>
|
||||
@@ -34,14 +104,77 @@ const { autoSubtitle, smartSubtitle, bgmEnabled, bgmVolume } = storeToRefs(workf
|
||||
class="w-20"
|
||||
/>
|
||||
<span class="text-sm text-slate-400">%</span>
|
||||
<Button label="选择音乐" size="small" severity="secondary" :disabled="!bgmEnabled" />
|
||||
<Button label="试听" size="small" outlined :disabled="!bgmEnabled" />
|
||||
<Button
|
||||
label="选择音乐"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
:disabled="!bgmEnabled || subtitleBgmGenerating"
|
||||
@click="onPickBgm"
|
||||
/>
|
||||
<Button
|
||||
label="试听"
|
||||
size="small"
|
||||
outlined
|
||||
:disabled="!bgmEnabled || !workflow.bgmPath"
|
||||
@click="onPreviewBgm"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="workflow.bgmFileName" class="mt-1 truncate text-xs text-slate-400">
|
||||
{{ workflow.bgmFileName }}
|
||||
</p>
|
||||
<audio
|
||||
v-if="bgmPreviewSrc"
|
||||
:src="bgmPreviewSrc"
|
||||
controls
|
||||
class="mt-2 h-8 w-full"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="!hasSourceVideo"
|
||||
class="mt-2 text-xs text-amber-400/90"
|
||||
>
|
||||
请先在步骤 02 生成口播视频
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="feedback.message"
|
||||
class="mt-2 text-sm"
|
||||
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
label="自动生成字幕和BGM"
|
||||
size="small"
|
||||
class="mt-3 w-full"
|
||||
:disabled="!autoSubtitle && !bgmEnabled"
|
||||
:loading="subtitleBgmGenerating"
|
||||
:disabled="!canGenerate"
|
||||
@click="onGenerate"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="templateDialogVisible"
|
||||
header="字幕模板"
|
||||
modal
|
||||
:style="{ width: '22rem' }"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="opt in templateOptions"
|
||||
:key="opt.value"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<RadioButton
|
||||
v-model="subtitleTemplateId"
|
||||
:input-id="`tpl-${opt.value}`"
|
||||
:value="opt.value"
|
||||
/>
|
||||
<label :for="`tpl-${opt.value}`" class="cursor-pointer text-sm">
|
||||
{{ opt.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</DashboardCard>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,178 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||
import { COVER_TEMPLATES } from "../../config/coverTemplates.js";
|
||||
|
||||
const workflow = useWorkflowStore();
|
||||
const {
|
||||
titleGenerated,
|
||||
generatedVideoPath,
|
||||
processedVideoPath,
|
||||
coverGenerating,
|
||||
coverImageSrc,
|
||||
coverTemplateId,
|
||||
} = storeToRefs(workflow);
|
||||
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
const settingsVisible = ref(false);
|
||||
|
||||
const templateOptions = COVER_TEMPLATES.map((t) => ({
|
||||
label: t.label,
|
||||
value: t.id,
|
||||
}));
|
||||
|
||||
const hasSourceVideo = computed(
|
||||
() => Boolean(processedVideoPath.value || generatedVideoPath.value),
|
||||
);
|
||||
|
||||
const hasTitle = computed(() => Boolean(String(titleGenerated.value || "").trim()));
|
||||
|
||||
const canGenerate = computed(
|
||||
() => hasSourceVideo.value && hasTitle.value && !coverGenerating.value,
|
||||
);
|
||||
|
||||
function showFeedback(result) {
|
||||
if (!result?.message) return;
|
||||
feedback.value = {
|
||||
severity: result.ok ? "success" : "error",
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
|
||||
async function onGenerateCover() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.generateCover());
|
||||
}
|
||||
|
||||
async function onPickMaterial() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
const result = await workflow.chooseCoverMaterial();
|
||||
if (result?.message) showFeedback(result);
|
||||
}
|
||||
|
||||
function applySettings() {
|
||||
settingsVisible.value = false;
|
||||
feedback.value = {
|
||||
severity: "success",
|
||||
message: "封面设置已保存,可点击「自动生成封面」",
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardCard title="封面制作" step="06">
|
||||
<Button label="自动生成封面" size="small" class="w-full" />
|
||||
<Button label="封面设置" size="small" severity="secondary" class="mt-2 w-full" />
|
||||
<Button
|
||||
label="自动生成封面"
|
||||
size="small"
|
||||
class="w-full"
|
||||
:loading="coverGenerating"
|
||||
:disabled="!canGenerate"
|
||||
@click="onGenerateCover"
|
||||
/>
|
||||
<Button
|
||||
label="封面设置"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="mt-2 w-full"
|
||||
@click="settingsVisible = true"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="!hasSourceVideo"
|
||||
class="mt-2 text-xs text-amber-400/90"
|
||||
>
|
||||
请先在步骤 02 生成口播视频
|
||||
</p>
|
||||
<p
|
||||
v-else-if="!hasTitle"
|
||||
class="mt-2 text-xs text-amber-400/90"
|
||||
>
|
||||
请先在步骤 04 生成标题文字
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="feedback.message"
|
||||
class="mt-2 text-sm"
|
||||
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="dashboard-field-label mb-2">封面预览</div>
|
||||
<div class="dashboard-aspect-video">
|
||||
<span class="dashboard-muted text-sm">暂无封面预览</span>
|
||||
<div class="dashboard-aspect-video overflow-hidden">
|
||||
<img
|
||||
v-if="coverImageSrc"
|
||||
:src="coverImageSrc"
|
||||
alt="封面预览"
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
<span v-else class="dashboard-muted text-sm">暂无封面预览</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="settingsVisible"
|
||||
header="封面设置"
|
||||
modal
|
||||
:style="{ width: '24rem' }"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<div class="dashboard-field-label mb-2">样式模板</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="opt in templateOptions"
|
||||
:key="opt.value"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<RadioButton
|
||||
v-model="coverTemplateId"
|
||||
:input-id="`cover-tpl-${opt.value}`"
|
||||
:value="opt.value"
|
||||
/>
|
||||
<label :for="`cover-tpl-${opt.value}`" class="cursor-pointer text-sm">
|
||||
{{ opt.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="dashboard-field-label mb-1">封面素材视频(可选)</div>
|
||||
<p class="dashboard-muted mb-2 text-xs">
|
||||
不选择时从当前口播视频第 3 秒抽帧;可上传其他视频作为封面底图。
|
||||
「虚化/抠图」类模板需 bundled Python 运行时(首次较慢)。
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
label="选择视频"
|
||||
size="small"
|
||||
outlined
|
||||
:disabled="coverGenerating"
|
||||
@click="onPickMaterial"
|
||||
/>
|
||||
<Button
|
||||
v-if="workflow.coverCustomVideoPath"
|
||||
label="清除"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="workflow.coverCustomVideoPath = ''"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="workflow.coverMaterialFileName"
|
||||
class="mt-1 truncate text-xs text-slate-400"
|
||||
>
|
||||
{{ workflow.coverMaterialFileName }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="确定" size="small" @click="applySettings" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</DashboardCard>
|
||||
</template>
|
||||
|
||||
@@ -1,33 +1,219 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||
|
||||
const workflow = useWorkflowStore();
|
||||
const {
|
||||
publishPlatforms,
|
||||
publishing,
|
||||
publishLoggingIn,
|
||||
titleGenerated,
|
||||
coverImagePath,
|
||||
generatedVideoPath,
|
||||
processedVideoPath,
|
||||
publishScheduledAt,
|
||||
} = storeToRefs(workflow);
|
||||
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
const scheduleDialogVisible = ref(false);
|
||||
const scheduleDate = ref(null);
|
||||
|
||||
const hasVideo = computed(
|
||||
() => Boolean(processedVideoPath.value || generatedVideoPath.value),
|
||||
);
|
||||
const hasCover = computed(() => Boolean(coverImagePath.value));
|
||||
const hasTitle = computed(() => Boolean(String(titleGenerated.value || "").trim()));
|
||||
|
||||
const canPublish = computed(
|
||||
() =>
|
||||
hasVideo.value &&
|
||||
hasCover.value &&
|
||||
hasTitle.value &&
|
||||
!publishing.value &&
|
||||
publishPlatforms.value.some((p) => p.checked),
|
||||
);
|
||||
|
||||
const accountOptions = (platform) =>
|
||||
(platform.accounts || []).map((a) => ({
|
||||
label: a.nickname || `账号 #${a.id}`,
|
||||
value: a.id,
|
||||
}));
|
||||
|
||||
function showFeedback(result) {
|
||||
if (!result?.message) return;
|
||||
feedback.value = {
|
||||
severity: result.ok ? "success" : "error",
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await workflow.refreshPublishAccounts();
|
||||
});
|
||||
|
||||
async function onPublish() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.publishVideo({ autoPublish: true }));
|
||||
}
|
||||
|
||||
async function onPublishManual() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.publishVideo({ autoPublish: false }));
|
||||
}
|
||||
|
||||
function openSchedule() {
|
||||
scheduleDate.value = null;
|
||||
scheduleDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function confirmSchedule() {
|
||||
if (!scheduleDate.value) {
|
||||
feedback.value = { severity: "error", message: "请选择发布时间" };
|
||||
return;
|
||||
}
|
||||
const d =
|
||||
scheduleDate.value instanceof Date
|
||||
? scheduleDate.value
|
||||
: new Date(scheduleDate.value);
|
||||
showFeedback(workflow.schedulePublishVideo(d));
|
||||
scheduleDialogVisible.value = false;
|
||||
}
|
||||
|
||||
function cancelSchedule() {
|
||||
workflow.clearPublishSchedule();
|
||||
feedback.value = { severity: "success", message: "已取消定时发布" };
|
||||
}
|
||||
|
||||
async function onLogin(platform) {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.loginPublishPlatform(platform.key));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardCard title="视频发布" step="07" :grow="true">
|
||||
<div
|
||||
v-for="(platform, index) in workflow.publishPlatforms"
|
||||
:key="index"
|
||||
v-for="platform in publishPlatforms"
|
||||
:key="platform.key"
|
||||
class="dashboard-platform-row mb-2"
|
||||
>
|
||||
<label class="flex shrink-0 items-center gap-2 text-sm" style="min-width: 70px">
|
||||
<label class="flex shrink-0 items-center gap-2 text-sm" style="min-width: 86px">
|
||||
<Checkbox v-model="platform.checked" binary />
|
||||
{{ platform.label }}
|
||||
<span>{{ platform.label }}</span>
|
||||
<span
|
||||
v-if="platform.loggedIn"
|
||||
class="text-xs text-emerald-400"
|
||||
:title="platform.nickname"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
</label>
|
||||
<Select
|
||||
v-model="platform.account"
|
||||
v-model="platform.accountId"
|
||||
:options="accountOptions(platform)"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="选择账号"
|
||||
size="small"
|
||||
class="min-w-0 flex-1"
|
||||
:disabled="!accountOptions(platform).length"
|
||||
/>
|
||||
<Button
|
||||
label="登录"
|
||||
size="small"
|
||||
text
|
||||
:loading="publishLoggingIn === platform.key"
|
||||
@click="onLogin(platform)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="!hasVideo" class="mt-1 text-xs text-amber-400/90">
|
||||
请先在步骤 02 生成口播视频
|
||||
</p>
|
||||
<p v-else-if="!hasCover" class="mt-1 text-xs text-amber-400/90">
|
||||
请先在步骤 06 生成封面
|
||||
</p>
|
||||
<p v-else-if="!hasTitle" class="mt-1 text-xs text-amber-400/90">
|
||||
请先在步骤 04 生成标题
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="feedback.message"
|
||||
class="mt-2 text-sm"
|
||||
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="workflow.publishResults?.length"
|
||||
class="mt-2 space-y-1 text-xs text-slate-300"
|
||||
>
|
||||
<li v-for="(r, idx) in workflow.publishResults" :key="idx">
|
||||
{{ r.platform }}:
|
||||
<span :class="r.success ? 'text-emerald-400' : r.pending ? 'text-amber-400' : 'text-red-400'">
|
||||
{{ r.message || r.status }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-if="publishScheduledAt" class="mt-2 text-xs text-sky-400">
|
||||
定时发布:{{ new Date(publishScheduledAt).toLocaleString("zh-CN") }}
|
||||
<Button label="取消" size="small" text class="ml-1" @click="cancelSchedule" />
|
||||
</p>
|
||||
|
||||
<div class="mt-3 flex gap-2">
|
||||
<Button label="发布" size="small" class="flex-1" />
|
||||
<Button label="定时发布" size="small" class="flex-1" />
|
||||
<Button
|
||||
label="发布"
|
||||
size="small"
|
||||
class="flex-1"
|
||||
:loading="publishing"
|
||||
:disabled="!canPublish"
|
||||
@click="onPublish"
|
||||
/>
|
||||
<Button
|
||||
label="半自动"
|
||||
size="small"
|
||||
class="flex-1"
|
||||
severity="secondary"
|
||||
:loading="publishing"
|
||||
:disabled="!canPublish"
|
||||
title="填写内容后由您在浏览器中手动点发布"
|
||||
@click="onPublishManual"
|
||||
/>
|
||||
<Button
|
||||
label="定时发布"
|
||||
size="small"
|
||||
class="flex-1"
|
||||
severity="secondary"
|
||||
:disabled="!canPublish || publishing"
|
||||
@click="openSchedule"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-center text-xs leading-relaxed text-gray-400 opacity-80">
|
||||
首次发布:请手动关闭浏览器内出现的任何弹窗提示(说明等),防止干扰脚本,下次即可流畅运行
|
||||
</p>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="scheduleDialogVisible"
|
||||
header="定时发布"
|
||||
modal
|
||||
:style="{ width: '22rem' }"
|
||||
>
|
||||
<DatePicker
|
||||
v-model="scheduleDate"
|
||||
show-time
|
||||
hour-format="24"
|
||||
date-format="yy-mm-dd"
|
||||
placeholder="选择发布时间"
|
||||
class="w-full"
|
||||
/>
|
||||
<template #footer>
|
||||
<Button label="取消" size="small" text @click="scheduleDialogVisible = false" />
|
||||
<Button label="确定" size="small" @click="confirmSchedule" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</DashboardCard>
|
||||
</template>
|
||||
|
||||
@@ -1,20 +1,114 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||
|
||||
const router = useRouter();
|
||||
const workflow = useWorkflowStore();
|
||||
const {
|
||||
oneClickRunning,
|
||||
oneClickStatus,
|
||||
oneClickProgress,
|
||||
} = storeToRefs(workflow);
|
||||
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
|
||||
function showFeedback(result) {
|
||||
if (!result?.message) return;
|
||||
feedback.value = {
|
||||
severity: result.ok ? "success" : "error",
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
|
||||
async function onStartOneClick() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
const result = await workflow.startOneClickAuto();
|
||||
showFeedback(result);
|
||||
}
|
||||
|
||||
function onStopTask() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(workflow.stopOneClickAuto());
|
||||
}
|
||||
|
||||
function onClearData() {
|
||||
if (oneClickRunning.value) {
|
||||
feedback.value = {
|
||||
severity: "error",
|
||||
message: "一键任务运行中,请先停止任务",
|
||||
};
|
||||
return;
|
||||
}
|
||||
workflow.clearData();
|
||||
feedback.value = { severity: "success", message: "已清除数据" };
|
||||
}
|
||||
|
||||
function onOpenAgentConfig() {
|
||||
router.push({ name: "local-config" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="dashboard-card p-3">
|
||||
<button type="button" class="dashboard-one-click">开启一键自动</button>
|
||||
<div class="mt-2 grid grid-cols-2 gap-2">
|
||||
<Button label="停止任务" size="small" text severity="danger" />
|
||||
<Button label="清除数据" size="small" text severity="danger" @click="onClearData" />
|
||||
<button
|
||||
type="button"
|
||||
class="dashboard-one-click"
|
||||
:disabled="oneClickRunning"
|
||||
@click="onStartOneClick"
|
||||
>
|
||||
{{ oneClickRunning ? "一键自动运行中…" : "开启一键自动" }}
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="oneClickRunning || oneClickProgress > 0"
|
||||
class="one-click-progress mt-2"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between gap-2 text-xs text-slate-400">
|
||||
<span class="truncate">{{ oneClickStatus || "准备中…" }}</span>
|
||||
<span class="shrink-0 tabular-nums">{{ oneClickProgress }}%</span>
|
||||
</div>
|
||||
<div class="one-click-progress-track">
|
||||
<div
|
||||
class="one-click-progress-bar"
|
||||
:style="{ width: `${Math.min(100, Math.max(0, oneClickProgress))}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button label="智能体配置" size="small" outlined class="mt-2 w-full" />
|
||||
|
||||
<p
|
||||
v-if="feedback.message"
|
||||
class="mt-2 text-xs"
|
||||
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
|
||||
<div class="mt-2 grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
label="停止任务"
|
||||
size="small"
|
||||
text
|
||||
severity="danger"
|
||||
:disabled="!oneClickRunning"
|
||||
@click="onStopTask"
|
||||
/>
|
||||
<Button
|
||||
label="清除数据"
|
||||
size="small"
|
||||
text
|
||||
severity="danger"
|
||||
:disabled="oneClickRunning"
|
||||
@click="onClearData"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
label="智能体配置"
|
||||
size="small"
|
||||
outlined
|
||||
class="mt-2 w-full"
|
||||
@click="onOpenAgentConfig"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
112
src/config/coverTemplates.js
Normal file
112
src/config/coverTemplates.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/** 封面样式预设(简易 ffmpeg + 高级 Python 模板) */
|
||||
|
||||
export const COVER_TEMPLATES = [
|
||||
{
|
||||
id: "default",
|
||||
label: "底部白字(快速)",
|
||||
titleFontSize: 90,
|
||||
titleFontColor: "#FFFFFF",
|
||||
titlePosition: "bottom",
|
||||
},
|
||||
{
|
||||
id: "top",
|
||||
label: "顶部白字(快速)",
|
||||
titleFontSize: 80,
|
||||
titleFontColor: "#FFFFFF",
|
||||
titlePosition: "top",
|
||||
},
|
||||
{
|
||||
id: "center",
|
||||
label: "居中黄字(快速)",
|
||||
titleFontSize: 88,
|
||||
titleFontColor: "#FFD700",
|
||||
titlePosition: "center",
|
||||
},
|
||||
{
|
||||
id: "pil_stroke",
|
||||
label: "描边标题(PIL)",
|
||||
titleFontSize: 96,
|
||||
titleColor: "#FFFFFF",
|
||||
titlePosition: "bottom",
|
||||
titleStrokeWidth: 4,
|
||||
titleStrokeColor: "#000000",
|
||||
titleFontFamily: "Microsoft YaHei",
|
||||
},
|
||||
{
|
||||
id: "advanced_blur",
|
||||
label: "虚化背景 + 抠图",
|
||||
backgroundBlurEnabled: true,
|
||||
blurBackground: true,
|
||||
extractPerson: true,
|
||||
personSize: 88,
|
||||
titleFontSize: 72,
|
||||
titleFontColor: "#FFFFFF",
|
||||
titlePosition: "bottom",
|
||||
titleFontFamily: "Microsoft YaHei",
|
||||
titleBackgroundEnabled: false,
|
||||
},
|
||||
{
|
||||
id: "advanced_outline",
|
||||
label: "人物描边强调",
|
||||
extractPerson: true,
|
||||
personOutlineColor: "#FFD700",
|
||||
personOutlineWidth: 6,
|
||||
personSize: 90,
|
||||
backgroundBlurEnabled: true,
|
||||
titleFontSize: 80,
|
||||
titleFontColor: "#FFFFFF",
|
||||
titlePosition: "top",
|
||||
titleFontFamily: "Microsoft YaHei",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {string} templateId
|
||||
*/
|
||||
export function getCoverTemplate(templateId) {
|
||||
return (
|
||||
COVER_TEMPLATES.find((t) => t.id === templateId) || COVER_TEMPLATES[0]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 Electron `isNewTemplate` 一致
|
||||
* @param {Record<string, unknown>} config
|
||||
*/
|
||||
export function isAdvancedCoverConfig(config) {
|
||||
if (!config || typeof config !== "object") return false;
|
||||
return (
|
||||
config.blurBackground !== undefined ||
|
||||
config.extractPerson !== undefined ||
|
||||
config.personOutlineColor !== undefined ||
|
||||
config.personOutlineWidth !== undefined ||
|
||||
config.maskImagePath !== undefined ||
|
||||
Boolean(config.titleFontFamily) ||
|
||||
config.titleBackgroundEnabled !== undefined ||
|
||||
config.personSize !== undefined ||
|
||||
config.backgroundBlurEnabled !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} tpl
|
||||
* @param {{ customVideoPath?: string, overrides?: Record<string, unknown> }} [extra]
|
||||
*/
|
||||
export function buildCoverEffectStyle(tpl, extra = {}) {
|
||||
const { id: _id, label: _label, ...style } = tpl;
|
||||
return {
|
||||
...style,
|
||||
...(extra.overrides || {}),
|
||||
customVideoPath: extra.customVideoPath || "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} titleGenerated
|
||||
*/
|
||||
export function pickCoverTitleText(titleGenerated) {
|
||||
const raw = String(titleGenerated || "").trim();
|
||||
if (!raw) return "";
|
||||
const line = raw.split(/\r?\n/)[0].trim();
|
||||
return line.slice(0, 80);
|
||||
}
|
||||
63
src/config/subtitleTemplates.js
Normal file
63
src/config/subtitleTemplates.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/** 字幕样式预设(对齐 Electron 常用底部白字黑边,供 ffmpeg force_style) */
|
||||
export const SUBTITLE_TEMPLATES = [
|
||||
{
|
||||
id: "default",
|
||||
label: "默认白字黑边",
|
||||
fontName: "Microsoft YaHei",
|
||||
fontSize: 24,
|
||||
primaryColor: "#FFFFFF",
|
||||
outlineColor: "#000000",
|
||||
outline: 2,
|
||||
marginV: 40,
|
||||
},
|
||||
{
|
||||
id: "large",
|
||||
label: "大号字幕",
|
||||
fontName: "Microsoft YaHei",
|
||||
fontSize: 32,
|
||||
primaryColor: "#FFFFFF",
|
||||
outlineColor: "#000000",
|
||||
outline: 3,
|
||||
marginV: 48,
|
||||
},
|
||||
{
|
||||
id: "yellow",
|
||||
label: "黄色强调",
|
||||
fontName: "Microsoft YaHei",
|
||||
fontSize: 26,
|
||||
primaryColor: "#FFD700",
|
||||
outlineColor: "#000000",
|
||||
outline: 2,
|
||||
marginV: 42,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {string} templateId
|
||||
*/
|
||||
export function getSubtitleTemplate(templateId) {
|
||||
return (
|
||||
SUBTITLE_TEMPLATES.find((t) => t.id === templateId) ||
|
||||
SUBTITLE_TEMPLATES[0]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ fontName?: string, fontSize?: number, primaryColor?: string, outlineColor?: string, outline?: number, marginV?: number }} tpl
|
||||
*/
|
||||
export function buildFfmpegForceStyle(tpl) {
|
||||
const hexToAss = (hex) => {
|
||||
const h = hex.replace("#", "");
|
||||
const r = h.substring(0, 2);
|
||||
const g = h.substring(2, 4);
|
||||
const b = h.substring(4, 6);
|
||||
return `&H00${b}${g}${r}`.toUpperCase();
|
||||
};
|
||||
const fontName = tpl.fontName || "Microsoft YaHei";
|
||||
const fontSize = tpl.fontSize || 24;
|
||||
const primary = hexToAss(tpl.primaryColor || "#FFFFFF");
|
||||
const outline = hexToAss(tpl.outlineColor || "#000000");
|
||||
const outlineW = tpl.outline ?? 2;
|
||||
const marginV = tpl.marginV ?? 40;
|
||||
return `FontName=${fontName},FontSize=${fontSize},PrimaryColour=${primary},OutlineColour=${outline},Outline=${outlineW},Alignment=2,MarginV=${marginV}`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT } from "../stores/auth";
|
||||
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT,ROLE_OEM } from "../stores/auth";
|
||||
|
||||
|
||||
|
||||
@@ -191,7 +191,69 @@ const mainChildren = [
|
||||
],
|
||||
|
||||
},
|
||||
{
|
||||
|
||||
path: "oem",
|
||||
|
||||
component: () => import("../views/OemView.vue"),
|
||||
|
||||
meta: { title: "管理", requiresRole: ROLE_OEM},
|
||||
|
||||
redirect: { name: "oem-overview" },
|
||||
|
||||
children: [
|
||||
|
||||
{
|
||||
|
||||
path: "",
|
||||
|
||||
name: "oem-overview",
|
||||
|
||||
component: () => import("../views/oem/OemOverviewView.vue"),
|
||||
|
||||
meta: { title: "概览" },
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
path: "users",
|
||||
|
||||
name: "oem-users",
|
||||
|
||||
component: () => import("../views/oem/OemUsersView.vue"),
|
||||
|
||||
meta: { title: "用户管理" },
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
path: "desktop-config",
|
||||
|
||||
name: "admin-desktop-config",
|
||||
|
||||
component: () => import("../views/oem/OemDesktopConfigView.vue"),
|
||||
|
||||
meta: { title: "桌面配置" },
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
path: "card-keys",
|
||||
|
||||
name: "admin-card-keys",
|
||||
|
||||
component: () => import("../views/oem/OemCardKeysView.vue"),
|
||||
|
||||
meta: { title: "卡密管理" },
|
||||
|
||||
},
|
||||
|
||||
],
|
||||
|
||||
},
|
||||
{
|
||||
|
||||
path: "agent",
|
||||
|
||||
113
src/services/coverGenerate.js
Normal file
113
src/services/coverGenerate.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import { convertFileSrc, invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
import {
|
||||
buildCoverEffectStyle,
|
||||
getCoverTemplate,
|
||||
pickCoverTitleText,
|
||||
} from "../config/coverTemplates.js";
|
||||
|
||||
const COVER_SCRIPT = "cover_generate.js";
|
||||
|
||||
/**
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function pickCoverMaterialVideo() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "视频",
|
||||
extensions: ["mp4", "mov", "mkv", "avi", "webm"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return null;
|
||||
if (Array.isArray(selected)) return selected[0] || null;
|
||||
return String(selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
*/
|
||||
export function localImageToPlayableUrl(filePath) {
|
||||
if (!isTauri()) {
|
||||
throw new Error("请在 Tauri 桌面端预览封面");
|
||||
}
|
||||
return convertFileSrc(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function executeGenerateCover(store) {
|
||||
const videoPath = String(
|
||||
store.processedVideoPath || store.generatedVideoPath || "",
|
||||
).trim();
|
||||
if (!videoPath) {
|
||||
return { ok: false, message: "请先在步骤 02 生成口播视频(步骤 03/05 处理后亦可)" };
|
||||
}
|
||||
|
||||
const titleText = pickCoverTitleText(store.titleGenerated);
|
||||
if (!titleText) {
|
||||
return { ok: false, message: "请先在步骤 04 生成标题文字" };
|
||||
}
|
||||
|
||||
const tpl = getCoverTemplate(store.coverTemplateId || "default");
|
||||
const effectStyle = buildCoverEffectStyle(tpl, {
|
||||
customVideoPath: store.coverCustomVideoPath || "",
|
||||
});
|
||||
|
||||
store.coverGenerating = true;
|
||||
|
||||
try {
|
||||
const result = await invoke("run_nodejs_script", {
|
||||
scriptName: COVER_SCRIPT,
|
||||
params: {
|
||||
videoPath,
|
||||
titleText,
|
||||
effectStyle,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.success || !result?.coverPath) {
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result?.error || "封面生成失败,请重试"),
|
||||
};
|
||||
}
|
||||
|
||||
store.coverImagePath = result.coverPath;
|
||||
store.coverImageSrc = localImageToPlayableUrl(result.coverPath);
|
||||
if (Array.isArray(result.previewImages) && result.previewImages.length) {
|
||||
store.coverPreviewImages = result.previewImages;
|
||||
}
|
||||
|
||||
const modeHint =
|
||||
result.mode === "advanced_python"
|
||||
? "(高级模板)"
|
||||
: result.mode === "pil_python"
|
||||
? "(PIL)"
|
||||
: "";
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: String(result.message || "封面生成完成") + modeHint,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
||||
return { ok: false, message: `封面生成失败:${msg}` };
|
||||
} finally {
|
||||
store.coverGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function chooseCoverMaterial(store) {
|
||||
const path = await pickCoverMaterialVideo();
|
||||
if (!path) return { ok: false, message: "" };
|
||||
store.coverCustomVideoPath = path;
|
||||
return { ok: true, message: "已选择封面素材视频" };
|
||||
}
|
||||
15
src/services/mediaDuration.js
Normal file
15
src/services/mediaDuration.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function getMediaDurationSeconds(filePath) {
|
||||
if (!isTauri()) {
|
||||
throw new Error("获取媒体时长需在 Tauri 桌面端使用");
|
||||
}
|
||||
const path = String(filePath || "").trim();
|
||||
if (!path) return 0;
|
||||
const secs = await invoke("get_media_duration_seconds", { path });
|
||||
return typeof secs === "number" && secs > 0 ? secs : 0;
|
||||
}
|
||||
200
src/services/oneClickPipeline.js
Normal file
200
src/services/oneClickPipeline.js
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 一键智能流程(对齐 Electron executeOneClickIntelligence / kS)
|
||||
*
|
||||
* 顺序:标题标签 → 语音 → 口播视频 → 视频编辑 → 字幕/BGM → 封面 →(发布待实现则跳过)
|
||||
*/
|
||||
|
||||
const STEP_DEFS = [
|
||||
{ key: "titleTags", label: "正在生成标题标签关键词…", progress: 10 },
|
||||
{ key: "speech", label: "正在生成语音…", progress: 25 },
|
||||
{ key: "video", label: "正在生成口播视频…", progress: 45 },
|
||||
{ key: "videoEdit", label: "正在自动剪辑视频…", progress: 58 },
|
||||
{ key: "subtitleBgm", label: "正在生成字幕和 BGM…", progress: 75 },
|
||||
{ key: "cover", label: "正在生成封面…", progress: 90 },
|
||||
{ key: "publish", label: "正在发布视频…", progress: 96 },
|
||||
];
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function throwIfCancelled(store) {
|
||||
if (store.oneClickCancelRequested) {
|
||||
throw new Error("用户已取消一键智能流程");
|
||||
}
|
||||
}
|
||||
|
||||
function setProgress(store, label, percent) {
|
||||
store.oneClickStatus = label;
|
||||
store.oneClickProgress = percent;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {() => boolean} isBusy
|
||||
* @param {number} timeoutMs
|
||||
*/
|
||||
async function waitUntilIdle(isBusy, timeoutMs) {
|
||||
const start = Date.now();
|
||||
while (isBusy()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error("操作超时,请重试");
|
||||
}
|
||||
await sleep(400);
|
||||
}
|
||||
}
|
||||
|
||||
function assertOk(result, fallback) {
|
||||
if (!result?.ok) {
|
||||
throw new Error(result?.message || fallback);
|
||||
}
|
||||
}
|
||||
|
||||
function hasVideoEditOptions(store) {
|
||||
return store.autoCutBreath || store.pipInPicture || store.greenScreen;
|
||||
}
|
||||
|
||||
function shouldRunSubtitleBgm(store) {
|
||||
return store.autoSubtitle || store.bgmEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function executeOneClickPipeline(store) {
|
||||
if (store.oneClickRunning) {
|
||||
return { ok: false, message: "一键流程正在运行中" };
|
||||
}
|
||||
|
||||
const script = String(store.scriptContent || "").trim();
|
||||
if (!script) {
|
||||
return { ok: false, message: "请先在文案编辑区输入口播文案" };
|
||||
}
|
||||
if (!store.avatarSelect) {
|
||||
return { ok: false, message: "请先在步骤 02 选择形象" };
|
||||
}
|
||||
if (store.greenScreen && !store.greenScreenBackgroundPath) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "已勾选绿幕切换,请先上传替换背景图,或取消绿幕后再一键运行",
|
||||
};
|
||||
}
|
||||
if (store.bgmEnabled && !store.bgmPath) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "已启用背景音乐,请先选择音乐文件,或关闭 BGM 后再一键运行",
|
||||
};
|
||||
}
|
||||
if (store.pipInPicture) {
|
||||
const { loadVideoMixCutSettings } = await import("./videoEditProcess.js");
|
||||
const mix = await loadVideoMixCutSettings();
|
||||
const hasReplacements =
|
||||
Array.isArray(mix?.replacements) && mix.replacements.length > 0;
|
||||
const hasSimple = Boolean(mix?.simplePip?.materialPath);
|
||||
if (!hasReplacements && !hasSimple) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "已启用画中画,请先在步骤 03 选择画中画素材",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
store.oneClickRunning = true;
|
||||
store.oneClickCancelRequested = false;
|
||||
setProgress(store, "检查文案内容…", 2);
|
||||
|
||||
const savedSubtitleFlags = {
|
||||
autoSubtitle: store.autoSubtitle,
|
||||
smartSubtitle: store.smartSubtitle,
|
||||
};
|
||||
|
||||
try {
|
||||
throwIfCancelled(store);
|
||||
|
||||
setProgress(store, STEP_DEFS[0].label, STEP_DEFS[0].progress);
|
||||
assertOk(await store.generateTitleTags(), "标题标签关键词生成失败");
|
||||
await waitUntilIdle(() => store.titleTagsGenerating, 120_000);
|
||||
throwIfCancelled(store);
|
||||
|
||||
setProgress(store, STEP_DEFS[1].label, STEP_DEFS[1].progress);
|
||||
assertOk(await store.generateSpeech(), "语音生成失败");
|
||||
await waitUntilIdle(() => store.speechGenerating, 300_000);
|
||||
if (!store.generatedAudioPath) {
|
||||
throw new Error("语音生成失败,请检查 TTS 与网络配置");
|
||||
}
|
||||
throwIfCancelled(store);
|
||||
|
||||
setProgress(store, STEP_DEFS[2].label, STEP_DEFS[2].progress);
|
||||
assertOk(await store.generateTalkingVideo(), "口播视频生成失败");
|
||||
await waitUntilIdle(() => store.videoGenerating, 600_000);
|
||||
if (!store.generatedVideoPath) {
|
||||
throw new Error("口播视频生成失败,请检查数字人/云端配置");
|
||||
}
|
||||
throwIfCancelled(store);
|
||||
|
||||
if (hasVideoEditOptions(store)) {
|
||||
setProgress(store, STEP_DEFS[3].label, STEP_DEFS[3].progress);
|
||||
assertOk(await store.autoProcessVideo(), "视频剪辑失败");
|
||||
await waitUntilIdle(() => store.videoProcessing, 300_000);
|
||||
throwIfCancelled(store);
|
||||
} else {
|
||||
setProgress(store, "跳过视频编辑(未勾选剪辑项)", 55);
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
if (!shouldRunSubtitleBgm(store)) {
|
||||
store.autoSubtitle = true;
|
||||
}
|
||||
setProgress(store, STEP_DEFS[4].label, STEP_DEFS[4].progress);
|
||||
assertOk(await store.generateSubtitleAndBgm(), "字幕/BGM 处理失败");
|
||||
await waitUntilIdle(() => store.subtitleBgmGenerating, 300_000);
|
||||
throwIfCancelled(store);
|
||||
|
||||
setProgress(store, STEP_DEFS[5].label, STEP_DEFS[5].progress);
|
||||
assertOk(await store.generateCover(), "封面生成失败");
|
||||
await waitUntilIdle(() => store.coverGenerating, 120_000);
|
||||
throwIfCancelled(store);
|
||||
|
||||
const hasPublish = store.publishPlatforms?.some((p) => p.checked);
|
||||
if (hasPublish) {
|
||||
setProgress(store, STEP_DEFS[6].label, STEP_DEFS[6].progress);
|
||||
assertOk(await store.publishVideo({ autoPublish: true }), "视频发布失败");
|
||||
await waitUntilIdle(() => store.publishing, 600_000);
|
||||
throwIfCancelled(store);
|
||||
} else {
|
||||
setProgress(store, "未选择发布平台,跳过发布", 98);
|
||||
}
|
||||
|
||||
setProgress(store, "一键智能流程全部完成", 100);
|
||||
return { ok: true, message: "一键智能流程全部完成" };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
||||
const cancelled = store.oneClickCancelRequested;
|
||||
setProgress(store, cancelled ? "已停止" : `失败:${msg}`, store.oneClickProgress);
|
||||
return {
|
||||
ok: false,
|
||||
message: cancelled ? "已停止一键智能流程" : msg,
|
||||
cancelled,
|
||||
};
|
||||
} finally {
|
||||
store.autoSubtitle = savedSubtitleFlags.autoSubtitle;
|
||||
store.smartSubtitle = savedSubtitleFlags.smartSubtitle;
|
||||
store.oneClickRunning = false;
|
||||
store.titleTagsGenerating = false;
|
||||
store.speechGenerating = false;
|
||||
store.videoGenerating = false;
|
||||
store.videoProcessing = false;
|
||||
store.subtitleBgmGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export function stopOneClickPipeline(store) {
|
||||
if (!store.oneClickRunning) {
|
||||
return { ok: false, message: "当前没有运行中的一键任务" };
|
||||
}
|
||||
store.oneClickCancelRequested = true;
|
||||
store.oneClickStatus = "正在停止…";
|
||||
return { ok: true, message: "已请求停止,将在当前步骤结束后中断" };
|
||||
}
|
||||
151
src/services/subtitleBgmGenerate.js
Normal file
151
src/services/subtitleBgmGenerate.js
Normal file
@@ -0,0 +1,151 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
import { ensureAppConfigReady } from "../config/videoPipeline.js";
|
||||
import {
|
||||
buildFfmpegForceStyle,
|
||||
getSubtitleTemplate,
|
||||
} from "../config/subtitleTemplates.js";
|
||||
import { importGeneratedVideo } from "./videoDb.js";
|
||||
import { localAudioToPlayableUrl } from "./localAudio.js";
|
||||
import { localVideoToPlayableUrl } from "./localVideo.js";
|
||||
|
||||
const SUBTITLE_BGM_SCRIPT = "subtitle_bgm_generate.js";
|
||||
|
||||
/**
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function pickBgmFile() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "音频",
|
||||
extensions: ["mp3", "wav", "flac", "aac", "m4a", "ogg"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return null;
|
||||
if (Array.isArray(selected)) return selected[0] || null;
|
||||
return String(selected);
|
||||
}
|
||||
|
||||
function formatHistoryTime() {
|
||||
const d = new Date();
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function executeGenerateSubtitleAndBgm(store) {
|
||||
const inputVideo = String(store.generatedVideoPath || "").trim();
|
||||
if (!inputVideo) {
|
||||
return { ok: false, message: "请先在步骤 02 生成口播视频(步骤 03 编辑后亦可)" };
|
||||
}
|
||||
|
||||
if (!store.autoSubtitle && !store.bgmEnabled) {
|
||||
return { ok: false, message: "请至少勾选「自动生成字幕」或「添加背景音乐」" };
|
||||
}
|
||||
|
||||
if (store.autoSubtitle) {
|
||||
const cfg = await ensureAppConfigReady();
|
||||
if (!cfg.ok) {
|
||||
return { ok: false, message: cfg.message };
|
||||
}
|
||||
}
|
||||
|
||||
if (store.bgmEnabled && !store.bgmPath) {
|
||||
return { ok: false, message: "已启用背景音乐,请先点击「选择音乐」" };
|
||||
}
|
||||
|
||||
const tpl = getSubtitleTemplate(store.subtitleTemplateId || "default");
|
||||
const subtitleForceStyle = buildFfmpegForceStyle(tpl);
|
||||
|
||||
store.subtitleBgmGenerating = true;
|
||||
|
||||
try {
|
||||
const result = await invoke("run_nodejs_script", {
|
||||
scriptName: SUBTITLE_BGM_SCRIPT,
|
||||
params: {
|
||||
inputVideo,
|
||||
autoSubtitle: store.autoSubtitle,
|
||||
smartSubtitle: store.smartSubtitle,
|
||||
scriptContent: store.scriptContent || "",
|
||||
subtitleForceStyle,
|
||||
bgmEnabled: store.bgmEnabled,
|
||||
bgmPath: store.bgmPath || null,
|
||||
bgmVolume: store.bgmVolume,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.success || !result?.videoPath) {
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result?.error || "字幕/BGM 处理失败,请重试"),
|
||||
};
|
||||
}
|
||||
|
||||
if (result.srtPath) {
|
||||
store.subtitleSrtPath = result.srtPath;
|
||||
}
|
||||
|
||||
const baseName =
|
||||
store.videoHistory.find((v) => v.id === store.currentVideoId)?.name ||
|
||||
"口播视频";
|
||||
const displayName = `${baseName} · 字幕BGM · ${formatHistoryTime()}`;
|
||||
const record = await importGeneratedVideo(
|
||||
result.videoPath,
|
||||
displayName,
|
||||
store.avatarSelect ?? null,
|
||||
store.currentAudioId ?? null,
|
||||
);
|
||||
const videoSrc = localVideoToPlayableUrl(record.filePath);
|
||||
|
||||
store.generatedVideoPath = record.filePath;
|
||||
store.generatedVideoSrc = videoSrc;
|
||||
store.processedVideoPath = record.filePath;
|
||||
store.processedVideoSrc = videoSrc;
|
||||
store.currentVideoId = record.id;
|
||||
await store.refreshVideoHistory();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: String(result.message || "处理完成"),
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
||||
return { ok: false, message: `处理失败:${msg}` };
|
||||
} finally {
|
||||
store.subtitleBgmGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function previewBgm(store) {
|
||||
const p = String(store.bgmPath || "").trim();
|
||||
if (!p) {
|
||||
return { ok: false, message: "请先选择音乐" };
|
||||
}
|
||||
try {
|
||||
store.bgmPreviewSrc = await localAudioToPlayableUrl(p);
|
||||
return { ok: true, message: "" };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, message: `无法试听:${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function chooseBgm(store) {
|
||||
const path = await pickBgmFile();
|
||||
if (!path) return { ok: false, message: "" };
|
||||
store.bgmPath = path;
|
||||
store.bgmPreviewSrc = "";
|
||||
return { ok: true, message: "已选择背景音乐" };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { loadAppConfigMap } from "../config/videoPipeline.js";
|
||||
import { useAvatarStore } from "../stores/avatar.js";
|
||||
import { importGeneratedVideo } from "./videoDb.js";
|
||||
import { localVideoToPlayableUrl } from "./localVideo.js";
|
||||
import { getMediaDurationSeconds } from "./mediaDuration.js";
|
||||
|
||||
const RUNNINGHUB_SCRIPT = "runninghub_generate.js";
|
||||
const INFINITETALK_API_SCRIPT = "infinitetalk_api_generate.js";
|
||||
@@ -41,6 +42,25 @@ export async function executeGenerateTalkingVideo(store) {
|
||||
return { ok: false, message: modeCheck.message };
|
||||
}
|
||||
|
||||
let audioDuration = 0;
|
||||
if (scriptName === RUNNINGHUB_SCRIPT) {
|
||||
try {
|
||||
audioDuration = await getMediaDurationSeconds(audioPath);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
ok: false,
|
||||
message: `无法读取音频时长(需 ffmpeg/ffprobe): ${msg}`,
|
||||
};
|
||||
}
|
||||
if (audioDuration <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "无法读取音频时长,请确认已安装 ffmpeg 且音频文件有效",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
store.videoGenerating = true;
|
||||
|
||||
try {
|
||||
@@ -49,6 +69,7 @@ export async function executeGenerateTalkingVideo(store) {
|
||||
params: {
|
||||
audioPath,
|
||||
avatarVideoPath: avatar.filePath,
|
||||
audioDuration,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -161,4 +182,6 @@ export async function selectVideoHistory(store, videoId) {
|
||||
store.generatedVideoPath = item.filePath;
|
||||
store.generatedVideoSrc =
|
||||
item.videoSrc || localVideoToPlayableUrl(item.filePath);
|
||||
store.processedVideoPath = "";
|
||||
store.processedVideoSrc = "";
|
||||
}
|
||||
|
||||
90
src/services/titleTagsGenerate.js
Normal file
90
src/services/titleTagsGenerate.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import { ensureLlmConfigReady } from "../config/videoPipeline.js";
|
||||
|
||||
const TITLE_TAGS_SCRIPT = "title_tags_generate.js";
|
||||
const TITLE_TAG_PROMPT_KEY = "TITLE_TAG_PROMPT";
|
||||
|
||||
const KEYWORD_GROUPS = [
|
||||
{ key: "重点词/成语词", field: "keywordsFocus" },
|
||||
{ key: "描述词", field: "keywordsDescribe" },
|
||||
{ key: "行动词", field: "keywordsAction" },
|
||||
{ key: "情感词", field: "keywordsEmotion" },
|
||||
];
|
||||
|
||||
/**
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
async function loadTitleTagPromptOverride() {
|
||||
try {
|
||||
const rows = await invoke("list_local_app_config");
|
||||
const row = Array.isArray(rows)
|
||||
? rows.find((r) => r.name === TITLE_TAG_PROMPT_KEY)
|
||||
: null;
|
||||
return row?.value?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成标题、标签、关键词(对齐 Electron generateTitleTags)
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function executeGenerateTitleTags(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 };
|
||||
}
|
||||
|
||||
const titleTagPrompt =
|
||||
(await loadTitleTagPromptOverride()) ||
|
||||
store.titleTagPrompt?.trim() ||
|
||||
null;
|
||||
|
||||
store.titleTagsGenerating = true;
|
||||
|
||||
try {
|
||||
const result = await invoke("run_nodejs_script", {
|
||||
scriptName: TITLE_TAGS_SCRIPT,
|
||||
params: {
|
||||
content,
|
||||
titleTagPrompt,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.success) {
|
||||
if (result?.rawContent) {
|
||||
store.titleGenerated = String(result.rawContent).trim();
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result.error || "结果解析失败,已将原始内容填入标题框"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result?.error || "生成失败,请重试"),
|
||||
};
|
||||
}
|
||||
|
||||
store.titleGenerated = String(result.title || "").trim();
|
||||
store.tagsGenerated = String(result.tags || "").trim();
|
||||
|
||||
const kw = result.keywords || {};
|
||||
for (const { key, field } of KEYWORD_GROUPS) {
|
||||
store[field] = String(kw[key] || "").trim();
|
||||
}
|
||||
|
||||
return { ok: true, message: "标题、标签和关键词生成完成" };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
||||
return { ok: false, message: `生成失败:${msg}` };
|
||||
} finally {
|
||||
store.titleTagsGenerating = false;
|
||||
}
|
||||
}
|
||||
177
src/services/videoEditProcess.js
Normal file
177
src/services/videoEditProcess.js
Normal file
@@ -0,0 +1,177 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
import { importGeneratedVideo } from "./videoDb.js";
|
||||
import { localVideoToPlayableUrl } from "./localVideo.js";
|
||||
|
||||
const VIDEO_EDIT_SCRIPT = "video_edit_process.js";
|
||||
const MIX_CUT_CONFIG_KEY = "videoMixCutSettings";
|
||||
|
||||
/**
|
||||
* 从本地 SQLite 配置读取混剪设置(对齐 Electron videoMixCutSettings)
|
||||
* @returns {Promise<object | null>}
|
||||
*/
|
||||
export async function loadVideoMixCutSettings() {
|
||||
try {
|
||||
const rows = await invoke("list_local_app_config");
|
||||
const row = Array.isArray(rows)
|
||||
? rows.find((r) => r.name === MIX_CUT_CONFIG_KEY)
|
||||
: null;
|
||||
if (!row?.value) return null;
|
||||
return JSON.parse(row.value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存/合并 simplePip 到本地混剪配置
|
||||
* @param {object} simplePip
|
||||
*/
|
||||
export async function saveSimplePipConfig(simplePip) {
|
||||
const existing = (await loadVideoMixCutSettings()) || {};
|
||||
const next = {
|
||||
...existing,
|
||||
enabled: true,
|
||||
displayMode: existing.displayMode || "pip",
|
||||
simplePip,
|
||||
};
|
||||
await invoke("set_local_app_config", {
|
||||
name: MIX_CUT_CONFIG_KEY,
|
||||
value: JSON.stringify(next),
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择绿幕替换背景图
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function pickGreenScreenBackground() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "图片",
|
||||
extensions: ["jpg", "jpeg", "png", "webp", "bmp"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return null;
|
||||
if (Array.isArray(selected)) return selected[0] || null;
|
||||
return String(selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择画中画素材(视频或图片)
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function pickPipMaterial() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "视频或图片",
|
||||
extensions: ["mp4", "mov", "webm", "mkv", "m4v", "jpg", "jpeg", "png", "webp"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return null;
|
||||
if (Array.isArray(selected)) return selected[0] || null;
|
||||
return String(selected);
|
||||
}
|
||||
|
||||
function formatHistoryTime() {
|
||||
const d = new Date();
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动处理视频(对齐 Electron autoProcessVideo)
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function executeAutoProcessVideo(store) {
|
||||
const inputVideo = String(store.generatedVideoPath || "").trim();
|
||||
if (!inputVideo) {
|
||||
return { ok: false, message: "请先在步骤 02 生成口播视频" };
|
||||
}
|
||||
|
||||
if (!store.autoCutBreath && !store.pipInPicture && !store.greenScreen) {
|
||||
return { ok: false, message: "请至少勾选一项处理选项" };
|
||||
}
|
||||
|
||||
if (store.greenScreen && !store.greenScreenBackgroundPath) {
|
||||
return { ok: false, message: "已启用绿幕切换,请先上传替换背景图" };
|
||||
}
|
||||
|
||||
let mixCutSettings = null;
|
||||
if (store.pipInPicture) {
|
||||
mixCutSettings = await loadVideoMixCutSettings();
|
||||
const hasReplacements =
|
||||
Array.isArray(mixCutSettings?.replacements) &&
|
||||
mixCutSettings.replacements.length > 0;
|
||||
const hasSimple = Boolean(mixCutSettings?.simplePip?.materialPath);
|
||||
if (!hasReplacements && !hasSimple) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "已启用画中画,请先点击「选择画中画素材」",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
store.videoProcessing = true;
|
||||
|
||||
try {
|
||||
const result = await invoke("run_nodejs_script", {
|
||||
scriptName: VIDEO_EDIT_SCRIPT,
|
||||
params: {
|
||||
inputVideo,
|
||||
autoCutBreath: store.autoCutBreath,
|
||||
pipInPicture: store.pipInPicture,
|
||||
greenScreen: store.greenScreen,
|
||||
backgroundImage: store.greenScreenBackgroundPath || null,
|
||||
silenceThreshold: store.silenceThreshold,
|
||||
silenceDuration: store.silenceDuration,
|
||||
minPauseDuration: store.minPauseDuration,
|
||||
mixCutSettings,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.success || !result?.videoPath) {
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result?.error || "视频处理失败,请重试"),
|
||||
};
|
||||
}
|
||||
|
||||
const baseName =
|
||||
store.videoHistory.find((v) => v.id === store.currentVideoId)?.name ||
|
||||
"口播视频";
|
||||
const displayName = `${baseName} · 已编辑 · ${formatHistoryTime()}`;
|
||||
const record = await importGeneratedVideo(
|
||||
result.videoPath,
|
||||
displayName,
|
||||
store.avatarSelect ?? null,
|
||||
store.currentAudioId ?? null,
|
||||
);
|
||||
const videoSrc = localVideoToPlayableUrl(record.filePath);
|
||||
|
||||
store.generatedVideoPath = record.filePath;
|
||||
store.generatedVideoSrc = videoSrc;
|
||||
store.processedVideoPath = record.filePath;
|
||||
store.processedVideoSrc = videoSrc;
|
||||
store.currentVideoId = record.id;
|
||||
await store.refreshVideoHistory();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: String(result.message || "视频处理完成"),
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
||||
return { ok: false, message: `视频处理失败: ${msg}` };
|
||||
} finally {
|
||||
store.videoProcessing = false;
|
||||
}
|
||||
}
|
||||
176
src/services/videoPublish.js
Normal file
176
src/services/videoPublish.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export const PUBLISH_PLATFORM_DEFS = [
|
||||
{ key: "douyin", label: "抖音" },
|
||||
{ key: "kuaishou", label: "快手" },
|
||||
{ key: "shipin", label: "视频号" },
|
||||
{ key: "xiaohongshu", label: "小红书" },
|
||||
];
|
||||
|
||||
/**
|
||||
* @returns {typeof PUBLISH_PLATFORM_DEFS[number][]}
|
||||
*/
|
||||
export function createDefaultPublishPlatforms() {
|
||||
return PUBLISH_PLATFORM_DEFS.map((p) => ({
|
||||
key: p.key,
|
||||
label: p.label,
|
||||
checked: false,
|
||||
accountId: null,
|
||||
accounts: [],
|
||||
loggedIn: false,
|
||||
nickname: "",
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function refreshPublishAccounts(store) {
|
||||
try {
|
||||
const all = await invoke("publish_list_accounts", { platform: null });
|
||||
const list = Array.isArray(all) ? all : [];
|
||||
for (const row of store.publishPlatforms) {
|
||||
row.accounts = list.filter((a) => a.platform === row.key);
|
||||
const active = row.accounts.find(
|
||||
(a) => a.loginStatus === "active" && a.hasCookies,
|
||||
);
|
||||
if (active) {
|
||||
row.loggedIn = true;
|
||||
row.nickname = active.nickname || "";
|
||||
if (!row.accountId) row.accountId = active.id;
|
||||
} else {
|
||||
row.loggedIn = false;
|
||||
row.nickname = "";
|
||||
}
|
||||
}
|
||||
return { ok: true, message: "" };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, message: msg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
* @param {string} platformKey
|
||||
*/
|
||||
export async function loginPublishPlatform(store, platformKey) {
|
||||
const row = store.publishPlatforms.find((p) => p.key === platformKey);
|
||||
if (!row) return { ok: false, message: "未知平台" };
|
||||
|
||||
store.publishLoggingIn = platformKey;
|
||||
try {
|
||||
const result = await invoke("publish_login", {
|
||||
platform: platformKey,
|
||||
accountId: row.accountId || null,
|
||||
});
|
||||
if (!result?.success) {
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result?.error || result?.message || "登录失败"),
|
||||
};
|
||||
}
|
||||
if (result.account) {
|
||||
row.accountId = result.account.id;
|
||||
row.loggedIn = true;
|
||||
row.nickname = result.account.nickname || "";
|
||||
}
|
||||
await refreshPublishAccounts(store);
|
||||
return {
|
||||
ok: true,
|
||||
message: String(result.message || "登录成功"),
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, message: `登录失败:${msg}` };
|
||||
} finally {
|
||||
store.publishLoggingIn = "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function executePublishVideo(store, { autoPublish = true } = {}) {
|
||||
const selected = store.publishPlatforms.filter((p) => p.checked);
|
||||
if (!selected.length) {
|
||||
return { ok: false, message: "请至少选择一个发布平台" };
|
||||
}
|
||||
|
||||
const videoPath = String(
|
||||
store.processedVideoPath || store.generatedVideoPath || "",
|
||||
).trim();
|
||||
if (!videoPath) {
|
||||
return { ok: false, message: "请先生成口播视频" };
|
||||
}
|
||||
|
||||
const coverPath = String(store.coverImagePath || "").trim();
|
||||
if (!coverPath) {
|
||||
return { ok: false, message: "请先在步骤 06 生成封面" };
|
||||
}
|
||||
|
||||
const title = String(store.titleGenerated || "").trim();
|
||||
if (!title) {
|
||||
return { ok: false, message: "请先在步骤 04 生成标题" };
|
||||
}
|
||||
|
||||
for (const row of selected) {
|
||||
const check = await invoke("publish_check_login", {
|
||||
platform: row.key,
|
||||
accountId: row.accountId || null,
|
||||
checkBrowser: false,
|
||||
});
|
||||
if (!check?.isLoggedIn) {
|
||||
const label = row.label || row.key;
|
||||
return { ok: false, message: `请先登录:${label}` };
|
||||
}
|
||||
row.loggedIn = true;
|
||||
row.nickname = check?.userInfo?.nickname || row.nickname;
|
||||
}
|
||||
|
||||
const tags = String(store.tagsGenerated || "")
|
||||
.split(/[,,\s#]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const description =
|
||||
String(store.scriptContent || "").trim().slice(0, 500) || title;
|
||||
|
||||
store.publishing = true;
|
||||
store.publishStatus = "";
|
||||
store.publishResults = [];
|
||||
|
||||
try {
|
||||
const result = await invoke("publish_execute", {
|
||||
platforms: selected.map((p) => ({
|
||||
platform: p.key,
|
||||
accountId: p.accountId || null,
|
||||
})),
|
||||
videoPath,
|
||||
coverPath,
|
||||
title: title.split(/\r?\n/)[0].slice(0, 30),
|
||||
description,
|
||||
tags,
|
||||
autoPublish,
|
||||
});
|
||||
|
||||
store.publishResults = Array.isArray(result?.results) ? result.results : [];
|
||||
|
||||
if (!result?.success && !store.publishResults.some((r) => r.pending)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result?.message || result?.error || "发布失败"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: String(result?.message || "发布流程完成"),
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, message: `发布失败:${msg}` };
|
||||
} finally {
|
||||
store.publishing = false;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ const USER_KEY = "aiclient_current_user";
|
||||
export const ROLE_ADMIN = 1;
|
||||
/** 代理 */
|
||||
export const ROLE_AGENT = 2;
|
||||
export const ROLE_OEM=3;
|
||||
|
||||
|
||||
async function syncAuthSessionToRust(token, user) {
|
||||
try {
|
||||
@@ -58,6 +60,7 @@ export const useAuthStore = defineStore("auth", {
|
||||
roleId: (state) => state.currentUser?.role_id ?? null,
|
||||
isAdmin: (state) => state.currentUser?.role_id === ROLE_ADMIN,
|
||||
isAgent: (state) => state.currentUser?.role_id === ROLE_AGENT,
|
||||
isOEM : (state) => state.currentUser?.role_id === ROLE_OEM,
|
||||
},
|
||||
|
||||
actions: {
|
||||
|
||||
@@ -25,6 +25,34 @@ import {
|
||||
executeGenerateTalkingVideo,
|
||||
selectVideoHistory,
|
||||
} from "../services/talkingVideoGenerate.js";
|
||||
import {
|
||||
executeAutoProcessVideo,
|
||||
pickGreenScreenBackground,
|
||||
pickPipMaterial,
|
||||
saveSimplePipConfig,
|
||||
} from "../services/videoEditProcess.js";
|
||||
import { executeGenerateTitleTags } from "../services/titleTagsGenerate.js";
|
||||
import {
|
||||
chooseBgm,
|
||||
executeGenerateSubtitleAndBgm,
|
||||
previewBgm,
|
||||
} from "../services/subtitleBgmGenerate.js";
|
||||
import {
|
||||
chooseCoverMaterial,
|
||||
executeGenerateCover,
|
||||
} from "../services/coverGenerate.js";
|
||||
import { COVER_TEMPLATES } from "../config/coverTemplates.js";
|
||||
import {
|
||||
executeOneClickPipeline,
|
||||
stopOneClickPipeline,
|
||||
} from "../services/oneClickPipeline.js";
|
||||
import {
|
||||
createDefaultPublishPlatforms,
|
||||
executePublishVideo,
|
||||
loginPublishPlatform,
|
||||
refreshPublishAccounts,
|
||||
} from "../services/videoPublish.js";
|
||||
import { SUBTITLE_TEMPLATES } from "../config/subtitleTemplates.js";
|
||||
import { listGeneratedAudios } from "../services/audioDb.js";
|
||||
import { listGeneratedVideos } from "../services/videoDb.js";
|
||||
import { localAudioToPlayableUrl } from "../services/localAudio.js";
|
||||
@@ -41,20 +69,6 @@ const NODEJS_EVENT = "nodejs:event";
|
||||
|
||||
|
||||
|
||||
const defaultPublishPlatforms = () => [
|
||||
|
||||
{ label: "*音", checked: false, account: null },
|
||||
|
||||
{ label: "*手", checked: false, account: null },
|
||||
|
||||
{ label: "*频号", checked: false, account: null },
|
||||
|
||||
{ label: "*红书", checked: false, account: null },
|
||||
|
||||
];
|
||||
|
||||
|
||||
|
||||
export const useWorkflowStore = defineStore("workflow", {
|
||||
|
||||
state: () => ({
|
||||
@@ -89,6 +103,10 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
topicRewritePrompt:
|
||||
"请仿写以下标题:{{content}}。要求保持原意和风格,但用不同的表达方式,更加吸引人。",
|
||||
|
||||
/** 标题标签关键词提示词(对齐 zhenqianba titleTagPrompt) */
|
||||
titleTagPrompt:
|
||||
"你是短视频标题与标签助手。分析文案内容 {{content}},生成吸引人的标题、相关标签,以及四个分组关键词(重点词/成语词、描述词、行动词、情感词,每组 0-2 个词)。",
|
||||
|
||||
videoLinkModalVisible: false,
|
||||
|
||||
videoShareText: "",
|
||||
@@ -195,10 +213,29 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
|
||||
greenScreen: false,
|
||||
|
||||
videoProcessing: false,
|
||||
|
||||
/** 绿幕替换背景图本地路径 */
|
||||
greenScreenBackgroundPath: "",
|
||||
|
||||
/** 编辑后视频(与 generated 同步更新,便于后续步骤区分) */
|
||||
processedVideoPath: "",
|
||||
|
||||
processedVideoSrc: "",
|
||||
|
||||
/** 剪气口参数(对齐 Electron settings) */
|
||||
silenceThreshold: -40,
|
||||
|
||||
silenceDuration: 1,
|
||||
|
||||
minPauseDuration: 0.15,
|
||||
|
||||
|
||||
|
||||
// 04 标题标签关键词
|
||||
|
||||
titleTagsGenerating: false,
|
||||
|
||||
titleGenerated: "",
|
||||
|
||||
tagsGenerated: "",
|
||||
@@ -227,11 +264,62 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
|
||||
subtitleTemplate: null,
|
||||
|
||||
/** 字幕样式模板 id(见 subtitleTemplates.js) */
|
||||
subtitleTemplateId: "default",
|
||||
|
||||
subtitleBgmGenerating: false,
|
||||
|
||||
subtitleSrtPath: "",
|
||||
|
||||
bgmPath: "",
|
||||
|
||||
bgmPreviewSrc: "",
|
||||
|
||||
|
||||
|
||||
// 06 封面制作
|
||||
|
||||
coverGenerating: false,
|
||||
|
||||
coverImagePath: "",
|
||||
|
||||
coverImageSrc: "",
|
||||
|
||||
/** 高级封面步骤预览图路径列表(Python 返回) */
|
||||
coverPreviewImages: [],
|
||||
|
||||
coverTemplateId: "default",
|
||||
|
||||
/** 封面素材视频(可选,覆盖口播视频抽帧) */
|
||||
coverCustomVideoPath: "",
|
||||
|
||||
|
||||
|
||||
// 07 视频发布
|
||||
|
||||
publishPlatforms: defaultPublishPlatforms(),
|
||||
publishPlatforms: createDefaultPublishPlatforms(),
|
||||
|
||||
publishing: false,
|
||||
|
||||
publishLoggingIn: "",
|
||||
|
||||
publishStatus: "",
|
||||
|
||||
publishResults: [],
|
||||
|
||||
publishScheduledAt: null,
|
||||
|
||||
publishScheduleTimerId: null,
|
||||
|
||||
// 一键自动
|
||||
|
||||
oneClickRunning: false,
|
||||
|
||||
oneClickCancelRequested: false,
|
||||
|
||||
oneClickStatus: "",
|
||||
|
||||
oneClickProgress: 0,
|
||||
|
||||
}),
|
||||
|
||||
@@ -269,6 +357,35 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
|
||||
keywordCountEmotion: (state) => countLines(state.keywordsEmotion),
|
||||
|
||||
subtitleTemplateLabel: (state) => {
|
||||
const tpl = SUBTITLE_TEMPLATES.find(
|
||||
(t) => t.id === (state.subtitleTemplateId || "default"),
|
||||
);
|
||||
return tpl?.label || "未选择";
|
||||
},
|
||||
|
||||
coverTemplateLabel: (state) => {
|
||||
const tpl = COVER_TEMPLATES.find(
|
||||
(t) => t.id === (state.coverTemplateId || "default"),
|
||||
);
|
||||
return tpl?.label || "未选择";
|
||||
},
|
||||
|
||||
coverMaterialFileName: (state) => {
|
||||
const p = String(state.coverCustomVideoPath || "").trim();
|
||||
if (!p) return "";
|
||||
const parts = p.replace(/\\/g, "/").split("/");
|
||||
return parts[parts.length - 1] || p;
|
||||
},
|
||||
|
||||
bgmFileName: (state) => {
|
||||
const p = state.bgmPath;
|
||||
if (!p) return "";
|
||||
return p.split(/[/\\]/).pop() || p;
|
||||
},
|
||||
|
||||
oneClickBusy: (state) => state.oneClickRunning,
|
||||
|
||||
ipBenchmarkCount: (state) => state.ipBenchmarks.length,
|
||||
|
||||
selectedBenchmark: (state) =>
|
||||
@@ -511,11 +628,109 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
await selectVideoHistory(this, videoId);
|
||||
},
|
||||
|
||||
async autoProcessVideo() {
|
||||
return executeAutoProcessVideo(this);
|
||||
},
|
||||
|
||||
async chooseGreenScreenBackground() {
|
||||
const path = await pickGreenScreenBackground();
|
||||
if (!path) return { ok: false, message: "" };
|
||||
this.greenScreenBackgroundPath = path;
|
||||
return { ok: true, message: "已选择绿幕背景图" };
|
||||
},
|
||||
|
||||
async choosePipMaterial() {
|
||||
const path = await pickPipMaterial();
|
||||
if (!path) return { ok: false, message: "" };
|
||||
await saveSimplePipConfig({
|
||||
materialPath: path,
|
||||
startTime: 0,
|
||||
displayMode: "pip",
|
||||
pipPosition: "bottom-right",
|
||||
pipSizePercent: 30,
|
||||
pipScaleMode: "fit",
|
||||
});
|
||||
this.pipInPicture = true;
|
||||
return { ok: true, message: "已选择画中画素材" };
|
||||
},
|
||||
|
||||
async generateTitleTags() {
|
||||
return executeGenerateTitleTags(this);
|
||||
},
|
||||
|
||||
async generateSubtitleAndBgm() {
|
||||
return executeGenerateSubtitleAndBgm(this);
|
||||
},
|
||||
|
||||
async chooseBgm() {
|
||||
return chooseBgm(this);
|
||||
},
|
||||
|
||||
async previewBgm() {
|
||||
return previewBgm(this);
|
||||
},
|
||||
|
||||
async generateCover() {
|
||||
return executeGenerateCover(this);
|
||||
},
|
||||
|
||||
async chooseCoverMaterial() {
|
||||
return chooseCoverMaterial(this);
|
||||
},
|
||||
|
||||
async refreshPublishAccounts() {
|
||||
return refreshPublishAccounts(this);
|
||||
},
|
||||
|
||||
async loginPublishPlatform(platformKey) {
|
||||
return loginPublishPlatform(this, platformKey);
|
||||
},
|
||||
|
||||
async publishVideo(options) {
|
||||
return executePublishVideo(this, options);
|
||||
},
|
||||
|
||||
clearPublishSchedule() {
|
||||
if (this.publishScheduleTimerId) {
|
||||
clearTimeout(this.publishScheduleTimerId);
|
||||
this.publishScheduleTimerId = null;
|
||||
}
|
||||
this.publishScheduledAt = null;
|
||||
},
|
||||
|
||||
schedulePublishVideo(scheduledAt) {
|
||||
this.clearPublishSchedule();
|
||||
const target = scheduledAt instanceof Date ? scheduledAt : new Date(scheduledAt);
|
||||
const delayMs = target.getTime() - Date.now();
|
||||
if (delayMs <= 0) {
|
||||
return { ok: false, message: "发布时间必须晚于当前时间" };
|
||||
}
|
||||
this.publishScheduledAt = target.toISOString();
|
||||
this.publishScheduleTimerId = setTimeout(async () => {
|
||||
this.publishScheduleTimerId = null;
|
||||
this.publishScheduledAt = null;
|
||||
await executePublishVideo(this, { autoPublish: true });
|
||||
}, delayMs);
|
||||
return {
|
||||
ok: true,
|
||||
message: `已设置定时发布:${target.toLocaleString("zh-CN")}`,
|
||||
};
|
||||
},
|
||||
|
||||
async startOneClickAuto() {
|
||||
return executeOneClickPipeline(this);
|
||||
},
|
||||
|
||||
stopOneClickAuto() {
|
||||
return stopOneClickPipeline(this);
|
||||
},
|
||||
|
||||
resetAll() {
|
||||
|
||||
this.$reset();
|
||||
|
||||
this.publishPlatforms = defaultPublishPlatforms();
|
||||
this.publishPlatforms = createDefaultPublishPlatforms();
|
||||
this.clearPublishSchedule();
|
||||
|
||||
},
|
||||
|
||||
|
||||
@@ -503,6 +503,27 @@ body {
|
||||
box-shadow: 0 4px 16px rgba(168, 85, 247, 0.35);
|
||||
}
|
||||
|
||||
.dashboard-one-click:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
filter: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.one-click-progress-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.one-click-progress-bar {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #a855f7, #d946ef);
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
/* 复选框选中:无实心 primary 底,保持深色背景 + 蓝色描边/勾 */
|
||||
:root {
|
||||
--p-checkbox-checked-background: rgba(255, 255, 255, 0.05);
|
||||
|
||||
12
src/views/OemView.vue
Normal file
12
src/views/OemView.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup>
|
||||
import OemSubNav from "../components/oem/OemSubNav.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<AdminSubNav />
|
||||
<div class="admin-layout__content custom-scrollbar">
|
||||
<RouterView />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT } from "../../stores/auth.js";
|
||||
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT,ROLE_OEM } from "../../stores/auth.js";
|
||||
import {
|
||||
listAdminUsersApi,
|
||||
createAdminUserApi,
|
||||
@@ -14,12 +14,14 @@ const ROLE_OPTIONS = [
|
||||
{ label: "普通用户", value: 0 },
|
||||
{ label: "管理员", value: 1 },
|
||||
{ label: "代理", value: 2 },
|
||||
{ label: "OEM", value: 3 },
|
||||
];
|
||||
|
||||
const ROLE_LABELS = {
|
||||
0: "普通用户",
|
||||
[ROLE_ADMIN]: "管理员",
|
||||
[ROLE_AGENT]: "代理",
|
||||
[ROLE_OEM]: "OEM",
|
||||
};
|
||||
|
||||
const users = ref([]);
|
||||
|
||||
369
src/views/oem/OemCardKeysView.vue
Normal file
369
src/views/oem/OemCardKeysView.vue
Normal file
@@ -0,0 +1,369 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useAuthStore } from "../../stores/auth.js";
|
||||
import {
|
||||
listAdminCardKeysApi,
|
||||
createAdminCardKeysApi,
|
||||
updateAdminCardKeyApi,
|
||||
deleteAdminCardKeyApi,
|
||||
} from "../../api/adminCardKeys.js";
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "未使用", value: "unused" },
|
||||
{ label: "已激活", value: "used" },
|
||||
];
|
||||
|
||||
const cards = ref([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const statusFilter = ref("all");
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const dialogMode = ref("create");
|
||||
const editingId = ref(null);
|
||||
|
||||
const emptyForm = () => ({
|
||||
serial_number: "",
|
||||
duration_days: 30,
|
||||
count: 1,
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const form = ref(emptyForm());
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
dialogMode.value === "create" ? "生成卡密" : "编辑卡密",
|
||||
);
|
||||
|
||||
const isActivated = computed(() => dialogMode.value === "edit" && !!editingActivatedAt.value);
|
||||
const editingActivatedAt = ref(null);
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function showMessage(severity, message) {
|
||||
feedback.value = { severity, message };
|
||||
}
|
||||
|
||||
async function loadCards() {
|
||||
loading.value = true;
|
||||
feedback.value = { severity: "", message: "" };
|
||||
const res = await listAdminCardKeysApi(auth.token, {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
status: statusFilter.value,
|
||||
});
|
||||
loading.value = false;
|
||||
|
||||
if (!res.ok) {
|
||||
showMessage("error", res.message || "加载卡密列表失败");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data;
|
||||
cards.value = Array.isArray(data?.items) ? data.items : [];
|
||||
total.value = typeof data?.total === "number" ? data.total : 0;
|
||||
if (typeof data?.page === "number") page.value = data.page;
|
||||
if (typeof data?.page_size === "number") pageSize.value = data.page_size;
|
||||
}
|
||||
|
||||
async function onPage(event) {
|
||||
page.value = event.page + 1;
|
||||
pageSize.value = event.rows;
|
||||
await loadCards();
|
||||
}
|
||||
|
||||
function onStatusChange() {
|
||||
page.value = 1;
|
||||
loadCards();
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
dialogMode.value = "create";
|
||||
editingId.value = null;
|
||||
editingActivatedAt.value = null;
|
||||
form.value = emptyForm();
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openEditDialog(row) {
|
||||
dialogMode.value = "edit";
|
||||
editingId.value = row.id;
|
||||
editingActivatedAt.value = row.activated_at;
|
||||
form.value = {
|
||||
serial_number: row.serial_number,
|
||||
duration_days: row.duration_days,
|
||||
count: 1,
|
||||
remark: row.remark || "",
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function copySerial(serial) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(serial);
|
||||
showMessage("success", "序列号已复制");
|
||||
} catch {
|
||||
showMessage("warn", "复制失败,请手动复制");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCard() {
|
||||
if (form.value.duration_days < 1) {
|
||||
showMessage("warn", "时长至少 1 天");
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
feedback.value = { severity: "", message: "" };
|
||||
|
||||
let res;
|
||||
if (dialogMode.value === "create") {
|
||||
const payload = {
|
||||
duration_days: form.value.duration_days,
|
||||
count: form.value.count,
|
||||
remark: form.value.remark.trim() || null,
|
||||
};
|
||||
const serial = form.value.serial_number.trim();
|
||||
if (serial) {
|
||||
if (form.value.count !== 1) {
|
||||
saving.value = false;
|
||||
showMessage("warn", "指定序列号时数量只能为 1");
|
||||
return;
|
||||
}
|
||||
payload.serial_number = serial.toUpperCase();
|
||||
}
|
||||
res = await createAdminCardKeysApi(auth.token, payload);
|
||||
} else {
|
||||
res = await updateAdminCardKeyApi(auth.token, editingId.value, {
|
||||
duration_days: isActivated.value ? undefined : form.value.duration_days,
|
||||
remark: form.value.remark.trim() || null,
|
||||
});
|
||||
}
|
||||
|
||||
saving.value = false;
|
||||
|
||||
if (!res.ok) {
|
||||
showMessage("error", res.message || "保存失败");
|
||||
return;
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
showMessage("success", res.message || "保存成功");
|
||||
await loadCards();
|
||||
}
|
||||
|
||||
async function removeCard(row) {
|
||||
if (row.activated_at) {
|
||||
showMessage("warn", "已激活卡密不能删除");
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = window.confirm(`确定删除卡密「${row.serial_number}」?`);
|
||||
if (!ok) return;
|
||||
|
||||
const res = await deleteAdminCardKeyApi(auth.token, row.id);
|
||||
if (!res.ok) {
|
||||
showMessage("error", res.message || "删除失败");
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage("success", res.message || "已删除");
|
||||
if (cards.value.length === 1 && page.value > 1) {
|
||||
page.value -= 1;
|
||||
}
|
||||
await loadCards();
|
||||
}
|
||||
|
||||
watch(statusFilter, onStatusChange);
|
||||
|
||||
onMounted(() => {
|
||||
loadCards();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-page admin-card-keys">
|
||||
<div class="admin-users__header">
|
||||
<div>
|
||||
<h1 class="admin-page__title gradient-text">卡密管理</h1>
|
||||
<p class="text-sm text-text-muted">生成、查看与管理 VIP 激活卡密</p>
|
||||
</div>
|
||||
<Button label="生成卡密" @click="openCreateDialog" />
|
||||
</div>
|
||||
|
||||
<Message
|
||||
v-if="feedback.message"
|
||||
:severity="feedback.severity"
|
||||
:closable="true"
|
||||
class="admin-users__message"
|
||||
@close="feedback.message = ''"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</Message>
|
||||
|
||||
<div class="admin-card-keys__toolbar">
|
||||
<label class="text-sm text-text-muted">状态筛选</label>
|
||||
<Select
|
||||
v-model="statusFilter"
|
||||
:options="STATUS_OPTIONS"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="admin-card-keys__status-select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="cards"
|
||||
:loading="loading"
|
||||
lazy
|
||||
paginator
|
||||
:rows="pageSize"
|
||||
:total-records="total"
|
||||
:first="(page - 1) * pageSize"
|
||||
:rows-per-page-options="[10, 20, 50]"
|
||||
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
|
||||
current-page-report-template="共 {totalRecords} 条"
|
||||
striped-rows
|
||||
size="small"
|
||||
class="admin-users__table"
|
||||
data-key="id"
|
||||
@page="onPage"
|
||||
>
|
||||
<Column field="serial_number" header="序列号" style="min-width: 11rem">
|
||||
<template #body="{ data }">
|
||||
<div class="admin-card-keys__serial">
|
||||
<code class="text-xs">{{ data.serial_number }}</code>
|
||||
<Button
|
||||
label="复制"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="copySerial(data.serial_number)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="duration_days" header="时长(天)" style="width: 6rem" />
|
||||
<Column field="created_at" header="创建时间">
|
||||
<template #body="{ data }">
|
||||
{{ formatDateTime(data.created_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="activated_at" header="激活时间">
|
||||
<template #body="{ data }">
|
||||
<Tag
|
||||
v-if="data.activated_at"
|
||||
:value="formatDateTime(data.activated_at)"
|
||||
severity="success"
|
||||
/>
|
||||
<Tag v-else value="未使用" severity="secondary" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="username" header="使用者">
|
||||
<template #body="{ data }">
|
||||
{{ data.username || "—" }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="remark" header="备注">
|
||||
<template #body="{ data }">
|
||||
{{ data.remark || "—" }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="操作" style="width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<div class="admin-users__actions">
|
||||
<Button
|
||||
label="编辑"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="openEditDialog(data)"
|
||||
/>
|
||||
<Button
|
||||
label="删除"
|
||||
size="small"
|
||||
severity="danger"
|
||||
text
|
||||
:disabled="!!data.activated_at"
|
||||
@click="removeCard(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<template #empty>
|
||||
<p class="py-6 text-center text-sm text-text-muted">暂无卡密</p>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="dialogVisible"
|
||||
:header="dialogTitle"
|
||||
modal
|
||||
:style="{ width: '28rem' }"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<template v-if="dialogMode === 'create'">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">时长(天)</label>
|
||||
<InputNumber v-model="form.duration_days" :min="1" :max="3650" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">生成数量</label>
|
||||
<InputNumber v-model="form.count" :min="1" :max="100" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">自定义序列号(可选,仅 1 张)</label>
|
||||
<InputText
|
||||
v-model="form.serial_number"
|
||||
class="w-full font-mono uppercase"
|
||||
placeholder="留空则自动生成"
|
||||
:disabled="form.count > 1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">序列号</label>
|
||||
<InputText
|
||||
:model-value="form.serial_number"
|
||||
class="w-full font-mono"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">时长(天)</label>
|
||||
<InputNumber
|
||||
v-model="form.duration_days"
|
||||
:min="1"
|
||||
:max="3650"
|
||||
class="w-full"
|
||||
:disabled="isActivated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">备注</label>
|
||||
<Textarea v-model="form.remark" rows="2" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="取消" severity="secondary" text @click="dialogVisible = false" />
|
||||
<Button label="保存" :loading="saving" @click="saveCard" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
325
src/views/oem/OemDesktopConfigView.vue
Normal file
325
src/views/oem/OemDesktopConfigView.vue
Normal file
@@ -0,0 +1,325 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useAuthStore } from "../../stores/auth.js";
|
||||
import {
|
||||
listAdminDesktopConfigsApi,
|
||||
createAdminDesktopConfigApi,
|
||||
updateAdminDesktopConfigApi,
|
||||
deleteAdminDesktopConfigApi,
|
||||
} from "../../api/adminDesktopConfigs.js";
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
const configs = ref([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const keyword = ref("");
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const dialogMode = ref("create");
|
||||
const editingId = ref(null);
|
||||
|
||||
const emptyForm = () => ({
|
||||
name: "",
|
||||
value: "",
|
||||
mark: "",
|
||||
});
|
||||
|
||||
const form = ref(emptyForm());
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
dialogMode.value === "create" ? "新建配置" : "编辑配置",
|
||||
);
|
||||
|
||||
const valuePreview = computed(() => {
|
||||
const v = form.value.value || "";
|
||||
if (v.length <= 120) return v;
|
||||
return `${v.slice(0, 120)}…`;
|
||||
});
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function showMessage(severity, message) {
|
||||
feedback.value = { severity, message };
|
||||
}
|
||||
|
||||
function truncateValue(value, max = 48) {
|
||||
if (!value) return "—";
|
||||
if (value.length <= max) return value;
|
||||
return `${value.slice(0, max)}…`;
|
||||
}
|
||||
|
||||
async function loadConfigs() {
|
||||
loading.value = true;
|
||||
feedback.value = { severity: "", message: "" };
|
||||
const res = await listAdminDesktopConfigsApi(auth.token, {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: keyword.value,
|
||||
});
|
||||
loading.value = false;
|
||||
|
||||
if (!res.ok) {
|
||||
showMessage("error", res.message || "加载配置失败");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data;
|
||||
configs.value = Array.isArray(data?.items) ? data.items : [];
|
||||
total.value = typeof data?.total === "number" ? data.total : 0;
|
||||
if (typeof data?.page === "number") page.value = data.page;
|
||||
if (typeof data?.page_size === "number") pageSize.value = data.page_size;
|
||||
}
|
||||
|
||||
async function onPage(event) {
|
||||
page.value = event.page + 1;
|
||||
pageSize.value = event.rows;
|
||||
await loadConfigs();
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1;
|
||||
loadConfigs();
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
dialogMode.value = "create";
|
||||
editingId.value = null;
|
||||
form.value = emptyForm();
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openEditDialog(row) {
|
||||
dialogMode.value = "edit";
|
||||
editingId.value = row.id;
|
||||
form.value = {
|
||||
name: row.name,
|
||||
value: row.value,
|
||||
mark: row.mark || "",
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function copyName(name) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(name);
|
||||
showMessage("success", "键名已复制");
|
||||
} catch {
|
||||
showMessage("warn", "复制失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
const name = form.value.name.trim();
|
||||
if (!name) {
|
||||
showMessage("warn", "配置键名不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
feedback.value = { severity: "", message: "" };
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
value: form.value.value,
|
||||
mark: form.value.mark.trim() || null,
|
||||
};
|
||||
|
||||
let res;
|
||||
if (dialogMode.value === "create") {
|
||||
res = await createAdminDesktopConfigApi(auth.token, payload);
|
||||
} else {
|
||||
res = await updateAdminDesktopConfigApi(auth.token, editingId.value, {
|
||||
value: payload.value,
|
||||
mark: payload.mark,
|
||||
});
|
||||
}
|
||||
|
||||
saving.value = false;
|
||||
|
||||
if (!res.ok) {
|
||||
showMessage("error", res.message || "保存失败");
|
||||
return;
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
showMessage("success", res.message || "保存成功");
|
||||
await loadConfigs();
|
||||
}
|
||||
|
||||
async function removeConfig(row) {
|
||||
const ok = window.confirm(`确定删除配置「${row.name}」?桌面端需重新登录后生效。`);
|
||||
if (!ok) return;
|
||||
|
||||
const res = await deleteAdminDesktopConfigApi(auth.token, row.id);
|
||||
if (!res.ok) {
|
||||
showMessage("error", res.message || "删除失败");
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage("success", res.message || "已删除");
|
||||
if (configs.value.length === 1 && page.value > 1) {
|
||||
page.value -= 1;
|
||||
}
|
||||
await loadConfigs();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfigs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-page admin-desktop-config">
|
||||
<div class="admin-users__header">
|
||||
<div>
|
||||
<h1 class="admin-page__title gradient-text">桌面配置</h1>
|
||||
<p class="text-sm text-text-muted">
|
||||
管理 desktop_configs 键值,下发至桌面端 Node 环境变量(AICLIENT_CFG_*)
|
||||
</p>
|
||||
</div>
|
||||
<Button label="新建配置" @click="openCreateDialog" />
|
||||
</div>
|
||||
|
||||
<Message
|
||||
v-if="feedback.message"
|
||||
:severity="feedback.severity"
|
||||
:closable="true"
|
||||
class="admin-users__message"
|
||||
@close="feedback.message = ''"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</Message>
|
||||
|
||||
<div class="admin-card-keys__toolbar">
|
||||
<InputText
|
||||
v-model="keyword"
|
||||
placeholder="按键名搜索,如 LLM_API_KEY"
|
||||
class="admin-desktop-config__search"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
<Button label="搜索" severity="secondary" @click="onSearch" />
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="configs"
|
||||
:loading="loading"
|
||||
lazy
|
||||
paginator
|
||||
:rows="pageSize"
|
||||
:total-records="total"
|
||||
:first="(page - 1) * pageSize"
|
||||
:rows-per-page-options="[10, 20, 50]"
|
||||
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
|
||||
current-page-report-template="共 {totalRecords} 条"
|
||||
striped-rows
|
||||
size="small"
|
||||
class="admin-users__table"
|
||||
data-key="id"
|
||||
@page="onPage"
|
||||
>
|
||||
<Column field="name" header="键名" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<div class="admin-card-keys__serial">
|
||||
<code class="text-xs">{{ data.name }}</code>
|
||||
<Button label="复制" size="small" severity="secondary" text @click="copyName(data.name)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="value" header="值">
|
||||
<template #body="{ data }">
|
||||
<span class="admin-desktop-config__value" :title="data.value">
|
||||
{{ truncateValue(data.value) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="mark" header="备注">
|
||||
<template #body="{ data }">
|
||||
{{ data.mark || "—" }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="updated_at" header="更新时间">
|
||||
<template #body="{ data }">
|
||||
{{ formatDateTime(data.updated_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="操作" style="width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<div class="admin-users__actions">
|
||||
<Button
|
||||
label="编辑"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="openEditDialog(data)"
|
||||
/>
|
||||
<Button
|
||||
label="删除"
|
||||
size="small"
|
||||
severity="danger"
|
||||
text
|
||||
@click="removeConfig(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<template #empty>
|
||||
<p class="py-6 text-center text-sm text-text-muted">暂无配置</p>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="dialogVisible"
|
||||
:header="dialogTitle"
|
||||
modal
|
||||
:style="{ width: '32rem' }"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">键名 (name)</label>
|
||||
<InputText
|
||||
v-model="form.name"
|
||||
class="w-full font-mono uppercase"
|
||||
placeholder="如 LLM_API_KEY"
|
||||
:disabled="dialogMode === 'edit'"
|
||||
/>
|
||||
<p v-if="dialogMode === 'edit'" class="text-xs text-text-muted">
|
||||
键名创建后不可修改,避免环境变量映射错乱
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">值 (value)</label>
|
||||
<Textarea
|
||||
v-model="form.value"
|
||||
rows="6"
|
||||
class="w-full font-mono text-sm"
|
||||
placeholder="支持长文本或 JSON,如 OSS_CONFIG"
|
||||
/>
|
||||
<p v-if="valuePreview && form.value.length > 120" class="text-xs text-text-muted">
|
||||
预览:{{ valuePreview }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">备注 (mark)</label>
|
||||
<InputText v-model="form.mark" class="w-full" placeholder="可选说明" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="取消" severity="secondary" text @click="dialogVisible = false" />
|
||||
<Button label="保存" :loading="saving" @click="saveConfig" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
14
src/views/oem/OemOverviewView.vue
Normal file
14
src/views/oem/OemOverviewView.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup>
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-page">
|
||||
<h1 class="admin-page__title gradient-text">概览</h1>
|
||||
<p class="text-sm text-text-muted">
|
||||
欢迎,{{ auth.currentUser?.username }}。在此查看系统运行概况。
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
364
src/views/oem/OemUsersView.vue
Normal file
364
src/views/oem/OemUsersView.vue
Normal file
@@ -0,0 +1,364 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT } from "../../stores/auth.js";
|
||||
import {
|
||||
listAdminUsersApi,
|
||||
createAdminUserApi,
|
||||
updateAdminUserApi,
|
||||
deleteAdminUserApi,
|
||||
} from "../../api/adminUsers.js";
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ label: "普通用户", value: 0 },
|
||||
{ label: "管理员", value: 1 },
|
||||
{ label: "代理", value: 2 },
|
||||
];
|
||||
|
||||
const ROLE_LABELS = {
|
||||
0: "普通用户",
|
||||
[ROLE_ADMIN]: "管理员",
|
||||
[ROLE_AGENT]: "代理",
|
||||
};
|
||||
|
||||
const users = ref([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const dialogMode = ref("create");
|
||||
const editingId = ref(null);
|
||||
|
||||
const emptyForm = () => ({
|
||||
username: "",
|
||||
password: "",
|
||||
phone: "",
|
||||
role_id: 0,
|
||||
vip_end_time: "",
|
||||
clear_vip_end_time: false,
|
||||
});
|
||||
|
||||
const form = ref(emptyForm());
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
dialogMode.value === "create" ? "新建用户" : "编辑用户",
|
||||
);
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function toDatetimeLocalValue(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function roleLabel(roleId) {
|
||||
return ROLE_LABELS[roleId] ?? `角色 ${roleId}`;
|
||||
}
|
||||
|
||||
function roleSeverity(roleId) {
|
||||
if (roleId === ROLE_ADMIN) return "info";
|
||||
if (roleId === ROLE_AGENT) return "warn";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
function showMessage(severity, message) {
|
||||
feedback.value = { severity, message };
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true;
|
||||
feedback.value = { severity: "", message: "" };
|
||||
const res = await listAdminUsersApi(auth.token, {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
});
|
||||
loading.value = false;
|
||||
|
||||
if (!res.ok) {
|
||||
showMessage("error", res.message || "加载用户列表失败");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data;
|
||||
users.value = Array.isArray(data?.items) ? data.items : [];
|
||||
total.value = typeof data?.total === "number" ? data.total : 0;
|
||||
if (typeof data?.page === "number") page.value = data.page;
|
||||
if (typeof data?.page_size === "number") pageSize.value = data.page_size;
|
||||
}
|
||||
|
||||
async function onPage(event) {
|
||||
page.value = event.page + 1;
|
||||
pageSize.value = event.rows;
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
dialogMode.value = "create";
|
||||
editingId.value = null;
|
||||
form.value = emptyForm();
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openEditDialog(row) {
|
||||
dialogMode.value = "edit";
|
||||
editingId.value = row.id;
|
||||
form.value = {
|
||||
username: row.username,
|
||||
password: "",
|
||||
phone: row.phone || "",
|
||||
role_id: row.role_id,
|
||||
vip_end_time: toDatetimeLocalValue(row.vip_end_time),
|
||||
clear_vip_end_time: false,
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
const payload = {
|
||||
username: form.value.username.trim(),
|
||||
phone: form.value.phone.trim() || null,
|
||||
role_id: form.value.role_id,
|
||||
};
|
||||
|
||||
if (form.value.password.trim()) {
|
||||
payload.password = form.value.password;
|
||||
}
|
||||
|
||||
if (form.value.clear_vip_end_time) {
|
||||
payload.clear_vip_end_time = true;
|
||||
} else if (form.value.vip_end_time) {
|
||||
payload.vip_end_time = new Date(form.value.vip_end_time).toISOString();
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
if (!form.value.username.trim()) {
|
||||
showMessage("warn", "用户名不能为空");
|
||||
return;
|
||||
}
|
||||
if (dialogMode.value === "create" && form.value.password.length < 6) {
|
||||
showMessage("warn", "密码至少 6 位");
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
feedback.value = { severity: "", message: "" };
|
||||
|
||||
const payload = buildPayload();
|
||||
let res;
|
||||
|
||||
if (dialogMode.value === "create") {
|
||||
if (!payload.password) {
|
||||
saving.value = false;
|
||||
showMessage("warn", "请设置初始密码");
|
||||
return;
|
||||
}
|
||||
res = await createAdminUserApi(auth.token, payload);
|
||||
} else {
|
||||
res = await updateAdminUserApi(auth.token, editingId.value, payload);
|
||||
}
|
||||
|
||||
saving.value = false;
|
||||
|
||||
if (!res.ok) {
|
||||
showMessage("error", res.message || "保存失败");
|
||||
return;
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
showMessage("success", res.message || "保存成功");
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
async function removeUser(row) {
|
||||
if (row.id === auth.currentUser?.id) {
|
||||
showMessage("warn", "不能删除当前登录账号");
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = window.confirm(`确定删除用户「${row.username}」?此操作不可恢复。`);
|
||||
if (!ok) return;
|
||||
|
||||
const res = await deleteAdminUserApi(auth.token, row.id);
|
||||
if (!res.ok) {
|
||||
showMessage("error", res.message || "删除失败");
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage("success", res.message || "已删除");
|
||||
if (users.value.length === 1 && page.value > 1) {
|
||||
page.value -= 1;
|
||||
}
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-page admin-users">
|
||||
<div class="admin-users__header">
|
||||
<div>
|
||||
<h1 class="admin-page__title gradient-text">用户管理</h1>
|
||||
|
||||
</div>
|
||||
<Button label="新建用户" @click="openCreateDialog" />
|
||||
</div>
|
||||
|
||||
<Message
|
||||
v-if="feedback.message"
|
||||
:severity="feedback.severity"
|
||||
:closable="true"
|
||||
class="admin-users__message"
|
||||
@close="feedback.message = ''"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</Message>
|
||||
|
||||
<DataTable
|
||||
:value="users"
|
||||
:loading="loading"
|
||||
lazy
|
||||
paginator
|
||||
:rows="pageSize"
|
||||
:total-records="total"
|
||||
:first="(page - 1) * pageSize"
|
||||
:rows-per-page-options="[10, 20, 50]"
|
||||
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
|
||||
current-page-report-template="共 {totalRecords} 条"
|
||||
striped-rows
|
||||
size="small"
|
||||
class="admin-users__table"
|
||||
data-key="id"
|
||||
@page="onPage"
|
||||
>
|
||||
<Column field="id" header="ID" style="width: 4rem" />
|
||||
<Column field="username" header="用户名" />
|
||||
<Column field="phone" header="手机号">
|
||||
<template #body="{ data }">
|
||||
{{ data.phone || "—" }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="role_id" header="角色" style="width: 7rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="roleLabel(data.role_id)" :severity="roleSeverity(data.role_id)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="vip_end_time" header="VIP 到期">
|
||||
<template #body="{ data }">
|
||||
{{ formatDateTime(data.vip_end_time) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="created_at" header="注册时间">
|
||||
<template #body="{ data }">
|
||||
{{ formatDateTime(data.created_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="操作" style="width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<div class="admin-users__actions">
|
||||
<Button
|
||||
label="编辑"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="openEditDialog(data)"
|
||||
/>
|
||||
<Button
|
||||
label="删除"
|
||||
size="small"
|
||||
severity="danger"
|
||||
text
|
||||
:disabled="data.id === auth.currentUser?.id"
|
||||
@click="removeUser(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<template #empty>
|
||||
<p class="py-6 text-center text-sm text-text-muted">暂无用户</p>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="dialogVisible"
|
||||
:header="dialogTitle"
|
||||
modal
|
||||
class="admin-users-dialog"
|
||||
:style="{ width: '28rem' }"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">用户名</label>
|
||||
<InputText v-model="form.username" class="w-full" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">
|
||||
{{ dialogMode === "create" ? "密码" : "新密码(留空不修改)" }}
|
||||
</label>
|
||||
<Password
|
||||
v-model="form.password"
|
||||
:feedback="false"
|
||||
toggle-mask
|
||||
fluid
|
||||
input-class="w-full"
|
||||
:placeholder="dialogMode === 'create' ? '至少 6 位' : '留空则不修改'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">手机号(可选)</label>
|
||||
<InputText v-model="form.phone" class="w-full" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">角色</label>
|
||||
<Select
|
||||
v-model="form.role_id"
|
||||
:options="ROLE_OPTIONS"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">VIP 到期时间</label>
|
||||
<InputText
|
||||
v-model="form.vip_end_time"
|
||||
type="datetime-local"
|
||||
class="w-full"
|
||||
:disabled="form.clear_vip_end_time"
|
||||
/>
|
||||
<label v-if="dialogMode === 'edit'" class="flex items-center gap-2 text-sm text-text-muted">
|
||||
<Checkbox v-model="form.clear_vip_end_time" :binary="true" input-id="clear-vip" />
|
||||
<span>清除 VIP 到期时间</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="取消" severity="secondary" text @click="dialogVisible = false" />
|
||||
<Button label="保存" :loading="saving" @click="saveUser" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user