diff --git a/apps/miniprogram/src/components/patterns/CardList/index.scss b/apps/miniprogram/src/components/patterns/CardList/index.scss new file mode 100644 index 0000000..006a5e7 --- /dev/null +++ b/apps/miniprogram/src/components/patterns/CardList/index.scss @@ -0,0 +1,12 @@ +.card-list { + display: flex; + flex-direction: column; + + &--sm { + gap: var(--tk-gap-sm); + } + + &--md { + gap: var(--tk-gap-md); + } +} diff --git a/apps/miniprogram/src/components/patterns/CardList/index.tsx b/apps/miniprogram/src/components/patterns/CardList/index.tsx new file mode 100644 index 0000000..403839e --- /dev/null +++ b/apps/miniprogram/src/components/patterns/CardList/index.tsx @@ -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 { + 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({ + items, + renderItem, + keyExtractor, + loading = false, + error = null, + emptyText = '暂无数据', + emptyAction, + gap = 'md', +}: CardListProps) { + if (loading) { + return ; + } + + if (error) { + return ( + + ); + } + + if (items.length === 0) { + return ( + + ); + } + + return ( + + {items.map((item, index) => ( + + {renderItem(item, index)} + + ))} + + ); +} + +export default CardList; diff --git a/apps/miniprogram/src/components/patterns/PageHeader/index.scss b/apps/miniprogram/src/components/patterns/PageHeader/index.scss new file mode 100644 index 0000000..a615f80 --- /dev/null +++ b/apps/miniprogram/src/components/patterns/PageHeader/index.scss @@ -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; + } +} diff --git a/apps/miniprogram/src/components/patterns/PageHeader/index.tsx b/apps/miniprogram/src/components/patterns/PageHeader/index.tsx new file mode 100644 index 0000000..dc1b8d7 --- /dev/null +++ b/apps/miniprogram/src/components/patterns/PageHeader/index.tsx @@ -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 = ({ + 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 ( + + + {showBack && ( + + + + )} + {title} + + {rightActions && ( + {rightActions} + )} + + ); +}; + +export default React.memo(PageHeader); diff --git a/apps/miniprogram/src/components/patterns/PaginationBar/index.scss b/apps/miniprogram/src/components/patterns/PaginationBar/index.scss new file mode 100644 index 0000000..50bfc1a --- /dev/null +++ b/apps/miniprogram/src/components/patterns/PaginationBar/index.scss @@ -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; + } +} diff --git a/apps/miniprogram/src/components/patterns/PaginationBar/index.tsx b/apps/miniprogram/src/components/patterns/PaginationBar/index.tsx new file mode 100644 index 0000000..16f6e19 --- /dev/null +++ b/apps/miniprogram/src/components/patterns/PaginationBar/index.tsx @@ -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 = ({ + current, + total, + pageSize, + onChange, +}) => { + const totalPages = Math.ceil(total / pageSize); + + if (totalPages <= 1) return null; + + const hasPrev = current > 1; + const hasNext = current < totalPages; + + return ( + + hasPrev && onChange(current - 1)} + > + 上一页 + + + {current} / {totalPages} + + hasNext && onChange(current + 1)} + > + 下一页 + + + ); +}; + +export default React.memo(PaginationBar); diff --git a/apps/miniprogram/src/components/patterns/SearchSection/index.scss b/apps/miniprogram/src/components/patterns/SearchSection/index.scss new file mode 100644 index 0000000..f9597da --- /dev/null +++ b/apps/miniprogram/src/components/patterns/SearchSection/index.scss @@ -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); + } +} diff --git a/apps/miniprogram/src/components/patterns/SearchSection/index.tsx b/apps/miniprogram/src/components/patterns/SearchSection/index.tsx new file mode 100644 index 0000000..526b3d2 --- /dev/null +++ b/apps/miniprogram/src/components/patterns/SearchSection/index.tsx @@ -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 = ({ + value, + onChange, + onSearch, + placeholder = '搜索...', + filters, + activeFilter, + onFilterChange, +}) => { + return ( + + + 🔍 + onChange(e.detail.value)} + onConfirm={onSearch} + placeholder={placeholder} + placeholderClass="search-section__placeholder" + confirmType="search" + /> + + {filters && filters.length > 0 && ( + + ({ key: f.key, label: f.label }))} + activeKey={activeFilter ?? filters[0]?.key ?? ''} + onChange={onFilterChange ?? (() => {})} + variant="pill" + /> + + )} + + ); +}; + +export default React.memo(SearchSection); diff --git a/apps/miniprogram/src/components/ui/ContentCard/index.scss b/apps/miniprogram/src/components/ui/ContentCard/index.scss new file mode 100644 index 0000000..7f23f43 --- /dev/null +++ b/apps/miniprogram/src/components/ui/ContentCard/index.scss @@ -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); + } +} diff --git a/apps/miniprogram/src/components/ui/ContentCard/index.tsx b/apps/miniprogram/src/components/ui/ContentCard/index.tsx new file mode 100644 index 0000000..c11aca6 --- /dev/null +++ b/apps/miniprogram/src/components/ui/ContentCard/index.tsx @@ -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 = ({ + 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 ( + + {children} + + ); +}; + +export default React.memo(ContentCard); diff --git a/apps/miniprogram/src/components/ui/LoadingCard/index.scss b/apps/miniprogram/src/components/ui/LoadingCard/index.scss new file mode 100644 index 0000000..73a573d --- /dev/null +++ b/apps/miniprogram/src/components/ui/LoadingCard/index.scss @@ -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; + } + } +} diff --git a/apps/miniprogram/src/components/ui/LoadingCard/index.tsx b/apps/miniprogram/src/components/ui/LoadingCard/index.tsx new file mode 100644 index 0000000..6d5698f --- /dev/null +++ b/apps/miniprogram/src/components/ui/LoadingCard/index.tsx @@ -0,0 +1,47 @@ +import { View } from '@tarojs/components'; +import './index.scss'; + +interface LoadingCardProps { + count?: number; + layout?: 'card' | 'list' | 'detail'; +} + +const LoadingCard: React.FC = ({ + count = 3, + layout = 'card', +}) => { + return ( + + {Array.from({ length: count }, (_, i) => ( + + {layout === 'card' && ( + <> + + + + + )} + {layout === 'list' && ( + + + + + + + + )} + {layout === 'detail' && ( + <> + + + + + + )} + + ))} + + ); +}; + +export default React.memo(LoadingCard); diff --git a/apps/miniprogram/src/components/ui/PageShell/index.scss b/apps/miniprogram/src/components/ui/PageShell/index.scss new file mode 100644 index 0000000..cafca74 --- /dev/null +++ b/apps/miniprogram/src/components/ui/PageShell/index.scss @@ -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)); + } +} diff --git a/apps/miniprogram/src/components/ui/PageShell/index.tsx b/apps/miniprogram/src/components/ui/PageShell/index.tsx new file mode 100644 index 0000000..76ba01f --- /dev/null +++ b/apps/miniprogram/src/components/ui/PageShell/index.tsx @@ -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 = ({ + 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 ( + + {children} + + ); + } + + return ( + + {children} + + ); +}; + +export default React.memo(PageShell); diff --git a/apps/miniprogram/src/components/ui/SectionTitle/index.scss b/apps/miniprogram/src/components/ui/SectionTitle/index.scss new file mode 100644 index 0000000..39693a3 --- /dev/null +++ b/apps/miniprogram/src/components/ui/SectionTitle/index.scss @@ -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; + } +} diff --git a/apps/miniprogram/src/components/ui/SectionTitle/index.tsx b/apps/miniprogram/src/components/ui/SectionTitle/index.tsx new file mode 100644 index 0000000..7ffcc9b --- /dev/null +++ b/apps/miniprogram/src/components/ui/SectionTitle/index.tsx @@ -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 = ({ + title, + subtitle, + action, + icon, +}) => { + return ( + + + + {icon && {icon}} + + {title} + {subtitle && {subtitle}} + + + {action && ( + + {action.text} + + )} + + ); +}; + +export default React.memo(SectionTitle); diff --git a/apps/miniprogram/src/components/ui/StatusTag/index.scss b/apps/miniprogram/src/components/ui/StatusTag/index.scss new file mode 100644 index 0000000..0ecf6aa --- /dev/null +++ b/apps/miniprogram/src/components/ui/StatusTag/index.scss @@ -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; + } +} diff --git a/apps/miniprogram/src/components/ui/StatusTag/index.tsx b/apps/miniprogram/src/components/ui/StatusTag/index.tsx new file mode 100644 index 0000000..45f654a --- /dev/null +++ b/apps/miniprogram/src/components/ui/StatusTag/index.tsx @@ -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; + size?: 'sm' | 'md'; + className?: string; + children?: ReactNode; +} + +const DEFAULT_COLOR_MAP: Record = { + 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 = { + 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 = ({ + 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 ( + + {children ?? status} + + ); +}; + +export default React.memo(StatusTag); diff --git a/apps/miniprogram/src/hooks/useListPage.ts b/apps/miniprogram/src/hooks/useListPage.ts new file mode 100644 index 0000000..97a9adc --- /dev/null +++ b/apps/miniprogram/src/hooks/useListPage.ts @@ -0,0 +1,163 @@ +import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; + +interface UseListPageConfig { + fetchFn: (params: { + page: number; + pageSize: number; + keyword: string; + filter?: string; + }) => Promise<{ items: T[]; total: number }>; + pageSize?: number; + autoLoad?: boolean; +} + +interface UseListPageReturn { + 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( + config: UseListPageConfig, +): UseListPageReturn { + const { fetchFn, pageSize = 10, autoLoad = true } = config; + + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [keyword, setKeywordState] = useState(''); + const [filter, setFilterState] = useState(undefined); + + const debounceRef = useRef>(); + 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, + }; +} diff --git a/apps/miniprogram/src/styles/tokens.scss b/apps/miniprogram/src/styles/tokens.scss index 14a2f4c..fcbc80d 100644 --- a/apps/miniprogram/src/styles/tokens.scss +++ b/apps/miniprogram/src/styles/tokens.scss @@ -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; } diff --git a/docs/superpowers/specs/2026-05-16-miniprogram-unified-components-design.md b/docs/superpowers/specs/2026-05-16-miniprogram-unified-components-design.md new file mode 100644 index 0000000..9055a79 --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-miniprogram-unified-components-design.md @@ -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 或真机预览验证触摸反馈和交互