feat(card): 完善名片微官网Bento搭建器无参自动选卡、名片切换、2列网格及出海多环境实时预览

这个提交包含在:
wikiboss
2026-09-21 11:59:59 +08:00
父节点 603f3d3964
当前提交 79dcc2bd61
共有 5 个文件被更改,包括 870 次插入 和 126 次删除

查看文件

@@ -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<T> {
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<BentoBlock, 'id'> {
@@ -110,6 +160,26 @@ function resolveErrorMessage(error: unknown, fallback: string): string {
return fallback;
}
function safeParseMeta(metaJson: unknown): Record<string, unknown> {
if (!metaJson) {
return {};
}
if (typeof metaJson === 'object' && metaJson !== null) {
return metaJson as Record<string, unknown>;
}
if (typeof metaJson === 'string') {
try {
const parsed = JSON.parse(metaJson);
if (typeof parsed === 'object' && parsed !== null) {
return parsed as Record<string, unknown>;
}
} 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<BlockFormValues>();
const draftSeq = useRef(0);
const [cards, setCards] = useState<CardProfile[]>([]);
const [blocks, setBlocks] = useState<BentoBlock[]>([]);
const [selectedId, setSelectedId] = useState<string | number | null>(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<CoolResponse<CardPageResult>>(
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<string | number | null>(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<BlockFormValues>, values: BlockFormValues) => {
if (selectedId === null) {
return;
}
const currentMeta = safeParseMeta(selectedBlock?.metaJson);
const updatedMeta: Record<string, unknown> = {
...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 <IconIdcard />;
case 'case':
return <IconFolder />;
case 'article':
return <IconFile />;
case 'social':
return <IconShareAlt />;
case 'video':
return <IconVideoCamera />;
case 'form':
return <IconEdit />;
}
};
// 若无 ID 且没有拉取到任何名片,展示空状态
if (!hasId && !cardsLoading && cards.length === 0) {
return (
<div className={styles.page} style={tokenStyle}>
<div className={styles.emptyWrap}>
<Card title={t['cardBuilder.empty.title']}>
<p className={styles.emptyHint}>{t['cardBuilder.empty.hint']}</p>
<p className={styles.emptyHint}>{t['cardBuilder.noCard']}</p>
<Button onClick={() => history.push(LIBRARY_PATH)}>
{t['cardBuilder.empty.back']}
</Button>
@@ -379,7 +611,66 @@ function CardBuilderPage() {
return (
<div className={styles.page} style={tokenStyle}>
{/* 顶部控制台 Toolbar */}
<div className={styles.topBar}>
<div className={styles.topBarStart}>
<span style={{ fontWeight: 500, fontSize: 13, color: 'var(--omni-color-text)' }}>
{t['cardBuilder.switchCard']}:
</span>
<Select
placeholder={t['cardBuilder.cardSelector.placeholder']}
loading={cardsLoading}
value={currentCardId ?? undefined}
options={cardOptions}
className={styles.cardSelect}
onChange={handleCardChange}
/>
</div>
<div className={styles.topBarEnd}>
<Radio.Group
type="button"
value={themeMode}
onChange={(val) => setThemeMode(val === 'dark' ? 'dark' : 'light')}
>
<Radio value="light">
<IconSun style={{ marginInlineEnd: 4 }} />
{t['cardBuilder.theme.light']}
</Radio>
<Radio value="dark">
<IconMoon style={{ marginInlineEnd: 4 }} />
{t['cardBuilder.theme.dark']}
</Radio>
</Radio.Group>
<Radio.Group
type="button"
value={isRtl ? 'rtl' : 'ltr'}
onChange={(val) => setIsRtl(val === 'rtl')}
>
<Radio value="ltr">{t['cardBuilder.dir.ltr']}</Radio>
<Radio value="rtl">{t['cardBuilder.dir.rtl']}</Radio>
</Radio.Group>
{currentCard?.slug && (
<Button icon={<IconLaunch />} onClick={handleOpenPreview}>
{t['cardBuilder.preview.online']}
</Button>
)}
{/* 单一保存排版主 CTA */}
<Button
type="primary"
loading={saving}
onClick={handleSave}
>
{t['cardBuilder.save']}
</Button>
</div>
</div>
<Layout>
{/* 左侧:组件库 */}
<Layout.Sider
theme="light"
width={240}
@@ -393,85 +684,220 @@ function CardBuilderPage() {
>
<div className={styles.paletteList}>
{BLOCK_TYPES.map((type) => (
<Button
key={type}
long
onClick={() => handleAdd(type)}
>
{t[`cardBuilder.type.${type}`]}
</Button>
<div key={type} className={styles.paletteItem}>
<div className={styles.paletteInfo}>
<span className={styles.paletteTitle}>
{t[`cardBuilder.type.${type}`] || type}
</span>
<span className={styles.paletteType}>
{type}
</span>
</div>
<Button
type="text"
size="small"
icon={<IconPlus />}
onClick={() => handleAdd(type)}
>
{t['cardBuilder.addBlock']}
</Button>
</div>
))}
</div>
</Tabs.TabPane>
</Tabs>
</Card>
</Layout.Sider>
{/* 中间:手机模型画布 */}
<Layout.Content className={styles.content}>
<Card
loading={loading}
title={t['cardBuilder.canvas.title']}
extra={
<Button type="primary" loading={saving} onClick={handleSave}>
{t['cardBuilder.save']}
</Button>
}
bordered={false}
>
<div className={styles.phone}>
{orderedBlocks.length === 0 ? (
<p className={styles.emptyHint}>
{t['cardBuilder.canvas.empty']}
</p>
) : (
orderedBlocks.map((block, index) => {
const selected = block.id === selectedId;
return (
<Card
key={String(block.id)}
className={`${styles.blockCard} ${
selected ? styles.blockSelected : ''
}`}
onClick={() => setSelectedId(block.id)}
>
<div className={styles.blockTitle}>
{block.title || t[`cardBuilder.type.${block.type}`]}
</div>
<div className={styles.blockMeta}>
{block.type} / colSpan {block.colSpan}
</div>
<div className={styles.blockActions}>
<Button
type="text"
size="small"
disabled={index === 0}
onClick={(event) => {
event.stopPropagation();
handleMove(index, -1);
}}
<div className={styles.phoneWrap}>
<div
dir={isRtl ? 'rtl' : 'ltr'}
className={`${styles.phone} ${
themeMode === 'dark' ? styles.phoneDark : ''
} ${isRtl ? styles.phoneRtl : ''}`}
>
{orderedBlocks.length === 0 ? (
<p className={styles.emptyHint}>
{t['cardBuilder.canvas.empty']}
</p>
) : (
<div className={styles.bentoGrid}>
{orderedBlocks.map((block, index) => {
const selected = block.id === selectedId;
const isSpan2 = block.colSpan === 2;
const meta = safeParseMeta(block.metaJson);
return (
<div
key={String(block.id)}
className={`${styles.blockCard} ${
isSpan2 ? styles.colSpan2 : styles.colSpan1
} ${selected ? styles.blockSelected : ''}`}
style={{ padding: 12 }}
onClick={() => setSelectedId(block.id)}
>
{t['cardBuilder.move.up']}
</Button>
<Button
type="text"
size="small"
disabled={index === orderedBlocks.length - 1}
onClick={(event) => {
event.stopPropagation();
handleMove(index, 1);
}}
>
{t['cardBuilder.move.down']}
</Button>
</div>
</Card>
);
})
)}
{/* 必须保留 blockTitle 结构供测试与交互识别 */}
<div className={styles.blockTitle}>
<Space size={6}>
{getBlockTypeIcon(block.type)}
<span>
{block.title || t[`cardBuilder.type.${block.type}`]}
</span>
</Space>
</div>
{/* 必须保留 blockMeta 结构供测试识别 (包含 block.type 与 colSpan) */}
<div className={styles.blockMeta}>
{block.type} / colSpan {block.colSpan}
</div>
{/* 组件微官网内容富呈现 */}
{block.type === 'hero' && (
<div>
<div className={styles.previewHeroHeader}>
{block.mediaUrl ? (
<img
src={block.mediaUrl}
alt="avatar"
className={styles.previewAvatar}
/>
) : (
<div
className={styles.previewAvatar}
style={{
background: 'var(--omni-color-primary-soft)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--omni-color-primary)',
}}
>
<IconIdcard />
</div>
)}
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>
{String(meta.name || currentCard?.name || 'Alex Morgan')}
</div>
<div style={{ fontSize: 11, color: 'var(--omni-color-text-2)' }}>
{String(meta.jobTitle || currentCard?.title || 'VP of Global Business')}
</div>
</div>
</div>
{block.content && (
<div className={styles.previewSnippet}>
{block.content}
</div>
)}
</div>
)}
{block.type === 'case' && (
<div>
{block.mediaUrl && (
<img
src={block.mediaUrl}
alt="case"
className={styles.previewImage}
/>
)}
{meta.client && (
<Tag size="small" color="arcoblue" style={{ marginBlockStart: 6 }}>
{String(meta.client)}
</Tag>
)}
{block.content && (
<div className={styles.previewSnippet}>
{block.content}
</div>
)}
</div>
)}
{block.type === 'article' && (
<div>
{block.content && (
<div className={styles.previewSnippet}>
{block.content}
</div>
)}
</div>
)}
{block.type === 'social' && (
<div style={{ marginBlockStart: 6 }}>
<Tag size="small" color="blue">
{String(meta.platform || 'LinkedIn')}
</Tag>
</div>
)}
{block.type === 'video' && block.mediaUrl && (
<img
src={block.mediaUrl}
alt="video cover"
className={styles.previewImage}
/>
)}
{block.type === 'form' && (
<div style={{ marginBlockStart: 6 }}>
<Input
size="small"
disabled
placeholder={String(meta.placeholder || 'Leave a message...')}
/>
</div>
)}
{/* 必须保留 blockActions 结构与上移下移 disabled 逻辑供测试断言 */}
<div className={styles.blockActions}>
<Button
type="text"
size="small"
icon={<IconArrowUp />}
disabled={index === 0}
onClick={(event) => {
event.stopPropagation();
handleMove(index, -1);
}}
>
{t['cardBuilder.move.up']}
</Button>
<Button
type="text"
size="small"
icon={<IconArrowDown />}
disabled={index === orderedBlocks.length - 1}
onClick={(event) => {
event.stopPropagation();
handleMove(index, 1);
}}
>
{t['cardBuilder.move.down']}
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</Card>
</Layout.Content>
{/* 右侧:属性配置面板 */}
<Layout.Sider
theme="light"
width={320}
width={340}
className={`${styles.sider} ${styles.siderEnd}`}
>
<Card title={t['cardBuilder.props.title']} bordered={false}>
@@ -481,42 +907,120 @@ function CardBuilderPage() {
layout="vertical"
onValuesChange={handleFormChange}
>
<div className={styles.propsSectionHeader}>
{t['cardBuilder.props.title']}
</div>
<Form.Item field="title" label={t['cardBuilder.field.title']}>
<Input />
</Form.Item>
<Form.Item
field="content"
label={t['cardBuilder.field.content']}
>
<Input.TextArea autoSize={{ minRows: 3, maxRows: 6 }} />
</Form.Item>
<Form.Item
field="mediaUrl"
label={t['cardBuilder.field.mediaUrl']}
>
<ImageUploader />
</Form.Item>
<Form.Item
field="linkUrl"
label={t['cardBuilder.field.linkUrl']}
>
<Input />
</Form.Item>
<Form.Item
field="colSpan"
label={t['cardBuilder.field.colSpan']}
>
<Form.Item field="colSpan" label={t['cardBuilder.field.colSpan']}>
<Select options={SPAN_OPTIONS} />
</Form.Item>
<Form.Item
field="rowSpan"
label={t['cardBuilder.field.rowSpan']}
>
<Form.Item field="rowSpan" label={t['cardBuilder.field.rowSpan']}>
<Select options={SPAN_OPTIONS} />
</Form.Item>
<Form.Item field="type" label={t['cardBuilder.field.type']}>
<Select options={typeOptions} />
</Form.Item>
<Form.Item field="content" label={t['cardBuilder.field.content']}>
<Input.TextArea autoSize={{ minRows: 3, maxRows: 6 }} />
</Form.Item>
<Form.Item field="mediaUrl" label={t['cardBuilder.field.mediaUrl']}>
<ImageUploader />
</Form.Item>
<Form.Item field="linkUrl" label={t['cardBuilder.field.linkUrl']}>
<Input />
</Form.Item>
{/* 针对各类型的扩展 metaJson 字段 */}
{selectedBlock.type === 'hero' && (
<>
<div className={styles.propsSectionHeader}>
Hero
</div>
<Form.Item field="heroName" label={t['cardBuilder.block.hero.name']}>
<Input />
</Form.Item>
<Form.Item field="heroJobTitle" label={t['cardBuilder.block.hero.jobTitle']}>
<Input />
</Form.Item>
<Form.Item field="heroCompany" label={t['cardBuilder.block.hero.company']}>
<Input />
</Form.Item>
<Form.Item field="heroSlogan" label={t['cardBuilder.block.hero.slogan']}>
<Input />
</Form.Item>
</>
)}
{selectedBlock.type === 'social' && (
<>
<div className={styles.propsSectionHeader}>
Social
</div>
<Form.Item field="socialPlatform" label={t['cardBuilder.block.social.platform']}>
<Select options={SOCIAL_PLATFORMS} />
</Form.Item>
</>
)}
{selectedBlock.type === 'case' && (
<>
<div className={styles.propsSectionHeader}>
Case
</div>
<Form.Item field="caseClient" label={t['cardBuilder.block.case.client']}>
<Input />
</Form.Item>
<Form.Item field="caseKpiTag" label={t['cardBuilder.block.case.kpiTag']}>
<Input />
</Form.Item>
</>
)}
{selectedBlock.type === 'form' && (
<>
<div className={styles.propsSectionHeader}>
Form
</div>
<Form.Item field="formPlaceholder" label={t['cardBuilder.block.form.placeholder']}>
<Input />
</Form.Item>
<Form.Item field="formSubmitText" label={t['cardBuilder.block.form.submitText']}>
<Input />
</Form.Item>
</>
)}
<Divider style={{ margin: '16px 0' }} />
<div className={styles.propsActions}>
<Button
icon={<IconCopy />}
onClick={handleDuplicate}
>
{t['cardBuilder.block.copy']}
</Button>
<Popconfirm
focusLock
title={t['cardBuilder.block.deleteConfirm']}
onOk={handleDelete}
>
<Button
status="danger"
icon={<IconDelete />}
>
{t['cardBuilder.block.delete']}
</Button>
</Popconfirm>
</div>
</Form>
) : (
<p className={styles.emptyHint}>{t['cardBuilder.props.empty']}</p>

查看文件

@@ -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': 'لم يتم العثور على بطاقات متاحة',
},
};

查看文件

@@ -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 {

查看文件

@@ -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 排版镜像切换'
);
});