Compare commits

...

8 Commits

Author SHA1 Message Date
iven
483342a1d8 refactor(mp): 迁移告警列表页 — 使用统一组件库 PageShell/ContentCard/StatusTag/LoadingCard/SearchSection/PaginationBar 2026-05-16 00:56:26 +08:00
iven
ae23baeece refactor(mp): 迁移报告列表页 — 使用统一组件库
- PageShell 替代手写 ScrollView + min-height/bg/padding
- SearchSection 替代搜索栏
- ContentCard 替代 report-card 手写样式
- StatusTag 替代 report-card__reviewed 手写标签
- LoadingCard 替代 Loading 组件
- 精简 SCSS:删除 page/search-bar/card 通用样式,保留业务特有样式
2026-05-16 00:56:18 +08:00
iven
3e88dcaba5 refactor(mp): 迁移处方列表页 — 使用统一组件库
- PageShell 替代手写 ScrollView + min-height/bg/padding
- SearchSection 替代搜索栏 + SegmentTabs 替代 tabs(预设患者时)
- ContentCard 替代 prescription-card 手写样式
- StatusTag 替代 status-tag 手写样式
- LoadingCard 替代 Loading 组件
- PaginationBar 替代手写分页
- 精简 SCSS:删除 page/search-bar/card/pagination 通用样式,保留业务特有样式
2026-05-16 00:56:02 +08:00
iven
9415807a40 refactor(mp): 迁移透析列表页 — 使用统一组件库
- PageShell 替代手写 ScrollView + min-height/bg/padding
- SearchSection 替代搜索栏 + SegmentTabs 替代 tabs(预设患者时)
- ContentCard 替代 record-card 手写样式
- StatusTag 替代 status-tag 手写样式
- LoadingCard 替代 Loading 组件
- PaginationBar 替代手写分页
- 精简 SCSS:删除 page/search-bar/card/pagination 通用样式,保留业务特有样式
2026-05-16 00:55:50 +08:00
iven
1579f35ff5 refactor(mp): 迁移患者咨询列表页 — 使用统一组件库
- PageShell 替代手写 min-height/bg/padding
- ContentCard 替代手写 session-card 卡片样式
- StatusTag 替代手写 session-tag 状态标签
- LoadingCard 替代初始加载态
- EmptyState 替代手写空状态
- ErrorState 替代手写错误状态
- 精简 SCSS 删除已接管样式,保留按钮/头像/角标等页面特有样式

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 00:55:40 +08:00
iven
9728afbc1b refactor(mp): 迁移文章列表页 — 使用统一组件库
- PageShell 替代手写 min-height/bg/padding
- ContentCard 替代手写 article-card 卡片样式
- LoadingCard 替代初始加载态
- 精简 SCSS 删除已接管样式,保留分类筛选/卡片内容布局

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 00:55:24 +08:00
iven
80794c9547 refactor(mp): 试点迁移患者列表页 — 使用统一组件库
替换手写 UI 为:
- PageShell 替代手动 min-height/bg/padding
- SearchSection 替代手写搜索栏 + 标签筛选
- ContentCard 替代手写卡片样式(背景/圆角/阴影/触摸反馈)
- StatusTag 替代 @include tag() mixin
- LoadingCard 替代初始加载的 Loading 组件

SCSS 从 151 行精简到 65 行,保留页面特有业务样式。
数据加载逻辑(无限滚动 + usePageData)保持不变。
2026-05-16 00:50:27 +08:00
iven
d758563a13 feat(mp): 小程序统一组件库 Phase 1 — Token 扩展 + 10 组件 + useListPage Hook
三层架构组件库:
- 第 1 层原子组件:PageShell/ContentCard/StatusTag/SectionTitle/LoadingCard
- 第 2 层组合模式:PageHeader/SearchSection/CardList/PaginationBar
- 第 3 层 Hook:useListPage(列表页通用逻辑抽象)

Token 扩展:新增 --tk-card-*/--tk-gap-*/--tk-page-* 等结构化 CSS 变量,
关怀模式通过变量覆写自动生效,新组件零额外代码即获关怀支持。

设计规格:docs/superpowers/specs/2026-05-16-miniprogram-unified-components-design.md
2026-05-16 00:47:39 +08:00
35 changed files with 1750 additions and 1028 deletions

View File

@@ -0,0 +1,12 @@
.card-list {
display: flex;
flex-direction: column;
&--sm {
gap: var(--tk-gap-sm);
}
&--md {
gap: var(--tk-gap-md);
}
}

View 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;

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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);

View 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);
}
}

View 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);

View 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;
}
}
}

View 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);

View 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));
}
}

View 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);

View 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;
}
}

View 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);

View 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;
}
}

View 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);

View 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,
};
}

View File

@@ -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);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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/lgsafeBottomscroll 包裹。
**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 等)
- 清理未使用的 mixinscard 等)
- 精简 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 或真机预览验证触摸反馈和交互