Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion doc/example.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@
},
{
"title": "PersonalCard",
"description": "个人档案卡片组件 - 在一个示例中演示三种模式、状态切换与空值处理",
"description": "个人档案卡片:三种模式、状态切换,以及无头像占位、无简介、只有姓名等空值示例",
"code": "./personal-card.js",
"scope": [
{
Expand Down
107 changes: 98 additions & 9 deletions doc/personal-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,31 @@ const statusOptions = [
{ label: '忙碌', value: 'busy' }
];

const renderCardList = (cards, mode, status) => (
<Flex wrap gap={24} justify="center">
{cards.map(item => {
const { key, label, withStatus = true, ...cardProps } = item;
return (
<div key={key} style={{ display: 'flex', flexDirection: 'column', gap: 12, alignItems: 'center' }}>
<Tag color="blue">{label}</Tag>
<PersonalCard mode={mode} {...(withStatus ? { status } : {})} {...cardProps} />
</div>
);
})}
</Flex>
);

const BaseExample = () => {
const [mode, setMode] = useState('large');
const [status, setStatus] = useState('online');

const profileCards = useMemo(
const basicCards = useMemo(
() => [
{
key: 'full',
label: '完整信息',
badge: '推荐',
avatar,
name: '莎拉·詹金斯',
title: '高级账户战略师',
description: '我的使命是确保您的团队拥有轻松扩展所需的资源和战略指导。让我们携手共创辉煌。',
Expand All @@ -43,6 +58,7 @@ const BaseExample = () => {
key: 'email-only',
label: '仅邮箱',
badge: '候选',
avatar,
name: '林知夏',
title: '产品经理',
description: '负责产品规划与用户体验优化,擅长把复杂问题转化为清晰方案。',
Expand All @@ -58,6 +74,7 @@ const BaseExample = () => {
key: 'empty-contact',
label: '无联系方式',
badge: '归档',
avatar,
name: '周予安',
title: '数据分析师',
description: '关注业务指标体系建设与可视化分析,强调用数据驱动决策。',
Expand All @@ -72,8 +89,78 @@ const BaseExample = () => {
[]
);

// 空值 / 占位:无头像、无简介、只有姓名等
const emptyStateCards = useMemo(
() => [
{
key: 'empty-avatar',
label: '无头像(占位)',
badge: '新入职',
name: '陈思远',
title: '前端工程师',
description: '头像为空时显示占位头像,避免破损图片。',
phone: '+86 139 1111 2222',
email: 'chensiyuan@example.com',
moreInfo: [
{ label: '性别', content: '男' },
{ label: '年龄', content: '27' },
{ label: '部门', content: '研发中心' },
{ label: '专长', content: 'React' }
]
},
{
key: 'empty-description',
label: '无简介',
avatar,
name: '赵清禾',
title: '设计师',
phone: '+86 137 3333 4444',
email: 'zhaoqinghe@example.com',
moreInfo: [
{ label: '性别', content: '女' },
{ label: '年龄', content: '26' },
{ label: '部门', content: '设计部' },
{ label: '专长', content: '视觉设计' }
]
},
{
key: 'name-only',
label: '只有姓名',
name: '顾南星'
},
{
key: 'no-status',
label: '无 status',
withStatus: false,
avatar,
name: '沈听澜',
title: '内容运营',
description: '不传 status 时不显示头像状态点。',
phone: '+86 135 7777 8888',
email: 'shentinglan@example.com',
moreInfo: [
{ label: '性别', content: '女' },
{ label: '年龄', content: '28' },
{ label: '部门', content: '运营部' },
{ label: '专长', content: '内容策划' }
]
},
{
key: 'empty-more-info',
label: '无更多信息',
avatar,
name: '韩亦辰',
title: '运营专员',
description: '更多信息为空时不展示信息网格区域。',
phone: '+86 136 5555 6666',
email: 'hanyichen@example.com'
}
],
[]
);

return (
<Flex vertical gap={32} style={{ padding: '40px' }}>
<Flex vertical gap={48} style={{ padding: '40px' }}>
<Flex vertical gap={16}>
<div style={{ fontSize: 18, fontWeight: 600 }}>综合示例</div>
<Flex wrap gap={16} align="center" justify="space-between">
Expand All @@ -88,14 +175,16 @@ const BaseExample = () => {
</Flex>
</Flex>

<Flex wrap gap={24} justify="center">
{profileCards.map(item => (
<div key={item.key} style={{ display: 'flex', flexDirection: 'column', gap: 12, alignItems: 'center' }}>
<Tag color="blue">{item.label}</Tag>
<PersonalCard avatar={avatar} mode={mode} status={status} {...item} />
</div>
))}
{renderCardList(basicCards, mode, status)}

<Flex vertical gap={16}>
<div style={{ fontSize: 18, fontWeight: 600 }}>空值与占位示例</div>
<div style={{ color: '#64748b', fontSize: 13 }}>
无头像显示占位图;无简介 / 无更多信息 / 只有姓名时,对应容器整块不渲染;无 status 时不显示状态点。
</div>
</Flex>

{renderCardList(emptyStateCards, mode, status)}
</Flex>
);
};
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@kne/react-box",
"version": "0.1.10",
"version": "0.1.11",
"description": "一个轻量级的 React 设计组件库,专注于提供精致美观的信息展示小组件",
"syntax": {
"esmodules": true
Expand Down
6 changes: 6 additions & 0 deletions src/PersonalCard/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,9 @@ export const QuoteIcon = ({ className = '', size = 24 }) => (
<path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
</svg>
);

export const UserIcon = ({ className = '', size = 24 }) => (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
);
122 changes: 79 additions & 43 deletions src/PersonalCard/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { Fragment } from 'react';
import style from './style.module.scss';
import { MailIcon, CallIcon, QuoteIcon } from './icons';
import { MailIcon, CallIcon, QuoteIcon, UserIcon } from './icons';

/**
* @param {React.ReactNode} [extra] 卡片角落扩展区(如 Checkbox)
Expand All @@ -9,6 +9,10 @@ import { MailIcon, CallIcon, QuoteIcon } from './icons';
*/
const PersonalCard = ({ avatar, name, title, description, phone, email, moreInfo = [], status, badge, mode = 'large', extra, footer, selected = false, className }) => {
const statusClass = style[`status-${status}`] || style['status-online'];
const validMoreInfo = Array.isArray(moreInfo) ? moreInfo.filter(item => item?.content != null && item.content !== '') : [];
const hasMoreInfo = validMoreInfo.length > 0;
const hasContact = !!(phone || email);
const hasDescription = !!description;

const AvatarWithStatus = ({ size = 'default' }) => {
const sizeClasses = {
Expand All @@ -17,10 +21,31 @@ const PersonalCard = ({ avatar, name, title, description, phone, email, moreInfo
small: 'avatar-small',
default: 'avatar-medium'
};
const iconSizeMap = {
large: 48,
medium: 32,
small: 24,
default: 32
};
const sizeKey = sizeClasses[size] ? size : 'default';

const renderAvatarContent = () => {
if (typeof avatar === 'function') {
return avatar({ className: style['avatar'] });
}
if (avatar) {
return <img src={avatar} alt={name} className={style['avatar']} />;
}
return (
<div className={`${style['avatar']} ${style['avatar-placeholder']}`} aria-label={name || 'avatar placeholder'}>
<UserIcon className={style['avatar-placeholder-icon']} size={iconSizeMap[sizeKey]} />
</div>
);
};

return (
<div className={`${style['avatar-container']} ${style[sizeClasses[size] || sizeClasses.default]}`}>
{typeof avatar === 'function' ? avatar({ className: style['avatar'] }) : <img src={avatar} alt={name} className={style['avatar']} />}
{renderAvatarContent()}
{status && <span className={`${style['status-indicator']} ${statusClass}`}></span>}
</div>
);
Expand All @@ -45,14 +70,13 @@ const PersonalCard = ({ avatar, name, title, description, phone, email, moreInfo

const BasicInfo = ({ showDivider = true }) => {
return (
Array.isArray(moreInfo) &&
moreInfo.length > 0 && (
hasMoreInfo && (
<div className={style['basic-info']}>
{moreInfo.map(({ content }, index) => {
{validMoreInfo.map(({ content }, index) => {
return (
<Fragment key={index}>
{content}
{showDivider && moreInfo.length - 1 !== index && <span className={style['divider']}></span>}
{showDivider && validMoreInfo.length - 1 !== index && <span className={style['divider']}></span>}
</Fragment>
);
})}
Expand All @@ -62,10 +86,9 @@ const PersonalCard = ({ avatar, name, title, description, phone, email, moreInfo
};

const InfoGrid = () =>
Array.isArray(moreInfo) &&
moreInfo.length > 0 && (
hasMoreInfo && (
<div className={style['info-grid']}>
{moreInfo.map(({ label, content }, index) => {
{validMoreInfo.map(({ label, content }, index) => {
return <InfoItem key={index} label={label} value={content} />;
})}
</div>
Expand Down Expand Up @@ -112,10 +135,12 @@ const PersonalCard = ({ avatar, name, title, description, phone, email, moreInfo
</div>
<BasicInfo />
</div>
<div className={style['card-description']}>
<div className={style['description']}>{description}</div>
</div>
{(phone || email) && (
{hasDescription && (
<div className={style['card-description']}>
<div className={style['description']}>{description}</div>
</div>
)}
{hasContact && (
<div className={style['card-footer']}>
<ContactItem icon={MailIcon} size={14} value={email} />
<ContactItem icon={CallIcon} size={14} value={phone} />
Expand All @@ -136,52 +161,63 @@ const PersonalCard = ({ avatar, name, title, description, phone, email, moreInfo
{badge && <span className={style['badge']}>{badge}</span>}
</div>
{title && <div className={style['title']}>{title}</div>}
{(phone || email) && (
{hasContact && (
<div className={style['contact-row']}>
<ContactItem icon={MailIcon} size={14} value={email} />
<ContactItem icon={CallIcon} size={14} value={phone} />
</div>
)}
</div>
</div>
<div className={style['minimal-main']}>
<InfoGrid />
<div className={style['mini-description']}>
<div className={style['description']}>{description}</div>
{(hasMoreInfo || hasDescription) && (
<div className={style['minimal-main']}>
<InfoGrid />
{hasDescription && (
<div className={style['mini-description']}>
<div className={style['description']}>{description}</div>
</div>
)}
</div>
</div>
)}
{renderFooter()}
</div>
);

const renderHorizontal = () => (
<div className={`${cardClassName} ${style['card-horizontal']}`}>
{renderExtra()}
<div className={style['card-left']}>
<AvatarWithStatus size="large" />
<h1 className={style['name']}>
{name} {badge && <span className={style['badge']}>{badge}</span>}
</h1>
{title && <div className={style['title']}>{title}</div>}
{(phone || email) && (
<div className={style['contact-section']}>
<div className={style['contact-box']}>
<ContactItem icon={MailIcon} size={20} value={email} />
<ContactItem icon={CallIcon} size={20} value={phone} />
const renderHorizontal = () => {
const showRight = hasMoreInfo || hasDescription || footer;
return (
<div className={`${cardClassName} ${style['card-horizontal']}${showRight ? '' : ` ${style['card-horizontal-single']}`}`}>
{renderExtra()}
<div className={style['card-left']}>
<AvatarWithStatus size="large" />
<h1 className={style['name']}>
{name} {badge && <span className={style['badge']}>{badge}</span>}
</h1>
{title && <div className={style['title']}>{title}</div>}
{hasContact && (
<div className={style['contact-section']}>
<div className={style['contact-box']}>
<ContactItem icon={MailIcon} size={20} value={email} />
<ContactItem icon={CallIcon} size={20} value={phone} />
</div>
</div>
)}
</div>
{showRight && (
<div className={style['card-right']}>
<InfoGrid />
{hasDescription && (
<div className={style['description-box']}>
<QuoteIcon className={style['quote-icon']} size={24} />
<div className={style['description']}>{description}</div>
</div>
)}
{renderFooter()}
</div>
)}
</div>
<div className={style['card-right']}>
<InfoGrid />
<div className={style['description-box']}>
<QuoteIcon className={style['quote-icon']} size={24} />
<div className={style['description']}>{description}</div>
</div>
{renderFooter()}
</div>
</div>
);
);
};

const renderMode = {
vertical: renderVertical,
Expand Down
Loading