feat(card): 完善名片微官网Bento搭建器无参自动选卡、名片切换、2列网格及出海多环境实时预览
这个提交包含在:
@@ -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 排版镜像切换'
|
||||
);
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户