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);
|
||||
34
apps/miniprogram/src/components/ui/ContentCard/index.scss
Normal file
34
apps/miniprogram/src/components/ui/ContentCard/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
52
apps/miniprogram/src/components/ui/ContentCard/index.tsx
Normal file
52
apps/miniprogram/src/components/ui/ContentCard/index.tsx
Normal 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);
|
||||
75
apps/miniprogram/src/components/ui/LoadingCard/index.scss
Normal file
75
apps/miniprogram/src/components/ui/LoadingCard/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
47
apps/miniprogram/src/components/ui/LoadingCard/index.tsx
Normal file
47
apps/miniprogram/src/components/ui/LoadingCard/index.tsx
Normal 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);
|
||||
11
apps/miniprogram/src/components/ui/PageShell/index.scss
Normal file
11
apps/miniprogram/src/components/ui/PageShell/index.scss
Normal 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));
|
||||
}
|
||||
}
|
||||
54
apps/miniprogram/src/components/ui/PageShell/index.tsx
Normal file
54
apps/miniprogram/src/components/ui/PageShell/index.tsx
Normal 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);
|
||||
51
apps/miniprogram/src/components/ui/SectionTitle/index.scss
Normal file
51
apps/miniprogram/src/components/ui/SectionTitle/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
37
apps/miniprogram/src/components/ui/SectionTitle/index.tsx
Normal file
37
apps/miniprogram/src/components/ui/SectionTitle/index.tsx
Normal 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);
|
||||
17
apps/miniprogram/src/components/ui/StatusTag/index.scss
Normal file
17
apps/miniprogram/src/components/ui/StatusTag/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
77
apps/miniprogram/src/components/ui/StatusTag/index.tsx
Normal file
77
apps/miniprogram/src/components/ui/StatusTag/index.tsx
Normal 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);
|
||||
163
apps/miniprogram/src/hooks/useListPage.ts
Normal file
163
apps/miniprogram/src/hooks/useListPage.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# 小程序统一组件库设计规格
|
||||
|
||||
> 日期: 2026-05-16 | 状态: 编写中 | 方案: 三层架构(原子 + 模式 + Hook)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
小程序端 66 个页面存在严重 UI 不统一:59 页手写骨架、119 处卡片重写、9+ 列表页无模板、关怀模式未覆盖 doctor 端。根因是缺少组件级统一抽象。
|
||||
|
||||
目标:改 1 处组件 = 全部页面同步;页面代码减少 ~70%;关怀 100% 覆盖。
|
||||
|
||||
## 2. 设计决策
|
||||
|
||||
三层架构 | 仅小程序端 | 温润东方风 | 组件级关怀 | 渐进迁移
|
||||
|
||||
## 3. 目录结构
|
||||
|
||||
components/ui/ (原子) → components/patterns/ (组合) → hooks/useListPage.ts (Hook)
|
||||
|
||||
## 4. 第 1 层:原子组件
|
||||
|
||||
**PageShell** — 页面容器。padding 语义化(none/sm/md/lg),safeBottom,scroll 包裹。
|
||||
**ContentCard** — 卡片。3 种 variant,统一圆角/阴影/触摸反馈。
|
||||
**StatusTag** — 状态标签。内置 5 色映射,pill 圆角。
|
||||
**SectionTitle** — 段落标题。赤土橙竖线装饰。
|
||||
**LoadingCard** — 骨架屏。与 ContentCard 同尺寸。
|
||||
|
||||
## 5. 第 2 层:组合模式
|
||||
|
||||
**PageHeader** — 页面顶部(title + back + actions)。
|
||||
**SearchSection** — 搜索区域(搜索框 + 可选筛选标签)。
|
||||
**CardList** — 列表容器(自动处理 loading/error/empty)。
|
||||
**PaginationBar** — 分页控制(替代 2 种命名体系)。
|
||||
|
||||
## 6. 第 3 层:useListPage Hook
|
||||
|
||||
封装分页/搜索/筛选/加载/空状态。返回 listPageProps 可直接展开给组合组件。列表页从 ~120 行降至 ~35 行。
|
||||
|
||||
---
|
||||
|
||||
## 7. 关怀模式集成
|
||||
|
||||
### 现状问题
|
||||
|
||||
当前关怀模式通过 `elder-mode.scss` 全局 CSS 覆写实现,存在 3 个问题:
|
||||
1. 按类名匹配,新增页面不用正确类名就漏掉
|
||||
2. doctor 端页面完全不在覆写范围内
|
||||
3. 每次改组件样式都要同步改 elder-mode.scss
|
||||
|
||||
### 新方案:组件级自适应
|
||||
|
||||
每个原子组件内部全部读取 CSS 变量,关怀模式只需改变量值:
|
||||
|
||||
1. **扩展 tokens.scss** — 新增结构化 Token:
|
||||
|
||||
```
|
||||
--tk-card-bg: #fff // 卡片背景
|
||||
--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 // 触摸反馈透明度
|
||||
```
|
||||
|
||||
2. **原子组件全部读变量** — ContentCard 的 padding 使用 `var(--tk-card-padding)`,border-radius 使用 `var(--tk-card-radius)`
|
||||
|
||||
3. **elder-mode.scss 只覆写变量值**:
|
||||
|
||||
```scss
|
||||
.elder-mode {
|
||||
--tk-card-padding: 32px; // 24px → 32px
|
||||
--tk-card-radius: 20px; // 16px → 20px
|
||||
--tk-gap-md: 20px; // 16px → 20px
|
||||
--tk-page-padding: 32px; // 24px → 32px
|
||||
// ... 已有字号/触控变量保持不变
|
||||
}
|
||||
```
|
||||
|
||||
4. **新页面自动获得关怀支持** — 只要使用原子组件,无需额外代码
|
||||
|
||||
5. **精简 elder-mode.scss** — 只保留页面级特殊覆写(如体征网格 2 列→1 列),不再需要组件级覆写
|
||||
|
||||
---
|
||||
|
||||
## 8. 迁移策略
|
||||
|
||||
### Phase 1:创建组件 + Token 扩展(~2天)
|
||||
|
||||
- 扩展 tokens.scss 新增 `--tk-card-*`、`--tk-gap-*`、`--tk-page-*` 等结构化 Token
|
||||
- 实现 5 个原子组件 + 4 个组合组件 + useListPage Hook
|
||||
- 每个组件编写基本测试(渲染/Props/变体)
|
||||
- **不动任何现有页面**,新组件与旧代码并行
|
||||
|
||||
### Phase 2:迁移 doctor 端列表页(~1.5天)
|
||||
|
||||
- 选择 patients 列表页做试点 — 完整迁移 + 截图对比验证
|
||||
- 批量迁移剩余 8 个列表页:dialysis, prescription, report, alerts, consultation(doctor), followup, article, consultation(患者)
|
||||
- 每页迁移后:截图对比 → 功能验证(搜索/分页/空状态)→ 确认无回归
|
||||
|
||||
### Phase 3:迁移非列表页 + 关怀模式(~1.5天)
|
||||
|
||||
- 首页、详情页、表单页等迁移为 PageShell + ContentCard
|
||||
- 扩展 elder-mode.scss 结构化 Token 覆写
|
||||
- doctor 端页面首次获得关怀模式支持
|
||||
- 全量页面关怀模式验证
|
||||
|
||||
### Phase 4:清理旧代码 + 文档(~0.5天)
|
||||
|
||||
- 删除各页面不再使用的手写样式(.search-bar, .xxx-card, .pagination 等)
|
||||
- 清理未使用的 mixins(card 等)
|
||||
- 精简 elder-mode.scss 为纯页面级特殊覆写
|
||||
- 编写组件使用文档
|
||||
|
||||
---
|
||||
|
||||
## 9. 预期效果
|
||||
|
||||
| 指标 | 改造前 | 改造后 |
|
||||
|------|--------|--------|
|
||||
| 页面模板代码 | ~120 行/页 | ~35 行/页 |
|
||||
| 卡片样式定义 | 119 处分散 | 1 处 ContentCard |
|
||||
| 搜索栏实现 | 4 处重写 | 1 个 SearchSection |
|
||||
| 分页器体系 | 2 种混用 | 1 个 PaginationBar |
|
||||
| 状态标签实现 | 3 种方式 | 1 个 StatusTag |
|
||||
| 关怀模式覆盖 | 仅患者端 TabBar | 100%(含 doctor 端) |
|
||||
| 新功能 UI 开发 | 从零手写 | 组装组件 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 验证方式
|
||||
|
||||
每个迁移阶段完成后执行:
|
||||
|
||||
- **视觉对比** — 截图对比改造前后,确保一致
|
||||
- **功能验证** — 搜索、分页、空状态、错误状态均正常
|
||||
- **关怀模式** — 切换后全量页面自适应无异常
|
||||
- **构建通过** — `pnpm build` 无报错
|
||||
- **真机预览** — DevTools 或真机预览验证触摸反馈和交互
|
||||
Reference in New Issue
Block a user