Compare commits
8 Commits
3fb5a77ac0
...
483342a1d8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
483342a1d8 | ||
|
|
ae23baeece | ||
|
|
3e88dcaba5 | ||
|
|
9415807a40 | ||
|
|
1579f35ff5 | ||
|
|
9728afbc1b | ||
|
|
80794c9547 | ||
|
|
d758563a13 |
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,
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
@import '../../styles/variables.scss';
|
||||
@import '../../styles/mixins.scss';
|
||||
|
||||
.article-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
// PageShell 已接管:min-height, background, padding
|
||||
// ContentCard 已接管:article-card 背景/圆角/阴影/触摸反馈
|
||||
// LoadingCard 已接管:初始加载骨架屏
|
||||
// EmptyState 已接管:空状态样式
|
||||
|
||||
/* ─── 分类筛选滚动条(页面特有) ─── */
|
||||
.article-categories {
|
||||
white-space: nowrap;
|
||||
margin-bottom: 24px;
|
||||
@@ -31,20 +29,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 文章列表布局 ─── */
|
||||
.article-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.article-card {
|
||||
display: flex;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 28px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
/* ─── 文章卡片内容(ContentCard 已接管外层卡片样式) ─── */
|
||||
.article-card-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -107,25 +99,3 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 120px 0;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
.loading-hint {
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ import { View, Text, Image, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useReachBottom } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { listArticles, listCategories, Article, ArticleCategory } from '../../services/article';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
import ErrorState from '../../components/ErrorState';
|
||||
import Loading from '../../components/Loading';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import LoadingCard from '@/components/ui/LoadingCard';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import ErrorState from '@/components/ErrorState';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
@@ -73,8 +76,12 @@ export default function ArticleList() {
|
||||
Taro.navigateTo({ url: `/pages/article/detail/index?id=${id}` });
|
||||
};
|
||||
|
||||
if (!loading && articles.length === 0 && !error && !categories.length) {
|
||||
return <LoadingCard count={3} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`article-page ${modeClass}`}>
|
||||
<PageShell safeBottom className={modeClass}>
|
||||
{/* 分类筛选 */}
|
||||
{categories.length > 0 && (
|
||||
<ScrollView scrollX className='article-categories'>
|
||||
@@ -96,47 +103,44 @@ export default function ArticleList() {
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<View className='article-list'>
|
||||
{error ? (
|
||||
<ErrorState onRetry={() => fetchData(1, false, null)} />
|
||||
) : articles.map((a) => (
|
||||
<View
|
||||
className='article-card'
|
||||
key={a.id}
|
||||
onClick={() => goToDetail(a.id)}
|
||||
>
|
||||
<View className='article-card-body'>
|
||||
<Text className='article-card-title'>{a.title}</Text>
|
||||
{a.summary && (
|
||||
<Text className='article-card-summary'>{a.summary}</Text>
|
||||
)}
|
||||
<View className='article-card-meta'>
|
||||
{(a.category_name || a.category) && (
|
||||
<Text className='article-card-tag'>{a.category_name || a.category}</Text>
|
||||
)}
|
||||
{a.published_at && (
|
||||
<Text className='article-card-date'>
|
||||
{a.published_at.slice(0, 10)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{a.cover_image && (
|
||||
<View className='article-card-cover'>
|
||||
<Image className='cover-img' src={a.cover_image} mode='aspectFill' lazyLoad />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{articles.length === 0 && !loading && (
|
||||
{error ? (
|
||||
<ErrorState onRetry={() => fetchData(1, false, null)} />
|
||||
) : articles.length === 0 && !loading ? (
|
||||
<EmptyState text='暂无资讯文章' />
|
||||
) : (
|
||||
<View className='article-list'>
|
||||
{articles.map((a) => (
|
||||
<ContentCard
|
||||
key={a.id}
|
||||
onPress={() => goToDetail(a.id)}
|
||||
>
|
||||
<View className='article-card-body'>
|
||||
<Text className='article-card-title'>{a.title}</Text>
|
||||
{a.summary && (
|
||||
<Text className='article-card-summary'>{a.summary}</Text>
|
||||
)}
|
||||
<View className='article-card-meta'>
|
||||
{(a.category_name || a.category) && (
|
||||
<Text className='article-card-tag'>{a.category_name || a.category}</Text>
|
||||
)}
|
||||
{a.published_at && (
|
||||
<Text className='article-card-date'>
|
||||
{a.published_at.slice(0, 10)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{a.cover_image && (
|
||||
<View className='article-card-cover'>
|
||||
<Image className='cover-img' src={a.cover_image} mode='aspectFill' lazyLoad />
|
||||
</View>
|
||||
)}
|
||||
</ContentCard>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<Loading />
|
||||
)}
|
||||
</View>
|
||||
{loading && <Loading />}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
@import '../../styles/variables.scss';
|
||||
@import '../../styles/mixins.scss';
|
||||
|
||||
.consultation-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
// PageShell 已接管:min-height, background, padding
|
||||
// ContentCard 已接管:session-card 背景/圆角/阴影/触摸反馈
|
||||
// StatusTag 已接管:session-tag 标签样式
|
||||
// LoadingCard 已接管:初始加载骨架屏
|
||||
// EmptyState 已接管:空状态样式
|
||||
// ErrorState 已接管:错误状态样式
|
||||
|
||||
.consultation-body {
|
||||
padding: 12px 24px 24px;
|
||||
}
|
||||
|
||||
/* ─── 副标题 ─── */
|
||||
/* ─── 副标题(页面特有) ─── */
|
||||
.consultation-subtitle {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
@@ -18,7 +16,7 @@
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ─── 发起咨询按钮 — 实心主色 ─── */
|
||||
/* ─── 发起咨询按钮(页面特有) ─── */
|
||||
.consultation-create-btn {
|
||||
height: 48px;
|
||||
border-radius: $r;
|
||||
@@ -38,80 +36,23 @@
|
||||
color: $white;
|
||||
}
|
||||
|
||||
/* ─── 居中容器 ─── */
|
||||
.consultation-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 120px 40px;
|
||||
}
|
||||
|
||||
.consultation-error {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
/* ─── 空状态 ─── */
|
||||
.consultation-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120px 40px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: $pri-l;
|
||||
@include flex-center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-char {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── 会话列表 ─── */
|
||||
/* ─── 会话列表布局 ─── */
|
||||
.session-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
/* ─── 已关闭会话半透明(ContentCard 已接管卡片外层) ─── */
|
||||
.session-card-closed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ─── 会话卡片内部布局(ContentCard 内部) ─── */
|
||||
.session-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.session-card-closed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.session-avatar {
|
||||
@@ -159,18 +100,6 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-tag {
|
||||
font-size: var(--tk-font-micro);
|
||||
padding: 2px 6px;
|
||||
border-radius: $r-xs;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
|
||||
&.tag-ok { background: $acc-l; color: $acc; }
|
||||
&.tag-warn { background: $wrn-l; color: $wrn; }
|
||||
&.tag-default { background: $surface-alt; color: $tx3; }
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -193,7 +122,7 @@
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* ─── 未读角标 ─── */
|
||||
/* ─── 未读角标(页面特有) ─── */
|
||||
.session-badge {
|
||||
background: $dan;
|
||||
border-radius: $r-pill;
|
||||
|
||||
@@ -4,17 +4,17 @@ import Taro, { useReachBottom } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { listConsultations, ConsultationSession } from '@/services/consultation';
|
||||
import Loading from '../../components/Loading';
|
||||
import GuestGuard from '../../components/GuestGuard';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import StatusTag from '@/components/ui/StatusTag';
|
||||
import LoadingCard from '@/components/ui/LoadingCard';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import ErrorState from '@/components/ErrorState';
|
||||
import Loading from '@/components/Loading';
|
||||
import GuestGuard from '@/components/GuestGuard';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
function getStatusTag(status: string) {
|
||||
if (status === 'active') return { label: '进行中', cls: 'tag-ok' };
|
||||
if (status === 'pending') return { label: '等待接诊', cls: 'tag-warn' };
|
||||
return { label: { closed: '已结束', cancelled: '已取消' }[status] || status, cls: 'tag-default' };
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
@@ -33,6 +33,22 @@ function formatTime(iso: string): string {
|
||||
return `${m}-${day}`;
|
||||
}
|
||||
|
||||
/** 咨询状态到 StatusTag status 的映射 */
|
||||
function getConsultStatus(status: string): string {
|
||||
if (status === 'active') return 'active';
|
||||
if (status === 'pending') return 'pending';
|
||||
if (status === 'closed') return 'completed';
|
||||
if (status === 'cancelled') return 'cancelled';
|
||||
return status;
|
||||
}
|
||||
|
||||
const STATUS_LABEL_MAP: Record<string, string> = {
|
||||
active: '进行中',
|
||||
pending: '等待接诊',
|
||||
closed: '已结束',
|
||||
cancelled: '已取消',
|
||||
};
|
||||
|
||||
export default function Consultation() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [sessions, setSessions] = useState<ConsultationSession[]>([]);
|
||||
@@ -60,6 +76,7 @@ export default function Consultation() {
|
||||
setSessions([]);
|
||||
setTotal(0);
|
||||
}
|
||||
setError('加载失败,请稍后重试');
|
||||
Taro.showToast({ title: '加载失败,下拉重试', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -85,52 +102,59 @@ export default function Consultation() {
|
||||
Taro.navigateTo({ url: `/pages/pkg-consultation/detail/index?id=${session.id}` });
|
||||
};
|
||||
|
||||
return (
|
||||
<View className={`consultation-page ${modeClass}`}>
|
||||
{!user ? (
|
||||
if (!user) {
|
||||
return (
|
||||
<PageShell safeBottom className={modeClass}>
|
||||
<GuestGuard title='请先登录' desc='登录后即可与医生在线交流' />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && sessions.length === 0) {
|
||||
return <LoadingCard count={4} layout="list" />;
|
||||
}
|
||||
|
||||
if (error && sessions.length === 0) {
|
||||
return (
|
||||
<PageShell safeBottom className={modeClass}>
|
||||
<ErrorState text={error} onRetry={() => loadSessions(1, true)} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell safeBottom className={modeClass}>
|
||||
{/* 副标题 */}
|
||||
<Text className='consultation-subtitle'>随时随地,连接专业医生</Text>
|
||||
|
||||
{/* 发起咨询按钮 */}
|
||||
<View
|
||||
className='consultation-create-btn'
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/consultation/create/index' })}
|
||||
>
|
||||
<Text className='consultation-create-btn-text'>发起咨询</Text>
|
||||
</View>
|
||||
|
||||
{/* 会话列表 */}
|
||||
{sessions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon='问'
|
||||
text='暂无咨询记录'
|
||||
hint='发起咨询后即可在这里与医生交流'
|
||||
/>
|
||||
) : (
|
||||
<View className='consultation-body'>
|
||||
{/* 副标题 */}
|
||||
<Text className='consultation-subtitle'>随时随地,连接专业医生</Text>
|
||||
|
||||
{/* 发起咨询按钮 — 实心主色 */}
|
||||
<View
|
||||
className='consultation-create-btn'
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/consultation/create/index' })}
|
||||
>
|
||||
<Text className='consultation-create-btn-text'>发起咨询</Text>
|
||||
</View>
|
||||
|
||||
{/* 内容区 */}
|
||||
{loading ? (
|
||||
<View className='consultation-center'>
|
||||
<Loading text='加载中...' />
|
||||
</View>
|
||||
) : error ? (
|
||||
<View className='consultation-center'>
|
||||
<Text className='consultation-error'>{error}</Text>
|
||||
</View>
|
||||
) : sessions.length === 0 ? (
|
||||
<View className='consultation-empty'>
|
||||
<View className='empty-icon'>
|
||||
<Text className='empty-char'>问</Text>
|
||||
</View>
|
||||
<Text className='empty-title'>暂无咨询记录</Text>
|
||||
<Text className='empty-hint'>发起咨询后即可在这里与医生交流</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='session-list'>
|
||||
{sessions.map((session) => {
|
||||
const tag = getStatusTag(session.status);
|
||||
const initial = (session.subject || '咨').charAt(0);
|
||||
const isClosed = session.status === 'closed' || session.status === 'cancelled';
|
||||
return (
|
||||
<View
|
||||
key={session.id}
|
||||
className={`session-card ${isClosed ? 'session-card-closed' : ''}`}
|
||||
onClick={() => handleTapSession(session)}
|
||||
>
|
||||
<View className='session-list'>
|
||||
{sessions.map((session) => {
|
||||
const initial = (session.subject || '咨').charAt(0);
|
||||
const isClosed = session.status === 'closed' || session.status === 'cancelled';
|
||||
return (
|
||||
<ContentCard
|
||||
key={session.id}
|
||||
className={isClosed ? 'session-card-closed' : ''}
|
||||
activeFeedback="opacity"
|
||||
onPress={() => handleTapSession(session)}
|
||||
>
|
||||
<View className='session-inner'>
|
||||
<View className='session-avatar'>
|
||||
<Text className='session-avatar-char'>{initial}</Text>
|
||||
</View>
|
||||
@@ -146,7 +170,9 @@ export default function Consultation() {
|
||||
</Text>
|
||||
</View>
|
||||
<View className='session-meta'>
|
||||
<Text className={`session-tag ${tag.cls}`}>{tag.label}</Text>
|
||||
<StatusTag status={getConsultStatus(session.status)} size="sm">
|
||||
{STATUS_LABEL_MAP[session.status] || session.status}
|
||||
</StatusTag>
|
||||
</View>
|
||||
<View className='session-message-row'>
|
||||
<Text className='session-message'>
|
||||
@@ -162,12 +188,13 @@ export default function Consultation() {
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ContentCard>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{loading && sessions.length > 0 && <Loading />}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.alert-list-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
// PageShell 已接管:min-height, background, padding
|
||||
// SearchSection 已接管:筛选标签栏
|
||||
// ContentCard 已接管:alert-card 背景/圆角/阴影/触摸反馈
|
||||
// StatusTag 已接管:告警严重度和状态标签样式
|
||||
// PaginationBar 已接管:分页控件
|
||||
|
||||
.alert-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alert-list-title {
|
||||
@@ -29,126 +27,47 @@
|
||||
.alert-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.alert-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 24px;
|
||||
box-shadow: $shadow-sm;
|
||||
// 告警卡片左侧彩色边框 — 业务特有,不归 ContentCard
|
||||
.alert-card--critical {
|
||||
border-left: 4px solid $dan;
|
||||
}
|
||||
|
||||
.alert-card--warning {
|
||||
border-left: 4px solid $wrn;
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
&--critical {
|
||||
border-left-color: $dan;
|
||||
}
|
||||
|
||||
&--info {
|
||||
border-left-color: $tx3;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-severity {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-sm;
|
||||
|
||||
&--info {
|
||||
background: $bd-l;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
&--critical {
|
||||
background: $dan-l;
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
&--urgent {
|
||||
background: $dan-l;
|
||||
color: $dan;
|
||||
}
|
||||
.alert-card--info {
|
||||
border-left: 4px solid $tx3;
|
||||
}
|
||||
|
||||
.alert-status {
|
||||
font-size: var(--tk-font-body);
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-sm;
|
||||
|
||||
&--pending {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
&--acknowledged {
|
||||
background: $pri-l;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
&--resolved {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&--dismissed {
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
}
|
||||
.alert-card--urgent {
|
||||
border-left: 4px solid $dan;
|
||||
}
|
||||
|
||||
.alert-pagination {
|
||||
.alert-card__header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: 32px;
|
||||
|
||||
&__btn {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri;
|
||||
padding: 12px 24px;
|
||||
|
||||
&.disabled {
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.alert-card__title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.alert-card__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.alert-card__time {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { listAlerts, type Alert } from '@/services/doctor/alerts';
|
||||
import Loading from '@/components/Loading';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import StatusTag from '@/components/ui/StatusTag';
|
||||
import LoadingCard from '@/components/ui/LoadingCard';
|
||||
import PaginationBar from '@/components/patterns/PaginationBar';
|
||||
import SearchSection from '@/components/patterns/SearchSection';
|
||||
import ErrorState from '@/components/ErrorState';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import SegmentTabs from '@/components/SegmentTabs';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import './index.scss';
|
||||
|
||||
const SEVERITY_MAP: Record<string, { label: string; className: string }> = {
|
||||
info: { label: '提示', className: 'alert-severity--info' },
|
||||
warning: { label: '警告', className: 'alert-severity--warning' },
|
||||
critical: { label: '严重', className: 'alert-severity--critical' },
|
||||
urgent: { label: '紧急', className: 'alert-severity--urgent' },
|
||||
const SEVERITY_COLOR_MAP: Record<string, 'success' | 'warning' | 'error' | 'default'> = {
|
||||
info: 'default',
|
||||
warning: 'warning',
|
||||
critical: 'error',
|
||||
urgent: 'error',
|
||||
};
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||
pending: { label: '待处理', className: 'alert-status--pending' },
|
||||
acknowledged: { label: '已确认', className: 'alert-status--acknowledged' },
|
||||
resolved: { label: '已恢复', className: 'alert-status--resolved' },
|
||||
dismissed: { label: '已忽略', className: 'alert-status--dismissed' },
|
||||
const SEVERITY_LABEL: Record<string, string> = {
|
||||
info: '提示',
|
||||
warning: '警告',
|
||||
critical: '严重',
|
||||
urgent: '紧急',
|
||||
};
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'pending', label: '待处理' },
|
||||
{ value: 'acknowledged', label: '已确认' },
|
||||
{ value: 'resolved', label: '已恢复' },
|
||||
const STATUS_COLOR_MAP: Record<string, 'success' | 'warning' | 'info' | 'default'> = {
|
||||
pending: 'warning',
|
||||
acknowledged: 'info',
|
||||
resolved: 'success',
|
||||
dismissed: 'default',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
acknowledged: '已确认',
|
||||
resolved: '已恢复',
|
||||
dismissed: '已忽略',
|
||||
};
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'pending', label: '待处理' },
|
||||
{ key: 'acknowledged', label: '已确认' },
|
||||
{ key: 'resolved', label: '已恢复' },
|
||||
];
|
||||
|
||||
export default function AlertList() {
|
||||
@@ -42,8 +60,6 @@ export default function AlertList() {
|
||||
const [page, setPage] = useState(1);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
const totalPages = useMemo(() => Math.ceil(total / 20), [total]);
|
||||
|
||||
const loadAlerts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
@@ -65,7 +81,6 @@ export default function AlertList() {
|
||||
|
||||
const { trigger } = usePageData(loadAlerts);
|
||||
|
||||
// tab/page 变化时重新加载(跳过首次 mount,由 usePageData 的 useDidShow 处理)
|
||||
useEffect(() => {
|
||||
if (mountedRef.current) {
|
||||
trigger();
|
||||
@@ -94,89 +109,79 @@ export default function AlertList() {
|
||||
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
if (loading && alerts.length === 0) return <Loading />;
|
||||
if (loading && alerts.length === 0) return <LoadingCard count={3} />;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ScrollView scrollY className={`alert-list-page ${modeClass}`}>
|
||||
<View className='alert-list-header'>
|
||||
<Text className='alert-list-title'>告警列表</Text>
|
||||
</View>
|
||||
<View className='alert-tabs'>
|
||||
{STATUS_TABS.map((tab) => (
|
||||
<Text
|
||||
key={tab.value}
|
||||
className={`alert-tab ${activeTab === tab.value ? 'alert-tab--active' : ''}`}
|
||||
onClick={() => handleTabChange(tab.value)}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
<PageShell safeBottom className={modeClass}>
|
||||
<SearchSection
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
filters={STATUS_FILTERS}
|
||||
activeFilter={activeTab}
|
||||
onFilterChange={handleTabChange}
|
||||
/>
|
||||
<ErrorState onRetry={loadAlerts} />
|
||||
</ScrollView>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`alert-list-page ${modeClass}`}>
|
||||
<View className='alert-list-header'>
|
||||
<Text className='alert-list-title'>告警列表</Text>
|
||||
<Text className='alert-list-count'>共 {total} 条</Text>
|
||||
<PageShell safeBottom className={modeClass}>
|
||||
<View className="alert-list-header">
|
||||
<Text className="alert-list-title">告警列表</Text>
|
||||
<Text className="alert-list-count">共 {total} 条</Text>
|
||||
</View>
|
||||
|
||||
<SegmentTabs tabs={STATUS_TABS.map(t => ({ key: t.value, label: t.label }))} activeKey={activeTab} onChange={handleTabChange} variant="pill" />
|
||||
<SearchSection
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
filters={STATUS_FILTERS}
|
||||
activeFilter={activeTab}
|
||||
onFilterChange={handleTabChange}
|
||||
/>
|
||||
|
||||
{alerts.length === 0 ? (
|
||||
<EmptyState description='暂无告警' />
|
||||
<EmptyState text="暂无告警" />
|
||||
) : (
|
||||
<View className='alert-cards'>
|
||||
{alerts.map((alert) => {
|
||||
const severity = SEVERITY_MAP[alert.severity] ?? SEVERITY_MAP.info;
|
||||
const status = STATUS_MAP[alert.status] ?? STATUS_MAP.pending;
|
||||
return (
|
||||
<View
|
||||
key={alert.id}
|
||||
className='alert-card'
|
||||
onClick={() => handleAlertClick(alert)}
|
||||
>
|
||||
<View className='alert-card__header'>
|
||||
<Text className={`alert-severity ${severity.className}`}>
|
||||
{severity.label}
|
||||
</Text>
|
||||
<Text className={`alert-status ${status.className}`}>
|
||||
{status.label}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='alert-card__title'>{alert.title}</Text>
|
||||
<View className='alert-card__footer'>
|
||||
<Text className='alert-card__time'>{formatTime(alert.created_at)}</Text>
|
||||
</View>
|
||||
<View className="alert-cards">
|
||||
{alerts.map((alert) => (
|
||||
<ContentCard
|
||||
key={alert.id}
|
||||
onPress={() => handleAlertClick(alert)}
|
||||
className={`alert-card--${alert.severity || 'info'}`}
|
||||
>
|
||||
<View className="alert-card__header">
|
||||
<StatusTag
|
||||
status={alert.severity}
|
||||
colorMap={SEVERITY_COLOR_MAP}
|
||||
size="sm"
|
||||
>
|
||||
{SEVERITY_LABEL[alert.severity] || '提示'}
|
||||
</StatusTag>
|
||||
<StatusTag
|
||||
status={alert.status}
|
||||
colorMap={STATUS_COLOR_MAP}
|
||||
size="sm"
|
||||
>
|
||||
{STATUS_LABEL[alert.status] || '未知'}
|
||||
</StatusTag>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<Text className="alert-card__title">{alert.title}</Text>
|
||||
<View className="alert-card__footer">
|
||||
<Text className="alert-card__time">{formatTime(alert.created_at)}</Text>
|
||||
</View>
|
||||
</ContentCard>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{total > 20 && (
|
||||
<View className='alert-pagination'>
|
||||
<Text
|
||||
className={`alert-pagination__btn ${page <= 1 ? 'disabled' : ''}`}
|
||||
onClick={() => page > 1 && setPage(page - 1)}
|
||||
>
|
||||
上一页
|
||||
</Text>
|
||||
<Text className='alert-pagination__info'>
|
||||
{page} / {totalPages}
|
||||
</Text>
|
||||
<Text
|
||||
className={`alert-pagination__btn ${page >= totalPages ? 'disabled' : ''}`}
|
||||
onClick={() => page < totalPages && setPage(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
<PaginationBar
|
||||
current={page}
|
||||
total={total}
|
||||
pageSize={20}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,24 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.dialysis-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
padding: 16px 24px;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px 20px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
// PageShell 已接管:min-height, background, padding
|
||||
// SearchSection 已接管:search-bar
|
||||
// ContentCard 已接管:record-card 背景/圆角/阴影/触摸反馈
|
||||
// StatusTag 已接管:status-tag 标签样式
|
||||
// PaginationBar 已接管:pagination 分页样式
|
||||
|
||||
.record-count {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
padding: 8px 0 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
text {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.record-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
.record-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.record-card__header {
|
||||
@@ -69,25 +48,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-xs;
|
||||
font-size: var(--tk-font-body);
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
|
||||
&--completed {
|
||||
background: $pri-l;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
&--reviewed {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.record-card__body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -106,31 +66,6 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 12px 24px;
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { listDialysisRecords, type DialysisRecord } from '@/services/doctor/dialysis';
|
||||
import { listPatients } from '@/services/doctor/patient';
|
||||
import Loading from '@/components/Loading';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import StatusTag from '@/components/ui/StatusTag';
|
||||
import LoadingCard from '@/components/ui/LoadingCard';
|
||||
import SearchSection from '@/components/patterns/SearchSection';
|
||||
import PaginationBar from '@/components/patterns/PaginationBar';
|
||||
import SegmentTabs from '@/components/SegmentTabs';
|
||||
import ErrorState from '@/components/ErrorState';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import SegmentTabs from '@/components/SegmentTabs';
|
||||
import './index.scss';;
|
||||
import './index.scss';
|
||||
|
||||
const TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
@@ -21,6 +26,12 @@ const TABS = [
|
||||
|
||||
const TYPE_MAP: Record<string, string> = { HD: 'HD', HDF: 'HDF', HF: 'HF' };
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
completed: '已完成',
|
||||
reviewed: '已审核',
|
||||
};
|
||||
|
||||
export default function DialysisList() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
@@ -90,82 +101,69 @@ export default function DialysisList() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// 服务端已按 activeTab 过滤,无需客户端二次筛选
|
||||
|
||||
if (loading && records.length === 0) return <Loading />;
|
||||
if (loading && records.length === 0) return <LoadingCard count={3} />;
|
||||
if (error) return <ErrorState onRetry={() => loadRecords(1)} />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`dialysis-page ${modeClass}`}>
|
||||
{!patientId && (
|
||||
<View className='search-bar'>
|
||||
<Input
|
||||
className='search-input'
|
||||
placeholder='搜索患者姓名'
|
||||
value={searchPatient}
|
||||
onInput={(e) => setSearchPatient(e.detail.value)}
|
||||
confirmType='search'
|
||||
onConfirm={handleSearch}
|
||||
/>
|
||||
</View>
|
||||
<PageShell safeBottom className={modeClass}>
|
||||
{!patientId ? (
|
||||
<SearchSection
|
||||
value={searchPatient}
|
||||
onChange={setSearchPatient}
|
||||
onSearch={handleSearch}
|
||||
placeholder="搜索患者姓名"
|
||||
filters={TABS}
|
||||
activeFilter={activeTab}
|
||||
onFilterChange={handleTab}
|
||||
/>
|
||||
) : (
|
||||
<SegmentTabs tabs={TABS} activeKey={activeTab} onChange={handleTab} variant="underline" />
|
||||
)}
|
||||
|
||||
<SegmentTabs tabs={TABS} activeKey={activeTab} onChange={handleTab} variant="underline" />
|
||||
|
||||
{!currentPatientId ? (
|
||||
<EmptyState text='请搜索并选择患者' />
|
||||
<EmptyState text="请搜索并选择患者" />
|
||||
) : records.length === 0 ? (
|
||||
<EmptyState text='暂无透析记录' />
|
||||
<EmptyState text="暂无透析记录" />
|
||||
) : (
|
||||
<View className='record-list'>
|
||||
<View className='record-count'><Text>共 {total} 条记录</Text></View>
|
||||
{records.map((r) => (
|
||||
<View
|
||||
key={r.id}
|
||||
className='record-card'
|
||||
onClick={() => safeNavigateTo(`/pages/pkg-doctor-clinical/dialysis/detail/index?id=${r.id}`)}
|
||||
>
|
||||
<View className='record-card__header'>
|
||||
<Text className={`type-tag type-tag--${(r.dialysis_type || 'hd').toLowerCase()}`}>
|
||||
{TYPE_MAP[r.dialysis_type] || r.dialysis_type}
|
||||
</Text>
|
||||
<Text className={`status-tag status-tag--${r.status}`}>
|
||||
{r.status === 'draft' ? '草稿' : r.status === 'completed' ? '已完成' : '已审核'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='record-card__body'>
|
||||
<Text className='record-card__date'>{r.dialysis_date}</Text>
|
||||
{r.dialysis_duration != null && (
|
||||
<Text className='record-card__meta'>时长 {r.dialysis_duration}分钟</Text>
|
||||
)}
|
||||
{r.ultrafiltration_volume != null && (
|
||||
<Text className='record-card__meta'>超滤 {r.ultrafiltration_volume}ml</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
{total > 20 && (
|
||||
<View className='pagination'>
|
||||
<View
|
||||
className={`page-btn ${page <= 1 ? 'page-btn--disabled' : ''}`}
|
||||
onClick={() => page > 1 && loadRecords(page - 1)}
|
||||
<>
|
||||
<View className="record-count">
|
||||
<Text>共 {total} 条记录</Text>
|
||||
</View>
|
||||
<View className="record-cards">
|
||||
{records.map((r) => (
|
||||
<ContentCard
|
||||
key={r.id}
|
||||
onPress={() => safeNavigateTo(`/pages/pkg-doctor-clinical/dialysis/detail/index?id=${r.id}`)}
|
||||
>
|
||||
<Text>上一页</Text>
|
||||
</View>
|
||||
<Text className='page-info'>{page} / {Math.ceil(total / 20)}</Text>
|
||||
<View
|
||||
className={`page-btn ${page * 20 >= total ? 'page-btn--disabled' : ''}`}
|
||||
onClick={() => page * 20 < total && loadRecords(page + 1)}
|
||||
>
|
||||
<Text>下一页</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className="record-card__header">
|
||||
<Text className={`type-tag type-tag--${(r.dialysis_type || 'hd').toLowerCase()}`}>
|
||||
{TYPE_MAP[r.dialysis_type] || r.dialysis_type}
|
||||
</Text>
|
||||
<StatusTag status={r.status} size="sm">{STATUS_LABEL[r.status] || r.status}</StatusTag>
|
||||
</View>
|
||||
<View className="record-card__body">
|
||||
<Text className="record-card__date">{r.dialysis_date}</Text>
|
||||
{r.dialysis_duration != null && (
|
||||
<Text className="record-card__meta">时长 {r.dialysis_duration}分钟</Text>
|
||||
)}
|
||||
{r.ultrafiltration_volume != null && (
|
||||
<Text className="record-card__meta">超滤 {r.ultrafiltration_volume}ml</Text>
|
||||
)}
|
||||
</View>
|
||||
</ContentCard>
|
||||
))}
|
||||
</View>
|
||||
<PaginationBar
|
||||
current={page}
|
||||
total={total}
|
||||
pageSize={20}
|
||||
onChange={(p) => loadRecords(p)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View
|
||||
className='fab'
|
||||
className="fab"
|
||||
onClick={() => {
|
||||
if (!currentPatientId) {
|
||||
Taro.showToast({ title: '请先选择患者', icon: 'none' });
|
||||
@@ -174,8 +172,8 @@ export default function DialysisList() {
|
||||
safeNavigateTo(`/pages/pkg-doctor-clinical/dialysis/create/index?patientId=${currentPatientId}`);
|
||||
}}
|
||||
>
|
||||
<Text className='fab-text'>+</Text>
|
||||
<Text className="fab-text">+</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,24 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.prescription-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
padding: 16px 24px;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px 20px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.prescription-list {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
// PageShell 已接管:min-height, background, padding
|
||||
// SearchSection 已接管:search-bar
|
||||
// ContentCard 已接管:prescription-card 背景/圆角/阴影/触摸反馈
|
||||
// StatusTag 已接管:status-tag 标签样式
|
||||
// PaginationBar 已接管:pagination 分页样式
|
||||
|
||||
.prescription-count {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
padding: 8px 0 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
text {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.prescription-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
.prescription-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.prescription-card__header {
|
||||
@@ -55,20 +34,6 @@
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-xs;
|
||||
font-size: var(--tk-font-body);
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
|
||||
&--active {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.prescription-card__body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
@@ -88,31 +53,6 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 12px 24px;
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { listDialysisPrescriptions, type DialysisPrescription } from '@/services/doctor/dialysis';
|
||||
import { listPatients } from '@/services/doctor/patient';
|
||||
import Loading from '@/components/Loading';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import StatusTag from '@/components/ui/StatusTag';
|
||||
import LoadingCard from '@/components/ui/LoadingCard';
|
||||
import SearchSection from '@/components/patterns/SearchSection';
|
||||
import PaginationBar from '@/components/patterns/PaginationBar';
|
||||
import SegmentTabs from '@/components/SegmentTabs';
|
||||
import ErrorState from '@/components/ErrorState';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import SegmentTabs from '@/components/SegmentTabs';
|
||||
import './index.scss';
|
||||
|
||||
const TABS = [
|
||||
@@ -18,6 +23,11 @@ const TABS = [
|
||||
{ key: 'inactive', label: '已停用' },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
active: '生效中',
|
||||
inactive: '已停用',
|
||||
};
|
||||
|
||||
export default function PrescriptionList() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
@@ -82,80 +92,74 @@ export default function PrescriptionList() {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && prescriptions.length === 0) return <Loading />;
|
||||
const handleTab = (key: string) => {
|
||||
setActiveTab(key);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
if (loading && prescriptions.length === 0) return <LoadingCard count={3} />;
|
||||
if (error) return <ErrorState onRetry={() => loadData(1)} />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`prescription-page ${modeClass}`}>
|
||||
{!patientId && (
|
||||
<View className='search-bar'>
|
||||
<Input
|
||||
className='search-input'
|
||||
placeholder='搜索患者姓名'
|
||||
value={searchPatient}
|
||||
onInput={(e) => setSearchPatient(e.detail.value)}
|
||||
confirmType='search'
|
||||
onConfirm={handleSearch}
|
||||
/>
|
||||
</View>
|
||||
<PageShell safeBottom className={modeClass}>
|
||||
{!patientId ? (
|
||||
<SearchSection
|
||||
value={searchPatient}
|
||||
onChange={setSearchPatient}
|
||||
onSearch={handleSearch}
|
||||
placeholder="搜索患者姓名"
|
||||
filters={TABS}
|
||||
activeFilter={activeTab}
|
||||
onFilterChange={handleTab}
|
||||
/>
|
||||
) : (
|
||||
<SegmentTabs tabs={TABS} activeKey={activeTab} onChange={handleTab} variant="underline" />
|
||||
)}
|
||||
|
||||
<SegmentTabs tabs={TABS} activeKey={activeTab} onChange={(key) => { setActiveTab(key); setPage(1); }} variant="underline" />
|
||||
|
||||
{prescriptions.length === 0 ? (
|
||||
<EmptyState text='暂无透析处方' />
|
||||
<EmptyState text="暂无透析处方" />
|
||||
) : (
|
||||
<View className='prescription-list'>
|
||||
<View className='prescription-count'><Text>共 {total} 条处方</Text></View>
|
||||
{prescriptions.map((p) => (
|
||||
<View
|
||||
key={p.id}
|
||||
className='prescription-card'
|
||||
onClick={() => safeNavigateTo(`/pages/pkg-doctor-clinical/prescription/detail/index?id=${p.id}`)}
|
||||
>
|
||||
<View className='prescription-card__header'>
|
||||
<Text className='prescription-card__model'>{p.dialyzer_model || '透析处方'}</Text>
|
||||
<Text className={`status-tag status-tag--${p.status}`}>
|
||||
{p.status === 'active' ? '生效中' : p.status === 'inactive' ? '已停用' : p.status}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='prescription-card__body'>
|
||||
{p.frequency_per_week != null && (
|
||||
<Text className='prescription-card__meta'>{p.frequency_per_week}次/周</Text>
|
||||
)}
|
||||
{p.duration_minutes != null && (
|
||||
<Text className='prescription-card__meta'>每次{p.duration_minutes}分钟</Text>
|
||||
)}
|
||||
</View>
|
||||
{(p.effective_from || p.effective_to) && (
|
||||
<Text className='prescription-card__date'>
|
||||
{p.effective_from || '...'} ~ {p.effective_to || '...'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
{total > 20 && (
|
||||
<View className='pagination'>
|
||||
<View
|
||||
className={`page-btn ${page <= 1 ? 'page-btn--disabled' : ''}`}
|
||||
onClick={() => page > 1 && loadData(page - 1)}
|
||||
<>
|
||||
<View className="prescription-count">
|
||||
<Text>共 {total} 条处方</Text>
|
||||
</View>
|
||||
<View className="prescription-cards">
|
||||
{prescriptions.map((p) => (
|
||||
<ContentCard
|
||||
key={p.id}
|
||||
onPress={() => safeNavigateTo(`/pages/pkg-doctor-clinical/prescription/detail/index?id=${p.id}`)}
|
||||
>
|
||||
<Text>上一页</Text>
|
||||
</View>
|
||||
<Text className='page-info'>{page} / {Math.ceil(total / 20)}</Text>
|
||||
<View
|
||||
className={`page-btn ${page * 20 >= total ? 'page-btn--disabled' : ''}`}
|
||||
onClick={() => page * 20 < total && loadData(page + 1)}
|
||||
>
|
||||
<Text>下一页</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className="prescription-card__header">
|
||||
<Text className="prescription-card__model">{p.dialyzer_model || '透析处方'}</Text>
|
||||
<StatusTag status={p.status} size="sm">{STATUS_LABEL[p.status] || p.status}</StatusTag>
|
||||
</View>
|
||||
<View className="prescription-card__body">
|
||||
{p.frequency_per_week != null && (
|
||||
<Text className="prescription-card__meta">{p.frequency_per_week}次/周</Text>
|
||||
)}
|
||||
{p.duration_minutes != null && (
|
||||
<Text className="prescription-card__meta">每次{p.duration_minutes}分钟</Text>
|
||||
)}
|
||||
</View>
|
||||
{(p.effective_from || p.effective_to) && (
|
||||
<Text className="prescription-card__date">
|
||||
{p.effective_from || '...'} ~ {p.effective_to || '...'}
|
||||
</Text>
|
||||
)}
|
||||
</ContentCard>
|
||||
))}
|
||||
</View>
|
||||
<PaginationBar
|
||||
current={page}
|
||||
total={total}
|
||||
pageSize={20}
|
||||
onChange={(p) => loadData(p)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View
|
||||
className='fab'
|
||||
className="fab"
|
||||
onClick={() => {
|
||||
if (!currentPatientId) {
|
||||
Taro.showToast({ title: '请先选择患者', icon: 'none' });
|
||||
@@ -164,8 +168,8 @@ export default function PrescriptionList() {
|
||||
safeNavigateTo(`/pages/pkg-doctor-clinical/prescription/create/index?patientId=${currentPatientId}`);
|
||||
}}
|
||||
>
|
||||
<Text className='fab-text'>+</Text>
|
||||
<Text className="fab-text">+</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,9 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.report-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.search-input {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
}
|
||||
// PageShell 已接管:min-height, background, padding
|
||||
// SearchSection 已接管:search-bar
|
||||
// ContentCard 已接管:report-card 背景/圆角/阴影/触摸反馈
|
||||
// StatusTag 已接管:reviewed 标签样式
|
||||
|
||||
.report-count {
|
||||
margin-bottom: 16px;
|
||||
@@ -31,59 +14,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
.report-list {
|
||||
.report-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.report-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.report-card__type {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.report-card__date {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.report-card__indicators {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.report-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 28px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__type {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__indicators {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__abnormal {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $dan;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__normal {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&__reviewed {
|
||||
@include tag($acc-l, $acc);
|
||||
}
|
||||
.report-card__abnormal {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $dan;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.report-card__normal {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { listLabReports, type LabReportItem } from '@/services/doctor/labReport';
|
||||
import { listPatients } from '@/services/doctor/patient';
|
||||
import Loading from '@/components/Loading';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import StatusTag from '@/components/ui/StatusTag';
|
||||
import LoadingCard from '@/components/ui/LoadingCard';
|
||||
import SearchSection from '@/components/patterns/SearchSection';
|
||||
import ErrorState from '@/components/ErrorState';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
@@ -68,59 +72,56 @@ export default function ReportList() {
|
||||
|
||||
const formatDate = (d: string) => new Date(d).toLocaleDateString('zh-CN');
|
||||
|
||||
if (loading && reports.length === 0) return <Loading />;
|
||||
if (loading && reports.length === 0) return <LoadingCard count={3} />;
|
||||
if (error) return <ErrorState onRetry={loadReports} />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`report-page ${modeClass}`}>
|
||||
<PageShell safeBottom className={modeClass}>
|
||||
{!patientId && (
|
||||
<View className='search-bar'>
|
||||
<Input
|
||||
className='search-input'
|
||||
placeholder='搜索患者姓名'
|
||||
value={searchPatient}
|
||||
onInput={(e) => setSearchPatient(e.detail.value)}
|
||||
confirmType='search'
|
||||
onConfirm={handleSearch}
|
||||
/>
|
||||
</View>
|
||||
<SearchSection
|
||||
value={searchPatient}
|
||||
onChange={setSearchPatient}
|
||||
onSearch={handleSearch}
|
||||
placeholder="搜索患者姓名"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!currentPatientId ? (
|
||||
<EmptyState text='请搜索并选择患者' />
|
||||
<EmptyState text="请搜索并选择患者" />
|
||||
) : reports.length === 0 ? (
|
||||
<EmptyState text='暂无化验报告' />
|
||||
<EmptyState text="暂无化验报告" />
|
||||
) : (
|
||||
<View className='report-list'>
|
||||
<View className='report-count'>
|
||||
<>
|
||||
<View className="report-count">
|
||||
<Text>共 {total} 份报告</Text>
|
||||
</View>
|
||||
{reports.map((r) => (
|
||||
<View
|
||||
key={r.id}
|
||||
className='report-card'
|
||||
onClick={() => Taro.navigateTo({
|
||||
url: `/pages/pkg-doctor-clinical/report/detail/index?patientId=${currentPatientId}&id=${r.id}`,
|
||||
})}
|
||||
>
|
||||
<View className='report-card__header'>
|
||||
<Text className='report-card__type'>{r.report_type}</Text>
|
||||
<Text className='report-card__date'>{formatDate(r.report_date)}</Text>
|
||||
</View>
|
||||
<View className='report-card__indicators'>
|
||||
{(r.abnormal_count ?? 0) > 0 ? (
|
||||
<Text className='report-card__abnormal'>{r.abnormal_count} 项异常</Text>
|
||||
) : (
|
||||
<Text className='report-card__normal'>指标正常</Text>
|
||||
)}
|
||||
{r.status === 'reviewed' && (
|
||||
<Text className='report-card__reviewed'>已审核</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<View className="report-cards">
|
||||
{reports.map((r) => (
|
||||
<ContentCard
|
||||
key={r.id}
|
||||
onPress={() => Taro.navigateTo({
|
||||
url: `/pages/pkg-doctor-clinical/report/detail/index?patientId=${currentPatientId}&id=${r.id}`,
|
||||
})}
|
||||
>
|
||||
<View className="report-card__header">
|
||||
<Text className="report-card__type">{r.report_type}</Text>
|
||||
<Text className="report-card__date">{formatDate(r.report_date)}</Text>
|
||||
</View>
|
||||
<View className="report-card__indicators">
|
||||
{(r.abnormal_count ?? 0) > 0 ? (
|
||||
<Text className="report-card__abnormal">{r.abnormal_count} 项异常</Text>
|
||||
) : (
|
||||
<Text className="report-card__normal">指标正常</Text>
|
||||
)}
|
||||
{r.status === 'reviewed' && (
|
||||
<StatusTag status="reviewed" size="sm">已审核</StatusTag>
|
||||
)}
|
||||
</View>
|
||||
</ContentCard>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,9 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.patient-list-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.search-input {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-filter {
|
||||
white-space: nowrap;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
display: inline-block;
|
||||
padding: 10px 24px;
|
||||
border-radius: $r-pill;
|
||||
background: $bd-l;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
margin-right: 16px;
|
||||
|
||||
&.active {
|
||||
background: $pri;
|
||||
color: $card;
|
||||
}
|
||||
}
|
||||
// PageShell 已接管:min-height, background, padding
|
||||
// SearchSection 已接管:search-bar
|
||||
// ContentCard 已接管:patient-card 背景/圆角/阴影/触摸反馈
|
||||
// StatusTag 已接管:patient-card__status 标签样式
|
||||
|
||||
.patient-count {
|
||||
margin-bottom: 16px;
|
||||
@@ -55,90 +17,44 @@
|
||||
.patient-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.patient-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.patient-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 28px;
|
||||
box-shadow: $shadow-sm;
|
||||
.patient-card__name {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
.patient-card__meta {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__status {
|
||||
@include tag($bg, $tx2);
|
||||
|
||||
&--active {
|
||||
@include tag($acc-l, $acc);
|
||||
}
|
||||
|
||||
&--inactive {
|
||||
@include tag($bd-l, $tx3);
|
||||
}
|
||||
}
|
||||
.patient-card__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.patient-tag {
|
||||
padding: 4px 14px;
|
||||
border-radius: $r;
|
||||
background: $pri-l;
|
||||
background: rgba($pri, 0.1);
|
||||
|
||||
&__text {
|
||||
font-size: var(--tk-font-body);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: 32px;
|
||||
|
||||
&__btn {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri;
|
||||
padding: 12px 24px;
|
||||
|
||||
&.disabled {
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
|
||||
.load-more-hint-wrap {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useReachBottom } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { listPatients, listPatientTags, type PatientItem, type PatientTag } from '@/services/doctor/patient';
|
||||
import Loading from '@/components/Loading';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import StatusTag from '@/components/ui/StatusTag';
|
||||
import LoadingCard from '@/components/ui/LoadingCard';
|
||||
import SearchSection from '@/components/patterns/SearchSection';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
@@ -19,9 +24,7 @@ export default function PatientList() {
|
||||
const [page, setPage] = useState(1);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadTags();
|
||||
}, []);
|
||||
useEffect(() => { loadTags(); }, []);
|
||||
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
@@ -40,11 +43,7 @@ export default function PatientList() {
|
||||
tag_id: activeTag || undefined,
|
||||
});
|
||||
const list = res.data || [];
|
||||
if (isRefresh) {
|
||||
setPatients(list);
|
||||
} else {
|
||||
setPatients((prev) => [...prev, ...list]);
|
||||
}
|
||||
setPatients(prev => isRefresh ? list : [...prev, ...list]);
|
||||
setTotal(res.total || 0);
|
||||
setPage(pageNum);
|
||||
} catch {
|
||||
@@ -59,24 +58,15 @@ export default function PatientList() {
|
||||
{ enablePullDown: true },
|
||||
);
|
||||
|
||||
// tag 变化时重新加载(跳过首次 mount,由 usePageData 的 useDidShow 处理)
|
||||
useEffect(() => {
|
||||
if (mountedRef.current) {
|
||||
loadPatients(1, true);
|
||||
}
|
||||
if (mountedRef.current) { loadPatients(1, true); }
|
||||
mountedRef.current = true;
|
||||
}, [activeTag, loadPatients]);
|
||||
|
||||
useReachBottom(() => {
|
||||
if (!loading && patients.length < total) {
|
||||
loadPatients(page + 1);
|
||||
}
|
||||
if (!loading && patients.length < total) { loadPatients(page + 1); }
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
loadPatients(1, true);
|
||||
};
|
||||
|
||||
const handleTagFilter = (tagId: string) => {
|
||||
setActiveTag(tagId === activeTag ? '' : tagId);
|
||||
};
|
||||
@@ -98,91 +88,69 @@ export default function PatientList() {
|
||||
return `${age}岁`;
|
||||
};
|
||||
|
||||
if (loading && patients.length === 0) return <Loading />;
|
||||
const filters = [
|
||||
{ key: '', label: '全部' },
|
||||
...tags.map(t => ({ key: t.id, label: t.name })),
|
||||
];
|
||||
|
||||
if (loading && patients.length === 0) return <LoadingCard count={3} />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`patient-list-page ${modeClass}`}>
|
||||
<View className='search-bar'>
|
||||
<Input
|
||||
className='search-input'
|
||||
placeholder='搜索患者姓名/手机号'
|
||||
value={search}
|
||||
onInput={(e) => setSearch(e.detail.value)}
|
||||
confirmType='search'
|
||||
onConfirm={handleSearch}
|
||||
/>
|
||||
</View>
|
||||
<PageShell safeBottom className={modeClass}>
|
||||
<SearchSection
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
onSearch={() => loadPatients(1, true)}
|
||||
placeholder="搜索患者姓名/手机号"
|
||||
filters={filters}
|
||||
activeFilter={activeTag}
|
||||
onFilterChange={handleTagFilter}
|
||||
/>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<ScrollView scrollX className='tag-filter'>
|
||||
<View
|
||||
className={`tag-chip ${!activeTag ? 'active' : ''}`}
|
||||
onClick={() => handleTagFilter('')}
|
||||
>
|
||||
<Text>全部</Text>
|
||||
</View>
|
||||
{tags.map((tag) => (
|
||||
<View
|
||||
key={tag.id}
|
||||
className={`tag-chip ${activeTag === tag.id ? 'active' : ''}`}
|
||||
style={activeTag === tag.id && tag.color ? `background: ${tag.color}; color: white` : ''}
|
||||
onClick={() => handleTagFilter(tag.id)}
|
||||
>
|
||||
<Text>{tag.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<View className='patient-count'>
|
||||
<View className="patient-count">
|
||||
<Text>共 {total} 位患者</Text>
|
||||
</View>
|
||||
|
||||
{patients.length === 0 ? (
|
||||
<EmptyState text='暂无患者数据' />
|
||||
<EmptyState text="暂无患者数据" />
|
||||
) : (
|
||||
<View className='patient-cards'>
|
||||
<View className="patient-cards">
|
||||
{patients.map((p) => (
|
||||
<View
|
||||
<ContentCard
|
||||
key={p.id}
|
||||
className='patient-card'
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/pkg-doctor-core/patients/detail/index?id=${p.id}` })}
|
||||
onPress={() => Taro.navigateTo({ url: `/pages/pkg-doctor-core/patients/detail/index?id=${p.id}` })}
|
||||
>
|
||||
<View className='patient-card__header'>
|
||||
<Text className='patient-card__name'>{p.name}</Text>
|
||||
<Text className='patient-card__meta'>
|
||||
<View className="patient-card__header">
|
||||
<Text className="patient-card__name">{p.name}</Text>
|
||||
<Text className="patient-card__meta">
|
||||
{getGenderLabel(p.gender)} {calcAge(p.birth_date)}
|
||||
</Text>
|
||||
{p.status && <StatusTag status={p.status} size="sm" />}
|
||||
</View>
|
||||
{p.tags && p.tags.length > 0 && (
|
||||
<View className='patient-card__tags'>
|
||||
<View className="patient-card__tags">
|
||||
{p.tags.map((t) => (
|
||||
<View
|
||||
key={t.id}
|
||||
className='patient-tag'
|
||||
className="patient-tag"
|
||||
style={t.color ? `background: ${t.color}20; color: ${t.color}` : ''}
|
||||
>
|
||||
<Text className='patient-tag__text'>{t.name}</Text>
|
||||
<Text className="patient-tag__text">{t.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{p.status && (
|
||||
<Text className={`patient-card__status patient-card__status--${p.status}`}>
|
||||
{p.status === 'active' ? '活跃' : p.status === 'inactive' ? '非活跃' : p.status}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ContentCard>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!loading && patients.length >= total && total > 0 && (
|
||||
<View className='load-more-hint-wrap'>
|
||||
<Text className='load-more-hint'>没有更多了</Text>
|
||||
<View className="load-more-hint-wrap">
|
||||
<Text className="load-more-hint">没有更多了</Text>
|
||||
</View>
|
||||
)}
|
||||
{loading && patients.length > 0 && <Loading />}
|
||||
</ScrollView>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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