feat(migrations): Harden task audit tracking and optimize article templates

- Added migration to harden task audit tracking by modifying audit_logs and related tables.
- Introduced operator_id to several tables for better tracking of actions.
- Updated article_templates with new prompt templates for various article types, enhancing content generation.
- Created prompt_rules and schedule_tasks tables to manage content generation rules and scheduling.
- Added foreign key constraints to articles for better data integrity.
This commit is contained in:
2026-04-02 00:31:28 +08:00
parent de30497f59
commit b31d8d0096
101 changed files with 16671 additions and 721 deletions
@@ -17,7 +17,6 @@ import {
getPublishStatusMeta,
getSourceTypeLabel,
} from "@/lib/display";
import { formatError } from "@/lib/errors";
const props = defineProps<{
open: boolean;
@@ -35,7 +34,6 @@ const enabled = computed(() => props.open && Boolean(props.articleId));
const streamTitle = ref("");
const streamMarkdown = ref("");
const streamStatus = ref<string | null>(null);
const streamError = ref("");
const streamFeatureEnabled = isGenerationStreamEnabled();
const detailQuery = useQuery({
@@ -85,7 +83,6 @@ const versionColumns = computed<TableColumnsType<ArticleVersion>>(() => [
const detail = computed(() => detailQuery.data.value);
const displayTitle = computed(() => streamTitle.value || detail.value?.title || t("article.untitled"));
const displayMarkdown = computed(() => streamMarkdown.value || detail.value?.markdown_content || "");
const displayError = computed(() => streamError.value || detail.value?.generation_error_message || "");
const generateMeta = computed(() =>
getGenerateStatusMeta(streamStatus.value || detail.value?.generate_status),
);
@@ -117,11 +114,11 @@ watch(
},
},
controller.signal,
).catch((error) => {
).catch(() => {
if (controller.signal.aborted) {
return;
}
streamError.value = formatError(error);
void refreshArticleData();
});
onCleanup(() => {
@@ -160,9 +157,6 @@ function handleStreamEvent(event: ArticleGenerationStreamEvent): void {
if (event.status) {
streamStatus.value = event.status;
}
if (event.error) {
streamError.value = event.error;
}
if (event.type === "snapshot" || event.type === "completed") {
if (event.content) {
@@ -185,7 +179,6 @@ function resetStreamState(): void {
streamTitle.value = "";
streamMarkdown.value = "";
streamStatus.value = null;
streamError.value = "";
}
async function refreshArticleData(): Promise<void> {
@@ -244,15 +237,6 @@ async function refreshArticleData(): Promise<void> {
</a-descriptions>
</section>
<a-alert
v-if="displayError"
class="article-drawer__alert"
type="error"
show-icon
:message="t('common.generateStatus')"
:description="displayError"
/>
<a-tabs v-model:activeKey="activeTab" class="article-drawer__tabs">
<a-tab-pane key="content" :tab="t('article.preview')">
<div
@@ -0,0 +1,274 @@
<script setup lang="ts">
import {
BoldOutlined,
ItalicOutlined,
OrderedListOutlined,
PicCenterOutlined,
PictureOutlined,
RedoOutlined,
UndoOutlined,
UnorderedListOutlined,
} from "@ant-design/icons-vue";
import { redoCommand, undoCommand } from "@milkdown/kit/plugin/history";
import {
insertHrCommand,
insertImageCommand,
toggleEmphasisCommand,
toggleStrongCommand,
wrapInBlockquoteCommand,
wrapInBulletListCommand,
wrapInHeadingCommand,
wrapInOrderedListCommand,
} from "@milkdown/kit/preset/commonmark";
import { callCommand } from "@milkdown/kit/utils";
import { Crepe, CrepeFeature } from "@milkdown/crepe";
import "@milkdown/crepe/theme/common/style.css";
import "@milkdown/crepe/theme/frame.css";
import { Milkdown, useEditor, useInstance } from "@milkdown/vue";
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
const props = defineProps<{
title: string;
modelValue: string;
disabled?: boolean;
}>();
const emit = defineEmits<{
"update:title": [value: string];
"update:modelValue": [value: string];
}>();
const { t } = useI18n();
const crepeRef = ref<Crepe | null>(null);
const { loading } = useEditor((root) => {
const crepe = new Crepe({
root,
defaultValue: props.modelValue || "",
features: {
[CrepeFeature.TopBar]: false,
[CrepeFeature.Toolbar]: true,
[CrepeFeature.Latex]: false,
[CrepeFeature.BlockEdit]: false,
},
});
crepe.setReadonly(!!props.disabled);
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown) => {
emit("update:modelValue", markdown);
});
});
crepeRef.value = crepe;
return crepe;
});
const [instanceLoading, getEditor] = useInstance();
const editorDisabled = computed(() => props.disabled || instanceLoading.value || loading.value);
watch(
() => props.disabled,
(value) => {
crepeRef.value?.setReadonly(!!value);
},
{ immediate: true },
);
onBeforeUnmount(() => {
crepeRef.value = null;
});
function runCommand(command: { key: unknown }, payload?: unknown): void {
if (editorDisabled.value) {
return;
}
const editor = getEditor();
if (!editor) {
return;
}
editor.action(callCommand(command.key as never, payload as never));
}
function insertImage(): void {
if (editorDisabled.value) {
return;
}
const src = window.prompt(t("article.editor.imagePrompt"));
if (!src?.trim()) {
return;
}
runCommand(insertImageCommand, { src: src.trim() });
}
</script>
<template>
<div class="article-editor-canvas" :class="{ 'article-editor-canvas--disabled': disabled }">
<div class="article-editor-canvas__toolbar">
<a-button size="small" type="text" @click="runCommand(undoCommand)">
<template #icon><UndoOutlined /></template>
</a-button>
<a-button size="small" type="text" @click="runCommand(redoCommand)">
<template #icon><RedoOutlined /></template>
</a-button>
<span class="article-editor-canvas__divider"></span>
<a-button size="small" type="text" @click="runCommand(wrapInHeadingCommand, 1)">H1</a-button>
<a-button size="small" type="text" @click="runCommand(wrapInHeadingCommand, 2)">H2</a-button>
<a-button size="small" type="text" @click="runCommand(wrapInHeadingCommand, 3)">H3</a-button>
<span class="article-editor-canvas__divider"></span>
<a-button size="small" type="text" @click="runCommand(toggleStrongCommand)">
<template #icon><BoldOutlined /></template>
</a-button>
<a-button size="small" type="text" @click="runCommand(toggleEmphasisCommand)">
<template #icon><ItalicOutlined /></template>
</a-button>
<a-button size="small" type="text" @click="runCommand(wrapInBlockquoteCommand)">
</a-button>
<a-button size="small" type="text" @click="runCommand(wrapInBulletListCommand)">
<template #icon><UnorderedListOutlined /></template>
</a-button>
<a-button size="small" type="text" @click="runCommand(wrapInOrderedListCommand)">
<template #icon><OrderedListOutlined /></template>
</a-button>
<a-button size="small" type="text" @click="runCommand(insertHrCommand)">
<template #icon><PicCenterOutlined /></template>
</a-button>
<a-button size="small" type="text" @click="insertImage">
<template #icon><PictureOutlined /></template>
</a-button>
</div>
<div class="article-editor-canvas__surface">
<div class="article-editor-canvas__title-row">
<a-input
:value="title"
size="large"
:disabled="disabled"
:bordered="false"
class="article-editor-canvas__title-input"
:placeholder="t('article.editor.titlePlaceholder')"
@update:value="emit('update:title', String($event ?? ''))"
/>
</div>
<Milkdown />
<div v-if="loading" class="article-editor-canvas__loading">
<a-skeleton active :paragraph="{ rows: 10 }" />
</div>
<div v-if="disabled" class="article-editor-canvas__mask">
{{ t("article.editor.messages.locked") }}
</div>
</div>
</div>
</template>
<style scoped>
.article-editor-canvas {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid #edf2fa;
border-radius: 24px;
overflow: hidden;
background:
radial-gradient(circle at top right, rgba(64, 110, 255, 0.08), transparent 20%),
linear-gradient(180deg, #ffffff 0%, #fbfcff 100%);
}
.article-editor-canvas__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 14px 18px;
border-bottom: 1px solid #eef2f8;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
}
.article-editor-canvas__divider {
width: 1px;
height: 20px;
margin: 0 2px;
background: #e5ebf5;
}
.article-editor-canvas__surface {
position: relative;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.article-editor-canvas__title-row {
position: relative;
z-index: 1;
padding: 18px 28px 0;
}
.article-editor-canvas__title-row :deep(.ant-input) {
height: 68px;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
color: #101828;
font-size: 30px !important;
font-weight: 800;
line-height: 1.15;
}
.article-editor-canvas__title-row :deep(.ant-input::placeholder) {
color: #98a2b3;
}
.article-editor-canvas__loading {
position: absolute;
inset: 0;
z-index: 2;
padding: 24px;
background: rgba(255, 255, 255, 0.92);
}
/* Removed height 100% blocks to allow natural vertical content growth and scrolling */
.article-editor-canvas__surface :deep(.ProseMirror) {
min-height: 640px;
padding: 14px 36px 80px;
background: transparent;
}
.article-editor-canvas__surface :deep(.ProseMirror:focus) {
outline: none;
}
.article-editor-canvas__mask {
position: absolute;
inset: 0;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 120px;
color: #475467;
font-size: 14px;
background: rgba(255, 255, 255, 0.58);
backdrop-filter: blur(3px);
}
.article-editor-canvas--disabled .article-editor-canvas__toolbar {
opacity: 0.56;
}
.article-editor-canvas--disabled .article-editor-canvas__title-row {
opacity: 0.72;
}
</style>
@@ -0,0 +1,386 @@
<script setup lang="ts">
import {
LoadingOutlined,
} from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message, type TableColumnsType } from "ant-design-vue";
import type { ArticleListItem, ArticleListParams } from "@geo/shared-types";
import type { Dayjs } from "dayjs";
import { computed, onBeforeUnmount, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import ArticleDetailDrawer from "@/components/ArticleDetailDrawer.vue";
import { articlesApi, promptRulesApi } from "@/lib/api";
import {
formatDateTime,
getGenerateStatusMeta,
getPublishStatusMeta,
} from "@/lib/display";
import { formatError } from "@/lib/errors";
import { formatPublishPlatformList } from "@/lib/publish-platforms";
const queryClient = useQueryClient();
const router = useRouter();
const { t } = useI18n();
const page = ref(1);
const pageSize = ref(20);
const draftGenerationRange = ref<[Dayjs, Dayjs] | null>(null);
const draftFilters = reactive<{
prompt_rule_id?: number;
publish_status?: string;
generate_status?: string;
keyword?: string;
}>({
keyword: "",
});
const appliedFilters = reactive<{
prompt_rule_id?: number;
publish_status?: string;
generate_status?: string;
keyword?: string;
created_from?: string;
created_to?: string;
}>({
keyword: "",
});
const articleDrawerOpen = ref(false);
const selectedArticleId = ref<number | null>(null);
const rulesQuery = useQuery({
queryKey: ["promptRules", "simple"],
queryFn: () => promptRulesApi.listSimple(),
});
const ruleOptions = computed(() =>
(rulesQuery.data.value ?? []).map((r) => ({ label: r.name, value: r.id })),
);
const generateStatusOptions = computed(() => [
{ label: getGenerateStatusMeta("draft").label, value: "draft" },
{ label: getGenerateStatusMeta("generating").label, value: "generating" },
{ label: getGenerateStatusMeta("completed").label, value: "completed" },
{ label: getGenerateStatusMeta("failed").label, value: "failed" },
]);
const publishStatusOptions = computed(() => [
{ label: getPublishStatusMeta("unpublished").label, value: "unpublished" },
{ label: getPublishStatusMeta("publishing").label, value: "publishing" },
{ label: getPublishStatusMeta("published").label, value: "published" },
{ label: getPublishStatusMeta("publish_failed").label, value: "publish_failed" },
]);
const articleParams = computed<ArticleListParams>(() => {
const params: ArticleListParams = {
page: page.value,
page_size: pageSize.value,
source_type: "prompt_rule",
};
if (appliedFilters.prompt_rule_id) params.prompt_rule_id = appliedFilters.prompt_rule_id;
if (appliedFilters.publish_status) params.publish_status = appliedFilters.publish_status;
if (appliedFilters.generate_status) params.generate_status = appliedFilters.generate_status;
if (appliedFilters.keyword?.trim()) params.keyword = appliedFilters.keyword.trim();
if (appliedFilters.created_from) params.created_from = appliedFilters.created_from;
if (appliedFilters.created_to) params.created_to = appliedFilters.created_to;
return params;
});
const listQuery = useQuery({
queryKey: computed(() => ["articles", "list", "prompt_rule", articleParams.value]),
queryFn: () => articlesApi.list(articleParams.value),
});
let pollingTimer: number | null = null;
const deleteMutation = useMutation({
mutationFn: (id: number) => articlesApi.remove(id),
onSuccess: async () => {
message.success(t("templates.list.deleteSuccess"));
await queryClient.invalidateQueries({ queryKey: ["articles"] });
},
onError: (error) => message.error(formatError(error)),
});
const columns = computed<TableColumnsType<ArticleListItem>>(() => [
{ title: t("common.title"), dataIndex: "title", key: "title" },
{ title: t("custom.filters.promptRule"), dataIndex: "prompt_rule_name", key: "prompt_rule_name", width: 160 },
{ title: t("custom.schedule.platform"), dataIndex: "platforms", key: "platforms", width: 180 },
{ title: t("common.generateStatus"), dataIndex: "generate_status", key: "generate_status", width: 128 },
{ title: t("common.publishStatus"), dataIndex: "publish_status", key: "publish_status", width: 128 },
{ title: t("common.wordCount"), dataIndex: "word_count", key: "word_count", width: 100 },
{ title: t("common.createdAt"), dataIndex: "created_at", key: "created_at", width: 168 },
{ title: t("common.actions"), key: "actions", width: 140, fixed: "right" },
]);
function applyFilters(): void {
page.value = 1;
appliedFilters.prompt_rule_id = draftFilters.prompt_rule_id;
appliedFilters.publish_status = draftFilters.publish_status;
appliedFilters.generate_status = draftFilters.generate_status;
appliedFilters.keyword = draftFilters.keyword?.trim() || "";
appliedFilters.created_from = draftGenerationRange.value?.[0]?.toISOString();
appliedFilters.created_to = draftGenerationRange.value?.[1]?.toISOString();
}
function resetFilters(): void {
draftFilters.prompt_rule_id = undefined;
draftFilters.publish_status = undefined;
draftFilters.generate_status = undefined;
draftFilters.keyword = "";
draftGenerationRange.value = null;
applyFilters();
}
function openDetail(article: ArticleListItem): void {
selectedArticleId.value = article.id;
articleDrawerOpen.value = true;
}
function openEditor(article: ArticleListItem): void {
void router.push({ name: "article-editor", params: { id: String(article.id) } });
}
function canEditArticle(status: string): boolean {
return status === "completed" || status === "draft";
}
function handleTableChange(nextPage: number, nextPageSize: number): void {
page.value = nextPage;
pageSize.value = nextPageSize;
}
function startPolling(): void {
if (pollingTimer !== null) return;
pollingTimer = window.setInterval(() => {
void listQuery.refetch();
}, 3000);
}
function stopPolling(): void {
if (pollingTimer === null) return;
window.clearInterval(pollingTimer);
pollingTimer = null;
}
watch(
() => listQuery.data.value?.items ?? [],
(items) => {
const hasActive = items.some(
(item) => item.generate_status === "generating" || item.generate_status === "running",
);
if (hasActive) {
startPolling();
} else {
stopPolling();
}
},
{ immediate: true },
);
onBeforeUnmount(() => {
stopPolling();
});
</script>
<template>
<div class="custom-article-tab">
<div class="custom-article-tab__filters">
<div class="custom-article-tab__filter-item">
<label>{{ t("custom.filters.promptRule") }}:</label>
<a-select
v-model:value="draftFilters.prompt_rule_id"
allow-clear
:options="ruleOptions"
:placeholder="t('common.selectPlease')"
:loading="rulesQuery.isPending.value"
@change="applyFilters"
/>
</div>
<div class="custom-article-tab__filter-item">
<label>{{ t("custom.filters.publishStatus") }}:</label>
<a-select
v-model:value="draftFilters.publish_status"
allow-clear
:options="publishStatusOptions"
:placeholder="t('common.selectPlease')"
@change="applyFilters"
/>
</div>
<div class="custom-article-tab__filter-item">
<label>{{ t("custom.filters.generateStatus") }}:</label>
<a-select
v-model:value="draftFilters.generate_status"
allow-clear
:options="generateStatusOptions"
:placeholder="t('common.selectPlease')"
@change="applyFilters"
/>
</div>
<div class="custom-article-tab__filter-item">
<label>{{ t("custom.filters.generateTime") }}:</label>
<a-range-picker
v-model:value="draftGenerationRange"
allow-clear
show-time
format="YYYY-MM-DD HH:mm"
@change="applyFilters"
/>
</div>
<div class="custom-article-tab__filter-item">
<label>{{ t("custom.filters.title") }}:</label>
<a-input-search
v-model:value="draftFilters.keyword"
:placeholder="t('templates.filters.keywordPlaceholder')"
@search="applyFilters"
@pressEnter="applyFilters"
/>
</div>
<a-button @click="resetFilters">{{ t("common.reset") }}</a-button>
</div>
<div class="custom-article-tab__head">
<span class="custom-article-tab__count">
{{ t("templates.list.count", { count: listQuery.data.value?.total ?? 0 }) }}
</span>
<a-button type="link" @click="listQuery.refetch()">
{{ t("common.refresh") }}
</a-button>
</div>
<a-table
:columns="columns"
:data-source="listQuery.data.value?.items ?? []"
:loading="listQuery.isPending.value"
:pagination="{
current: page,
pageSize,
total: listQuery.data.value?.total ?? 0,
showSizeChanger: true,
}"
row-key="id"
size="small"
:scroll="{ x: 1000 }"
@change="(p: any) => handleTableChange(p.current, p.pageSize)"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<a class="custom-article-tab__title-link" @click="openDetail(record)">
{{ record.title || t("article.untitled") }}
</a>
</template>
<template v-else-if="column.key === 'prompt_rule_name'">
{{ record.prompt_rule_name || "--" }}
</template>
<template v-else-if="column.key === 'platforms'">
{{ formatPublishPlatformList(record.platforms) }}
</template>
<template v-else-if="column.key === 'generate_status'">
<a-tag :color="getGenerateStatusMeta(record.generate_status).color">
<span class="custom-article-tab__status-tag">
<LoadingOutlined
v-if="record.generate_status === 'generating' || record.generate_status === 'running'"
spin
/>
<span>{{ getGenerateStatusMeta(record.generate_status).label }}</span>
</span>
</a-tag>
</template>
<template v-else-if="column.key === 'publish_status'">
<a-tag :color="getPublishStatusMeta(record.publish_status).color">
{{ getPublishStatusMeta(record.publish_status).label }}
</a-tag>
</template>
<template v-else-if="column.key === 'word_count'">
{{ record.word_count || "--" }}
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4">
<a-button
type="link"
size="small"
:disabled="!canEditArticle(record.generate_status)"
@click="openEditor(record)"
>
{{ t("common.edit") }}
</a-button>
<a-popconfirm
:title="t('templates.list.deleteConfirm')"
@confirm="deleteMutation.mutate(record.id)"
>
<a-button type="link" size="small" danger>
{{ t("common.delete") }}
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<ArticleDetailDrawer
:open="articleDrawerOpen"
:article-id="selectedArticleId"
@close="articleDrawerOpen = false"
/>
</div>
</template>
<style scoped>
.custom-article-tab__filters {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
margin-bottom: 16px;
}
.custom-article-tab__filter-item {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 160px;
}
.custom-article-tab__filter-item label {
font-size: 12px;
color: #8c8c8c;
}
.custom-article-tab__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.custom-article-tab__count {
font-size: 13px;
color: #8c8c8c;
}
.custom-article-tab__title-link {
color: #1677ff;
cursor: pointer;
}
.custom-article-tab__title-link:hover {
text-decoration: underline;
}
.custom-article-tab__status-tag {
display: inline-flex;
align-items: center;
gap: 4px;
}
</style>
@@ -0,0 +1,602 @@
<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, promptRulesApi, schedulesApi } from "@/lib/api";
import { formatError } from "@/lib/errors";
import {
getAllPublishPlatforms,
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 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 allPlatforms = computed(() => getAllPublishPlatforms());
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({
name: form.name.trim(),
prompt_rule_id: form.promptRuleId!,
cron_expr: buildDailyCron(form.scheduleTime),
target_platform: serializeTargetPlatforms(form.platformIds),
}),
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, {
name: form.name.trim(),
prompt_rule_id: form.promptRuleId!,
cron_expr: buildDailyCron(form.scheduleTime),
target_platform: serializeTargetPlatforms(form.platformIds),
}),
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({
prompt_rule_id: form.promptRuleId!,
target_platform: serializeTargetPlatforms(form.platformIds),
extra_params: {
task_name: form.name.trim(),
enable_web_search: form.enableWebSearch,
generate_count: form.generateCount,
target_platforms: form.platformIds,
cover_file_name: coverFileName.value || null,
},
}),
onSuccess: async () => {
message.success(t("custom.messages.generateSubmitted"));
emit("update:open", false);
await queryClient.invalidateQueries({ queryKey: ["articles"] });
},
onError: (error) => message.error(formatError(error)),
});
const submitLoading = computed(
() =>
createScheduleMutation.isPending.value ||
updateScheduleMutation.isPending.value ||
instantGenerateMutation.isPending.value,
);
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 = false;
form.generateCount = 1;
form.scheduleTime = parseCronToDailyTime(props.task?.cron_expr);
resetCoverState();
}
function resetCoverState(): void {
if (coverPreviewUrl.value) {
URL.revokeObjectURL(coverPreviewUrl.value);
}
coverPreviewUrl.value = "";
coverFileName.value = "";
}
function handleCoverChange(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
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>
<PublishPlatformSelector 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 }"
>
<input
type="file"
accept="image/*"
hidden
:disabled="!form.coverEnabled"
@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>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import GenerateTaskDrawer from "@/components/GenerateTaskDrawer.vue";
defineProps<{
open: boolean;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
}>();
</script>
<template>
<GenerateTaskDrawer
:open="open"
mode="instant"
@update:open="emit('update:open', $event)"
/>
</template>
@@ -0,0 +1,296 @@
<script setup lang="ts">
import { InfoCircleOutlined } from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message } from "ant-design-vue";
import type { PromptRule, PromptRuleGroup } from "@geo/shared-types";
import { computed, reactive, watch } from "vue";
import { useI18n } from "vue-i18n";
import { promptRulesApi } from "@/lib/api";
import { formatError } from "@/lib/errors";
const props = defineProps<{
open: boolean;
rule?: PromptRule | null;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
saved: [ruleId: number];
}>();
const queryClient = useQueryClient();
const { t } = useI18n();
const form = reactive({
name: "",
prompt_content: "",
scene: "",
default_tone: "",
default_word_count: undefined as number | undefined,
group_id: undefined as number | undefined,
});
const groupsQuery = useQuery({
queryKey: ["promptRules", "groups"],
queryFn: () => promptRulesApi.listGroups(),
});
watch(
() => props.open,
(open) => {
if (!open) {
return;
}
const rule = props.rule;
form.name = rule?.name ?? "";
form.prompt_content = rule?.prompt_content ?? "";
form.scene = rule?.scene ?? "";
form.default_tone = rule?.default_tone ?? "";
form.default_word_count = rule?.default_word_count ?? undefined;
form.group_id = rule?.group_id ?? undefined;
},
);
const createMutation = useMutation({
mutationFn: () =>
promptRulesApi.create({
group_id: form.group_id ?? null,
name: form.name.trim(),
prompt_content: form.prompt_content.trim(),
scene: form.scene.trim() || undefined,
default_tone: form.default_tone.trim() || undefined,
default_word_count: form.default_word_count,
}),
onSuccess: async (data) => {
message.success(t("custom.messages.ruleCreated"));
emit("saved", data.id);
emit("update:open", false);
await queryClient.invalidateQueries({ queryKey: ["promptRules"] });
},
onError: (error) => message.error(formatError(error)),
});
const updateMutation = useMutation({
mutationFn: () =>
promptRulesApi.update(props.rule!.id, {
group_id: form.group_id ?? null,
name: form.name.trim(),
prompt_content: form.prompt_content.trim(),
scene: form.scene.trim() || undefined,
default_tone: form.default_tone.trim() || undefined,
default_word_count: form.default_word_count,
}),
onSuccess: async () => {
message.success(t("custom.messages.ruleUpdated"));
if (props.rule?.id) {
emit("saved", props.rule.id);
}
emit("update:open", false);
await queryClient.invalidateQueries({ queryKey: ["promptRules"] });
},
onError: (error) => message.error(formatError(error)),
});
const submitLoading = computed(
() => createMutation.isPending.value || updateMutation.isPending.value,
);
const drawerTitle = computed(() =>
props.rule?.id ? t("custom.promptRule.editTitle") : t("custom.promptRule.createTitle"),
);
async function handleSubmit(): Promise<void> {
if (!form.name.trim()) {
message.warning(t("custom.messages.missingPromptName"));
return;
}
if (!form.prompt_content.trim()) {
message.warning(t("custom.messages.missingPromptContent"));
return;
}
if (props.rule?.id) {
await updateMutation.mutateAsync();
return;
}
await createMutation.mutateAsync();
}
</script>
<template>
<a-drawer
:open="open"
:title="drawerTitle"
placement="right"
width="860"
:mask-closable="false"
class="prompt-rule-drawer"
@close="emit('update:open', false)"
>
<div class="prompt-rule-drawer__body">
<div class="prompt-rule-drawer__field">
<label class="prompt-rule-drawer__label prompt-rule-drawer__label--required">
{{ t("custom.promptRule.name") }}
</label>
<a-input
v-model:value="form.name"
size="large"
:maxlength="100"
:placeholder="t('custom.promptRule.namePlaceholder')"
/>
</div>
<div class="prompt-rule-drawer__field">
<label class="prompt-rule-drawer__label prompt-rule-drawer__label--required">
{{ t("custom.promptRule.content") }}
</label>
<a-textarea
v-model:value="form.prompt_content"
:rows="12"
:placeholder="t('custom.promptRule.contentPlaceholder')"
/>
</div>
<div class="prompt-rule-drawer__field">
<label class="prompt-rule-drawer__label">{{ t("custom.group.title") }}</label>
<a-select
v-model:value="form.group_id"
size="large"
style="width: 100%"
:placeholder="t('custom.promptRule.groupPlaceholder')"
:options="(groupsQuery.data.value ?? []).map((group: PromptRuleGroup) => ({
label: group.name,
value: group.id,
}))"
allow-clear
/>
</div>
<div class="prompt-rule-drawer__grid">
<div class="prompt-rule-drawer__field">
<label class="prompt-rule-drawer__label">{{ t("custom.promptRule.scene") }}</label>
<a-input v-model:value="form.scene" size="large" />
</div>
<div class="prompt-rule-drawer__field">
<label class="prompt-rule-drawer__label">{{ t("custom.promptRule.tone") }}</label>
<a-input v-model:value="form.default_tone" size="large" />
</div>
<div class="prompt-rule-drawer__field">
<label class="prompt-rule-drawer__label">
{{ t("custom.promptRule.wordCount") }}
<a-tooltip :title="t('custom.promptRule.wordCountHint')">
<InfoCircleOutlined style="margin-left: 6px; color: #8c8c8c; cursor: help" />
</a-tooltip>
</label>
<a-input-number
v-model:value="form.default_word_count"
:min="100"
:max="6000"
style="width: 100%"
size="large"
/>
</div>
</div>
</div>
<template #footer>
<div class="prompt-rule-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">
{{ t("common.confirm") }}
</a-button>
</div>
</template>
</a-drawer>
</template>
<style scoped>
.prompt-rule-drawer :deep(.ant-drawer-header) {
padding: 30px 28px;
border-bottom: 1px solid #edf2f7;
}
.prompt-rule-drawer :deep(.ant-drawer-title) {
color: #111827;
font-size: 18px;
font-weight: 800;
}
.prompt-rule-drawer :deep(.ant-drawer-body) {
padding: 30px 32px 32px;
background: #fff;
}
.prompt-rule-drawer :deep(.ant-drawer-footer) {
padding: 18px 28px;
border-top: 1px solid #edf2f7;
background: #fff;
}
.prompt-rule-drawer__body {
padding: 0 20px;
display: flex;
flex-direction: column;
}
.prompt-rule-drawer__field {
margin-top: 24px;
}
.prompt-rule-drawer__field:first-child {
margin-top: 0;
}
.prompt-rule-drawer__grid .prompt-rule-drawer__field {
margin-top: 0;
}
.prompt-rule-drawer__field :deep(.ant-input),
.prompt-rule-drawer__field :deep(.ant-input-number),
.prompt-rule-drawer__field :deep(.ant-select-selector),
.prompt-rule-drawer__field :deep(textarea.ant-input) {
margin-top: 4px;
}
.prompt-rule-drawer__label {
display: inline-flex;
align-items: center;
margin-bottom: 12px;
color: #1f2937;
font-size: 15px;
font-weight: 700;
}
.prompt-rule-drawer__label--required::before {
content: "*";
margin-right: 4px;
color: #ff4d4f;
}
.prompt-rule-drawer__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px 18px;
margin-top: 24px;
}
.prompt-rule-drawer__footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 820px) {
.prompt-rule-drawer__grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,395 @@
<script setup lang="ts">
import {
DeleteOutlined,
EditOutlined,
PlusOutlined,
} from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message, type TableColumnsType } from "ant-design-vue";
import type { PromptRule, PromptRuleGroup } from "@geo/shared-types";
import { computed, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import PromptRuleModal from "@/components/PromptRuleModal.vue";
import { promptRulesApi } from "@/lib/api";
import { formatDateTime } from "@/lib/display";
import { formatError } from "@/lib/errors";
const queryClient = useQueryClient();
const { t } = useI18n();
const selectedGroupId = ref<number | "all" | "ungrouped">("all");
const ruleModalOpen = ref(false);
const editingRule = ref<PromptRule | null>(null);
const groupModalOpen = ref(false);
const editingGroupId = ref<number | null>(null);
const groupForm = reactive({ name: "" });
const page = ref(1);
const pageSize = ref(20);
const groupsQuery = useQuery({
queryKey: ["promptRules", "groups"],
queryFn: () => promptRulesApi.listGroups(),
});
const ruleListParams = computed(() => {
const params: Record<string, unknown> = {
page: page.value,
page_size: pageSize.value,
};
if (selectedGroupId.value === "ungrouped") {
params.ungrouped = true;
} else if (selectedGroupId.value !== "all") {
params.group_id = selectedGroupId.value;
}
return params;
});
const rulesQuery = useQuery({
queryKey: computed(() => ["promptRules", "list", ruleListParams.value]),
queryFn: () => promptRulesApi.list(ruleListParams.value),
});
const columns = computed<TableColumnsType<PromptRule>>(() => [
{ title: t("custom.promptRule.name"), dataIndex: "name", key: "name", width: 160 },
{ title: t("custom.promptRule.content"), dataIndex: "prompt_content", key: "prompt_content", ellipsis: true },
{ title: t("custom.promptRule.scene"), dataIndex: "scene", key: "scene", width: 120 },
{ title: t("custom.promptRule.tone"), dataIndex: "default_tone", key: "default_tone", width: 100 },
{ title: t("custom.promptRule.wordCount"), dataIndex: "default_word_count", key: "default_word_count", width: 100 },
{ title: t("custom.promptRule.articleCount"), dataIndex: "article_count", key: "article_count", width: 100 },
{ title: t("custom.promptRule.status"), dataIndex: "status", key: "status", width: 100 },
{ title: t("common.createdAt"), dataIndex: "created_at", key: "created_at", width: 168 },
{ title: t("common.actions"), key: "actions", width: 140, fixed: "right" },
]);
const groupMutations = {
create: useMutation({
mutationFn: () => promptRulesApi.createGroup({ name: groupForm.name.trim() }),
onSuccess: async () => {
message.success(t("custom.messages.groupCreated"));
groupModalOpen.value = false;
await queryClient.invalidateQueries({ queryKey: ["promptRules", "groups"] });
},
onError: (error) => message.error(formatError(error)),
}),
update: useMutation({
mutationFn: () =>
promptRulesApi.updateGroup(editingGroupId.value as number, { name: groupForm.name.trim() }),
onSuccess: async () => {
message.success(t("custom.messages.groupUpdated"));
groupModalOpen.value = false;
await queryClient.invalidateQueries({ queryKey: ["promptRules", "groups"] });
},
onError: (error) => message.error(formatError(error)),
}),
remove: useMutation({
mutationFn: (id: number) => promptRulesApi.removeGroup(id),
onSuccess: async () => {
message.success(t("custom.messages.groupDeleted"));
if (selectedGroupId.value === editingGroupId.value) {
selectedGroupId.value = "all";
}
await queryClient.invalidateQueries({ queryKey: ["promptRules"] });
},
onError: (error) => message.error(formatError(error)),
}),
};
const ruleMutations = {
remove: useMutation({
mutationFn: (id: number) => promptRulesApi.remove(id),
onSuccess: async () => {
message.success(t("custom.messages.ruleDeleted"));
await queryClient.invalidateQueries({ queryKey: ["promptRules"] });
},
onError: (error) => message.error(formatError(error)),
}),
toggleStatus: useMutation({
mutationFn: ({ id, status }: { id: number; status: "enabled" | "disabled" }) =>
promptRulesApi.toggleStatus(id, { status }),
onSuccess: async (_data, variables) => {
message.success(
variables.status === "enabled"
? t("custom.messages.ruleEnabled")
: t("custom.messages.ruleDisabled"),
);
await queryClient.invalidateQueries({ queryKey: ["promptRules"] });
},
onError: (error) => message.error(formatError(error)),
}),
};
function openGroupModal(group?: PromptRuleGroup): void {
editingGroupId.value = group?.id ?? null;
groupForm.name = group?.name ?? "";
groupModalOpen.value = true;
}
async function submitGroup(): Promise<void> {
if (!groupForm.name.trim()) return;
if (editingGroupId.value) {
await groupMutations.update.mutateAsync();
} else {
await groupMutations.create.mutateAsync();
}
}
function openRuleModal(rule?: PromptRule): void {
editingRule.value = rule ?? null;
ruleModalOpen.value = true;
}
function handleTableChange(nextPage: number, nextPageSize: number): void {
page.value = nextPage;
pageSize.value = nextPageSize;
}
watch(selectedGroupId, () => {
page.value = 1;
});
</script>
<template>
<div class="prompt-rule-tab">
<div class="prompt-rule-tab__layout">
<!-- Group sidebar -->
<aside class="prompt-rule-tab__sidebar">
<div class="prompt-rule-tab__sidebar-head">
<h4>{{ t("custom.group.title") }}</h4>
<a-button type="text" size="small" @click="openGroupModal()">
<template #icon><PlusOutlined /></template>
</a-button>
</div>
<div class="prompt-rule-tab__group-list">
<div
class="prompt-rule-tab__group-item"
:class="{ 'prompt-rule-tab__group-item--active': selectedGroupId === 'all' }"
@click="selectedGroupId = 'all'"
>
<span>{{ t("custom.group.allRules") }}</span>
</div>
<div
v-for="group in groupsQuery.data.value ?? []"
:key="group.id"
class="prompt-rule-tab__group-item"
:class="{ 'prompt-rule-tab__group-item--active': selectedGroupId === group.id }"
@click="selectedGroupId = group.id"
>
<span>{{ group.name }}</span>
<span class="prompt-rule-tab__group-actions">
<a-button type="text" size="small" @click.stop="openGroupModal(group)">
<template #icon><EditOutlined /></template>
</a-button>
<a-popconfirm
:title="t('custom.group.deleteConfirm')"
@confirm="groupMutations.remove.mutate(group.id)"
>
<a-button type="text" size="small" danger @click.stop>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-popconfirm>
</span>
</div>
<div
class="prompt-rule-tab__group-item"
:class="{ 'prompt-rule-tab__group-item--active': selectedGroupId === 'ungrouped' }"
@click="selectedGroupId = 'ungrouped'"
>
<span>{{ t("custom.promptRule.ungrouped") }}</span>
</div>
</div>
</aside>
<!-- Rule list panel -->
<div class="prompt-rule-tab__panel">
<div class="prompt-rule-tab__panel-head">
<span class="prompt-rule-tab__count">
{{ t("templates.list.count", { count: rulesQuery.data.value?.total ?? 0 }) }}
</span>
<a-button type="primary" @click="openRuleModal()">
<template #icon><PlusOutlined /></template>
{{ t("common.create") }}
</a-button>
</div>
<a-table
:columns="columns"
:data-source="rulesQuery.data.value?.items ?? []"
:loading="rulesQuery.isPending.value"
:pagination="{
current: page,
pageSize,
total: rulesQuery.data.value?.total ?? 0,
showSizeChanger: true,
}"
row-key="id"
size="small"
:scroll="{ x: 1100 }"
@change="(p: any) => handleTableChange(p.current, p.pageSize)"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'prompt_content'">
<span class="prompt-rule-tab__content-cell">{{ record.prompt_content }}</span>
</template>
<template v-else-if="column.key === 'scene'">
{{ record.scene || "--" }}
</template>
<template v-else-if="column.key === 'default_tone'">
{{ record.default_tone || "--" }}
</template>
<template v-else-if="column.key === 'default_word_count'">
{{ record.default_word_count || "--" }}
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'enabled' ? 'green' : 'default'">
{{ record.status === "enabled" ? t("custom.promptRule.enabled") : t("custom.promptRule.disabled") }}
</a-tag>
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4">
<a-button type="link" size="small" @click="openRuleModal(record)">
{{ t("common.edit") }}
</a-button>
<a-button
type="link"
size="small"
@click="ruleMutations.toggleStatus.mutate({
id: record.id,
status: record.status === 'enabled' ? 'disabled' : 'enabled',
})"
>
{{ record.status === "enabled" ? t("custom.promptRule.disabled") : t("custom.promptRule.enabled") }}
</a-button>
<a-popconfirm
:title="t('custom.promptRule.deleteConfirm')"
@confirm="ruleMutations.remove.mutate(record.id)"
>
<a-button type="link" size="small" danger>
{{ t("common.delete") }}
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
</div>
<!-- Group modal -->
<a-modal
:open="groupModalOpen"
:title="editingGroupId ? t('custom.group.editTitle') : t('custom.group.createTitle')"
:confirm-loading="groupMutations.create.isPending.value || groupMutations.update.isPending.value"
@ok="submitGroup"
@cancel="groupModalOpen = false"
>
<a-form layout="vertical" style="padding-top: 8px">
<a-form-item :label="t('custom.group.name')" required>
<a-input v-model:value="groupForm.name" :maxlength="50" />
</a-form-item>
</a-form>
</a-modal>
<!-- Rule modal -->
<PromptRuleModal v-model:open="ruleModalOpen" :rule="editingRule" />
</div>
</template>
<style scoped>
.prompt-rule-tab__layout {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 20px;
}
.prompt-rule-tab__sidebar {
background: #f8fafc;
border: 1px solid #e6edf5;
border-radius: 12px;
padding: 16px;
}
.prompt-rule-tab__sidebar-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.prompt-rule-tab__sidebar-head h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.prompt-rule-tab__group-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.prompt-rule-tab__group-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
border: 1px solid transparent;
transition: background 0.2s, border-color 0.2s;
}
.prompt-rule-tab__group-item:hover {
background: #f0f5ff;
}
.prompt-rule-tab__group-item--active {
border-color: #bfd7ff;
background: #eef5ff;
}
.prompt-rule-tab__group-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
}
.prompt-rule-tab__group-item:hover .prompt-rule-tab__group-actions {
opacity: 1;
}
.prompt-rule-tab__panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.prompt-rule-tab__count {
font-size: 13px;
color: #8c8c8c;
}
.prompt-rule-tab__content-cell {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 12px;
color: #595959;
}
@media (max-width: 720px) {
.prompt-rule-tab__layout {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,212 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import type { PublishPlatformOption } from "@/lib/publish-platforms";
import { isPlatformBound } from "@/lib/publish-platforms";
const props = withDefaults(defineProps<{
modelValue: string[];
platforms: PublishPlatformOption[];
disabled?: boolean;
multiple?: boolean;
}>(), {
disabled: false,
multiple: true,
});
const emit = defineEmits<{
"update:modelValue": [value: string[]];
}>();
const { t } = useI18n();
const selectedValues = computed(() => props.modelValue ?? []);
function isSelected(platformId: string): boolean {
return selectedValues.value.includes(platformId);
}
function togglePlatform(platformId: string): void {
if (props.disabled) {
return;
}
if (props.multiple) {
const next = isSelected(platformId)
? selectedValues.value.filter((item) => item !== platformId)
: [...selectedValues.value, platformId];
emit("update:modelValue", next);
return;
}
emit("update:modelValue", isSelected(platformId) ? [] : [platformId]);
}
</script>
<template>
<div class="publish-platform-selector">
<div v-if="platforms.length" class="publish-platform-selector__grid">
<button
v-for="platform in platforms"
:key="platform.id"
type="button"
class="publish-platform-selector__card"
:class="{
'publish-platform-selector__card--active': isSelected(platform.id),
'publish-platform-selector__card--disabled': disabled,
}"
@click="togglePlatform(platform.id)"
>
<span class="publish-platform-selector__check">
<span v-if="isSelected(platform.id)" class="publish-platform-selector__check-dot"></span>
</span>
<span
class="publish-platform-selector__badge"
:style="{ '--badge-accent': platform.accent }"
>
{{ platform.shortName }}
</span>
<span class="publish-platform-selector__copy">
<strong>{{ platform.name }}</strong>
</span>
<span
class="publish-platform-selector__status"
:class="{ 'publish-platform-selector__status--bound': isPlatformBound(platform.id) }"
>
{{ isPlatformBound(platform.id) ? (platform.accountLabel ?? t("custom.task.platformConnected")) : t("custom.task.platformUnauthorized") }}
</span>
</button>
</div>
<div v-else class="publish-platform-selector__empty">
<strong>{{ t("custom.task.platformEmptyTitle") }}</strong>
<p>{{ t("custom.task.platformEmptyHint") }}</p>
</div>
</div>
</template>
<style scoped>
.publish-platform-selector {
width: 100%;
}
.publish-platform-selector__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.publish-platform-selector__card {
display: grid;
grid-template-columns: 20px 32px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 16px;
border: 1px solid #e1e8f2;
border-radius: 12px;
background: #ffffff;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
}
.publish-platform-selector__card:hover {
border-color: #bbccf1;
background: #fcfdff;
}
.publish-platform-selector__card--active {
border-color: #355dff;
background: #f2f6ff;
box-shadow: 0 4px 12px rgba(53, 93, 255, 0.08);
}
.publish-platform-selector__card--disabled {
cursor: not-allowed;
opacity: 0.7;
}
.publish-platform-selector__check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 1px solid #d0d7e5;
border-radius: 4px;
background: #fff;
}
.publish-platform-selector__card--active .publish-platform-selector__check {
border-color: #355dff;
background: #355dff;
}
.publish-platform-selector__check-dot {
width: 6px;
height: 6px;
border-radius: 1px;
background: #fff;
}
.publish-platform-selector__badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: color-mix(in srgb, var(--badge-accent) 10%, #ffffff);
color: var(--badge-accent);
font-size: 15px;
font-weight: 700;
}
.publish-platform-selector__copy {
display: flex;
flex-direction: column;
min-width: 0;
}
.publish-platform-selector__copy strong {
color: #1f2937;
font-size: 14px;
font-weight: 600;
line-height: 1.2;
}
.publish-platform-selector__status {
font-size: 13px;
color: #355dff;
}
.publish-platform-selector__status--bound {
color: #8fa0ba;
}
.publish-platform-selector__empty {
padding: 18px 20px;
border: 1px dashed #cfd9e8;
border-radius: 18px;
background: linear-gradient(180deg, #fcfdff 0%, #f5f8fc 100%);
}
.publish-platform-selector__empty strong {
display: block;
color: #1f2937;
font-size: 15px;
font-weight: 700;
}
.publish-platform-selector__empty p {
margin: 6px 0 0;
color: #667085;
font-size: 13px;
line-height: 1.7;
}
</style>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { ScheduleTask } from "@geo/shared-types";
import GenerateTaskDrawer from "@/components/GenerateTaskDrawer.vue";
defineProps<{
open: boolean;
task?: ScheduleTask | null;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
}>();
</script>
<template>
<GenerateTaskDrawer
:open="open"
mode="schedule"
:task="task"
@update:open="emit('update:open', $event)"
/>
</template>
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { PlusOutlined } from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message, type TableColumnsType } from "ant-design-vue";
import type { ScheduleTask } from "@geo/shared-types";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import ScheduleTaskModal from "@/components/ScheduleTaskModal.vue";
import { schedulesApi } from "@/lib/api";
import { formatDateTime } from "@/lib/display";
import { formatError } from "@/lib/errors";
import { formatPublishPlatformSummary } from "@/lib/publish-platforms";
const queryClient = useQueryClient();
const { t } = useI18n();
const modalOpen = ref(false);
const editingTask = ref<ScheduleTask | null>(null);
const page = ref(1);
const pageSize = ref(20);
const listParams = computed(() => ({
page: page.value,
page_size: pageSize.value,
}));
const listQuery = useQuery({
queryKey: computed(() => ["schedules", "list", listParams.value]),
queryFn: () => schedulesApi.list(listParams.value),
});
const columns = computed<TableColumnsType<ScheduleTask>>(() => [
{ title: t("custom.schedule.name"), dataIndex: "name", key: "name", width: 160 },
{ title: t("custom.schedule.rule"), dataIndex: "prompt_rule_name", key: "prompt_rule_name", width: 140 },
{ title: t("custom.schedule.cron"), dataIndex: "cron_expr", key: "cron_expr", width: 140 },
{ title: t("custom.schedule.platform"), dataIndex: "target_platform", key: "target_platform", width: 120 },
{ title: t("custom.schedule.brand"), dataIndex: "brand_name", key: "brand_name", width: 120 },
{ title: t("custom.schedule.nextRun"), dataIndex: "next_run_at", key: "next_run_at", width: 168 },
{ title: t("custom.promptRule.status"), dataIndex: "status", key: "status", width: 100 },
{ title: t("common.createdAt"), dataIndex: "created_at", key: "created_at", width: 168 },
{ title: t("common.actions"), key: "actions", width: 160, fixed: "right" },
]);
const removeMutation = useMutation({
mutationFn: (id: number) => schedulesApi.remove(id),
onSuccess: async () => {
message.success(t("custom.messages.scheduleDeleted"));
await queryClient.invalidateQueries({ queryKey: ["schedules"] });
},
onError: (error) => message.error(formatError(error)),
});
const toggleStatusMutation = useMutation({
mutationFn: ({ id, status }: { id: number; status: "enabled" | "disabled" }) =>
schedulesApi.toggleStatus(id, { status }),
onSuccess: async (_data, variables) => {
message.success(
variables.status === "enabled"
? t("custom.messages.scheduleEnabled")
: t("custom.messages.scheduleDisabled"),
);
await queryClient.invalidateQueries({ queryKey: ["schedules"] });
},
onError: (error) => message.error(formatError(error)),
});
function openModal(task?: ScheduleTask): void {
editingTask.value = task ?? null;
modalOpen.value = true;
}
function handleTableChange(nextPage: number, nextPageSize: number): void {
page.value = nextPage;
pageSize.value = nextPageSize;
}
</script>
<template>
<div class="schedule-task-tab">
<div class="schedule-task-tab__head">
<span class="schedule-task-tab__count">
{{ t("templates.list.count", { count: listQuery.data.value?.total ?? 0 }) }}
</span>
<a-button type="primary" @click="openModal()">
<template #icon><PlusOutlined /></template>
{{ t("common.create") }}
</a-button>
</div>
<a-table
:columns="columns"
:data-source="listQuery.data.value?.items ?? []"
:loading="listQuery.isPending.value"
:pagination="{
current: page,
pageSize,
total: listQuery.data.value?.total ?? 0,
showSizeChanger: true,
}"
row-key="id"
size="small"
:scroll="{ x: 1200 }"
@change="(p: any) => handleTableChange(p.current, p.pageSize)"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'prompt_rule_name'">
{{ record.prompt_rule_name || "--" }}
</template>
<template v-else-if="column.key === 'target_platform'">
{{ formatPublishPlatformSummary(record.target_platform) }}
</template>
<template v-else-if="column.key === 'brand_name'">
{{ record.brand_name || "--" }}
</template>
<template v-else-if="column.key === 'next_run_at'">
{{ record.next_run_at ? formatDateTime(record.next_run_at) : "--" }}
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'enabled' ? 'green' : 'default'">
{{ record.status === "enabled" ? t("custom.schedule.enabled") : t("custom.schedule.disabled") }}
</a-tag>
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4">
<a-button type="link" size="small" @click="openModal(record)">
{{ t("common.edit") }}
</a-button>
<a-button
type="link"
size="small"
@click="toggleStatusMutation.mutate({
id: record.id,
status: record.status === 'enabled' ? 'disabled' : 'enabled',
})"
>
{{ record.status === "enabled" ? t("custom.schedule.disabled") : t("custom.schedule.enabled") }}
</a-button>
<a-popconfirm
:title="t('custom.schedule.deleteConfirm')"
@confirm="removeMutation.mutate(record.id)"
>
<a-button type="link" size="small" danger>
{{ t("common.delete") }}
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<ScheduleTaskModal v-model:open="modalOpen" :task="editingTask" />
</div>
</template>
<style scoped>
.schedule-task-tab__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.schedule-task-tab__count {
font-size: 13px;
color: #8c8c8c;
}
</style>