diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json index 024bebe..f0f48bf 100644 --- a/apps/admin-web/package.json +++ b/apps/admin-web/package.json @@ -13,6 +13,10 @@ "@ant-design/icons-vue": "^7.0.1", "@geo/http-client": "workspace:*", "@geo/shared-types": "workspace:*", + "@milkdown/crepe": "^7.20.0", + "@milkdown/kit": "^7.20.0", + "@milkdown/theme-nord": "^7.20.0", + "@milkdown/vue": "^7.20.0", "@tanstack/vue-query": "^5.96.0", "@vueuse/core": "^14.2.1", "ant-design-vue": "^4.2.6", diff --git a/apps/admin-web/src/components/ArticleDetailDrawer.vue b/apps/admin-web/src/components/ArticleDetailDrawer.vue index bbbc883..24b5a36 100644 --- a/apps/admin-web/src/components/ArticleDetailDrawer.vue +++ b/apps/admin-web/src/components/ArticleDetailDrawer.vue @@ -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(null); -const streamError = ref(""); const streamFeatureEnabled = isGenerationStreamEnabled(); const detailQuery = useQuery({ @@ -85,7 +83,6 @@ const versionColumns = computed>(() => [ 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 { @@ -244,15 +237,6 @@ async function refreshArticleData(): Promise { - -
+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(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() }); +} + + + + + diff --git a/apps/admin-web/src/components/CustomArticleTab.vue b/apps/admin-web/src/components/CustomArticleTab.vue new file mode 100644 index 0000000..5c8ee71 --- /dev/null +++ b/apps/admin-web/src/components/CustomArticleTab.vue @@ -0,0 +1,386 @@ + + + + + diff --git a/apps/admin-web/src/components/GenerateTaskDrawer.vue b/apps/admin-web/src/components/GenerateTaskDrawer.vue new file mode 100644 index 0000000..0cf08ad --- /dev/null +++ b/apps/admin-web/src/components/GenerateTaskDrawer.vue @@ -0,0 +1,602 @@ + + + + + diff --git a/apps/admin-web/src/components/InstantGenerateModal.vue b/apps/admin-web/src/components/InstantGenerateModal.vue new file mode 100644 index 0000000..4271f01 --- /dev/null +++ b/apps/admin-web/src/components/InstantGenerateModal.vue @@ -0,0 +1,19 @@ + + + diff --git a/apps/admin-web/src/components/PromptRuleModal.vue b/apps/admin-web/src/components/PromptRuleModal.vue new file mode 100644 index 0000000..db4d35c --- /dev/null +++ b/apps/admin-web/src/components/PromptRuleModal.vue @@ -0,0 +1,296 @@ + + + + + diff --git a/apps/admin-web/src/components/PromptRuleTab.vue b/apps/admin-web/src/components/PromptRuleTab.vue new file mode 100644 index 0000000..bc738c8 --- /dev/null +++ b/apps/admin-web/src/components/PromptRuleTab.vue @@ -0,0 +1,395 @@ + + + + + diff --git a/apps/admin-web/src/components/PublishPlatformSelector.vue b/apps/admin-web/src/components/PublishPlatformSelector.vue new file mode 100644 index 0000000..90d81be --- /dev/null +++ b/apps/admin-web/src/components/PublishPlatformSelector.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/apps/admin-web/src/components/ScheduleTaskModal.vue b/apps/admin-web/src/components/ScheduleTaskModal.vue new file mode 100644 index 0000000..44c4960 --- /dev/null +++ b/apps/admin-web/src/components/ScheduleTaskModal.vue @@ -0,0 +1,23 @@ + + + diff --git a/apps/admin-web/src/components/ScheduleTaskTab.vue b/apps/admin-web/src/components/ScheduleTaskTab.vue new file mode 100644 index 0000000..34c02fe --- /dev/null +++ b/apps/admin-web/src/components/ScheduleTaskTab.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index 61f0a87..670a238 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -18,6 +18,7 @@ const enUS = { refresh: "Refresh", search: "Search", save: "Save", + back: "Back", next: "Next", previous: "Previous", submit: "Submit", @@ -91,11 +92,19 @@ const enUS = { }, templates: { title: "Template Creation", - description: "Filter existing articles, choose templates, and trigger new generation tasks.", + description: "Choose a suitable template or use the batch feature to quickly generate GEO content.", + }, + wizard: { + title: "Template Wizard", + description: "Follow the template-driven three-step flow to fill inputs, confirm structure, and generate the article.", + }, + articleEditor: { + title: "Article Editor", + description: "Edit titles, content, and publishing settings in a dedicated workspace.", }, custom: { title: "Custom Generation", - description: "No matching backend APIs exist in this repository yet.", + description: "Generate articles with custom prompt rules, instant or scheduled.", }, optimize: { title: "Article Optimization", @@ -206,13 +215,17 @@ const enUS = { publishStatus: "Publish status", generateStatus: "Generation status", keyword: "Search", + generationTime: "Generation time", + generationTimeStart: "Start time", + generationTimeEnd: "End time", keywordPlaceholder: "Search by article title", - unsupportedDate: "Date range filtering is not available in the current backend yet.", }, list: { count: "{count} articles", empty: "No articles have been created yet.", preview: "Preview", + edit: "Edit article", + editDisabled: "Only draft, failed, or completed articles can be edited", versions: "Versions", deleteConfirm: "Delete this article?", deleteSuccess: "Article deleted.", @@ -228,20 +241,28 @@ const enUS = { basic: "Basic info", basicDesc: "Language, template fields, and brand context", structure: "Structure", - structureDesc: "Title, key points, and outline", + structureDesc: "Title and outline", generate: "Generate", - generateDesc: "Review and submit the task", + generateDesc: "Confirm the outline, add key points, and submit the task", }, sections: { templateFields: "Template fields", brandContext: "Brand context", language: "Language", brandInfo: "Brand", + brandLibrary: "Brand library", + brandName: "Brand name", + website: "Official website", + brandSummary: "AI brand summary", keywords: "Keywords", competitors: "Competitors", chooseTitle: "Choose a title", customTitle: "Custom title", structure: "Article structure", + customOutline: "Custom section", + preview: "Article preview", + generatedOutline: "Confirm outline", + outlinePreview: "Outline preview", keyPoints: "Key points", review: "Review", notes: "Notes", @@ -249,11 +270,35 @@ const enUS = { }, hints: { brandContext: "Optional. Selecting a brand pulls in keywords and competitor context.", + brandFlow: "You can pick a brand from the library or type one manually. AI analysis will expand keywords, competitors, and title options.", + brandSummaryPlaceholder: "After analysis, AI will summarize the brand angle, positioning, and content opportunities here.", + keywords: "Type keywords directly or reuse brand-library keywords. AI results will be merged into this list.", + competitors: "Competitors stay editable. You can add rows manually and save useful ones back into the competitor library.", + competitorEmptyEditable: "No competitors yet. Add one manually or let AI fill the list first.", + templateFields: "Keep any extra template-specific fields here to constrain the writing direction.", + titleSelection: "AI will return 5 better title options before you move forward. Choose one or rewrite it.", + structure: "Keep the default outline or trim sections for this draft.", + preview: "The panel on the right updates the article frame in real time.", + previewEmpty: "Select one or more sections to preview the article frame here.", + generatedOutline: "AI generates the full article outline first. You can edit, add, or delete nodes before creating the final article.", + outlinePreview: "The panel on the right updates from the current outline in real time.", + outlineEmpty: "Generate the outline first to edit the article structure here.", + review: "Check the final setup before creating the generation task.", competitorEmpty: "No competitors are available under this brand yet.", customTitle: "Use this field if you want to override the title manually.", keyPoints: "Add any key messages, angles, or style notes here.", async: "The backend creates the article and task first, then completes content generation asynchronously.", }, + actions: { + analyze: "Analyze", + addCompetitor: "Add competitor", + addSection: "Add section", + addOutlineNode: "Add node", + addOutlineChild: "Add child", + favorite: "Save", + saved: "Saved", + submit: "Submit and generate", + }, review: { templateName: "Template", articleTitle: "Article title", @@ -268,12 +313,65 @@ const enUS = { zh: "Simplified Chinese", en: "English", }, + table: { + website: "Website", + name: "Name", + description: "Description", + actions: "Actions", + }, + placeholders: { + brandLibrary: "Choose an existing brand from the library", + brandName: "Enter a brand name, or refine the selected library brand", + website: "Official website, optional", + keywords: "Type keywords and press Enter", + customTitle: "Override the AI title here if needed", + customOutline: "Add another section, for example “Buying advice”", + keyPoints: "Add any extra instructions, key angles, or constraints for the article", + competitorWebsite: "Official site or landing page", + competitorName: "Competitor name", + competitorDescription: "One-line positioning or summary", + outlineNode: "Enter a title", + }, promptVisible: "Prompt visible", promptSealed: "Prompt sealed", + assist: { + analyzeTitle: "AI is analyzing keywords and competitors. Please wait…", + titleTitle: "AI is generating titles. Please wait…", + outlineTitle: "AI is generating the article outline. Please wait…", + analyzeStages: { + brand: "Analyzing the brand context…", + keywords: "Extracting keyword opportunities…", + competitors: "Mapping competitors and websites…", + }, + titleStages: { + context: "Preparing the current brief…", + strategy: "Generating title directions from keywords and competitors…", + finalize: "Returning 5 title options…", + }, + outlineStages: { + context: "Preparing the title, keywords, and competitor context…", + structure: "Generating the article outline from the selected structure…", + finalize: "Returning the editable outline result…", + }, + }, messages: { requiredField: "Please fill in {field} first.", + missingBrand: "Choose a brand from the library or enter a brand name first.", missingTitle: "Please choose or enter an article title.", missingOutline: "Please select at least one outline section.", + missingGeneratedOutline: "Please generate and confirm the article outline first.", + missingCompetitorName: "Enter a competitor name before saving it to the library.", + savedCompetitor: "Competitor saved to the brand library.", + assistReady: "AI analysis is ready. You can keep refining the draft.", + assistFailed: "AI analysis failed. Please try again.", + assistTimeout: "AI analysis timed out. Please try again.", + titleFailed: "Title generation failed. Please try again.", + titleTimeout: "Title generation timed out. Please try again.", + outlineFailed: "Outline generation failed. Please try again.", + outlineTimeout: "Outline generation timed out. Please try again.", + draftSaved: "Draft saved. Returning to the template list.", + draftSaveFailed: "Failed to save the draft. Please try again.", + queued: "Task submitted. The article is now waiting in the generation queue.", submitted: "Generation task submitted. Refreshing article data.", }, }, @@ -284,6 +382,31 @@ const enUS = { versionHistory: "Version history", untitled: "Untitled article", noContent: "This article does not have generated content yet.", + editor: { + publish: "Publish", + titlePlaceholder: "Enter article title", + leaveConfirm: { + title: "Unsaved changes", + description: "This article has unsaved edits. Leave now to discard them, or save first before going back.", + discard: "Discard changes", + saveAndLeave: "Save and leave", + }, + platformsTitle: "Publish platforms", + platformsHint: "This uses the shared publish-platform selector. For now it shows placeholder bound platforms on the frontend.", + coverTitle: "Cover image", + coverHint: "Upload a cover asset before publishing. This is local preview only for now.", + coverUpload: "Upload cover image", + imagePrompt: "Enter image URL", + messages: { + saved: "Article saved.", + generating: "This article is still generating. Try editing it later.", + locked: "Only completed articles can be edited.", + publishPending: "Publishing is not wired yet. The current action only saves content.", + }, + platforms: { + zhihu: "Zhihu", + }, + }, meta: { templateType: "Template", currentVersion: "Current version", @@ -350,6 +473,131 @@ const enUS = { chooseKeyword: "Please choose a keyword first.", }, }, + custom: { + eyebrow: "Custom Generation", + title: "Custom Generation", + description: "Generate articles with custom prompt rules, instant or scheduled.", + cards: { + instantTitle: "Instant Generation", + instantDesc: "Quickly create custom content tasks", + scheduleTitle: "Scheduled Generation", + scheduleDesc: "Set up automated content generation plans", + }, + tabs: { + articles: "Articles", + instantTasks: "Instant Tasks", + scheduleTasks: "Scheduled Tasks", + promptRules: "Prompt Rules", + }, + promptRule: { + name: "Rule name", + namePlaceholder: "Enter prompt name", + content: "Prompt content", + scene: "Scene", + tone: "Default tone", + wordCount: "Default word count", + status: "Status", + articleCount: "Article count", + createTitle: "Create Prompt Rule", + editTitle: "Edit Prompt Rule", + deleteConfirm: "Delete this rule?", + enabled: "Enabled", + disabled: "Disabled", + ungrouped: "Ungrouped", + groupPlaceholder: "Select a prompt group", + contentPlaceholder: "Enter prompt content, variables supported", + }, + task: { + basicSection: "I Basic Information", + advancedSection: "I Advanced Settings", + name: "Task name", + namePlaceholder: "Use a simple name so the task is easy to identify later.", + prompt: "Select Prompt", + promptPlaceholder: "Select a prompt", + createPrompt: "New Prompt", + platforms: "Publish platforms", + platformsHint: "Choose one or more publish platforms. Only bound platforms are shown here.", + platformConnected: "Connected", + platformEmptyTitle: "Publish platforms are coming", + platformEmptyHint: "Once the media integration is wired, bound tenant platforms will appear here automatically.", + cover: "Cover image", + coverHint: "Keep the cover clear, complete, and visually polished.", + coverUpload: "Upload cover image", + coverUploadHint: "Local preview only for now", + scheduleTime: "Schedule article generation", + scheduleEveryDay: "Every day", + enableWebSearch: "Enable web search for generation", + generateCount: "Article count", + submit: "Create task", + }, + group: { + title: "Rule Groups", + createTitle: "Create Group", + editTitle: "Edit Group", + deleteConfirm: "Deleting this group will move its rules to ungrouped. Continue?", + name: "Group name", + allRules: "All Rules", + }, + schedule: { + name: "Task name", + rule: "Prompt rule", + cron: "Frequency", + platform: "Target platform", + nextRun: "Next run", + startAt: "Start time", + endAt: "End time", + brand: "Brand", + createTitle: "Create Scheduled Task", + editTitle: "Edit Scheduled Task", + deleteConfirm: "Delete this scheduled task?", + enabled: "Enabled", + disabled: "Disabled", + cronOptions: { + daily: "Once daily", + twiceDaily: "Twice daily", + weekly: "Once weekly", + custom: "Custom Cron", + }, + }, + instant: { + title: "Instant Generate", + createTitle: "Create Instant Task", + selectRule: "Select prompt rule", + selectBrand: "Select brand", + selectPlatform: "Target platform", + submit: "Generate now", + extraParams: "Extra parameters", + }, + filters: { + promptRule: "Prompt rule", + publishStatus: "Publish status", + generateStatus: "Generation status", + title: "Title", + generateTime: "Generation time", + }, + messages: { + ruleCreated: "Rule created.", + ruleUpdated: "Rule updated.", + ruleDeleted: "Rule deleted.", + ruleEnabled: "Rule enabled.", + ruleDisabled: "Rule disabled.", + groupCreated: "Group created.", + groupUpdated: "Group updated.", + groupDeleted: "Group deleted.", + scheduleCreated: "Scheduled task created.", + scheduleUpdated: "Scheduled task updated.", + scheduleDeleted: "Scheduled task deleted.", + scheduleEnabled: "Scheduled task enabled.", + scheduleDisabled: "Scheduled task disabled.", + generateSubmitted: "Generation task submitted.", + missingTaskName: "Enter a task name first.", + missingPromptRule: "Choose a prompt first.", + invalidGenerateCount: "Article count must be at least 1.", + invalidScheduleTime: "Choose a valid schedule time.", + missingPromptName: "Enter a prompt name first.", + missingPromptContent: "Enter prompt content first.", + }, + }, featureStub: { eyebrow: "Feature Placeholder", currentStatus: "Current status", diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index 91abbe2..5d93a09 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -22,6 +22,7 @@ const zhCN = { previous: "上一步", submit: "提交", submitGenerate: "提交生成", + back: "返回", actions: "操作", title: "标题", description: "描述", @@ -91,11 +92,19 @@ const zhCN = { }, templates: { title: "模版创作", - description: "筛选已有文章、选择模版并发起新一轮内容生成。", + description: "选择合适的模版或使用批量功能,快速生成GEO内容。", + }, + wizard: { + title: "模版向导", + description: "按模版驱动的三步流程完成信息填写、结构确认与文章生成。", + }, + articleEditor: { + title: "文章编辑", + description: "在独立编辑页中调整标题、正文和发布配置。", }, custom: { title: "自定义生成", - description: "当前仓库还没有匹配的后端接口,页面暂保持为设计占位。", + description: "基于自定义 Prompt 规则灵活生成文章,支持即时与定时任务。", }, optimize: { title: "文章优化", @@ -206,13 +215,17 @@ const zhCN = { publishStatus: "发布状态", generateStatus: "生成状态", keyword: "搜索文章", + generationTime: "生成时间", + generationTimeStart: "开始时间", + generationTimeEnd: "结束时间", keywordPlaceholder: "请输入标题内容搜索", - unsupportedDate: "当前后端尚未提供按时间范围筛选", }, list: { count: "共 {count} 篇文章", empty: "还未创建文章,暂无相关数据~", preview: "预览正文", + edit: "编辑文章", + editDisabled: "只有草稿、生成失败或已完成文章才可编辑", versions: "查看版本", deleteConfirm: "确认删除这篇文章吗?", deleteSuccess: "文章已删除", @@ -228,20 +241,29 @@ const zhCN = { basic: "基本信息", basicDesc: "语言、模版字段与品牌上下文", structure: "文章结构", - structureDesc: "标题方案、关键要点与结构段落", + structureDesc: "标题方案与结构段落", generate: "生成文章", - generateDesc: "确认参数并提交生成任务", + generateDesc: "确认大纲、补充关键要点并提交生成任务", }, sections: { templateFields: "模版字段", brandContext: "品牌上下文", language: "文章语言", brandInfo: "品牌信息", + brandLibrary: "品牌库", + brandName: "品牌名称", + website: "官网地址", + brandSummary: "AI 品牌摘要", keywords: "关键词", competitors: "竞品列表", chooseTitle: "选择文章标题", customTitle: "自定义标题", + reviewIntroHook: "评测引言钩子", structure: "文章结构", + customOutline: "自定义结构", + preview: "文章预览", + generatedOutline: "确认文章大纲", + outlinePreview: "大纲预览", keyPoints: "文章关键要点", review: "提交前确认", notes: "生成说明", @@ -249,11 +271,36 @@ const zhCN = { }, hints: { brandContext: "可选。选择后会自动带出关键词与竞品信息。", + brandFlow: "品牌可直接输入,也可以从品牌库带入。点击分析后,AI 会补充关键词、竞品和标题建议。", + brandSummaryPlaceholder: "AI 分析后会在这里补充品牌简介、差异点和适合切入的内容角度。", + keywords: "支持直接输入,也支持复用品牌词库里的关键词。AI 分析结果会自动补充到这里。", + competitors: "竞品支持手动编辑、追加和收藏到品牌词库。AI 分析会尽量补齐候选竞品。", + competitorEmptyEditable: "还没有竞品,先手动添加一条或点击分析让 AI 自动补齐。", + templateFields: "这里保留当前模板自己的补充字段,用来约束 AI 生成角度。", + titleSelection: "下一步前会由 AI 给出 5 个更合适的标题候选,你可以在这里挑选或改写。", + reviewIntroHook: "选择一种开场方式,AI 会按这个评测引言风格组织文章开头。", + structure: "保留文章默认结构,也可以按本次出稿需求删减。", + preview: "右侧会根据当前标题和结构实时预览最终文章框架。", + previewEmpty: "选择结构后,这里会实时预览文章框架。", + generatedOutline: "AI 会先生成完整大纲,你可以继续编辑、补充或删除节点后再正式生成文章。", + outlinePreview: "右侧会根据当前大纲实时预览最终文章框架。", + outlineEmpty: "生成大纲后,这里会显示可编辑的文章结构。", + review: "确认无误后提交,文章会进入生成队列。", competitorEmpty: "当前品牌下暂无竞品数据", customTitle: "如需手动改写,可在这里输入最终标题", keyPoints: "补充希望文章强调的卖点、观点或风格要求", async: "后端会立即创建文章和任务记录,再异步完成正文生成。提交成功后,列表会自动刷新。", }, + actions: { + analyze: "分析", + addCompetitor: "添加竞品", + addSection: "添加结构", + addOutlineNode: "添加节点", + addOutlineChild: "添加子节点", + favorite: "收藏", + saved: "已收藏", + submit: "提交并生成", + }, review: { templateName: "模版名称", articleTitle: "文章标题", @@ -268,12 +315,70 @@ const zhCN = { zh: "中文简体", en: "English", }, + table: { + website: "网址", + name: "名称", + description: "简介", + actions: "操作", + }, + placeholders: { + brandLibrary: "从品牌库选择已有品牌", + brandName: "输入品牌名,或在选择品牌后继续微调", + website: "输入官网地址,选填", + keywords: "输入关键词后回车,可多选", + customTitle: "如需覆盖 AI 标题,可在这里直接输入", + customOutline: "补充一个额外结构,例如“选购建议”", + keyPoints: "告诉 AI 还需要强调哪些要点、风格或限制条件", + competitorWebsite: "官网或落地页地址", + competitorName: "竞品名称", + competitorDescription: "一句话说明竞品定位或特点", + outlineNode: "请输入标题", + }, promptVisible: "Prompt 可见", promptSealed: "Prompt 已封装", + assist: { + analyzeTitle: "AI 正在分析关键词和竞品,请稍后…", + analyzeTitleNoCompetitors: "AI 正在分析关键词和评测上下文,请稍后…", + titleTitle: "AI 正在生成标题,请稍后…", + outlineTitle: "AI 正在生成文章大纲,请稍后…", + analyzeStages: { + brand: "品牌信息分析中…", + keywords: "关键词提炼中…", + competitors: "竞品与官网信息补充中…", + context: "评测上下文整理中…", + }, + titleStages: { + context: "正在整理当前内容上下文…", + strategy: "正在结合关键词和竞品生成标题策略…", + strategyNoCompetitors: "正在结合关键词生成标题策略…", + finalize: "正在输出 5 个标题候选…", + }, + outlineStages: { + context: "正在整理标题、关键词和竞品上下文…", + contextNoCompetitors: "正在整理标题、关键词和评测上下文…", + structure: "正在根据已选结构生成文章大纲…", + finalize: "正在输出可编辑的大纲结果…", + }, + }, messages: { requiredField: "请先填写 {field}", + missingBrand: "请先选择品牌库对象或输入品牌名称", missingTitle: "请先选择或输入文章标题", + missingReviewIntroHook: "请先选择一个评测引言钩子", missingOutline: "请至少选择一个文章结构段落", + missingGeneratedOutline: "请先生成并确认文章大纲", + missingCompetitorName: "至少需要填写竞品名称后才能收藏", + savedCompetitor: "竞品已收藏到品牌词库", + assistReady: "AI 分析已完成,可以继续调整内容。", + assistFailed: "AI 分析失败,请稍后重试。", + assistTimeout: "AI 分析超时,请稍后重试。", + titleFailed: "标题生成失败,请稍后重试。", + titleTimeout: "标题生成超时,请稍后重试。", + outlineFailed: "大纲生成失败,请稍后重试。", + outlineTimeout: "大纲生成超时,请稍后重试。", + draftSaved: "草稿已保存,已返回模版创作列表", + draftSaveFailed: "草稿保存失败,请稍后重试。", + queued: "任务已提交,正在排队生成", submitted: "生成任务已提交,正在刷新文章列表。", }, }, @@ -284,6 +389,31 @@ const zhCN = { versionHistory: "版本记录", untitled: "未命名文章", noContent: "当前文章还没有正文内容", + editor: { + publish: "发布", + titlePlaceholder: "请输入文章标题", + leaveConfirm: { + title: "当前文章还没保存", + description: "你有未保存的改动。现在离开会丢失本次编辑内容,是否需要先保存再返回?", + discard: "放弃更改", + saveAndLeave: "保存并返回", + }, + platformsTitle: "发布平台", + platformsHint: "这里会复用统一的发布平台组件,当前先展示前端占位的已绑定平台。", + coverTitle: "封面图", + coverHint: "发布前可先上传封面素材,当前只做本地预览。", + coverUpload: "上传封面图", + imagePrompt: "请输入图片地址", + messages: { + saved: "文章已保存", + generating: "文章还在生成中,请稍后再编辑。", + locked: "只有已完成文章才可编辑。", + publishPending: "发布能力待接入,当前仅保存正文内容。", + }, + platforms: { + zhihu: "知乎", + }, + }, meta: { templateType: "模版类型", currentVersion: "当前版本", @@ -350,6 +480,133 @@ const zhCN = { chooseKeyword: "请先选择关键词", }, }, + custom: { + eyebrow: "Custom Generation", + title: "自定义生成", + description: "基于自定义 Prompt 规则灵活生成文章,支持即时与定时任务。", + cards: { + instantTitle: "即时生成内容", + instantDesc: "快速自定义内容创作任务", + scheduleTitle: "定时计划生成", + scheduleDesc: "配置自动化内容生成计划", + }, + tabs: { + articles: "生成文章", + instantTasks: "即时任务", + scheduleTasks: "定时任务", + promptRules: "Prompt 规则", + }, + promptRule: { + name: "规则名称", + namePlaceholder: "请输入 Prompt 名称", + content: "Prompt 内容", + scene: "适用场景", + tone: "默认语气", + wordCount: "默认字数", + wordCountHint: "主流大语言模型单次长文本输出普遍存在限制,为了保证生成内容质量,最大建议字数不超过 6000 字。", + status: "状态", + articleCount: "关联文章数", + createTitle: "新建 Prompt 规则", + editTitle: "编辑 Prompt 规则", + deleteConfirm: "确定删除该规则?", + enabled: "启用", + disabled: "停用", + ungrouped: "未分组", + groupPlaceholder: "请选择 Prompt 分组", + contentPlaceholder: "输入 Prompt 内容,支持变量占位符", + }, + task: { + basicSection: "基础信息", + advancedSection: "高级设置", + name: "任务名称", + namePlaceholder: "简单易懂的名称更方便区分哦", + prompt: "选择Prompt", + promptPlaceholder: "请选择Prompt", + createPrompt: "新建Prompt", + platforms: "发布平台", + platformsHint: "请选择需要发布的平台,支持多选", + platformConnected: "已绑定", + platformUnauthorized: "未授权", + platformEmptyTitle: "发布平台接入中", + platformEmptyHint: "媒体平台接口接入后,这里会自动展示当前租户已绑定的平台。", + cover: "封面图", + coverHint: "请保证封面清晰、美观和完整", + coverUpload: "上传封面图", + coverUploadHint: "当前仅做本地预览", + scheduleTime: "定时生成文章", + scheduleEveryDay: "每天", + enableWebSearch: "生成文章联网搜索", + generateCount: "生成篇数", + submit: "创建任务", + }, + group: { + title: "规则分组", + createTitle: "新建分组", + editTitle: "编辑分组", + deleteConfirm: "删除分组后,该组下的规则将移至未分组。确定删除?", + name: "分组名称", + allRules: "全部规则", + }, + schedule: { + name: "任务名称", + rule: "关联规则", + cron: "执行频率", + platform: "目标平台", + nextRun: "下次执行", + startAt: "开始时间", + endAt: "结束时间", + brand: "关联品牌", + createTitle: "新建定时任务", + editTitle: "编辑定时任务", + deleteConfirm: "确定删除该定时任务?", + enabled: "已启用", + disabled: "已停用", + cronOptions: { + daily: "每天一次", + twiceDaily: "每天两次", + weekly: "每周一次", + custom: "自定义 Cron", + }, + }, + instant: { + title: "即时生成", + createTitle: "新建即时任务", + selectRule: "选择 Prompt 规则", + selectBrand: "选择品牌", + selectPlatform: "目标平台", + submit: "立即生成", + extraParams: "额外参数", + }, + filters: { + promptRule: "Prompt 规则", + publishStatus: "发布状态", + generateStatus: "生成状态", + title: "标题", + generateTime: "生成时间", + }, + messages: { + ruleCreated: "规则创建成功", + ruleUpdated: "规则更新成功", + ruleDeleted: "规则删除成功", + ruleEnabled: "规则已启用", + ruleDisabled: "规则已停用", + groupCreated: "分组创建成功", + groupUpdated: "分组更新成功", + groupDeleted: "分组删除成功", + scheduleCreated: "定时任务创建成功", + scheduleUpdated: "定时任务更新成功", + scheduleDeleted: "定时任务删除成功", + scheduleEnabled: "定时任务已启用", + scheduleDisabled: "定时任务已停用", + generateSubmitted: "生成任务已提交", + missingTaskName: "请先填写任务名称", + missingPromptRule: "请先选择 Prompt", + invalidGenerateCount: "生成篇数至少为 1", + invalidScheduleTime: "请选择有效的定时时间", + missingPromptName: "请先填写 Prompt 名称", + missingPromptContent: "请先填写 Prompt 内容", + }, + }, featureStub: { eyebrow: "Feature Placeholder", currentStatus: "当前状态", diff --git a/apps/admin-web/src/layouts/AppShell.vue b/apps/admin-web/src/layouts/AppShell.vue index a80a6ba..565777e 100644 --- a/apps/admin-web/src/layouts/AppShell.vue +++ b/apps/admin-web/src/layouts/AppShell.vue @@ -148,7 +148,7 @@ async function handleLogout(): Promise { - +

{{ pageTitle }}

@@ -218,8 +218,19 @@ async function handleLogout(): Promise { } .admin-sider { + overflow: auto; + height: 100vh; + position: fixed; + left: 0; + top: 0; + bottom: 0; background: #fff; border-right: 1px solid #f0f0f0; + z-index: 10; +} + +.admin-main-layout { + margin-left: 250px; } .admin-brand { @@ -243,6 +254,8 @@ async function handleLogout(): Promise { } .admin-header { + position: sticky; + top: 0; background: #fff; padding: 0 24px; display: flex; diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 3b7c058..d9a3d48 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -9,24 +9,48 @@ import type { BrandRequest, Competitor, CompetitorRequest, + GenerateFromRuleRequest, + GenerateFromRuleResponse, JsonValue, Keyword, KeywordRequest, LoginRequest, LoginResponse, + PromptRule, + PromptRuleGroup, + PromptRuleGroupRequest, + PromptRuleListParams, + PromptRuleListResponse, + PromptRuleRequest, + PromptRuleSimple, + PromptRuleStatusRequest, QuotaSummary, Question, QuestionRequest, QuestionVersion, RecentArticle, RefreshResponse, + ScheduleTask, + ScheduleTaskListParams, + ScheduleTaskListResponse, + ScheduleTaskRequest, + ScheduleTaskStatusRequest, TemplateDetail, + TemplateAnalyzeTaskRequest, + TemplateAnalyzeTaskResultResponse, + TemplateAssistTaskCreateResponse, + TemplateOutlineTaskRequest, + TemplateOutlineTaskResultResponse, + TemplateSaveDraftRequest, + TemplateSaveDraftResponse, + TemplateTitleTaskRequest, + TemplateTitleTaskResultResponse, TemplateGenerateRequest, TemplateGenerateResponse, TemplateListItem, - TemplateSchema, TemplateCard, UpdateQuestionRequest, + UpdateArticleRequest, UserInfo, WorkspaceOverview, } from "@geo/shared-types"; @@ -98,8 +122,47 @@ export const templatesApi = { detail(id: number) { return apiClient.get(`/api/tenant/templates/${id}`); }, - schema(id: number) { - return apiClient.get(`/api/tenant/templates/${id}/schema`); + createAnalyzeTask(id: number, payload: TemplateAnalyzeTaskRequest) { + return apiClient.post( + `/api/tenant/templates/${id}/analyze-tasks`, + payload, + ); + }, + getAnalyzeTaskResult(id: number, taskId: string) { + return apiClient.get( + `/api/tenant/templates/${id}/analyze_task_result`, + { params: { task_id: taskId } }, + ); + }, + createTitleTask(id: number, payload: TemplateTitleTaskRequest) { + return apiClient.post( + `/api/tenant/templates/${id}/title-tasks`, + payload, + ); + }, + getTitleTaskResult(id: number, taskId: string) { + return apiClient.get( + `/api/tenant/templates/${id}/gen_title_task_result`, + { params: { task_id: taskId } }, + ); + }, + createOutlineTask(id: number, payload: TemplateOutlineTaskRequest) { + return apiClient.post( + `/api/tenant/templates/${id}/outline-tasks`, + payload, + ); + }, + getOutlineTaskResult(id: number, taskId: string) { + return apiClient.get( + `/api/tenant/templates/${id}/gen_outline_task_result`, + { params: { task_id: taskId } }, + ); + }, + createAssistTask(id: number, payload: TemplateAnalyzeTaskRequest) { + return this.createAnalyzeTask(id, payload); + }, + getAssistTask(id: number, taskId: string) { + return this.getAnalyzeTaskResult(id, taskId); }, generate(id: number, payload: TemplateGenerateRequest) { return apiClient.post( @@ -107,6 +170,12 @@ export const templatesApi = { payload, ); }, + saveDraft(id: number, payload: TemplateSaveDraftRequest) { + return apiClient.post( + `/api/tenant/templates/${id}/drafts`, + payload, + ); + }, }; export const articlesApi = { @@ -116,6 +185,9 @@ export const articlesApi = { detail(id: number) { return apiClient.get(`/api/tenant/articles/${id}`); }, + update(id: number, payload: UpdateArticleRequest) { + return apiClient.put(`/api/tenant/articles/${id}`, payload); + }, versions(id: number) { return apiClient.get(`/api/tenant/articles/${id}/versions`); }, @@ -270,10 +342,82 @@ export const brandsApi = { }, }; +export const promptRulesApi = { + list(params?: PromptRuleListParams) { + return apiClient.get("/api/tenant/prompt-rules", { params }); + }, + listSimple() { + return apiClient.get("/api/tenant/prompt-rules/simple"); + }, + detail(id: number) { + return apiClient.get(`/api/tenant/prompt-rules/${id}`); + }, + create(payload: PromptRuleRequest) { + return apiClient.post("/api/tenant/prompt-rules", payload); + }, + update(id: number, payload: PromptRuleRequest) { + return apiClient.put(`/api/tenant/prompt-rules/${id}`, payload); + }, + remove(id: number) { + return apiClient.remove(`/api/tenant/prompt-rules/${id}`); + }, + toggleStatus(id: number, payload: PromptRuleStatusRequest) { + return apiClient.put(`/api/tenant/prompt-rules/${id}/status`, payload); + }, + listGroups() { + return apiClient.get("/api/tenant/prompt-rules/groups"); + }, + createGroup(payload: PromptRuleGroupRequest) { + return apiClient.post("/api/tenant/prompt-rules/groups", payload); + }, + updateGroup(id: number, payload: PromptRuleGroupRequest) { + return apiClient.put(`/api/tenant/prompt-rules/groups/${id}`, payload); + }, + removeGroup(id: number) { + return apiClient.remove(`/api/tenant/prompt-rules/groups/${id}`); + }, +}; + +export const schedulesApi = { + list(params?: ScheduleTaskListParams) { + return apiClient.get("/api/tenant/schedules", { params }); + }, + detail(id: number) { + return apiClient.get(`/api/tenant/schedules/${id}`); + }, + create(payload: ScheduleTaskRequest) { + return apiClient.post("/api/tenant/schedules", payload); + }, + update(id: number, payload: ScheduleTaskRequest) { + return apiClient.put(`/api/tenant/schedules/${id}`, payload); + }, + remove(id: number) { + return apiClient.remove(`/api/tenant/schedules/${id}`); + }, + toggleStatus(id: number, payload: ScheduleTaskStatusRequest) { + return apiClient.put(`/api/tenant/schedules/${id}/status`, payload); + }, +}; + +export const generateApi = { + fromRule(payload: GenerateFromRuleRequest) { + return apiClient.post( + "/api/tenant/articles/generate-from-rule", + payload, + ); + }, +}; + export function normalizeInputParams( payload: Record, + articleId?: number, + wizardState?: Record, ): TemplateGenerateRequest { - return { input_params: payload }; + return { + article_id: articleId, + input_params: payload, + wizard_state: wizardState, + }; } function consumeSSEBuffer( diff --git a/apps/admin-web/src/lib/errors.ts b/apps/admin-web/src/lib/errors.ts index a85ab0b..60e0a09 100644 --- a/apps/admin-web/src/lib/errors.ts +++ b/apps/admin-web/src/lib/errors.ts @@ -13,6 +13,21 @@ const errorMessageMap: Record = { query_failed: "数据读取失败", quota_insufficient: "生成额度不足", llm_unavailable: "生成服务不可用", + analyze_context_required: "请先填写品牌或模板上下文信息", + title_context_required: "请先确认品牌、关键词或竞品信息", + outline_context_required: "请先确认标题和文章结构后再生成大纲", + analyze_queue_unavailable: "品牌分析任务较多,请稍后重试", + title_queue_unavailable: "标题生成任务较多,请稍后重试", + outline_queue_unavailable: "大纲生成任务较多,请稍后重试", + analyze_task_not_found: "品牌分析任务不存在或已失效", + title_task_not_found: "标题生成任务不存在或已失效", + outline_task_not_found: "文章大纲任务不存在或已失效", + article_generating: "文章仍在生成中", + article_not_editable: "只有已完成文章才可编辑", + draft_not_editable: "只有草稿或生成失败的文章才可继续在向导中编辑", + article_title_required: "请输入文章标题", + invalid_payload: "请求参数不合法", + update_failed: "保存文章失败", network_error: "网络连接失败,请稍后重试", unknown_error: "发生未知错误", }; diff --git a/apps/admin-web/src/lib/publish-platforms.ts b/apps/admin-web/src/lib/publish-platforms.ts new file mode 100644 index 0000000..bda857d --- /dev/null +++ b/apps/admin-web/src/lib/publish-platforms.ts @@ -0,0 +1,72 @@ +export interface PublishPlatformOption { + id: string; + name: string; + shortName: string; + accent: string; + accountLabel?: string; +} + +const platformCatalog: Record> = { + zhihu: { id: "zhihu", name: "知乎", shortName: "知", accent: "#1677ff" }, + baijiahao: { id: "baijiahao", name: "百家号", shortName: "百", accent: "#31445a" }, + toutiao: { id: "toutiao", name: "头条号", shortName: "头", accent: "#f5222d" }, + souhu: { id: "souhu", name: "搜狐号", shortName: "搜", accent: "#fa8c16" }, + qq: { id: "qq", name: "企鹅号", shortName: "Q", accent: "#111827" }, + wechat: { id: "wechat", name: "公众号", shortName: "微", accent: "#13c26b" }, + bilibili: { id: "bilibili", name: "bilibili", shortName: "B", accent: "#eb2f96" }, + jianshu: { id: "jianshu", name: "简书", shortName: "简", accent: "#f56a5e" }, + juejin: { id: "juejin", name: "稀土掘金", shortName: "掘", accent: "#1677ff" }, + smzdm: { id: "smzdm", name: "什么值得买", shortName: "值", accent: "#f5222d" }, + zol: { id: "zol", name: "中关村在线", shortName: "Z", accent: "#ff4d4f" }, + dongchedi: { id: "dongchedi", name: "懂车帝", shortName: "懂", accent: "#fadb14" }, +}; + +const mockBoundPlatformIds = ["zhihu", "wechat", "juejin", "bilibili", "jianshu", "baijiahao"]; + +export function getBoundPublishPlatforms(): PublishPlatformOption[] { + return mockBoundPlatformIds + .map((id) => platformCatalog[id]) + .filter((item): item is Omit => Boolean(item)); +} + +export function getAllPublishPlatforms(): PublishPlatformOption[] { + return Object.values(platformCatalog); +} + +export function isPlatformBound(platformId: string): boolean { + return mockBoundPlatformIds.includes(platformId); +} + +export function serializeTargetPlatforms(platformIds: string[]): string | undefined { + const next = Array.from(new Set(platformIds.map((item) => item.trim()).filter(Boolean))); + return next.length ? next.join(",") : undefined; +} + +export function parseTargetPlatforms(value?: string | null): string[] { + if (!value) { + return []; + } + + return Array.from( + new Set( + value + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + ), + ); +} + +export function getPublishPlatformLabel(platformId: string): string { + return platformCatalog[platformId]?.name ?? platformId; +} + +export function formatPublishPlatformSummary(value?: string | null): string { + const labels = parseTargetPlatforms(value).map(getPublishPlatformLabel); + return labels.length ? labels.join("、") : "--"; +} + +export function formatPublishPlatformList(platformIds?: string[] | null): string { + const labels = (platformIds ?? []).map(getPublishPlatformLabel).filter(Boolean); + return labels.length ? labels.join("、") : "--"; +} diff --git a/apps/admin-web/src/main.ts b/apps/admin-web/src/main.ts index 69b20a5..010efc2 100644 --- a/apps/admin-web/src/main.ts +++ b/apps/admin-web/src/main.ts @@ -12,6 +12,7 @@ import { ConfigProvider, DatePicker, Descriptions, + Divider, Drawer, Dropdown, Empty, @@ -28,12 +29,15 @@ import { Row, Select, Skeleton, + Space, Spin, Statistic, + Switch, Steps, Table, Tabs, Tag, + TimePicker, Tooltip, } from "ant-design-vue"; @@ -55,6 +59,7 @@ const queryClient = new QueryClient({ }); const app = createApp(App); +app.component("ATextarea", Input.TextArea); [ Alert, @@ -68,6 +73,7 @@ const app = createApp(App); ConfigProvider, DatePicker, Descriptions, + Divider, Drawer, Dropdown, Empty, @@ -84,12 +90,15 @@ const app = createApp(App); Row, Select, Skeleton, + Space, Spin, Statistic, + Switch, Steps, Table, Tabs, Tag, + TimePicker, Tooltip, ].forEach((component) => { app.use(component); diff --git a/apps/admin-web/src/router/index.ts b/apps/admin-web/src/router/index.ts index f82f34a..979c6d5 100644 --- a/apps/admin-web/src/router/index.ts +++ b/apps/admin-web/src/router/index.ts @@ -56,10 +56,20 @@ const router = createRouter({ navKey: "/articles/templates", }, }, + { + path: "articles/:id/edit", + name: "article-editor", + component: () => import("@/views/ArticleEditorView.vue"), + meta: { + titleKey: "route.articleEditor.title", + descriptionKey: "route.articleEditor.description", + navKey: "/articles/templates", + }, + }, { path: "articles/custom", name: "articles-custom", - component: () => import("@/views/FeatureStubView.vue"), + component: () => import("@/views/CustomGenerateView.vue"), meta: { titleKey: "route.custom.title", descriptionKey: "route.custom.description", diff --git a/apps/admin-web/src/views/ArticleEditorView.vue b/apps/admin-web/src/views/ArticleEditorView.vue new file mode 100644 index 0000000..5028dee --- /dev/null +++ b/apps/admin-web/src/views/ArticleEditorView.vue @@ -0,0 +1,531 @@ + + + + + diff --git a/apps/admin-web/src/views/BrandsView.vue b/apps/admin-web/src/views/BrandsView.vue index c3ccf1a..b0b0d09 100644 --- a/apps/admin-web/src/views/BrandsView.vue +++ b/apps/admin-web/src/views/BrandsView.vue @@ -661,7 +661,7 @@ async function submitCompetitor(): Promise { - + @@ -691,7 +691,7 @@ async function submitCompetitor(): Promise { /> - + @@ -709,7 +709,7 @@ async function submitCompetitor(): Promise { - + +import { + ThunderboltOutlined, + ClockCircleOutlined, +} from "@ant-design/icons-vue"; +import { ref } from "vue"; +import { useI18n } from "vue-i18n"; + +import CustomArticleTab from "@/components/CustomArticleTab.vue"; +import PromptRuleTab from "@/components/PromptRuleTab.vue"; +import ScheduleTaskTab from "@/components/ScheduleTaskTab.vue"; +import InstantGenerateModal from "@/components/InstantGenerateModal.vue"; +import ScheduleTaskModal from "@/components/ScheduleTaskModal.vue"; + +const { t } = useI18n(); + +const activeTab = ref("articles"); +const instantModalOpen = ref(false); +const scheduleModalOpen = ref(false); + +function openInstantModal(): void { + instantModalOpen.value = true; +} + +function openScheduleModal(): void { + scheduleModalOpen.value = true; +} + + + + + diff --git a/apps/admin-web/src/views/TemplateWizardView.vue b/apps/admin-web/src/views/TemplateWizardView.vue index 847773b..6b22f05 100644 --- a/apps/admin-web/src/views/TemplateWizardView.vue +++ b/apps/admin-web/src/views/TemplateWizardView.vue @@ -1,34 +1,197 @@ diff --git a/apps/admin-web/src/views/TemplatesView.vue b/apps/admin-web/src/views/TemplatesView.vue index 18eb0c1..f455fe2 100644 --- a/apps/admin-web/src/views/TemplatesView.vue +++ b/apps/admin-web/src/views/TemplatesView.vue @@ -1,8 +1,9 @@