feat: add tenant and user management with migrations, handlers, and tests

- Implemented tenant and user management features including:
  - Tenant creation and management with associated migrations.
  - User creation and management with associated migrations.
  - Tenant membership management with associated migrations.
  - Platform user roles management with associated migrations.
  - Quota management with associated migrations.
  - Article and template management with associated migrations.
- Added HTTP handlers for templates and workspaces.
- Created tests for protected and public routes.
- Introduced a script to check tenant scope in SQL queries.
- Documented task plan for backend completion and frontend foundation.
This commit is contained in:
2026-04-01 00:58:42 +08:00
commit de30497f59
210 changed files with 23733 additions and 0 deletions
@@ -0,0 +1,563 @@
<script setup lang="ts">
import { LeftOutlined } from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message } from "ant-design-vue";
import type { JsonValue, TemplateSchemaField } from "@geo/shared-types";
import { computed, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { brandsApi, normalizeInputParams, templatesApi } from "@/lib/api";
import { formatError } from "@/lib/errors";
import { getTemplateMeta } from "@/lib/display";
const queryClient = useQueryClient();
const { locale: uiLocale, t } = useI18n();
const route = useRoute();
const router = useRouter();
const currentStep = ref(0);
const articleLocale = ref("zh-CN");
const fieldValues = reactive<Record<string, JsonValue>>({});
const selectedBrandId = ref<number | null>(null);
const selectedKeywordIds = ref<number[]>([]);
const selectedCompetitorIds = ref<number[]>([]);
const selectedTitle = ref("");
const customTitle = ref("");
const keyPoints = ref("");
const outlineSections = ref<string[]>([]);
const templateId = computed(() => Number(route.query.template_id));
const enabled = computed(() => !Number.isNaN(templateId.value) && templateId.value > 0);
const templateDetailQuery = useQuery({
queryKey: computed(() => ["templates", "detail", templateId.value]),
enabled,
queryFn: () => templatesApi.detail(templateId.value),
});
const templateSchemaQuery = useQuery({
queryKey: computed(() => ["templates", "schema", templateId.value]),
enabled,
queryFn: () => templatesApi.schema(templateId.value),
});
const brandsQuery = useQuery({
queryKey: ["brands", "wizard-context"],
enabled,
queryFn: () => brandsApi.list(),
});
const keywordsQuery = useQuery({
queryKey: computed(() => ["brands", selectedBrandId.value, "keywords"]),
enabled: computed(() => enabled.value && Boolean(selectedBrandId.value)),
queryFn: () => brandsApi.listKeywords(selectedBrandId.value as number),
});
const competitorsQuery = useQuery({
queryKey: computed(() => ["brands", selectedBrandId.value, "competitors"]),
enabled: computed(() => enabled.value && Boolean(selectedBrandId.value)),
queryFn: () => brandsApi.listCompetitors(selectedBrandId.value as number),
});
const mutation = useMutation({
mutationFn: (payload: Record<string, JsonValue>) =>
templatesApi.generate(templateId.value, normalizeInputParams(payload)),
onSuccess: async (data) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["articles"] }),
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
queryClient.invalidateQueries({ queryKey: ["templates"] }),
]);
message.info("任务已提交,正在排队生成");
await router.replace("/articles/templates");
},
onError: (error) => {
message.error(formatError(error));
},
});
const templateDetail = computed(() => templateDetailQuery.data.value);
const schemaFields = computed<TemplateSchemaField[]>(() => {
return (
templateSchemaQuery.data.value?.fields ??
templateDetailQuery.data.value?.schema_json?.fields ??
[]
);
});
const selectedBrand = computed(() =>
brandsQuery.data.value?.find((brand) => brand.id === selectedBrandId.value) ?? null,
);
const keywordOptions = computed(() => keywordsQuery.data.value ?? []);
const competitorOptions = computed(() => competitorsQuery.data.value ?? []);
const templateMeta = computed(() => getTemplateMeta(templateDetail.value?.template_key ?? "unknown"));
const titleOptions = computed(() => {
const topic =
(fieldValues.topic as string | undefined) ??
(fieldValues.product_name as string | undefined) ??
selectedBrand.value?.name ??
"GEO";
const count = Number(fieldValues.count ?? 3);
const isEnglish = uiLocale.value === "en-US";
const templateKey = templateDetail.value?.template_key;
if (templateKey === "top_x_article") {
return isEnglish
? [`Top ${count} ${topic} picks worth watching in 2026`, `A sharper shortlist: Top ${count} ${topic} recommendations`]
: [`2026 年最值得关注的 Top ${count} ${topic}`, `高效出稿必备:Top ${count} ${topic} 精选推荐`];
}
if (templateKey === "product_review") {
return isEnglish
? [`${topic} review: strengths, trade-offs, and fit`, `Is ${topic} worth it? A clearer look at the product`]
: [`${topic} 评测:核心能力、优缺点与适合人群`, `${topic} 值不值得选?一次看懂功能表现`];
}
if (templateKey === "research_report") {
return isEnglish
? [`${topic} report: trends, opportunities, and actions`]
: [`${topic} 研究报告:趋势、机会与落地建议`];
}
return isEnglish
? [`Content strategy notes for ${topic}`]
: [`${topic} 相关内容生成建议`];
});
const outlineOptions = computed(() => {
const isEnglish = uiLocale.value === "en-US";
const templateKey = templateDetail.value?.template_key;
if (templateKey === "product_review") {
return isEnglish
? ["Intro", "Overview", "Core Features", "Pros & Cons", "Conclusion"]
: ["引言", "产品概览", "核心特点", "优缺点", "结论"];
}
if (templateKey === "research_report") {
return isEnglish
? ["Summary", "Background", "Key Findings", "Market View", "Conclusion"]
: ["摘要", "研究背景", "关键发现", "市场判断", "结论"];
}
return isEnglish
? ["Intro", "List", "Key Points", "Highlights", "Conclusion"]
: ["引言", "网站列表", "文章关键要点", "特点", "结论"];
});
const finalTitle = computed(() => customTitle.value.trim() || selectedTitle.value.trim());
watch(
[enabled, schemaFields, templateDetail, outlineOptions],
([isOpen, fields, detail, sections]) => {
if (!isOpen || !detail) return;
currentStep.value = 0;
articleLocale.value = "zh-CN";
selectedBrandId.value = null;
selectedKeywordIds.value = [];
selectedCompetitorIds.value = [];
selectedTitle.value = "";
customTitle.value = "";
keyPoints.value = "";
outlineSections.value = [...sections.slice(0, 5)];
for (const key of Object.keys(fieldValues)) delete fieldValues[key];
for (const field of fields) {
if (field.default !== undefined) {
fieldValues[field.name] = field.default;
} else if (field.type === "number") {
fieldValues[field.name] = null;
} else {
fieldValues[field.name] = "";
}
}
},
{ immediate: true },
);
watch(
[titleOptions, enabled],
([options, isOpen]) => {
if (!isOpen) return;
if (!selectedTitle.value && options.length > 0) {
selectedTitle.value = options[0];
}
},
{ immediate: true },
);
watch(selectedBrandId, (value) => {
selectedKeywordIds.value = [];
selectedCompetitorIds.value = [];
const brand = brandsQuery.data.value?.find((item) => item.id === value);
if (brand && (!fieldValues.brand || fieldValues.brand === "")) {
fieldValues.brand = brand.name;
}
});
function normalizeOption(
option: string | number | { label: string; value: string | number },
): { label: string; value: string | number } {
if (typeof option === "string" || typeof option === "number") {
return { label: String(option), value: option };
}
return option;
}
function renderFieldPlaceholder(field: TemplateSchemaField): string {
return field.placeholder || `${t("common.inputPlease")}${field.label}`;
}
function validateStepOne(): boolean {
for (const field of schemaFields.value) {
const value = fieldValues[field.name];
if (field.required && (value === null || value === "" || value === undefined)) {
message.warning(t("templates.wizard.messages.requiredField", { field: field.label }));
return false;
}
}
return true;
}
function validateStepTwo(): boolean {
if (!finalTitle.value) {
message.warning(t("templates.wizard.messages.missingTitle"));
return false;
}
if (outlineSections.value.length === 0) {
message.warning(t("templates.wizard.messages.missingOutline"));
return false;
}
return true;
}
function handleNext(): void {
if (currentStep.value === 0 && !validateStepOne()) return;
if (currentStep.value === 1 && !validateStepTwo()) return;
currentStep.value = Math.min(2, currentStep.value + 1);
}
function handlePrev(): void {
currentStep.value = Math.max(0, currentStep.value - 1);
}
function buildPayload(): Record<string, JsonValue> {
const selectedKeywords = keywordOptions.value
.filter((item) => selectedKeywordIds.value.includes(item.id))
.map((item) => item.name);
const selectedCompetitors = competitorOptions.value
.filter((item) => selectedCompetitorIds.value.includes(item.id))
.map((item) => ({
name: item.name,
website: item.website,
description: item.description,
}));
const payload: Record<string, JsonValue | undefined> = {
...fieldValues,
locale: articleLocale.value,
title: finalTitle.value || undefined,
outline_sections: outlineSections.value,
key_points: keyPoints.value || undefined,
brand_id: selectedBrandId.value ?? undefined,
brand_name: selectedBrand.value?.name ?? undefined,
keyword_ids: selectedKeywordIds.value.length > 0 ? selectedKeywordIds.value : undefined,
keywords: selectedKeywords.length > 0 ? selectedKeywords : undefined,
competitor_ids: selectedCompetitorIds.value.length > 0 ? selectedCompetitorIds.value : undefined,
competitors: selectedCompetitors.length > 0 ? selectedCompetitors : undefined,
};
return Object.fromEntries(
Object.entries(payload).filter(([, value]) => value !== undefined),
) as Record<string, JsonValue>;
}
function handleGenerate(): void {
if (!validateStepOne() || !validateStepTwo()) return;
mutation.mutate(buildPayload());
}
</script>
<template>
<div class="wizard-page">
<div v-if="templateDetailQuery.isPending.value" class="wizard-page__loading">
<a-skeleton active :paragraph="{ rows: 12 }" />
</div>
<template v-else-if="templateDetail">
<!-- Full Page Header -->
<header class="wizard-page__header">
<div class="header-left">
<a-button type="text" shape="circle" @click="router.back()">
<template #icon><LeftOutlined /></template>
</a-button>
<div class="header-title-box">
<h2 class="header-title">创建 "{{ templateDetail.template_name }}" 文章 <span class="header-accent">{{ templateMeta.accent }}</span></h2>
<p class="header-eyebrow">{{ templateMeta.helper }}</p>
</div>
</div>
</header>
<!-- Main Content Block -->
<main class="wizard-page__main">
<!-- Steps Navigator -->
<div class="wizard-steps-container">
<a-steps :current="currentStep" class="wizard-steps" size="small">
<a-step title="基本信息" />
<a-step title="文章结构" />
<a-step title="生成文章" />
</a-steps>
</div>
<section v-if="currentStep === 0" class="wizard-section">
<!-- Form layout matching design -->
<div class="field-item">
<label class="required-asterisk">语言版本选择</label>
<a-select
v-model:value="articleLocale"
style="width: 200px"
:options="[
{ label: '中文简体', value: 'zh-CN' },
{ label: 'English', value: 'en-US' }
]"
/>
</div>
<div class="field-item">
<label class="required-asterisk">品牌信息</label>
<div style="display: flex; gap: 16px; align-items: flex-start;">
<a-select
v-model:value="selectedBrandId"
allow-clear
show-search
style="width: 240px"
:placeholder="'选择已有品牌对象'"
:options="(brandsQuery.data.value || []).map((item) => ({ label: item.name, value: item.id }))"
/>
<a-input
v-model:value="fieldValues.brand"
placeholder="品牌相关词汇"
style="width: 240px"
/>
<a-button type="primary" ghost>分析</a-button>
</div>
<p class="hint-text"> 品牌建议描述{{ selectedBrand?.description || "自动补充产品优势..." }}</p>
</div>
<div class="field-item">
<label class="required-asterisk">关键词</label>
<a-select
v-model:value="selectedKeywordIds"
mode="multiple"
:disabled="!selectedBrandId"
placeholder="请接入关键词"
style="max-width: 480px"
:options="keywordOptions.map((item) => ({ label: item.name, value: item.id }))"
/>
</div>
<!-- Dynamic Template Fields placed here seamlessly -->
<div v-for="field in schemaFields" :key="field.name" class="field-item">
<label :class="{ 'required-asterisk': field.required }">{{ field.label }}</label>
<div style="max-width: 480px">
<a-input v-if="field.type === 'text'" v-model:value="fieldValues[field.name]" :placeholder="renderFieldPlaceholder(field)" />
<a-input-number v-else-if="field.type === 'number'" v-model:value="fieldValues[field.name]" :placeholder="renderFieldPlaceholder(field)" style="width: 100%" :min="1" />
<a-select v-else-if="field.type === 'select'" v-model:value="fieldValues[field.name]" :options="(field.options || []).map(normalizeOption)" style="width: 100%" :placeholder="renderFieldPlaceholder(field)" />
<a-input v-else v-model:value="fieldValues[field.name]" :placeholder="renderFieldPlaceholder(field)" />
</div>
</div>
<div class="field-item">
<label class="required-asterisk">竞品列表</label>
<div class="competitor-list-header" style="margin-bottom: 12px; display: flex; gap: 12px; align-items: center">
<a-button>+ 批量添加</a-button>
<span class="hint-text">智能提取竞品 试试 Ai 探索 竞品库</span>
</div>
<!-- In flat design visually this is a table -->
<div class="competitor-table-view">
<div class="cth">
<div class="ch">网址</div><div class="ch">名称</div><div class="ch">简介</div><div class="ch" style="text-align: right">操作</div>
</div>
<div class="ctb" v-if="competitorOptions.length">
<div v-for="competitor in competitorOptions" :key="competitor.id" class="ctr">
<div class="cd"><a-checkbox v-model:checked="selectedCompetitorIds" :value="competitor.id">{{ competitor.website || "--" }}</a-checkbox></div>
<div class="cd">{{ competitor.name }}</div>
<div class="cd">{{ competitor.description || "--" }}</div>
<div class="cd" style="text-align: right; color: #1677ff">编辑 | 删除</div>
</div>
</div>
<div style="padding: 16px; text-align: center; color: #8c8c8c" v-else>
暂无竞品数据
</div>
</div>
</div>
</section>
<!-- Step 2: Structure -->
<section v-else-if="currentStep === 1" class="wizard-section">
<div class="field-item">
<label class="required-asterisk">标题选择</label>
<a-radio-group v-model:value="selectedTitle" class="title-options">
<div v-for="option in titleOptions" :key="option" style="margin-bottom: 8px;">
<a-radio :value="option">{{ option }}</a-radio>
</div>
</a-radio-group>
</div>
<div class="field-item" style="margin-top: 24px">
<label>自定义标题 (可选)</label>
<a-input v-model:value="customTitle" style="max-width: 480px;" placeholder="覆盖上方选择的生成标题" />
</div>
<div class="field-item" style="margin-top: 24px">
<label class="required-asterisk">文章大纲调整</label>
<a-checkbox-group v-model:value="outlineSections">
<a-checkbox v-for="section in outlineOptions" :key="section" :value="section" style="margin-right: 16px; margin-bottom: 8px;">
{{ section }}
</a-checkbox>
</a-checkbox-group>
</div>
<div class="field-item" style="margin-top: 24px">
<label>额外要点说明</label>
<a-input-text-area v-model:value="keyPoints" :rows="4" style="max-width: 600px;" placeholder="告诉 AI 还需要注意哪些信息点 (选填)" />
</div>
</section>
<!-- Step 3: Generating Confirm -->
<section v-else class="wizard-section">
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="生成模板">{{ templateDetail.template_name }}</a-descriptions-item>
<a-descriptions-item label="选用标题">{{ finalTitle || "--" }}</a-descriptions-item>
<a-descriptions-item label="关联品牌">{{ selectedBrand?.name || "--" }}</a-descriptions-item>
<a-descriptions-item label="拟定大纲">{{ outlineSections.join(" / ") || "--" }}</a-descriptions-item>
</a-descriptions>
<div style="margin-top: 24px;">
<a-alert type="info" show-icon message="提交后云端 AI 代理将会接管生成任务,耗时约 5-10 分钟。您可以离开中途返回查看进度。" />
</div>
</section>
</main>
<!-- Fixed Bottom Action Bar -->
<footer class="wizard-page__footer">
<div>
<a-button v-if="currentStep === 0" disabled>上一步</a-button>
<a-button v-else @click="handlePrev">上一步</a-button>
</div>
<div style="display: flex; gap: 12px">
<a-button @click="router.back()">取消</a-button>
<a-button v-if="currentStep < 2" type="primary" @click="handleNext">下一步</a-button>
<a-button v-else type="primary" :loading="mutation.isPending.value" @click="handleGenerate">提交并生成</a-button>
</div>
</footer>
</template>
</div>
</template>
<style scoped>
.wizard-page {
background: #f8fafc;
min-height: calc(100vh - 64px);
position: relative;
padding-bottom: 80px; /* footer offset */
}
.wizard-page__loading { padding: 40px; }
.wizard-page__header {
background: #ffffff;
padding: 16px 24px;
border-bottom: 1px solid #f0f3fa;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-title-box { display: flex; flex-direction: column; }
.header-title { font-size: 18px; font-weight: 700; margin: 0; color: #141414; }
.header-accent { color: #1677ff; font-weight: 400; font-size: 14px; margin-left: 8px;}
.header-eyebrow { margin: 0; color: #8c8c8c; font-size: 12px; margin-top: 4px; }
.wizard-page__main {
background: #ffffff;
margin: 24px;
border-radius: 12px;
box-shadow: 0 1px 4px rgba(0,0,0,0.02);
padding: 32px 40px;
min-height: 500px;
}
.wizard-steps-container {
max-width: 600px;
margin-bottom: 40px;
}
.wizard-steps :deep(.ant-steps-item-title) {
font-size: 14px;
}
.wizard-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.field-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.field-item > label {
font-weight: 600;
color: #1a1a1a;
font-size: 14px;
margin-bottom: 4px;
}
.required-asterisk::before {
content: "*";
color: #ff4d4f;
margin-right: 4px;
}
.hint-text {
color: #8c8c8c;
font-size: 12px;
margin: 0;
}
/* Custom Table for competitors */
.competitor-table-view {
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fafafa;
overflow: hidden;
}
.cth { display: flex; background: #f5f5f5; padding: 12px 16px; font-weight: 500; font-size: 13px; color: #595959; }
.cth .ch { flex: 1; }
.ctb .ctr { display: flex; border-top: 1px solid #f0f0f0; padding: 12px 16px; font-size: 13px; background: #fff; }
.ctb .ctr .cd { flex: 1; word-break: break-all; padding-right: 8px; }
/* Fixed Footer */
.wizard-page__footer {
position: fixed;
bottom: 0;
left: 250px; /* Sider offset */
right: 0;
background: #ffffff;
border-top: 1px solid #f0f3fa;
padding: 16px 40px;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 10;
box-shadow: 0 -2px 8px rgba(0,0,0,0.03);
}
@media (max-width: 768px) {
.wizard-page__footer { left: 0; }
}
</style>