This commit is contained in:
fengchuanhn@gmail.com
2026-05-21 00:19:08 +08:00
parent 3215d39a32
commit 3b0a8d777c
63 changed files with 12483 additions and 1114 deletions

View File

@@ -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" });
}

View 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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>