feat(admin-web): localize template wizard for en-US articles
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:
@@ -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: '一句话说明竞品定位或特点',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user