From 0acd37918ecd85ef208a7439576d03536b9b599a Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 9 Jul 2026 19:39:29 +0800 Subject: [PATCH] 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) --- apps/admin-web/src/i18n/messages/en-US.ts | 3 + apps/admin-web/src/i18n/messages/zh-CN.ts | 2 + .../src/views/TemplateWizardView.vue | 303 ++++++++++++++++-- 3 files changed, 286 insertions(+), 22 deletions(-) diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index 616e515..b4b5e4b 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -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.', diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index 1ef1f7f..786f2cc 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -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: '一句话说明竞品定位或特点', diff --git a/apps/admin-web/src/views/TemplateWizardView.vue b/apps/admin-web/src/views/TemplateWizardView.vue index 4d09fc0..4baf3a0 100644 --- a/apps/admin-web/src/views/TemplateWizardView.vue +++ b/apps/admin-web/src/views/TemplateWizardView.vue @@ -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(() => const wizardConfig = computed( () => 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( - () => wizardConfig.value?.structure?.review_intro_hook, +const reviewIntroHookConfig = computed(() => + localizeReviewIntroHookConfig(wizardConfig.value?.structure?.review_intro_hook), ) const reviewIntroHookOptions = computed( () => 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(() => wizardConfig.value?.steps?.basic ?? {}) const structureStepCopy = computed( () => 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, ): 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, + 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 | 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 {
-

{{ brandCardCopy.title || t('templates.wizard.sections.brandInfo') }}

+

+ {{ brandCardCopy.title || t('templates.wizard.sections.brandInfo') }} +

{{ brandCardCopy.hint || t('templates.wizard.hints.brandFlow') }}

@@ -2444,7 +2696,9 @@ function onStructureDragEnd(): void { {{ t('templates.wizard.sections.brandName') }}
- {{ normalizedBrandName || t('shell.currentCompanyPlaceholder') }} + + {{ normalizedBrandName || t('shell.currentCompanyPlaceholder') }} + {{ selectedBrand.website }}
@@ -2541,7 +2795,9 @@ function onStructureDragEnd(): void {

{{ competitorsCardCopy.title || t('templates.wizard.sections.competitors') }}

-

{{ competitorsCardCopy.hint || t('templates.wizard.hints.competitors') }}

+

+ {{ competitorsCardCopy.hint || t('templates.wizard.hints.competitors') }} +

-

{{ titleCardCopy.title || t('templates.wizard.sections.chooseTitle') }}

-

{{ titleCardCopy.hint || t('templates.wizard.hints.titleSelection') }}

+

+ {{ titleCardCopy.title || t('templates.wizard.sections.chooseTitle') }} +

+

+ {{ titleCardCopy.hint || t('templates.wizard.hints.titleSelection') }} +

@@ -2691,7 +2951,9 @@ function onStructureDragEnd(): void {
-

{{ outlineCardCopy.title || t('templates.wizard.sections.structure') }}

+

+ {{ outlineCardCopy.title || t('templates.wizard.sections.structure') }} +

{{ outlineCardCopy.hint || t('templates.wizard.hints.structure') }}

@@ -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" /> @@ -2903,10 +3162,10 @@ function onStructureDragEnd(): void {
- +