feat: Refactor template handling and brand management

- Removed schema_json from article templates and related queries.
- Updated brand service to include website field in requests and responses.
- Simplified template wizard view by eliminating unused schema fields and related logic.
- Added tests for ark text format handling.
- Created migrations for dropping schema_json and adding website to brands.
This commit is contained in:
2026-04-02 11:38:08 +08:00
parent 7fa1809682
commit 111498a65f
25 changed files with 298 additions and 379 deletions
+1 -1
View File
@@ -7,4 +7,4 @@ dist/
.env.local .env.local
apps/*/dist/ apps/*/dist/
apps/*/node_modules/ apps/*/node_modules/
.claude
@@ -447,6 +447,7 @@ const enUS = {
}, },
form: { form: {
brandName: "Brand name", brandName: "Brand name",
brandWebsite: "Company website (optional)",
brandDescription: "Brand description", brandDescription: "Brand description",
keywordName: "Keyword", keywordName: "Keyword",
questionText: "Question text", questionText: "Question text",
@@ -454,6 +454,7 @@ const zhCN = {
}, },
form: { form: {
brandName: "品牌名称", brandName: "品牌名称",
brandWebsite: "公司官网(选填)",
brandDescription: "品牌描述", brandDescription: "品牌描述",
keywordName: "关键词名称", keywordName: "关键词名称",
questionText: "问题内容", questionText: "问题内容",
+122 -57
View File
@@ -12,7 +12,6 @@ import type { TableColumnsType } from "ant-design-vue";
import { computed, reactive, ref, watch } from "vue"; import { computed, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import PageHero from "@/components/PageHero.vue";
import { brandsApi } from "@/lib/api"; import { brandsApi } from "@/lib/api";
import { formatError } from "@/lib/errors"; import { formatError } from "@/lib/errors";
@@ -36,6 +35,7 @@ const editingCompetitorId = ref<number | null>(null);
const brandForm = reactive({ const brandForm = reactive({
name: "", name: "",
website: "",
description: "", description: "",
}); });
@@ -60,12 +60,6 @@ const brandListQuery = useQuery({
queryFn: () => brandsApi.list(), queryFn: () => brandsApi.list(),
}); });
const brandDetailQuery = useQuery({
queryKey: computed(() => ["brands", "detail", selectedBrandId.value]),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () => brandsApi.detail(selectedBrandId.value as number),
});
const keywordsQuery = useQuery({ const keywordsQuery = useQuery({
queryKey: computed(() => ["brands", selectedBrandId.value, "keywords"]), queryKey: computed(() => ["brands", selectedBrandId.value, "keywords"]),
enabled: computed(() => Boolean(selectedBrandId.value)), enabled: computed(() => Boolean(selectedBrandId.value)),
@@ -101,6 +95,7 @@ const brandMutations = {
mutationFn: () => mutationFn: () =>
brandsApi.create({ brandsApi.create({
name: brandForm.name.trim(), name: brandForm.name.trim(),
website: brandForm.website.trim() || null,
description: brandForm.description.trim() || null, description: brandForm.description.trim() || null,
}), }),
onSuccess: async (brand) => { onSuccess: async (brand) => {
@@ -118,6 +113,7 @@ const brandMutations = {
mutationFn: () => mutationFn: () =>
brandsApi.update(editingBrandId.value as number, { brandsApi.update(editingBrandId.value as number, {
name: brandForm.name.trim(), name: brandForm.name.trim(),
website: brandForm.website.trim() || null,
description: brandForm.description.trim() || null, description: brandForm.description.trim() || null,
}), }),
onSuccess: async () => { onSuccess: async () => {
@@ -274,7 +270,6 @@ const competitorMutations = {
}), }),
}; };
const selectedBrand = computed(() => brandDetailQuery.data.value);
const selectedKeyword = computed(() => const selectedKeyword = computed(() =>
keywordsQuery.data.value?.find((item) => item.id === selectedKeywordId.value) ?? null, keywordsQuery.data.value?.find((item) => item.id === selectedKeywordId.value) ?? null,
); );
@@ -345,6 +340,7 @@ function stringifyProductLines(value: unknown): string {
function openBrandModal(brand?: Brand): void { function openBrandModal(brand?: Brand): void {
editingBrandId.value = brand?.id ?? null; editingBrandId.value = brand?.id ?? null;
brandForm.name = brand?.name ?? ""; brandForm.name = brand?.name ?? "";
brandForm.website = brand?.website ?? "";
brandForm.description = brand?.description ?? ""; brandForm.description = brand?.description ?? "";
brandModalOpen.value = true; brandModalOpen.value = true;
} }
@@ -439,18 +435,21 @@ async function submitCompetitor(): Promise<void> {
<template> <template>
<div class="brands-view"> <div class="brands-view">
<PageHero <section class="brands-view__top-card">
:eyebrow="t('brands.eyebrow')" <div class="brands-view__header">
:title="t('brands.title')" <div class="brands-view__header-title">
:description="t('brands.description')" <p class="eyebrow" style="margin-bottom: 4px;">{{ t('brands.eyebrow') }}</p>
> <h2>{{ t('brands.title') }}</h2>
<template #actions> <p>{{ t('brands.description') }}</p>
<a-button type="primary" @click="openBrandModal()"> </div>
<template #icon><PlusOutlined /></template> <div class="brands-view__header-actions">
{{ t("brands.newBrand") }} <a-button type="primary" @click="openBrandModal()">
</a-button> <template #icon><PlusOutlined /></template>
</template> {{ t("brands.newBrand") }}
</PageHero> </a-button>
</div>
</div>
</section>
<section class="brands-view__brand-rail"> <section class="brands-view__brand-rail">
<div class="brands-view__section-head"> <div class="brands-view__section-head">
@@ -491,19 +490,7 @@ async function submitCompetitor(): Promise<void> {
<a-empty v-else :description="t('brands.empty.brands')" /> <a-empty v-else :description="t('brands.empty.brands')" />
</section> </section>
<template v-if="selectedBrandId && selectedBrand"> <template v-if="selectedBrandId">
<section class="brands-view__brand-summary">
<div>
<p class="eyebrow">{{ t("brands.title") }}</p>
<h3>{{ selectedBrand.name }}</h3>
<p>{{ selectedBrand.description || "--" }}</p>
</div>
<div class="brands-view__brand-summary-actions">
<a-button @click="openBrandModal(selectedBrand)">{{ t("brands.editBrand") }}</a-button>
<a-button @click="activeTab = 'competitors'">{{ t("brands.tabs.competitors") }}</a-button>
</div>
</section>
<a-tabs v-model:activeKey="activeTab" class="brands-view__tabs"> <a-tabs v-model:activeKey="activeTab" class="brands-view__tabs">
<a-tab-pane key="keywords" :tab="t('brands.tabs.keywords')"> <a-tab-pane key="keywords" :tab="t('brands.tabs.keywords')">
<section class="brands-view__keyword-layout"> <section class="brands-view__keyword-layout">
@@ -660,6 +647,9 @@ async function submitCompetitor(): Promise<void> {
<a-form-item :label="t('brands.form.brandName')"> <a-form-item :label="t('brands.form.brandName')">
<a-input v-model:value="brandForm.name" /> <a-input v-model:value="brandForm.name" />
</a-form-item> </a-form-item>
<a-form-item :label="t('brands.form.brandWebsite')">
<a-input v-model:value="brandForm.website" />
</a-form-item>
<a-form-item :label="t('brands.form.brandDescription')"> <a-form-item :label="t('brands.form.brandDescription')">
<a-textarea v-model:value="brandForm.description" :rows="4" /> <a-textarea v-model:value="brandForm.description" :rows="4" />
</a-form-item> </a-form-item>
@@ -745,15 +735,47 @@ async function submitCompetitor(): Promise<void> {
gap: 22px; gap: 22px;
} }
.brands-view__top-card,
.brands-view__brand-rail, .brands-view__brand-rail,
.brands-view__brand-summary,
.brands-view__keywords-panel, .brands-view__keywords-panel,
.brands-view__questions-panel, .brands-view__questions-panel,
.brands-view__competitors-panel { .brands-view__competitors-panel {
padding: 24px; padding: 24px;
background: #fff; background: #fff;
border: 1px solid #e6edf5; border: 1px solid #e6edf5;
border-radius: 24px; border-radius: 12px;
overflow: hidden;
}
.brands-view__top-card {
padding: 0;
}
.brands-view__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px;
}
.brands-view__header-title h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.4;
}
.brands-view__header-title p {
margin: 6px 0 0 0;
font-size: 13px;
color: #8c8c8c;
}
.brands-view__header-actions {
display: flex;
gap: 12px;
margin-left: auto; /* explicit push to right just in case */
} }
.brands-view__section-head { .brands-view__section-head {
@@ -764,13 +786,31 @@ async function submitCompetitor(): Promise<void> {
margin-bottom: 18px; margin-bottom: 18px;
} }
.brands-view__section-head--compact h3 {
font-size: 20px;
}
.brands-view__section-head h3 { .brands-view__section-head h3 {
margin: 6px 0 0; margin: 6px 0 0;
font-size: 24px; font-size: 20px;
font-weight: 800;
color: #111827;
display: flex;
align-items: center;
}
.brands-view__section-head h3::before {
content: "";
display: inline-block;
width: 4px;
height: 18px;
border-radius: 2px;
background: #111827;
margin-right: 12px;
}
.brands-view__section-head--compact h3 {
font-size: 18px;
}
.brands-view__section-head--compact h3::before {
height: 16px;
} }
.brands-view__brand-grid { .brands-view__brand-grid {
@@ -784,14 +824,37 @@ async function submitCompetitor(): Promise<void> {
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
padding: 18px; padding: 18px;
background: #f8fafc; background: #fff;
border: 1px solid #e6edf5; border: 1px solid #e6edf5;
border-radius: 20px; border-radius: 12px;
cursor: pointer; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.brands-view__brand-card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 24px rgba(0,0,0,0.06);
}
.brands-view__brand-card-actions,
.brands-view__keyword-actions,
.brands-view__competitor-head .brands-view__inline-actions {
opacity: 0;
transition: opacity 0.2s ease;
}
.brands-view__brand-card:hover .brands-view__brand-card-actions,
.brands-view__brand-card:focus-within .brands-view__brand-card-actions,
.brands-view__keyword-item:hover .brands-view__keyword-actions,
.brands-view__keyword-item:focus-within .brands-view__keyword-actions,
.brands-view__competitor-card:hover .brands-view__competitor-head .brands-view__inline-actions,
.brands-view__competitor-card:focus-within .brands-view__competitor-head .brands-view__inline-actions {
opacity: 1;
} }
.brands-view__brand-card--active { .brands-view__brand-card--active {
border-color: #bfd7ff; border-color: #bfd7ff;
background: #f0f7ff;
box-shadow: 0 18px 40px rgba(31, 92, 255, 0.08); box-shadow: 0 18px 40px rgba(31, 92, 255, 0.08);
} }
@@ -802,13 +865,11 @@ async function submitCompetitor(): Promise<void> {
} }
.brands-view__brand-card h4, .brands-view__brand-card h4,
.brands-view__brand-summary h3,
.brands-view__competitor-card h4 { .brands-view__competitor-card h4 {
margin: 0; margin: 0;
} }
.brands-view__brand-card p, .brands-view__brand-card p,
.brands-view__brand-summary p,
.brands-view__competitor-card p { .brands-view__competitor-card p {
margin: 8px 0 0; margin: 8px 0 0;
color: var(--muted); color: var(--muted);
@@ -816,19 +877,12 @@ async function submitCompetitor(): Promise<void> {
} }
.brands-view__brand-card-actions, .brands-view__brand-card-actions,
.brands-view__inline-actions,
.brands-view__keyword-actions, .brands-view__keyword-actions,
.brands-view__brand-summary-actions { .brands-view__inline-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
.brands-view__brand-summary {
display: flex;
justify-content: space-between;
gap: 24px;
}
.brands-view__keyword-layout { .brands-view__keyword-layout {
display: grid; display: grid;
grid-template-columns: 320px minmax(0, 1fr); grid-template-columns: 320px minmax(0, 1fr);
@@ -847,11 +901,17 @@ async function submitCompetitor(): Promise<void> {
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding: 14px 16px; padding: 14px 16px;
background: #f8fafc; background: #fff;
border: 1px solid #e6edf5; border: 1px solid #e6edf5;
border-radius: 18px; border-radius: 12px;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
transition: all 0.2s;
}
.brands-view__keyword-item:hover {
border-color: #bfd7ff;
background: #f0f7ff;
} }
.brands-view__keyword-item--active { .brands-view__keyword-item--active {
@@ -878,9 +938,15 @@ async function submitCompetitor(): Promise<void> {
.brands-view__competitor-card { .brands-view__competitor-card {
padding: 18px; padding: 18px;
background: #f8fafc; background: #fff;
border: 1px solid #e6edf5; border: 1px solid #e6edf5;
border-radius: 20px; border-radius: 12px;
transition: transform 0.2s, box-shadow 0.2s;
}
.brands-view__competitor-card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 24px rgba(0,0,0,0.06);
} }
.brands-view__competitor-head { .brands-view__competitor-head {
@@ -928,7 +994,6 @@ async function submitCompetitor(): Promise<void> {
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.brands-view__brand-summary,
.brands-view__section-head { .brands-view__section-head {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
+10 -163
View File
@@ -14,9 +14,8 @@ import type {
TemplateAssistCompetitor, TemplateAssistCompetitor,
TemplateAnalyzeResult, TemplateAnalyzeResult,
TemplateOutlineNode, TemplateOutlineNode,
TemplateSchemaField,
} from "@geo/shared-types"; } from "@geo/shared-types";
import { computed, reactive, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
@@ -154,7 +153,6 @@ let outlineNodeSeed = 0;
const currentStep = ref(0); const currentStep = ref(0);
const articleLocale = ref("zh-CN"); const articleLocale = ref("zh-CN");
const fieldValues = reactive<Record<string, JsonValue>>({});
const currentArticleId = ref<number | null>(null); const currentArticleId = ref<number | null>(null);
const restoringDraft = ref(false); const restoringDraft = ref(false);
const savingDraft = ref(false); const savingDraft = ref(false);
@@ -259,7 +257,6 @@ const templateCardConfig = computed<TemplateCardConfig>(() =>
); );
const wizardConfig = computed<TemplateWizardConfig | null>(() => templateCardConfig.value.wizard ?? null); const wizardConfig = computed<TemplateWizardConfig | null>(() => templateCardConfig.value.wizard ?? null);
const isResearchReportTemplate = computed(() => templateDetail.value?.template_key === "research_report"); const isResearchReportTemplate = computed(() => templateDetail.value?.template_key === "research_report");
const wizardManagedFields = computed(() => wizardConfig.value?.managed_fields ?? []);
const templateMeta = computed(() => { const templateMeta = computed(() => {
const fallback = getTemplateMeta(templateDetail.value?.template_key ?? "unknown"); const fallback = getTemplateMeta(templateDetail.value?.template_key ?? "unknown");
return { return {
@@ -272,20 +269,6 @@ const selectedBrand = computed(() =>
brandsQuery.data.value?.find((brand) => brand.id === selectedBrandId.value) ?? null, brandsQuery.data.value?.find((brand) => brand.id === selectedBrandId.value) ?? null,
); );
const schemaFields = computed<TemplateSchemaField[]>(() => {
return templateDetailQuery.data.value?.schema_json?.fields ?? [];
});
const visibleSchemaFields = computed(() =>
schemaFields.value.filter((field) =>
!isWizardManagedField(
field.name,
templateDetail.value?.template_key,
wizardManagedFields.value,
),
),
);
const keywordOptions = computed(() => const keywordOptions = computed(() =>
(keywordsQuery.data.value ?? []).map((item) => ({ (keywordsQuery.data.value ?? []).map((item) => ({
label: item.name, label: item.name,
@@ -378,17 +361,7 @@ const structureStepCopy = computed<TemplateStepConfig>(() => wizardConfig.value?
const generateStepCopy = computed<TemplateStepConfig>(() => wizardConfig.value?.steps?.generate ?? {}); const generateStepCopy = computed<TemplateStepConfig>(() => wizardConfig.value?.steps?.generate ?? {});
const brandCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.basic?.cards?.brand ?? {}); const brandCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.basic?.cards?.brand ?? {});
const keywordCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.basic?.cards?.keywords ?? {}); const keywordCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.basic?.cards?.keywords ?? {});
const fieldsCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.basic?.cards?.template_fields ?? {});
const competitorsCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.basic?.cards?.competitors ?? {}); const competitorsCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.basic?.cards?.competitors ?? {});
const hasTemplateFieldsCardConfig = computed(
() => !isResearchReportTemplate.value && Boolean(wizardConfig.value?.basic?.cards?.template_fields),
);
const editableSchemaFields = computed<TemplateSchemaField[]>(() =>
hasTemplateFieldsCardConfig.value ? visibleSchemaFields.value : [],
);
const showTemplateFieldsCard = computed(
() => editableSchemaFields.value.length > 0 && fieldsCardCopy.value.visible !== false,
);
const titleCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.structure?.cards?.choose_title ?? {}); const titleCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.structure?.cards?.choose_title ?? {});
const outlineCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.structure?.cards?.outline ?? {}); const outlineCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.structure?.cards?.outline ?? {});
const previewCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.structure?.cards?.preview ?? {}); const previewCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.structure?.cards?.preview ?? {});
@@ -436,7 +409,7 @@ const assistHeading = computed(() =>
: t("templates.wizard.assist.outlineTitle"), : t("templates.wizard.assist.outlineTitle"),
); );
function resetWizardState(fields: TemplateSchemaField[], detail: NonNullable<typeof templateDetail.value>): void { function resetWizardState(detail: NonNullable<typeof templateDetail.value>): void {
currentArticleId.value = draftArticleId.value; currentArticleId.value = draftArticleId.value;
currentStep.value = 0; currentStep.value = 0;
articleLocale.value = "zh-CN"; articleLocale.value = "zh-CN";
@@ -455,20 +428,6 @@ function resetWizardState(fields: TemplateSchemaField[], detail: NonNullable<typ
customOutlineInput.value = ""; customOutlineInput.value = "";
generatedOutline.value = []; generatedOutline.value = [];
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] = "";
}
}
const defaults = buildOutlineOptions( const defaults = buildOutlineOptions(
templateCardConfig.value.wizard?.outline, templateCardConfig.value.wizard?.outline,
detail.template_key, detail.template_key,
@@ -527,26 +486,17 @@ function applyDraftArticleState(detail: ArticleDetail): void {
outlineSections.value = stringListFromUnknown(draftState.outline_sections) ?? outlineSections.value; outlineSections.value = stringListFromUnknown(draftState.outline_sections) ?? outlineSections.value;
generatedOutline.value = decorateOutlineNodes(parseOutlineNodes(draftState.generated_outline)); generatedOutline.value = decorateOutlineNodes(parseOutlineNodes(draftState.generated_outline));
const savedFields = asRecord(draftState.field_values);
if (savedFields) {
for (const field of schemaFields.value) {
if (Object.prototype.hasOwnProperty.call(savedFields, field.name)) {
fieldValues[field.name] = savedFields[field.name] as JsonValue;
}
}
}
restoringDraft.value = false; restoringDraft.value = false;
} }
watch( watch(
[enabled, schemaFields, templateDetail, () => draftArticleQuery.data.value], [enabled, templateDetail, () => draftArticleQuery.data.value],
([isOpen, fields, detail, draftArticle]) => { ([isOpen, detail, draftArticle]) => {
if (!isOpen || !detail) { if (!isOpen || !detail) {
return; return;
} }
resetWizardState(fields, detail); resetWizardState(detail);
if (draftArticle && (draftArticle.generate_status === "draft" || draftArticle.generate_status === "failed")) { if (draftArticle && (draftArticle.generate_status === "draft" || draftArticle.generate_status === "failed")) {
applyDraftArticleState(draftArticle); applyDraftArticleState(draftArticle);
} }
@@ -599,6 +549,9 @@ watch(selectedBrandId, async (brandId) => {
if (brand && !brandName.value.trim()) { if (brand && !brandName.value.trim()) {
brandName.value = brand.name; brandName.value = brand.name;
} }
if (brand?.website && !officialWebsite.value.trim()) {
officialWebsite.value = brand.website;
}
if (brand?.description && !brandSummary.value.trim()) { if (brand?.description && !brandSummary.value.trim()) {
brandSummary.value = brand.description; brandSummary.value = brand.description;
} }
@@ -633,37 +586,12 @@ function nextOutlineNodeKey(): string {
return `outline-${outlineNodeSeed}`; return `outline-${outlineNodeSeed}`;
} }
function isWizardManagedField(name: string, templateKey?: string, managedFields: string[] = []): boolean {
if (managedFields.includes(name)) {
return true;
}
if (name === "count" || name === "brand" || name === "keyword") {
return true;
}
if (templateKey === "top_x_article" && name === "topic") {
return true;
}
return false;
}
function renderFieldPlaceholder(field: TemplateSchemaField): string {
return field.placeholder || `${t("common.inputPlease")}${field.label}`;
}
function validateBasicInfo(): boolean { function validateBasicInfo(): boolean {
if (!normalizedBrandName.value) { if (!normalizedBrandName.value) {
message.warning(t("templates.wizard.messages.missingBrand")); message.warning(t("templates.wizard.messages.missingBrand"));
return false; return false;
} }
for (const field of editableSchemaFields.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; return true;
} }
@@ -992,6 +920,7 @@ async function ensureBrandForLibrary(): Promise<number> {
const created = await brandsApi.create({ const created = await brandsApi.create({
name: normalizedBrandName.value, name: normalizedBrandName.value,
website: officialWebsite.value.trim() || null,
description: brandSummary.value.trim() || null, description: brandSummary.value.trim() || null,
}); });
selectedBrandId.value = created.id; selectedBrandId.value = created.id;
@@ -1015,13 +944,6 @@ function buildCompetitorPayload(): TemplateAssistCompetitor[] {
function buildAssistInputParams(): Record<string, JsonValue> { function buildAssistInputParams(): Record<string, JsonValue> {
const payload: Record<string, JsonValue> = {}; const payload: Record<string, JsonValue> = {};
for (const field of schemaFields.value) {
const value = fieldValues[field.name];
if (value !== null && value !== "" && value !== undefined) {
payload[field.name] = value;
}
}
const derivedInputs = wizardConfig.value?.derived_inputs ?? {}; const derivedInputs = wizardConfig.value?.derived_inputs ?? {};
for (const [key, config] of Object.entries(derivedInputs)) { for (const [key, config] of Object.entries(derivedInputs)) {
const value = resolveDerivedInputValue(key, config); const value = resolveDerivedInputValue(key, config);
@@ -1052,7 +974,6 @@ function buildPayload(): Record<string, JsonValue> {
})) }))
: undefined; : undefined;
const payload: Record<string, JsonValue | undefined> = { const payload: Record<string, JsonValue | undefined> = {
...fieldValues,
locale: articleLocale.value, locale: articleLocale.value,
title: finalTitle.value || undefined, title: finalTitle.value || undefined,
outline_sections: selectedOutlineLabels.value, outline_sections: selectedOutlineLabels.value,
@@ -1080,20 +1001,6 @@ function buildPayload(): Record<string, JsonValue> {
payload[key] = derivedValue; payload[key] = derivedValue;
} }
} }
} else {
const competitorCount = competitorPayload.length;
if (hasSchemaField("brand")) {
payload.brand = normalizedBrandName.value;
}
if (hasSchemaField("keyword") && primaryKeyword.value) {
payload.keyword = primaryKeyword.value;
}
if (hasSchemaField("topic") && !stringValue(fieldValues.topic)) {
payload.topic = primaryKeyword.value || normalizedBrandName.value;
}
if (hasSchemaField("count")) {
payload.count = competitorCount > 0 ? competitorCount : defaultFieldNumber("count", 5);
}
} }
return Object.fromEntries( return Object.fromEntries(
@@ -1105,7 +1012,6 @@ function buildDraftState(): Record<string, JsonValue> {
return { return {
current_step: currentStep.value, current_step: currentStep.value,
locale: articleLocale.value, locale: articleLocale.value,
field_values: { ...fieldValues },
selected_brand_id: selectedBrandId.value, selected_brand_id: selectedBrandId.value,
brand_name: brandName.value.trim(), brand_name: brandName.value.trim(),
official_website: officialWebsite.value.trim(), official_website: officialWebsite.value.trim(),
@@ -1138,23 +1044,6 @@ function buildDraftState(): Record<string, JsonValue> {
}; };
} }
function hasSchemaField(name: string): boolean {
return schemaFields.value.some((field) => field.name === name);
}
function defaultFieldNumber(name: string, fallback: number): number {
const field = schemaFields.value.find((item) => item.name === name);
if (!field) {
return fallback;
}
const numeric = Number(field.default);
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
}
function stringValue(value: JsonValue | undefined): string {
return typeof value === "string" ? value.trim() : "";
}
function handleGenerate(): void { function handleGenerate(): void {
if (!validateBasicInfo() || !validateStructure() || !validateGeneratedOutline()) { if (!validateBasicInfo() || !validateStructure() || !validateGeneratedOutline()) {
return; return;
@@ -1814,7 +1703,7 @@ function resolveDerivedInputValue(
if (typeof config.fallback_number === "number") { if (typeof config.fallback_number === "number") {
return config.fallback_number; return config.fallback_number;
} }
return hasSchemaField(fieldName) ? defaultFieldNumber(fieldName, 0) || undefined : undefined; return undefined;
} }
default: default:
return undefined; return undefined;
@@ -2207,48 +2096,6 @@ function onStructureDragEnd(): void {
/> />
</div> </div>
<div v-if="showTemplateFieldsCard" class="wizard-card">
<div class="wizard-card__header">
<div>
<h3>{{ fieldsCardCopy.title || t("templates.wizard.sections.templateFields") }}</h3>
<p>{{ fieldsCardCopy.hint || t("templates.wizard.hints.templateFields") }}</p>
</div>
</div>
<div class="field-grid">
<div v-for="field in editableSchemaFields" :key="field.name" class="field-item">
<label :class="{ 'required-asterisk': field.required }">{{ field.label }}</label>
<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((option) =>
typeof option === 'object'
? option
: { label: String(option), value: option }
)"
:placeholder="renderFieldPlaceholder(field)"
/>
<a-input
v-else
v-model:value="fieldValues[field.name]"
:placeholder="renderFieldPlaceholder(field)"
/>
</div>
</div>
</div>
<div v-if="showCompetitorsCard" class="wizard-card"> <div v-if="showCompetitorsCard" class="wizard-card">
<div class="wizard-card__header"> <div class="wizard-card__header">
<div> <div>
+2 -17
View File
@@ -86,29 +86,12 @@ export interface TemplateCard {
export type JsonPrimitive = string | number | boolean | null; export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
export interface TemplateSchemaField {
name: string;
type: string;
label: string;
required?: boolean;
default?: JsonValue;
options?: Array<string | number | { label: string; value: string | number }>;
placeholder?: string;
help_text?: string;
}
export interface TemplateSchema {
fields?: TemplateSchemaField[];
[key: string]: JsonValue | TemplateSchemaField[] | undefined;
}
export interface TemplateListItem { export interface TemplateListItem {
id: number; id: number;
scope: string; scope: string;
origin_type: string; origin_type: string;
template_key: string; template_key: string;
template_name: string; template_name: string;
schema_json: TemplateSchema;
prompt_visibility: string; prompt_visibility: string;
card_config: Record<string, JsonValue> | null; card_config: Record<string, JsonValue> | null;
status: string; status: string;
@@ -298,12 +281,14 @@ export interface ArticleVersion {
export interface BrandRequest { export interface BrandRequest {
name: string; name: string;
website?: string | null;
description?: string | null; description?: string | null;
} }
export interface Brand { export interface Brand {
id: number; id: number;
name: string; name: string;
website: string | null;
description: string | null; description: string | null;
status: string; status: string;
created_at: string; created_at: string;
+7 -12
View File
@@ -140,12 +140,11 @@ func main() {
// Platform templates // Platform templates
templates := []struct { templates := []struct {
key, name, schema, prompt, cardConfig string key, name, prompt, cardConfig string
}{ }{
{ {
"top_x_article", "top_x_article",
"Top X 文章", "Top X 文章",
`{"fields":[{"name":"topic","type":"text","label":"话题","required":true},{"name":"count","type":"number","label":"数量","required":true,"default":10}]}`,
`你是一名经验丰富的内容编辑,请围绕「{{topic}}」撰写一篇完整的 Markdown 推荐类文章。 `你是一名经验丰富的内容编辑,请围绕「{{topic}}」撰写一篇完整的 Markdown 推荐类文章。
文章目标: 文章目标:
1. 帮读者快速理解这个主题下值得关注的 {{count}} 个方向、产品或对象。 1. 帮读者快速理解这个主题下值得关注的 {{count}} 个方向、产品或对象。
@@ -245,7 +244,6 @@ func main() {
{ {
"product_review", "product_review",
"产品评测文章", "产品评测文章",
`{"fields":[{"name":"product_name","type":"text","label":"产品名称","required":true},{"name":"category","type":"text","label":"品类","required":true}]}`,
`你是一名资深产品评测编辑,请围绕「{{product_name}}」这款{{category}}产品撰写一篇完整的 Markdown 评测文章。 `你是一名资深产品评测编辑,请围绕「{{product_name}}」这款{{category}}产品撰写一篇完整的 Markdown 评测文章。
文章目标: 文章目标:
1. 帮读者判断这款产品是否值得关注或购买。 1. 帮读者判断这款产品是否值得关注或购买。
@@ -346,7 +344,6 @@ func main() {
{ {
"research_report", "research_report",
"研究报告文章", "研究报告文章",
`{"fields":[{"name":"subject","type":"text","label":"研究主题","required":true},{"name":"depth","type":"select","label":"深度","options":["overview","detailed"],"default":"overview"}]}`,
`你是一名研究分析师,请围绕「{{subject}}」撰写一篇完整的 Markdown 研究报告。 `你是一名研究分析师,请围绕「{{subject}}」撰写一篇完整的 Markdown 研究报告。
文章目标: 文章目标:
1. 提炼关键事实、趋势与结构化发现。 1. 提炼关键事实、趋势与结构化发现。
@@ -439,7 +436,6 @@ func main() {
{ {
"brand_search_expansion", "brand_search_expansion",
"品牌词搜索扩写", "品牌词搜索扩写",
`{"fields":[{"name":"brand","type":"text","label":"品牌名","required":true},{"name":"keyword","type":"text","label":"关键词","required":true}]}`,
`你是一名擅长搜索意图写作的内容编辑,请围绕「{{brand}} {{keyword}}」撰写一篇完整的 Markdown 品牌词搜索扩写文章。 `你是一名擅长搜索意图写作的内容编辑,请围绕「{{brand}} {{keyword}}」撰写一篇完整的 Markdown 品牌词搜索扩写文章。
文章目标: 文章目标:
1. 直接回答搜索用户最关心的问题。 1. 直接回答搜索用户最关心的问题。
@@ -536,10 +532,10 @@ func main() {
for _, t := range templates { for _, t := range templates {
_, err = tx.Exec(ctx, ` _, err = tx.Exec(ctx, `
INSERT INTO article_templates (scope, origin_type, template_key, template_name, schema_json, prompt_template, prompt_visibility, card_config_json, status) INSERT INTO article_templates (scope, origin_type, template_key, template_name, prompt_template, prompt_visibility, card_config_json, status)
VALUES ('platform', 'platform', $1, $2, $3::jsonb, $4, 'visible', $5::jsonb, 'active') VALUES ('platform', 'platform', $1, $2, $3, 'visible', $4::jsonb, 'active')
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
`, t.key, t.name, t.schema, t.prompt, t.cardConfig) `, t.key, t.name, t.prompt, t.cardConfig)
if err != nil { if err != nil {
log.Fatalf("insert template %s: %v", t.key, err) log.Fatalf("insert template %s: %v", t.key, err)
} }
@@ -547,16 +543,15 @@ func main() {
_, err = tx.Exec(ctx, ` _, err = tx.Exec(ctx, `
UPDATE article_templates UPDATE article_templates
SET template_name = $2, SET template_name = $2,
schema_json = $3::jsonb, prompt_template = $3,
prompt_template = $4, card_config_json = $4::jsonb,
card_config_json = $5::jsonb,
prompt_visibility = 'visible', prompt_visibility = 'visible',
status = 'active', status = 'active',
updated_at = NOW() updated_at = NOW()
WHERE scope = 'platform' WHERE scope = 'platform'
AND template_key = $1 AND template_key = $1
AND deleted_at IS NULL AND deleted_at IS NULL
`, t.key, t.name, t.schema, t.prompt, t.cardConfig) `, t.key, t.name, t.prompt, t.cardConfig)
if err != nil { if err != nil {
log.Fatalf("update template %s: %v", t.key, err) log.Fatalf("update template %s: %v", t.key, err)
} }
+33 -25
View File
@@ -132,31 +132,7 @@ func (c *arkClient) Generate(ctx context.Context, req GenerateRequest, onDelta f
}, },
} }
var textFormat *responses.ResponsesText textFormat := buildArkTextFormat(req.ResponseFormat)
if req.ResponseFormat != nil {
format := &responses.TextFormat{
Name: strings.TrimSpace(req.ResponseFormat.Name),
}
switch req.ResponseFormat.Type {
case ResponseFormatTypeJSONSchema:
format.Type = responses.TextType_json_schema
case ResponseFormatTypeJSONObject:
format.Type = responses.TextType_json_object
default:
format.Type = responses.TextType_text
}
if description := strings.TrimSpace(req.ResponseFormat.Description); description != "" {
format.Description = &description
}
if len(req.ResponseFormat.SchemaJSON) > 0 {
format.Schema = &responses.Bytes{Value: req.ResponseFormat.SchemaJSON}
}
if req.ResponseFormat.Strict {
strict := true
format.Strict = &strict
}
textFormat = &responses.ResponsesText{Format: format}
}
var tools []*responses.ResponsesTool var tools []*responses.ResponsesTool
if req.WebSearch != nil && req.WebSearch.Enabled { if req.WebSearch != nil && req.WebSearch.Enabled {
@@ -260,6 +236,38 @@ func (c *arkClient) Generate(ctx context.Context, req GenerateRequest, onDelta f
}, nil }, nil
} }
func buildArkTextFormat(format *ResponseFormat) *responses.ResponsesText {
if format == nil {
return nil
}
textFormat := &responses.TextFormat{}
switch format.Type {
case ResponseFormatTypeJSONSchema:
textFormat.Type = responses.TextType_json_schema
if name := strings.TrimSpace(format.Name); name != "" {
textFormat.Name = name
}
if description := strings.TrimSpace(format.Description); description != "" {
textFormat.Description = &description
}
if len(format.SchemaJSON) > 0 {
textFormat.Schema = &responses.Bytes{Value: format.SchemaJSON}
}
if format.Strict {
strict := true
textFormat.Strict = &strict
}
case ResponseFormatTypeJSONObject:
// Ark only accepts the type discriminator for json_object.
textFormat.Type = responses.TextType_json_object
default:
textFormat.Type = responses.TextType_text
}
return &responses.ResponsesText{Format: textFormat}
}
func resolveArkReasoning(value string) *responses.ResponsesReasoning { func resolveArkReasoning(value string) *responses.ResponsesReasoning {
switch strings.ToLower(strings.TrimSpace(value)) { switch strings.ToLower(strings.TrimSpace(value)) {
case "": case "":
+65
View File
@@ -0,0 +1,65 @@
package llm
import (
"testing"
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model/responses"
)
func TestBuildArkTextFormatJSONObjectDropsSchemaFields(t *testing.T) {
text := buildArkTextFormat(&ResponseFormat{
Type: ResponseFormatTypeJSONObject,
Name: "article_outline",
Description: "outline object",
SchemaJSON: []byte(`{"type":"object"}`),
Strict: true,
})
if text == nil || text.Format == nil {
t.Fatal("buildArkTextFormat() returned nil")
}
if got := text.Format.Type; got != responses.TextType_json_object {
t.Fatalf("buildArkTextFormat() type = %v, want %v", got, responses.TextType_json_object)
}
if text.Format.Name != "" {
t.Fatalf("buildArkTextFormat() name = %q, want empty for json_object", text.Format.Name)
}
if text.Format.Description != nil {
t.Fatalf("buildArkTextFormat() description = %v, want nil for json_object", *text.Format.Description)
}
if text.Format.Schema != nil {
t.Fatalf("buildArkTextFormat() schema = %v, want nil for json_object", text.Format.Schema)
}
if text.Format.Strict != nil {
t.Fatalf("buildArkTextFormat() strict = %v, want nil for json_object", *text.Format.Strict)
}
}
func TestBuildArkTextFormatJSONSchemaKeepsSchemaFields(t *testing.T) {
text := buildArkTextFormat(&ResponseFormat{
Type: ResponseFormatTypeJSONSchema,
Name: "article_outline",
Description: "outline object",
SchemaJSON: []byte(`{"type":"object"}`),
Strict: true,
})
if text == nil || text.Format == nil {
t.Fatal("buildArkTextFormat() returned nil")
}
if got := text.Format.Type; got != responses.TextType_json_schema {
t.Fatalf("buildArkTextFormat() type = %v, want %v", got, responses.TextType_json_schema)
}
if text.Format.Name != "article_outline" {
t.Fatalf("buildArkTextFormat() name = %q, want article_outline", text.Format.Name)
}
if text.Format.Description == nil || *text.Format.Description != "outline object" {
t.Fatalf("buildArkTextFormat() description = %v, want outline object", text.Format.Description)
}
if text.Format.Schema == nil || string(text.Format.Schema.Value) != `{"type":"object"}` {
t.Fatalf("buildArkTextFormat() schema = %v, want schema payload", text.Format.Schema)
}
if text.Format.Strict == nil || !*text.Format.Strict {
t.Fatalf("buildArkTextFormat() strict = %v, want true", text.Format.Strict)
}
}
+42 -11
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -27,12 +28,14 @@ func NewBrandService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter) *Brand
type BrandRequest struct { type BrandRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Website *string `json:"website"`
Description *string `json:"description"` Description *string `json:"description"`
} }
type BrandResponse struct { type BrandResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Website *string `json:"website"`
Description *string `json:"description"` Description *string `json:"description"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
@@ -42,7 +45,7 @@ type BrandResponse struct {
func (s *BrandService) List(ctx context.Context) ([]BrandResponse, error) { func (s *BrandService) List(ctx context.Context) ([]BrandResponse, error) {
actor := auth.MustActor(ctx) actor := auth.MustActor(ctx)
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, name, description, status, created_at, updated_at SELECT id, name, website, description, status, created_at, updated_at
FROM brands WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC FROM brands WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC
`, actor.TenantID) `, actor.TenantID)
if err != nil { if err != nil {
@@ -54,7 +57,7 @@ func (s *BrandService) List(ctx context.Context) ([]BrandResponse, error) {
for rows.Next() { for rows.Next() {
var b BrandResponse var b BrandResponse
var ca, ua interface{} var ca, ua interface{}
if err := rows.Scan(&b.ID, &b.Name, &b.Description, &b.Status, &ca, &ua); err != nil { if err := rows.Scan(&b.ID, &b.Name, &b.Website, &b.Description, &b.Status, &ca, &ua); err != nil {
return nil, response.ErrInternal(50010, "scan_failed", err.Error()) return nil, response.ErrInternal(50010, "scan_failed", err.Error())
} }
b.CreatedAt = fmt.Sprintf("%v", ca) b.CreatedAt = fmt.Sprintf("%v", ca)
@@ -69,12 +72,16 @@ func (s *BrandService) List(ctx context.Context) ([]BrandResponse, error) {
func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResponse, error) { func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResponse, error) {
actor := auth.MustActor(ctx) actor := auth.MustActor(ctx)
normalizeBrandRequest(&req)
if req.Name == "" {
return nil, response.ErrBadRequest(40001, "invalid_params", "name is required")
}
var id int64 var id int64
var ca interface{} var ca interface{}
err := s.pool.QueryRow(ctx, ` err := s.pool.QueryRow(ctx, `
INSERT INTO brands (tenant_id, name, description, status) INSERT INTO brands (tenant_id, name, website, description, status)
VALUES ($1, $2, $3, 'active') RETURNING id, created_at VALUES ($1, $2, $3, $4, 'active') RETURNING id, created_at
`, actor.TenantID, req.Name, req.Description).Scan(&id, &ca) `, actor.TenantID, req.Name, req.Website, req.Description).Scan(&id, &ca)
if err != nil { if err != nil {
return nil, response.ErrConflict(40901, "brand_exists", "brand with this name already exists") return nil, response.ErrConflict(40901, "brand_exists", "brand with this name already exists")
} }
@@ -95,7 +102,14 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
Result: &result, Result: &result,
}) })
return &BrandResponse{ID: id, Name: req.Name, Description: req.Description, Status: "active", CreatedAt: fmt.Sprintf("%v", ca)}, nil return &BrandResponse{
ID: id,
Name: req.Name,
Website: req.Website,
Description: req.Description,
Status: "active",
CreatedAt: fmt.Sprintf("%v", ca),
}, nil
} }
func (s *BrandService) Detail(ctx context.Context, id int64) (*BrandResponse, error) { func (s *BrandService) Detail(ctx context.Context, id int64) (*BrandResponse, error) {
@@ -103,9 +117,9 @@ func (s *BrandService) Detail(ctx context.Context, id int64) (*BrandResponse, er
var b BrandResponse var b BrandResponse
var ca, ua interface{} var ca, ua interface{}
err := s.pool.QueryRow(ctx, ` err := s.pool.QueryRow(ctx, `
SELECT id, name, description, status, created_at, updated_at SELECT id, name, website, description, status, created_at, updated_at
FROM brands WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL FROM brands WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, id, actor.TenantID).Scan(&b.ID, &b.Name, &b.Description, &b.Status, &ca, &ua) `, id, actor.TenantID).Scan(&b.ID, &b.Name, &b.Website, &b.Description, &b.Status, &ca, &ua)
if err != nil { if err != nil {
return nil, response.ErrNotFound(40420, "brand_not_found", "brand not found") return nil, response.ErrNotFound(40420, "brand_not_found", "brand not found")
} }
@@ -116,16 +130,33 @@ func (s *BrandService) Detail(ctx context.Context, id int64) (*BrandResponse, er
func (s *BrandService) Update(ctx context.Context, id int64, req BrandRequest) error { func (s *BrandService) Update(ctx context.Context, id int64, req BrandRequest) error {
actor := auth.MustActor(ctx) actor := auth.MustActor(ctx)
normalizeBrandRequest(&req)
if req.Name == "" {
return response.ErrBadRequest(40001, "invalid_params", "name is required")
}
tag, err := s.pool.Exec(ctx, ` tag, err := s.pool.Exec(ctx, `
UPDATE brands SET name = $1, description = $2, updated_at = NOW() UPDATE brands SET name = $1, website = $2, description = $3, updated_at = NOW()
WHERE id = $3 AND tenant_id = $4 AND deleted_at IS NULL WHERE id = $4 AND tenant_id = $5 AND deleted_at IS NULL
`, req.Name, req.Description, id, actor.TenantID) `, req.Name, req.Website, req.Description, id, actor.TenantID)
if err != nil || tag.RowsAffected() == 0 { if err != nil || tag.RowsAffected() == 0 {
return response.ErrNotFound(40420, "brand_not_found", "brand not found") return response.ErrNotFound(40420, "brand_not_found", "brand not found")
} }
return nil return nil
} }
func normalizeBrandRequest(req *BrandRequest) {
req.Name = strings.TrimSpace(req.Name)
req.Website = normalizeOptionalString(req.Website)
req.Description = normalizeOptionalString(req.Description)
}
func normalizeOptionalString(value *string) *string {
if value == nil {
return nil
}
return nilIfEmptyString(*value)
}
func (s *BrandService) Delete(ctx context.Context, id int64) error { func (s *BrandService) Delete(ctx context.Context, id int64) error {
actor := auth.MustActor(ctx) actor := auth.MustActor(ctx)
tx, err := s.pool.Begin(ctx) tx, err := s.pool.Begin(ctx)
+1 -16
View File
@@ -93,7 +93,6 @@ type TemplateListItem struct {
OriginType string `json:"origin_type"` OriginType string `json:"origin_type"`
TemplateKey string `json:"template_key"` TemplateKey string `json:"template_key"`
TemplateName string `json:"template_name"` TemplateName string `json:"template_name"`
SchemaJSON map[string]interface{} `json:"schema_json"`
PromptVisibility string `json:"prompt_visibility"` PromptVisibility string `json:"prompt_visibility"`
CardConfigJSON map[string]interface{} `json:"card_config"` CardConfigJSON map[string]interface{} `json:"card_config"`
Status string `json:"status"` Status string `json:"status"`
@@ -122,7 +121,6 @@ func (s *TemplateService) List(ctx context.Context) ([]TemplateListItem, error)
VersionNo: row.VersionNo, VersionNo: row.VersionNo,
CreatedAt: row.CreatedAt, CreatedAt: row.CreatedAt,
} }
_ = json.Unmarshal(row.SchemaJSON, &item.SchemaJSON)
_ = json.Unmarshal(row.CardConfigJSON, &item.CardConfigJSON) _ = json.Unmarshal(row.CardConfigJSON, &item.CardConfigJSON)
items = append(items, item) items = append(items, item)
} }
@@ -142,7 +140,7 @@ func (s *TemplateService) Detail(ctx context.Context, id int64) (*TemplateDetail
return nil, response.ErrNotFound(40410, "template_not_found", "template not found") return nil, response.ErrNotFound(40410, "template_not_found", "template not found")
} }
detail := &TemplateDetail{ detail := &TemplateDetail{
TemplateListItem: TemplateListItem{ TemplateListItem: TemplateListItem{
ID: record.ID, ID: record.ID,
Scope: record.Scope, Scope: record.Scope,
@@ -156,7 +154,6 @@ func (s *TemplateService) Detail(ctx context.Context, id int64) (*TemplateDetail
CreatedAt: record.CreatedAt, CreatedAt: record.CreatedAt,
}, },
} }
_ = json.Unmarshal(record.SchemaJSON, &detail.SchemaJSON)
_ = json.Unmarshal(record.CardConfigJSON, &detail.CardConfigJSON) _ = json.Unmarshal(record.CardConfigJSON, &detail.CardConfigJSON)
if canViewPromptTemplate(record, actor.TenantID) { if canViewPromptTemplate(record, actor.TenantID) {
@@ -166,18 +163,6 @@ func (s *TemplateService) Detail(ctx context.Context, id int64) (*TemplateDetail
return detail, nil return detail, nil
} }
func (s *TemplateService) Schema(ctx context.Context, id int64) (map[string]interface{}, error) {
actor := auth.MustActor(ctx)
record, err := s.templates.GetTemplateByID(ctx, id, actor.TenantID)
if err != nil {
return nil, response.ErrNotFound(40410, "template_not_found", "template not found")
}
schema := map[string]interface{}{}
_ = json.Unmarshal(record.SchemaJSON, &schema)
return schema, nil
}
func canViewPromptTemplate(record *repository.TemplateRecord, actorTenantID int64) bool { func canViewPromptTemplate(record *repository.TemplateRecord, actorTenantID int64) bool {
if record == nil || record.TenantID == nil { if record == nil || record.TenantID == nil {
return false return false
@@ -9,7 +9,6 @@ type ArticleTemplate struct {
OriginType string OriginType string
TemplateKey string TemplateKey string
TemplateName string TemplateName string
SchemaJSON map[string]interface{}
PromptTemplate *string PromptTemplate *string
PromptVisibility string PromptVisibility string
ProtectedPromptAssetKey *string ProtectedPromptAssetKey *string
+1
View File
@@ -6,6 +6,7 @@ type Brand struct {
ID int64 ID int64
TenantID int64 TenantID int64
Name string Name string
Website *string
Description *string Description *string
Status string Status string
CreatedAt time.Time CreatedAt time.Time
@@ -31,7 +31,6 @@ type ArticleTemplate struct {
ShareCodeID pgtype.Int8 `json:"share_code_id"` ShareCodeID pgtype.Int8 `json:"share_code_id"`
TemplateKey string `json:"template_key"` TemplateKey string `json:"template_key"`
TemplateName string `json:"template_name"` TemplateName string `json:"template_name"`
SchemaJson []byte `json:"schema_json"`
PromptTemplate pgtype.Text `json:"prompt_template"` PromptTemplate pgtype.Text `json:"prompt_template"`
PromptVisibility string `json:"prompt_visibility"` PromptVisibility string `json:"prompt_visibility"`
ProtectedPromptAssetKey pgtype.Text `json:"protected_prompt_asset_key"` ProtectedPromptAssetKey pgtype.Text `json:"protected_prompt_asset_key"`
@@ -42,7 +42,6 @@ type Querier interface {
GetRecentArticles(ctx context.Context, tenantID int64) ([]GetRecentArticlesRow, error) GetRecentArticles(ctx context.Context, tenantID int64) ([]GetRecentArticlesRow, error)
GetScheduleTaskByID(ctx context.Context, arg GetScheduleTaskByIDParams) (GetScheduleTaskByIDRow, error) GetScheduleTaskByID(ctx context.Context, arg GetScheduleTaskByIDParams) (GetScheduleTaskByIDRow, error)
GetTemplateByID(ctx context.Context, arg GetTemplateByIDParams) (GetTemplateByIDRow, error) GetTemplateByID(ctx context.Context, arg GetTemplateByIDParams) (GetTemplateByIDRow, error)
GetTemplateSchema(ctx context.Context, id int64) (GetTemplateSchemaRow, error)
GetTenantMembership(ctx context.Context, userID int64) (GetTenantMembershipRow, error) GetTenantMembership(ctx context.Context, userID int64) (GetTenantMembershipRow, error)
GetTenantMembershipByTenantAndUser(ctx context.Context, arg GetTenantMembershipByTenantAndUserParams) (GetTenantMembershipByTenantAndUserRow, error) GetTenantMembershipByTenantAndUser(ctx context.Context, arg GetTenantMembershipByTenantAndUserParams) (GetTenantMembershipByTenantAndUserRow, error)
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
@@ -13,7 +13,7 @@ import (
const getTemplateByID = `-- name: GetTemplateByID :one const getTemplateByID = `-- name: GetTemplateByID :one
SELECT id, scope, tenant_id, origin_type, template_key, template_name, SELECT id, scope, tenant_id, origin_type, template_key, template_name,
schema_json, prompt_template, prompt_visibility, protected_prompt_asset_key, prompt_template, prompt_visibility, protected_prompt_asset_key,
card_config_json, status, version_no, created_at, updated_at card_config_json, status, version_no, created_at, updated_at
FROM article_templates FROM article_templates
WHERE id = $1 WHERE id = $1
@@ -33,7 +33,6 @@ type GetTemplateByIDRow struct {
OriginType string `json:"origin_type"` OriginType string `json:"origin_type"`
TemplateKey string `json:"template_key"` TemplateKey string `json:"template_key"`
TemplateName string `json:"template_name"` TemplateName string `json:"template_name"`
SchemaJson []byte `json:"schema_json"`
PromptTemplate pgtype.Text `json:"prompt_template"` PromptTemplate pgtype.Text `json:"prompt_template"`
PromptVisibility string `json:"prompt_visibility"` PromptVisibility string `json:"prompt_visibility"`
ProtectedPromptAssetKey pgtype.Text `json:"protected_prompt_asset_key"` ProtectedPromptAssetKey pgtype.Text `json:"protected_prompt_asset_key"`
@@ -54,7 +53,6 @@ func (q *Queries) GetTemplateByID(ctx context.Context, arg GetTemplateByIDParams
&i.OriginType, &i.OriginType,
&i.TemplateKey, &i.TemplateKey,
&i.TemplateName, &i.TemplateName,
&i.SchemaJson,
&i.PromptTemplate, &i.PromptTemplate,
&i.PromptVisibility, &i.PromptVisibility,
&i.ProtectedPromptAssetKey, &i.ProtectedPromptAssetKey,
@@ -67,28 +65,9 @@ func (q *Queries) GetTemplateByID(ctx context.Context, arg GetTemplateByIDParams
return i, err return i, err
} }
const getTemplateSchema = `-- name: GetTemplateSchema :one
SELECT id, template_name, schema_json
FROM article_templates
WHERE id = $1 AND deleted_at IS NULL
`
type GetTemplateSchemaRow struct {
ID int64 `json:"id"`
TemplateName string `json:"template_name"`
SchemaJson []byte `json:"schema_json"`
}
func (q *Queries) GetTemplateSchema(ctx context.Context, id int64) (GetTemplateSchemaRow, error) {
row := q.db.QueryRow(ctx, getTemplateSchema, id)
var i GetTemplateSchemaRow
err := row.Scan(&i.ID, &i.TemplateName, &i.SchemaJson)
return i, err
}
const listTemplates = `-- name: ListTemplates :many const listTemplates = `-- name: ListTemplates :many
SELECT id, scope, tenant_id, origin_type, template_key, template_name, SELECT id, scope, tenant_id, origin_type, template_key, template_name,
schema_json, prompt_template, prompt_visibility, card_config_json, prompt_template, prompt_visibility, card_config_json,
status, version_no, created_at, updated_at status, version_no, created_at, updated_at
FROM article_templates FROM article_templates
WHERE (scope = 'platform' OR tenant_id = $1::bigint) WHERE (scope = 'platform' OR tenant_id = $1::bigint)
@@ -103,7 +82,6 @@ type ListTemplatesRow struct {
OriginType string `json:"origin_type"` OriginType string `json:"origin_type"`
TemplateKey string `json:"template_key"` TemplateKey string `json:"template_key"`
TemplateName string `json:"template_name"` TemplateName string `json:"template_name"`
SchemaJson []byte `json:"schema_json"`
PromptTemplate pgtype.Text `json:"prompt_template"` PromptTemplate pgtype.Text `json:"prompt_template"`
PromptVisibility string `json:"prompt_visibility"` PromptVisibility string `json:"prompt_visibility"`
CardConfigJson []byte `json:"card_config_json"` CardConfigJson []byte `json:"card_config_json"`
@@ -129,7 +107,6 @@ func (q *Queries) ListTemplates(ctx context.Context, tenantID int64) ([]ListTemp
&i.OriginType, &i.OriginType,
&i.TemplateKey, &i.TemplateKey,
&i.TemplateName, &i.TemplateName,
&i.SchemaJson,
&i.PromptTemplate, &i.PromptTemplate,
&i.PromptVisibility, &i.PromptVisibility,
&i.CardConfigJson, &i.CardConfigJson,
@@ -1,6 +1,6 @@
-- name: ListTemplates :many -- name: ListTemplates :many
SELECT id, scope, tenant_id, origin_type, template_key, template_name, SELECT id, scope, tenant_id, origin_type, template_key, template_name,
schema_json, prompt_template, prompt_visibility, card_config_json, prompt_template, prompt_visibility, card_config_json,
status, version_no, created_at, updated_at status, version_no, created_at, updated_at
FROM article_templates FROM article_templates
WHERE (scope = 'platform' OR tenant_id = sqlc.arg(tenant_id)::bigint) WHERE (scope = 'platform' OR tenant_id = sqlc.arg(tenant_id)::bigint)
@@ -9,14 +9,9 @@ ORDER BY created_at DESC;
-- name: GetTemplateByID :one -- name: GetTemplateByID :one
SELECT id, scope, tenant_id, origin_type, template_key, template_name, SELECT id, scope, tenant_id, origin_type, template_key, template_name,
schema_json, prompt_template, prompt_visibility, protected_prompt_asset_key, prompt_template, prompt_visibility, protected_prompt_asset_key,
card_config_json, status, version_no, created_at, updated_at card_config_json, status, version_no, created_at, updated_at
FROM article_templates FROM article_templates
WHERE id = sqlc.arg(id) WHERE id = sqlc.arg(id)
AND (scope = 'platform' OR tenant_id = sqlc.arg(tenant_id)::bigint) AND (scope = 'platform' OR tenant_id = sqlc.arg(tenant_id)::bigint)
AND deleted_at IS NULL; AND deleted_at IS NULL;
-- name: GetTemplateSchema :one
SELECT id, template_name, schema_json
FROM article_templates
WHERE id = sqlc.arg(id) AND deleted_at IS NULL;
@@ -61,7 +61,3 @@ func (r *cachedTemplateRepository) GetTemplateByID(ctx context.Context, id, tena
} }
return record, nil return record, nil
} }
func (r *cachedTemplateRepository) GetTemplateSchema(ctx context.Context, id int64) (*TemplateSchemaRecord, error) {
return r.inner.GetTemplateSchema(ctx, id)
}
@@ -14,7 +14,6 @@ type TemplateRecord struct {
OriginType string OriginType string
TemplateKey string TemplateKey string
TemplateName string TemplateName string
SchemaJSON []byte
PromptTemplate *string PromptTemplate *string
PromptVisibility string PromptVisibility string
ProtectedPromptAssetKey *string ProtectedPromptAssetKey *string
@@ -25,16 +24,9 @@ type TemplateRecord struct {
UpdatedAt time.Time UpdatedAt time.Time
} }
type TemplateSchemaRecord struct {
ID int64
TemplateName string
SchemaJSON []byte
}
type TemplateRepository interface { type TemplateRepository interface {
ListTemplates(ctx context.Context, tenantID int64) ([]TemplateRecord, error) ListTemplates(ctx context.Context, tenantID int64) ([]TemplateRecord, error)
GetTemplateByID(ctx context.Context, id, tenantID int64) (*TemplateRecord, error) GetTemplateByID(ctx context.Context, id, tenantID int64) (*TemplateRecord, error)
GetTemplateSchema(ctx context.Context, id int64) (*TemplateSchemaRecord, error)
} }
type templateRepository struct { type templateRepository struct {
@@ -60,7 +52,6 @@ func (r *templateRepository) ListTemplates(ctx context.Context, tenantID int64)
OriginType: row.OriginType, OriginType: row.OriginType,
TemplateKey: row.TemplateKey, TemplateKey: row.TemplateKey,
TemplateName: row.TemplateName, TemplateName: row.TemplateName,
SchemaJSON: row.SchemaJson,
PromptTemplate: nullableText(row.PromptTemplate), PromptTemplate: nullableText(row.PromptTemplate),
PromptVisibility: row.PromptVisibility, PromptVisibility: row.PromptVisibility,
CardConfigJSON: row.CardConfigJson, CardConfigJSON: row.CardConfigJson,
@@ -90,7 +81,6 @@ func (r *templateRepository) GetTemplateByID(ctx context.Context, id, tenantID i
OriginType: row.OriginType, OriginType: row.OriginType,
TemplateKey: row.TemplateKey, TemplateKey: row.TemplateKey,
TemplateName: row.TemplateName, TemplateName: row.TemplateName,
SchemaJSON: row.SchemaJson,
PromptTemplate: nullableText(row.PromptTemplate), PromptTemplate: nullableText(row.PromptTemplate),
PromptVisibility: row.PromptVisibility, PromptVisibility: row.PromptVisibility,
ProtectedPromptAssetKey: nullableText(row.ProtectedPromptAssetKey), ProtectedPromptAssetKey: nullableText(row.ProtectedPromptAssetKey),
@@ -101,16 +91,3 @@ func (r *templateRepository) GetTemplateByID(ctx context.Context, id, tenantID i
UpdatedAt: timeFromTimestamp(row.UpdatedAt), UpdatedAt: timeFromTimestamp(row.UpdatedAt),
}, nil }, nil
} }
func (r *templateRepository) GetTemplateSchema(ctx context.Context, id int64) (*TemplateSchemaRecord, error) {
row, err := r.q.GetTemplateSchema(ctx, id)
if err != nil {
return nil, err
}
return &TemplateSchemaRecord{
ID: row.ID,
TemplateName: row.TemplateName,
SchemaJSON: row.SchemaJson,
}, nil
}
@@ -30,7 +30,6 @@ func RegisterRoutes(app *bootstrap.App) {
tplHandler := NewTemplateHandler(app) tplHandler := NewTemplateHandler(app)
templates.GET("", tplHandler.List) templates.GET("", tplHandler.List)
templates.GET("/:id", tplHandler.Detail) templates.GET("/:id", tplHandler.Detail)
templates.GET("/:id/schema", tplHandler.Schema)
templates.POST("/:id/drafts", tplHandler.SaveDraft) templates.POST("/:id/drafts", tplHandler.SaveDraft)
templates.POST("/:id/analyze-tasks", tplHandler.CreateAnalyzeTask) templates.POST("/:id/analyze-tasks", tplHandler.CreateAnalyzeTask)
templates.GET("/:id/analyze_task_result", tplHandler.GetAnalyzeTaskResult) templates.GET("/:id/analyze_task_result", tplHandler.GetAnalyzeTaskResult)
@@ -54,20 +54,6 @@ func (h *TemplateHandler) Detail(c *gin.Context) {
response.Success(c, data) response.Success(c, data)
} }
func (h *TemplateHandler) Schema(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "template id must be a number"))
return
}
data, err := h.svc.Schema(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *TemplateHandler) Generate(c *gin.Context) { func (h *TemplateHandler) Generate(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64) id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil { if err != nil {
@@ -0,0 +1,2 @@
ALTER TABLE article_templates
ADD COLUMN IF NOT EXISTS schema_json JSONB NOT NULL DEFAULT '{}'::jsonb;
@@ -0,0 +1,2 @@
ALTER TABLE article_templates
DROP COLUMN IF EXISTS schema_json;
@@ -0,0 +1,2 @@
ALTER TABLE brands
DROP COLUMN IF EXISTS website;
@@ -0,0 +1,2 @@
ALTER TABLE brands
ADD COLUMN website TEXT;