Files
geo/apps/admin-web/src/components/GenerateTaskDrawer.vue
T
root a617d39a4a feat(desktop): drop parked review flow, add publish management
SaaS 侧人工审核后才创建 publish job,desktop 不再做二次审核:移除
manual/waiting_user/parked/from_parked 状态机与 LeaseFromParked 查询,
desktop client 只执行发布并新增"发布管理"页(待发布队列 / 历史 / 再次
发送)。同时抽离 @geo/publisher-platforms 共享适配器包、新增 Redis-based
desktop_presence 与 publish_record_support,刷新 admin-web 发布弹窗与
媒体库;plan A / spec 文档同步口径。
2026-04-20 09:52:48 +08:00

665 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { PlusOutlined } from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message } from "ant-design-vue";
import type { PromptRuleSimple, ScheduleTask } from "@geo/shared-types";
import { computed, onBeforeUnmount, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import PromptRuleModal from "@/components/PromptRuleModal.vue";
import PublishPlatformSelector from "@/components/PublishPlatformSelector.vue";
import { generateApi, mediaApi, promptRulesApi, schedulesApi, tenantAccountsApi } from "@/lib/api";
import { formatError } from "@/lib/errors";
import { IMAGE_UPLOAD_ACCEPT, useImageUploadProgress, validateImageUploadFile } from "@/lib/image-webp";
import {
buildPublishPlatformOptions,
normalizePublishPlatformIds,
parseTargetPlatforms,
serializeTargetPlatforms,
} from "@/lib/publish-platforms";
const props = defineProps<{
open: boolean;
mode: "instant" | "schedule";
task?: ScheduleTask | null;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
}>();
const queryClient = useQueryClient();
const { t } = useI18n();
const imageUploadProgress = useImageUploadProgress();
const promptDrawerOpen = ref(false);
const coverPreviewUrl = ref("");
const coverFileName = ref("");
const form = reactive({
name: "",
promptRuleId: undefined as number | undefined,
platformIds: [] as string[],
coverEnabled: true,
enableWebSearch: false,
generateCount: 1,
scheduleTime: "08:00:00",
});
const isSchedule = computed(() => props.mode === "schedule");
const platformsQuery = useQuery({
queryKey: ["media", "platforms", "task-drawer"],
enabled: computed(() => props.open),
queryFn: () => mediaApi.platforms(),
});
const accountsQuery = useQuery({
queryKey: ["tenant", "desktop-accounts", "task-drawer"],
enabled: computed(() => props.open),
queryFn: () => tenantAccountsApi.list(),
});
const allPlatforms = computed(() =>
buildPublishPlatformOptions(platformsQuery.data.value ?? [], accountsQuery.data.value),
);
const platformsLoading = computed(
() => platformsQuery.isPending.value || accountsQuery.isPending.value,
);
const rulesQuery = useQuery({
queryKey: ["promptRules", "simple"],
queryFn: () => promptRulesApi.listSimple(),
});
const promptOptions = computed(() =>
(rulesQuery.data.value ?? []).map((rule: PromptRuleSimple) => ({
label: rule.name,
value: rule.id,
})),
);
const drawerTitle = computed(() => {
if (isSchedule.value) {
return props.task?.id ? t("custom.schedule.editTitle") : t("custom.schedule.createTitle");
}
return t("custom.instant.createTitle");
});
const submitText = computed(() => {
if (isSchedule.value) {
return props.task?.id ? t("common.save") : t("custom.task.submit");
}
return t("custom.task.submit");
});
watch(
() => props.open,
(open) => {
if (open) {
hydrateForm();
return;
}
resetCoverState();
},
);
onBeforeUnmount(() => {
resetCoverState();
});
const createScheduleMutation = useMutation({
mutationFn: () =>
schedulesApi.create(buildSchedulePayload()),
onSuccess: async () => {
message.success(t("custom.messages.scheduleCreated"));
emit("update:open", false);
await queryClient.invalidateQueries({ queryKey: ["schedules"] });
},
onError: (error) => message.error(formatError(error)),
});
const updateScheduleMutation = useMutation({
mutationFn: () =>
schedulesApi.update(props.task!.id, buildSchedulePayload()),
onSuccess: async () => {
message.success(t("custom.messages.scheduleUpdated"));
emit("update:open", false);
await queryClient.invalidateQueries({ queryKey: ["schedules"] });
},
onError: (error) => message.error(formatError(error)),
});
const instantGenerateMutation = useMutation({
mutationFn: () =>
generateApi.fromRule(buildInstantPayload()),
onSuccess: async () => {
message.success(t("custom.messages.generateSubmitted"));
emit("update:open", false);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["articles"] }),
queryClient.invalidateQueries({ queryKey: ["instantTasks"] }),
]);
},
onError: (error) => message.error(formatError(error)),
});
const submitLoading = computed(
() =>
createScheduleMutation.isPending.value ||
updateScheduleMutation.isPending.value ||
instantGenerateMutation.isPending.value,
);
const imageUploadBusy = computed(() => imageUploadProgress.visible);
function hydrateForm(): void {
form.name = props.task?.name ?? "";
form.promptRuleId = props.task?.prompt_rule_id ?? undefined;
form.platformIds = parseTargetPlatforms(props.task?.target_platform);
form.coverEnabled = true;
form.enableWebSearch = props.task?.enable_web_search ?? false;
form.generateCount = props.task?.generate_count ?? 1;
form.scheduleTime = parseCronToDailyTime(props.task?.cron_expr);
resetCoverState();
}
function getSelectedPlatformIds(): string[] {
return normalizePublishPlatformIds(form.platformIds);
}
function buildSchedulePayload() {
return {
name: form.name.trim(),
prompt_rule_id: form.promptRuleId!,
cron_expr: buildDailyCron(form.scheduleTime),
target_platform: serializeTargetPlatforms(getSelectedPlatformIds()),
enable_web_search: form.enableWebSearch,
generate_count: form.generateCount,
};
}
function buildInstantPayload() {
const platformIds = getSelectedPlatformIds();
return {
prompt_rule_id: form.promptRuleId!,
target_platform: serializeTargetPlatforms(platformIds),
extra_params: {
task_name: form.name.trim(),
generation_mode: "instant",
enable_web_search: form.enableWebSearch,
generate_count: form.generateCount,
target_platforms: platformIds,
cover_file_name: coverFileName.value || null,
},
};
}
function resetCoverState(): void {
if (coverPreviewUrl.value) {
URL.revokeObjectURL(coverPreviewUrl.value);
}
coverPreviewUrl.value = "";
coverFileName.value = "";
}
function handleCoverChange(event: Event): void {
if (imageUploadBusy.value) {
(event.target as HTMLInputElement).value = "";
return;
}
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) {
return;
}
try {
validateImageUploadFile(file);
} catch (error) {
message.warning(formatError(error));
return;
}
if (coverPreviewUrl.value) {
URL.revokeObjectURL(coverPreviewUrl.value);
}
coverFileName.value = file.name;
coverPreviewUrl.value = URL.createObjectURL(file);
}
function handlePromptSaved(ruleId: number): void {
form.promptRuleId = ruleId;
promptDrawerOpen.value = false;
void queryClient.invalidateQueries({ queryKey: ["promptRules", "simple"] });
}
function validateForm(): boolean {
if (!form.name.trim()) {
message.warning(t("custom.messages.missingTaskName"));
return false;
}
if (!form.promptRuleId) {
message.warning(t("custom.messages.missingPromptRule"));
return false;
}
if (form.generateCount < 1) {
message.warning(t("custom.messages.invalidGenerateCount"));
return false;
}
if (isSchedule.value && !isValidTime(form.scheduleTime)) {
message.warning(t("custom.messages.invalidScheduleTime"));
return false;
}
return true;
}
async function handleSubmit(): Promise<void> {
if (!validateForm()) {
return;
}
if (isSchedule.value) {
if (props.task?.id) {
await updateScheduleMutation.mutateAsync();
} else {
await createScheduleMutation.mutateAsync();
}
return;
}
await instantGenerateMutation.mutateAsync();
}
function parseCronToDailyTime(cronExpr?: string | null): string {
if (!cronExpr) {
return "08:00:00";
}
const fiveFieldMatch = cronExpr.match(/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/);
if (fiveFieldMatch) {
const [, minute, hour] = fiveFieldMatch;
return `${padTime(hour)}:${padTime(minute)}:00`;
}
const sixFieldMatch = cronExpr.match(/^(\d{1,2})\s+(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/);
if (sixFieldMatch) {
const [, second, minute, hour] = sixFieldMatch;
return `${padTime(hour)}:${padTime(minute)}:${padTime(second)}`;
}
return "08:00:00";
}
function buildDailyCron(timeValue: string): string {
const [hourRaw, minuteRaw] = timeValue.split(":");
const hour = Number(hourRaw || 0);
const minute = Number(minuteRaw || 0);
return `${minute} ${hour} * * *`;
}
function isValidTime(value: string): boolean {
return /^\d{2}:\d{2}(:\d{2})?$/.test(value);
}
function padTime(value: string): string {
return value.padStart(2, "0");
}
</script>
<template>
<a-drawer
:open="open"
:title="drawerTitle"
placement="right"
width="1120"
:mask-closable="false"
class="generate-task-drawer"
@close="emit('update:open', false)"
>
<div class="generate-task-drawer__body">
<section class="generate-task-drawer__panel">
<div class="generate-task-drawer__section-head">
<div class="generate-task-drawer__section-marker"></div>
<h3>{{ t("custom.task.basicSection") }}</h3>
</div>
<div class="generate-task-drawer__field">
<label class="generate-task-drawer__label generate-task-drawer__label--required">
{{ t("custom.task.name") }}
</label>
<a-input
v-model:value="form.name"
:maxlength="100"
size="large"
:placeholder="t('custom.task.namePlaceholder')"
/>
</div>
<div class="generate-task-drawer__field">
<label class="generate-task-drawer__label generate-task-drawer__label--required">
{{ t("custom.task.prompt") }}
</label>
<div class="generate-task-drawer__prompt-row">
<a-select
v-model:value="form.promptRuleId"
size="large"
:placeholder="t('custom.task.promptPlaceholder')"
:options="promptOptions"
:loading="rulesQuery.isPending.value"
allow-clear
class="generate-task-drawer__prompt-select"
/>
<a-button class="generate-task-drawer__prompt-create" size="large" @click="promptDrawerOpen = true">
<template #icon><PlusOutlined /></template>
{{ t("custom.task.createPrompt") }}
</a-button>
</div>
</div>
<div class="generate-task-drawer__field">
<label class="generate-task-drawer__label">{{ t("custom.task.platforms") }}</label>
<p class="generate-task-drawer__hint">{{ t("custom.task.platformsHint") }}</p>
<a-skeleton
v-if="platformsLoading"
active
:title="false"
:paragraph="{ rows: 4 }"
/>
<PublishPlatformSelector
v-else
v-model="form.platformIds"
:platforms="allPlatforms"
/>
</div>
<div class="generate-task-drawer__field">
<label class="generate-task-drawer__label">{{ t("custom.task.cover") }}</label>
<p class="generate-task-drawer__hint">{{ t("custom.task.coverHint") }}</p>
<label
class="generate-task-drawer__cover"
:class="{ 'generate-task-drawer__cover--disabled': !form.coverEnabled || imageUploadBusy }"
>
<input
type="file"
:accept="IMAGE_UPLOAD_ACCEPT"
hidden
:disabled="!form.coverEnabled || imageUploadBusy"
@change="handleCoverChange"
/>
<template v-if="coverPreviewUrl">
<img :src="coverPreviewUrl" alt="cover preview" />
</template>
<template v-else>
<span class="generate-task-drawer__cover-plus">+</span>
<strong>{{ t("custom.task.coverUpload") }}</strong>
<small>{{ coverFileName || t("custom.task.coverUploadHint") }}</small>
</template>
</label>
</div>
</section>
<section class="generate-task-drawer__panel generate-task-drawer__panel--bordered">
<div class="generate-task-drawer__section-head">
<div class="generate-task-drawer__section-marker"></div>
<h3>{{ t("custom.task.advancedSection") }}</h3>
</div>
<div v-if="isSchedule" class="generate-task-drawer__field">
<label class="generate-task-drawer__label">{{ t("custom.task.scheduleTime") }}</label>
<div class="generate-task-drawer__time-wrapper">
<span>{{ t("custom.task.scheduleEveryDay") }}</span>
<a-time-picker
v-model:value="form.scheduleTime"
value-format="HH:mm:ss"
size="large"
:allow-clear="false"
style="width: 160px"
/>
</div>
</div>
<div class="generate-task-drawer__field generate-task-drawer__field--inline">
<label class="generate-task-drawer__label">{{ t("custom.task.enableWebSearch") }}</label>
<a-switch v-model:checked="form.enableWebSearch" />
</div>
<div class="generate-task-drawer__field generate-task-drawer__field--narrow">
<label class="generate-task-drawer__label">{{ t("custom.task.generateCount") }}</label>
<a-input-number
v-model:value="form.generateCount"
:min="1"
:max="20"
:precision="0"
size="large"
/>
</div>
</section>
</div>
<template #footer>
<div class="generate-task-drawer__footer">
<a-button size="large" @click="emit('update:open', false)">
{{ t("common.cancel") }}
</a-button>
<a-button type="primary" size="large" :loading="submitLoading" @click="handleSubmit">
{{ submitText }}
</a-button>
</div>
</template>
<PromptRuleModal v-model:open="promptDrawerOpen" @saved="handlePromptSaved" />
</a-drawer>
</template>
<style scoped>
.generate-task-drawer :deep(.ant-drawer-header) {
padding: 30px 28px;
border-bottom: 1px solid #edf2f7;
}
.generate-task-drawer :deep(.ant-drawer-title) {
color: #111827;
font-size: 18px;
font-weight: 800;
}
.generate-task-drawer :deep(.ant-drawer-body) {
padding: 30px 32px 32px;
background: #fff;
}
.generate-task-drawer :deep(.ant-drawer-footer) {
padding: 18px 24px;
border-top: 1px solid #edf2f7;
background: #fff;
}
.generate-task-drawer__body {
padding: 0 20px;
display: flex;
flex-direction: column;
}
.generate-task-drawer__panel {
padding: 16px 0 32px;
}
.generate-task-drawer__panel--bordered {
padding-top: 32px;
border-top: 1px solid #edf2f7;
}
.generate-task-drawer__section-head {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 20px;
border-bottom: 1px solid #fafafa;
}
.generate-task-drawer__section-marker {
width: 4px;
height: 16px;
border-radius: 2px;
background: #111827;
}
.generate-task-drawer__section-head h3 {
margin: 0;
color: #111827;
font-size: 16px;
font-weight: 800;
}
.generate-task-drawer__field {
margin-top: 24px;
}
.generate-task-drawer__field--inline {
display: flex;
align-items: center;
justify-content: space-between;
}
.generate-task-drawer__field--narrow {
max-width: 220px;
}
.generate-task-drawer__label {
display: inline-flex;
align-items: center;
margin-bottom: 12px;
color: #1f2937;
font-size: 15px;
font-weight: 700;
}
.generate-task-drawer__label--required::before {
content: "*";
margin-right: 4px;
color: #ff4d4f;
}
.generate-task-drawer__hint {
margin: -2px 0 14px;
color: #667085;
font-size: 13px;
line-height: 1.7;
}
.generate-task-drawer__hint--compact {
margin-top: 6px;
}
.generate-task-drawer__prompt-row {
display: flex;
gap: 12px;
align-items: center;
}
.generate-task-drawer__prompt-select {
flex: 0 1 320px;
}
.generate-task-drawer__prompt-create {
border-style: dashed;
border-color: #9db7ff;
color: #355dff;
}
.generate-task-drawer__cover {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
width: 228px;
min-height: 172px;
border: 1px dashed #cfd9e8;
border-radius: 20px;
background:
radial-gradient(circle at top, rgba(53, 93, 255, 0.08), transparent 48%),
#fbfcff;
color: #1f2937;
cursor: pointer;
overflow: hidden;
}
.generate-task-drawer__cover img {
width: 100%;
height: 172px;
object-fit: cover;
}
.generate-task-drawer__cover strong {
font-size: 16px;
font-weight: 700;
}
.generate-task-drawer__cover small {
color: #667085;
font-size: 13px;
}
.generate-task-drawer__cover--disabled {
opacity: 0.55;
cursor: not-allowed;
}
.generate-task-drawer__cover-plus {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 999px;
background: #eef4ff;
color: #355dff;
font-size: 28px;
line-height: 1;
}
.generate-task-drawer__time-wrapper {
display: flex;
align-items: center;
gap: 12px;
}
.generate-task-drawer__time-wrapper span {
color: #344054;
font-size: 15px;
font-weight: 600;
}
.generate-task-drawer__footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 1024px) {
.generate-task-drawer__prompt-row {
flex-direction: column;
align-items: stretch;
}
.generate-task-drawer__prompt-select {
flex-basis: auto;
}
}
</style>