feat(admin): 从网站导入对话框与审核页

Task 9 装配:site-import/index.vue 单文件两形态——导入对话框(URL
启动/3s 轮询五键/phase 文案/failed retry 复活形态/7.5 分钟窗口/404
过期终态/放弃确认)与审核页 fullscreen dialog(品牌表单+头像
dataURL 预览与跳过勾选/四件套 tabs 等宽编辑+UTF-8 字节计数/墙尾巴
三语单行/联系方式两开关/warnings 分级黄条)。

保存序列执行器按 buildSaveSequence 依次调既有端点(每步 GET 在该步
执行时取,avatar 上传写的版本不丢);wall 步消费 Task 9 备忘新增的
wallPoolDispatch 纯函数(保存时新鲜 GET 池判断逐语言替换/追加,
append 提示「原池含人工编辑句」),contact 步读-改-写仅覆写两开关。
部分失败停当前组,重试跳过已完成组。

两入口接线:租户详情抽屉(超管,停用禁用)与配置中心页头(两角色,
slug=当前租户上下文),completed 后各自刷新。

测试 201→206(wallPoolDispatch 五例 TDD);pnpm build 零错误;
dts 行尾漂移按纪律还原。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-20 22:30:44 +08:00
parent 1e006d8f45
commit 44c53429d9
5 changed files with 1398 additions and 1 deletions
+27
View File
@@ -65,6 +65,15 @@
<p class="page-sub">按左侧分组编辑——保存后立即生效并产生新版本,历史可随时回滚</p>
</div>
<div class="header-side">
<ElButton
type="primary"
plain
:disabled="!activeSlug"
title="抓取官网 → AI 生成品牌/话术草稿 → 审核后写入当前租户配置"
@click="openSiteImport"
>
从网站导入
</ElButton>
<ElTag v-if="version !== null" type="info" size="large">当前版本 v{{ version }}</ElTag>
<ElSelect
v-if="isSuper"
@@ -592,12 +601,16 @@
</template>
</div>
</ElCard>
<!-- 从网站导入(Task 9):页头按钮打开,保存成功后整页重载配置 -->
<SiteImportDialog ref="siteImportRef" @completed="onSiteImportCompleted" />
</div>
</template>
<script setup lang="ts">
import { onBeforeRouteLeave } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import SiteImportDialog from '@/views/site-import/index.vue'
import { useAuthStore } from '@/store/modules/auth'
import { fetchTenantDetail, type TenantDetailResponse } from '@/api/tenants'
import { getWidgetDomain } from '@/api/settings'
@@ -1086,6 +1099,20 @@
// ---- 装配(租户切换:重置表单/版本/嵌入选择,配置+详情双载) ------------------------------
// ---- 从网站导入(Task 9):页头按钮两角色可见,slug = 当前租户上下文 --------------------
const siteImportRef = ref<InstanceType<typeof SiteImportDialog> | null>(null)
function openSiteImport(): void {
if (activeSlug.value) siteImportRef.value?.open(activeSlug.value)
}
/** 保存全部成功——整页重载配置(服务端权威态覆盖本地快照/品牌表单;
* 版本号与品牌表单随 loadConfig 一并刷新) */
function onSiteImportCompleted(): void {
reloadConfig()
}
/** 切租户前置确认(Task 14):ElSelect 单向 :model-value——取消时 prop
* 未变,选择器显示自动还原旧租户且草稿保留;确认后才写 activeSlug 触发
* 下方 watch 重载(草稿随重载丢弃) */
@@ -23,6 +23,7 @@ import {
draftToBrandForm,
draftToPromptsTabs,
isTerminal404,
wallPoolDispatch,
warningsLevel,
type SiteImportDraft
} from '../model'
@@ -198,3 +199,59 @@ describe('isTerminal404', () => {
expect(isTerminal404(null)).toBe(false)
})
})
// ---- wallPoolDispatch(Task 9:保存时以新鲜 GET 快照分派替换/追加) ----------------------
describe('wallPoolDispatch', () => {
it('出厂单句池_整句替换', () => {
// 出厂形态(seed_profiles auto/jiancai 的 deflections_contact:每语言单句)
const current = {
en: ['Our manager will send the exact quote to your WhatsApp shortly. Anything else about our pipes?'],
zh: ['客户经理稍后会把准确报价发到你的 WhatsApp。还想了解什么管道产品?'],
es: ['Nuestro gerente te enviará el precio exacto a tu WhatsApp en breve.']
}
const d = wallPoolDispatch(current, { en: 'tail-en', zh: 'tail-zh', es: 'tail-es' })
expect(d.mode).toBe('replace')
expect(d.pools).toEqual({ en: ['tail-en'], zh: ['tail-zh'], es: ['tail-es'] })
})
it('人工多句池_追加到末项', () => {
const current = { en: ['human-a', 'human-b'], zh: ['人工句一', '人工句二'], es: ['uno', 'dos'] }
const d = wallPoolDispatch(current, { en: 'tail-en', zh: 'tail-zh', es: 'tail-es' })
expect(d.mode).toBe('append')
// 原句保留 + 新句 push 到末项
expect(d.pools.en).toEqual(['human-a', 'human-b', 'tail-en'])
expect(d.pools.zh).toEqual(['人工句一', '人工句二', 'tail-zh'])
expect(d.pools.es).toEqual(['uno', 'dos', 'tail-es'])
})
it('es新句为null_整键删除', () => {
// es 尾巴缺失 = 合法降级(spec §4):pools.es 为 null——消费方载荷
// 不触 es 键(保持 GET 快照原值),「整键删除」指分派结果该键为 null
const current = { en: ['a'], zh: ['中'], es: ['es-现句'] }
const d = wallPoolDispatch(current, { en: 'e', zh: 'z', es: null })
expect(d.mode).toBe('replace')
expect(d.pools.en).toEqual(['e'])
expect(d.pools.zh).toEqual(['z'])
expect(d.pools.es).toBeNull()
})
it('混合形态:多句语言追加、单句语言替换(mode 取 append)', () => {
const current = { en: ['a', 'b'], zh: ['仅一句'] }
const d = wallPoolDispatch(current, { en: 'tail', zh: '替换句', es: null })
expect(d.mode).toBe('append')
expect(d.pools.en).toEqual(['a', 'b', 'tail'])
expect(d.pools.zh).toEqual(['替换句'])
expect(d.pools.es).toBeNull()
})
it('现池缺语言键/非数组值防御:按空池走替换', () => {
const d = wallPoolDispatch(
{ en: 'not-an-array' } as unknown as Record<string, string[]>,
{ en: 'e', zh: 'z', es: null }
)
expect(d.pools.en).toEqual(['e'])
expect(d.pools.zh).toEqual(['z'])
expect(d.pools.es).toBeNull()
})
})
File diff suppressed because it is too large Load Diff
+51 -1
View File
@@ -14,7 +14,8 @@
* (有则 dataURL→Blob→POST brand/avatar)→ PUT config/brand(读-改-
* 写)→ PUT config/prompts → PUT config/wall(读-改-写,仅尾巴句)→
* PUT config/contact。本模块只构造步骤描述(kind/label/note),实际
* 请求编排与部分失败重试在 Task 9 的 index.vue。
* 请求编排与部分失败重试在 Task 9 的 index.vue;墙步的池形态分派由
* wallPoolDispatch 纯函数承担(Task 9 备忘——保存时新鲜 GET 快照判断)。
*/
import type { ApiError } from '@/api/client'
@@ -218,6 +219,55 @@ export function buildSaveSequence(draft: SiteImportDraft): SaveStep[] {
return steps
}
// ---- 墙池形态分派(Task 9——保存时以新鲜 GET 快照判断替换/追加) ------------------------
/** 分派结果:mode=append 时 UI 提示「原池含人工编辑句,已追加而非替换」;
* pools 按语言给出目标池,null = 不触该语言(es 尾巴缺失的合法降级,
* 载荷保持 GET 快照原值) */
export interface WallDispatch {
mode: 'replace' | 'append'
pools: Record<'en' | 'zh' | 'es', string[] | null>
}
/** 现池语言槽防御归一:非数组值按空池对待(字符串项过滤) */
function asStringList(value: unknown): string[] {
return Array.isArray(value)
? value.filter((s): s is string => typeof s === 'string')
: []
}
/**
* deflections_contact 池形态分派(spec §3.2 / §5,Task 9 备忘升为纯函数):
* 保存时以**新鲜 GET** 的现池为判断基准,逐语言分派——
* - 池内 >1 句 = 含人工编辑句 → 追加到末项(原句保留);
* - ≤1 句(出厂单句/空/缺键)→ 整句替换为新尾巴;
* - es 新尾巴 null → pools.es 为 null(不触该语言池)。
* 任一语言走了追加 → mode='append'(调用方展示追加提示文案)。
*/
export function wallPoolDispatch(
currentPools: Record<string, string[]>,
newTails: { en: string; zh: string; es: string | null }
): WallDispatch {
let appended = false
const pools = {} as WallDispatch['pools']
const current = currentPools ?? {}
for (const lang of ['en', 'zh', 'es'] as const) {
const tail = newTails[lang]
if (tail === null) {
pools[lang] = null
continue
}
const sentences = asStringList(current[lang])
if (sentences.length > 1) {
appended = true
pools[lang] = [...sentences, tail]
} else {
pools[lang] = [tail]
}
}
return { mode: appended ? 'append' : 'replace', pools }
}
// ---- warnings 分级 -------------------------------------------------------------------
/**
+25
View File
@@ -121,6 +121,17 @@
{{ detail.status === 'active' ? '启用' : '停用' }}
</ElTag>
</span>
<ElButton
v-if="detail"
size="small"
type="primary"
plain
:disabled="detail.status !== 'active'"
:title="detail.status !== 'active' ? '租户已停用,复启用后可导入' : ''"
@click="openSiteImport"
>
从网站导入
</ElButton>
<ElButton
v-if="detail"
size="small"
@@ -478,11 +489,15 @@
<ElButton type="primary" :loading="domainBinding" @click="onBindDomain"> 绑定 </ElButton>
</template>
</ElDialog>
<!-- ==================== 从网站导入(Task 9) ==================== -->
<SiteImportDialog ref="siteImportRef" @completed="refreshAfterMutation" />
</div>
</template>
<script setup lang="ts">
import { ElMessageBox } from 'element-plus'
import SiteImportDialog from '@/views/site-import/index.vue'
import SiteOverlayFields from '@/components/business/SiteOverlayFields/index.vue'
import {
OVERLAY_LABELS,
@@ -676,6 +691,16 @@
detailError.value = ''
}
// ---- 从网站导入(Task 9) ------------------------------------------------------------
/** 详情抽屉头按钮 → SiteImportDialog 传当前租户 slug;保存全部成功后
* 刷新列表 + 详情(版本号会随各组保存递增) */
const siteImportRef = ref<InstanceType<typeof SiteImportDialog> | null>(null)
function openSiteImport(): void {
if (detailSlug.value) siteImportRef.value?.open(detailSlug.value)
}
/** 站点覆盖键列表(空值键也计入——展示实际落库形状) */
function overlayKeys(site: SiteSummary): string[] {
return Object.keys(site.brand_overlay ?? {})