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:
@@ -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>
|
||||
Reference in New Issue
Block a user