diff --git a/admin/src/api/siteImport.ts b/admin/src/api/siteImport.ts new file mode 100644 index 0000000..f29c260 --- /dev/null +++ b/admin/src/api/siteImport.ts @@ -0,0 +1,77 @@ +/** + * site-import API(POST 启动 / GET 轮询)——Task 8;消费方 + * site-import 视图(Task 9)。 + * + * 端点事实源:app/platform/routes/site_import_routes.py + * (/api/platform/tenants/{slug}/site-import[/{task_id}], + * _tenant_guard 守租户——超管与租户管理员两角色可用)。 + * + * 契约要点(Task 7 评审备忘): + * - POST 200(非 201)→ {task_id};body 单形态发送——新建 {url} / + * 复活 {retry: 'generate', task_id}(retry 形态后端忽略 url 字段); + * - 429(每租户 15 分钟 3 次频控)/ 409(并发上限·非 generate-failed + * 态复活)的 detail 已是白话文案——调用方原样展示,不二次翻译; + * - GET 五键 status/phase/pages/draft/error——**无 failed_phase**:前端 + * retry 按钮对 failed 一律展示,能否复活由后端 409 兜底判定; + * - GET 404 = 任务不存在/已过期(终态语义——视图层 isTerminal404 消费)。 + * + * 草稿包类型 SiteImportDraft 是 spec §4 的单一 TS 镜像,定义在视图 + * model(type-only 反向引用,无运行时耦合——api 层不 import 视图代码)。 + */ +import { http, type RequestOptions } from './client' +import type { SiteImportDraft } from '@/views/site-import/model' + +/** 任务状态(app/platform/site_import/task.py TaskState 镜像) */ +export type SiteImportStatus = 'pending' | 'crawling' | 'generating' | 'done' | 'failed' + +/** GET 轮询响应五键(spec §4;后端恒返五键——非 done 时 draft 为 null) */ +export interface SiteImportTaskResponse { + status: SiteImportStatus + /** 运行中阶段('crawling' | 'generating';非运行态 null) */ + phase: string | null + /** 已抓页数(运行中递增;BFS 总数不可知——展示「第 N 页」不展示 M) */ + pages: number + /** 草稿包(仅 status=done 非 null) */ + draft: SiteImportDraft | null + /** 白话失败原因(仅 status=failed 非 null) */ + error: string | null +} + +/** POST body 单形态:新建 {url} / 复活 {retry, task_id}(后端对 retry + * 形态忽略 url——前端按用途二选一发送,不带冗余键) */ +export type StartSiteImportBody = { url: string } | { retry: 'generate'; task_id: string } + +/** POST 启动响应 */ +export interface StartSiteImportResponse { + task_id: string +} + +/** 启动/复活导入任务(POST /api/platform/tenants/{slug}/site-import)。 + * 新建传 {url}(http(s) 公网地址——内网/非法地址 400);复活传 + * {retry: 'generate', task_id}(同 task_id 只重跑生成步,复用已抓文本)。 + * 429/409/404 经既有 ApiError 抛(detail 为白话文案)。 */ +export function startSiteImport( + slug: string, + body: StartSiteImportBody, + config?: RequestOptions +): Promise { + return http.post( + `/api/platform/tenants/${slug}/site-import`, + body, + config + ) +} + +/** 轮询任务状态(GET /api/platform/tenants/{slug}/site-import/{task_id})。 + * 3s 间隔;7.5 分钟窗口外自动停(> 服务端 7 分钟总超时)。404 = 过期 + * 终态(isTerminal404)——跨租户持他人 task_id 同为 404。 */ +export function getSiteImportTask( + slug: string, + taskId: string, + config?: RequestOptions +): Promise { + return http.get( + `/api/platform/tenants/${slug}/site-import/${taskId}`, + config + ) +} diff --git a/admin/src/views/site-import/__tests__/model.test.ts b/admin/src/views/site-import/__tests__/model.test.ts new file mode 100644 index 0000000..e0cb67c --- /dev/null +++ b/admin/src/views/site-import/__tests__/model.test.ts @@ -0,0 +1,200 @@ +/** + * site-import 视图模型单元测试(Task 8) + * + * 钉住 src/views/site-import/model.ts 纯函数契约(事实源: + * app/platform/site_import/generator.py build() 的草稿包形状 spec §4 + * + app/platform/routes/site_import_routes.py 的 GET 五键/POST 单形态): + * - draftToBrandForm:草稿 brand 八键 → 审核页品牌表单(含头像 dataURL + * 预览槽——avatar null 时表单 avatarDataUrl 为 null); + * - draftToPromptsTabs:四件套 → tabs 结构(key/label/text); + * - dataUrlToBlob:data:;base64 解码为 Blob(type/字节正确;非 + * data URL / 缺 base64 标记 / 非法 base64 → null)——头像上传步的源; + * - buildSaveSequence:保存序列 头像→brand→prompts→wall→contact(头像 + * 空则无头像步;brand 的 GET 语义在头像上传之后——spec §5 上传写新 + * 版本含 avatar 文件名,读-改-写才不丢;wall 带池形态分派注记;每步 + * 含 label 供进度展示); + * - warningsLevel:规格判定=info / 剔除·数字移除=warning / 其他=info; + * - isTerminal404:ApiError status 404 → 终态(任务过期,停止轮询)。 + */ +import { describe, expect, it } from 'vitest' +import { + buildSaveSequence, + dataUrlToBlob, + draftToBrandForm, + draftToPromptsTabs, + isTerminal404, + warningsLevel, + type SiteImportDraft +} from '../model' + +/** 假草稿包(spec §4 形状,数值为编造样本)——品牌八键/头像 data_url/ + * 四件套/三语墙尾巴/contact 双开关/warnings 规格判定+剔除各一 */ +const DRAFT: SiteImportDraft = { + brand: { + title: 'Televic Support', + color: '#1a73e8', + whatsapp: 'https://wa.me/34600000000', + email: 'info@televic.com', + welcome: 'Hi! What project are you working on?', + topics: ['Sic Bars', 'Graphite Components', 'Ceramic Tubes'], + placeholder: 'Ask about our products...', + privacy_url: '' + }, + avatar: { data_url: 'data:image/png;base64,iVBORw0KGgo=', mime: 'image/png' }, + prompts: { + system: 'You are the support assistant for Televic.', + company_facts: '- Founded in 1998\n- Based in Valencia', + extractor: 'Extract: name, whatsapp, email', + evaluator: 'Rate the intent level.' + }, + wall_tails: { + en: 'Our manager will send you the full quotation on WhatsApp.', + zh: '客户经理会把完整报价发您——请留下您的联系方式。', + es: 'Nuestro gerente le enviará el presupuesto completo.' + }, + contact: { cc_completion: true, email_light_ask: true }, + warnings: [ + '规格判定:公开(依据:产品页含 MPa/°C 参数表)', + '已剔除疑似模板内容(联系方式):555-0100' + ] +} + +/** 无头像形态(es 尾巴缺失降级一并覆盖——avatar null / es null 均合法) */ +const DRAFT_NO_AVATAR: SiteImportDraft = { + ...DRAFT, + avatar: null, + wall_tails: { en: DRAFT.wall_tails.en, zh: DRAFT.wall_tails.zh, es: null } +} + +// ---- draftToBrandForm ---------------------------------------------------------------- + +describe('draftToBrandForm', () => { + it('草稿品牌八键映射表单模型', () => { + const form = draftToBrandForm(DRAFT) + expect(form.title).toBe('Televic Support') + expect(form.color).toBe('#1a73e8') + expect(form.whatsapp).toBe('https://wa.me/34600000000') + expect(form.email).toBe('info@televic.com') + expect(form.welcome).toBe('Hi! What project are you working on?') + expect(form.topics).toEqual(['Sic Bars', 'Graphite Components', 'Ceramic Tubes']) + expect(form.placeholder).toBe('Ask about our products...') + expect(form.privacy_url).toBe('') + // 头像预览槽来自草稿 avatar.data_url(非 brand 组——brand.avatar 是 + // 上传后的文件名,由上传端点写版本,不在草稿里) + expect(form.avatarDataUrl).toBe('data:image/png;base64,iVBORw0KGgo=') + }) + + it('avatar 为 null 时表单 avatarDataUrl 为 null(其余品牌键不受影响)', () => { + const form = draftToBrandForm(DRAFT_NO_AVATAR) + expect(form.avatarDataUrl).toBeNull() + expect(form.title).toBe('Televic Support') + }) +}) + +// ---- draftToPromptsTabs ---------------------------------------------------------------- + +describe('draftToPromptsTabs', () => { + it('四件套映射 tabs 结构(key/label/text)', () => { + const tabs = draftToPromptsTabs(DRAFT) + expect(tabs.map((t) => t.key)).toEqual(['system', 'company_facts', 'extractor', 'evaluator']) + for (const tab of tabs) { + expect(tab.label.trim()).not.toBe('') + expect(tab.text).toBe(DRAFT.prompts[tab.key]) + } + }) +}) + +// ---- dataUrlToBlob ---------------------------------------------------------------- + +describe('dataUrlToBlob', () => { + it('data:image/png;base64 解码为 Blob(type 正确/字节正确)', async () => { + const blob = dataUrlToBlob('data:image/png;base64,iVBORw0KGgo=') + expect(blob).not.toBeNull() + expect(blob!.type).toBe('image/png') + // atob('iVBORw0KGgo=') → PNG 魔数 89 50 4E 47 0D 0A 1A 0A + const bytes = new Uint8Array(await blob!.arrayBuffer()) + expect([...bytes]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + }) + + it('非 data URL / 缺 base64 标记 / 非法 base64 → null', () => { + expect(dataUrlToBlob('https://example.com/logo.png')).toBeNull() + expect(dataUrlToBlob('data:image/png,rawbytes')).toBeNull() + expect(dataUrlToBlob('')).toBeNull() + expect(dataUrlToBlob('data:image/png;base64,!!!not-base64!!!')).toBeNull() + }) +}) + +// ---- buildSaveSequence ---------------------------------------------------------------- + +describe('buildSaveSequence', () => { + it('保存序列:头像→brand→prompts→wall→contact 五步', () => { + const seq = buildSaveSequence(DRAFT) + expect(seq.map((s) => s.kind)).toEqual(['avatar', 'brand', 'prompts', 'wall', 'contact']) + }) + + it('avatar 为 null 时四步(无头像步)', () => { + const seq = buildSaveSequence(DRAFT_NO_AVATAR) + expect(seq.map((s) => s.kind)).toEqual(['brand', 'prompts', 'wall', 'contact']) + }) + + it('brand 步骤的 GET 在头像上传之后(顺序语义——序列注释里说明)', () => { + const seq = buildSaveSequence(DRAFT) + const avatarIdx = seq.findIndex((s) => s.kind === 'avatar') + const brandIdx = seq.findIndex((s) => s.kind === 'brand') + expect(avatarIdx).toBeLessThan(brandIdx) + // 顺序约束落在 brand 步注记里(Task 9 装配消费的执行语义) + expect(seq[brandIdx].note).toContain('头像') + }) + + it('wall 步骤带池形态分派信息', () => { + const wall = buildSaveSequence(DRAFT).find((s) => s.kind === 'wall')! + expect(wall.note).toContain('deflections_contact') + expect(wall.note).toContain('替换') + expect(wall.note).toContain('追加') + }) + + it('每步含 label 供进度展示', () => { + for (const step of buildSaveSequence(DRAFT)) { + expect(step.label.trim()).not.toBe('') + } + }) +}) + +// ---- warningsLevel ---------------------------------------------------------------- + +describe('warningsLevel', () => { + it('规格判定=info / 剔除=warning / 数字移除=warning / 其他=info', () => { + expect(warningsLevel('规格判定:公开(依据:产品页含 MPa/°C 参数表)')).toBe('info') + expect(warningsLevel('规格判定:不公开(依据:产品页参数表判定)')).toBe('info') + expect(warningsLevel('已剔除疑似模板内容(联系方式):555-0100')).toBe('warning') + expect(warningsLevel('已剔除疑似模板内容(事实卡):+86 400-123-4567')).toBe('warning') + expect(warningsLevel('以下数字未在源站出现已移除:500、3000')).toBe('warning') + // 缺省兜底注记属「请人工确认」级——info + expect(warningsLevel('以下内容未生成,已用默认,请审核页确认:welcome、topics')).toBe('info') + }) +}) + +// ---- isTerminal404 ---------------------------------------------------------------- + +/** 最小 ApiError 形状(client.ts 的 ApiError 契约:name + status)。本 + * 测试不引真实 client——避免拉起 axios/element-plus/pinia 传递依赖。 */ +function apiError(status: number): Error & { status: number } { + const err = new Error('任务不存在或已过期') as Error & { status: number } + err.name = 'ApiError' + err.status = status + return err +} + +describe('isTerminal404', () => { + it('404 判终态(任务过期)', () => { + expect(isTerminal404(apiError(404))).toBe(true) + }) + + it('其他错误非终态', () => { + expect(isTerminal404(apiError(409))).toBe(false) + expect(isTerminal404(apiError(500))).toBe(false) + expect(isTerminal404(new Error('网络连接异常'))).toBe(false) + expect(isTerminal404('404')).toBe(false) + expect(isTerminal404(null)).toBe(false) + }) +}) diff --git a/admin/src/views/site-import/model.ts b/admin/src/views/site-import/model.ts new file mode 100644 index 0000000..9d4e8e5 --- /dev/null +++ b/admin/src/views/site-import/model.ts @@ -0,0 +1,248 @@ +/** + * site-import 视图模型纯函数(Task 8)——草稿包→表单/保存序列/warnings 分级。 + * + * 消费方:site-import/index.vue(Task 9 装配)。POST/GET 契约见 + * app/platform/routes/site_import_routes.py 与 src/api/siteImport.ts + * (GET 五键形状/无 failed_phase——retry 对 failed 一律展示,后端 409 + * 兜底判形态)。 + * + * 草稿包形状事实源:app/platform/site_import/generator.py 的 build() + * (spec §4)——brand 八键 / avatar data_url / prompts 四件 / wall_tails + * 三语(es 可 None 合法降级)/ contact 双开关 / warnings 白话清单。 + * + * 保存序列事实源:spec §5「零新写路径」——前端依次调既有端点:头像 + * (有则 dataURL→Blob→POST brand/avatar)→ PUT config/brand(读-改- + * 写)→ PUT config/prompts → PUT config/wall(读-改-写,仅尾巴句)→ + * PUT config/contact。本模块只构造步骤描述(kind/label/note),实际 + * 请求编排与部分失败重试在 Task 9 的 index.vue。 + */ +import type { ApiError } from '@/api/client' + +// ---- 草稿包 TS 镜像(spec §4) ------------------------------------------------------- + +/** 草稿品牌八键(brand 组除 avatar 外的全部字符串键 + topics 列表; + * avatar 是上传后落库的文件名,不在草稿里——草稿侧头像在顶层 avatar 键) */ +export interface SiteImportDraftBrand { + title: string + color: string + whatsapp: string | null + email: string | null + welcome: string + topics: string[] + placeholder: string + privacy_url: string +} + +/** 草稿包(GET site-import 五键中的 draft,仅 status=done 非 null) */ +export interface SiteImportDraft { + brand: SiteImportDraftBrand + /** 抓到的站点 logo(data: URL + mime);null = 无可上传头像 */ + avatar: { data_url: string; mime: string | null } | null + prompts: { + system: string + company_facts: string + extractor: string + evaluator: string + } + /** 墙尾巴三语(deflections_contact 池的已留资态收尾句);es 可 null + * (合法降级——wall_schema 只必留 en/zh,保存时不触 es 池) */ + wall_tails: { en: string; zh: string; es: string | null } + contact: { cc_completion: boolean; email_light_ask: boolean } + /** 白话注记清单(规格判定/剔除/移除/缺省兜底)——分级见 warningsLevel */ + warnings: string[] +} + +// ---- 品牌表单 ------------------------------------------------------------------------ + +/** 审核页品牌表单(草稿 brand 八键 + 头像 dataURL 预览槽;camelCase 无—— + * 保持后端键名原样,与配置中心 BrandGroupValue 的键序对齐方便对照) */ +export interface SiteImportBrandForm { + title: string + color: string + whatsapp: string + email: string + welcome: string + topics: string[] + placeholder: string + privacy_url: string + /** 草稿头像 data: URL( 预览与上传源);null = 无头像步 */ + avatarDataUrl: string | null +} + +/** 防御读取:字符串(null → ''——PUT brand 的字符串键空串 = 未设置) */ +function asStr(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +/** 草稿 brand 八键 → 审核页品牌表单(头像预览槽取顶层 avatar.data_url) */ +export function draftToBrandForm(draft: SiteImportDraft): SiteImportBrandForm { + const brand = draft.brand ?? ({} as SiteImportDraftBrand) + const topics = Array.isArray(brand.topics) + ? brand.topics.filter((t): t is string => typeof t === 'string') + : [] + return { + title: asStr(brand.title), + color: asStr(brand.color), + whatsapp: asStr(brand.whatsapp), + email: asStr(brand.email), + welcome: asStr(brand.welcome), + topics, + placeholder: asStr(brand.placeholder), + privacy_url: asStr(brand.privacy_url), + avatarDataUrl: + draft.avatar && typeof draft.avatar.data_url === 'string' + ? draft.avatar.data_url + : null + } +} + +// ---- 提示词四件套 tabs ---------------------------------------------------------------- + +/** 四件套键序(与配置中心 PROMPTS_KEYS / ai_service.PROMPTS_KEYS 同构) */ +export const PROMPTS_TAB_KEYS = [ + 'system', + 'company_facts', + 'extractor', + 'evaluator' +] as const + +export type PromptsTabKey = (typeof PROMPTS_TAB_KEYS)[number] + +/** tabs 展示标签(审核页右列 tab 标题) */ +export const PROMPTS_TAB_LABELS: Record = { + system: '系统提示词', + company_facts: '公司事实卡', + extractor: '线索提取器', + evaluator: '意向评估器' +} + +/** 提示词 tab 结构(key 定位 / label 展示 / text 可编辑文本) */ +export interface PromptsTab { + key: PromptsTabKey + label: string + text: string +} + +/** 草稿四件套 → tabs 结构(缺键防御补空串——编辑器空态可改可存) */ +export function draftToPromptsTabs(draft: SiteImportDraft): PromptsTab[] { + const prompts = (draft.prompts ?? {}) as Record + return PROMPTS_TAB_KEYS.map((key) => ({ + key, + label: PROMPTS_TAB_LABELS[key], + text: asStr(prompts[key]) + })) +} + +// ---- dataURL → Blob ------------------------------------------------------------------- + +/** base64 → binary string(浏览器与 vitest happy-dom 均有全局 atob; + * 兜底 Buffer——两者输出一致:每字符一个字节) */ +function decodeBase64(text: string): string { + if (typeof atob === 'function') return atob(text) + return Buffer.from(text, 'base64').toString('binary') +} + +/** + * data:;base64, → Blob(头像上传步的 FormData 源)。 + * 非 data URL / 缺 base64 标记 / 非法 base64 → null(调用方跳过头像步 + * 并提示——草稿头像已过 crawler 魔数校验,null 属防御分支)。 + */ +export function dataUrlToBlob(dataUrl: string): Blob | null { + const match = /^data:([^;,]+);base64,(.+)$/.exec(dataUrl) + if (!match) return null + const [, mime, base64] = match + try { + const binary = decodeBase64(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return new Blob([bytes], { type: mime }) + } catch { + return null + } +} + +// ---- 保存序列 ------------------------------------------------------------------------ + +export type SaveStepKind = 'avatar' | 'brand' | 'prompts' | 'wall' | 'contact' + +/** 保存步骤描述(kind 驱动分派;label 供进度展示「第 N 步: