feat(mp): 小程序统一组件库 Phase 1 — Token 扩展 + 10 组件 + useListPage Hook
三层架构组件库: - 第 1 层原子组件:PageShell/ContentCard/StatusTag/SectionTitle/LoadingCard - 第 2 层组合模式:PageHeader/SearchSection/CardList/PaginationBar - 第 3 层 Hook:useListPage(列表页通用逻辑抽象) Token 扩展:新增 --tk-card-*/--tk-gap-*/--tk-page-* 等结构化 CSS 变量, 关怀模式通过变量覆写自动生效,新组件零额外代码即获关怀支持。 设计规格:docs/superpowers/specs/2026-05-16-miniprogram-unified-components-design.md
This commit is contained in:
12
apps/miniprogram/src/components/patterns/CardList/index.scss
Normal file
12
apps/miniprogram/src/components/patterns/CardList/index.scss
Normal file
@@ -0,0 +1,12 @@
|
||||
.card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&--sm {
|
||||
gap: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
&--md {
|
||||
gap: var(--tk-gap-md);
|
||||
}
|
||||
}
|
||||
62
apps/miniprogram/src/components/patterns/CardList/index.tsx
Normal file
62
apps/miniprogram/src/components/patterns/CardList/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { View } from '@tarojs/components';
|
||||
import { ReactNode } from 'react';
|
||||
import EmptyState from '../../EmptyState';
|
||||
import ErrorState from '../../ErrorState';
|
||||
import LoadingCard from '../../ui/LoadingCard';
|
||||
import './index.scss';
|
||||
|
||||
interface CardListProps<T> {
|
||||
items: T[];
|
||||
renderItem: (item: T, index: number) => ReactNode;
|
||||
keyExtractor: (item: T) => string;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
emptyText?: string;
|
||||
emptyAction?: { text: string; onPress: () => void };
|
||||
gap?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
function CardList<T>({
|
||||
items,
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
loading = false,
|
||||
error = null,
|
||||
emptyText = '暂无数据',
|
||||
emptyAction,
|
||||
gap = 'md',
|
||||
}: CardListProps<T>) {
|
||||
if (loading) {
|
||||
return <LoadingCard count={3} layout="card" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorState
|
||||
text={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
text={emptyText}
|
||||
actionText={emptyAction?.text}
|
||||
onAction={emptyAction?.onPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`card-list card-list--${gap}`}>
|
||||
{items.map((item, index) => (
|
||||
<View key={keyExtractor(item)} className="card-list__item">
|
||||
{renderItem(item, index)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default CardList;
|
||||
@@ -0,0 +1,55 @@
|
||||
@import '../../styles/variables';
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--tk-touch-min);
|
||||
padding: 0 var(--tk-page-padding);
|
||||
background: $bg;
|
||||
z-index: 10;
|
||||
|
||||
&--sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-height: var(--tk-touch-min);
|
||||
}
|
||||
|
||||
&__back-icon {
|
||||
font-size: 24px;
|
||||
color: $tx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { ReactNode } from 'react';
|
||||
import './index.scss';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
showBack?: boolean;
|
||||
onBack?: () => void;
|
||||
rightActions?: ReactNode;
|
||||
sticky?: boolean;
|
||||
}
|
||||
|
||||
const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
title,
|
||||
showBack = true,
|
||||
onBack,
|
||||
rightActions,
|
||||
sticky = true,
|
||||
}) => {
|
||||
const handleBack = () => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
} else {
|
||||
Taro.navigateBack({ delta: 1 }).catch(() => {
|
||||
Taro.switchTab({ url: '/pages/index/index' });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const cls = [
|
||||
'page-header',
|
||||
sticky && 'page-header--sticky',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<View className={cls}>
|
||||
<View className="page-header__left">
|
||||
{showBack && (
|
||||
<View className="page-header__back" onClick={handleBack}>
|
||||
<Text className="page-header__back-icon">‹</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text className="page-header__title">{title}</Text>
|
||||
</View>
|
||||
{rightActions && (
|
||||
<View className="page-header__right">{rightActions}</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(PageHeader);
|
||||
@@ -0,0 +1,38 @@
|
||||
@import '../../styles/variables';
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--tk-gap-md);
|
||||
padding: var(--tk-gap-md) 0;
|
||||
|
||||
&__btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 20px;
|
||||
border-radius: $r-sm;
|
||||
background: var(--tk-card-bg);
|
||||
border: 1px solid $bd;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx;
|
||||
min-height: var(--tk-touch-min);
|
||||
|
||||
&:active:not(&--disabled) {
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import './index.scss';
|
||||
|
||||
interface PaginationBarProps {
|
||||
current: number;
|
||||
total: number;
|
||||
pageSize: number;
|
||||
onChange: (page: number) => void;
|
||||
}
|
||||
|
||||
const PaginationBar: React.FC<PaginationBarProps> = ({
|
||||
current,
|
||||
total,
|
||||
pageSize,
|
||||
onChange,
|
||||
}) => {
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const hasPrev = current > 1;
|
||||
const hasNext = current < totalPages;
|
||||
|
||||
return (
|
||||
<View className="pagination-bar">
|
||||
<View
|
||||
className={`pagination-bar__btn ${!hasPrev ? 'pagination-bar__btn--disabled' : ''}`}
|
||||
onClick={() => hasPrev && onChange(current - 1)}
|
||||
>
|
||||
<Text>上一页</Text>
|
||||
</View>
|
||||
<Text className="pagination-bar__info">
|
||||
{current} / {totalPages}
|
||||
</Text>
|
||||
<View
|
||||
className={`pagination-bar__btn ${!hasNext ? 'pagination-bar__btn--disabled' : ''}`}
|
||||
onClick={() => hasNext && onChange(current + 1)}
|
||||
>
|
||||
<Text>下一页</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(PaginationBar);
|
||||
@@ -0,0 +1,37 @@
|
||||
@import '../../styles/variables';
|
||||
|
||||
.search-section {
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
|
||||
&__input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--tk-card-bg);
|
||||
border-radius: var(--tk-card-radius);
|
||||
padding: 0 16px;
|
||||
height: var(--tk-touch-min);
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__input {
|
||||
flex: 1;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body);
|
||||
}
|
||||
|
||||
&__filters {
|
||||
margin-top: var(--tk-gap-sm);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { View, Input, Text } from '@tarojs/components';
|
||||
import SegmentTabs from '../../SegmentTabs';
|
||||
import './index.scss';
|
||||
|
||||
interface SearchSectionProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onSearch?: () => void;
|
||||
placeholder?: string;
|
||||
filters?: { key: string; label: string }[];
|
||||
activeFilter?: string;
|
||||
onFilterChange?: (key: string) => void;
|
||||
}
|
||||
|
||||
const SearchSection: React.FC<SearchSectionProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSearch,
|
||||
placeholder = '搜索...',
|
||||
filters,
|
||||
activeFilter,
|
||||
onFilterChange,
|
||||
}) => {
|
||||
return (
|
||||
<View className="search-section">
|
||||
<View className="search-section__input-wrap">
|
||||
<Text className="search-section__icon">🔍</Text>
|
||||
<Input
|
||||
className="search-section__input"
|
||||
value={value}
|
||||
onInput={(e) => onChange(e.detail.value)}
|
||||
onConfirm={onSearch}
|
||||
placeholder={placeholder}
|
||||
placeholderClass="search-section__placeholder"
|
||||
confirmType="search"
|
||||
/>
|
||||
</View>
|
||||
{filters && filters.length > 0 && (
|
||||
<View className="search-section__filters">
|
||||
<SegmentTabs
|
||||
tabs={filters.map((f) => ({ key: f.key, label: f.label }))}
|
||||
activeKey={activeFilter ?? filters[0]?.key ?? ''}
|
||||
onChange={onFilterChange ?? (() => {})}
|
||||
variant="pill"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(SearchSection);
|
||||
Reference in New Issue
Block a user