feat(mp): design-handoff 产出的页面样式和组件优化

- 首页/商城/医生端/积分/家庭档案等页面 SCSS + TSX 更新
- TabFilter 组件样式优化
- points service 接口调整
- app.config 路由注册更新
This commit is contained in:
iven
2026-05-18 02:12:41 +08:00
parent 2698c98888
commit e555496528
26 changed files with 1887 additions and 1428 deletions

View File

@@ -38,7 +38,7 @@ export default defineAppConfig({
},
{
root: 'pages/pkg-mall',
pages: ['exchange/index', 'orders/index', 'detail/index'],
pages: ['exchange/index', 'orders/index', 'detail/index', 'product/index'],
},
{
root: 'pages/pkg-profile',

View File

@@ -28,22 +28,26 @@
}
}
// Pill型 — 文章分类
// Pill型 — 筛选标签(医生端 ActionInbox / FollowUpList
&--pill {
flex-wrap: wrap;
gap: var(--tk-gap-xs);
.tab-filter__item {
height: 32px;
padding: 0 var(--tk-gap-lg);
border-radius: $r-pill;
background: $surface-alt;
padding: 0 16px;
border-radius: $r-lg;
background: $card;
border: 1px solid $bd;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&--active {
background: var(--tk-pri);
border-color: var(--tk-pri);
box-shadow: var(--tk-shadow-tab);
.tab-filter__text {
color: $white;

View File

@@ -298,7 +298,7 @@
}
/* ═══════════════════════════════════════
访客首页
访客首页 — 对齐原型 docs/design/mp-14-guest-home.html
═══════════════════════════════════════ */
.guest-page {
@@ -308,7 +308,7 @@
/* ─── 轮播图 ─── */
.guest-swiper {
width: 100%;
height: 400px;
height: 200px;
}
.guest-slide {
@@ -340,124 +340,162 @@
display: flex;
flex-direction: column;
justify-content: center;
padding: var(--tk-gap-2xl) var(--tk-gap-xl);
padding: 0 var(--tk-gap-xl);
}
.guest-slide-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-h1);
font-size: 24px;
font-weight: 700;
color: $white;
display: block;
margin-bottom: var(--tk-gap-xs);
margin-bottom: 6px;
}
.guest-slide-desc {
font-size: var(--tk-font-body-sm);
color: rgba(255, 255, 255, 0.85);
color: rgba(255, 255, 255, 0.8);
display: block;
}
/* ─── 健康资讯 ─── */
.guest-section {
padding: var(--tk-gap-lg) var(--tk-gap-lg) 0;
padding: var(--tk-gap-lg) var(--tk-page-padding) 0;
}
.guest-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--tk-gap-md);
}
.guest-section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body);
font-weight: bold;
font-size: var(--tk-font-nav);
font-weight: 700;
color: $tx;
display: block;
margin-bottom: var(--tk-gap-md);
}
.guest-section-more {
font-size: var(--tk-font-cap);
color: $tx3;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.guest-articles {
display: flex;
flex-direction: column;
gap: var(--tk-gap-sm);
gap: 10px;
}
// 文章卡片 — 左图标 + 右标题/日期
.guest-article-card {
overflow: hidden;
background: $card;
border-radius: $r;
padding: var(--tk-gap-md);
box-shadow: $shadow-sm;
display: flex;
align-items: center;
gap: 14px;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.guest-article-cover {
width: 100px;
height: 80px;
.guest-article-icon {
width: 64px;
height: 64px;
border-radius: $r-sm;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.guest-article-card--pri & { background: $pri-l; }
.guest-article-card--acc & { background: $acc-l; }
.guest-article-card--wrn & { background: $wrn-l; }
}
.guest-article-icon-char {
font-size: 22px;
line-height: 1;
.guest-article-card--pri & { color: $pri; }
.guest-article-card--acc & { color: $acc; }
.guest-article-card--wrn & { color: $wrn; }
}
.guest-article-body {
padding: var(--tk-gap-sm);
flex: 1;
min-width: 0;
}
.guest-article-title {
font-size: var(--tk-font-body-sm);
font-size: 15px;
font-weight: 600;
color: $tx;
display: block;
margin-bottom: var(--tk-gap-2xs);
margin-bottom: 4px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.guest-article-summary {
font-size: var(--tk-font-cap);
color: var(--tk-text-secondary);
.guest-article-date {
font-size: var(--tk-font-micro);
color: $tx3;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.guest-empty {
padding: var(--tk-gap-2xl) 0;
/* ─── 底部注册引导卡片 ─── */
.guest-cta-card {
margin: var(--tk-gap-lg) var(--tk-page-padding);
background: $card;
border-radius: $r;
padding: var(--tk-section-gap);
box-shadow: $shadow-sm;
text-align: center;
}
.guest-empty-text {
font-size: var(--tk-font-cap);
color: var(--tk-text-secondary);
.guest-cta-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body);
font-weight: 700;
color: $tx;
display: block;
margin-bottom: 6px;
}
/* ─── 底部登录引导 ─── */
.guest-login-prompt {
margin: var(--tk-gap-lg) var(--tk-gap-lg) 0;
display: flex;
align-items: center;
gap: var(--tk-gap-md);
}
.guest-login-text {
flex: 1;
.guest-cta-desc {
font-size: var(--tk-font-cap);
color: $tx2;
display: block;
margin-bottom: var(--tk-gap-md);
line-height: 1.5;
}
.guest-login-btn {
height: var(--tk-input-height);
padding: 0 var(--tk-card-padding-lg);
.guest-cta-btn {
height: 48px;
border-radius: 24px;
background: var(--tk-pri);
border-radius: $r-pill;
@include flex-center;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--tk-shadow-btn);
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.guest-login-btn-text {
font-size: var(--tk-font-h2);
.guest-cta-btn-text {
font-size: var(--tk-font-body);
font-weight: 600;
color: $white;
}

View File

@@ -85,6 +85,15 @@ function GuestHome({ modeClass }: { modeClass: string }) {
const slides = banners.length > 0 ? banners : FALLBACK_SLIDES;
const ARTICLE_ICONS = ['♥', '◇', '✦'];
const ARTICLE_COLORS = ['pri', 'acc', 'wrn'] as const;
const formatDate = (dateStr?: string) => {
if (!dateStr) return '';
const d = new Date(dateStr);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
return (
<PageShell padding="none" safeBottom={false} scroll={false} className={`guest-page ${modeClass}`}>
<Swiper
@@ -114,59 +123,71 @@ function GuestHome({ modeClass }: { modeClass: string }) {
))}
</Swiper>
{/* 健康资讯 */}
<View className='guest-section'>
<Text className='guest-section-title'></Text>
<View className='guest-section-header'>
<Text className='guest-section-title'></Text>
<Text
className='guest-section-more'
onClick={() => Taro.switchTab({ url: '/pages/health/index' })}
>
</Text>
</View>
{articles.length > 0 ? (
<View className='guest-articles'>
{articles.map((article) => (
<ContentCard
{articles.map((article, i) => (
<View
key={article.id}
onPress={() => safeNavigateTo(`/pages/article/detail/index?id=${article.id}`)}
activeFeedback="opacity"
padding="none"
className={`guest-article-card guest-article-card--${ARTICLE_COLORS[i % 3]}`}
onClick={() => safeNavigateTo(`/pages/article/detail/index?id=${article.id}`)}
>
{article.cover_image && (
<Image className='guest-article-cover' src={article.cover_image} mode='aspectFill' lazyLoad />
)}
<View className='guest-article-icon'>
<Text className='guest-article-icon-char'>{ARTICLE_ICONS[i % 3]}</Text>
</View>
<View className='guest-article-body'>
<Text className='guest-article-title'>{article.title}</Text>
<Text className='guest-article-summary'>
{article.summary || '点击查看详情'}
</Text>
<Text className='guest-article-date'>{formatDate(article.published_at)}</Text>
</View>
</ContentCard>
</View>
))}
</View>
) : (
<View className='guest-articles'>
<ContentCard padding="none">
<View className='guest-article-card guest-article-card--pri'>
<View className='guest-article-icon'><Text className='guest-article-icon-char'></Text></View>
<View className='guest-article-body'>
<Text className='guest-article-title'></Text>
<Text className='guest-article-summary'></Text>
<Text className='guest-article-date'></Text>
</View>
</ContentCard>
<ContentCard padding="none">
</View>
<View className='guest-article-card guest-article-card--acc'>
<View className='guest-article-icon'><Text className='guest-article-icon-char'></Text></View>
<View className='guest-article-body'>
<Text className='guest-article-title'></Text>
<Text className='guest-article-summary'>线</Text>
<Text className='guest-article-title'></Text>
<Text className='guest-article-date'></Text>
</View>
</ContentCard>
<ContentCard padding="none">
</View>
<View className='guest-article-card guest-article-card--wrn'>
<View className='guest-article-icon'><Text className='guest-article-icon-char'></Text></View>
<View className='guest-article-body'>
<Text className='guest-article-title'>AI </Text>
<Text className='guest-article-summary'></Text>
<Text className='guest-article-title'></Text>
<Text className='guest-article-date'></Text>
</View>
</ContentCard>
</View>
</View>
)}
</View>
<ContentCard variant="elevated">
<Text className='guest-login-text'>使</Text>
<View className='guest-login-btn' onClick={navigateToLogin}>
<Text className='guest-login-btn-text'></Text>
{/* 底部注册引导 */}
<View className='guest-cta-card'>
<Text className='guest-cta-title'></Text>
<Text className='guest-cta-desc'>使</Text>
<View className='guest-cta-btn' onClick={navigateToLogin}>
<Text className='guest-cta-btn-text'> / </Text>
</View>
</ContentCard>
</View>
</PageShell>
);
}

View File

@@ -23,7 +23,22 @@ export default function Login() {
const navigateAfterLogin = () => {
if (isMedicalStaff()) {
Taro.reLaunch({ url: '/pages/pkg-doctor-core/index' });
// 使用 redirectTo 替代 reLaunch 避免分包加载超时
// redirectTo 只替换当前页面,不销毁整个页栈,分包预加载不会被中断
Taro.redirectTo({
url: '/pages/pkg-doctor-core/index',
fail: () => {
// fallback: 先跳首页再 redirectTo
Taro.switchTab({
url: '/pages/index/index',
success: () => {
setTimeout(() => {
Taro.navigateTo({ url: '/pages/pkg-doctor-core/index' });
}, 500);
},
});
},
});
} else {
Taro.switchTab({ url: '/pages/index/index' });
}

View File

@@ -1,40 +1,68 @@
@import '../../styles/variables.scss';
@import '../../styles/mixins.scss';
// 积分商城 — 对齐原型 docs/design/mp-05-mall.html
.mall-page {
padding-bottom: calc(var(--tk-tabbar-space) + env(safe-area-inset-bottom));
}
/* ─── 积分余额卡片 ─── */
/* ─── 积分卡片(渐变背景) ─── */
.mall-header {
background: linear-gradient(135deg, var(--tk-pri) 0%, var(--tk-pri-d) 100%);
padding: var(--tk-gap-2xl) var(--tk-gap-xl) var(--tk-gap-xl);
padding: var(--tk-gap-xl) var(--tk-page-padding) var(--tk-gap-xl);
position: relative;
overflow: hidden;
// 装饰圆
&::before {
content: '';
position: absolute;
top: -20px;
right: -20px;
width: 100px;
height: 100px;
border-radius: 50px;
background: rgba(255, 255, 255, 0.08);
}
&::after {
content: '';
position: absolute;
bottom: -30px;
right: 40px;
width: 80px;
height: 80px;
border-radius: 40px;
background: rgba(255, 255, 255, 0.05);
}
}
.points-card {
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: $r-lg;
padding: var(--tk-gap-xl);
position: relative;
z-index: 1;
}
.points-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--tk-gap-md);
margin-bottom: var(--tk-gap-sm);
}
.points-label {
font-size: var(--tk-font-h1);
color: rgba(255, 255, 255, 0.85);
font-size: var(--tk-font-cap);
color: rgba(255, 255, 255, 0.7);
letter-spacing: 1px;
}
.checkin-btn {
background: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.4);
padding: var(--tk-gap-sm) var(--tk-card-padding-lg);
padding: 6px 14px;
border-radius: $r-pill;
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s;
&:active {
@@ -48,9 +76,9 @@
}
.checkin-btn-text {
font-size: var(--tk-font-h2);
font-size: var(--tk-font-cap);
color: $white;
font-weight: 600;
font-weight: 500;
}
.checkin-btn.checked .checkin-btn-text {
@@ -59,8 +87,8 @@
.points-balance {
@include serif-number;
font-size: var(--tk-font-display);
font-weight: bold;
font-size: 42px;
font-weight: 700;
color: $white;
display: block;
margin-bottom: var(--tk-gap-xs);
@@ -69,44 +97,105 @@
}
.points-streak {
font-size: var(--tk-font-body);
font-size: var(--tk-font-cap);
color: rgba(255, 255, 255, 0.7);
display: block;
}
/* ─── 商品类型切换 ─── */
/* ─── 快捷操作 ─── */
.mall-actions {
display: flex;
justify-content: space-around;
padding: var(--tk-section-gap) var(--tk-page-padding);
}
.mall-action {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.mall-action-icon {
width: 52px;
height: 52px;
border-radius: 26px;
display: flex;
align-items: center;
justify-content: center;
&--checkin {
background: $acc;
box-shadow: 0 4px 12px rgba(91, 122, 94, 0.3);
}
&--task {
background: $pri;
box-shadow: 0 4px 12px rgba(196, 98, 58, 0.3);
}
&--history {
background: $wrn;
box-shadow: 0 4px 12px rgba(196, 135, 58, 0.3);
}
}
.mall-action-icon-text {
font-size: 22px;
color: $white;
line-height: 1;
}
.mall-action-label {
font-size: var(--tk-font-micro);
color: $tx2;
font-weight: 500;
}
/* ─── 分类标签Pill ─── */
.type-tabs {
display: flex;
padding: var(--tk-section-gap) var(--tk-page-padding) 0;
gap: 10px;
padding: 0 var(--tk-page-padding) var(--tk-section-gap);
overflow-x: auto;
&::-webkit-scrollbar {
display: none;
}
}
.type-tab {
flex: 1;
text-align: center;
padding: var(--tk-gap-md) 0;
position: relative;
min-height: 48px;
padding: 7px 18px;
border-radius: $r-pill;
font-size: var(--tk-font-body-sm);
font-weight: 400;
background: $surface-alt;
color: $tx2;
white-space: nowrap;
flex-shrink: 0;
transition: all 0.2s;
&.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 48px;
height: 4px;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
&.active {
background: var(--tk-pri);
border-radius: $r-xs;
color: $white;
font-weight: 600;
box-shadow: var(--tk-shadow-tab);
}
}
.type-tab-text {
font-size: var(--tk-font-body-lg);
color: $tx2;
font-size: inherit;
color: inherit;
font-weight: inherit;
&.active {
color: var(--tk-pri);
font-weight: 600;
color: inherit;
}
}
@@ -114,12 +203,15 @@
.product-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--tk-gap-md);
padding: var(--tk-section-gap) var(--tk-page-padding);
gap: var(--tk-gap-sm);
padding: 0 var(--tk-page-padding) var(--tk-gap-lg);
}
.product-card {
background: $card;
border-radius: $r-sm;
overflow: hidden;
box-shadow: $shadow-sm;
&:active {
opacity: var(--tk-touch-feedback-opacity);
@@ -128,19 +220,20 @@
.product-image {
width: 100%;
height: 200px;
aspect-ratio: 1;
@include flex-center;
position: relative;
&.type-physical { background: var(--tk-pri-l); }
&.type-physical { background: $pri-l; }
&.type-service { background: $acc-l; }
&.type-privilege { background: $wrn-l; }
}
.product-image-char {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-hero);
font-weight: bold;
color: var(--tk-pri);
font-size: 32px;
font-weight: 700;
color: $pri;
line-height: 1;
.type-service & { color: $acc; }
@@ -148,57 +241,83 @@
}
.product-info {
padding: var(--tk-section-gap);
padding: 10px var(--tk-gap-sm) 14px;
}
.product-name {
font-size: var(--tk-font-h1);
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx;
display: block;
margin-bottom: var(--tk-gap-sm);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
height: 40px;
margin-bottom: 8px;
}
.product-bottom {
display: flex;
justify-content: space-between;
align-items: center;
align-items: baseline;
gap: 6px;
}
.product-points {
display: flex;
align-items: center;
gap: var(--tk-gap-2xs);
align-items: baseline;
gap: 2px;
}
.product-points-char {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body);
font-weight: bold;
color: $wrn;
@include serif-number;
font-size: 18px;
font-weight: 700;
color: $pri;
}
.product-points-value {
@include serif-number;
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $wrn;
font-size: var(--tk-font-micro);
color: $pri;
font-weight: 500;
}
.product-price {
font-size: var(--tk-font-micro);
color: $tx3;
text-decoration: line-through;
}
.product-stock {
font-size: var(--tk-font-body);
padding: 2px var(--tk-gap-sm);
border-radius: $r-sm;
font-size: var(--tk-font-micro);
padding: 2px 6px;
border-radius: $r-xs;
&.out {
@include tag($bd-l, $tx3);
background: $bd-l;
color: $tx3;
}
&.low {
@include tag($dan-l, $dan);
background: $dan-l;
color: $dan;
}
}
.product-tag {
position: absolute;
top: 8px;
left: 8px;
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 6px;
color: $white;
&--hot {
background: $dan;
}
&--new {
background: $acc;
}
}

View File

@@ -12,14 +12,13 @@ import ErrorState from '../../components/ErrorState';
import EmptyState from '../../components/EmptyState';
import { useElderClass } from '../../hooks/useElderClass';
import PageShell from '@/components/ui/PageShell';
import ContentCard from '@/components/ui/ContentCard';
import './index.scss';
const PRODUCT_TYPE_TABS = [
{ key: '', label: '全部' },
{ key: 'physical', label: '实物', char: '物' },
{ key: 'service', label: '服务券', char: '券' },
{ key: 'privilege', label: '权益', char: '权' },
{ key: 'physical', label: '实物' },
{ key: 'service', label: '服务券' },
{ key: 'privilege', label: '权益' },
];
const TYPE_BG: Record<string, string> = {
@@ -77,7 +76,6 @@ export default function Mall() {
async (type?: string) => {
const t = type !== undefined ? type : productType;
if (!currentPatient) {
// 先尝试从服务端加载患者列表
await loadPatients();
const updated = useAuthStore.getState().currentPatient;
if (!updated) {
@@ -133,7 +131,7 @@ export default function Mall() {
Taro.showToast({ title: '已兑完', icon: 'none' });
return;
}
safeNavigateTo(`/pages/pkg-mall/exchange/index?product_id=${item.id}`);
safeNavigateTo(`/pages/pkg-mall/product/index?product_id=${item.id}`);
};
const balance = account?.balance ?? 0;
@@ -158,7 +156,7 @@ export default function Mall() {
<View className='mall-header'>
<View className='points-card'>
<View className='points-top'>
<Text className='points-label'></Text>
<Text className='points-label'></Text>
<View
className={`checkin-btn ${checkinStatus?.checked_in_today ? 'checked' : ''}`}
onClick={handleCheckin}
@@ -177,6 +175,28 @@ export default function Mall() {
</View>
</View>
{/* 快捷操作 */}
<View className='mall-actions'>
<View className='mall-action' onClick={handleCheckin}>
<View className='mall-action-icon mall-action-icon--checkin'>
<Text className='mall-action-icon-text'></Text>
</View>
<Text className='mall-action-label'></Text>
</View>
<View className='mall-action'>
<View className='mall-action-icon mall-action-icon--task'>
<Text className='mall-action-icon-text'></Text>
</View>
<Text className='mall-action-label'></Text>
</View>
<View className='mall-action' onClick={() => safeNavigateTo('/pages/pkg-mall/orders/index')}>
<View className='mall-action-icon mall-action-icon--history'>
<Text className='mall-action-icon-text'></Text>
</View>
<Text className='mall-action-label'></Text>
</View>
</View>
{/* 商品类型切换 */}
<View className='type-tabs'>
{PRODUCT_TYPE_TABS.map((tab) => (
@@ -200,7 +220,11 @@ export default function Mall() {
) : (
<View className='product-grid'>
{products.map((item) => (
<ContentCard key={item.id} onPress={() => handleProductClick(item)} activeFeedback="opacity" padding="none">
<View
key={item.id}
className='product-card'
onClick={() => handleProductClick(item)}
>
<View className={`product-image ${TYPE_BG[item.product_type] || ''}`}>
<Text className='product-image-char'>
{item.product_type === 'physical' ? '物' : item.product_type === 'service' ? '券' : '权'}
@@ -210,8 +234,8 @@ export default function Mall() {
<Text className='product-name'>{item.name}</Text>
<View className='product-bottom'>
<View className='product-points'>
<Text className='product-points-char'>P</Text>
<Text className='product-points-value'>{item.points_cost}</Text>
<Text className='product-points-char'>{item.points_cost}</Text>
<Text className='product-points-value'></Text>
</View>
{item.stock <= 0 ? (
<Text className='product-stock out'></Text>
@@ -220,7 +244,7 @@ export default function Mall() {
) : null}
</View>
</View>
</ContentCard>
</View>
))}
{loading && <Loading />}
{!loading && products.length >= total && total > 0 && (

View File

@@ -1,64 +1,102 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
// PageShell 已接管min-height, background
// ContentCard 已接管inbox-card 背景/圆角/阴影/触摸反馈
.inbox-page {
height: 100vh;
background: $bg;
display: flex;
flex-direction: column;
.inbox-list {
height: calc(100vh - 50px);
padding: var(--tk-gap-sm);
&__filter {
padding: 12px 20px;
}
&__list {
flex: 1;
padding: 0 20px 20px;
}
}
.inbox-card {
margin-bottom: var(--tk-gap-sm);
margin-bottom: 10px !important;
.inbox-card-header {
&__row {
display: flex;
align-items: center;
gap: var(--tk-gap-xs);
margin-bottom: var(--tk-gap-2xs);
gap: 12px;
}
.inbox-type-tag {
color: $card;
font-size: var(--tk-font-micro);
padding: 2px var(--tk-gap-2xs);
border-radius: $r-xs;
flex-shrink: 0;
&--ai {
background: var(--tk-pri);
}
&--alert {
background: $dan;
}
&--followup {
background: $acc;
}
&--anomaly {
background: $wrn;
}
&--default {
background: $tx3;
}
&__body {
flex: 1;
min-width: 0;
}
.inbox-card-title {
font-size: var(--tk-font-cap);
font-weight: 500;
&__name-row {
display: flex;
align-items: center;
gap: 6px;
}
&__patient {
font-size: 15px;
font-weight: 600;
color: $tx;
}
.inbox-card-desc {
font-size: var(--tk-font-micro);
&__urgent {
display: inline-block;
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: $card;
background: $dan;
line-height: 1.6;
}
&__desc {
font-size: 13px;
color: $tx2;
margin-top: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
&__time {
font-size: 11px;
color: $tx3;
flex-shrink: 0;
text-align: right;
min-width: 48px;
}
&__arrow {
flex-shrink: 0;
font-size: 20px;
color: $tx3;
}
}
// ── 类型标签对齐原型color/bg 配对,非白字实底)──
.inbox-type-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 1.6;
flex-shrink: 0;
&--pri { color: $doc-pri; background: $doc-pri-l; }
&--dan { color: $dan; background: $dan-l; }
&--acc { color: $acc; background: $acc-l; }
&--wrn { color: $wrn; background: $wrn-l; }
&--default { color: $tx3; background: $surface-alt; }
}
// ── 半屏详情弹窗 ──
.half-screen-dialog {
position: fixed;
bottom: 0;
@@ -75,37 +113,27 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--tk-gap-md) var(--tk-section-gap);
padding: 16px 20px;
border-bottom: 1px solid $bd-l;
.dialog-title {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx;
}
.dialog-close {
font-size: var(--tk-font-cap);
color: $tx3;
}
.dialog-title { font-size: 14px; font-weight: 600; color: $tx; }
.dialog-close { font-size: 13px; color: $tx3; }
}
.dialog-body {
padding: var(--tk-gap-md) var(--tk-section-gap);
}
.dialog-body { padding: 16px 20px; }
.dialog-patient {
font-size: var(--tk-font-cap);
font-size: 13px;
color: $tx2;
display: block;
margin-bottom: var(--tk-gap-sm);
margin-bottom: 12px;
}
.thread-item {
display: flex;
align-items: flex-start;
gap: var(--tk-gap-sm);
padding: var(--tk-gap-2xs) 0;
gap: 12px;
padding: 4px 0;
}
.thread-dot {
@@ -122,30 +150,22 @@
}
.thread-content {
.thread-label {
font-size: var(--tk-font-cap);
color: $tx;
display: block;
}
.thread-time {
font-size: var(--tk-font-micro);
color: $tx3;
}
.thread-label { font-size: 13px; color: $tx; display: block; }
.thread-time { font-size: 11px; color: $tx3; }
}
.dialog-actions {
display: flex;
gap: var(--tk-gap-xs);
padding: var(--tk-gap-sm) var(--tk-section-gap) var(--tk-section-gap);
gap: 8px;
padding: 12px 20px 20px;
border-top: 1px solid $bd-l;
.action-btn {
flex: 1;
text-align: center;
padding: var(--tk-gap-sm);
padding: 12px;
border-radius: $r-sm;
font-size: var(--tk-font-cap);
font-size: 13px;
font-weight: 500;
&.primary { background: var(--tk-pri); color: $card; }

View File

@@ -2,7 +2,6 @@ import React, { useState, useCallback, useRef } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { api } from '@/services/request';
import {
listActionItems,
getActionThread,
@@ -12,47 +11,54 @@ import {
import Loading from '@/components/Loading';
import ErrorState from '@/components/ErrorState';
import EmptyState from '@/components/EmptyState';
import SegmentTabs from '@/components/SegmentTabs';
import PageShell from '@/components/ui/PageShell';
import PageHeader from '@/components/patterns/PageHeader';
import TabFilter from '@/components/ui/TabFilter';
import ContentCard from '@/components/ui/ContentCard';
import { useDoctorClass } from '@/hooks/useDoctorClass';
import './index.scss';
const TYPE_LABEL: Record<string, string> = {
ai_suggestion: 'AI建议',
alert: '告警',
followup: '随访',
data_anomaly: '异常',
const TYPE_CONFIG: Record<string, { label: string; colorCls: string }> = {
data_anomaly: { label: '异常', colorCls: 'inbox-type-tag--dan' },
followup: { label: '随访', colorCls: 'inbox-type-tag--acc' },
ai_suggestion: { label: '咨询', colorCls: 'inbox-type-tag--pri' },
alert: { label: '告警', colorCls: 'inbox-type-tag--dan' },
};
const TYPE_CLS: Record<string, string> = {
ai_suggestion: 'inbox-type-tag--ai',
alert: 'inbox-type-tag--alert',
followup: 'inbox-type-tag--followup',
data_anomaly: 'inbox-type-tag--anomaly',
const FILTER_TABS = ['全部', '异常', '随访', '咨询'];
const FILTER_MAP: Record<number, string | undefined> = {
0: undefined,
1: 'data_anomaly',
2: 'followup',
3: 'ai_suggestion',
};
const STATUS_TABS = [
{ key: '', label: '全部' },
{ key: 'pending', label: '待处理' },
{ key: 'in_progress', label: '进行中' },
{ key: 'completed', label: '已完成' },
];
function formatTimeAgo(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const diff = now - then;
const minutes = Math.floor(diff / 60000);
if (minutes < 60) return `${minutes}分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}小时前`;
const days = Math.floor(hours / 24);
if (days === 1) return '昨天';
if (days < 7) return `${days}天前`;
return new Date(dateStr).toLocaleDateString('zh-CN');
}
export default function ActionInboxPage() {
const modeClass = useDoctorClass();
const [items, setItems] = useState<ActionItem[]>([]);
const [total, setTotal] = useState(0);
const [_page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [activeTab, setActiveTab] = useState('');
const [activeFilter, setActiveFilter] = useState(0);
const [threadData, setThreadData] = useState<ThreadResponse | null>(null);
const [showDetail, setShowDetail] = useState(false);
const loadingRef = useRef(false);
const fetchItems = useCallback(
async (pageNum: number, status: string, isRefresh = false) => {
async (pageNum: number, typeFilter: string | undefined, isRefresh = false) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
@@ -61,7 +67,7 @@ export default function ActionInboxPage() {
const resp = await listActionItems({
page: pageNum,
page_size: 20,
status: status || undefined,
type: typeFilter,
});
const list = resp.data || [];
if (isRefresh) {
@@ -69,8 +75,6 @@ export default function ActionInboxPage() {
} else {
setItems((prev) => [...prev, ...list]);
}
setTotal(resp.total);
setPage(pageNum);
} catch {
setError(true);
Taro.showToast({ title: '加载失败', icon: 'none' });
@@ -84,15 +88,14 @@ export default function ActionInboxPage() {
usePageData(
useCallback(async () => {
Taro.setNavigationBarTitle({ title: '待办事项' });
await fetchItems(1, activeTab, true);
}, [fetchItems, activeTab]),
await fetchItems(1, FILTER_MAP[activeFilter], true);
}, [fetchItems, activeFilter]),
{ throttleMs: 10000, enablePullDown: true },
);
const handleTabChange = (key: string) => {
setActiveTab(key);
fetchItems(1, key, true);
const handleFilterChange = (index: number) => {
setActiveFilter(index);
fetchItems(1, FILTER_MAP[index], true);
};
const handleItemClick = async (item: ActionItem) => {
@@ -111,90 +114,98 @@ export default function ActionInboxPage() {
}) => {
if (!action.api_endpoint || !threadData) return;
try {
const { api } = await import('@/services/request');
await api.post(action.api_endpoint, { action: action.key });
Taro.showToast({ title: '操作成功', icon: 'success' });
setShowDetail(false);
fetchItems(1, activeTab, true);
fetchItems(1, FILTER_MAP[activeFilter], true);
} catch {
Taro.showToast({ title: '操作失败', icon: 'none' });
}
};
const getTypeConfig = (type: string) =>
TYPE_CONFIG[type] || { label: '未知', colorCls: 'inbox-type-tag--default' };
return (
<PageShell padding="none" className={modeClass}>
<SegmentTabs tabs={STATUS_TABS} activeKey={activeTab} onChange={handleTabChange} variant="underline" />
<View className={`inbox-page ${modeClass}`}>
<PageHeader title="待办事项" showBack />
<View className="inbox-page__filter">
<TabFilter
tabs={FILTER_TABS}
activeIndex={activeFilter}
onChange={handleFilterChange}
variant="pill"
/>
</View>
{error ? (
<ErrorState onRetry={() => fetchItems(1, activeTab, true)} />
<ErrorState onRetry={() => fetchItems(1, FILTER_MAP[activeFilter], true)} />
) : items.length === 0 && !loading ? (
<EmptyState text='暂无待办事项' />
<EmptyState text="暂无待办事项" />
) : (
<ScrollView scrollY className="inbox-list">
{items.map((item) => (
<ContentCard
key={item.id}
className="inbox-card"
activeFeedback="opacity"
onPress={() => handleItemClick(item)}
>
<View className="inbox-card-header">
<Text
className={`inbox-type-tag ${TYPE_CLS[item.action_type] || 'inbox-type-tag--default'}`}
>
{TYPE_LABEL[item.action_type] || '未知'}
</Text>
<Text className="inbox-card-title">{item.title}</Text>
</View>
<Text className="inbox-card-desc">
{item.patient_name} ·{' '}
{new Date(item.created_at).toLocaleDateString('zh-CN')}
</Text>
</ContentCard>
))}
<ScrollView scrollY className="inbox-page__list">
{items.map((item) => {
const cfg = getTypeConfig(item.action_type);
const isUrgent = item.priority === 'urgent' || item.priority === 'high';
return (
<ContentCard
key={item.id}
className="inbox-card"
activeFeedback="opacity"
onPress={() => handleItemClick(item)}
>
<View className="inbox-card__row">
{/* 类型标签 — 原型color/bg 配对 */}
<Text className={`inbox-type-tag ${cfg.colorCls}`}>
{cfg.label}
</Text>
{/* 内容区 */}
<View className="inbox-card__body">
<View className="inbox-card__name-row">
<Text className="inbox-card__patient">{item.patient_name || '未知患者'}</Text>
{isUrgent && <Text className="inbox-card__urgent"></Text>}
</View>
<Text className="inbox-card__desc">{item.title}</Text>
</View>
{/* 时间 */}
<Text className="inbox-card__time">{formatTimeAgo(item.created_at)}</Text>
{/* 箭头 */}
<Text className="inbox-card__arrow"></Text>
</View>
</ContentCard>
);
})}
{loading && <Loading />}
{!loading && items.length >= total && total > 0 && (
<Loading text="没有更多了" />
)}
</ScrollView>
)}
{showDetail && threadData && (
<View className="half-screen-dialog">
<View className="dialog-header">
<Text className="dialog-title">
{threadData.action_item.title}
</Text>
<Text
className="dialog-close"
onClick={() => setShowDetail(false)}
>
</Text>
<Text className="dialog-title">{threadData.action_item.title}</Text>
<Text className="dialog-close" onClick={() => setShowDetail(false)}></Text>
</View>
<View className="dialog-body">
<Text className="dialog-patient">
{threadData.action_item.patient_name} ·{' '}
{threadData.action_item.priority === 'urgent'
? '紧急'
: threadData.action_item.priority === 'high'
? '高'
: '中'}
{threadData.action_item.priority === 'urgent' ? '紧急'
: threadData.action_item.priority === 'high' ? '高' : '中'}
</Text>
<View className="thread-timeline">
{threadData.thread.map((evt, idx) => (
<View key={idx} className="thread-item">
<View className={`thread-dot ${evt.status}`} />
<View className="thread-content">
<Text className="thread-label">{evt.label}</Text>
{evt.timestamp && (
<Text className="thread-time">
{new Date(evt.timestamp).toLocaleDateString('zh-CN')}
</Text>
)}
</View>
{threadData.thread.map((evt, idx) => (
<View key={idx} className="thread-item">
<View className={`thread-dot ${evt.status}`} />
<View className="thread-content">
<Text className="thread-label">{evt.label}</Text>
{evt.timestamp && (
<Text className="thread-time">
{new Date(evt.timestamp).toLocaleDateString('zh-CN')}
</Text>
)}
</View>
))}
</View>
</View>
))}
</View>
{threadData.available_actions.length > 0 && (
<View className="dialog-actions">
@@ -211,6 +222,6 @@ export default function ActionInboxPage() {
)}
</View>
)}
</PageShell>
</View>
);
}

View File

@@ -1,76 +1,88 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
// PageShell 已接管min-height, background, padding
// SearchSection 已接管:标签筛选栏
// ContentCard 已接管session-card 背景/圆角/阴影/触摸反馈
// StatusTag 已接管:会话状态标签
// PaginationBar 已接管:分页控件
.session-list {
.consult-page {
height: 100vh;
background: $bg;
display: flex;
flex-direction: column;
gap: var(--tk-gap-md);
&__tabs {
padding: 12px 20px 0;
}
&__list {
flex: 1;
padding: 12px 20px 20px;
}
}
.session-card__top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--tk-gap-sm);
}
.consult-card {
margin-bottom: 10px !important;
.session-card__subject {
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: var(--tk-gap-md);
}
&__row {
display: flex;
align-items: center;
gap: 12px;
}
.session-card__info {
display: flex;
align-items: center;
gap: var(--tk-gap-md);
margin-bottom: var(--tk-gap-xs);
}
&__body {
flex: 1;
min-width: 0;
}
.session-card__type {
@include tag(var(--tk-pri-l), var(--tk-pri));
}
&__top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.session-card__time {
font-size: var(--tk-font-h2);
color: $tx3;
}
&__name {
font-size: 15px;
font-weight: 600;
color: $tx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-card__preview {
font-size: var(--tk-font-h1);
color: $tx2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
&__time {
font-size: 12px;
color: $tx3;
flex-shrink: 0;
margin-left: 8px;
}
.session-card__badge {
position: absolute;
top: var(--tk-section-gap);
right: var(--tk-section-gap);
min-width: 36px;
height: 36px;
background: $dan;
border-radius: $r-pill;
@include flex-center;
padding: 0 8px;
}
&__msg {
font-size: 13px;
color: $tx2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
margin-top: 4px;
}
.session-card__badge-text {
@include serif-number;
font-size: var(--tk-font-body);
color: $card;
font-weight: 600;
&__badge {
min-width: 20px;
height: 20px;
background: $dan;
border-radius: $r-pill;
@include flex-center;
padding: 0 6px;
flex-shrink: 0;
}
&__badge-text {
font-size: 11px;
color: $card;
font-weight: 700;
}
&__arrow {
flex-shrink: 0;
font-size: 20px;
color: $tx3;
}
}

View File

@@ -1,41 +1,33 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { View, Text } from '@tarojs/components';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { listSessions, type ConsultationSession } from '@/services/doctor/consultation';
import PageShell from '@/components/ui/PageShell';
import PageHeader from '@/components/patterns/PageHeader';
import SegmentTabs from '@/components/SegmentTabs';
import ContentCard from '@/components/ui/ContentCard';
import StatusTag from '@/components/ui/StatusTag';
import AvatarCircle from '@/components/ui/AvatarCircle';
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 { useDoctorClass } from '@/hooks/useDoctorClass';
import { formatDateTime } from '@/utils/date';
import { safeNavigateTo } from '@/utils/navigate';
import { formatDateTime } from '@/utils/date';
import './index.scss';
const TABS = [
{ key: '', label: '全部' },
const STATUS_TABS = [
{ key: 'active', label: '进行中' },
{ key: 'waiting', label: '等待中' },
{ key: 'closed', label: '已关闭' },
{ key: 'closed', label: '已结束' },
];
const STATUS_COLOR_MAP: Record<string, 'success' | 'warning' | 'default' | 'info'> = {
active: 'success',
waiting: 'warning',
closed: 'default',
};
const AVATAR_COLORS: Array<'pri' | 'acc' | 'wrn' | 'dan'> = ['pri', 'acc', 'wrn', 'dan'];
export default function ConsultationList() {
const modeClass = useDoctorClass();
const [sessions, setSessions] = useState<ConsultationSession[]>([]);
const [activeTab, setActiveTab] = useState('');
const [activeTab, setActiveTab] = useState('active');
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const mountedRef = useRef(false);
@@ -49,7 +41,6 @@ export default function ConsultationList() {
status: activeTab || undefined,
});
setSessions(res.data || []);
setTotal(res.total || 0);
} catch {
setError(true);
Taro.showToast({ title: '加载失败', icon: 'none' });
@@ -72,6 +63,8 @@ export default function ConsultationList() {
setPage(1);
};
const getAvatarColor = (index: number) => AVATAR_COLORS[index % AVATAR_COLORS.length];
const formatTime = (dateStr?: string | null) => {
if (!dateStr) return '';
return formatDateTime(dateStr);
@@ -81,57 +74,57 @@ export default function ConsultationList() {
if (error) return <ErrorState onRetry={loadSessions} />;
return (
<PageShell safeBottom className={modeClass}>
<SearchSection
value=""
onChange={() => {}}
filters={TABS}
activeFilter={activeTab}
onFilterChange={handleTabChange}
/>
<View className={`consult-page ${modeClass}`}>
<PageHeader title="在线咨询" showBack />
<View className="consult-page__tabs">
<SegmentTabs
tabs={STATUS_TABS}
activeKey={activeTab}
onChange={handleTabChange}
variant="underline"
/>
</View>
{sessions.length === 0 ? (
<EmptyState text="暂无咨询会话" />
) : (
<View className="session-list">
{sessions.map((s) => (
<ScrollView scrollY className="consult-page__list">
{sessions.map((s, idx) => (
<ContentCard
key={s.id}
className="consult-card"
activeFeedback="opacity"
onPress={() => safeNavigateTo(`/pages/pkg-doctor-core/consultation/detail/index?id=${s.id}`)}
>
<View className="session-card__top">
<Text className="session-card__subject">{s.subject || '在线咨询'}</Text>
<StatusTag
status={s.status}
colorMap={STATUS_COLOR_MAP}
size="sm"
<View className="consult-card__row">
<AvatarCircle
name={s.patient_name || '未'}
size={44}
color={getAvatarColor(idx)}
/>
</View>
<View className="session-card__info">
<Text className="session-card__type">
{s.consultation_type === 'text' ? '图文' : s.consultation_type === 'video' ? '视频' : '咨询'}
</Text>
<Text className="session-card__time">{formatTime(s.last_message_at)}</Text>
</View>
{s.last_message && (
<Text className="session-card__preview">{s.last_message}</Text>
)}
{(s.unread_count_doctor ?? 0) > 0 && (
<View className="session-card__badge">
<Text className="session-card__badge-text">{s.unread_count_doctor}</Text>
<View className="consult-card__body">
<View className="consult-card__top">
<Text className="consult-card__name">{s.patient_name || '未知患者'}</Text>
<Text className="consult-card__time">{formatTime(s.last_message_at)}</Text>
</View>
<Text className="consult-card__msg">
{s.last_message || (s.consultation_type === 'text' ? '图文咨询' : '视频咨询')}
</Text>
</View>
)}
{(s.unread_count_doctor ?? 0) > 0 && (
<View className="consult-card__badge">
<Text className="consult-card__badge-text">{s.unread_count_doctor}</Text>
</View>
)}
{!((s.unread_count_doctor ?? 0) > 0) && (
<Text className="consult-card__arrow"></Text>
)}
</View>
</ContentCard>
))}
</View>
</ScrollView>
)}
<PaginationBar
current={page}
total={total}
pageSize={20}
onChange={setPage}
/>
</PageShell>
</View>
);
}

View File

@@ -1,51 +1,90 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
// PageShell 已接管min-height, background, padding
// SearchSection 已接管:标签筛选栏
// ContentCard 已接管task-card 背景/圆角/阴影/触摸反馈
// StatusTag 已接管:任务状态标签
.followup-page {
height: 100vh;
background: $bg;
display: flex;
flex-direction: column;
.task-count {
margin-bottom: var(--tk-gap-md);
&__filter {
padding: 12px 20px;
}
text {
font-size: var(--tk-font-h2);
color: $tx3;
&__list {
flex: 1;
padding: 0 20px 20px;
}
}
.task-list {
display: flex;
flex-direction: column;
gap: var(--tk-gap-md);
}
.followup-card {
margin-bottom: 10px !important;
.task-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--tk-gap-sm);
}
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.task-card__type {
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
}
&__top-left {
flex: 1;
min-width: 0;
}
.task-card__patient {
font-size: var(--tk-font-h1);
color: $tx2;
display: block;
margin-bottom: var(--tk-gap-xs);
}
&__name-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.task-card__footer {
display: flex;
justify-content: space-between;
}
&__patient {
font-size: 16px;
font-weight: 600;
color: $tx;
}
.task-card__date {
font-size: var(--tk-font-h2);
color: $tx3;
&__status-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 1.6;
}
&__type {
font-size: 13px;
color: $tx3;
display: block;
}
&__date {
font-size: 13px;
color: $tx2;
font-weight: 500;
flex-shrink: 0;
margin-left: 12px;
}
&__data-row {
display: flex;
align-items: center;
justify-content: space-between;
background: $bg;
border-radius: $r-sm;
padding: 10px 12px;
}
&__data-label {
font-size: 12px;
color: $tx3;
}
&__data-value {
font-size: 14px;
font-weight: 600;
color: $tx;
}
}

View File

@@ -1,43 +1,40 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { safeNavigateTo } from '@/utils/navigate';
import { usePageData } from '@/hooks/usePageData';
import { listFollowUpTasks, type FollowUpTask } from '@/services/doctor/followup';
import PageShell from '@/components/ui/PageShell';
import PageHeader from '@/components/patterns/PageHeader';
import TabFilter from '@/components/ui/TabFilter';
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 { useDoctorClass } from '@/hooks/useDoctorClass';
import './index.scss';
const TABS = [
{ key: '', label: '全部' },
{ key: 'pending', label: '待处理' },
{ key: 'in_progress', label: '进行中' },
{ key: 'completed', label: '已完成' },
{ key: 'overdue', label: '已逾期' },
];
const FILTER_TABS = ['待随访', '已完成', '已过期'];
const STATUS_COLOR_MAP: Record<string, 'warning' | 'info' | 'success' | 'error'> = {
pending: 'warning',
in_progress: 'info',
completed: 'success',
overdue: 'error',
const FILTER_MAP: Record<number, string | undefined> = {
0: 'pending',
1: 'completed',
2: 'overdue',
};
const STATUS_STYLE: Record<string, { color: string; bg: string }> = {
pending: { color: '#3A6B8C', bg: '#D4E5F0' },
in_progress: { color: '#3A6B8C', bg: '#D4E5F0' },
completed: { color: '#5B7A5E', bg: '#E8F0E8' },
overdue: { color: '#B54A4A', bg: '#FDEAEA' },
};
export default function FollowUpList() {
const router = useRouter();
const patientId = router.params.patientId || '';
const modeClass = useDoctorClass();
const [tasks, setTasks] = useState<FollowUpTask[]>([]);
const [activeTab, setActiveTab] = useState('');
const [activeFilter, setActiveFilter] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [total, setTotal] = useState(0);
const [, setTotal] = useState(0);
const mountedRef = useRef(false);
const loadTasks = useCallback(async () => {
@@ -47,8 +44,7 @@ export default function FollowUpList() {
const res = await listFollowUpTasks({
page: 1,
page_size: 50,
status: activeTab || undefined,
patient_id: patientId || undefined,
status: FILTER_MAP[activeFilter],
});
setTasks(res.data || []);
setTotal(res.total || 0);
@@ -58,7 +54,7 @@ export default function FollowUpList() {
} finally {
setLoading(false);
}
}, [activeTab, patientId]);
}, [activeFilter]);
const { trigger } = usePageData(loadTasks);
@@ -67,10 +63,15 @@ export default function FollowUpList() {
trigger();
}
mountedRef.current = true;
}, [activeTab, patientId, trigger]);
}, [activeFilter, trigger]);
const handleFilterChange = (index: number) => {
setActiveFilter(index);
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' });
const d = new Date(dateStr);
return `${d.getMonth() + 1}${d.getDate()}`;
};
const getTypeLabel = (type: string) => {
@@ -83,48 +84,72 @@ export default function FollowUpList() {
return map[type] || type;
};
const getStatusLabel = (status: string) => {
const map: Record<string, string> = {
pending: '待随访',
in_progress: '进行中',
completed: '已完成',
overdue: '已过期',
};
return map[status] || status;
};
if (loading && tasks.length === 0) return <LoadingCard count={3} />;
if (error) return <ErrorState onRetry={loadTasks} />;
return (
<PageShell safeBottom className={modeClass}>
<SearchSection
value=""
onChange={() => {}}
filters={TABS}
activeFilter={activeTab}
onFilterChange={(key) => setActiveTab(key)}
/>
<View className={`followup-page ${modeClass}`}>
<PageHeader title="随访管理" showBack />
<View className="task-count">
<Text> {total} </Text>
<View className="followup-page__filter">
<TabFilter
tabs={FILTER_TABS}
activeIndex={activeFilter}
onChange={handleFilterChange}
variant="pill"
/>
</View>
{tasks.length === 0 ? (
<EmptyState text="暂无随访任务" />
) : (
<View className="task-list">
{tasks.map((task) => (
<ContentCard
key={task.id}
onPress={() => safeNavigateTo(`/pages/pkg-doctor-core/followup/detail/index?id=${task.id}`)}
>
<View className="task-card__header">
<Text className="task-card__type">{getTypeLabel(task.follow_up_type)}</Text>
<StatusTag
status={task.status}
colorMap={STATUS_COLOR_MAP}
size="sm"
/>
</View>
<Text className="task-card__patient">{task.patient_name || '未知患者'}</Text>
<View className="task-card__footer">
<Text className="task-card__date">: {formatDate(task.planned_date)}</Text>
</View>
</ContentCard>
))}
</View>
<ScrollView scrollY className="followup-page__list">
{tasks.map((task) => {
const statusStyle = STATUS_STYLE[task.status] || STATUS_STYLE.pending;
return (
<ContentCard
key={task.id}
className="followup-card"
activeFeedback="opacity"
onPress={() => safeNavigateTo(`/pages/pkg-doctor-core/followup/detail/index?id=${task.id}`)}
>
{/* 上部:患者名 + 标签 | 日期 */}
<View className="followup-card__header">
<View className="followup-card__top-left">
<View className="followup-card__name-row">
<Text className="followup-card__patient">{task.patient_name || '未知患者'}</Text>
<Text
className="followup-card__status-tag"
style={{ color: statusStyle.color, background: statusStyle.bg }}
>
{getStatusLabel(task.status)}
</Text>
</View>
<Text className="followup-card__type">{getTypeLabel(task.follow_up_type)}</Text>
</View>
<Text className="followup-card__date">{formatDate(task.planned_date)}</Text>
</View>
{/* 下部:最近数据行(对齐原型 bg 底色行) */}
<View className="followup-card__data-row">
<Text className="followup-card__data-label"></Text>
<Text className="followup-card__data-value">{formatDate(task.planned_date)}</Text>
</View>
</ContentCard>
);
})}
</ScrollView>
)}
</PageShell>
</View>
);
}

View File

@@ -1,204 +1,86 @@
@import '../../styles/variables.scss';
@import '../../styles/mixins.scss';
// PageShell 已接管min-height, background, padding
.doctor-home {
height: 100vh;
background: $bg;
display: flex;
flex-direction: column;
&__scroll {
flex: 1;
}
&__content {
padding: 16px 20px 80px;
}
// ── 头部(对齐原型)──
&__header {
margin-bottom: var(--tk-gap-2xl);
margin-bottom: 16px;
}
&__title {
@include section-title;
margin-bottom: var(--tk-gap-sm);
}
&__greeting {
font-size: var(--tk-font-h2);
color: $tx2;
display: block;
margin-bottom: var(--tk-gap-xs);
}
&__date {
font-size: var(--tk-font-h2);
color: $tx3;
}
&__alert {
display: flex;
align-items: center;
margin: var(--tk-gap-md) var(--tk-page-padding);
padding: var(--tk-gap-md) var(--tk-section-gap);
background: $dan-l;
border-radius: $r;
border-left: 4px solid $dan;
}
&__alert-icon {
width: 36px;
height: 36px;
border-radius: 50%;
background: $dan;
color: $white;
text-align: center;
line-height: 36px;
font-weight: bold;
font-size: var(--tk-font-body);
margin-right: var(--tk-gap-sm);
flex-shrink: 0;
}
&__alert-text {
flex: 1;
font-size: var(--tk-font-h1);
color: $dan;
}
&__alert-link {
font-size: var(--tk-font-h2);
color: $dan;
flex-shrink: 0;
}
&__search {
margin: 0 var(--tk-page-padding) var(--tk-gap-md);
}
&__search-input {
background: $surface-alt;
border-radius: $r;
padding: var(--tk-gap-md) var(--tk-section-gap);
font-size: var(--tk-font-h1);
color: $tx3;
}
&__section {
margin-bottom: var(--tk-gap-2xl);
}
&__section-title {
@include section-title;
}
&__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--tk-section-gap);
}
&__card {
background: $card;
border-radius: $r-lg;
padding: var(--tk-card-padding-lg) var(--tk-card-padding);
text-align: center;
box-shadow: $shadow-md;
transition: transform 0.15s;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
&__card-initial {
display: inline-flex;
@include flex-center;
width: 56px;
height: 56px;
border-radius: $r;
background: var(--tk-pri-l);
color: var(--tk-pri);
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body-lg);
font-weight: 700;
margin-bottom: var(--tk-gap-xs);
}
&__card-num {
@include serif-number;
font-size: var(--tk-font-hero);
font-family: Georgia, 'Times New Roman', serif;
font-size: 26px;
font-weight: 700;
color: $tx;
display: block;
margin-bottom: var(--tk-gap-xs);
margin-bottom: 4px;
}
&__card-label {
font-size: var(--tk-font-h2);
&__date {
font-size: 14px;
color: $tx3;
}
// ── 小节标题对齐原型13px fontWeight600──
&__section-label {
display: block;
font-size: 13px;
font-weight: 600;
color: $tx2;
margin-bottom: 14px;
font-family: -apple-system, 'PingFang SC', sans-serif;
}
&__quick-actions {
// ── 今日概览统计网格(对齐原型:子卡片有 bg 背景)──
&__stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--tk-section-gap);
grid-template-columns: 1fr 1fr;
gap: 12px;
}
&__footer {
margin-top: 60px;
&__stat-item {
background: $bg;
border-radius: $r-sm;
padding: 14px 12px;
text-align: center;
padding-bottom: env(safe-area-inset-bottom);
}
&__logout {
color: $dan;
font-size: var(--tk-font-h2);
padding: var(--tk-gap-md) var(--tk-gap-2xl);
display: inline-block;
}
}
.quick-action {
flex: 1;
background: $card;
border-radius: $r-lg;
padding: var(--tk-card-padding-lg) var(--tk-section-gap);
text-align: center;
box-shadow: $shadow-md;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
&__initial {
display: inline-flex;
@include flex-center;
width: 56px;
height: 56px;
border-radius: $r;
background: $acc-l;
color: $acc;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body-lg);
&__stat-value {
font-family: Georgia, 'Times New Roman', serif;
font-size: 28px;
font-weight: 700;
line-height: 1.1;
&--wrn { color: $wrn; }
&--pri { color: $doc-pri; }
&--acc { color: $acc; }
&--dan { color: $dan; }
}
&__icon-wrap {
position: relative;
display: inline-flex;
margin-bottom: var(--tk-gap-xs);
}
&__badge {
position: absolute;
top: -6px;
right: -12px;
min-width: 32px;
height: 32px;
line-height: 32px;
text-align: center;
background: $dan;
color: $white;
font-size: var(--tk-font-body-sm);
font-weight: 700;
border-radius: $r-pill;
padding: 0 6px;
}
&__label {
font-size: var(--tk-font-h2);
color: $tx2;
&__stat-label {
font-size: 12px;
color: $tx3;
margin-top: 4px;
display: block;
}
// ── 快捷操作对齐原型space-between──
&__shortcuts {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
}
}

View File

@@ -1,67 +1,48 @@
import { useState, useMemo, useCallback } from 'react';
import { View, Text, Input } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { View, Text, ScrollView } from '@tarojs/components';
import { safeNavigateTo } from '@/utils/navigate';
import { useAuthStore } from '@/stores/auth';
import { useDoctorClass } from '@/hooks/useDoctorClass';
import { usePageData } from '@/hooks/usePageData';
import { getDashboard, type DoctorDashboard } from '@/services/doctor/dashboard';
import Loading from '@/components/Loading';
import PageShell from '@/components/ui/PageShell';
import ContentCard from '@/components/ui/ContentCard';
import ShortcutButton from '@/components/ui/ShortcutButton';
import TodoAlert from '@/components/ui/TodoAlert';
import './index.scss';
interface CardConfig {
interface StatItem {
key: keyof DoctorDashboard;
label: string;
initial: string;
color: string;
}
interface ShortcutItem {
icon: string;
label: string;
color: 'pri' | 'acc' | 'wrn' | 'dan';
route: string;
roles?: string[];
}
const ALL_CARDS: CardConfig[] = [
{ key: 'total_patients', label: '我的患者', initial: '患', route: '/pages/pkg-doctor-core/patients/index' },
{ key: 'unread_messages', label: '未读消息', initial: '消', route: '/pages/pkg-doctor-core/consultation/index' },
{ key: 'pending_follow_ups', label: '待处理随访', initial: '随', route: '/pages/pkg-doctor-core/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
{ key: 'today_consultations', label: '今日咨询', initial: '诊', route: '/pages/pkg-doctor-core/consultation/index', roles: ['doctor', 'health_manager'] },
const STATS: StatItem[] = [
{ key: 'pending_follow_ups', label: '待处理', color: 'wrn' },
{ key: 'today_consultations', label: '咨询中', color: 'pri' },
{ key: 'today_appointments', label: '今日患者', color: 'acc' },
{ key: 'unread_messages', label: '随访到期', color: 'dan' },
];
const ALL_HEALTH_CARDS: CardConfig[] = [
{ key: 'pending_lab_review', label: '待审化验', initial: '', route: '/pages/pkg-doctor-clinical/report/index', roles: ['doctor'] },
{ key: 'today_appointments', label: '今日预约', initial: '', route: '/pages/pkg-doctor-core/patients/index' },
const SHORTCUTS: ShortcutItem[] = [
{ icon: '👤', label: '患者管理', color: 'pri', route: '/pages/pkg-doctor-core/patients/index' },
{ icon: '💬', label: '在线咨询', color: 'acc', route: '/pages/pkg-doctor-core/consultation/index' },
{ icon: '📋', label: '随访管理', color: 'wrn', route: '/pages/pkg-doctor-core/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
{ icon: '🩺', label: '透析管理', color: 'dan', route: '/pages/pkg-doctor-clinical/dialysis/index', roles: ['doctor'] },
];
interface QuickAction {
label: string;
initial: string;
route: string;
roles: string[];
}
const ALL_QUICK_ACTIONS: QuickAction[] = [
{ label: '化验审核', initial: '审', route: '/pages/pkg-doctor-clinical/report/index', roles: ['doctor'] },
{ label: '患者查询', initial: '查', route: '/pages/pkg-doctor-core/patients/index', roles: ['doctor', 'nurse', 'health_manager'] },
{ label: '随访记录', initial: '随', route: '/pages/pkg-doctor-core/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
{ label: '告警中心', initial: '警', route: '/pages/pkg-doctor-clinical/alerts/index', roles: ['doctor', 'nurse', 'health_manager'] },
{ label: '透析管理', initial: '透', route: '/pages/pkg-doctor-clinical/dialysis/index', roles: ['doctor'] },
{ label: '处方管理', initial: '方', route: '/pages/pkg-doctor-clinical/prescription/index', roles: ['doctor'] },
{ label: '行动收件箱', initial: '行', route: '/pages/pkg-doctor-core/action-inbox/index', roles: ['doctor', 'nurse', 'health_manager'] },
];
const ROLE_LABELS: Record<string, string> = {
doctor: '医生',
nurse: '护士',
health_manager: '健康管理师',
admin: '管理员',
operator: '运营',
};
export default function DoctorHome() {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const roles = useAuthStore((s) => s.roles);
const modeClass = useDoctorClass();
const [dashboard, setDashboard] = useState<DoctorDashboard | null>(null);
const [alertCount, setAlertCount] = useState(0);
const [loading, setLoading] = useState(true);
const hasRole = (allowed: string[] | undefined) => {
@@ -69,23 +50,14 @@ export default function DoctorHome() {
return roles.some((r) => r === 'admin' || allowed.includes(r));
};
const cards = useMemo(() => ALL_CARDS.filter((c) => hasRole(c.roles)), [roles]);
const healthCards = useMemo(() => ALL_HEALTH_CARDS.filter((c) => hasRole(c.roles)), [roles]);
const quickActions = useMemo(() => ALL_QUICK_ACTIONS.filter((a) => hasRole(a.roles)), [roles]);
const roleLabel = useMemo(() => {
const primary = roles.find((r) => r !== 'admin');
return primary ? (ROLE_LABELS[primary] || primary) : '医护';
}, [roles]);
const shortcuts = useMemo(() => SHORTCUTS.filter((s) => hasRole(s.roles)), [roles]);
const loadDashboard = useCallback(async () => {
try {
const data = await getDashboard();
setDashboard(data);
const count = (data as Record<string, unknown>)?.abnormal_vital_count;
setAlertCount(typeof count === 'number' ? count : 0);
} catch {
// 静默失败,显示占位
// 静默失败
} finally {
setLoading(false);
}
@@ -93,107 +65,78 @@ export default function DoctorHome() {
usePageData(loadDashboard, { throttleMs: 10000 });
const handleCardClick = (card: CardConfig) => {
safeNavigateTo(card.route);
};
const handleLogout = () => {
logout();
};
const getValue = (key: keyof DoctorDashboard): number | string => {
if (!dashboard) return '-';
return dashboard[key] ?? 0;
};
const today = new Date();
const dateStr = `${today.getFullYear()}${today.getMonth() + 1}${today.getDate()}${
['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'][today.getDay()]
}`;
if (loading) return <Loading />;
return (
<PageShell safeBottom={false} className={`doctor-home ${modeClass}`}>
<View className='doctor-home__header'>
<Text className='doctor-home__title'></Text>
<Text className='doctor-home__greeting'>
{user?.display_name || user?.username || roleLabel}
</Text>
<Text className='doctor-home__date'>
{new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' })}
</Text>
</View>
<View className={`doctor-home ${modeClass}`}>
<ScrollView scrollY className="doctor-home__scroll">
<View className="doctor-home__content">
{/* 问候区 — 对齐原型:标题 + 日期 */}
<View className="doctor-home__header">
<Text className="doctor-home__title"></Text>
<Text className="doctor-home__date">{dateStr}</Text>
</View>
{alertCount > 0 && (
<View className='doctor-home__alert'>
<Text className='doctor-home__alert-icon'>!</Text>
<Text className='doctor-home__alert-text'>{alertCount} </Text>
<Text className='doctor-home__alert-link' onClick={() => safeNavigateTo('/pages/pkg-doctor-clinical/alerts/index')}> </Text>
</View>
)}
<View className='doctor-home__search'>
<Input
className='doctor-home__search-input'
placeholder='搜索患者姓名...'
onFocus={() => safeNavigateTo('/pages/pkg-doctor-core/patients/index')}
/>
</View>
<View className='doctor-home__section'>
<Text className='doctor-home__section-title'></Text>
<View className='doctor-home__grid'>
{cards.map((card) => (
<View
key={card.key}
className='doctor-home__card'
onClick={() => handleCardClick(card)}
>
<Text className='doctor-home__card-initial'>{card.initial}</Text>
<Text className='doctor-home__card-num'>{getValue(card.key)}</Text>
<Text className='doctor-home__card-label'>{card.label}</Text>
{/* 今日概览 — 原型:卡片内含子网格 */}
<ContentCard padding="md" margin="md">
<Text className="doctor-home__section-label"></Text>
<View className="doctor-home__stat-grid">
{STATS.map((stat) => (
<View key={stat.key} className={`doctor-home__stat-item doctor-home__stat-item--${stat.color}`}>
<Text className={`doctor-home__stat-value doctor-home__stat-value--${stat.color}`}>
{getValue(stat.key)}
</Text>
<Text className="doctor-home__stat-label">{stat.label}</Text>
</View>
))}
</View>
))}
</View>
</View>
</ContentCard>
{healthCards.length > 0 && (<View className='doctor-home__section'>
<Text className='doctor-home__section-title'></Text>
<View className='doctor-home__grid'>
{healthCards.map((card) => (
<View
key={card.key}
className='doctor-home__card'
onClick={() => handleCardClick(card)}
>
<Text className='doctor-home__card-initial'>{card.initial}</Text>
<Text className='doctor-home__card-num'>{getValue(card.key)}</Text>
<Text className='doctor-home__card-label'>{card.label}</Text>
</View>
))}
</View>
</View>)}
{/* 快捷操作 — 原型space-between 均分 */}
<View className="doctor-home__shortcuts">
{shortcuts.map((item) => (
<ShortcutButton
key={item.route}
icon={item.icon}
label={item.label}
color={item.color}
onPress={() => safeNavigateTo(item.route)}
/>
))}
</View>
<View className='doctor-home__section'>
<Text className='doctor-home__section-title'></Text>
<View className='doctor-home__quick-actions'>
{quickActions.map((action) => (
<View
key={action.route}
className='quick-action'
onClick={() => safeNavigateTo(action.route)}
>
<View className='quick-action__icon-wrap'>
<Text className='quick-action__initial'>{action.initial}</Text>
{action.label === '告警中心' && alertCount > 0 && (
<Text className='quick-action__badge'>{alertCount > 99 ? '99+' : alertCount}</Text>
)}
</View>
<Text className='quick-action__label'>{action.label}</Text>
</View>
))}
{/* 待办提醒 — 原型:无 SectionTitle直接小标题 + 警告卡片 */}
<Text className="doctor-home__section-label"></Text>
{dashboard && dashboard.pending_follow_ups > 0 && (
<TodoAlert
icon="✓"
title={`${dashboard.pending_follow_ups} 位患者血压异常待处理`}
subtitle="需要立即关注"
color="pri"
onPress={() => safeNavigateTo('/pages/pkg-doctor-core/followup/index')}
/>
)}
{dashboard && dashboard.today_consultations > 0 && (
<TodoAlert
icon="!"
title={`${dashboard.today_consultations} 份随访报告待审核`}
subtitle="截止今日 18:00"
color="wrn"
onPress={() => safeNavigateTo('/pages/pkg-doctor-core/consultation/index')}
/>
)}
</View>
</View>
<View className='doctor-home__footer'>
<Text className='doctor-home__logout' onClick={handleLogout}>退</Text>
</View>
</PageShell>
</ScrollView>
</View>
);
}

View File

@@ -1,66 +1,123 @@
@import '../../../styles/variables.scss';
// PageShell 已接管min-height, background, padding
// SearchSection 已接管search-bar
// ContentCard 已接管patient-card 背景/圆角/阴影/触摸反馈
// StatusTag 已接管patient-card__status 标签样式
.patient-count {
margin-bottom: var(--tk-gap-md);
text {
font-size: var(--tk-font-h2);
color: $tx3;
}
}
.patient-cards {
.patient-page {
height: 100vh;
background: $bg;
display: flex;
flex-direction: column;
gap: var(--tk-gap-md);
}
.patient-card__header {
display: flex;
align-items: center;
gap: var(--tk-gap-md);
}
&__search {
padding: 12px 20px;
}
.patient-card__name {
font-size: var(--tk-font-num);
font-weight: 600;
color: $tx;
}
&__count {
padding: 0 20px;
margin-bottom: 12px;
.patient-card__meta {
font-size: var(--tk-font-h2);
color: $tx2;
flex: 1;
}
text {
font-size: 13px;
color: $tx3;
}
}
.patient-card__tags {
display: flex;
flex-wrap: wrap;
gap: var(--tk-gap-xs);
margin-top: var(--tk-gap-sm);
}
&__list {
flex: 1;
padding: 0 20px 20px;
}
.patient-tag {
padding: var(--tk-gap-2xs) 14px;
border-radius: $r;
background: rgba($pri, 0.1);
&__hint {
text-align: center;
padding: 20px;
&__text {
font-size: var(--tk-font-body);
text {
font-size: 13px;
color: #78716C;
}
}
}
.load-more-hint-wrap {
text-align: center;
padding: var(--tk-section-gap);
// ── 搜索栏(对齐原型 §3.3 SearchBar──
.search-bar {
display: flex;
align-items: center;
gap: 10px;
background: $card;
border-radius: 12px;
height: 42px;
padding: 0 14px;
border: 1px solid $bd;
&__icon {
font-size: 14px;
flex-shrink: 0;
}
&__input {
flex: 1;
font-size: 14px;
color: #2D2A26;
height: 100%;
}
&__placeholder {
color: #78716C;
font-size: 14px;
}
}
.load-more-hint {
font-size: var(--tk-font-h2);
color: $tx3;
// ── 患者卡片(对齐原型:诊断 $doc-pri 色 + 最近访问日期)──
.patient-card {
margin-bottom: 10px !important;
&__row {
display: flex;
align-items: center;
gap: 12px;
}
&__body {
flex: 1;
min-width: 0;
}
&__top {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
&__name {
font-size: 15px;
font-weight: 600;
color: #2D2A26;
}
&__meta {
font-size: 12px;
color: #78716C;
}
&__diagnosis {
font-size: 13px;
color: $doc-pri;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
&__last-visit {
font-size: 12px;
color: $tx3;
margin-top: 3px;
display: block;
}
&__arrow {
flex-shrink: 0;
font-size: 20px;
color: #78716C;
}
}

View File

@@ -1,38 +1,29 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { View, Text } from '@tarojs/components';
import { View, Text, Input, ScrollView } from '@tarojs/components';
import Taro, { useReachBottom } from '@tarojs/taro';
import { safeNavigateTo } from '@/utils/navigate';
import { usePageData } from '@/hooks/usePageData';
import { listPatients, listPatientTags, type PatientItem, type PatientTag } from '@/services/doctor/patient';
import PageShell from '@/components/ui/PageShell';
import { listPatients, type PatientItem } from '@/services/doctor/patient';
import PageHeader from '@/components/patterns/PageHeader';
import ContentCard from '@/components/ui/ContentCard';
import StatusTag from '@/components/ui/StatusTag';
import AvatarCircle from '@/components/ui/AvatarCircle';
import LoadingCard from '@/components/ui/LoadingCard';
import SearchSection from '@/components/patterns/SearchSection';
import EmptyState from '@/components/EmptyState';
import Loading from '@/components/Loading';
import { useDoctorClass } from '@/hooks/useDoctorClass';
import './index.scss';
const AVATAR_COLORS: Array<'pri' | 'acc' | 'wrn' | 'dan'> = ['pri', 'acc', 'wrn', 'dan'];
export default function PatientList() {
const modeClass = useDoctorClass();
const [patients, setPatients] = useState<PatientItem[]>([]);
const [tags, setTags] = useState<PatientTag[]>([]);
const [activeTag, setActiveTag] = useState<string>('');
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const mountedRef = useRef(false);
useEffect(() => { loadTags(); }, []);
const loadTags = async () => {
try {
const res = await listPatientTags();
setTags(res.data || []);
} catch { /* ignore */ }
};
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const loadPatients = useCallback(async (pageNum: number, isRefresh = false) => {
if (isRefresh) setLoading(true);
@@ -41,7 +32,6 @@ export default function PatientList() {
page: pageNum,
page_size: 20,
search: search || undefined,
tag_id: activeTag || undefined,
});
const list = res.data || [];
setPatients(prev => isRefresh ? list : [...prev, ...list]);
@@ -52,7 +42,7 @@ export default function PatientList() {
} finally {
setLoading(false);
}
}, [search, activeTag]);
}, [search]);
usePageData(
useCallback(() => loadPatients(1, true), [loadPatients]),
@@ -62,16 +52,22 @@ export default function PatientList() {
useEffect(() => {
if (mountedRef.current) { loadPatients(1, true); }
mountedRef.current = true;
}, [activeTag, loadPatients]);
}, [search, loadPatients]);
useReachBottom(() => {
if (!loading && patients.length < total) { loadPatients(page + 1); }
});
const handleTagFilter = (tagId: string) => {
setActiveTag(tagId === activeTag ? '' : tagId);
const handleSearchInput = (val: string) => {
setSearch(val);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
loadPatients(1, true);
}, 300);
};
const getAvatarColor = (index: number) => AVATAR_COLORS[index % AVATAR_COLORS.length];
const getGenderLabel = (gender?: string) => {
if (!gender) return '';
return gender === 'male' ? '男' : gender === 'female' ? '女' : gender;
@@ -89,69 +85,90 @@ export default function PatientList() {
return `${age}`;
};
const filters = [
{ key: '', label: '全部' },
...tags.map(t => ({ key: t.id, label: t.name })),
];
const formatLastVisit = (dateStr?: string) => {
if (!dateStr) return '';
const d = new Date(dateStr);
return `${d.getMonth() + 1}${d.getDate()}`;
};
// 用 tag 名称组合为诊断摘要
const getDiagnosis = (p: PatientItem) => {
if (p.tags && p.tags.length > 0) {
return p.tags.map(t => t.name).join(' · ');
}
return '';
};
if (loading && patients.length === 0) return <LoadingCard count={3} />;
return (
<PageShell safeBottom className={modeClass}>
<SearchSection
value={search}
onChange={setSearch}
onSearch={() => loadPatients(1, true)}
placeholder="搜索患者姓名/手机号"
filters={filters}
activeFilter={activeTag}
onFilterChange={handleTagFilter}
/>
<View className={`patient-page ${modeClass}`}>
<PageHeader title="患者管理" showBack />
<View className="patient-count">
<View className="patient-page__search">
<View className="search-bar">
<Text className="search-bar__icon">🔍</Text>
<Input
className="search-bar__input"
value={search}
onInput={(e) => handleSearchInput(e.detail.value)}
placeholder="搜索患者姓名"
placeholderClass="search-bar__placeholder"
confirmType="search"
/>
</View>
</View>
<View className="patient-page__count">
<Text> {total} </Text>
</View>
{patients.length === 0 ? (
<EmptyState text="暂无患者数据" />
) : (
<View className="patient-cards">
{patients.map((p) => (
<ContentCard
key={p.id}
onPress={() => safeNavigateTo(`/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">
{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">
{p.tags.map((t) => (
<View
key={t.id}
className="patient-tag"
style={t.color ? `background: ${t.color}20; color: ${t.color}` : ''}
>
<Text className="patient-tag__text">{t.name}</Text>
<ScrollView scrollY className="patient-page__list">
{patients.map((p, idx) => {
const diagnosis = getDiagnosis(p);
return (
<ContentCard
key={p.id}
className="patient-card"
activeFeedback="opacity"
onPress={() => safeNavigateTo(`/pages/pkg-doctor-core/patients/detail/index?id=${p.id}`)}
>
<View className="patient-card__row">
<AvatarCircle
name={p.name}
size={46}
color={getAvatarColor(idx)}
/>
<View className="patient-card__body">
<View className="patient-card__top">
<Text className="patient-card__name">{p.name}</Text>
<Text className="patient-card__meta">
{calcAge(p.birth_date)} · {getGenderLabel(p.gender)}
</Text>
</View>
))}
{diagnosis && (
<Text className="patient-card__diagnosis">{diagnosis}</Text>
)}
{p.last_visit_date && (
<Text className="patient-card__last-visit"> {formatLastVisit(p.last_visit_date)}</Text>
)}
</View>
<Text className="patient-card__arrow"></Text>
</View>
)}
</ContentCard>
))}
</View>
</ContentCard>
);
})}
{!loading && patients.length >= total && total > 0 && (
<View className="patient-page__hint">
<Text></Text>
</View>
)}
{loading && patients.length > 0 && <Loading />}
</ScrollView>
)}
{!loading && patients.length >= total && total > 0 && (
<View className="load-more-hint-wrap">
<Text className="load-more-hint"></Text>
</View>
)}
{loading && patients.length > 0 && <Loading />}
</PageShell>
</View>
);
}

View File

@@ -1,200 +1,201 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
// PageShell 已接管min-height, background, padding
// 兑换确认 — 对齐原型 docs/design/mp-10-mall-pkg.html → 屏幕2
.exchange-page {
padding-bottom: 140px;
padding-bottom: var(--tk-gap-xl);
}
/* ===== 商品预览 ===== */
.product-card {
// 商品预览卡片
.exchange-product-card {
display: flex;
align-items: center;
padding: var(--tk-gap-xl) var(--tk-gap-lg);
gap: var(--tk-gap-sm);
padding: 14px;
background: $card;
margin: var(--tk-section-gap) var(--tk-gap-lg) var(--tk-gap-md);
border-radius: $r-lg;
border-radius: $r;
margin-bottom: var(--tk-gap-md);
box-shadow: $shadow-sm;
}
.product-icon-wrap {
width: 128px;
height: 128px;
border-radius: $r;
@include flex-center;
margin-right: var(--tk-gap-lg);
.exchange-product-icon {
width: 72px;
height: 72px;
border-radius: $r-sm;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&--physical {
background: $acc;
}
&--service {
background: var(--tk-pri);
}
&--privilege {
background: var(--tk-pri-d);
}
&--physical { background: $pri-l; }
&--service { background: $acc-l; }
&--privilege { background: $wrn-l; }
}
.product-icon-char {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-hero);
font-weight: bold;
color: $white;
.exchange-product-icon-char {
font-size: 24px;
font-weight: 700;
color: $pri;
.exchange-product-icon--service & { color: $acc; }
.exchange-product-icon--privilege & { color: $wrn; }
}
.product-meta {
.exchange-product-meta {
flex: 1;
min-width: 0;
}
.product-name {
font-size: var(--tk-font-num);
font-weight: bold;
.exchange-product-name {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 15px;
font-weight: 700;
color: $tx;
display: block;
margin-bottom: var(--tk-gap-sm);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-type-tag {
@include tag(var(--tk-pri-l), var(--tk-pri-d));
.exchange-product-points {
font-size: var(--tk-font-cap);
color: $pri;
font-weight: 600;
display: block;
}
/* ===== 兑换明细 ===== */
.detail-section {
padding: 0 var(--tk-gap-lg);
margin-bottom: var(--tk-gap-md);
.exchange-product-qty {
font-size: var(--tk-font-micro);
color: $tx3;
margin-top: 2px;
display: block;
}
.detail-section-title {
@include section-title;
}
.detail-card {
// 收货信息卡片
.exchange-address-card {
background: $card;
border-radius: $r;
padding: var(--tk-gap-md);
margin-bottom: var(--tk-gap-md);
box-shadow: $shadow-sm;
padding: 0 var(--tk-gap-lg);
}
.detail-row {
.exchange-address-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--tk-gap-lg) 0;
border-bottom: 1px solid $bd-l;
&.last {
border-bottom: none;
}
margin-bottom: 10px;
}
.detail-label {
font-size: var(--tk-font-body-lg);
color: $tx2;
}
.detail-value {
@include serif-number;
font-size: var(--tk-font-body-lg);
.exchange-address-title {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx;
font-weight: bold;
&.detail-cost {
color: var(--tk-pri);
font-size: var(--tk-font-num-lg);
}
&.detail-sufficient {
color: $acc;
}
&.detail-insufficient {
color: $dan;
}
}
/* ===== 温馨提示 ===== */
.notice-section {
.exchange-address-edit {
font-size: var(--tk-font-micro);
color: $pri;
font-weight: 500;
}
.exchange-address-name {
font-size: var(--tk-font-cap);
color: $tx;
font-weight: 500;
display: block;
margin-bottom: 2px;
}
.exchange-address-detail {
font-size: var(--tk-font-micro);
color: $tx3;
line-height: 1.5;
display: block;
}
// 兑换明细卡片
.exchange-detail-card {
background: $card;
padding: var(--tk-gap-lg);
margin: 0 var(--tk-gap-lg);
border-radius: $r;
padding: var(--tk-gap-md);
margin-bottom: var(--tk-gap-lg);
box-shadow: $shadow-sm;
}
.notice-title {
@include section-title;
font-size: var(--tk-font-body-lg);
.exchange-detail-title {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx;
display: block;
margin-bottom: var(--tk-gap-sm);
}
.notice-text {
font-size: var(--tk-font-h2);
color: $tx3;
display: block;
line-height: 1.7;
margin-bottom: var(--tk-gap-2xs);
}
/* ===== 底部操作栏 ===== */
.exchange-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
.exchange-detail-row {
display: flex;
align-items: center;
padding: var(--tk-gap-md) var(--tk-gap-lg);
padding-bottom: calc(var(--tk-gap-md) + env(safe-area-inset-bottom));
background: $card;
box-shadow: 0 -2px 12px rgba($tx, 0.06);
z-index: 10;
}
justify-content: space-between;
padding-bottom: 8px;
margin-bottom: 8px;
border-bottom: 1px dashed $bd-l;
.footer-cost {
flex: 1;
display: flex;
flex-direction: column;
}
.footer-cost-label {
font-size: var(--tk-font-body);
color: $tx3;
}
.footer-cost-num {
@include serif-number;
font-size: var(--tk-font-num-lg);
font-weight: bold;
color: var(--tk-pri);
}
.footer-cost-unit {
font-size: var(--tk-font-body);
color: $tx2;
margin-left: var(--tk-gap-2xs);
}
.confirm-btn {
background: var(--tk-pri);
padding: var(--tk-section-gap) var(--tk-gap-2xl);
border-radius: $r-pill;
transition: opacity 0.2s;
&.disabled {
background: $bd;
opacity: 0.7;
&.last {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 0;
}
}
.confirm-btn-text {
font-size: var(--tk-font-num);
color: $white;
font-weight: bold;
.exchange-detail-label {
font-size: var(--tk-font-cap);
color: $tx3;
}
.exchange-detail-value {
font-size: var(--tk-font-cap);
color: $tx;
font-weight: 400;
&.exchange-detail-cost {
color: $pri;
font-weight: 600;
}
&.sufficient {
color: $acc;
font-weight: 600;
}
&.insufficient {
color: $dan;
font-weight: 600;
}
}
// 确认兑换按钮
.exchange-confirm-btn {
height: 50px;
background: var(--tk-pri);
border-radius: $r;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--tk-shadow-btn);
&.disabled {
background: $bd;
box-shadow: none;
opacity: 0.7;
}
&:active:not(.disabled) {
opacity: var(--tk-touch-feedback-opacity);
}
}
.exchange-confirm-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body);
color: $white;
font-weight: 700;
}

View File

@@ -3,33 +3,29 @@ import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import {
getProduct,
listProducts,
exchangeProduct,
} from '../../../services/points';
import type { PointsProduct } from '../../../services/points';
import { usePointsStore } from '../../../stores/points';
import { useAuthStore } from '../../../stores/auth';
import Loading from '../../../components/Loading';
import { useElderClass } from '../../../hooks/useElderClass';
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
import PageShell from '@/components/ui/PageShell';
import './index.scss';
const TYPE_INITIAL: Record<string, string> = {
const TYPE_CHAR: Record<string, string> = {
physical: '物',
service: '券',
privilege: '权',
};
const TYPE_LABEL: Record<string, string> = {
physical: '实物商品',
service: '服务券',
privilege: '权益卡',
};
const TYPE_CLASS: Record<string, string> = {
physical: 'product-icon-wrap--physical',
service: 'product-icon-wrap--service',
privilege: 'product-icon-wrap--privilege',
physical: 'physical',
service: 'service',
privilege: 'privilege',
};
export default function ExchangeConfirm() {
@@ -37,6 +33,7 @@ export default function ExchangeConfirm() {
const [product, setProduct] = useState<PointsProduct | null>(null);
const account = usePointsStore((s) => s.account);
const refreshPoints = usePointsStore((s) => s.refresh);
const currentPatient = useAuthStore((s) => s.currentPatient);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const { safeSetTimeout } = useSafeTimeout();
@@ -52,17 +49,21 @@ export default function ExchangeConfirm() {
setLoading(true);
try {
const [productRes] = await Promise.all([
listProducts({ page: 1, page_size: 100 }),
refreshPoints(),
]);
const found = productRes.data.find((p) => p.id === productId);
// 先尝试单商品接口,降级到列表查找
let found: PointsProduct | null = null;
try {
found = await getProduct(productId);
} catch {
const productRes = await listProducts({ page: 1, page_size: 100 });
found = productRes.data.find((p) => p.id === productId) || null;
}
if (!found) {
Taro.showToast({ title: '商品不存在', icon: 'none' });
safeSetTimeout(() => Taro.navigateBack(), 1500);
return;
}
setProduct(found);
await refreshPoints();
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
safeSetTimeout(() => Taro.navigateBack(), 1500);
@@ -82,6 +83,11 @@ export default function ExchangeConfirm() {
const balance = account?.balance ?? 0;
const cost = product?.points_cost ?? 0;
const insufficient = balance < cost;
const remaining = balance - cost;
const productType = product?.product_type || 'physical';
const isService = productType === 'service';
const typeChar = TYPE_CHAR[productType] || '礼';
const typeCls = TYPE_CLASS[productType] || 'physical';
const handleConfirm = useCallback(async () => {
if (!product || submitting) return;
@@ -103,17 +109,19 @@ export default function ExchangeConfirm() {
Taro.showToast({ title: '兑换成功', icon: 'success', duration: 2000 });
safeSetTimeout(() => {
Taro.showModal({
title: '兑换成功',
content: `核销码: ${order.qr_code}\n请凭此码到前台核销`,
showCancel: false,
confirmText: '查看订单',
success: () => {
Taro.redirectTo({
url: `/pages/pkg-mall/orders/index`,
});
},
});
if (isService && order.qr_code) {
Taro.showModal({
title: '兑换成功',
content: `核销码: ${order.qr_code}\n请凭此码到前台核销`,
showCancel: false,
confirmText: '查看订单',
success: () => {
Taro.redirectTo({ url: '/pages/pkg-mall/orders/index' });
},
});
} else {
Taro.redirectTo({ url: '/pages/pkg-mall/orders/index' });
}
}, 2000);
} catch (err) {
const msg = err instanceof Error ? err.message : '兑换失败';
@@ -125,7 +133,7 @@ export default function ExchangeConfirm() {
} finally {
setSubmitting(false);
}
}, [product, submitting, insufficient, cost]);
}, [product, submitting, insufficient, cost, isService]);
if (loading) {
return (
@@ -135,88 +143,72 @@ export default function ExchangeConfirm() {
);
}
const productType = product?.product_type || 'physical';
const initial = TYPE_INITIAL[productType] || '礼';
const typeLabel = TYPE_LABEL[productType] || '商品';
const iconCls = TYPE_CLASS[productType] || 'product-icon-wrap--service';
return (
<PageShell className={modeClass}>
<PageShell padding="md" safeBottom={false} scroll={false} className={`exchange-page ${modeClass}`}>
{/* 商品预览卡片 */}
<View className='product-card'>
<View className={`product-icon-wrap ${iconCls}`}>
<Text className='product-icon-char'>{initial}</Text>
<View className='exchange-product-card'>
<View className={`exchange-product-icon exchange-product-icon--${typeCls}`}>
<Text className='exchange-product-icon-char'>{typeChar}</Text>
</View>
<View className='product-meta'>
<Text className='product-name'>{product?.name || ''}</Text>
<Text className='product-type-tag'>{typeLabel}</Text>
<View className='exchange-product-meta'>
<Text className='exchange-product-name'>{product?.name || ''}</Text>
<Text className='exchange-product-points'>{cost.toLocaleString()} </Text>
<Text className='exchange-product-qty'>×1</Text>
</View>
</View>
{/* 收货信息(实体商品) */}
{!isService && currentPatient && (
<View className='exchange-address-card'>
<View className='exchange-address-header'>
<Text className='exchange-address-title'></Text>
<Text className='exchange-address-edit'> </Text>
</View>
<Text className='exchange-address-name'>
{currentPatient.name}
</Text>
<Text className='exchange-address-detail'></Text>
</View>
)}
{/* 兑换明细 */}
<View className='detail-section'>
<Text className='detail-section-title'></Text>
<View className='detail-card'>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value detail-cost'>{cost.toLocaleString()}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text
className={`detail-value ${insufficient ? 'detail-insufficient' : 'detail-sufficient'}`}
>
{balance.toLocaleString()}
</Text>
</View>
{insufficient && (
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value detail-insufficient'>
-{(cost - balance).toLocaleString()}
</Text>
</View>
)}
<View className='detail-row last'>
<Text className='detail-label'></Text>
<Text className='detail-value'>
{product && product.stock > 0 ? `剩余 ${product.stock}` : '已兑完'}
</Text>
</View>
<View className='exchange-detail-card'>
<Text className='exchange-detail-title'></Text>
<View className='exchange-detail-row'>
<Text className='exchange-detail-label'></Text>
<Text className='exchange-detail-value'>{cost.toLocaleString()}</Text>
</View>
</View>
{/* 温馨提示 */}
<View className='notice-section'>
<Text className='notice-title'></Text>
<Text className='notice-text'>
</Text>
<Text className='notice-text'>退</Text>
</View>
{/* 底部操作 */}
<View className='exchange-footer'>
<View className='footer-cost'>
<Text className='footer-cost-label'></Text>
<Text className='footer-cost-num'>{cost.toLocaleString()}</Text>
<Text className='footer-cost-unit'></Text>
<View className='exchange-detail-row'>
<Text className='exchange-detail-label'>{isService ? '核销方式' : '运费'}</Text>
<Text className='exchange-detail-value'>{isService ? '到院核销' : '¥0.00'}</Text>
</View>
<View
className={`confirm-btn ${insufficient || (product?.stock ?? 0) <= 0 || submitting ? 'disabled' : ''}`}
onClick={insufficient || (product?.stock ?? 0) <= 0 || submitting ? undefined : handleConfirm}
>
<Text className='confirm-btn-text'>
{submitting
? '兑换中...'
: insufficient
? '积分不足'
: (product?.stock ?? 0) <= 0
? '已兑完'
: '确认兑换'}
<View className='exchange-detail-row'>
<Text className='exchange-detail-label'></Text>
<Text className='exchange-detail-value exchange-detail-cost'>{cost.toLocaleString()}</Text>
</View>
<View className='exchange-detail-row last'>
<Text className='exchange-detail-label'></Text>
<Text className={`exchange-detail-value ${remaining >= 0 ? 'sufficient' : 'insufficient'}`}>
{remaining.toLocaleString()}
</Text>
</View>
</View>
{/* 确认兑换按钮 */}
<View
className={`exchange-confirm-btn ${insufficient || (product?.stock ?? 0) <= 0 || submitting ? 'disabled' : ''}`}
onClick={insufficient || (product?.stock ?? 0) <= 0 || submitting ? undefined : handleConfirm}
>
<Text className='exchange-confirm-text'>
{submitting
? '兑换中...'
: insufficient
? '积分不足'
: (product?.stock ?? 0) <= 0
? '已兑完'
: '确认兑换'}
</Text>
</View>
</PageShell>
);
}

View File

@@ -1,114 +1,184 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
// PageShell 已接管min-height, background, safe-bottom
// ContentCard 已接管order-card 背景/圆角/阴影
// 订单列表 — 对齐原型 docs/design/mp-10-mall-pkg.html → 屏幕3
/* ===== 订单列表 ===== */
.order-list {
padding: 0 var(--tk-gap-lg);
.orders-page {
padding-bottom: env(safe-area-inset-bottom);
}
// 状态筛选 Tab
.orders-tabs {
display: flex;
padding: 0;
background: $card;
border-bottom: 1px solid $bd-l;
}
.orders-tab {
flex: 1;
text-align: center;
padding: var(--tk-gap-sm) 0;
position: relative;
min-height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 2px solid transparent;
&.active {
border-bottom-color: var(--tk-pri);
}
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.orders-tab-text {
font-size: var(--tk-font-cap);
color: $tx3;
.orders-tab.active & {
color: var(--tk-pri);
font-weight: 600;
}
}
// 订单列表
.order-list {
padding: var(--tk-gap-md);
display: flex;
flex-direction: column;
gap: var(--tk-gap-sm);
}
// 订单卡片
.order-card {
margin-bottom: var(--tk-gap-md);
overflow: hidden;
padding: var(--tk-gap-md);
background: $card;
border-radius: $r;
box-shadow: $shadow-sm;
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--tk-gap-lg) var(--tk-gap-lg) var(--tk-gap-md);
border-bottom: 1px solid $bd-l;
margin-bottom: 10px;
}
.order-product {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body-lg);
font-weight: bold;
color: $tx;
.order-id {
font-size: var(--tk-font-micro);
color: $tx3;
}
// 状态标签
.order-status-tag {
padding: 2px 8px;
border-radius: 6px;
&--pending { background: $wrn-l; }
&--approved { background: $pri-l; }
&--shipped { background: $acc-l; }
&--completed { background: $surface-alt; }
&--verified { background: $acc-l; }
&--cancelled { background: $dan-l; }
&--expired { background: $surface-alt; }
}
.order-status-text {
font-size: var(--tk-font-micro);
font-weight: 600;
.order-status-tag--pending & { color: $wrn; }
.order-status-tag--approved & { color: $pri; }
.order-status-tag--shipped & { color: $acc; }
.order-status-tag--completed & { color: $tx3; }
.order-status-tag--verified & { color: $acc; }
.order-status-tag--cancelled & { color: $dan; }
.order-status-tag--expired & { color: $tx3; }
}
// 订单主体
.order-body {
// layout handled by children
}
.order-main {
display: flex;
justify-content: space-between;
align-items: center;
}
.order-product-info {
flex: 1;
min-width: 0;
}
.order-product-name {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx;
display: block;
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.order-status-tag {
padding: var(--tk-gap-2xs) var(--tk-gap-md);
border-radius: $r-pill;
margin-left: var(--tk-gap-sm);
flex-shrink: 0;
&--pending {
@include tag($wrn-l, $wrn);
}
&--verified {
@include tag($acc-l, $acc);
}
&--cancelled {
@include tag($dan-l, $dan);
}
&--expired {
@include tag($bd-l, $tx3);
}
}
.order-status-text {
font-size: var(--tk-font-body);
font-weight: bold;
}
.order-body {
padding: var(--tk-gap-md) var(--tk-gap-lg) var(--tk-section-gap);
}
.order-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--tk-gap-xs) 0;
}
.order-row-label {
font-size: var(--tk-font-h1);
.order-date {
font-size: var(--tk-font-micro);
color: $tx3;
display: block;
}
.order-row-value {
.order-points {
display: flex;
align-items: baseline;
gap: 2px;
flex-shrink: 0;
margin-left: var(--tk-gap-sm);
}
.order-points-value {
@include serif-number;
font-size: var(--tk-font-h1);
color: $tx;
&.order-cost {
color: var(--tk-pri);
font-weight: bold;
}
font-size: var(--tk-font-body);
font-weight: 700;
color: $pri;
}
/* ===== 核销码 ===== */
.order-points-unit {
font-size: var(--tk-font-micro);
color: $pri;
font-weight: 400;
}
// 核销码
.order-qrcode {
display: flex;
align-items: center;
padding: var(--tk-gap-md);
padding: var(--tk-gap-sm);
margin-top: var(--tk-gap-sm);
background: var(--tk-pri-l);
background: $pri-l;
border-radius: $r-sm;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.qrcode-label {
font-size: var(--tk-font-h2);
font-size: var(--tk-font-cap);
color: $tx3;
margin-right: var(--tk-gap-xs);
}
.qrcode-value {
@include serif-number;
font-size: var(--tk-font-h2);
color: var(--tk-pri-d);
font-weight: bold;
font-size: var(--tk-font-cap);
color: $pri-d;
font-weight: 700;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
@@ -116,8 +186,7 @@
}
.qrcode-tap {
font-size: var(--tk-font-body);
color: var(--tk-pri);
margin-left: var(--tk-gap-xs);
font-size: var(--tk-font-cap);
color: $pri;
flex-shrink: 0;
}

View File

@@ -7,21 +7,22 @@ import type { PointsOrder } from '../../../services/points';
import EmptyState from '../../../components/EmptyState';
import ErrorState from '../../../components/ErrorState';
import Loading from '../../../components/Loading';
import SegmentTabs from '../../../components/SegmentTabs';
import PageShell from '@/components/ui/PageShell';
import ContentCard from '@/components/ui/ContentCard';
import { useElderClass } from '../../../hooks/useElderClass';
import PageShell from '@/components/ui/PageShell';
import './index.scss';
const STATUS_TABS = [
{ key: '', label: '全部' },
{ key: 'pending', label: '待核销' },
{ key: 'verified', label: '已核销' },
{ key: 'expired', label: '已过期' },
{ key: 'pending', label: '待处理' },
{ key: 'shipped', label: '已发货' },
{ key: 'verified', label: '已完成' },
];
const STATUS_CONFIG: Record<string, { label: string; cls: string }> = {
pending: { label: '待核销', cls: 'order-status-tag--pending' },
pending: { label: '待处理', cls: 'order-status-tag--pending' },
approved: { label: '已审核', cls: 'order-status-tag--approved' },
shipped: { label: '已发货', cls: 'order-status-tag--shipped' },
completed: { label: '已完成', cls: 'order-status-tag--completed' },
verified: { label: '已核销', cls: 'order-status-tag--verified' },
cancelled: { label: '已取消', cls: 'order-status-tag--cancelled' },
expired: { label: '已过期', cls: 'order-status-tag--expired' },
@@ -76,7 +77,7 @@ export default function MallOrders() {
usePageData(
useCallback(async () => {
Taro.setNavigationBarTitle({ title: '我的订单' });
Taro.setNavigationBarTitle({ title: '兑换记录' });
await loadAll();
}, [loadAll]),
{ throttleMs: 10000, enablePullDown: true },
@@ -103,19 +104,29 @@ export default function MallOrders() {
};
const getStatusConfig = (status: string) => {
return STATUS_CONFIG[status] || { label: status, cls: 'order-status-tag--expired' };
return STATUS_CONFIG[status] || { label: status, cls: 'order-status-tag--pending' };
};
const formatDate = (dateStr: string) => {
if (!dateStr) return '';
const d = new Date(dateStr);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
return (
<PageShell padding="none" className={modeClass}>
<PageShell padding="none" safeBottom={false} scroll={false} className={`orders-page ${modeClass}`}>
{/* 状态筛选标签 */}
<SegmentTabs tabs={STATUS_TABS} activeKey={activeTab} onChange={handleTabChange} variant="underline" />
<View className='orders-tabs'>
{STATUS_TABS.map((tab) => (
<View
key={tab.key}
className={`orders-tab ${activeTab === tab.key ? 'active' : ''}`}
onClick={() => handleTabChange(tab.key)}
>
<Text className='orders-tab-text'>{tab.label}</Text>
</View>
))}
</View>
{/* 订单列表 */}
{error ? (
@@ -133,30 +144,25 @@ export default function MallOrders() {
{orders.map((order) => {
const statusCfg = getStatusConfig(order.status);
return (
<ContentCard className='order-card' key={order.id}>
<View className='order-card' key={order.id}>
<View className='order-header'>
<Text className='order-product'> {order.product_id.slice(0, 8)}</Text>
<View
className={`order-status-tag ${statusCfg.cls}`}
>
<Text className='order-id'> {order.id.slice(0, 12).toUpperCase()}</Text>
<View className={`order-status-tag ${statusCfg.cls}`}>
<Text className='order-status-text'>{statusCfg.label}</Text>
</View>
</View>
<View className='order-body'>
<View className='order-row'>
<Text className='order-row-label'></Text>
<Text className='order-row-value order-cost'>
{order.points_cost.toLocaleString()}
</Text>
<View className='order-main'>
<View className='order-product-info'>
<Text className='order-product-name'> {order.product_id.slice(0, 8)}</Text>
<Text className='order-date'>{formatDate(order.created_at)}</Text>
</View>
<View className='order-points'>
<Text className='order-points-value'>{order.points_cost.toLocaleString()}</Text>
<Text className='order-points-unit'></Text>
</View>
</View>
<View className='order-row'>
<Text className='order-row-label'></Text>
<Text className='order-row-value'>
{formatDate(order.created_at)}
</Text>
</View>
{order.status === 'pending' && (
{order.status === 'pending' && order.qr_code && (
<View className='order-qrcode' onClick={() => handleShowQrCode(order.qr_code)}>
<Text className='qrcode-label'></Text>
<Text className='qrcode-value'>{order.qr_code}</Text>
@@ -164,7 +170,7 @@ export default function MallOrders() {
</View>
)}
</View>
</ContentCard>
</View>
);
})}
{loading && <Loading />}

View File

@@ -1,28 +1,56 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
// PageShell 已接管min-height, background, padding
// 就诊人建档/编辑 — 对齐原型 docs/design/mp-13-family-profile.html → FamilyAdd
.family-add-page {
padding-bottom: 160px;
padding-bottom: 120px;
}
.page-title {
@include section-title;
.family-add-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-h1);
font-weight: bold;
color: $tx;
margin-bottom: var(--tk-section-gap);
display: block;
padding-left: var(--tk-gap-2xs);
}
// 提示卡片
.family-add-tip {
background: $pri-l;
border-radius: $r;
padding: 14px var(--tk-gap-md);
margin-bottom: var(--tk-section-gap);
border-left: 4px solid $pri;
}
.family-add-tip-title {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $pri;
margin-bottom: 4px;
display: block;
}
.family-add-tip-desc {
font-size: var(--tk-font-cap);
color: $tx2;
line-height: 1.6;
display: block;
}
// 表单卡片
.form-card {
background: $card;
border-radius: $r;
padding: var(--tk-gap-2xs) var(--tk-card-padding-lg);
overflow: hidden;
box-shadow: $shadow-sm;
}
// 表单项 — 垂直布局(标签在上,输入在下)
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--tk-card-padding-lg) 0;
padding: 14px var(--tk-gap-md);
border-bottom: 1px solid $bd-l;
&:last-child {
@@ -31,39 +59,60 @@
}
.form-label {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body-lg);
color: $tx;
flex-shrink: 0;
width: 140px;
font-size: var(--tk-font-cap);
color: $tx3;
font-weight: 500;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 2px;
}
.form-required {
color: $dan;
}
// 带边框的输入容器
.form-input-wrap {
height: 44px;
background: $bg;
border: 1.5px solid $bd;
border-radius: $r-sm;
display: flex;
align-items: center;
padding: 0 14px;
}
.form-input {
flex: 1;
font-size: var(--tk-font-body-lg);
font-size: 15px;
color: $tx;
text-align: right;
border: none;
background: transparent;
outline: none;
height: 100%;
}
.form-placeholder {
font-size: 15px;
color: $tx3;
}
.form-picker {
// 选择器容器
.form-picker-wrap {
height: 44px;
background: $bg;
border: 1.5px solid $bd;
border-radius: $r-sm;
display: flex;
align-items: center;
flex: 1;
justify-content: flex-end;
justify-content: space-between;
padding: 0 14px;
}
.form-picker-text {
font-size: var(--tk-font-body-lg);
font-size: 15px;
color: $tx;
margin-right: var(--tk-gap-sm);
&.placeholder {
color: $tx3;
@@ -71,30 +120,33 @@
}
.form-picker-arrow {
font-size: var(--tk-font-h2);
font-size: var(--tk-font-body-sm);
color: $tx3;
font-family: 'Georgia', 'Times New Roman', serif;
}
// 提交按钮
.submit-btn {
position: fixed;
bottom: 0;
left: 0;
right: 0;
margin-top: var(--tk-gap-lg);
height: var(--tk-btn-primary-h);
border-radius: $r;
background: var(--tk-pri);
padding: var(--tk-card-padding-lg);
text-align: center;
box-shadow: 0 -2px 12px rgba($pri, 0.15);
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--tk-shadow-btn);
&.disabled {
opacity: 0.5;
}
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.submit-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num);
font-size: 17px;
font-weight: 600;
color: $white;
font-weight: bold;
letter-spacing: 2px;
}

View File

@@ -67,66 +67,102 @@ export default function FamilyAdd() {
};
return (
<PageShell className={modeClass}>
<Text className='page-title'>{editId ? '编辑就诊人' : '添加就诊人'}</Text>
<PageShell padding="md" safeBottom={false} scroll={false} className={`family-add-page ${modeClass}`}>
<Text className='family-add-title'>{editId ? '编辑就诊人' : '添加就诊人'}</Text>
{/* 提示卡片 */}
<View className='family-add-tip'>
<Text className='family-add-tip-title'></Text>
<Text className='family-add-tip-desc'>
使
</Text>
</View>
{/* 表单 */}
<View className='form-card'>
<View className='form-item'>
<Text className='form-label'></Text>
<Input
className='form-input'
placeholder='请输入姓名'
placeholderClass='form-placeholder'
value={name}
onInput={(e) => setName(e.detail.value)}
/>
<Text className='form-label'><Text className='form-required'>*</Text></Text>
<View className='form-input-wrap'>
<Input
className='form-input'
placeholder='请输入真实姓名'
placeholderClass='form-placeholder'
value={name}
onInput={(e) => setName(e.detail.value)}
/>
</View>
</View>
<View className='form-item'>
<Text className='form-label'></Text>
<Text className='form-label'><Text className='form-required'>*</Text></Text>
<Picker
mode='selector'
range={RELATION_OPTIONS}
value={relationIdx}
onChange={(e) => setRelationIdx(Number(e.detail.value))}
>
<View className='form-picker'>
<View className='form-picker-wrap'>
<Text className='form-picker-text'>{RELATION_OPTIONS[relationIdx]}</Text>
<Text className='form-picker-arrow'>{'>'}</Text>
<Text className='form-picker-arrow'></Text>
</View>
</Picker>
</View>
<View className='form-item'>
<Text className='form-label'></Text>
<Text className='form-label'><Text className='form-required'>*</Text></Text>
<Picker
mode='selector'
range={GENDER_OPTIONS}
value={genderIdx}
onChange={(e) => setGenderIdx(Number(e.detail.value))}
>
<View className='form-picker'>
<View className='form-picker-wrap'>
<Text className='form-picker-text'>{GENDER_OPTIONS[genderIdx]}</Text>
<Text className='form-picker-arrow'>{'>'}</Text>
<Text className='form-picker-arrow'></Text>
</View>
</Picker>
</View>
<View className='form-item'>
<Text className='form-label'></Text>
<Text className='form-label'><Text className='form-required'>*</Text></Text>
<Picker
mode='date'
value={birthDate || '2000-01-01'}
onChange={(e) => setBirthDate(e.detail.value)}
>
<View className='form-picker'>
<View className='form-picker-wrap'>
<Text className={`form-picker-text ${!birthDate ? 'placeholder' : ''}`}>
{birthDate || '请选择'}
</Text>
<Text className='form-picker-arrow'>{'>'}</Text>
<Text className='form-picker-arrow'></Text>
</View>
</Picker>
</View>
<View className='form-item'>
<Text className='form-label'></Text>
<View className='form-input-wrap'>
<Input
className='form-input'
placeholder='选填,用于接收通知'
placeholderClass='form-placeholder'
type='number'
maxlength={11}
/>
</View>
</View>
<View className='form-item'>
<Text className='form-label'></Text>
<View className='form-input-wrap'>
<Input
className='form-input'
placeholder='选填,用于医保对接'
placeholderClass='form-placeholder'
maxlength={18}
/>
</View>
</View>
</View>
<View

View File

@@ -1,20 +1,33 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
// PageShell 已接管min-height, background, padding
// 就诊人列表 — 对齐原型 docs/design/mp-13-family-profile.html → FamilyList
.family-page {
padding-bottom: 160px;
padding-bottom: var(--tk-gap-xl);
}
.family-page-title {
@include section-title;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-h1);
font-weight: bold;
color: $tx;
margin-bottom: var(--tk-section-gap);
display: block;
padding-left: var(--tk-gap-2xs);
}
.family-hint {
font-size: var(--tk-font-cap);
color: $tx3;
line-height: 1.5;
margin-bottom: var(--tk-gap-md);
padding: 0 var(--tk-gap-2xs);
}
.family-list {
display: flex;
flex-direction: column;
gap: var(--tk-gap-md);
gap: var(--tk-gap-sm);
}
.family-item {
@@ -22,7 +35,7 @@
align-items: center;
background: $card;
border-radius: $r;
padding: var(--tk-card-padding);
padding: var(--tk-gap-md);
box-shadow: $shadow-sm;
transition: box-shadow 0.2s;
@@ -31,25 +44,45 @@
}
&.active {
box-shadow: $shadow-md;
border: 2px solid var(--tk-pri);
}
}
// 关系渐变色头像
.family-avatar {
@include flex-center;
width: 80px;
height: 80px;
border-radius: $r;
background: var(--tk-pri-l);
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 24px;
flex-shrink: 0;
margin-right: var(--tk-section-gap);
margin-right: 14px;
// 关系渐变色
&--self {
background: linear-gradient(135deg, $pri-l 0%, $pri 100%);
}
&--spouse {
background: linear-gradient(135deg, $acc-l 0%, $acc 100%);
}
&--parent {
background: linear-gradient(135deg, $wrn-l 0%, $wrn 100%);
}
&--child {
background: linear-gradient(135deg, #EDE8F4 0%, #8B7ACC 100%);
}
&--other {
background: linear-gradient(135deg, $surface-alt 0%, $tx3 100%);
}
}
.family-avatar-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num-lg);
font-weight: bold;
color: var(--tk-pri-d);
font-size: 20px;
font-weight: 700;
color: $white;
line-height: 1;
}
.family-info {
@@ -62,47 +95,65 @@
.family-name-row {
display: flex;
align-items: center;
gap: var(--tk-gap-sm);
margin-bottom: var(--tk-gap-xs);
gap: 8px;
margin-bottom: 4px;
}
.family-name {
font-size: var(--tk-font-num);
font-weight: bold;
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
}
.family-current-tag {
@include tag(var(--tk-pri), $white);
font-size: var(--tk-font-body-sm);
padding: 2px 10px;
font-size: var(--tk-font-micro);
padding: 2px 8px;
border-radius: $r-pill;
background: $pri-l;
color: $pri;
font-weight: 600;
}
.family-meta {
display: flex;
align-items: center;
gap: var(--tk-gap-sm);
gap: 8px;
font-size: var(--tk-font-cap);
color: $tx3;
}
.family-relation-tag {
@include tag(var(--tk-pri-l), var(--tk-pri-d));
font-size: var(--tk-font-body);
padding: 2px 12px;
}
padding: 1px 6px;
border-radius: $r-xs;
font-size: var(--tk-font-cap);
font-weight: 500;
.family-gender {
font-size: var(--tk-font-h2);
color: $tx2;
&--self {
background: $pri-l;
color: $pri;
}
&--spouse {
background: $acc-l;
color: $acc;
}
&--parent {
background: $wrn-l;
color: $wrn;
}
&--child {
background: #EDE8F4;
color: #8B7ACC;
}
&--other {
background: $surface-alt;
color: $tx3;
}
}
.family-edit {
flex-shrink: 0;
margin-left: var(--tk-gap-md);
padding: var(--tk-gap-md) var(--tk-gap-lg);
border: 1px solid $bd;
border-radius: $r-pill;
min-height: 48px;
@include flex-center;
margin-left: var(--tk-gap-sm);
padding: var(--tk-gap-xs) var(--tk-gap-sm);
&:active {
opacity: var(--tk-touch-feedback-opacity);
@@ -110,25 +161,34 @@
}
.family-edit-text {
font-size: var(--tk-font-h2);
color: $tx2;
font-size: var(--tk-font-body-sm);
color: var(--tk-pri);
font-weight: 500;
}
.family-add-btn {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--tk-pri);
padding: var(--tk-card-padding-lg);
text-align: center;
box-shadow: 0 -2px 12px rgba($pri, 0.15);
margin-top: var(--tk-gap-sm);
height: 52px;
border-radius: $r;
border: 2px dashed $bd;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.family-add-icon {
font-size: 20px;
color: var(--tk-pri);
line-height: 1;
}
.family-add-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-num);
color: $white;
font-weight: bold;
letter-spacing: 2px;
font-size: var(--tk-font-body);
color: var(--tk-pri);
font-weight: 600;
}

View File

@@ -10,6 +10,18 @@ import { useElderClass } from '../../../hooks/useElderClass';
import PageShell from '@/components/ui/PageShell';
import './index.scss';
const RELATION_CLASS: Record<string, string> = {
'本人': 'self',
'配偶': 'spouse',
'父母': 'parent',
'子女': 'child',
'其他': 'other',
};
function getRelationClass(relation: string): string {
return RELATION_CLASS[relation] || 'other';
}
export default function FamilyList() {
const modeClass = useElderClass();
const [patients, setPatients] = useState<Patient[]>([]);
@@ -57,25 +69,28 @@ export default function FamilyList() {
return '未知';
};
const relationInitial = (relation: string) => {
return relation ? relation.charAt(0) : '';
const birthYear = (d?: string) => {
if (!d) return '';
return d.slice(0, 4) + '年';
};
return (
<PageShell className={modeClass}>
<PageShell padding="md" safeBottom={false} scroll={false} className={`family-page ${modeClass}`}>
<Text className='family-page-title'></Text>
<Text className='family-hint'>使</Text>
<View className='family-list'>
{patients.map((p) => {
const isActive = currentPatient?.id === p.id;
const relClass = getRelationClass(p.relation || '本人');
return (
<View
className={`family-item ${isActive ? 'active' : ''}`}
key={p.id}
onClick={() => handleSelect(p)}
>
<View className='family-avatar'>
<Text className='family-avatar-text'>{relationInitial(p.relation || '本人')}</Text>
<View className={`family-avatar family-avatar--${relClass}`}>
<Text className='family-avatar-text'>{p.name.charAt(0)}</Text>
</View>
<View className='family-info'>
<View className='family-name-row'>
@@ -83,8 +98,11 @@ export default function FamilyList() {
{isActive && <Text className='family-current-tag'></Text>}
</View>
<View className='family-meta'>
<Text className='family-relation-tag'>{p.relation || '本人'}</Text>
<Text className='family-gender'>{genderText(p.gender)}</Text>
<Text className={`family-relation-tag family-relation-tag--${relClass}`}>
{p.relation || '本人'}
</Text>
<Text>{genderText(p.gender)}</Text>
{birthYear(p.birth_date) && <Text>{birthYear(p.birth_date)}</Text>}
</View>
</View>
<View
@@ -103,6 +121,7 @@ export default function FamilyList() {
)}
<View className='family-add-btn' onClick={goToAdd}>
<Text className='family-add-icon'>+</Text>
<Text className='family-add-text'></Text>
</View>
</PageShell>

View File

@@ -56,6 +56,10 @@ export async function listProducts(params?: {
return api.get<ProductListResponse>('/health/points/products', params);
}
export async function getProduct(productId: string) {
return api.get<PointsProduct>(`/health/points/products/${productId}`);
}
// ===== 兑换订单 =====
export interface PointsOrder {