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', root: 'pages/pkg-mall',
pages: ['exchange/index', 'orders/index', 'detail/index'], pages: ['exchange/index', 'orders/index', 'detail/index', 'product/index'],
}, },
{ {
root: 'pages/pkg-profile', root: 'pages/pkg-profile',

View File

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

View File

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

View File

@@ -85,6 +85,15 @@ function GuestHome({ modeClass }: { modeClass: string }) {
const slides = banners.length > 0 ? banners : FALLBACK_SLIDES; 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 ( return (
<PageShell padding="none" safeBottom={false} scroll={false} className={`guest-page ${modeClass}`}> <PageShell padding="none" safeBottom={false} scroll={false} className={`guest-page ${modeClass}`}>
<Swiper <Swiper
@@ -114,59 +123,71 @@ function GuestHome({ modeClass }: { modeClass: string }) {
))} ))}
</Swiper> </Swiper>
{/* 健康资讯 */}
<View className='guest-section'> <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 ? ( {articles.length > 0 ? (
<View className='guest-articles'> <View className='guest-articles'>
{articles.map((article) => ( {articles.map((article, i) => (
<ContentCard <View
key={article.id} key={article.id}
onPress={() => safeNavigateTo(`/pages/article/detail/index?id=${article.id}`)} className={`guest-article-card guest-article-card--${ARTICLE_COLORS[i % 3]}`}
activeFeedback="opacity" onClick={() => safeNavigateTo(`/pages/article/detail/index?id=${article.id}`)}
padding="none"
> >
{article.cover_image && ( <View className='guest-article-icon'>
<Image className='guest-article-cover' src={article.cover_image} mode='aspectFill' lazyLoad /> <Text className='guest-article-icon-char'>{ARTICLE_ICONS[i % 3]}</Text>
)} </View>
<View className='guest-article-body'> <View className='guest-article-body'>
<Text className='guest-article-title'>{article.title}</Text> <Text className='guest-article-title'>{article.title}</Text>
<Text className='guest-article-summary'> <Text className='guest-article-date'>{formatDate(article.published_at)}</Text>
{article.summary || '点击查看详情'}
</Text>
</View> </View>
</ContentCard> </View>
))} ))}
</View> </View>
) : ( ) : (
<View className='guest-articles'> <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'> <View className='guest-article-body'>
<Text className='guest-article-title'></Text> <Text className='guest-article-title'></Text>
<Text className='guest-article-summary'></Text> <Text className='guest-article-date'></Text>
</View> </View>
</ContentCard> </View>
<ContentCard padding="none"> <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'> <View className='guest-article-body'>
<Text className='guest-article-title'></Text> <Text className='guest-article-title'></Text>
<Text className='guest-article-summary'>线</Text> <Text className='guest-article-date'></Text>
</View> </View>
</ContentCard> </View>
<ContentCard padding="none"> <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'> <View className='guest-article-body'>
<Text className='guest-article-title'>AI </Text> <Text className='guest-article-title'></Text>
<Text className='guest-article-summary'></Text> <Text className='guest-article-date'></Text>
</View> </View>
</ContentCard> </View>
</View> </View>
)} )}
</View> </View>
<ContentCard variant="elevated"> {/* 底部注册引导 */}
<Text className='guest-login-text'>使</Text> <View className='guest-cta-card'>
<View className='guest-login-btn' onClick={navigateToLogin}> <Text className='guest-cta-title'></Text>
<Text className='guest-login-btn-text'></Text> <Text className='guest-cta-desc'>使</Text>
<View className='guest-cta-btn' onClick={navigateToLogin}>
<Text className='guest-cta-btn-text'> / </Text>
</View> </View>
</ContentCard> </View>
</PageShell> </PageShell>
); );
} }

View File

@@ -23,7 +23,22 @@ export default function Login() {
const navigateAfterLogin = () => { const navigateAfterLogin = () => {
if (isMedicalStaff()) { 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 { } else {
Taro.switchTab({ url: '/pages/index/index' }); Taro.switchTab({ url: '/pages/index/index' });
} }

View File

@@ -1,40 +1,68 @@
@import '../../styles/variables.scss'; @import '../../styles/variables.scss';
@import '../../styles/mixins.scss'; @import '../../styles/mixins.scss';
// 积分商城 — 对齐原型 docs/design/mp-05-mall.html
.mall-page { .mall-page {
padding-bottom: calc(var(--tk-tabbar-space) + env(safe-area-inset-bottom)); padding-bottom: calc(var(--tk-tabbar-space) + env(safe-area-inset-bottom));
} }
/* ─── 积分余额卡片 ─── */ /* ─── 积分卡片(渐变背景) ─── */
.mall-header { .mall-header {
background: linear-gradient(135deg, var(--tk-pri) 0%, var(--tk-pri-d) 100%); 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 { .points-card {
background: rgba(255, 255, 255, 0.15); position: relative;
border: 1px solid rgba(255, 255, 255, 0.2); z-index: 1;
border-radius: $r-lg;
padding: var(--tk-gap-xl);
} }
.points-top { .points-top {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: var(--tk-gap-md); margin-bottom: var(--tk-gap-sm);
} }
.points-label { .points-label {
font-size: var(--tk-font-h1); font-size: var(--tk-font-cap);
color: rgba(255, 255, 255, 0.85); color: rgba(255, 255, 255, 0.7);
letter-spacing: 1px;
} }
.checkin-btn { .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); 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; border-radius: $r-pill;
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s; transition: all 0.2s;
&:active { &:active {
@@ -48,9 +76,9 @@
} }
.checkin-btn-text { .checkin-btn-text {
font-size: var(--tk-font-h2); font-size: var(--tk-font-cap);
color: $white; color: $white;
font-weight: 600; font-weight: 500;
} }
.checkin-btn.checked .checkin-btn-text { .checkin-btn.checked .checkin-btn-text {
@@ -59,8 +87,8 @@
.points-balance { .points-balance {
@include serif-number; @include serif-number;
font-size: var(--tk-font-display); font-size: 42px;
font-weight: bold; font-weight: 700;
color: $white; color: $white;
display: block; display: block;
margin-bottom: var(--tk-gap-xs); margin-bottom: var(--tk-gap-xs);
@@ -69,44 +97,105 @@
} }
.points-streak { .points-streak {
font-size: var(--tk-font-body); font-size: var(--tk-font-cap);
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
display: block; 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 { .type-tabs {
display: flex; 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 { .type-tab {
flex: 1; padding: 7px 18px;
text-align: center; border-radius: $r-pill;
padding: var(--tk-gap-md) 0; font-size: var(--tk-font-body-sm);
position: relative; font-weight: 400;
min-height: 48px; background: $surface-alt;
color: $tx2;
white-space: nowrap;
flex-shrink: 0;
transition: all 0.2s;
&.active::after { &:active {
content: ''; opacity: var(--tk-touch-feedback-opacity);
position: absolute; }
bottom: 0;
left: 50%; &.active {
transform: translateX(-50%);
width: 48px;
height: 4px;
background: var(--tk-pri); background: var(--tk-pri);
border-radius: $r-xs; color: $white;
font-weight: 600;
box-shadow: var(--tk-shadow-tab);
} }
} }
.type-tab-text { .type-tab-text {
font-size: var(--tk-font-body-lg); font-size: inherit;
color: $tx2; color: inherit;
font-weight: inherit;
&.active { &.active {
color: var(--tk-pri); color: inherit;
font-weight: 600;
} }
} }
@@ -114,12 +203,15 @@
.product-grid { .product-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: var(--tk-gap-md); gap: var(--tk-gap-sm);
padding: var(--tk-section-gap) var(--tk-page-padding); padding: 0 var(--tk-page-padding) var(--tk-gap-lg);
} }
.product-card { .product-card {
background: $card;
border-radius: $r-sm;
overflow: hidden; overflow: hidden;
box-shadow: $shadow-sm;
&:active { &:active {
opacity: var(--tk-touch-feedback-opacity); opacity: var(--tk-touch-feedback-opacity);
@@ -128,19 +220,20 @@
.product-image { .product-image {
width: 100%; width: 100%;
height: 200px; aspect-ratio: 1;
@include flex-center; @include flex-center;
position: relative;
&.type-physical { background: var(--tk-pri-l); } &.type-physical { background: $pri-l; }
&.type-service { background: $acc-l; } &.type-service { background: $acc-l; }
&.type-privilege { background: $wrn-l; } &.type-privilege { background: $wrn-l; }
} }
.product-image-char { .product-image-char {
font-family: 'Georgia', 'Times New Roman', serif; font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-hero); font-size: 32px;
font-weight: bold; font-weight: 700;
color: var(--tk-pri); color: $pri;
line-height: 1; line-height: 1;
.type-service & { color: $acc; } .type-service & { color: $acc; }
@@ -148,57 +241,83 @@
} }
.product-info { .product-info {
padding: var(--tk-section-gap); padding: 10px var(--tk-gap-sm) 14px;
} }
.product-name { .product-name {
font-size: var(--tk-font-h1); font-size: var(--tk-font-body-sm);
font-weight: 600; font-weight: 600;
color: $tx; color: $tx;
display: block; display: -webkit-box;
margin-bottom: var(--tk-gap-sm); -webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; line-height: 1.4;
white-space: nowrap; height: 40px;
margin-bottom: 8px;
} }
.product-bottom { .product-bottom {
display: flex; display: flex;
justify-content: space-between; align-items: baseline;
align-items: center; gap: 6px;
} }
.product-points { .product-points {
display: flex; display: flex;
align-items: center; align-items: baseline;
gap: var(--tk-gap-2xs); gap: 2px;
} }
.product-points-char { .product-points-char {
font-family: 'Georgia', 'Times New Roman', serif; @include serif-number;
font-size: var(--tk-font-body); font-size: 18px;
font-weight: bold; font-weight: 700;
color: $wrn; color: $pri;
} }
.product-points-value { .product-points-value {
@include serif-number; font-size: var(--tk-font-micro);
font-size: var(--tk-font-body-lg); color: $pri;
font-weight: bold; font-weight: 500;
color: $wrn; }
.product-price {
font-size: var(--tk-font-micro);
color: $tx3;
text-decoration: line-through;
} }
.product-stock { .product-stock {
font-size: var(--tk-font-body); font-size: var(--tk-font-micro);
padding: 2px var(--tk-gap-sm); padding: 2px 6px;
border-radius: $r-sm; border-radius: $r-xs;
&.out { &.out {
@include tag($bd-l, $tx3); background: $bd-l;
color: $tx3;
} }
&.low { &.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 EmptyState from '../../components/EmptyState';
import { useElderClass } from '../../hooks/useElderClass'; import { useElderClass } from '../../hooks/useElderClass';
import PageShell from '@/components/ui/PageShell'; import PageShell from '@/components/ui/PageShell';
import ContentCard from '@/components/ui/ContentCard';
import './index.scss'; import './index.scss';
const PRODUCT_TYPE_TABS = [ const PRODUCT_TYPE_TABS = [
{ key: '', label: '全部' }, { key: '', label: '全部' },
{ key: 'physical', label: '实物', char: '物' }, { key: 'physical', label: '实物' },
{ key: 'service', label: '服务券', char: '券' }, { key: 'service', label: '服务券' },
{ key: 'privilege', label: '权益', char: '权' }, { key: 'privilege', label: '权益' },
]; ];
const TYPE_BG: Record<string, string> = { const TYPE_BG: Record<string, string> = {
@@ -77,7 +76,6 @@ export default function Mall() {
async (type?: string) => { async (type?: string) => {
const t = type !== undefined ? type : productType; const t = type !== undefined ? type : productType;
if (!currentPatient) { if (!currentPatient) {
// 先尝试从服务端加载患者列表
await loadPatients(); await loadPatients();
const updated = useAuthStore.getState().currentPatient; const updated = useAuthStore.getState().currentPatient;
if (!updated) { if (!updated) {
@@ -133,7 +131,7 @@ export default function Mall() {
Taro.showToast({ title: '已兑完', icon: 'none' }); Taro.showToast({ title: '已兑完', icon: 'none' });
return; 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; const balance = account?.balance ?? 0;
@@ -158,7 +156,7 @@ export default function Mall() {
<View className='mall-header'> <View className='mall-header'>
<View className='points-card'> <View className='points-card'>
<View className='points-top'> <View className='points-top'>
<Text className='points-label'></Text> <Text className='points-label'></Text>
<View <View
className={`checkin-btn ${checkinStatus?.checked_in_today ? 'checked' : ''}`} className={`checkin-btn ${checkinStatus?.checked_in_today ? 'checked' : ''}`}
onClick={handleCheckin} onClick={handleCheckin}
@@ -177,6 +175,28 @@ export default function Mall() {
</View> </View>
</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'> <View className='type-tabs'>
{PRODUCT_TYPE_TABS.map((tab) => ( {PRODUCT_TYPE_TABS.map((tab) => (
@@ -200,7 +220,11 @@ export default function Mall() {
) : ( ) : (
<View className='product-grid'> <View className='product-grid'>
{products.map((item) => ( {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] || ''}`}> <View className={`product-image ${TYPE_BG[item.product_type] || ''}`}>
<Text className='product-image-char'> <Text className='product-image-char'>
{item.product_type === 'physical' ? '物' : item.product_type === 'service' ? '券' : '权'} {item.product_type === 'physical' ? '物' : item.product_type === 'service' ? '券' : '权'}
@@ -210,8 +234,8 @@ export default function Mall() {
<Text className='product-name'>{item.name}</Text> <Text className='product-name'>{item.name}</Text>
<View className='product-bottom'> <View className='product-bottom'>
<View className='product-points'> <View className='product-points'>
<Text className='product-points-char'>P</Text> <Text className='product-points-char'>{item.points_cost}</Text>
<Text className='product-points-value'>{item.points_cost}</Text> <Text className='product-points-value'></Text>
</View> </View>
{item.stock <= 0 ? ( {item.stock <= 0 ? (
<Text className='product-stock out'></Text> <Text className='product-stock out'></Text>
@@ -220,7 +244,7 @@ export default function Mall() {
) : null} ) : null}
</View> </View>
</View> </View>
</ContentCard> </View>
))} ))}
{loading && <Loading />} {loading && <Loading />}
{!loading && products.length >= total && total > 0 && ( {!loading && products.length >= total && total > 0 && (

View File

@@ -1,64 +1,102 @@
@import '../../../styles/variables.scss'; @import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss'; @import '../../../styles/mixins.scss';
// PageShell 已接管min-height, background .inbox-page {
// ContentCard 已接管inbox-card 背景/圆角/阴影/触摸反馈 height: 100vh;
background: $bg;
display: flex;
flex-direction: column;
.inbox-list { &__filter {
height: calc(100vh - 50px); padding: 12px 20px;
padding: var(--tk-gap-sm); }
&__list {
flex: 1;
padding: 0 20px 20px;
}
} }
.inbox-card { .inbox-card {
margin-bottom: var(--tk-gap-sm); margin-bottom: 10px !important;
.inbox-card-header { &__row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--tk-gap-xs); gap: 12px;
margin-bottom: var(--tk-gap-2xs);
} }
.inbox-type-tag { &__body {
color: $card; flex: 1;
font-size: var(--tk-font-micro); min-width: 0;
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;
}
} }
.inbox-card-title { &__name-row {
font-size: var(--tk-font-cap); display: flex;
font-weight: 500; align-items: center;
gap: 6px;
}
&__patient {
font-size: 15px;
font-weight: 600;
color: $tx; color: $tx;
} }
.inbox-card-desc { &__urgent {
font-size: var(--tk-font-micro); 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: $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 { .half-screen-dialog {
position: fixed; position: fixed;
bottom: 0; bottom: 0;
@@ -75,37 +113,27 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: var(--tk-gap-md) var(--tk-section-gap); padding: 16px 20px;
border-bottom: 1px solid $bd-l; border-bottom: 1px solid $bd-l;
.dialog-title { .dialog-title { font-size: 14px; font-weight: 600; color: $tx; }
font-size: var(--tk-font-body-sm); .dialog-close { font-size: 13px; color: $tx3; }
font-weight: 600;
color: $tx;
}
.dialog-close {
font-size: var(--tk-font-cap);
color: $tx3;
}
} }
.dialog-body { .dialog-body { padding: 16px 20px; }
padding: var(--tk-gap-md) var(--tk-section-gap);
}
.dialog-patient { .dialog-patient {
font-size: var(--tk-font-cap); font-size: 13px;
color: $tx2; color: $tx2;
display: block; display: block;
margin-bottom: var(--tk-gap-sm); margin-bottom: 12px;
} }
.thread-item { .thread-item {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: var(--tk-gap-sm); gap: 12px;
padding: var(--tk-gap-2xs) 0; padding: 4px 0;
} }
.thread-dot { .thread-dot {
@@ -122,30 +150,22 @@
} }
.thread-content { .thread-content {
.thread-label { .thread-label { font-size: 13px; color: $tx; display: block; }
font-size: var(--tk-font-cap); .thread-time { font-size: 11px; color: $tx3; }
color: $tx;
display: block;
}
.thread-time {
font-size: var(--tk-font-micro);
color: $tx3;
}
} }
.dialog-actions { .dialog-actions {
display: flex; display: flex;
gap: var(--tk-gap-xs); gap: 8px;
padding: var(--tk-gap-sm) var(--tk-section-gap) var(--tk-section-gap); padding: 12px 20px 20px;
border-top: 1px solid $bd-l; border-top: 1px solid $bd-l;
.action-btn { .action-btn {
flex: 1; flex: 1;
text-align: center; text-align: center;
padding: var(--tk-gap-sm); padding: 12px;
border-radius: $r-sm; border-radius: $r-sm;
font-size: var(--tk-font-cap); font-size: 13px;
font-weight: 500; font-weight: 500;
&.primary { background: var(--tk-pri); color: $card; } &.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 { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData'; import { usePageData } from '@/hooks/usePageData';
import { api } from '@/services/request';
import { import {
listActionItems, listActionItems,
getActionThread, getActionThread,
@@ -12,47 +11,54 @@ import {
import Loading from '@/components/Loading'; import Loading from '@/components/Loading';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import EmptyState from '@/components/EmptyState'; import EmptyState from '@/components/EmptyState';
import SegmentTabs from '@/components/SegmentTabs'; import PageHeader from '@/components/patterns/PageHeader';
import PageShell from '@/components/ui/PageShell'; import TabFilter from '@/components/ui/TabFilter';
import ContentCard from '@/components/ui/ContentCard'; import ContentCard from '@/components/ui/ContentCard';
import { useDoctorClass } from '@/hooks/useDoctorClass'; import { useDoctorClass } from '@/hooks/useDoctorClass';
import './index.scss'; import './index.scss';
const TYPE_LABEL: Record<string, string> = { const TYPE_CONFIG: Record<string, { label: string; colorCls: string }> = {
ai_suggestion: 'AI建议', data_anomaly: { label: '异常', colorCls: 'inbox-type-tag--dan' },
alert: '告警', followup: { label: '随访', colorCls: 'inbox-type-tag--acc' },
followup: '随访', ai_suggestion: { label: '咨询', colorCls: 'inbox-type-tag--pri' },
data_anomaly: '异常', alert: { label: '告警', colorCls: 'inbox-type-tag--dan' },
}; };
const TYPE_CLS: Record<string, string> = { const FILTER_TABS = ['全部', '异常', '随访', '咨询'];
ai_suggestion: 'inbox-type-tag--ai',
alert: 'inbox-type-tag--alert', const FILTER_MAP: Record<number, string | undefined> = {
followup: 'inbox-type-tag--followup', 0: undefined,
data_anomaly: 'inbox-type-tag--anomaly', 1: 'data_anomaly',
2: 'followup',
3: 'ai_suggestion',
}; };
const STATUS_TABS = [ function formatTimeAgo(dateStr: string): string {
{ key: '', label: '全部' }, const now = Date.now();
{ key: 'pending', label: '待处理' }, const then = new Date(dateStr).getTime();
{ key: 'in_progress', label: '进行中' }, const diff = now - then;
{ key: 'completed', label: '已完成' }, 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() { export default function ActionInboxPage() {
const modeClass = useDoctorClass(); const modeClass = useDoctorClass();
const [items, setItems] = useState<ActionItem[]>([]); const [items, setItems] = useState<ActionItem[]>([]);
const [total, setTotal] = useState(0);
const [_page, setPage] = useState(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [activeTab, setActiveTab] = useState(''); const [activeFilter, setActiveFilter] = useState(0);
const [threadData, setThreadData] = useState<ThreadResponse | null>(null); const [threadData, setThreadData] = useState<ThreadResponse | null>(null);
const [showDetail, setShowDetail] = useState(false); const [showDetail, setShowDetail] = useState(false);
const loadingRef = useRef(false); const loadingRef = useRef(false);
const fetchItems = useCallback( const fetchItems = useCallback(
async (pageNum: number, status: string, isRefresh = false) => { async (pageNum: number, typeFilter: string | undefined, isRefresh = false) => {
if (loadingRef.current) return; if (loadingRef.current) return;
loadingRef.current = true; loadingRef.current = true;
setLoading(true); setLoading(true);
@@ -61,7 +67,7 @@ export default function ActionInboxPage() {
const resp = await listActionItems({ const resp = await listActionItems({
page: pageNum, page: pageNum,
page_size: 20, page_size: 20,
status: status || undefined, type: typeFilter,
}); });
const list = resp.data || []; const list = resp.data || [];
if (isRefresh) { if (isRefresh) {
@@ -69,8 +75,6 @@ export default function ActionInboxPage() {
} else { } else {
setItems((prev) => [...prev, ...list]); setItems((prev) => [...prev, ...list]);
} }
setTotal(resp.total);
setPage(pageNum);
} catch { } catch {
setError(true); setError(true);
Taro.showToast({ title: '加载失败', icon: 'none' }); Taro.showToast({ title: '加载失败', icon: 'none' });
@@ -84,15 +88,14 @@ export default function ActionInboxPage() {
usePageData( usePageData(
useCallback(async () => { useCallback(async () => {
Taro.setNavigationBarTitle({ title: '待办事项' }); await fetchItems(1, FILTER_MAP[activeFilter], true);
await fetchItems(1, activeTab, true); }, [fetchItems, activeFilter]),
}, [fetchItems, activeTab]),
{ throttleMs: 10000, enablePullDown: true }, { throttleMs: 10000, enablePullDown: true },
); );
const handleTabChange = (key: string) => { const handleFilterChange = (index: number) => {
setActiveTab(key); setActiveFilter(index);
fetchItems(1, key, true); fetchItems(1, FILTER_MAP[index], true);
}; };
const handleItemClick = async (item: ActionItem) => { const handleItemClick = async (item: ActionItem) => {
@@ -111,90 +114,98 @@ export default function ActionInboxPage() {
}) => { }) => {
if (!action.api_endpoint || !threadData) return; if (!action.api_endpoint || !threadData) return;
try { try {
const { api } = await import('@/services/request');
await api.post(action.api_endpoint, { action: action.key }); await api.post(action.api_endpoint, { action: action.key });
Taro.showToast({ title: '操作成功', icon: 'success' }); Taro.showToast({ title: '操作成功', icon: 'success' });
setShowDetail(false); setShowDetail(false);
fetchItems(1, activeTab, true); fetchItems(1, FILTER_MAP[activeFilter], true);
} catch { } catch {
Taro.showToast({ title: '操作失败', icon: 'none' }); Taro.showToast({ title: '操作失败', icon: 'none' });
} }
}; };
const getTypeConfig = (type: string) =>
TYPE_CONFIG[type] || { label: '未知', colorCls: 'inbox-type-tag--default' };
return ( return (
<PageShell padding="none" className={modeClass}> <View className={`inbox-page ${modeClass}`}>
<SegmentTabs tabs={STATUS_TABS} activeKey={activeTab} onChange={handleTabChange} variant="underline" /> <PageHeader title="待办事项" showBack />
<View className="inbox-page__filter">
<TabFilter
tabs={FILTER_TABS}
activeIndex={activeFilter}
onChange={handleFilterChange}
variant="pill"
/>
</View>
{error ? ( {error ? (
<ErrorState onRetry={() => fetchItems(1, activeTab, true)} /> <ErrorState onRetry={() => fetchItems(1, FILTER_MAP[activeFilter], true)} />
) : items.length === 0 && !loading ? ( ) : items.length === 0 && !loading ? (
<EmptyState text='暂无待办事项' /> <EmptyState text="暂无待办事项" />
) : ( ) : (
<ScrollView scrollY className="inbox-list"> <ScrollView scrollY className="inbox-page__list">
{items.map((item) => ( {items.map((item) => {
<ContentCard const cfg = getTypeConfig(item.action_type);
key={item.id} const isUrgent = item.priority === 'urgent' || item.priority === 'high';
className="inbox-card" return (
activeFeedback="opacity" <ContentCard
onPress={() => handleItemClick(item)} key={item.id}
> className="inbox-card"
<View className="inbox-card-header"> activeFeedback="opacity"
<Text onPress={() => handleItemClick(item)}
className={`inbox-type-tag ${TYPE_CLS[item.action_type] || 'inbox-type-tag--default'}`} >
> <View className="inbox-card__row">
{TYPE_LABEL[item.action_type] || '未知'} {/* 类型标签 — 原型color/bg 配对 */}
</Text> <Text className={`inbox-type-tag ${cfg.colorCls}`}>
<Text className="inbox-card-title">{item.title}</Text> {cfg.label}
</View> </Text>
<Text className="inbox-card-desc"> {/* 内容区 */}
{item.patient_name} ·{' '} <View className="inbox-card__body">
{new Date(item.created_at).toLocaleDateString('zh-CN')} <View className="inbox-card__name-row">
</Text> <Text className="inbox-card__patient">{item.patient_name || '未知患者'}</Text>
</ContentCard> {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 && <Loading />}
{!loading && items.length >= total && total > 0 && (
<Loading text="没有更多了" />
)}
</ScrollView> </ScrollView>
)} )}
{showDetail && threadData && ( {showDetail && threadData && (
<View className="half-screen-dialog"> <View className="half-screen-dialog">
<View className="dialog-header"> <View className="dialog-header">
<Text className="dialog-title"> <Text className="dialog-title">{threadData.action_item.title}</Text>
{threadData.action_item.title} <Text className="dialog-close" onClick={() => setShowDetail(false)}></Text>
</Text>
<Text
className="dialog-close"
onClick={() => setShowDetail(false)}
>
</Text>
</View> </View>
<View className="dialog-body"> <View className="dialog-body">
<Text className="dialog-patient"> <Text className="dialog-patient">
{threadData.action_item.patient_name} ·{' '} {threadData.action_item.patient_name} ·{' '}
{threadData.action_item.priority === 'urgent' {threadData.action_item.priority === 'urgent' ? '紧急'
? '紧急' : threadData.action_item.priority === 'high' ? '高' : '中'}
: threadData.action_item.priority === 'high'
? '高'
: '中'}
</Text> </Text>
<View className="thread-timeline"> {threadData.thread.map((evt, idx) => (
{threadData.thread.map((evt, idx) => ( <View key={idx} className="thread-item">
<View key={idx} className="thread-item"> <View className={`thread-dot ${evt.status}`} />
<View className={`thread-dot ${evt.status}`} /> <View className="thread-content">
<View className="thread-content"> <Text className="thread-label">{evt.label}</Text>
<Text className="thread-label">{evt.label}</Text> {evt.timestamp && (
{evt.timestamp && ( <Text className="thread-time">
<Text className="thread-time"> {new Date(evt.timestamp).toLocaleDateString('zh-CN')}
{new Date(evt.timestamp).toLocaleDateString('zh-CN')} </Text>
</Text> )}
)}
</View>
</View> </View>
))} </View>
</View> ))}
</View> </View>
{threadData.available_actions.length > 0 && ( {threadData.available_actions.length > 0 && (
<View className="dialog-actions"> <View className="dialog-actions">
@@ -211,6 +222,6 @@ export default function ActionInboxPage() {
)} )}
</View> </View>
)} )}
</PageShell> </View>
); );
} }

View File

@@ -1,76 +1,88 @@
@import '../../../styles/variables.scss'; @import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss'; @import '../../../styles/mixins.scss';
// PageShell 已接管min-height, background, padding .consult-page {
// SearchSection 已接管:标签筛选栏 height: 100vh;
// ContentCard 已接管session-card 背景/圆角/阴影/触摸反馈 background: $bg;
// StatusTag 已接管:会话状态标签
// PaginationBar 已接管:分页控件
.session-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--tk-gap-md);
&__tabs {
padding: 12px 20px 0;
}
&__list {
flex: 1;
padding: 12px 20px 20px;
}
} }
.session-card__top { .consult-card {
display: flex; margin-bottom: 10px !important;
justify-content: space-between;
align-items: center;
margin-bottom: var(--tk-gap-sm);
}
.session-card__subject { &__row {
font-size: var(--tk-font-body-lg); display: flex;
font-weight: 600; align-items: center;
color: $tx; gap: 12px;
flex: 1; }
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: var(--tk-gap-md);
}
.session-card__info { &__body {
display: flex; flex: 1;
align-items: center; min-width: 0;
gap: var(--tk-gap-md); }
margin-bottom: var(--tk-gap-xs);
}
.session-card__type { &__top {
@include tag(var(--tk-pri-l), var(--tk-pri)); display: flex;
} justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.session-card__time { &__name {
font-size: var(--tk-font-h2); font-size: 15px;
color: $tx3; font-weight: 600;
} color: $tx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-card__preview { &__time {
font-size: var(--tk-font-h1); font-size: 12px;
color: $tx2; color: $tx3;
overflow: hidden; flex-shrink: 0;
text-overflow: ellipsis; margin-left: 8px;
white-space: nowrap; }
display: block;
}
.session-card__badge { &__msg {
position: absolute; font-size: 13px;
top: var(--tk-section-gap); color: $tx2;
right: var(--tk-section-gap); overflow: hidden;
min-width: 36px; text-overflow: ellipsis;
height: 36px; white-space: nowrap;
background: $dan; display: block;
border-radius: $r-pill; margin-top: 4px;
@include flex-center; }
padding: 0 8px;
}
.session-card__badge-text { &__badge {
@include serif-number; min-width: 20px;
font-size: var(--tk-font-body); height: 20px;
color: $card; background: $dan;
font-weight: 600; 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 { 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 Taro from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData'; import { usePageData } from '@/hooks/usePageData';
import { listSessions, type ConsultationSession } from '@/services/doctor/consultation'; 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 ContentCard from '@/components/ui/ContentCard';
import StatusTag from '@/components/ui/StatusTag'; import AvatarCircle from '@/components/ui/AvatarCircle';
import LoadingCard from '@/components/ui/LoadingCard'; import LoadingCard from '@/components/ui/LoadingCard';
import PaginationBar from '@/components/patterns/PaginationBar';
import SearchSection from '@/components/patterns/SearchSection';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import EmptyState from '@/components/EmptyState'; import EmptyState from '@/components/EmptyState';
import { useDoctorClass } from '@/hooks/useDoctorClass'; import { useDoctorClass } from '@/hooks/useDoctorClass';
import { formatDateTime } from '@/utils/date';
import { safeNavigateTo } from '@/utils/navigate'; import { safeNavigateTo } from '@/utils/navigate';
import { formatDateTime } from '@/utils/date';
import './index.scss'; import './index.scss';
const TABS = [ const STATUS_TABS = [
{ key: '', label: '全部' },
{ key: 'active', label: '进行中' }, { key: 'active', label: '进行中' },
{ key: 'waiting', label: '等待中' }, { key: 'closed', label: '已结束' },
{ key: 'closed', label: '已关闭' },
]; ];
const STATUS_COLOR_MAP: Record<string, 'success' | 'warning' | 'default' | 'info'> = { const AVATAR_COLORS: Array<'pri' | 'acc' | 'wrn' | 'dan'> = ['pri', 'acc', 'wrn', 'dan'];
active: 'success',
waiting: 'warning',
closed: 'default',
};
export default function ConsultationList() { export default function ConsultationList() {
const modeClass = useDoctorClass(); const modeClass = useDoctorClass();
const [sessions, setSessions] = useState<ConsultationSession[]>([]); const [sessions, setSessions] = useState<ConsultationSession[]>([]);
const [activeTab, setActiveTab] = useState(''); const [activeTab, setActiveTab] = useState('active');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const mountedRef = useRef(false); const mountedRef = useRef(false);
@@ -49,7 +41,6 @@ export default function ConsultationList() {
status: activeTab || undefined, status: activeTab || undefined,
}); });
setSessions(res.data || []); setSessions(res.data || []);
setTotal(res.total || 0);
} catch { } catch {
setError(true); setError(true);
Taro.showToast({ title: '加载失败', icon: 'none' }); Taro.showToast({ title: '加载失败', icon: 'none' });
@@ -72,6 +63,8 @@ export default function ConsultationList() {
setPage(1); setPage(1);
}; };
const getAvatarColor = (index: number) => AVATAR_COLORS[index % AVATAR_COLORS.length];
const formatTime = (dateStr?: string | null) => { const formatTime = (dateStr?: string | null) => {
if (!dateStr) return ''; if (!dateStr) return '';
return formatDateTime(dateStr); return formatDateTime(dateStr);
@@ -81,57 +74,57 @@ export default function ConsultationList() {
if (error) return <ErrorState onRetry={loadSessions} />; if (error) return <ErrorState onRetry={loadSessions} />;
return ( return (
<PageShell safeBottom className={modeClass}> <View className={`consult-page ${modeClass}`}>
<SearchSection <PageHeader title="在线咨询" showBack />
value=""
onChange={() => {}} <View className="consult-page__tabs">
filters={TABS} <SegmentTabs
activeFilter={activeTab} tabs={STATUS_TABS}
onFilterChange={handleTabChange} activeKey={activeTab}
/> onChange={handleTabChange}
variant="underline"
/>
</View>
{sessions.length === 0 ? ( {sessions.length === 0 ? (
<EmptyState text="暂无咨询会话" /> <EmptyState text="暂无咨询会话" />
) : ( ) : (
<View className="session-list"> <ScrollView scrollY className="consult-page__list">
{sessions.map((s) => ( {sessions.map((s, idx) => (
<ContentCard <ContentCard
key={s.id} key={s.id}
className="consult-card"
activeFeedback="opacity"
onPress={() => safeNavigateTo(`/pages/pkg-doctor-core/consultation/detail/index?id=${s.id}`)} onPress={() => safeNavigateTo(`/pages/pkg-doctor-core/consultation/detail/index?id=${s.id}`)}
> >
<View className="session-card__top"> <View className="consult-card__row">
<Text className="session-card__subject">{s.subject || '在线咨询'}</Text> <AvatarCircle
<StatusTag name={s.patient_name || '未'}
status={s.status} size={44}
colorMap={STATUS_COLOR_MAP} color={getAvatarColor(idx)}
size="sm"
/> />
</View> <View className="consult-card__body">
<View className="session-card__info"> <View className="consult-card__top">
<Text className="session-card__type"> <Text className="consult-card__name">{s.patient_name || '未知患者'}</Text>
{s.consultation_type === 'text' ? '图文' : s.consultation_type === 'video' ? '视频' : '咨询'} <Text className="consult-card__time">{formatTime(s.last_message_at)}</Text>
</Text> </View>
<Text className="session-card__time">{formatTime(s.last_message_at)}</Text> <Text className="consult-card__msg">
</View> {s.last_message || (s.consultation_type === 'text' ? '图文咨询' : '视频咨询')}
{s.last_message && ( </Text>
<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> </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> </ContentCard>
))} ))}
</View> </ScrollView>
)} )}
</View>
<PaginationBar
current={page}
total={total}
pageSize={20}
onChange={setPage}
/>
</PageShell>
); );
} }

View File

@@ -1,51 +1,90 @@
@import '../../../styles/variables.scss'; @import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
// PageShell 已接管min-height, background, padding .followup-page {
// SearchSection 已接管:标签筛选栏 height: 100vh;
// ContentCard 已接管task-card 背景/圆角/阴影/触摸反馈 background: $bg;
// StatusTag 已接管:任务状态标签 display: flex;
flex-direction: column;
.task-count { &__filter {
margin-bottom: var(--tk-gap-md); padding: 12px 20px;
}
text { &__list {
font-size: var(--tk-font-h2); flex: 1;
color: $tx3; padding: 0 20px 20px;
} }
} }
.task-list { .followup-card {
display: flex; margin-bottom: 10px !important;
flex-direction: column;
gap: var(--tk-gap-md);
}
.task-card__header { &__header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: flex-start;
margin-bottom: var(--tk-gap-sm); margin-bottom: 8px;
} }
.task-card__type { &__top-left {
font-size: var(--tk-font-body-lg); flex: 1;
font-weight: 600; min-width: 0;
color: $tx; }
}
.task-card__patient { &__name-row {
font-size: var(--tk-font-h1); display: flex;
color: $tx2; align-items: center;
display: block; gap: 8px;
margin-bottom: var(--tk-gap-xs); margin-bottom: 4px;
} }
.task-card__footer { &__patient {
display: flex; font-size: 16px;
justify-content: space-between; font-weight: 600;
} color: $tx;
}
.task-card__date { &__status-tag {
font-size: var(--tk-font-h2); display: inline-block;
color: $tx3; 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 { useState, useEffect, useCallback, useRef } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro'; import Taro from '@tarojs/taro';
import { safeNavigateTo } from '@/utils/navigate'; import { safeNavigateTo } from '@/utils/navigate';
import { usePageData } from '@/hooks/usePageData'; import { usePageData } from '@/hooks/usePageData';
import { listFollowUpTasks, type FollowUpTask } from '@/services/doctor/followup'; 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 ContentCard from '@/components/ui/ContentCard';
import StatusTag from '@/components/ui/StatusTag';
import LoadingCard from '@/components/ui/LoadingCard'; import LoadingCard from '@/components/ui/LoadingCard';
import SearchSection from '@/components/patterns/SearchSection';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import EmptyState from '@/components/EmptyState'; import EmptyState from '@/components/EmptyState';
import { useDoctorClass } from '@/hooks/useDoctorClass'; import { useDoctorClass } from '@/hooks/useDoctorClass';
import './index.scss'; import './index.scss';
const TABS = [ const FILTER_TABS = ['待随访', '已完成', '已过期'];
{ key: '', label: '全部' },
{ key: 'pending', label: '待处理' },
{ key: 'in_progress', label: '进行中' },
{ key: 'completed', label: '已完成' },
{ key: 'overdue', label: '已逾期' },
];
const STATUS_COLOR_MAP: Record<string, 'warning' | 'info' | 'success' | 'error'> = { const FILTER_MAP: Record<number, string | undefined> = {
pending: 'warning', 0: 'pending',
in_progress: 'info', 1: 'completed',
completed: 'success', 2: 'overdue',
overdue: 'error', };
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() { export default function FollowUpList() {
const router = useRouter();
const patientId = router.params.patientId || '';
const modeClass = useDoctorClass(); const modeClass = useDoctorClass();
const [tasks, setTasks] = useState<FollowUpTask[]>([]); const [tasks, setTasks] = useState<FollowUpTask[]>([]);
const [activeTab, setActiveTab] = useState(''); const [activeFilter, setActiveFilter] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [total, setTotal] = useState(0); const [, setTotal] = useState(0);
const mountedRef = useRef(false); const mountedRef = useRef(false);
const loadTasks = useCallback(async () => { const loadTasks = useCallback(async () => {
@@ -47,8 +44,7 @@ export default function FollowUpList() {
const res = await listFollowUpTasks({ const res = await listFollowUpTasks({
page: 1, page: 1,
page_size: 50, page_size: 50,
status: activeTab || undefined, status: FILTER_MAP[activeFilter],
patient_id: patientId || undefined,
}); });
setTasks(res.data || []); setTasks(res.data || []);
setTotal(res.total || 0); setTotal(res.total || 0);
@@ -58,7 +54,7 @@ export default function FollowUpList() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [activeTab, patientId]); }, [activeFilter]);
const { trigger } = usePageData(loadTasks); const { trigger } = usePageData(loadTasks);
@@ -67,10 +63,15 @@ export default function FollowUpList() {
trigger(); trigger();
} }
mountedRef.current = true; mountedRef.current = true;
}, [activeTab, patientId, trigger]); }, [activeFilter, trigger]);
const handleFilterChange = (index: number) => {
setActiveFilter(index);
};
const formatDate = (dateStr: string) => { 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) => { const getTypeLabel = (type: string) => {
@@ -83,48 +84,72 @@ export default function FollowUpList() {
return map[type] || type; 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 (loading && tasks.length === 0) return <LoadingCard count={3} />;
if (error) return <ErrorState onRetry={loadTasks} />; if (error) return <ErrorState onRetry={loadTasks} />;
return ( return (
<PageShell safeBottom className={modeClass}> <View className={`followup-page ${modeClass}`}>
<SearchSection <PageHeader title="随访管理" showBack />
value=""
onChange={() => {}}
filters={TABS}
activeFilter={activeTab}
onFilterChange={(key) => setActiveTab(key)}
/>
<View className="task-count"> <View className="followup-page__filter">
<Text> {total} </Text> <TabFilter
tabs={FILTER_TABS}
activeIndex={activeFilter}
onChange={handleFilterChange}
variant="pill"
/>
</View> </View>
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<EmptyState text="暂无随访任务" /> <EmptyState text="暂无随访任务" />
) : ( ) : (
<View className="task-list"> <ScrollView scrollY className="followup-page__list">
{tasks.map((task) => ( {tasks.map((task) => {
<ContentCard const statusStyle = STATUS_STYLE[task.status] || STATUS_STYLE.pending;
key={task.id} return (
onPress={() => safeNavigateTo(`/pages/pkg-doctor-core/followup/detail/index?id=${task.id}`)} <ContentCard
> key={task.id}
<View className="task-card__header"> className="followup-card"
<Text className="task-card__type">{getTypeLabel(task.follow_up_type)}</Text> activeFeedback="opacity"
<StatusTag onPress={() => safeNavigateTo(`/pages/pkg-doctor-core/followup/detail/index?id=${task.id}`)}
status={task.status} >
colorMap={STATUS_COLOR_MAP} {/* 上部:患者名 + 标签 | 日期 */}
size="sm" <View className="followup-card__header">
/> <View className="followup-card__top-left">
</View> <View className="followup-card__name-row">
<Text className="task-card__patient">{task.patient_name || '未知患者'}</Text> <Text className="followup-card__patient">{task.patient_name || '未知患者'}</Text>
<View className="task-card__footer"> <Text
<Text className="task-card__date">: {formatDate(task.planned_date)}</Text> className="followup-card__status-tag"
</View> style={{ color: statusStyle.color, background: statusStyle.bg }}
</ContentCard> >
))} {getStatusLabel(task.status)}
</View> </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/variables.scss';
@import '../../styles/mixins.scss'; @import '../../styles/mixins.scss';
// PageShell 已接管min-height, background, padding
.doctor-home { .doctor-home {
height: 100vh;
background: $bg;
display: flex;
flex-direction: column;
&__scroll {
flex: 1;
}
&__content {
padding: 16px 20px 80px;
}
// ── 头部(对齐原型)──
&__header { &__header {
margin-bottom: var(--tk-gap-2xl); margin-bottom: 16px;
} }
&__title { &__title {
@include section-title; font-family: Georgia, 'Times New Roman', serif;
margin-bottom: var(--tk-gap-sm); font-size: 26px;
}
&__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-weight: 700; font-weight: 700;
color: $tx; color: $tx;
display: block; display: block;
margin-bottom: var(--tk-gap-xs); margin-bottom: 4px;
} }
&__card-label { &__date {
font-size: var(--tk-font-h2); font-size: 14px;
color: $tx3;
}
// ── 小节标题对齐原型13px fontWeight600──
&__section-label {
display: block;
font-size: 13px;
font-weight: 600;
color: $tx2; color: $tx2;
margin-bottom: 14px;
font-family: -apple-system, 'PingFang SC', sans-serif;
} }
&__quick-actions { // ── 今日概览统计网格(对齐原型:子卡片有 bg 背景)──
&__stat-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: 1fr 1fr;
gap: var(--tk-section-gap); gap: 12px;
} }
&__footer { &__stat-item {
margin-top: 60px; background: $bg;
border-radius: $r-sm;
padding: 14px 12px;
text-align: center; text-align: center;
padding-bottom: env(safe-area-inset-bottom);
} }
&__logout { &__stat-value {
color: $dan; font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-h2); font-size: 28px;
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);
font-weight: 700; font-weight: 700;
line-height: 1.1;
&--wrn { color: $wrn; }
&--pri { color: $doc-pri; }
&--acc { color: $acc; }
&--dan { color: $dan; }
} }
&__icon-wrap { &__stat-label {
position: relative; font-size: 12px;
display: inline-flex; color: $tx3;
margin-bottom: var(--tk-gap-xs); margin-top: 4px;
}
&__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;
display: block; 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 { useState, useMemo, useCallback } from 'react';
import { View, Text, Input } from '@tarojs/components'; import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { safeNavigateTo } from '@/utils/navigate'; import { safeNavigateTo } from '@/utils/navigate';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useDoctorClass } from '@/hooks/useDoctorClass'; import { useDoctorClass } from '@/hooks/useDoctorClass';
import { usePageData } from '@/hooks/usePageData'; import { usePageData } from '@/hooks/usePageData';
import { getDashboard, type DoctorDashboard } from '@/services/doctor/dashboard'; import { getDashboard, type DoctorDashboard } from '@/services/doctor/dashboard';
import Loading from '@/components/Loading'; 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'; import './index.scss';
interface CardConfig { interface StatItem {
key: keyof DoctorDashboard; key: keyof DoctorDashboard;
label: string; label: string;
initial: string; color: string;
}
interface ShortcutItem {
icon: string;
label: string;
color: 'pri' | 'acc' | 'wrn' | 'dan';
route: string; route: string;
roles?: string[]; roles?: string[];
} }
const ALL_CARDS: CardConfig[] = [ const STATS: StatItem[] = [
{ key: 'total_patients', label: '我的患者', initial: '患', route: '/pages/pkg-doctor-core/patients/index' }, { key: 'pending_follow_ups', label: '待处理', color: 'wrn' },
{ key: 'unread_messages', label: '未读消息', initial: '消', route: '/pages/pkg-doctor-core/consultation/index' }, { key: 'today_consultations', label: '咨询中', color: 'pri' },
{ key: 'pending_follow_ups', label: '待处理随访', initial: '随', route: '/pages/pkg-doctor-core/followup/index', roles: ['doctor', 'nurse', 'health_manager'] }, { key: 'today_appointments', label: '今日患者', color: 'acc' },
{ key: 'today_consultations', label: '今日咨询', initial: '诊', route: '/pages/pkg-doctor-core/consultation/index', roles: ['doctor', 'health_manager'] }, { key: 'unread_messages', label: '随访到期', color: 'dan' },
]; ];
const ALL_HEALTH_CARDS: CardConfig[] = [ const SHORTCUTS: ShortcutItem[] = [
{ key: 'pending_lab_review', label: '待审化验', initial: '', route: '/pages/pkg-doctor-clinical/report/index', roles: ['doctor'] }, { icon: '👤', label: '患者管理', color: 'pri', route: '/pages/pkg-doctor-core/patients/index' },
{ key: 'today_appointments', label: '今日预约', initial: '', 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() { export default function DoctorHome() {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const roles = useAuthStore((s) => s.roles); const roles = useAuthStore((s) => s.roles);
const modeClass = useDoctorClass(); const modeClass = useDoctorClass();
const [dashboard, setDashboard] = useState<DoctorDashboard | null>(null); const [dashboard, setDashboard] = useState<DoctorDashboard | null>(null);
const [alertCount, setAlertCount] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const hasRole = (allowed: string[] | undefined) => { const hasRole = (allowed: string[] | undefined) => {
@@ -69,23 +50,14 @@ export default function DoctorHome() {
return roles.some((r) => r === 'admin' || allowed.includes(r)); return roles.some((r) => r === 'admin' || allowed.includes(r));
}; };
const cards = useMemo(() => ALL_CARDS.filter((c) => hasRole(c.roles)), [roles]); const shortcuts = useMemo(() => SHORTCUTS.filter((s) => hasRole(s.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 loadDashboard = useCallback(async () => { const loadDashboard = useCallback(async () => {
try { try {
const data = await getDashboard(); const data = await getDashboard();
setDashboard(data); setDashboard(data);
const count = (data as Record<string, unknown>)?.abnormal_vital_count;
setAlertCount(typeof count === 'number' ? count : 0);
} catch { } catch {
// 静默失败,显示占位 // 静默失败
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -93,107 +65,78 @@ export default function DoctorHome() {
usePageData(loadDashboard, { throttleMs: 10000 }); usePageData(loadDashboard, { throttleMs: 10000 });
const handleCardClick = (card: CardConfig) => {
safeNavigateTo(card.route);
};
const handleLogout = () => {
logout();
};
const getValue = (key: keyof DoctorDashboard): number | string => { const getValue = (key: keyof DoctorDashboard): number | string => {
if (!dashboard) return '-'; if (!dashboard) return '-';
return dashboard[key] ?? 0; return dashboard[key] ?? 0;
}; };
const today = new Date();
const dateStr = `${today.getFullYear()}${today.getMonth() + 1}${today.getDate()}${
['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'][today.getDay()]
}`;
if (loading) return <Loading />; if (loading) return <Loading />;
return ( return (
<PageShell safeBottom={false} className={`doctor-home ${modeClass}`}> <View className={`doctor-home ${modeClass}`}>
<View className='doctor-home__header'> <ScrollView scrollY className="doctor-home__scroll">
<Text className='doctor-home__title'></Text> <View className="doctor-home__content">
<Text className='doctor-home__greeting'> {/* 问候区 — 对齐原型:标题 + 日期 */}
{user?.display_name || user?.username || roleLabel} <View className="doctor-home__header">
</Text> <Text className="doctor-home__title"></Text>
<Text className='doctor-home__date'> <Text className="doctor-home__date">{dateStr}</Text>
{new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' })} </View>
</Text>
</View>
{alertCount > 0 && ( {/* 今日概览 — 原型:卡片内含子网格 */}
<View className='doctor-home__alert'> <ContentCard padding="md" margin="md">
<Text className='doctor-home__alert-icon'>!</Text> <Text className="doctor-home__section-label"></Text>
<Text className='doctor-home__alert-text'>{alertCount} </Text> <View className="doctor-home__stat-grid">
<Text className='doctor-home__alert-link' onClick={() => safeNavigateTo('/pages/pkg-doctor-clinical/alerts/index')}> </Text> {STATS.map((stat) => (
</View> <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)}
<View className='doctor-home__search'> </Text>
<Input <Text className="doctor-home__stat-label">{stat.label}</Text>
className='doctor-home__search-input' </View>
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>
</View> </View>
))} </ContentCard>
</View>
</View>
{healthCards.length > 0 && (<View className='doctor-home__section'> {/* 快捷操作 — 原型space-between 均分 */}
<Text className='doctor-home__section-title'></Text> <View className="doctor-home__shortcuts">
<View className='doctor-home__grid'> {shortcuts.map((item) => (
{healthCards.map((card) => ( <ShortcutButton
<View key={item.route}
key={card.key} icon={item.icon}
className='doctor-home__card' label={item.label}
onClick={() => handleCardClick(card)} color={item.color}
> onPress={() => safeNavigateTo(item.route)}
<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>
</View>)}
<View className='doctor-home__section'> {/* 待办提醒 — 原型:无 SectionTitle直接小标题 + 警告卡片 */}
<Text className='doctor-home__section-title'></Text> <Text className="doctor-home__section-label"></Text>
<View className='doctor-home__quick-actions'> {dashboard && dashboard.pending_follow_ups > 0 && (
{quickActions.map((action) => ( <TodoAlert
<View icon="✓"
key={action.route} title={`${dashboard.pending_follow_ups} 位患者血压异常待处理`}
className='quick-action' subtitle="需要立即关注"
onClick={() => safeNavigateTo(action.route)} color="pri"
> onPress={() => safeNavigateTo('/pages/pkg-doctor-core/followup/index')}
<View className='quick-action__icon-wrap'> />
<Text className='quick-action__initial'>{action.initial}</Text> )}
{action.label === '告警中心' && alertCount > 0 && ( {dashboard && dashboard.today_consultations > 0 && (
<Text className='quick-action__badge'>{alertCount > 99 ? '99+' : alertCount}</Text> <TodoAlert
)} icon="!"
</View> title={`${dashboard.today_consultations} 份随访报告待审核`}
<Text className='quick-action__label'>{action.label}</Text> subtitle="截止今日 18:00"
</View> color="wrn"
))} onPress={() => safeNavigateTo('/pages/pkg-doctor-core/consultation/index')}
/>
)}
</View> </View>
</View> </ScrollView>
</View>
<View className='doctor-home__footer'>
<Text className='doctor-home__logout' onClick={handleLogout}>退</Text>
</View>
</PageShell>
); );
} }

View File

@@ -1,66 +1,123 @@
@import '../../../styles/variables.scss'; @import '../../../styles/variables.scss';
// PageShell 已接管min-height, background, padding .patient-page {
// SearchSection 已接管search-bar height: 100vh;
// ContentCard 已接管patient-card 背景/圆角/阴影/触摸反馈 background: $bg;
// StatusTag 已接管patient-card__status 标签样式
.patient-count {
margin-bottom: var(--tk-gap-md);
text {
font-size: var(--tk-font-h2);
color: $tx3;
}
}
.patient-cards {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--tk-gap-md);
}
.patient-card__header { &__search {
display: flex; padding: 12px 20px;
align-items: center; }
gap: var(--tk-gap-md);
}
.patient-card__name { &__count {
font-size: var(--tk-font-num); padding: 0 20px;
font-weight: 600; margin-bottom: 12px;
color: $tx;
}
.patient-card__meta { text {
font-size: var(--tk-font-h2); font-size: 13px;
color: $tx2; color: $tx3;
flex: 1; }
} }
.patient-card__tags { &__list {
display: flex; flex: 1;
flex-wrap: wrap; padding: 0 20px 20px;
gap: var(--tk-gap-xs); }
margin-top: var(--tk-gap-sm);
}
.patient-tag { &__hint {
padding: var(--tk-gap-2xs) 14px; text-align: center;
border-radius: $r; padding: 20px;
background: rgba($pri, 0.1);
&__text { text {
font-size: var(--tk-font-body); font-size: 13px;
color: #78716C;
}
} }
} }
.load-more-hint-wrap { // ── 搜索栏(对齐原型 §3.3 SearchBar──
text-align: center; .search-bar {
padding: var(--tk-section-gap); 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 { // ── 患者卡片(对齐原型:诊断 $doc-pri 色 + 最近访问日期)──
font-size: var(--tk-font-h2); .patient-card {
color: $tx3; 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 { 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 Taro, { useReachBottom } from '@tarojs/taro';
import { safeNavigateTo } from '@/utils/navigate'; import { safeNavigateTo } from '@/utils/navigate';
import { usePageData } from '@/hooks/usePageData'; import { usePageData } from '@/hooks/usePageData';
import { listPatients, listPatientTags, type PatientItem, type PatientTag } from '@/services/doctor/patient'; import { listPatients, type PatientItem } from '@/services/doctor/patient';
import PageShell from '@/components/ui/PageShell'; import PageHeader from '@/components/patterns/PageHeader';
import ContentCard from '@/components/ui/ContentCard'; 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 LoadingCard from '@/components/ui/LoadingCard';
import SearchSection from '@/components/patterns/SearchSection';
import EmptyState from '@/components/EmptyState'; import EmptyState from '@/components/EmptyState';
import Loading from '@/components/Loading'; import Loading from '@/components/Loading';
import { useDoctorClass } from '@/hooks/useDoctorClass'; import { useDoctorClass } from '@/hooks/useDoctorClass';
import './index.scss'; import './index.scss';
const AVATAR_COLORS: Array<'pri' | 'acc' | 'wrn' | 'dan'> = ['pri', 'acc', 'wrn', 'dan'];
export default function PatientList() { export default function PatientList() {
const modeClass = useDoctorClass(); const modeClass = useDoctorClass();
const [patients, setPatients] = useState<PatientItem[]>([]); const [patients, setPatients] = useState<PatientItem[]>([]);
const [tags, setTags] = useState<PatientTag[]>([]);
const [activeTag, setActiveTag] = useState<string>('');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const mountedRef = useRef(false); const mountedRef = useRef(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => { loadTags(); }, []);
const loadTags = async () => {
try {
const res = await listPatientTags();
setTags(res.data || []);
} catch { /* ignore */ }
};
const loadPatients = useCallback(async (pageNum: number, isRefresh = false) => { const loadPatients = useCallback(async (pageNum: number, isRefresh = false) => {
if (isRefresh) setLoading(true); if (isRefresh) setLoading(true);
@@ -41,7 +32,6 @@ export default function PatientList() {
page: pageNum, page: pageNum,
page_size: 20, page_size: 20,
search: search || undefined, search: search || undefined,
tag_id: activeTag || undefined,
}); });
const list = res.data || []; const list = res.data || [];
setPatients(prev => isRefresh ? list : [...prev, ...list]); setPatients(prev => isRefresh ? list : [...prev, ...list]);
@@ -52,7 +42,7 @@ export default function PatientList() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [search, activeTag]); }, [search]);
usePageData( usePageData(
useCallback(() => loadPatients(1, true), [loadPatients]), useCallback(() => loadPatients(1, true), [loadPatients]),
@@ -62,16 +52,22 @@ export default function PatientList() {
useEffect(() => { useEffect(() => {
if (mountedRef.current) { loadPatients(1, true); } if (mountedRef.current) { loadPatients(1, true); }
mountedRef.current = true; mountedRef.current = true;
}, [activeTag, loadPatients]); }, [search, loadPatients]);
useReachBottom(() => { useReachBottom(() => {
if (!loading && patients.length < total) { loadPatients(page + 1); } if (!loading && patients.length < total) { loadPatients(page + 1); }
}); });
const handleTagFilter = (tagId: string) => { const handleSearchInput = (val: string) => {
setActiveTag(tagId === activeTag ? '' : tagId); 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) => { const getGenderLabel = (gender?: string) => {
if (!gender) return ''; if (!gender) return '';
return gender === 'male' ? '男' : gender === 'female' ? '女' : gender; return gender === 'male' ? '男' : gender === 'female' ? '女' : gender;
@@ -89,69 +85,90 @@ export default function PatientList() {
return `${age}`; return `${age}`;
}; };
const filters = [ const formatLastVisit = (dateStr?: string) => {
{ key: '', label: '全部' }, if (!dateStr) return '';
...tags.map(t => ({ key: t.id, label: t.name })), 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} />; if (loading && patients.length === 0) return <LoadingCard count={3} />;
return ( return (
<PageShell safeBottom className={modeClass}> <View className={`patient-page ${modeClass}`}>
<SearchSection <PageHeader title="患者管理" showBack />
value={search}
onChange={setSearch}
onSearch={() => loadPatients(1, true)}
placeholder="搜索患者姓名/手机号"
filters={filters}
activeFilter={activeTag}
onFilterChange={handleTagFilter}
/>
<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> <Text> {total} </Text>
</View> </View>
{patients.length === 0 ? ( {patients.length === 0 ? (
<EmptyState text="暂无患者数据" /> <EmptyState text="暂无患者数据" />
) : ( ) : (
<View className="patient-cards"> <ScrollView scrollY className="patient-page__list">
{patients.map((p) => ( {patients.map((p, idx) => {
<ContentCard const diagnosis = getDiagnosis(p);
key={p.id} return (
onPress={() => safeNavigateTo(`/pages/pkg-doctor-core/patients/detail/index?id=${p.id}`)} <ContentCard
> key={p.id}
<View className="patient-card__header"> className="patient-card"
<Text className="patient-card__name">{p.name}</Text> activeFeedback="opacity"
<Text className="patient-card__meta"> onPress={() => safeNavigateTo(`/pages/pkg-doctor-core/patients/detail/index?id=${p.id}`)}
{getGenderLabel(p.gender)} {calcAge(p.birth_date)} >
</Text> <View className="patient-card__row">
{p.status && <StatusTag status={p.status} size="sm" />} <AvatarCircle
</View> name={p.name}
{p.tags && p.tags.length > 0 && ( size={46}
<View className="patient-card__tags"> color={getAvatarColor(idx)}
{p.tags.map((t) => ( />
<View <View className="patient-card__body">
key={t.id} <View className="patient-card__top">
className="patient-tag" <Text className="patient-card__name">{p.name}</Text>
style={t.color ? `background: ${t.color}20; color: ${t.color}` : ''} <Text className="patient-card__meta">
> {calcAge(p.birth_date)} · {getGenderLabel(p.gender)}
<Text className="patient-tag__text">{t.name}</Text> </Text>
</View> </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> </View>
)} </ContentCard>
</ContentCard> );
))} })}
</View> {!loading && patients.length >= total && total > 0 && (
<View className="patient-page__hint">
<Text></Text>
</View>
)}
{loading && patients.length > 0 && <Loading />}
</ScrollView>
)} )}
</View>
{!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 File

@@ -1,200 +1,201 @@
@import '../../../styles/variables.scss'; @import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss'; @import '../../../styles/mixins.scss';
// PageShell 已接管min-height, background, padding // 兑换确认 — 对齐原型 docs/design/mp-10-mall-pkg.html → 屏幕2
.exchange-page { .exchange-page {
padding-bottom: 140px; padding-bottom: var(--tk-gap-xl);
} }
/* ===== 商品预览 ===== */ // 商品预览卡片
.product-card { .exchange-product-card {
display: flex; display: flex;
align-items: center; gap: var(--tk-gap-sm);
padding: var(--tk-gap-xl) var(--tk-gap-lg); padding: 14px;
background: $card; background: $card;
margin: var(--tk-section-gap) var(--tk-gap-lg) var(--tk-gap-md); border-radius: $r;
border-radius: $r-lg; margin-bottom: var(--tk-gap-md);
box-shadow: $shadow-sm; box-shadow: $shadow-sm;
} }
.product-icon-wrap { .exchange-product-icon {
width: 128px; width: 72px;
height: 128px; height: 72px;
border-radius: $r; border-radius: $r-sm;
@include flex-center; display: flex;
margin-right: var(--tk-gap-lg); align-items: center;
justify-content: center;
flex-shrink: 0; flex-shrink: 0;
&--physical { &--physical { background: $pri-l; }
background: $acc; &--service { background: $acc-l; }
} &--privilege { background: $wrn-l; }
&--service {
background: var(--tk-pri);
}
&--privilege {
background: var(--tk-pri-d);
}
} }
.product-icon-char { .exchange-product-icon-char {
font-family: 'Georgia', 'Times New Roman', serif; font-size: 24px;
font-size: var(--tk-font-hero); font-weight: 700;
font-weight: bold; color: $pri;
color: $white;
.exchange-product-icon--service & { color: $acc; }
.exchange-product-icon--privilege & { color: $wrn; }
} }
.product-meta { .exchange-product-meta {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.product-name { .exchange-product-name {
font-size: var(--tk-font-num); font-family: 'Georgia', 'Times New Roman', serif;
font-weight: bold; font-size: 15px;
font-weight: 700;
color: $tx; color: $tx;
display: block; display: block;
margin-bottom: var(--tk-gap-sm); margin-bottom: 4px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.product-type-tag { .exchange-product-points {
@include tag(var(--tk-pri-l), var(--tk-pri-d)); font-size: var(--tk-font-cap);
color: $pri;
font-weight: 600;
display: block;
} }
/* ===== 兑换明细 ===== */ .exchange-product-qty {
.detail-section { font-size: var(--tk-font-micro);
padding: 0 var(--tk-gap-lg); color: $tx3;
margin-bottom: var(--tk-gap-md); margin-top: 2px;
display: block;
} }
.detail-section-title { // 收货信息卡片
@include section-title; .exchange-address-card {
}
.detail-card {
background: $card; background: $card;
border-radius: $r; border-radius: $r;
padding: var(--tk-gap-md);
margin-bottom: var(--tk-gap-md);
box-shadow: $shadow-sm; box-shadow: $shadow-sm;
padding: 0 var(--tk-gap-lg);
} }
.detail-row { .exchange-address-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: var(--tk-gap-lg) 0; margin-bottom: 10px;
border-bottom: 1px solid $bd-l;
&.last {
border-bottom: none;
}
} }
.detail-label { .exchange-address-title {
font-size: var(--tk-font-body-lg); font-size: var(--tk-font-body-sm);
color: $tx2; font-weight: 600;
}
.detail-value {
@include serif-number;
font-size: var(--tk-font-body-lg);
color: $tx; 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;
}
} }
/* ===== 温馨提示 ===== */ .exchange-address-edit {
.notice-section { 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; background: $card;
padding: var(--tk-gap-lg);
margin: 0 var(--tk-gap-lg);
border-radius: $r; border-radius: $r;
padding: var(--tk-gap-md);
margin-bottom: var(--tk-gap-lg);
box-shadow: $shadow-sm; box-shadow: $shadow-sm;
} }
.notice-title { .exchange-detail-title {
@include section-title; font-size: var(--tk-font-body-sm);
font-size: var(--tk-font-body-lg); font-weight: 600;
color: $tx;
display: block;
margin-bottom: var(--tk-gap-sm); margin-bottom: var(--tk-gap-sm);
} }
.notice-text { .exchange-detail-row {
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;
display: flex; display: flex;
align-items: center; justify-content: space-between;
padding: var(--tk-gap-md) var(--tk-gap-lg); padding-bottom: 8px;
padding-bottom: calc(var(--tk-gap-md) + env(safe-area-inset-bottom)); margin-bottom: 8px;
background: $card; border-bottom: 1px dashed $bd-l;
box-shadow: 0 -2px 12px rgba($tx, 0.06);
z-index: 10;
}
.footer-cost { &.last {
flex: 1; border-bottom: none;
display: flex; padding-bottom: 0;
flex-direction: column; margin-bottom: 0;
}
.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;
} }
} }
.confirm-btn-text { .exchange-detail-label {
font-size: var(--tk-font-num); font-size: var(--tk-font-cap);
color: $white; color: $tx3;
font-weight: bold; }
.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 Taro from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData'; import { usePageData } from '@/hooks/usePageData';
import { import {
getProduct,
listProducts, listProducts,
exchangeProduct, exchangeProduct,
} from '../../../services/points'; } from '../../../services/points';
import type { PointsProduct } from '../../../services/points'; import type { PointsProduct } from '../../../services/points';
import { usePointsStore } from '../../../stores/points'; import { usePointsStore } from '../../../stores/points';
import { useAuthStore } from '../../../stores/auth';
import Loading from '../../../components/Loading'; import Loading from '../../../components/Loading';
import { useElderClass } from '../../../hooks/useElderClass'; import { useElderClass } from '../../../hooks/useElderClass';
import { useSafeTimeout } from '@/hooks/useSafeTimeout'; import { useSafeTimeout } from '@/hooks/useSafeTimeout';
import PageShell from '@/components/ui/PageShell'; import PageShell from '@/components/ui/PageShell';
import './index.scss'; import './index.scss';
const TYPE_INITIAL: Record<string, string> = { const TYPE_CHAR: Record<string, string> = {
physical: '物', physical: '物',
service: '券', service: '券',
privilege: '权', privilege: '权',
}; };
const TYPE_LABEL: Record<string, string> = {
physical: '实物商品',
service: '服务券',
privilege: '权益卡',
};
const TYPE_CLASS: Record<string, string> = { const TYPE_CLASS: Record<string, string> = {
physical: 'product-icon-wrap--physical', physical: 'physical',
service: 'product-icon-wrap--service', service: 'service',
privilege: 'product-icon-wrap--privilege', privilege: 'privilege',
}; };
export default function ExchangeConfirm() { export default function ExchangeConfirm() {
@@ -37,6 +33,7 @@ export default function ExchangeConfirm() {
const [product, setProduct] = useState<PointsProduct | null>(null); const [product, setProduct] = useState<PointsProduct | null>(null);
const account = usePointsStore((s) => s.account); const account = usePointsStore((s) => s.account);
const refreshPoints = usePointsStore((s) => s.refresh); const refreshPoints = usePointsStore((s) => s.refresh);
const currentPatient = useAuthStore((s) => s.currentPatient);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const { safeSetTimeout } = useSafeTimeout(); const { safeSetTimeout } = useSafeTimeout();
@@ -52,17 +49,21 @@ export default function ExchangeConfirm() {
setLoading(true); setLoading(true);
try { try {
const [productRes] = await Promise.all([ // 先尝试单商品接口,降级到列表查找
listProducts({ page: 1, page_size: 100 }), let found: PointsProduct | null = null;
refreshPoints(), try {
]); found = await getProduct(productId);
const found = productRes.data.find((p) => p.id === productId); } catch {
const productRes = await listProducts({ page: 1, page_size: 100 });
found = productRes.data.find((p) => p.id === productId) || null;
}
if (!found) { if (!found) {
Taro.showToast({ title: '商品不存在', icon: 'none' }); Taro.showToast({ title: '商品不存在', icon: 'none' });
safeSetTimeout(() => Taro.navigateBack(), 1500); safeSetTimeout(() => Taro.navigateBack(), 1500);
return; return;
} }
setProduct(found); setProduct(found);
await refreshPoints();
} catch { } catch {
Taro.showToast({ title: '加载失败', icon: 'none' }); Taro.showToast({ title: '加载失败', icon: 'none' });
safeSetTimeout(() => Taro.navigateBack(), 1500); safeSetTimeout(() => Taro.navigateBack(), 1500);
@@ -82,6 +83,11 @@ export default function ExchangeConfirm() {
const balance = account?.balance ?? 0; const balance = account?.balance ?? 0;
const cost = product?.points_cost ?? 0; const cost = product?.points_cost ?? 0;
const insufficient = balance < cost; 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 () => { const handleConfirm = useCallback(async () => {
if (!product || submitting) return; if (!product || submitting) return;
@@ -103,17 +109,19 @@ export default function ExchangeConfirm() {
Taro.showToast({ title: '兑换成功', icon: 'success', duration: 2000 }); Taro.showToast({ title: '兑换成功', icon: 'success', duration: 2000 });
safeSetTimeout(() => { safeSetTimeout(() => {
Taro.showModal({ if (isService && order.qr_code) {
title: '兑换成功', Taro.showModal({
content: `核销码: ${order.qr_code}\n请凭此码到前台核销`, title: '兑换成功',
showCancel: false, content: `核销码: ${order.qr_code}\n请凭此码到前台核销`,
confirmText: '查看订单', showCancel: false,
success: () => { confirmText: '查看订单',
Taro.redirectTo({ success: () => {
url: `/pages/pkg-mall/orders/index`, Taro.redirectTo({ url: '/pages/pkg-mall/orders/index' });
}); },
}, });
}); } else {
Taro.redirectTo({ url: '/pages/pkg-mall/orders/index' });
}
}, 2000); }, 2000);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : '兑换失败'; const msg = err instanceof Error ? err.message : '兑换失败';
@@ -125,7 +133,7 @@ export default function ExchangeConfirm() {
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}, [product, submitting, insufficient, cost]); }, [product, submitting, insufficient, cost, isService]);
if (loading) { if (loading) {
return ( 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 ( return (
<PageShell className={modeClass}> <PageShell padding="md" safeBottom={false} scroll={false} className={`exchange-page ${modeClass}`}>
{/* 商品预览卡片 */} {/* 商品预览卡片 */}
<View className='product-card'> <View className='exchange-product-card'>
<View className={`product-icon-wrap ${iconCls}`}> <View className={`exchange-product-icon exchange-product-icon--${typeCls}`}>
<Text className='product-icon-char'>{initial}</Text> <Text className='exchange-product-icon-char'>{typeChar}</Text>
</View> </View>
<View className='product-meta'> <View className='exchange-product-meta'>
<Text className='product-name'>{product?.name || ''}</Text> <Text className='exchange-product-name'>{product?.name || ''}</Text>
<Text className='product-type-tag'>{typeLabel}</Text> <Text className='exchange-product-points'>{cost.toLocaleString()} </Text>
<Text className='exchange-product-qty'>×1</Text>
</View> </View>
</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'> <View className='exchange-detail-card'>
<Text className='detail-section-title'></Text> <Text className='exchange-detail-title'></Text>
<View className='detail-card'> <View className='exchange-detail-row'>
<View className='detail-row'> <Text className='exchange-detail-label'></Text>
<Text className='detail-label'></Text> <Text className='exchange-detail-value'>{cost.toLocaleString()}</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> </View>
</View> <View className='exchange-detail-row'>
<Text className='exchange-detail-label'>{isService ? '核销方式' : '运费'}</Text>
{/* 温馨提示 */} <Text className='exchange-detail-value'>{isService ? '到院核销' : '¥0.00'}</Text>
<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> </View>
<View <View className='exchange-detail-row'>
className={`confirm-btn ${insufficient || (product?.stock ?? 0) <= 0 || submitting ? 'disabled' : ''}`} <Text className='exchange-detail-label'></Text>
onClick={insufficient || (product?.stock ?? 0) <= 0 || submitting ? undefined : handleConfirm} <Text className='exchange-detail-value exchange-detail-cost'>{cost.toLocaleString()}</Text>
> </View>
<Text className='confirm-btn-text'> <View className='exchange-detail-row last'>
{submitting <Text className='exchange-detail-label'></Text>
? '兑换中...' <Text className={`exchange-detail-value ${remaining >= 0 ? 'sufficient' : 'insufficient'}`}>
: insufficient {remaining.toLocaleString()}
? '积分不足'
: (product?.stock ?? 0) <= 0
? '已兑完'
: '确认兑换'}
</Text> </Text>
</View> </View>
</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> </PageShell>
); );
} }

View File

@@ -1,114 +1,184 @@
@import '../../../styles/variables.scss'; @import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss'; @import '../../../styles/mixins.scss';
// PageShell 已接管min-height, background, safe-bottom // 订单列表 — 对齐原型 docs/design/mp-10-mall-pkg.html → 屏幕3
// ContentCard 已接管order-card 背景/圆角/阴影
/* ===== 订单列表 ===== */ .orders-page {
.order-list { padding-bottom: env(safe-area-inset-bottom);
padding: 0 var(--tk-gap-lg);
} }
// 状态筛选 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 { .order-card {
margin-bottom: var(--tk-gap-md); padding: var(--tk-gap-md);
overflow: hidden; background: $card;
border-radius: $r;
box-shadow: $shadow-sm;
} }
.order-header { .order-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: var(--tk-gap-lg) var(--tk-gap-lg) var(--tk-gap-md); margin-bottom: 10px;
border-bottom: 1px solid $bd-l;
} }
.order-product { .order-id {
font-family: 'Georgia', 'Times New Roman', serif; font-size: var(--tk-font-micro);
font-size: var(--tk-font-body-lg); color: $tx3;
font-weight: bold; }
color: $tx;
// 状态标签
.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; 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; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.order-status-tag { .order-date {
padding: var(--tk-gap-2xs) var(--tk-gap-md); font-size: var(--tk-font-micro);
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);
color: $tx3; 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; @include serif-number;
font-size: var(--tk-font-h1); font-size: var(--tk-font-body);
color: $tx; font-weight: 700;
color: $pri;
&.order-cost {
color: var(--tk-pri);
font-weight: bold;
}
} }
/* ===== 核销码 ===== */ .order-points-unit {
font-size: var(--tk-font-micro);
color: $pri;
font-weight: 400;
}
// 核销码
.order-qrcode { .order-qrcode {
display: flex; display: flex;
align-items: center; align-items: center;
padding: var(--tk-gap-md); padding: var(--tk-gap-sm);
margin-top: var(--tk-gap-sm); margin-top: var(--tk-gap-sm);
background: var(--tk-pri-l); background: $pri-l;
border-radius: $r-sm; border-radius: $r-sm;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
} }
.qrcode-label { .qrcode-label {
font-size: var(--tk-font-h2); font-size: var(--tk-font-cap);
color: $tx3; color: $tx3;
margin-right: var(--tk-gap-xs); margin-right: var(--tk-gap-xs);
} }
.qrcode-value { .qrcode-value {
@include serif-number; @include serif-number;
font-size: var(--tk-font-h2); font-size: var(--tk-font-cap);
color: var(--tk-pri-d); color: $pri-d;
font-weight: bold; font-weight: 700;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -116,8 +186,7 @@
} }
.qrcode-tap { .qrcode-tap {
font-size: var(--tk-font-body); font-size: var(--tk-font-cap);
color: var(--tk-pri); color: $pri;
margin-left: var(--tk-gap-xs);
flex-shrink: 0; flex-shrink: 0;
} }

View File

@@ -7,21 +7,22 @@ import type { PointsOrder } from '../../../services/points';
import EmptyState from '../../../components/EmptyState'; import EmptyState from '../../../components/EmptyState';
import ErrorState from '../../../components/ErrorState'; import ErrorState from '../../../components/ErrorState';
import Loading from '../../../components/Loading'; 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 { useElderClass } from '../../../hooks/useElderClass';
import PageShell from '@/components/ui/PageShell';
import './index.scss'; import './index.scss';
const STATUS_TABS = [ const STATUS_TABS = [
{ key: '', label: '全部' }, { key: '', label: '全部' },
{ key: 'pending', label: '待核销' }, { key: 'pending', label: '待处理' },
{ key: 'verified', label: '已核销' }, { key: 'shipped', label: '已发货' },
{ key: 'expired', label: '已过期' }, { key: 'verified', label: '已完成' },
]; ];
const STATUS_CONFIG: Record<string, { label: string; cls: string }> = { 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' }, verified: { label: '已核销', cls: 'order-status-tag--verified' },
cancelled: { label: '已取消', cls: 'order-status-tag--cancelled' }, cancelled: { label: '已取消', cls: 'order-status-tag--cancelled' },
expired: { label: '已过期', cls: 'order-status-tag--expired' }, expired: { label: '已过期', cls: 'order-status-tag--expired' },
@@ -76,7 +77,7 @@ export default function MallOrders() {
usePageData( usePageData(
useCallback(async () => { useCallback(async () => {
Taro.setNavigationBarTitle({ title: '我的订单' }); Taro.setNavigationBarTitle({ title: '兑换记录' });
await loadAll(); await loadAll();
}, [loadAll]), }, [loadAll]),
{ throttleMs: 10000, enablePullDown: true }, { throttleMs: 10000, enablePullDown: true },
@@ -103,19 +104,29 @@ export default function MallOrders() {
}; };
const getStatusConfig = (status: string) => { 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) => { const formatDate = (dateStr: string) => {
if (!dateStr) return ''; if (!dateStr) return '';
const d = new Date(dateStr); 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 ( 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 ? ( {error ? (
@@ -133,30 +144,25 @@ export default function MallOrders() {
{orders.map((order) => { {orders.map((order) => {
const statusCfg = getStatusConfig(order.status); const statusCfg = getStatusConfig(order.status);
return ( return (
<ContentCard className='order-card' key={order.id}> <View className='order-card' key={order.id}>
<View className='order-header'> <View className='order-header'>
<Text className='order-product'> {order.product_id.slice(0, 8)}</Text> <Text className='order-id'> {order.id.slice(0, 12).toUpperCase()}</Text>
<View <View className={`order-status-tag ${statusCfg.cls}`}>
className={`order-status-tag ${statusCfg.cls}`}
>
<Text className='order-status-text'>{statusCfg.label}</Text> <Text className='order-status-text'>{statusCfg.label}</Text>
</View> </View>
</View> </View>
<View className='order-body'> <View className='order-body'>
<View className='order-row'> <View className='order-main'>
<Text className='order-row-label'></Text> <View className='order-product-info'>
<Text className='order-row-value order-cost'> <Text className='order-product-name'> {order.product_id.slice(0, 8)}</Text>
{order.points_cost.toLocaleString()} <Text className='order-date'>{formatDate(order.created_at)}</Text>
</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>
<View className='order-row'> {order.status === 'pending' && order.qr_code && (
<Text className='order-row-label'></Text>
<Text className='order-row-value'>
{formatDate(order.created_at)}
</Text>
</View>
{order.status === 'pending' && (
<View className='order-qrcode' onClick={() => handleShowQrCode(order.qr_code)}> <View className='order-qrcode' onClick={() => handleShowQrCode(order.qr_code)}>
<Text className='qrcode-label'></Text> <Text className='qrcode-label'></Text>
<Text className='qrcode-value'>{order.qr_code}</Text> <Text className='qrcode-value'>{order.qr_code}</Text>
@@ -164,7 +170,7 @@ export default function MallOrders() {
</View> </View>
)} )}
</View> </View>
</ContentCard> </View>
); );
})} })}
{loading && <Loading />} {loading && <Loading />}

View File

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

View File

@@ -67,66 +67,102 @@ export default function FamilyAdd() {
}; };
return ( return (
<PageShell className={modeClass}> <PageShell padding="md" safeBottom={false} scroll={false} className={`family-add-page ${modeClass}`}>
<Text className='page-title'>{editId ? '编辑就诊人' : '添加就诊人'}</Text> <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-card'>
<View className='form-item'> <View className='form-item'>
<Text className='form-label'></Text> <Text className='form-label'><Text className='form-required'>*</Text></Text>
<Input <View className='form-input-wrap'>
className='form-input' <Input
placeholder='请输入姓名' className='form-input'
placeholderClass='form-placeholder' placeholder='请输入真实姓名'
value={name} placeholderClass='form-placeholder'
onInput={(e) => setName(e.detail.value)} value={name}
/> onInput={(e) => setName(e.detail.value)}
/>
</View>
</View> </View>
<View className='form-item'> <View className='form-item'>
<Text className='form-label'></Text> <Text className='form-label'><Text className='form-required'>*</Text></Text>
<Picker <Picker
mode='selector' mode='selector'
range={RELATION_OPTIONS} range={RELATION_OPTIONS}
value={relationIdx} value={relationIdx}
onChange={(e) => setRelationIdx(Number(e.detail.value))} 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-text'>{RELATION_OPTIONS[relationIdx]}</Text>
<Text className='form-picker-arrow'>{'>'}</Text> <Text className='form-picker-arrow'></Text>
</View> </View>
</Picker> </Picker>
</View> </View>
<View className='form-item'> <View className='form-item'>
<Text className='form-label'></Text> <Text className='form-label'><Text className='form-required'>*</Text></Text>
<Picker <Picker
mode='selector' mode='selector'
range={GENDER_OPTIONS} range={GENDER_OPTIONS}
value={genderIdx} value={genderIdx}
onChange={(e) => setGenderIdx(Number(e.detail.value))} 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-text'>{GENDER_OPTIONS[genderIdx]}</Text>
<Text className='form-picker-arrow'>{'>'}</Text> <Text className='form-picker-arrow'></Text>
</View> </View>
</Picker> </Picker>
</View> </View>
<View className='form-item'> <View className='form-item'>
<Text className='form-label'></Text> <Text className='form-label'><Text className='form-required'>*</Text></Text>
<Picker <Picker
mode='date' mode='date'
value={birthDate || '2000-01-01'} value={birthDate || '2000-01-01'}
onChange={(e) => setBirthDate(e.detail.value)} onChange={(e) => setBirthDate(e.detail.value)}
> >
<View className='form-picker'> <View className='form-picker-wrap'>
<Text className={`form-picker-text ${!birthDate ? 'placeholder' : ''}`}> <Text className={`form-picker-text ${!birthDate ? 'placeholder' : ''}`}>
{birthDate || '请选择'} {birthDate || '请选择'}
</Text> </Text>
<Text className='form-picker-arrow'>{'>'}</Text> <Text className='form-picker-arrow'></Text>
</View> </View>
</Picker> </Picker>
</View> </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 <View

View File

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

View File

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

View File

@@ -56,6 +56,10 @@ export async function listProducts(params?: {
return api.get<ProductListResponse>('/health/points/products', 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 { export interface PointsOrder {