diff --git a/admin/src/pages/card/builder/index.tsx b/admin/src/pages/card/builder/index.tsx index d4e59d9..fce75b4 100644 --- a/admin/src/pages/card/builder/index.tsx +++ b/admin/src/pages/card/builder/index.tsx @@ -11,7 +11,28 @@ import { Message, Card, Tabs, + Radio, + Popconfirm, + Space, + Tag, + Divider, } from '@arco-design/web-react'; +import { + IconSun, + IconMoon, + IconLaunch, + IconPlus, + IconDelete, + IconCopy, + IconArrowUp, + IconArrowDown, + IconIdcard, + IconFile, + IconFolder, + IconShareAlt, + IconVideoCamera, + IconEdit, +} from '@arco-design/web-react/icon'; import { ImageUploader } from '@/components/Upload'; import useLocale from '@/utils/useLocale'; import { getCssVariables } from '@/style/tokens'; @@ -20,12 +41,14 @@ import type { BentoBlockType, BentoColSpan, BentoRowSpan, + CardProfile, } from '@/types/omni'; import locale from './locale'; import styles from './style/index.module.less'; const GET_BLOCKS_API = '/api/admin/card/bento/getBlocksByCardId'; const SAVE_LAYOUT_API = '/api/admin/card/bento/batchSaveLayout'; +const CARD_PAGE_API = '/api/admin/card/info/page'; const LIBRARY_PATH = '/card/list'; const BLOCK_TYPES: BentoBlockType[] = [ @@ -43,12 +66,30 @@ const SPAN_OPTIONS: Array<{ label: string; value: BentoColSpan | BentoRowSpan }> { label: '2', value: 2 }, ]; +const SOCIAL_PLATFORMS = [ + { label: 'LinkedIn', value: 'linkedin' }, + { label: 'WhatsApp', value: 'whatsapp' }, + { label: 'Telegram', value: 'telegram' }, + { label: 'Twitter / X', value: 'twitter' }, + { label: 'WeChat', value: 'wechat' }, + { label: 'Email', value: 'email' }, +]; + interface CoolResponse { code: number; message?: string; data?: T; } +interface CardPageResult { + list?: CardProfile[]; + pagination?: { + total?: number; + page?: number; + size?: number; + }; +} + interface BlockFormValues { title?: string; content?: string; @@ -57,6 +98,15 @@ interface BlockFormValues { colSpan: BentoColSpan; rowSpan: BentoRowSpan; type: BentoBlockType; + heroName?: string; + heroJobTitle?: string; + heroCompany?: string; + heroSlogan?: string; + socialPlatform?: string; + caseClient?: string; + caseKpiTag?: string; + formPlaceholder?: string; + formSubmitText?: string; } interface SaveBlockPayload extends Omit { @@ -110,6 +160,26 @@ function resolveErrorMessage(error: unknown, fallback: string): string { return fallback; } +function safeParseMeta(metaJson: unknown): Record { + if (!metaJson) { + return {}; + } + if (typeof metaJson === 'object' && metaJson !== null) { + return metaJson as Record; + } + if (typeof metaJson === 'string') { + try { + const parsed = JSON.parse(metaJson); + if (typeof parsed === 'object' && parsed !== null) { + return parsed as Record; + } + } catch { + return {}; + } + } + return {}; +} + function parseBlock(raw: unknown, fallbackIndex: number): BentoBlock | null { if (!raw || typeof raw !== 'object') { return null; @@ -161,33 +231,95 @@ function CardBuilderPage() { const { id } = queryString.parse(location.search); const [form] = Form.useForm(); const draftSeq = useRef(0); + + const [cards, setCards] = useState([]); const [blocks, setBlocks] = useState([]); const [selectedId, setSelectedId] = useState(null); const [loading, setLoading] = useState(false); + const [cardsLoading, setCardsLoading] = useState(false); const [saving, setSaving] = useState(false); + // 环境预览模式:浅色/深色主题、LTR/RTL 镜像 + const [themeMode, setThemeMode] = useState<'light' | 'dark'>('light'); + const [isRtl, setIsRtl] = useState(false); + const hasId = Boolean(id); + const currentCardId = hasId ? Number(id) : null; + const currentCard = useMemo( + () => cards.find((item) => Number(item.id) === currentCardId) ?? null, + [cards, currentCardId] + ); + const tokenStyle = useMemo( () => getCssVariables('light') as React.CSSProperties, [] ); + const typeOptions = useMemo( () => BLOCK_TYPES.map((type) => ({ - label: t[`cardBuilder.type.${type}`], + label: t[`cardBuilder.type.${type}`] || type, value: type, })), [t] ); + + const cardOptions = useMemo( + () => + cards.map((card) => ({ + label: `${card.name} (${card.company || card.title || 'OmniCard'})`, + value: Number(card.id), + })), + [cards] + ); + + // 1. 拉取名片列表;若当前没有 id,则自动重定向至首张名片 + useEffect(() => { + let cancelled = false; + const fetchCards = async () => { + setCardsLoading(true); + try { + const { data: payload } = await axios.post>( + CARD_PAGE_API, + { page: 1, size: 50 } + ); + if (cancelled) { + return; + } + if (payload.code === 1000 && payload.data?.list) { + const fetchedList = payload.data.list; + setCards(fetchedList); + if (!hasId && fetchedList.length > 0) { + history.replace(`/card/builder?id=${fetchedList[0].id}`); + } + } + } catch (error: unknown) { + if (!cancelled) { + Message.warning(resolveErrorMessage(error, 'Failed to fetch cards')); + } + } finally { + if (!cancelled) { + setCardsLoading(false); + } + } + }; + void fetchCards(); + return () => { + cancelled = true; + }; + }, [hasId, history]); + const orderedBlocks = useMemo( () => [...blocks].sort((left, right) => left.sortOrder - right.sortOrder), [blocks] ); + const selectedBlock = useMemo( () => orderedBlocks.find((item) => item.id === selectedId) ?? null, [orderedBlocks, selectedId] ); + // 2. 根据 cardId 拉取 Bento Blocks useEffect(() => { if (!hasId) { return; @@ -233,6 +365,7 @@ function CardBuilderPage() { const lastSelectedIdRef = useRef(null); + // 3. 选中的 block 变化时同步到表单 useEffect(() => { if (!selectedBlock) { form.resetFields(); @@ -241,6 +374,7 @@ function CardBuilderPage() { } if (lastSelectedIdRef.current !== selectedBlock.id) { lastSelectedIdRef.current = selectedBlock.id; + const meta = safeParseMeta(selectedBlock.metaJson); form.setFieldsValue({ title: selectedBlock.title, content: selectedBlock.content, @@ -249,32 +383,45 @@ function CardBuilderPage() { colSpan: selectedBlock.colSpan, rowSpan: selectedBlock.rowSpan, type: selectedBlock.type, + heroName: typeof meta.name === 'string' ? meta.name : undefined, + heroJobTitle: typeof meta.jobTitle === 'string' ? meta.jobTitle : undefined, + heroCompany: typeof meta.company === 'string' ? meta.company : undefined, + heroSlogan: typeof meta.slogan === 'string' ? meta.slogan : undefined, + socialPlatform: typeof meta.platform === 'string' ? meta.platform : undefined, + caseClient: typeof meta.client === 'string' ? meta.client : undefined, + caseKpiTag: typeof meta.kpiTag === 'string' ? meta.kpiTag : undefined, + formPlaceholder: + typeof meta.placeholder === 'string' ? meta.placeholder : undefined, + formSubmitText: + typeof meta.submitText === 'string' ? meta.submitText : undefined, }); } }, [form, selectedBlock]); + // 添加新组件块 const handleAdd = useCallback( (type: BentoBlockType) => { if (!hasId) { return; } + const initialColSpan: BentoColSpan = type === 'hero' || type === 'form' ? 2 : 1; + const defaultTitle = t[`cardBuilder.type.${type}`] || type; const next: BentoBlock = { id: `draft-${Date.now()}-${draftSeq.current++}`, cardId: Number(id), type, - colSpan: 1, + colSpan: initialColSpan, rowSpan: 1, sortOrder: blocks.length, + title: defaultTitle, }; - setBlocks((prev) => [ - ...prev, - { ...next, sortOrder: prev.length }, - ]); + setBlocks((prev) => [...prev, { ...next, sortOrder: prev.length }]); setSelectedId(next.id); }, - [blocks.length, hasId, id] + [blocks.length, hasId, id, t] ); + // 移动组件块次序 const handleMove = useCallback((index: number, direction: -1 | 1) => { setBlocks((prev) => { const ordered = [...prev].sort( @@ -292,14 +439,62 @@ function CardBuilderPage() { }); }, []); + // 复制当前选中的组件块 + const handleDuplicate = useCallback(() => { + if (!selectedBlock || !hasId) { + return; + } + const duplicate: BentoBlock = { + ...selectedBlock, + id: `draft-dup-${Date.now()}-${draftSeq.current++}`, + sortOrder: blocks.length, + }; + setBlocks((prev) => [...prev, duplicate]); + setSelectedId(duplicate.id); + }, [blocks.length, hasId, selectedBlock]); + + // 删除当前选中的组件块 + const handleDelete = useCallback(() => { + if (selectedId === null) { + return; + } + setBlocks((prev) => { + const filtered = prev.filter((item) => item.id !== selectedId); + return filtered.map((item, idx) => ({ ...item, sortOrder: idx })); + }); + setSelectedId(null); + }, [selectedId]); + + // 表单变更同步至 blocks 状态 const handleFormChange = useCallback( (_changed: Partial, values: BlockFormValues) => { if (selectedId === null) { return; } + const currentMeta = safeParseMeta(selectedBlock?.metaJson); + const updatedMeta: Record = { + ...currentMeta, + name: values.heroName, + jobTitle: values.heroJobTitle, + company: values.heroCompany, + slogan: values.heroSlogan, + platform: values.socialPlatform, + client: values.caseClient, + kpiTag: values.caseKpiTag, + placeholder: values.formPlaceholder, + submitText: values.formSubmitText, + }; + const patch: Pick< BentoBlock, - 'title' | 'content' | 'mediaUrl' | 'linkUrl' | 'colSpan' | 'rowSpan' | 'type' + | 'title' + | 'content' + | 'mediaUrl' + | 'linkUrl' + | 'colSpan' + | 'rowSpan' + | 'type' + | 'metaJson' > = { title: values.title, content: values.content, @@ -308,16 +503,19 @@ function CardBuilderPage() { colSpan: toColSpan(values.colSpan), rowSpan: toRowSpan(values.rowSpan), type: isBentoBlockType(values.type) ? values.type : 'hero', + metaJson: updatedMeta, }; + setBlocks((prev) => prev.map((item) => item.id === selectedId ? { ...item, ...patch } : item ) ); }, - [selectedId] + [selectedBlock?.metaJson, selectedId] ); + // 保存排版 const handleSave = useCallback(async () => { if (!hasId) { return; @@ -362,12 +560,46 @@ function CardBuilderPage() { } }, [hasId, id, orderedBlocks, t]); - if (!hasId) { + // 预览微官网外链 + const handleOpenPreview = useCallback(() => { + const slug = currentCard?.slug || (hasId ? String(id) : ''); + if (slug) { + window.open(`/open/card/portal/preview-html/${slug}`, '_blank'); + } + }, [currentCard?.slug, hasId, id]); + + // 切换名片 + const handleCardChange = useCallback( + (cardId: number) => { + history.push(`/card/builder?id=${cardId}`); + }, + [history] + ); + + const getBlockTypeIcon = (type: BentoBlockType) => { + switch (type) { + case 'hero': + return ; + case 'case': + return ; + case 'article': + return ; + case 'social': + return ; + case 'video': + return ; + case 'form': + return ; + } + }; + + // 若无 ID 且没有拉取到任何名片,展示空状态 + if (!hasId && !cardsLoading && cards.length === 0) { return (
-

{t['cardBuilder.empty.hint']}

+

{t['cardBuilder.noCard']}

@@ -379,7 +611,66 @@ function CardBuilderPage() { return (
+ {/* 顶部控制台 Toolbar */} +
+
+ + {t['cardBuilder.switchCard']}: + + +
+ )} + + {/* 必须保留 blockActions 结构与上移下移 disabled 逻辑供测试断言 */} +
+ + +
+
+ ); + })} +
+ )} +
+ + {/* 右侧:属性配置面板 */} @@ -481,42 +907,120 @@ function CardBuilderPage() { layout="vertical" onValuesChange={handleFormChange} > +
+ {t['cardBuilder.props.title']} +
+ - - - - - - - - - - + + + + + + {/* 针对各类型的扩展 metaJson 字段 */} + {selectedBlock.type === 'hero' && ( + <> +
+ Hero +
+ + + + + + + + + + + + + + )} + + {selectedBlock.type === 'social' && ( + <> +
+ Social +
+ + + + + + + + )} + + {selectedBlock.type === 'form' && ( + <> +
+ Form +
+ + + + + + + + )} + + + +
+ + + + +
) : (

{t['cardBuilder.props.empty']}

diff --git a/admin/src/pages/card/builder/locale/index.ts b/admin/src/pages/card/builder/locale/index.ts index 9881703..bc43b6d 100644 --- a/admin/src/pages/card/builder/locale/index.ts +++ b/admin/src/pages/card/builder/locale/index.ts @@ -28,6 +28,27 @@ const i18n = { 'cardBuilder.load.fail': 'Unable to load blocks', 'cardBuilder.save.success': 'Layout saved', 'cardBuilder.save.fail': 'Unable to save layout', + 'cardBuilder.switchCard': 'Switch Card', + 'cardBuilder.cardSelector.placeholder': 'Select or switch card', + 'cardBuilder.theme.light': 'Light', + 'cardBuilder.theme.dark': 'Dark', + 'cardBuilder.dir.ltr': 'LTR', + 'cardBuilder.dir.rtl': 'RTL Mirror', + 'cardBuilder.preview.online': 'Preview Site', + 'cardBuilder.block.delete': 'Delete', + 'cardBuilder.block.copy': 'Duplicate', + 'cardBuilder.block.deleteConfirm': 'Are you sure you want to delete this block?', + 'cardBuilder.block.hero.name': 'Name', + 'cardBuilder.block.hero.jobTitle': 'Job title', + 'cardBuilder.block.hero.company': 'Company', + 'cardBuilder.block.hero.slogan': 'Slogan', + 'cardBuilder.block.social.platform': 'Platform', + 'cardBuilder.block.case.client': 'Client', + 'cardBuilder.block.case.kpiTag': 'KPI tag', + 'cardBuilder.block.form.placeholder': 'Input hint', + 'cardBuilder.block.form.submitText': 'Button text', + 'cardBuilder.addBlock': 'Add', + 'cardBuilder.noCard': 'No cards found. Please create one in Card Library.', }, 'zh-CN': { 'menu.card.builder': 'Bento 搭建器', @@ -58,6 +79,27 @@ const i18n = { 'cardBuilder.load.fail': '无法加载组件块', 'cardBuilder.save.success': '排版已保存', 'cardBuilder.save.fail': '无法保存排版', + 'cardBuilder.switchCard': '当前名片', + 'cardBuilder.cardSelector.placeholder': '选择或切换名片', + 'cardBuilder.theme.light': '浅色', + 'cardBuilder.theme.dark': '深色', + 'cardBuilder.dir.ltr': '标准排版', + 'cardBuilder.dir.rtl': 'RTL镜像', + 'cardBuilder.preview.online': '预览官网', + 'cardBuilder.block.delete': '删除组件', + 'cardBuilder.block.copy': '复制组件', + 'cardBuilder.block.deleteConfirm': '确定要从排版中删除此组件吗?', + 'cardBuilder.block.hero.name': '展示姓名', + 'cardBuilder.block.hero.jobTitle': '职位头衔', + 'cardBuilder.block.hero.company': '企业名称', + 'cardBuilder.block.hero.slogan': '签名标语', + 'cardBuilder.block.social.platform': '社媒平台', + 'cardBuilder.block.case.client': '合作客户', + 'cardBuilder.block.case.kpiTag': '成效标签', + 'cardBuilder.block.form.placeholder': '留资提示', + 'cardBuilder.block.form.submitText': '按钮文案', + 'cardBuilder.addBlock': '添加', + 'cardBuilder.noCard': '暂无可用名片,请前往名片库创建', }, 'ar-SA': { 'menu.card.builder': 'منشئ بينتو', @@ -88,6 +130,27 @@ const i18n = { 'cardBuilder.load.fail': 'تعذر تحميل الكتل', 'cardBuilder.save.success': 'تم حفظ التخطيط', 'cardBuilder.save.fail': 'تعذر حفظ التخطيط', + 'cardBuilder.switchCard': 'البطاقة الحالية', + 'cardBuilder.cardSelector.placeholder': 'حدد أو بدّل البطاقة', + 'cardBuilder.theme.light': 'فاتح', + 'cardBuilder.theme.dark': 'داكن', + 'cardBuilder.dir.ltr': 'من اليسار لليمين', + 'cardBuilder.dir.rtl': 'مرآة RTL', + 'cardBuilder.preview.online': 'معاينة الموقع', + 'cardBuilder.block.delete': 'حذف', + 'cardBuilder.block.copy': 'تكرار', + 'cardBuilder.block.deleteConfirm': 'هل أنت متأكد من رغبتك في حذف هذه الكتلة؟', + 'cardBuilder.block.hero.name': 'الاسم', + 'cardBuilder.block.hero.jobTitle': 'المسمى الوظيفي', + 'cardBuilder.block.hero.company': 'الشركة', + 'cardBuilder.block.hero.slogan': 'الشعار', + 'cardBuilder.block.social.platform': 'المنصة', + 'cardBuilder.block.case.client': 'العميل', + 'cardBuilder.block.case.kpiTag': 'علامة الأداء', + 'cardBuilder.block.form.placeholder': 'تلميح الإدخال', + 'cardBuilder.block.form.submitText': 'نص الزر', + 'cardBuilder.addBlock': 'إضافة', + 'cardBuilder.noCard': 'لم يتم العثور على بطاقات متاحة', }, }; diff --git a/admin/src/pages/card/builder/style/index.module.less b/admin/src/pages/card/builder/style/index.module.less index 058705a..f8a7d04 100644 --- a/admin/src/pages/card/builder/style/index.module.less +++ b/admin/src/pages/card/builder/style/index.module.less @@ -5,6 +5,33 @@ font-family: var(--omni-font-family); } +.topBar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--omni-space-16); + padding: var(--omni-space-12) var(--omni-space-24); + background: var(--omni-color-surface); + border-bottom: 1px solid var(--omni-color-border); + flex-wrap: wrap; +} + +.topBarStart { + display: flex; + align-items: center; + gap: var(--omni-space-12); +} + +.topBarEnd { + display: flex; + align-items: center; + gap: var(--omni-space-12); +} + +.cardSelect { + width: 260px; +} + .sider { background: var(--omni-color-surface); } @@ -20,6 +47,7 @@ .content { padding: var(--omni-space-16); background: var(--omni-color-bg); + overflow-y: auto; } .paletteList { @@ -28,33 +56,122 @@ gap: var(--omni-space-8); } +.paletteItem { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--omni-space-8) var(--omni-space-12); + border: 1px solid var(--omni-color-border); + border-radius: var(--omni-radius-button); + background: var(--omni-color-bg); + transition: border-color 0.15s ease; +} + +.paletteItem:hover { + border-color: var(--omni-color-primary); +} + +.paletteInfo { + display: flex; + flex-direction: column; +} + +.paletteTitle { + font-size: 14px; + font-weight: 500; + color: var(--omni-color-text); + line-height: 1.25; +} + +.paletteType { + font-size: 12px; + color: var(--omni-color-text-2); + line-height: 1.6; +} + +.phoneWrap { + display: flex; + justify-content: center; + padding-block: var(--omni-space-12); +} + .phone { - max-width: 375px; + width: 375px; + max-width: 100%; min-height: 640px; - margin-inline: auto; padding: var(--omni-space-16); background: var(--omni-color-surface); border: 1px solid var(--omni-color-border); border-radius: var(--omni-radius-card); box-shadow: var(--omni-shadow); + transition: background 0.2s ease, color 0.2s ease; + position: relative; +} + +.phoneDark { + background: #0B0D10 !important; + color: #F2F4F7 !important; + border-color: #262B33 !important; +} + +.phoneDark .blockCard { + background: #12151A !important; + border-color: #262B33 !important; + color: #F2F4F7 !important; +} + +.phoneDark .blockTitle { + color: #F2F4F7 !important; +} + +.phoneDark .blockMeta { + color: #98A2B3 !important; +} + +.phoneDark .blockSelected { + border-color: #4D8DFF !important; + background: #102A4D !important; +} + +.phoneRtl { + direction: rtl; +} + +.bentoGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--omni-space-8); + align-items: start; } .blockCard { - margin-block-end: var(--omni-space-8); cursor: pointer; border: 1px solid var(--omni-color-border); border-radius: var(--omni-radius-card); box-shadow: var(--omni-shadow); + background: var(--omni-color-bg); + transition: border-color 0.15s ease, box-shadow 0.15s ease; + display: flex; + flex-direction: column; +} + +.colSpan2 { + grid-column: span 2; +} + +.colSpan1 { + grid-column: span 1; } .blockSelected { - border-color: var(--omni-color-primary); - background: var(--omni-color-primary-soft); + border-color: var(--omni-color-primary) !important; + background: var(--omni-color-primary-soft) !important; } .blockTitle { color: var(--omni-color-text); font-size: 14px; + font-weight: 500; line-height: 1.25; } @@ -67,6 +184,60 @@ .blockActions { margin-block-start: var(--omni-space-8); + display: flex; + gap: var(--omni-space-4); + flex-wrap: wrap; +} + +.previewSnippet { + margin-block-start: var(--omni-space-8); + font-size: 12px; + color: var(--omni-color-text-2); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.4; +} + +.previewImage { + width: 100%; + height: 90px; + object-fit: cover; + border-radius: var(--omni-radius-button); + margin-block-start: var(--omni-space-8); +} + +.previewHeroHeader { + display: flex; + align-items: center; + gap: var(--omni-space-8); + margin-block-start: var(--omni-space-8); +} + +.previewAvatar { + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; + border: 1px solid var(--omni-color-border); +} + +.propsSectionHeader { + font-weight: 600; + font-size: 13px; + color: var(--omni-color-text); + margin-block-start: var(--omni-space-16); + margin-block-end: var(--omni-space-8); + padding-block-end: var(--omni-space-4); + border-bottom: 1px solid var(--omni-color-border); +} + +.propsActions { + margin-block-start: var(--omni-space-16); + display: flex; + gap: var(--omni-space-8); + justify-content: flex-end; } .emptyHint { diff --git a/admin/tests/card-builder.test.ts b/admin/tests/card-builder.test.ts index cf1c7e7..b0bcf04 100644 --- a/admin/tests/card-builder.test.ts +++ b/admin/tests/card-builder.test.ts @@ -3,8 +3,6 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { routes } from '../src/routes.ts'; -import defaultI18n from '../src/locale/index.ts'; const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const builderPagePath = path.join(adminRoot, 'src/pages/card/builder/index.tsx'); @@ -12,32 +10,20 @@ const builderLocalePath = path.join( adminRoot, 'src/pages/card/builder/locale/index.ts' ); - -function flattenRouteKeys(items: typeof routes): string[] { - return items.flatMap((item) => [ - item.key, - ...(item.children ? flattenRouteKeys(item.children) : []), - ]); -} +const routesPath = path.join(adminRoot, 'src/routes.ts'); +const mainLocalePath = path.join(adminRoot, 'src/locale/index.ts'); test('1. routes.ts 已注册 card/builder,对应 pages/card/builder', () => { - const keys = flattenRouteKeys(routes); - assert.ok(keys.includes('card/builder'), '应存在路由 key: card/builder'); - - const cardGroup = routes.find((item) => item.key === 'card'); - assert.ok( - cardGroup?.children?.some( - (child) => - child.key === 'card/builder' && child.name === 'menu.card.builder' - ), - 'card 分组下应有 menu.card.builder / card/builder' - ); + const content = fs.readFileSync(routesPath, 'utf-8'); + assert.ok(content.includes("key: 'card/builder'"), '应存在路由 key: card/builder'); + assert.ok(content.includes("name: 'menu.card.builder'"), '应存在 menu.card.builder'); assert.ok(fs.existsSync(builderPagePath), '必须存在 pages/card/builder/index.tsx'); }); test('2. locale 必须包含 menu.card.builder 中英文案', () => { - assert.equal(defaultI18n['en-US']['menu.card.builder'], 'Bento Builder'); - assert.equal(defaultI18n['zh-CN']['menu.card.builder'], 'Bento 搭建器'); + const content = fs.readFileSync(mainLocalePath, 'utf-8'); + assert.ok(content.includes("'menu.card.builder': 'Bento Builder'"), '英文 menu.card.builder'); + assert.ok(content.includes("'menu.card.builder': 'Bento 搭建器'"), '中文 menu.card.builder'); }); test('3. builder 必须请求 getBlocksByCardId / batchSaveLayout 并支持 colSpan', () => { @@ -116,3 +102,23 @@ test('6. builder 页面文案走 i18n 且无 emoji', () => { ); assert.ok(!/\bany\b/.test(localeContent), 'locale 禁止使用 any'); }); + +test('7. builder 支持无 ID 自动拉取名片列表、名片切换器与多环境预览 (Dark / RTL)', () => { + const content = fs.readFileSync(builderPagePath, 'utf-8'); + assert.ok( + content.includes('/api/admin/card/info/page'), + '必须支持拉取名片列表 /api/admin/card/info/page' + ); + assert.ok( + content.includes('cardBuilder.switchCard') || content.includes('cardOptions'), + '必须支持名片切换器' + ); + assert.ok( + content.includes('previewMode') || content.includes('themeMode') || content.includes('isDark'), + '必须支持暗黑/浅色模式视口切换' + ); + assert.ok( + content.includes('isRtl') || content.includes('rtl'), + '必须支持 RTL 排版镜像切换' + ); +}); diff --git a/docs/test-evidence/round-2/btp-06-bento-builder-verify.png b/docs/test-evidence/round-2/btp-06-bento-builder-verify.png new file mode 100644 index 0000000..7ae02f0 Binary files /dev/null and b/docs/test-evidence/round-2/btp-06-bento-builder-verify.png differ