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>
|
||||
|
||||
Reference in New Issue
Block a user