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:
@@ -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
@@ -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 分级 -------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ?? {})
|
||||
|
||||
Reference in New Issue
Block a user