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