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>
|
<p class="page-sub">按左侧分组编辑——保存后立即生效并产生新版本,历史可随时回滚</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-side">
|
<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>
|
<ElTag v-if="version !== null" type="info" size="large">当前版本 v{{ version }}</ElTag>
|
||||||
<ElSelect
|
<ElSelect
|
||||||
v-if="isSuper"
|
v-if="isSuper"
|
||||||
@@ -592,12 +601,16 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</ElCard>
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- 从网站导入(Task 9):页头按钮打开,保存成功后整页重载配置 -->
|
||||||
|
<SiteImportDialog ref="siteImportRef" @completed="onSiteImportCompleted" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeRouteLeave } from 'vue-router'
|
import { onBeforeRouteLeave } from 'vue-router'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
import SiteImportDialog from '@/views/site-import/index.vue'
|
||||||
import { useAuthStore } from '@/store/modules/auth'
|
import { useAuthStore } from '@/store/modules/auth'
|
||||||
import { fetchTenantDetail, type TenantDetailResponse } from '@/api/tenants'
|
import { fetchTenantDetail, type TenantDetailResponse } from '@/api/tenants'
|
||||||
import { getWidgetDomain } from '@/api/settings'
|
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
|
/** 切租户前置确认(Task 14):ElSelect 单向 :model-value——取消时 prop
|
||||||
* 未变,选择器显示自动还原旧租户且草稿保留;确认后才写 activeSlug 触发
|
* 未变,选择器显示自动还原旧租户且草稿保留;确认后才写 activeSlug 触发
|
||||||
* 下方 watch 重载(草稿随重载丢弃) */
|
* 下方 watch 重载(草稿随重载丢弃) */
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
draftToBrandForm,
|
draftToBrandForm,
|
||||||
draftToPromptsTabs,
|
draftToPromptsTabs,
|
||||||
isTerminal404,
|
isTerminal404,
|
||||||
|
wallPoolDispatch,
|
||||||
warningsLevel,
|
warningsLevel,
|
||||||
type SiteImportDraft
|
type SiteImportDraft
|
||||||
} from '../model'
|
} from '../model'
|
||||||
@@ -198,3 +199,59 @@ describe('isTerminal404', () => {
|
|||||||
expect(isTerminal404(null)).toBe(false)
|
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(读-改-
|
* (有则 dataURL→Blob→POST brand/avatar)→ PUT config/brand(读-改-
|
||||||
* 写)→ PUT config/prompts → PUT config/wall(读-改-写,仅尾巴句)→
|
* 写)→ PUT config/prompts → PUT config/wall(读-改-写,仅尾巴句)→
|
||||||
* PUT config/contact。本模块只构造步骤描述(kind/label/note),实际
|
* PUT config/contact。本模块只构造步骤描述(kind/label/note),实际
|
||||||
* 请求编排与部分失败重试在 Task 9 的 index.vue。
|
* 请求编排与部分失败重试在 Task 9 的 index.vue;墙步的池形态分派由
|
||||||
|
* wallPoolDispatch 纯函数承担(Task 9 备忘——保存时新鲜 GET 快照判断)。
|
||||||
*/
|
*/
|
||||||
import type { ApiError } from '@/api/client'
|
import type { ApiError } from '@/api/client'
|
||||||
|
|
||||||
@@ -218,6 +219,55 @@ export function buildSaveSequence(draft: SiteImportDraft): SaveStep[] {
|
|||||||
return steps
|
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 分级 -------------------------------------------------------------------
|
// ---- warnings 分级 -------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -121,6 +121,17 @@
|
|||||||
{{ detail.status === 'active' ? '启用' : '停用' }}
|
{{ detail.status === 'active' ? '启用' : '停用' }}
|
||||||
</ElTag>
|
</ElTag>
|
||||||
</span>
|
</span>
|
||||||
|
<ElButton
|
||||||
|
v-if="detail"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
:disabled="detail.status !== 'active'"
|
||||||
|
:title="detail.status !== 'active' ? '租户已停用,复启用后可导入' : ''"
|
||||||
|
@click="openSiteImport"
|
||||||
|
>
|
||||||
|
从网站导入
|
||||||
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="detail"
|
v-if="detail"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -478,11 +489,15 @@
|
|||||||
<ElButton type="primary" :loading="domainBinding" @click="onBindDomain"> 绑定 </ElButton>
|
<ElButton type="primary" :loading="domainBinding" @click="onBindDomain"> 绑定 </ElButton>
|
||||||
</template>
|
</template>
|
||||||
</ElDialog>
|
</ElDialog>
|
||||||
|
|
||||||
|
<!-- ==================== 从网站导入(Task 9) ==================== -->
|
||||||
|
<SiteImportDialog ref="siteImportRef" @completed="refreshAfterMutation" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
import SiteImportDialog from '@/views/site-import/index.vue'
|
||||||
import SiteOverlayFields from '@/components/business/SiteOverlayFields/index.vue'
|
import SiteOverlayFields from '@/components/business/SiteOverlayFields/index.vue'
|
||||||
import {
|
import {
|
||||||
OVERLAY_LABELS,
|
OVERLAY_LABELS,
|
||||||
@@ -676,6 +691,16 @@
|
|||||||
detailError.value = ''
|
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[] {
|
function overlayKeys(site: SiteSummary): string[] {
|
||||||
return Object.keys(site.brand_overlay ?? {})
|
return Object.keys(site.brand_overlay ?? {})
|
||||||
|
|||||||
Reference in New Issue
Block a user