feat(admin-web): localize template wizard for en-US articles
Deployment Config CI / Deployment Config (push) Successful in 47s
Frontend CI / Frontend (push) Successful in 3m14s
Backend CI / Backend (push) Successful in 16m45s

Drive the wizard off the article locale instead of the UI locale so an
en-US article stays in English end to end, and stop Chinese values from
leaking into English drafts.

- Localize review-intro-hook copy and outline section labels to English
  (with fallbacks), and use a ": " separator for en-US
- Sanitize CJK content from titles, outline, and key points when the
  article locale is en-US, and re-sanitize on locale switch/draft restore
- Add knowledge-base reference labels/placeholders and the
  missing-review-intro-hook validation message to both locales

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 19:39:29 +08:00
parent 2c394d436a
commit 0acd37918e
3 changed files with 286 additions and 22 deletions
@@ -981,6 +981,7 @@ const enUS = {
generatedOutline: 'Confirm outline',
outlinePreview: 'Outline preview',
keyPoints: 'Key points',
knowledgeGroups: 'Knowledge base references',
review: 'Review',
notes: 'Notes',
inputParams: 'Input params',
@@ -1063,6 +1064,7 @@ const enUS = {
customTitle: 'Override the AI title here if needed',
customOutline: 'Add another section, for example “Buying advice”',
keyPoints: 'Add any extra instructions, key angles, or constraints for the article',
knowledgeGroups: 'Optional, used to enrich the article context and factual background',
competitorWebsite: 'Official site or landing page',
competitorName: 'Competitor name',
competitorDescription: 'One-line positioning or summary',
@@ -1111,6 +1113,7 @@ const enUS = {
selectBrandBeforeQuestion: 'Select the current company in the top bar before continuing.',
missingPrimaryQuestion: 'Choose one primary brand question.',
missingTitle: 'Please choose or enter an article title.',
missingReviewIntroHook: 'Choose one review intro hook first.',
missingOutline: 'Please select at least one outline section.',
missingGeneratedOutline: 'Please generate and confirm the article outline first.',
customOutlineTooLong: 'Custom sections cannot exceed {count} characters.',
@@ -954,6 +954,7 @@ const zhCN = {
generatedOutline: '确认文章大纲',
outlinePreview: '大纲预览',
keyPoints: '文章关键要点',
knowledgeGroups: '引用知识库',
review: '提交前确认',
notes: '生成说明',
inputParams: '输入参数',
@@ -1028,6 +1029,7 @@ const zhCN = {
customTitle: '如需覆盖 AI 标题,可在这里直接输入',
customOutline: '补充一个额外结构,例如“选购建议”',
keyPoints: '告诉 AI 还需要强调哪些要点、风格或限制条件',
knowledgeGroups: '可选,用于补充文章内容和事实背景',
competitorWebsite: '官网或落地页地址',
competitorName: '竞品名称',
competitorDescription: '一句话说明竞品定位或特点',
+281 -22
View File
@@ -148,9 +148,40 @@ interface ResolvedOutlineOption {
}
const templateTokenPattern = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g
const cjkTextPattern = /[\u3400-\u9fff]/
const englishReviewIntroHookCopy: Record<
string,
{ label: string; description: string; badge: string }
> = {
question: {
label: 'Ask a question',
description: 'Open with an interesting question that pulls readers forward',
badge: 'Q',
},
fact: {
label: 'Share a fact',
description: 'Start with a surprising fact or data point',
badge: 'F',
},
quote: {
label: 'Use a quote',
description: 'Begin with a relevant quote or industry view',
badge: 'Q',
},
story: {
label: 'Tell a story',
description: 'Lead with a concrete scene or short story',
badge: 'S',
},
feeling: {
label: 'Share a feeling',
description: 'Use a first-person impression to create empathy',
badge: 'I',
},
}
const queryClient = useQueryClient()
const { locale: uiLocale, t } = useI18n()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const companyStore = useCompanyStore()
@@ -268,6 +299,7 @@ const templateCardConfig = computed<TemplateCardConfig>(() =>
const wizardConfig = computed<TemplateWizardConfig | null>(
() => templateCardConfig.value.wizard ?? null,
)
const isArticleEnglish = computed(() => articleLocale.value === 'en-US')
const isResearchReportTemplate = computed(
() => templateDetail.value?.template_key === 'research_report',
)
@@ -314,8 +346,8 @@ const canManageCompetitors = computed(() => Boolean(selectedBrandId.value))
const customOutlineMaxLength = computed(
() => wizardConfig.value?.outline?.custom_max_length ?? CUSTOM_OUTLINE_MAX_LENGTH,
)
const reviewIntroHookConfig = computed<TemplateReviewIntroHookConfig | undefined>(
() => wizardConfig.value?.structure?.review_intro_hook,
const reviewIntroHookConfig = computed<TemplateReviewIntroHookConfig | undefined>(() =>
localizeReviewIntroHookConfig(wizardConfig.value?.structure?.review_intro_hook),
)
const reviewIntroHookOptions = computed<TemplateReviewIntroHookOption[]>(
() => reviewIntroHookConfig.value?.options ?? [],
@@ -334,7 +366,8 @@ const reviewIntroHookPromptValue = computed(() => {
if (!option) {
return ''
}
return option.description ? `${option.label}${option.description}` : option.label
const separator = isArticleEnglish.value ? ': ' : ''
return option.description ? `${option.label}${separator}${option.description}` : option.label
})
const templateRenderContext = computed(() =>
buildTemplateRenderContext({
@@ -357,7 +390,7 @@ const titleOptions = computed(() => {
templateDetail.value?.template_key,
normalizedBrandName.value,
primaryKeyword.value,
uiLocale.value,
articleLocale.value,
)
})
@@ -392,6 +425,13 @@ const outlinePreviewTitle = computed(
const outlinePreviewCaption = computed(
() => wizardConfig.value?.outline?.preview_caption || t('templates.wizard.hints.preview'),
)
const customOutlinePlaceholder = computed(
() =>
wizardConfig.value?.outline?.custom_placeholder ||
t('templates.wizard.placeholders.customOutline'),
)
const knowledgeGroupLabel = computed(() => t('templates.wizard.sections.knowledgeGroups'))
const knowledgeGroupPlaceholder = computed(() => t('templates.wizard.placeholders.knowledgeGroups'))
const basicStepCopy = computed<TemplateStepConfig>(() => wizardConfig.value?.steps?.basic ?? {})
const structureStepCopy = computed<TemplateStepConfig>(
() => wizardConfig.value?.steps?.structure ?? {},
@@ -558,6 +598,7 @@ function applyDraftArticleState(detail: ArticleDetail): void {
outlineSections.value =
sanitizeOutlineKeys(stringListFromUnknown(draftState.outline_sections)) ?? outlineSections.value
generatedOutline.value = decorateOutlineNodes(parseOutlineNodes(draftState.generated_outline))
sanitizeEnglishArticleLanguageContent()
restoringDraft.value = false
}
@@ -606,6 +647,17 @@ watch(
{ immediate: true },
)
watch(articleLocale, (nextLocale, previousLocale) => {
if (restoringDraft.value || !previousLocale || nextLocale === previousLocale) {
return
}
assistTitles.value = []
selectedTitle.value = ''
generatedOutline.value = []
sanitizeEnglishArticleLanguageContent()
})
watch(
reviewIntroHookOptions,
(options) => {
@@ -814,6 +866,89 @@ function nextOutlineNodeKey(): string {
return `outline-${outlineNodeSeed}`
}
function containsCJKText(value: string | undefined): boolean {
return Boolean(value && cjkTextPattern.test(value))
}
function outlineDraftContainsCJK(node: OutlineDraftNode): boolean {
return containsCJKText(node.outline) || node.children.some(outlineDraftContainsCJK)
}
function sanitizeEnglishArticleLanguageContent(): void {
if (articleLocale.value !== 'en-US') {
return
}
assistTitles.value = assistTitles.value.filter((title) => !containsCJKText(title))
if (containsCJKText(selectedTitle.value)) {
selectedTitle.value = ''
}
if (containsCJKText(customTitle.value)) {
customTitle.value = ''
}
if (containsCJKText(keyPoints.value)) {
keyPoints.value = ''
}
customOutlineSections.value = customOutlineSections.value.filter(
(section) => !containsCJKText(section.label),
)
if (containsCJKText(customOutlineInput.value)) {
customOutlineInput.value = ''
}
if (generatedOutline.value.some(outlineDraftContainsCJK)) {
generatedOutline.value = []
}
}
function localizeConfiguredText(value: string | undefined): string | undefined {
return localizeConfiguredTextForLocale(value, articleLocale.value)
}
function localizeConfiguredTextForLocale(
value: string | undefined,
locale: string,
): string | undefined {
const text = value?.trim()
if (!text) {
return undefined
}
if (locale !== 'en-US') {
return text
}
return containsCJKText(text) ? undefined : text
}
function localizeReviewIntroHookConfig(
config: TemplateReviewIntroHookConfig | undefined,
): TemplateReviewIntroHookConfig | undefined {
if (!config) {
return undefined
}
return {
...config,
title: config.title,
hint: config.hint,
options: config.options?.map(localizeReviewIntroHookOption) ?? [],
}
}
function localizeReviewIntroHookOption(
option: TemplateReviewIntroHookOption,
): TemplateReviewIntroHookOption {
if (!isArticleEnglish.value) {
return option
}
const fallback = englishReviewIntroHookCopy[option.key]
return {
...option,
label: localizeConfiguredText(option.label) || fallback?.label || option.label,
description:
localizeConfiguredText(option.description) || fallback?.description || option.description,
}
}
function validateBasicInfo(): boolean {
if (!normalizedBrandName.value) {
message.warning(t('templates.wizard.messages.missingBrand'))
@@ -1463,7 +1598,9 @@ function addCustomOutlineSection(): void {
}
if (Array.from(label).length > customOutlineMaxLength.value) {
message.warning(
t('templates.wizard.messages.customOutlineTooLong', { count: customOutlineMaxLength.value }),
t('templates.wizard.messages.customOutlineTooLong', {
count: customOutlineMaxLength.value,
}),
)
return
}
@@ -1767,10 +1904,10 @@ function buildOutlineOptions(
templateKey: string | undefined,
context: Record<string, string>,
): ResolvedOutlineOption[] {
const locale = context.locale || 'zh-CN'
const configured = (config?.sections ?? [])
.map((section) => {
const label =
resolveTemplateText(section.label_template, context) || section.label || section.key
const label = resolveOutlineSectionLabel(section, context, locale)
return {
key: section.key,
label,
@@ -1783,7 +1920,7 @@ function buildOutlineOptions(
return configured
}
const defaults = buildDefaultOutlineOptions(templateKey, context.locale || 'zh-CN')
const defaults = buildDefaultOutlineOptions(templateKey, locale)
return defaults.map((label, index) => ({
key: `default-${index}`,
label,
@@ -1791,10 +1928,119 @@ function buildOutlineOptions(
}))
}
function resolveOutlineSectionLabel(
section: TemplateOutlineSectionConfig,
context: Record<string, string>,
locale: string,
): string {
if (locale !== 'en-US') {
return resolveTemplateText(section.label_template, context) || section.label || section.key
}
const configuredTemplate = localizeConfiguredTextForLocale(section.label_template, locale)
const configuredLabel = localizeConfiguredTextForLocale(section.label, locale)
return (
resolveTemplateText(configuredTemplate, context) ||
configuredLabel ||
englishOutlineSectionLabel(section.key, context) ||
section.key
)
}
function englishOutlineSectionLabel(
key: string,
context: Record<string, string>,
): string | undefined {
const topic = context.brand_question || context.primary_keyword || 'the topic'
const brandName = context.brand_name || 'the brand'
switch (key) {
case 'intro':
return 'Introduction'
case 'key_points':
return 'Key Points'
case 'what_is_question':
return `About ${topic}`
case 'what_is_keyword':
return `What is ${topic}?`
case 'features':
return 'Key Features'
case 'pros_cons':
return 'Pros and Cons'
case 'audience':
return 'Best-fit Audience'
case 'pricing':
return 'Pricing'
case 'how_to_choose':
return 'How to Choose'
case 'conclusion':
return 'Conclusion'
case 'overview':
return 'Product Overview'
case 'core_features':
return 'Core Selling Points'
case 'real_experience':
return 'Real-world Experience'
case 'buying_advice':
return 'Buying Advice'
case 'summary':
return 'Executive Summary'
case 'background':
return 'Research Background'
case 'findings':
return 'Key Findings'
case 'market_view':
return 'Market View'
case 'risks':
return 'Risks and Challenges'
case 'action_items':
return 'Recommended Actions'
case 'methodology':
return 'Methodology and Sources'
case 'segment_overview':
return 'Market Segment Overview'
case 'positioning_analysis':
return 'Positioning, Strengths, and Trade-offs'
case 'user_research':
return 'User Research'
case 'target_audience':
return 'Target Audience'
case 'competitor_comparison':
return 'Competitor Comparison'
case 'research_conclusion':
return 'Research Conclusion'
case 'analysis_recommendations':
return 'Analysis and Recommendations'
case 'search_intent':
return 'Search Intent'
case 'brand_overview':
return `What is ${brandName}?`
case 'core_points':
return 'Core Points'
case 'comparison':
return 'Comparisons and Alternatives'
case 'faq':
return 'FAQ'
case 'site_list':
return 'Website List'
default:
return undefined
}
}
function isBlockedOutlineOption(section: ResolvedOutlineOption): boolean {
const key = section.key.trim().toLowerCase()
const label = section.label.trim()
return key === 'site_list' || ['网站列表', '官网列表', '网址列表'].includes(label)
return (
key === 'site_list' ||
[
'网站列表',
'官网列表',
'网址列表',
'Website List',
'Official Website List',
'URL List',
].includes(label)
)
}
function sanitizeOutlineKeys(keys: string[] | undefined): string[] | undefined {
@@ -2090,6 +2336,10 @@ function reviewIntroHookToneClass(key: string): string {
}
function reviewIntroHookBadgeText(key: string): string {
if (isArticleEnglish.value) {
return englishReviewIntroHookCopy[key]?.badge || Array.from(key.trim())[0]?.toUpperCase() || 'H'
}
switch (key) {
case 'question':
return '问'
@@ -2420,7 +2670,9 @@ function onStructureDragEnd(): void {
<div class="wizard-card">
<div class="wizard-card__header">
<div>
<h3>{{ brandCardCopy.title || t('templates.wizard.sections.brandInfo') }}</h3>
<h3>
{{ brandCardCopy.title || t('templates.wizard.sections.brandInfo') }}
</h3>
<p>{{ brandCardCopy.hint || t('templates.wizard.hints.brandFlow') }}</p>
</div>
</div>
@@ -2444,7 +2696,9 @@ function onStructureDragEnd(): void {
{{ t('templates.wizard.sections.brandName') }}
</label>
<div class="current-brand-field">
<strong>{{ normalizedBrandName || t('shell.currentCompanyPlaceholder') }}</strong>
<strong>
{{ normalizedBrandName || t('shell.currentCompanyPlaceholder') }}
</strong>
<span v-if="selectedBrand?.website">{{ selectedBrand.website }}</span>
</div>
</div>
@@ -2541,7 +2795,9 @@ function onStructureDragEnd(): void {
<h3>
{{ competitorsCardCopy.title || t('templates.wizard.sections.competitors') }}
</h3>
<p>{{ competitorsCardCopy.hint || t('templates.wizard.hints.competitors') }}</p>
<p>
{{ competitorsCardCopy.hint || t('templates.wizard.hints.competitors') }}
</p>
</div>
<div class="competitor-header-actions">
<a-button
@@ -2628,8 +2884,12 @@ function onStructureDragEnd(): void {
<div class="wizard-card">
<div class="wizard-card__header">
<div>
<h3>{{ titleCardCopy.title || t('templates.wizard.sections.chooseTitle') }}</h3>
<p>{{ titleCardCopy.hint || t('templates.wizard.hints.titleSelection') }}</p>
<h3>
{{ titleCardCopy.title || t('templates.wizard.sections.chooseTitle') }}
</h3>
<p>
{{ titleCardCopy.hint || t('templates.wizard.hints.titleSelection') }}
</p>
</div>
</div>
@@ -2691,7 +2951,9 @@ function onStructureDragEnd(): void {
<div class="wizard-card">
<div class="wizard-card__header">
<div>
<h3>{{ outlineCardCopy.title || t('templates.wizard.sections.structure') }}</h3>
<h3>
{{ outlineCardCopy.title || t('templates.wizard.sections.structure') }}
</h3>
<p>{{ outlineCardCopy.hint || t('templates.wizard.hints.structure') }}</p>
</div>
</div>
@@ -2745,10 +3007,7 @@ function onStructureDragEnd(): void {
v-model:value="customOutlineInput"
:maxlength="customOutlineMaxLength"
show-count
:placeholder="
wizardConfig?.outline?.custom_placeholder ||
t('templates.wizard.placeholders.customOutline')
"
:placeholder="customOutlinePlaceholder"
@pressEnter="addCustomOutlineSection"
/>
<a-button @click="addCustomOutlineSection">
@@ -2903,10 +3162,10 @@ function onStructureDragEnd(): void {
</div>
<div class="field-item field-item--compact">
<label>引用知识库</label>
<label>{{ knowledgeGroupLabel }}</label>
<KnowledgeGroupSelect
v-model="selectedKnowledgeGroupIds"
placeholder="可选,用于补充文章内容和事实背景"
:placeholder="knowledgeGroupPlaceholder"
/>
</div>