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:
iven
2026-05-16 00:47:39 +08:00
parent 3fb5a77ac0
commit d758563a13
21 changed files with 1138 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
.card-list {
display: flex;
flex-direction: column;
&--sm {
gap: var(--tk-gap-sm);
}
&--md {
gap: var(--tk-gap-md);
}
}

View 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;

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -0,0 +1,34 @@
@import '../../styles/variables';
.content-card {
background: var(--tk-card-bg);
border-radius: var(--tk-card-radius);
box-shadow: $shadow-sm;
margin-bottom: var(--tk-gap-md);
transition: background 0.15s, opacity 0.15s, transform 0.15s;
&--outlined {
box-shadow: none;
border: 1px solid $bd;
}
&--elevated {
box-shadow: $shadow-md;
}
&--pressable {
cursor: pointer;
}
&--feedback-bg.content-card--pressable:active {
background: $bd-l;
}
&--feedback-opacity.content-card--pressable:active {
opacity: var(--tk-touch-feedback-opacity);
}
&--feedback-scale.content-card--pressable:active {
transform: scale(0.98);
}
}

View File

@@ -0,0 +1,52 @@
import { View } from '@tarojs/components';
import { CSSProperties, ReactNode, useMemo } from 'react';
import './index.scss';
interface ContentCardProps {
variant?: 'default' | 'outlined' | 'elevated';
onPress?: () => void;
padding?: 'none' | 'sm' | 'md' | 'lg';
activeFeedback?: 'bg' | 'opacity' | 'scale' | 'none';
className?: string;
style?: CSSProperties;
children: ReactNode;
}
const PADDING_MAP = {
none: '0',
sm: '12px',
md: 'var(--tk-card-padding)',
lg: '32px',
} as const;
const ContentCard: React.FC<ContentCardProps> = ({
variant = 'default',
onPress,
padding = 'md',
activeFeedback = 'bg',
className,
style,
children,
}) => {
const innerStyle = useMemo(() => ({
padding: PADDING_MAP[padding],
...style,
}), [padding, style]);
const hasPress = !!onPress;
const cls = [
'content-card',
`content-card--${variant}`,
hasPress && 'content-card--pressable',
hasPress && activeFeedback !== 'none' && `content-card--feedback-${activeFeedback}`,
className,
].filter(Boolean).join(' ');
return (
<View className={cls} style={innerStyle} onClick={onPress}>
{children}
</View>
);
};
export default React.memo(ContentCard);

View File

@@ -0,0 +1,75 @@
@import '../../styles/variables';
@keyframes skeleton-pulse {
0% { opacity: 1; }
50% { opacity: 0.4; }
100% { opacity: 1; }
}
.loading-card-group {
&--card {
display: flex;
flex-direction: column;
gap: var(--tk-gap-md);
}
&--list {
display: flex;
flex-direction: column;
gap: var(--tk-gap-sm);
}
&--detail {
display: flex;
flex-direction: column;
gap: var(--tk-gap-md);
}
}
.loading-card {
background: var(--tk-card-bg);
border-radius: var(--tk-card-radius);
padding: var(--tk-card-padding);
animation: skeleton-pulse 1.5s ease-in-out infinite;
&__row {
display: flex;
align-items: center;
gap: 12px;
}
&__circle {
width: 40px;
height: 40px;
border-radius: 50%;
background: $bd-l;
flex-shrink: 0;
}
&__lines {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
&__line {
border-radius: 4px;
background: $bd-l;
&--title {
width: 60%;
height: 16px;
}
&--text {
width: 100%;
height: 12px;
}
&--short {
width: 40%;
height: 12px;
}
}
}

View File

@@ -0,0 +1,47 @@
import { View } from '@tarojs/components';
import './index.scss';
interface LoadingCardProps {
count?: number;
layout?: 'card' | 'list' | 'detail';
}
const LoadingCard: React.FC<LoadingCardProps> = ({
count = 3,
layout = 'card',
}) => {
return (
<View className={`loading-card-group loading-card-group--${layout}`}>
{Array.from({ length: count }, (_, i) => (
<View key={i} className="loading-card">
{layout === 'card' && (
<>
<View className="loading-card__line loading-card__line--title" />
<View className="loading-card__line loading-card__line--text" />
<View className="loading-card__line loading-card__line--short" />
</>
)}
{layout === 'list' && (
<View className="loading-card__row">
<View className="loading-card__circle" />
<View className="loading-card__lines">
<View className="loading-card__line loading-card__line--title" />
<View className="loading-card__line loading-card__line--short" />
</View>
</View>
)}
{layout === 'detail' && (
<>
<View className="loading-card__line loading-card__line--title" />
<View className="loading-card__line loading-card__line--text" />
<View className="loading-card__line loading-card__line--text" />
<View className="loading-card__line loading-card__line--short" />
</>
)}
</View>
))}
</View>
);
};
export default React.memo(LoadingCard);

View File

@@ -0,0 +1,11 @@
@import '../../styles/variables';
.page-shell {
min-height: 100vh;
background: $bg;
box-sizing: border-box;
&--safe-bottom {
padding-bottom: calc(var(--tk-page-padding) + env(safe-area-inset-bottom, 0px));
}
}

View File

@@ -0,0 +1,54 @@
import { View, ScrollView } from '@tarojs/components';
import { ReactNode, useMemo } from 'react';
import './index.scss';
interface PageShellProps {
padding?: 'none' | 'sm' | 'md' | 'lg';
safeBottom?: boolean;
scroll?: boolean;
className?: string;
children: ReactNode;
}
const PADDING_MAP = {
none: '0',
sm: '16px',
md: 'var(--tk-page-padding)',
lg: '32px',
} as const;
const PageShell: React.FC<PageShellProps> = ({
padding = 'md',
safeBottom = true,
scroll = true,
className,
children,
}) => {
const style = useMemo(() => ({
paddingLeft: PADDING_MAP[padding],
paddingRight: PADDING_MAP[padding],
paddingTop: PADDING_MAP[padding],
}), [padding]);
const cls = [
'page-shell',
safeBottom && 'page-shell--safe-bottom',
className,
].filter(Boolean).join(' ');
if (scroll) {
return (
<ScrollView scrollY className={cls} style={style}>
{children}
</ScrollView>
);
}
return (
<View className={cls} style={style}>
{children}
</View>
);
};
export default React.memo(PageShell);

View File

@@ -0,0 +1,51 @@
@import '../../styles/variables';
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--tk-gap-md);
&__left {
display: flex;
align-items: center;
gap: 10px;
}
&__bar {
width: 3px;
height: 20px;
background: $pri;
border-radius: 2px;
flex-shrink: 0;
}
&__icon {
flex-shrink: 0;
}
&__text-wrap {
display: flex;
flex-direction: column;
}
&__text {
font-size: var(--tk-font-h2);
font-weight: 600;
color: $tx;
}
&__subtitle {
font-size: var(--tk-font-body-sm);
color: $tx3;
margin-top: 2px;
}
&__action {
font-size: var(--tk-font-body-sm);
color: $pri;
min-height: var(--tk-touch-min);
display: flex;
align-items: center;
}
}

View File

@@ -0,0 +1,37 @@
import { View, Text } from '@tarojs/components';
import { ReactNode } from 'react';
import './index.scss';
interface SectionTitleProps {
title: string;
subtitle?: string;
action?: { text: string; onPress: () => void };
icon?: ReactNode;
}
const SectionTitle: React.FC<SectionTitleProps> = ({
title,
subtitle,
action,
icon,
}) => {
return (
<View className="section-title">
<View className="section-title__left">
<View className="section-title__bar" />
{icon && <View className="section-title__icon">{icon}</View>}
<View className="section-title__text-wrap">
<Text className="section-title__text">{title}</Text>
{subtitle && <Text className="section-title__subtitle">{subtitle}</Text>}
</View>
</View>
{action && (
<Text className="section-title__action" onClick={action.onPress}>
{action.text}
</Text>
)}
</View>
);
};
export default React.memo(SectionTitle);

View File

@@ -0,0 +1,17 @@
@import '../../styles/variables';
.status-tag {
display: inline-flex;
align-items: center;
border-radius: $r-pill;
padding: var(--tk-tag-padding-v) var(--tk-tag-padding-h);
font-size: var(--tk-tag-font-size);
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
&--sm {
padding: 2px 8px;
font-size: 11px;
}
}

View File

@@ -0,0 +1,77 @@
import { View, Text } from '@tarojs/components';
import { CSSProperties, ReactNode, useMemo } from 'react';
import './index.scss';
type TagColor = 'success' | 'warning' | 'error' | 'info' | 'default';
interface StatusTagProps {
status: string;
colorMap?: Record<string, TagColor>;
size?: 'sm' | 'md';
className?: string;
children?: ReactNode;
}
const DEFAULT_COLOR_MAP: Record<string, TagColor> = {
active: 'success',
success: 'success',
completed: 'success',
confirmed: 'success',
resolved: 'success',
warning: 'warning',
pending: 'warning',
waiting: 'warning',
scheduled: 'warning',
in_progress: 'info',
error: 'error',
urgent: 'error',
cancelled: 'error',
critical: 'error',
expired: 'error',
inactive: 'default',
draft: 'default',
unknown: 'default',
};
const COLOR_STYLES: Record<TagColor, { bg: string; color: string }> = {
success: { bg: '#ECFDF5', color: '#5B7A5E' },
warning: { bg: '#FFF7ED', color: '#C4873A' },
error: { bg: '#FEF2F2', color: '#B54A4A' },
info: { bg: '#EFF6FF', color: '#3B82F6' },
default: { bg: '#F5F5F4', color: '#78716C' },
};
const StatusTag: React.FC<StatusTagProps> = ({
status,
colorMap,
size = 'md',
className,
children,
}) => {
const mergedMap = useMemo(
() => ({ ...DEFAULT_COLOR_MAP, ...colorMap }),
[colorMap],
);
const tagColor = mergedMap[status] ?? 'default';
const colorStyle = COLOR_STYLES[tagColor];
const cls = [
'status-tag',
`status-tag--${size}`,
className,
].filter(Boolean).join(' ');
const style: CSSProperties = {
backgroundColor: colorStyle.bg,
color: colorStyle.color,
};
return (
<View className={cls} style={style}>
<Text>{children ?? status}</Text>
</View>
);
};
export default React.memo(StatusTag);

View File

@@ -0,0 +1,163 @@
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
interface UseListPageConfig<T> {
fetchFn: (params: {
page: number;
pageSize: number;
keyword: string;
filter?: string;
}) => Promise<{ items: T[]; total: number }>;
pageSize?: number;
autoLoad?: boolean;
}
interface UseListPageReturn<T> {
items: T[];
loading: boolean;
error: string | null;
isEmpty: boolean;
page: number;
total: number;
pageSize: number;
keyword: string;
setKeyword: (v: string) => void;
filter: string | undefined;
setFilter: (v: string | undefined) => void;
refresh: () => void;
listPageProps: {
items: T[];
loading: boolean;
error: string | null;
isEmpty: boolean;
searchProps: {
value: string;
onChange: (v: string) => void;
onSearch: () => void;
};
paginationProps: {
current: number;
total: number;
pageSize: number;
onChange: (page: number) => void;
};
};
}
export function useListPage<T>(
config: UseListPageConfig<T>,
): UseListPageReturn<T> {
const { fetchFn, pageSize = 10, autoLoad = true } = config;
const [items, setItems] = useState<T[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [keyword, setKeywordState] = useState('');
const [filter, setFilterState] = useState<string | undefined>(undefined);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const mountedRef = useRef(false);
const isEmpty = !loading && items.length === 0;
const fetchData = useCallback(
async (targetPage: number, kw: string, ft?: string) => {
setLoading(true);
setError(null);
try {
const result = await fetchFn({
page: targetPage,
pageSize,
keyword: kw,
filter: ft,
});
setItems(result.items);
setTotal(result.total);
setPage(targetPage);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setLoading(false);
}
},
[fetchFn, pageSize],
);
const refresh = useCallback(() => {
fetchData(page, keyword, filter);
}, [fetchData, page, keyword, filter]);
const handleKeywordChange = useCallback(
(v: string) => {
setKeywordState(v);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
fetchData(1, v, filter);
}, 300);
},
[fetchData, filter],
);
const handleFilterChange = useCallback(
(v: string | undefined) => {
setFilterState(v);
fetchData(1, keyword, v);
},
[fetchData, keyword],
);
const handlePageChange = useCallback(
(p: number) => {
fetchData(p, keyword, filter);
},
[fetchData, keyword, filter],
);
useEffect(() => {
if (autoLoad && !mountedRef.current) {
mountedRef.current = true;
fetchData(1, '', undefined);
}
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [autoLoad, fetchData]);
const listPageProps = useMemo(
() => ({
items,
loading,
error,
isEmpty,
searchProps: {
value: keyword,
onChange: handleKeywordChange,
onSearch: refresh,
},
paginationProps: {
current: page,
total,
pageSize,
onChange: handlePageChange,
},
}),
[items, loading, error, isEmpty, keyword, handleKeywordChange, refresh, page, total, pageSize, handlePageChange],
);
return {
items,
loading,
error,
isEmpty,
page,
total,
pageSize,
keyword,
setKeyword: handleKeywordChange,
filter,
setFilter: handleFilterChange,
refresh,
listPageProps,
};
}

View File

@@ -25,6 +25,19 @@ page {
--tk-touch-min: 48px;
--tk-btn-primary-h: 56px;
--tk-text-secondary: #78716C; // $tx3 — 关怀模式提升对比度
// ─── 统一组件库结构化 Token ───
--tk-card-bg: #FFFFFF;
--tk-card-padding: 24px;
--tk-card-radius: 16px;
--tk-gap-sm: 12px;
--tk-gap-md: 16px;
--tk-gap-lg: 24px;
--tk-page-padding: 24px;
--tk-touch-feedback-opacity: 0.85;
--tk-tag-font-size: 12px;
--tk-tag-padding-v: 4px;
--tk-tag-padding-h: 12px;
}
// ═══════════════════════════════════════
@@ -48,4 +61,16 @@ page {
--tk-touch-min: 56px;
--tk-btn-primary-h: 64px;
--tk-text-secondary: #5A554F; // $tx2 — 对比度提升
// ─── 统一组件库 — 关怀覆写 ───
--tk-card-padding: 32px;
--tk-card-radius: 20px;
--tk-gap-sm: 16px;
--tk-gap-md: 20px;
--tk-gap-lg: 32px;
--tk-page-padding: 32px;
--tk-touch-feedback-opacity: 0.8;
--tk-tag-font-size: 13px;
--tk-tag-padding-v: 6px;
--tk-tag-padding-h: 16px;
}