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:
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);
|
||||
Reference in New Issue
Block a user