feat(mp): design-handoff 产出的页面样式和组件优化
- 首页/商城/医生端/积分/家庭档案等页面 SCSS + TSX 更新 - TabFilter 组件样式优化 - points service 接口调整 - app.config 路由注册更新
This commit is contained in:
@@ -38,7 +38,7 @@ export default defineAppConfig({
|
||||
},
|
||||
{
|
||||
root: 'pages/pkg-mall',
|
||||
pages: ['exchange/index', 'orders/index', 'detail/index'],
|
||||
pages: ['exchange/index', 'orders/index', 'detail/index', 'product/index'],
|
||||
},
|
||||
{
|
||||
root: 'pages/pkg-profile',
|
||||
|
||||
@@ -28,22 +28,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Pill型 — 文章分类
|
||||
// Pill型 — 筛选标签(医生端 ActionInbox / FollowUpList)
|
||||
&--pill {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--tk-gap-xs);
|
||||
|
||||
.tab-filter__item {
|
||||
height: 32px;
|
||||
padding: 0 var(--tk-gap-lg);
|
||||
border-radius: $r-pill;
|
||||
background: $surface-alt;
|
||||
padding: 0 16px;
|
||||
border-radius: $r-lg;
|
||||
background: $card;
|
||||
border: 1px solid $bd;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&--active {
|
||||
background: var(--tk-pri);
|
||||
border-color: var(--tk-pri);
|
||||
box-shadow: var(--tk-shadow-tab);
|
||||
|
||||
.tab-filter__text {
|
||||
color: $white;
|
||||
|
||||
@@ -298,7 +298,7 @@
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
访客首页
|
||||
访客首页 — 对齐原型 docs/design/mp-14-guest-home.html
|
||||
═══════════════════════════════════════ */
|
||||
|
||||
.guest-page {
|
||||
@@ -308,7 +308,7 @@
|
||||
/* ─── 轮播图 ─── */
|
||||
.guest-swiper {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.guest-slide {
|
||||
@@ -340,124 +340,162 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: var(--tk-gap-2xl) var(--tk-gap-xl);
|
||||
padding: 0 var(--tk-gap-xl);
|
||||
}
|
||||
|
||||
.guest-slide-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: $white;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.guest-slide-desc {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ─── 健康资讯 ─── */
|
||||
.guest-section {
|
||||
padding: var(--tk-gap-lg) var(--tk-gap-lg) 0;
|
||||
padding: var(--tk-gap-lg) var(--tk-page-padding) 0;
|
||||
}
|
||||
|
||||
.guest-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.guest-section-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: bold;
|
||||
font-size: var(--tk-font-nav);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.guest-section-more {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.guest-articles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tk-gap-sm);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
// 文章卡片 — 左图标 + 右标题/日期
|
||||
.guest-article-card {
|
||||
overflow: hidden;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: var(--tk-gap-md);
|
||||
box-shadow: $shadow-sm;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.guest-article-cover {
|
||||
width: 100px;
|
||||
height: 80px;
|
||||
.guest-article-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: $r-sm;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
.guest-article-card--pri & { background: $pri-l; }
|
||||
.guest-article-card--acc & { background: $acc-l; }
|
||||
.guest-article-card--wrn & { background: $wrn-l; }
|
||||
}
|
||||
|
||||
.guest-article-icon-char {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
|
||||
.guest-article-card--pri & { color: $pri; }
|
||||
.guest-article-card--acc & { color: $acc; }
|
||||
.guest-article-card--wrn & { color: $wrn; }
|
||||
}
|
||||
|
||||
.guest-article-body {
|
||||
padding: var(--tk-gap-sm);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.guest-article-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-2xs);
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.guest-article-summary {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
.guest-article-date {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.guest-empty {
|
||||
padding: var(--tk-gap-2xl) 0;
|
||||
/* ─── 底部注册引导卡片 ─── */
|
||||
.guest-cta-card {
|
||||
margin: var(--tk-gap-lg) var(--tk-page-padding);
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: var(--tk-section-gap);
|
||||
box-shadow: $shadow-sm;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.guest-empty-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
.guest-cta-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ─── 底部登录引导 ─── */
|
||||
.guest-login-prompt {
|
||||
margin: var(--tk-gap-lg) var(--tk-gap-lg) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.guest-login-text {
|
||||
flex: 1;
|
||||
.guest-cta-desc {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.guest-login-btn {
|
||||
height: var(--tk-input-height);
|
||||
padding: 0 var(--tk-card-padding-lg);
|
||||
.guest-cta-btn {
|
||||
height: 48px;
|
||||
border-radius: 24px;
|
||||
background: var(--tk-pri);
|
||||
border-radius: $r-pill;
|
||||
@include flex-center;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--tk-shadow-btn);
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.guest-login-btn-text {
|
||||
font-size: var(--tk-font-h2);
|
||||
.guest-cta-btn-text {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,15 @@ function GuestHome({ modeClass }: { modeClass: string }) {
|
||||
|
||||
const slides = banners.length > 0 ? banners : FALLBACK_SLIDES;
|
||||
|
||||
const ARTICLE_ICONS = ['♥', '◇', '✦'];
|
||||
const ARTICLE_COLORS = ['pri', 'acc', 'wrn'] as const;
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell padding="none" safeBottom={false} scroll={false} className={`guest-page ${modeClass}`}>
|
||||
<Swiper
|
||||
@@ -114,59 +123,71 @@ function GuestHome({ modeClass }: { modeClass: string }) {
|
||||
))}
|
||||
</Swiper>
|
||||
|
||||
{/* 健康资讯 */}
|
||||
<View className='guest-section'>
|
||||
<Text className='guest-section-title'>健康资讯</Text>
|
||||
<View className='guest-section-header'>
|
||||
<Text className='guest-section-title'>健康资讯</Text>
|
||||
<Text
|
||||
className='guest-section-more'
|
||||
onClick={() => Taro.switchTab({ url: '/pages/health/index' })}
|
||||
>
|
||||
查看全部 ›
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{articles.length > 0 ? (
|
||||
<View className='guest-articles'>
|
||||
{articles.map((article) => (
|
||||
<ContentCard
|
||||
{articles.map((article, i) => (
|
||||
<View
|
||||
key={article.id}
|
||||
onPress={() => safeNavigateTo(`/pages/article/detail/index?id=${article.id}`)}
|
||||
activeFeedback="opacity"
|
||||
padding="none"
|
||||
className={`guest-article-card guest-article-card--${ARTICLE_COLORS[i % 3]}`}
|
||||
onClick={() => safeNavigateTo(`/pages/article/detail/index?id=${article.id}`)}
|
||||
>
|
||||
{article.cover_image && (
|
||||
<Image className='guest-article-cover' src={article.cover_image} mode='aspectFill' lazyLoad />
|
||||
)}
|
||||
<View className='guest-article-icon'>
|
||||
<Text className='guest-article-icon-char'>{ARTICLE_ICONS[i % 3]}</Text>
|
||||
</View>
|
||||
<View className='guest-article-body'>
|
||||
<Text className='guest-article-title'>{article.title}</Text>
|
||||
<Text className='guest-article-summary'>
|
||||
{article.summary || '点击查看详情'}
|
||||
</Text>
|
||||
<Text className='guest-article-date'>{formatDate(article.published_at)}</Text>
|
||||
</View>
|
||||
</ContentCard>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View className='guest-articles'>
|
||||
<ContentCard padding="none">
|
||||
<View className='guest-article-card guest-article-card--pri'>
|
||||
<View className='guest-article-icon'><Text className='guest-article-icon-char'>♥</Text></View>
|
||||
<View className='guest-article-body'>
|
||||
<Text className='guest-article-title'>健康数据管理</Text>
|
||||
<Text className='guest-article-summary'>记录并追踪您的体征数据</Text>
|
||||
<Text className='guest-article-date'>记录并追踪您的体征数据</Text>
|
||||
</View>
|
||||
</ContentCard>
|
||||
<ContentCard padding="none">
|
||||
</View>
|
||||
<View className='guest-article-card guest-article-card--acc'>
|
||||
<View className='guest-article-icon'><Text className='guest-article-icon-char'>◇</Text></View>
|
||||
<View className='guest-article-body'>
|
||||
<Text className='guest-article-title'>智能预约排班</Text>
|
||||
<Text className='guest-article-summary'>在线预约透析和治疗</Text>
|
||||
<Text className='guest-article-title'>积分商城</Text>
|
||||
<Text className='guest-article-date'>签到赚积分,好礼兑不停</Text>
|
||||
</View>
|
||||
</ContentCard>
|
||||
<ContentCard padding="none">
|
||||
</View>
|
||||
<View className='guest-article-card guest-article-card--wrn'>
|
||||
<View className='guest-article-icon'><Text className='guest-article-icon-char'>✦</Text></View>
|
||||
<View className='guest-article-body'>
|
||||
<Text className='guest-article-title'>AI 健康分析</Text>
|
||||
<Text className='guest-article-summary'>个性化健康趋势解读</Text>
|
||||
<Text className='guest-article-title'>专业科普文章</Text>
|
||||
<Text className='guest-article-date'>权威健康知识推送</Text>
|
||||
</View>
|
||||
</ContentCard>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ContentCard variant="elevated">
|
||||
<Text className='guest-login-text'>登录后即可使用完整健康管理服务</Text>
|
||||
<View className='guest-login-btn' onClick={navigateToLogin}>
|
||||
<Text className='guest-login-btn-text'>立即登录</Text>
|
||||
{/* 底部注册引导 */}
|
||||
<View className='guest-cta-card'>
|
||||
<Text className='guest-cta-title'>加入我们</Text>
|
||||
<Text className='guest-cta-desc'>注册后即可使用签到、积分商城等全部功能</Text>
|
||||
<View className='guest-cta-btn' onClick={navigateToLogin}>
|
||||
<Text className='guest-cta-btn-text'>注册 / 登录</Text>
|
||||
</View>
|
||||
</ContentCard>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,22 @@ export default function Login() {
|
||||
|
||||
const navigateAfterLogin = () => {
|
||||
if (isMedicalStaff()) {
|
||||
Taro.reLaunch({ url: '/pages/pkg-doctor-core/index' });
|
||||
// 使用 redirectTo 替代 reLaunch 避免分包加载超时
|
||||
// redirectTo 只替换当前页面,不销毁整个页栈,分包预加载不会被中断
|
||||
Taro.redirectTo({
|
||||
url: '/pages/pkg-doctor-core/index',
|
||||
fail: () => {
|
||||
// fallback: 先跳首页再 redirectTo
|
||||
Taro.switchTab({
|
||||
url: '/pages/index/index',
|
||||
success: () => {
|
||||
setTimeout(() => {
|
||||
Taro.navigateTo({ url: '/pages/pkg-doctor-core/index' });
|
||||
}, 500);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
} else {
|
||||
Taro.switchTab({ url: '/pages/index/index' });
|
||||
}
|
||||
|
||||
@@ -1,40 +1,68 @@
|
||||
@import '../../styles/variables.scss';
|
||||
@import '../../styles/mixins.scss';
|
||||
|
||||
// 积分商城 — 对齐原型 docs/design/mp-05-mall.html
|
||||
|
||||
.mall-page {
|
||||
padding-bottom: calc(var(--tk-tabbar-space) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* ─── 积分余额卡片 ─── */
|
||||
/* ─── 积分卡片(渐变背景) ─── */
|
||||
.mall-header {
|
||||
background: linear-gradient(135deg, var(--tk-pri) 0%, var(--tk-pri-d) 100%);
|
||||
padding: var(--tk-gap-2xl) var(--tk-gap-xl) var(--tk-gap-xl);
|
||||
padding: var(--tk-gap-xl) var(--tk-page-padding) var(--tk-gap-xl);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// 装饰圆
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -30px;
|
||||
right: 40px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 40px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.points-card {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: $r-lg;
|
||||
padding: var(--tk-gap-xl);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.points-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.points-label {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: var(--tk-font-cap);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.checkin-btn {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
padding: var(--tk-gap-sm) var(--tk-card-padding-lg);
|
||||
padding: 6px 14px;
|
||||
border-radius: $r-pill;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
@@ -48,9 +76,9 @@
|
||||
}
|
||||
|
||||
.checkin-btn-text {
|
||||
font-size: var(--tk-font-h2);
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $white;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.checkin-btn.checked .checkin-btn-text {
|
||||
@@ -59,8 +87,8 @@
|
||||
|
||||
.points-balance {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-display);
|
||||
font-weight: bold;
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
color: $white;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
@@ -69,44 +97,105 @@
|
||||
}
|
||||
|
||||
.points-streak {
|
||||
font-size: var(--tk-font-body);
|
||||
font-size: var(--tk-font-cap);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ─── 商品类型切换 ─── */
|
||||
/* ─── 快捷操作 ─── */
|
||||
.mall-actions {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: var(--tk-section-gap) var(--tk-page-padding);
|
||||
}
|
||||
|
||||
.mall-action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.mall-action-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&--checkin {
|
||||
background: $acc;
|
||||
box-shadow: 0 4px 12px rgba(91, 122, 94, 0.3);
|
||||
}
|
||||
&--task {
|
||||
background: $pri;
|
||||
box-shadow: 0 4px 12px rgba(196, 98, 58, 0.3);
|
||||
}
|
||||
&--history {
|
||||
background: $wrn;
|
||||
box-shadow: 0 4px 12px rgba(196, 135, 58, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.mall-action-icon-text {
|
||||
font-size: 22px;
|
||||
color: $white;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mall-action-label {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── 分类标签(Pill) ─── */
|
||||
.type-tabs {
|
||||
display: flex;
|
||||
padding: var(--tk-section-gap) var(--tk-page-padding) 0;
|
||||
gap: 10px;
|
||||
padding: 0 var(--tk-page-padding) var(--tk-section-gap);
|
||||
overflow-x: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.type-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: var(--tk-gap-md) 0;
|
||||
position: relative;
|
||||
min-height: 48px;
|
||||
padding: 7px 18px;
|
||||
border-radius: $r-pill;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 400;
|
||||
background: $surface-alt;
|
||||
color: $tx2;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 48px;
|
||||
height: 4px;
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--tk-pri);
|
||||
border-radius: $r-xs;
|
||||
color: $white;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--tk-shadow-tab);
|
||||
}
|
||||
}
|
||||
|
||||
.type-tab-text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx2;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
font-weight: inherit;
|
||||
|
||||
&.active {
|
||||
color: var(--tk-pri);
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,12 +203,15 @@
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--tk-gap-md);
|
||||
padding: var(--tk-section-gap) var(--tk-page-padding);
|
||||
gap: var(--tk-gap-sm);
|
||||
padding: 0 var(--tk-page-padding) var(--tk-gap-lg);
|
||||
}
|
||||
|
||||
.product-card {
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
overflow: hidden;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
@@ -128,19 +220,20 @@
|
||||
|
||||
.product-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
aspect-ratio: 1;
|
||||
@include flex-center;
|
||||
position: relative;
|
||||
|
||||
&.type-physical { background: var(--tk-pri-l); }
|
||||
&.type-physical { background: $pri-l; }
|
||||
&.type-service { background: $acc-l; }
|
||||
&.type-privilege { background: $wrn-l; }
|
||||
}
|
||||
|
||||
.product-image-char {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-hero);
|
||||
font-weight: bold;
|
||||
color: var(--tk-pri);
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
line-height: 1;
|
||||
|
||||
.type-service & { color: $acc; }
|
||||
@@ -148,57 +241,83 @@
|
||||
}
|
||||
|
||||
.product-info {
|
||||
padding: var(--tk-section-gap);
|
||||
padding: 10px var(--tk-gap-sm) 14px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: var(--tk-font-h1);
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
height: 40px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.product-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.product-points {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-2xs);
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.product-points-char {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: bold;
|
||||
color: $wrn;
|
||||
@include serif-number;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.product-points-value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $wrn;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $pri;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.product-stock {
|
||||
font-size: var(--tk-font-body);
|
||||
padding: 2px var(--tk-gap-sm);
|
||||
border-radius: $r-sm;
|
||||
font-size: var(--tk-font-micro);
|
||||
padding: 2px 6px;
|
||||
border-radius: $r-xs;
|
||||
|
||||
&.out {
|
||||
@include tag($bd-l, $tx3);
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&.low {
|
||||
@include tag($dan-l, $dan);
|
||||
background: $dan-l;
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
.product-tag {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
color: $white;
|
||||
|
||||
&--hot {
|
||||
background: $dan;
|
||||
}
|
||||
&--new {
|
||||
background: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,13 @@ import ErrorState from '../../components/ErrorState';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import './index.scss';
|
||||
|
||||
const PRODUCT_TYPE_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'physical', label: '实物', char: '物' },
|
||||
{ key: 'service', label: '服务券', char: '券' },
|
||||
{ key: 'privilege', label: '权益', char: '权' },
|
||||
{ key: 'physical', label: '实物' },
|
||||
{ key: 'service', label: '服务券' },
|
||||
{ key: 'privilege', label: '权益' },
|
||||
];
|
||||
|
||||
const TYPE_BG: Record<string, string> = {
|
||||
@@ -77,7 +76,6 @@ export default function Mall() {
|
||||
async (type?: string) => {
|
||||
const t = type !== undefined ? type : productType;
|
||||
if (!currentPatient) {
|
||||
// 先尝试从服务端加载患者列表
|
||||
await loadPatients();
|
||||
const updated = useAuthStore.getState().currentPatient;
|
||||
if (!updated) {
|
||||
@@ -133,7 +131,7 @@ export default function Mall() {
|
||||
Taro.showToast({ title: '已兑完', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
safeNavigateTo(`/pages/pkg-mall/exchange/index?product_id=${item.id}`);
|
||||
safeNavigateTo(`/pages/pkg-mall/product/index?product_id=${item.id}`);
|
||||
};
|
||||
|
||||
const balance = account?.balance ?? 0;
|
||||
@@ -158,7 +156,7 @@ export default function Mall() {
|
||||
<View className='mall-header'>
|
||||
<View className='points-card'>
|
||||
<View className='points-top'>
|
||||
<Text className='points-label'>当前积分</Text>
|
||||
<Text className='points-label'>我的积分</Text>
|
||||
<View
|
||||
className={`checkin-btn ${checkinStatus?.checked_in_today ? 'checked' : ''}`}
|
||||
onClick={handleCheckin}
|
||||
@@ -177,6 +175,28 @@ export default function Mall() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 快捷操作 */}
|
||||
<View className='mall-actions'>
|
||||
<View className='mall-action' onClick={handleCheckin}>
|
||||
<View className='mall-action-icon mall-action-icon--checkin'>
|
||||
<Text className='mall-action-icon-text'>✓</Text>
|
||||
</View>
|
||||
<Text className='mall-action-label'>签到打卡</Text>
|
||||
</View>
|
||||
<View className='mall-action'>
|
||||
<View className='mall-action-icon mall-action-icon--task'>
|
||||
<Text className='mall-action-icon-text'>★</Text>
|
||||
</View>
|
||||
<Text className='mall-action-label'>积分任务</Text>
|
||||
</View>
|
||||
<View className='mall-action' onClick={() => safeNavigateTo('/pages/pkg-mall/orders/index')}>
|
||||
<View className='mall-action-icon mall-action-icon--history'>
|
||||
<Text className='mall-action-icon-text'>◷</Text>
|
||||
</View>
|
||||
<Text className='mall-action-label'>兑换记录</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 商品类型切换 */}
|
||||
<View className='type-tabs'>
|
||||
{PRODUCT_TYPE_TABS.map((tab) => (
|
||||
@@ -200,7 +220,11 @@ export default function Mall() {
|
||||
) : (
|
||||
<View className='product-grid'>
|
||||
{products.map((item) => (
|
||||
<ContentCard key={item.id} onPress={() => handleProductClick(item)} activeFeedback="opacity" padding="none">
|
||||
<View
|
||||
key={item.id}
|
||||
className='product-card'
|
||||
onClick={() => handleProductClick(item)}
|
||||
>
|
||||
<View className={`product-image ${TYPE_BG[item.product_type] || ''}`}>
|
||||
<Text className='product-image-char'>
|
||||
{item.product_type === 'physical' ? '物' : item.product_type === 'service' ? '券' : '权'}
|
||||
@@ -210,8 +234,8 @@ export default function Mall() {
|
||||
<Text className='product-name'>{item.name}</Text>
|
||||
<View className='product-bottom'>
|
||||
<View className='product-points'>
|
||||
<Text className='product-points-char'>P</Text>
|
||||
<Text className='product-points-value'>{item.points_cost}</Text>
|
||||
<Text className='product-points-char'>{item.points_cost}</Text>
|
||||
<Text className='product-points-value'>积分</Text>
|
||||
</View>
|
||||
{item.stock <= 0 ? (
|
||||
<Text className='product-stock out'>已兑完</Text>
|
||||
@@ -220,7 +244,7 @@ export default function Mall() {
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</ContentCard>
|
||||
</View>
|
||||
))}
|
||||
{loading && <Loading />}
|
||||
{!loading && products.length >= total && total > 0 && (
|
||||
|
||||
@@ -1,64 +1,102 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
// PageShell 已接管:min-height, background
|
||||
// ContentCard 已接管:inbox-card 背景/圆角/阴影/触摸反馈
|
||||
.inbox-page {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.inbox-list {
|
||||
height: calc(100vh - 50px);
|
||||
padding: var(--tk-gap-sm);
|
||||
&__filter {
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
&__list {
|
||||
flex: 1;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.inbox-card {
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
margin-bottom: 10px !important;
|
||||
|
||||
.inbox-card-header {
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-xs);
|
||||
margin-bottom: var(--tk-gap-2xs);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.inbox-type-tag {
|
||||
color: $card;
|
||||
font-size: var(--tk-font-micro);
|
||||
padding: 2px var(--tk-gap-2xs);
|
||||
border-radius: $r-xs;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--ai {
|
||||
background: var(--tk-pri);
|
||||
}
|
||||
|
||||
&--alert {
|
||||
background: $dan;
|
||||
}
|
||||
|
||||
&--followup {
|
||||
background: $acc;
|
||||
}
|
||||
|
||||
&--anomaly {
|
||||
background: $wrn;
|
||||
}
|
||||
|
||||
&--default {
|
||||
background: $tx3;
|
||||
}
|
||||
&__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.inbox-card-title {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 500;
|
||||
&__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__patient {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.inbox-card-desc {
|
||||
font-size: var(--tk-font-micro);
|
||||
&__urgent {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: $card;
|
||||
background: $dan;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 13px;
|
||||
color: $tx2;
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: 11px;
|
||||
color: $tx3;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
flex-shrink: 0;
|
||||
font-size: 20px;
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 类型标签(对齐原型:color/bg 配对,非白字实底)──
|
||||
.inbox-type-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--pri { color: $doc-pri; background: $doc-pri-l; }
|
||||
&--dan { color: $dan; background: $dan-l; }
|
||||
&--acc { color: $acc; background: $acc-l; }
|
||||
&--wrn { color: $wrn; background: $wrn-l; }
|
||||
&--default { color: $tx3; background: $surface-alt; }
|
||||
}
|
||||
|
||||
// ── 半屏详情弹窗 ──
|
||||
.half-screen-dialog {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
@@ -75,37 +113,27 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--tk-gap-md) var(--tk-section-gap);
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
.dialog-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.dialog-close {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
.dialog-title { font-size: 14px; font-weight: 600; color: $tx; }
|
||||
.dialog-close { font-size: 13px; color: $tx3; }
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: var(--tk-gap-md) var(--tk-section-gap);
|
||||
}
|
||||
.dialog-body { padding: 16px 20px; }
|
||||
|
||||
.dialog-patient {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-size: 13px;
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.thread-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--tk-gap-sm);
|
||||
padding: var(--tk-gap-2xs) 0;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.thread-dot {
|
||||
@@ -122,30 +150,22 @@
|
||||
}
|
||||
|
||||
.thread-content {
|
||||
.thread-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.thread-time {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
}
|
||||
.thread-label { font-size: 13px; color: $tx; display: block; }
|
||||
.thread-time { font-size: 11px; color: $tx3; }
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: var(--tk-gap-xs);
|
||||
padding: var(--tk-gap-sm) var(--tk-section-gap) var(--tk-section-gap);
|
||||
gap: 8px;
|
||||
padding: 12px 20px 20px;
|
||||
border-top: 1px solid $bd-l;
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: var(--tk-gap-sm);
|
||||
padding: 12px;
|
||||
border-radius: $r-sm;
|
||||
font-size: var(--tk-font-cap);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
&.primary { background: var(--tk-pri); color: $card; }
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useState, useCallback, useRef } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { api } from '@/services/request';
|
||||
import {
|
||||
listActionItems,
|
||||
getActionThread,
|
||||
@@ -12,47 +11,54 @@ import {
|
||||
import Loading from '@/components/Loading';
|
||||
import ErrorState from '@/components/ErrorState';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import SegmentTabs from '@/components/SegmentTabs';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import PageHeader from '@/components/patterns/PageHeader';
|
||||
import TabFilter from '@/components/ui/TabFilter';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import { useDoctorClass } from '@/hooks/useDoctorClass';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
ai_suggestion: 'AI建议',
|
||||
alert: '告警',
|
||||
followup: '随访',
|
||||
data_anomaly: '异常',
|
||||
const TYPE_CONFIG: Record<string, { label: string; colorCls: string }> = {
|
||||
data_anomaly: { label: '异常', colorCls: 'inbox-type-tag--dan' },
|
||||
followup: { label: '随访', colorCls: 'inbox-type-tag--acc' },
|
||||
ai_suggestion: { label: '咨询', colorCls: 'inbox-type-tag--pri' },
|
||||
alert: { label: '告警', colorCls: 'inbox-type-tag--dan' },
|
||||
};
|
||||
|
||||
const TYPE_CLS: Record<string, string> = {
|
||||
ai_suggestion: 'inbox-type-tag--ai',
|
||||
alert: 'inbox-type-tag--alert',
|
||||
followup: 'inbox-type-tag--followup',
|
||||
data_anomaly: 'inbox-type-tag--anomaly',
|
||||
const FILTER_TABS = ['全部', '异常', '随访', '咨询'];
|
||||
|
||||
const FILTER_MAP: Record<number, string | undefined> = {
|
||||
0: undefined,
|
||||
1: 'data_anomaly',
|
||||
2: 'followup',
|
||||
3: 'ai_suggestion',
|
||||
};
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'pending', label: '待处理' },
|
||||
{ key: 'in_progress', label: '进行中' },
|
||||
{ key: 'completed', label: '已完成' },
|
||||
];
|
||||
function formatTimeAgo(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(dateStr).getTime();
|
||||
const diff = now - then;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 60) return `${minutes}分钟前`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}小时前`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days === 1) return '昨天';
|
||||
if (days < 7) return `${days}天前`;
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN');
|
||||
}
|
||||
|
||||
export default function ActionInboxPage() {
|
||||
const modeClass = useDoctorClass();
|
||||
const [items, setItems] = useState<ActionItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [_page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [activeFilter, setActiveFilter] = useState(0);
|
||||
const [threadData, setThreadData] = useState<ThreadResponse | null>(null);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async (pageNum: number, status: string, isRefresh = false) => {
|
||||
async (pageNum: number, typeFilter: string | undefined, isRefresh = false) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
@@ -61,7 +67,7 @@ export default function ActionInboxPage() {
|
||||
const resp = await listActionItems({
|
||||
page: pageNum,
|
||||
page_size: 20,
|
||||
status: status || undefined,
|
||||
type: typeFilter,
|
||||
});
|
||||
const list = resp.data || [];
|
||||
if (isRefresh) {
|
||||
@@ -69,8 +75,6 @@ export default function ActionInboxPage() {
|
||||
} else {
|
||||
setItems((prev) => [...prev, ...list]);
|
||||
}
|
||||
setTotal(resp.total);
|
||||
setPage(pageNum);
|
||||
} catch {
|
||||
setError(true);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
@@ -84,15 +88,14 @@ export default function ActionInboxPage() {
|
||||
|
||||
usePageData(
|
||||
useCallback(async () => {
|
||||
Taro.setNavigationBarTitle({ title: '待办事项' });
|
||||
await fetchItems(1, activeTab, true);
|
||||
}, [fetchItems, activeTab]),
|
||||
await fetchItems(1, FILTER_MAP[activeFilter], true);
|
||||
}, [fetchItems, activeFilter]),
|
||||
{ throttleMs: 10000, enablePullDown: true },
|
||||
);
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
setActiveTab(key);
|
||||
fetchItems(1, key, true);
|
||||
const handleFilterChange = (index: number) => {
|
||||
setActiveFilter(index);
|
||||
fetchItems(1, FILTER_MAP[index], true);
|
||||
};
|
||||
|
||||
const handleItemClick = async (item: ActionItem) => {
|
||||
@@ -111,90 +114,98 @@ export default function ActionInboxPage() {
|
||||
}) => {
|
||||
if (!action.api_endpoint || !threadData) return;
|
||||
try {
|
||||
const { api } = await import('@/services/request');
|
||||
await api.post(action.api_endpoint, { action: action.key });
|
||||
Taro.showToast({ title: '操作成功', icon: 'success' });
|
||||
setShowDetail(false);
|
||||
fetchItems(1, activeTab, true);
|
||||
fetchItems(1, FILTER_MAP[activeFilter], true);
|
||||
} catch {
|
||||
Taro.showToast({ title: '操作失败', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeConfig = (type: string) =>
|
||||
TYPE_CONFIG[type] || { label: '未知', colorCls: 'inbox-type-tag--default' };
|
||||
|
||||
return (
|
||||
<PageShell padding="none" className={modeClass}>
|
||||
<SegmentTabs tabs={STATUS_TABS} activeKey={activeTab} onChange={handleTabChange} variant="underline" />
|
||||
<View className={`inbox-page ${modeClass}`}>
|
||||
<PageHeader title="待办事项" showBack />
|
||||
|
||||
<View className="inbox-page__filter">
|
||||
<TabFilter
|
||||
tabs={FILTER_TABS}
|
||||
activeIndex={activeFilter}
|
||||
onChange={handleFilterChange}
|
||||
variant="pill"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error ? (
|
||||
<ErrorState onRetry={() => fetchItems(1, activeTab, true)} />
|
||||
<ErrorState onRetry={() => fetchItems(1, FILTER_MAP[activeFilter], true)} />
|
||||
) : items.length === 0 && !loading ? (
|
||||
<EmptyState text='暂无待办事项' />
|
||||
<EmptyState text="暂无待办事项" />
|
||||
) : (
|
||||
<ScrollView scrollY className="inbox-list">
|
||||
{items.map((item) => (
|
||||
<ContentCard
|
||||
key={item.id}
|
||||
className="inbox-card"
|
||||
activeFeedback="opacity"
|
||||
onPress={() => handleItemClick(item)}
|
||||
>
|
||||
<View className="inbox-card-header">
|
||||
<Text
|
||||
className={`inbox-type-tag ${TYPE_CLS[item.action_type] || 'inbox-type-tag--default'}`}
|
||||
>
|
||||
{TYPE_LABEL[item.action_type] || '未知'}
|
||||
</Text>
|
||||
<Text className="inbox-card-title">{item.title}</Text>
|
||||
</View>
|
||||
<Text className="inbox-card-desc">
|
||||
{item.patient_name} ·{' '}
|
||||
{new Date(item.created_at).toLocaleDateString('zh-CN')}
|
||||
</Text>
|
||||
</ContentCard>
|
||||
))}
|
||||
<ScrollView scrollY className="inbox-page__list">
|
||||
{items.map((item) => {
|
||||
const cfg = getTypeConfig(item.action_type);
|
||||
const isUrgent = item.priority === 'urgent' || item.priority === 'high';
|
||||
return (
|
||||
<ContentCard
|
||||
key={item.id}
|
||||
className="inbox-card"
|
||||
activeFeedback="opacity"
|
||||
onPress={() => handleItemClick(item)}
|
||||
>
|
||||
<View className="inbox-card__row">
|
||||
{/* 类型标签 — 原型:color/bg 配对 */}
|
||||
<Text className={`inbox-type-tag ${cfg.colorCls}`}>
|
||||
{cfg.label}
|
||||
</Text>
|
||||
{/* 内容区 */}
|
||||
<View className="inbox-card__body">
|
||||
<View className="inbox-card__name-row">
|
||||
<Text className="inbox-card__patient">{item.patient_name || '未知患者'}</Text>
|
||||
{isUrgent && <Text className="inbox-card__urgent">紧急</Text>}
|
||||
</View>
|
||||
<Text className="inbox-card__desc">{item.title}</Text>
|
||||
</View>
|
||||
{/* 时间 */}
|
||||
<Text className="inbox-card__time">{formatTimeAgo(item.created_at)}</Text>
|
||||
{/* 箭头 */}
|
||||
<Text className="inbox-card__arrow">›</Text>
|
||||
</View>
|
||||
</ContentCard>
|
||||
);
|
||||
})}
|
||||
{loading && <Loading />}
|
||||
{!loading && items.length >= total && total > 0 && (
|
||||
<Loading text="没有更多了" />
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{showDetail && threadData && (
|
||||
<View className="half-screen-dialog">
|
||||
<View className="dialog-header">
|
||||
<Text className="dialog-title">
|
||||
{threadData.action_item.title}
|
||||
</Text>
|
||||
<Text
|
||||
className="dialog-close"
|
||||
onClick={() => setShowDetail(false)}
|
||||
>
|
||||
收起
|
||||
</Text>
|
||||
<Text className="dialog-title">{threadData.action_item.title}</Text>
|
||||
<Text className="dialog-close" onClick={() => setShowDetail(false)}>收起</Text>
|
||||
</View>
|
||||
<View className="dialog-body">
|
||||
<Text className="dialog-patient">
|
||||
{threadData.action_item.patient_name} ·{' '}
|
||||
{threadData.action_item.priority === 'urgent'
|
||||
? '紧急'
|
||||
: threadData.action_item.priority === 'high'
|
||||
? '高'
|
||||
: '中'}
|
||||
{threadData.action_item.priority === 'urgent' ? '紧急'
|
||||
: threadData.action_item.priority === 'high' ? '高' : '中'}
|
||||
</Text>
|
||||
<View className="thread-timeline">
|
||||
{threadData.thread.map((evt, idx) => (
|
||||
<View key={idx} className="thread-item">
|
||||
<View className={`thread-dot ${evt.status}`} />
|
||||
<View className="thread-content">
|
||||
<Text className="thread-label">{evt.label}</Text>
|
||||
{evt.timestamp && (
|
||||
<Text className="thread-time">
|
||||
{new Date(evt.timestamp).toLocaleDateString('zh-CN')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{threadData.thread.map((evt, idx) => (
|
||||
<View key={idx} className="thread-item">
|
||||
<View className={`thread-dot ${evt.status}`} />
|
||||
<View className="thread-content">
|
||||
<Text className="thread-label">{evt.label}</Text>
|
||||
{evt.timestamp && (
|
||||
<Text className="thread-time">
|
||||
{new Date(evt.timestamp).toLocaleDateString('zh-CN')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{threadData.available_actions.length > 0 && (
|
||||
<View className="dialog-actions">
|
||||
@@ -211,6 +222,6 @@ export default function ActionInboxPage() {
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</PageShell>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,76 +1,88 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
// PageShell 已接管:min-height, background, padding
|
||||
// SearchSection 已接管:标签筛选栏
|
||||
// ContentCard 已接管:session-card 背景/圆角/阴影/触摸反馈
|
||||
// StatusTag 已接管:会话状态标签
|
||||
// PaginationBar 已接管:分页控件
|
||||
|
||||
.session-list {
|
||||
.consult-page {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tk-gap-md);
|
||||
|
||||
&__tabs {
|
||||
padding: 12px 20px 0;
|
||||
}
|
||||
|
||||
&__list {
|
||||
flex: 1;
|
||||
padding: 12px 20px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.session-card__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
}
|
||||
.consult-card {
|
||||
margin-bottom: 10px !important;
|
||||
|
||||
.session-card__subject {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: var(--tk-gap-md);
|
||||
}
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.session-card__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-md);
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
}
|
||||
&__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-card__type {
|
||||
@include tag(var(--tk-pri-l), var(--tk-pri));
|
||||
}
|
||||
&__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.session-card__time {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
&__name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-card__preview {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
&__time {
|
||||
font-size: 12px;
|
||||
color: $tx3;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.session-card__badge {
|
||||
position: absolute;
|
||||
top: var(--tk-section-gap);
|
||||
right: var(--tk-section-gap);
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
background: $dan;
|
||||
border-radius: $r-pill;
|
||||
@include flex-center;
|
||||
padding: 0 8px;
|
||||
}
|
||||
&__msg {
|
||||
font-size: 13px;
|
||||
color: $tx2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.session-card__badge-text {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $card;
|
||||
font-weight: 600;
|
||||
&__badge {
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
background: $dan;
|
||||
border-radius: $r-pill;
|
||||
@include flex-center;
|
||||
padding: 0 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__badge-text {
|
||||
font-size: 11px;
|
||||
color: $card;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
flex-shrink: 0;
|
||||
font-size: 20px;
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,33 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { listSessions, type ConsultationSession } from '@/services/doctor/consultation';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import PageHeader from '@/components/patterns/PageHeader';
|
||||
import SegmentTabs from '@/components/SegmentTabs';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import StatusTag from '@/components/ui/StatusTag';
|
||||
import AvatarCircle from '@/components/ui/AvatarCircle';
|
||||
import LoadingCard from '@/components/ui/LoadingCard';
|
||||
import PaginationBar from '@/components/patterns/PaginationBar';
|
||||
import SearchSection from '@/components/patterns/SearchSection';
|
||||
import ErrorState from '@/components/ErrorState';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useDoctorClass } from '@/hooks/useDoctorClass';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import './index.scss';
|
||||
|
||||
const TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
const STATUS_TABS = [
|
||||
{ key: 'active', label: '进行中' },
|
||||
{ key: 'waiting', label: '等待中' },
|
||||
{ key: 'closed', label: '已关闭' },
|
||||
{ key: 'closed', label: '已结束' },
|
||||
];
|
||||
|
||||
const STATUS_COLOR_MAP: Record<string, 'success' | 'warning' | 'default' | 'info'> = {
|
||||
active: 'success',
|
||||
waiting: 'warning',
|
||||
closed: 'default',
|
||||
};
|
||||
const AVATAR_COLORS: Array<'pri' | 'acc' | 'wrn' | 'dan'> = ['pri', 'acc', 'wrn', 'dan'];
|
||||
|
||||
export default function ConsultationList() {
|
||||
const modeClass = useDoctorClass();
|
||||
const [sessions, setSessions] = useState<ConsultationSession[]>([]);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('active');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
@@ -49,7 +41,6 @@ export default function ConsultationList() {
|
||||
status: activeTab || undefined,
|
||||
});
|
||||
setSessions(res.data || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch {
|
||||
setError(true);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
@@ -72,6 +63,8 @@ export default function ConsultationList() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const getAvatarColor = (index: number) => AVATAR_COLORS[index % AVATAR_COLORS.length];
|
||||
|
||||
const formatTime = (dateStr?: string | null) => {
|
||||
if (!dateStr) return '';
|
||||
return formatDateTime(dateStr);
|
||||
@@ -81,57 +74,57 @@ export default function ConsultationList() {
|
||||
if (error) return <ErrorState onRetry={loadSessions} />;
|
||||
|
||||
return (
|
||||
<PageShell safeBottom className={modeClass}>
|
||||
<SearchSection
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
filters={TABS}
|
||||
activeFilter={activeTab}
|
||||
onFilterChange={handleTabChange}
|
||||
/>
|
||||
<View className={`consult-page ${modeClass}`}>
|
||||
<PageHeader title="在线咨询" showBack />
|
||||
|
||||
<View className="consult-page__tabs">
|
||||
<SegmentTabs
|
||||
tabs={STATUS_TABS}
|
||||
activeKey={activeTab}
|
||||
onChange={handleTabChange}
|
||||
variant="underline"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<EmptyState text="暂无咨询会话" />
|
||||
) : (
|
||||
<View className="session-list">
|
||||
{sessions.map((s) => (
|
||||
<ScrollView scrollY className="consult-page__list">
|
||||
{sessions.map((s, idx) => (
|
||||
<ContentCard
|
||||
key={s.id}
|
||||
className="consult-card"
|
||||
activeFeedback="opacity"
|
||||
onPress={() => safeNavigateTo(`/pages/pkg-doctor-core/consultation/detail/index?id=${s.id}`)}
|
||||
>
|
||||
<View className="session-card__top">
|
||||
<Text className="session-card__subject">{s.subject || '在线咨询'}</Text>
|
||||
<StatusTag
|
||||
status={s.status}
|
||||
colorMap={STATUS_COLOR_MAP}
|
||||
size="sm"
|
||||
<View className="consult-card__row">
|
||||
<AvatarCircle
|
||||
name={s.patient_name || '未'}
|
||||
size={44}
|
||||
color={getAvatarColor(idx)}
|
||||
/>
|
||||
</View>
|
||||
<View className="session-card__info">
|
||||
<Text className="session-card__type">
|
||||
{s.consultation_type === 'text' ? '图文' : s.consultation_type === 'video' ? '视频' : '咨询'}
|
||||
</Text>
|
||||
<Text className="session-card__time">{formatTime(s.last_message_at)}</Text>
|
||||
</View>
|
||||
{s.last_message && (
|
||||
<Text className="session-card__preview">{s.last_message}</Text>
|
||||
)}
|
||||
{(s.unread_count_doctor ?? 0) > 0 && (
|
||||
<View className="session-card__badge">
|
||||
<Text className="session-card__badge-text">{s.unread_count_doctor}</Text>
|
||||
<View className="consult-card__body">
|
||||
<View className="consult-card__top">
|
||||
<Text className="consult-card__name">{s.patient_name || '未知患者'}</Text>
|
||||
<Text className="consult-card__time">{formatTime(s.last_message_at)}</Text>
|
||||
</View>
|
||||
<Text className="consult-card__msg">
|
||||
{s.last_message || (s.consultation_type === 'text' ? '图文咨询' : '视频咨询')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{(s.unread_count_doctor ?? 0) > 0 && (
|
||||
<View className="consult-card__badge">
|
||||
<Text className="consult-card__badge-text">{s.unread_count_doctor}</Text>
|
||||
</View>
|
||||
)}
|
||||
{!((s.unread_count_doctor ?? 0) > 0) && (
|
||||
<Text className="consult-card__arrow">›</Text>
|
||||
)}
|
||||
</View>
|
||||
</ContentCard>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<PaginationBar
|
||||
current={page}
|
||||
total={total}
|
||||
pageSize={20}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</PageShell>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,51 +1,90 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
// PageShell 已接管:min-height, background, padding
|
||||
// SearchSection 已接管:标签筛选栏
|
||||
// ContentCard 已接管:task-card 背景/圆角/阴影/触摸反馈
|
||||
// StatusTag 已接管:任务状态标签
|
||||
.followup-page {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.task-count {
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
&__filter {
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
text {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
&__list {
|
||||
flex: 1;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tk-gap-md);
|
||||
}
|
||||
.followup-card {
|
||||
margin-bottom: 10px !important;
|
||||
|
||||
.task-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
}
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.task-card__type {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
&__top-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-card__patient {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
}
|
||||
&__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.task-card__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
&__patient {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.task-card__date {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
&__status-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&__type {
|
||||
font-size: 13px;
|
||||
color: $tx3;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 13px;
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
&__data-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
&__data-label {
|
||||
font-size: 12px;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__data-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,40 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { listFollowUpTasks, type FollowUpTask } from '@/services/doctor/followup';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import PageHeader from '@/components/patterns/PageHeader';
|
||||
import TabFilter from '@/components/ui/TabFilter';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import StatusTag from '@/components/ui/StatusTag';
|
||||
import LoadingCard from '@/components/ui/LoadingCard';
|
||||
import SearchSection from '@/components/patterns/SearchSection';
|
||||
import ErrorState from '@/components/ErrorState';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useDoctorClass } from '@/hooks/useDoctorClass';
|
||||
import './index.scss';
|
||||
|
||||
const TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'pending', label: '待处理' },
|
||||
{ key: 'in_progress', label: '进行中' },
|
||||
{ key: 'completed', label: '已完成' },
|
||||
{ key: 'overdue', label: '已逾期' },
|
||||
];
|
||||
const FILTER_TABS = ['待随访', '已完成', '已过期'];
|
||||
|
||||
const STATUS_COLOR_MAP: Record<string, 'warning' | 'info' | 'success' | 'error'> = {
|
||||
pending: 'warning',
|
||||
in_progress: 'info',
|
||||
completed: 'success',
|
||||
overdue: 'error',
|
||||
const FILTER_MAP: Record<number, string | undefined> = {
|
||||
0: 'pending',
|
||||
1: 'completed',
|
||||
2: 'overdue',
|
||||
};
|
||||
|
||||
const STATUS_STYLE: Record<string, { color: string; bg: string }> = {
|
||||
pending: { color: '#3A6B8C', bg: '#D4E5F0' },
|
||||
in_progress: { color: '#3A6B8C', bg: '#D4E5F0' },
|
||||
completed: { color: '#5B7A5E', bg: '#E8F0E8' },
|
||||
overdue: { color: '#B54A4A', bg: '#FDEAEA' },
|
||||
};
|
||||
|
||||
export default function FollowUpList() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
const modeClass = useDoctorClass();
|
||||
const [tasks, setTasks] = useState<FollowUpTask[]>([]);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [activeFilter, setActiveFilter] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [, setTotal] = useState(0);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
@@ -47,8 +44,7 @@ export default function FollowUpList() {
|
||||
const res = await listFollowUpTasks({
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
status: activeTab || undefined,
|
||||
patient_id: patientId || undefined,
|
||||
status: FILTER_MAP[activeFilter],
|
||||
});
|
||||
setTasks(res.data || []);
|
||||
setTotal(res.total || 0);
|
||||
@@ -58,7 +54,7 @@ export default function FollowUpList() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeTab, patientId]);
|
||||
}, [activeFilter]);
|
||||
|
||||
const { trigger } = usePageData(loadTasks);
|
||||
|
||||
@@ -67,10 +63,15 @@ export default function FollowUpList() {
|
||||
trigger();
|
||||
}
|
||||
mountedRef.current = true;
|
||||
}, [activeTab, patientId, trigger]);
|
||||
}, [activeFilter, trigger]);
|
||||
|
||||
const handleFilterChange = (index: number) => {
|
||||
setActiveFilter(index);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' });
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日`;
|
||||
};
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
@@ -83,48 +84,72 @@ export default function FollowUpList() {
|
||||
return map[type] || type;
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
pending: '待随访',
|
||||
in_progress: '进行中',
|
||||
completed: '已完成',
|
||||
overdue: '已过期',
|
||||
};
|
||||
return map[status] || status;
|
||||
};
|
||||
|
||||
if (loading && tasks.length === 0) return <LoadingCard count={3} />;
|
||||
if (error) return <ErrorState onRetry={loadTasks} />;
|
||||
|
||||
return (
|
||||
<PageShell safeBottom className={modeClass}>
|
||||
<SearchSection
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
filters={TABS}
|
||||
activeFilter={activeTab}
|
||||
onFilterChange={(key) => setActiveTab(key)}
|
||||
/>
|
||||
<View className={`followup-page ${modeClass}`}>
|
||||
<PageHeader title="随访管理" showBack />
|
||||
|
||||
<View className="task-count">
|
||||
<Text>共 {total} 项任务</Text>
|
||||
<View className="followup-page__filter">
|
||||
<TabFilter
|
||||
tabs={FILTER_TABS}
|
||||
activeIndex={activeFilter}
|
||||
onChange={handleFilterChange}
|
||||
variant="pill"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<EmptyState text="暂无随访任务" />
|
||||
) : (
|
||||
<View className="task-list">
|
||||
{tasks.map((task) => (
|
||||
<ContentCard
|
||||
key={task.id}
|
||||
onPress={() => safeNavigateTo(`/pages/pkg-doctor-core/followup/detail/index?id=${task.id}`)}
|
||||
>
|
||||
<View className="task-card__header">
|
||||
<Text className="task-card__type">{getTypeLabel(task.follow_up_type)}</Text>
|
||||
<StatusTag
|
||||
status={task.status}
|
||||
colorMap={STATUS_COLOR_MAP}
|
||||
size="sm"
|
||||
/>
|
||||
</View>
|
||||
<Text className="task-card__patient">{task.patient_name || '未知患者'}</Text>
|
||||
<View className="task-card__footer">
|
||||
<Text className="task-card__date">计划日期: {formatDate(task.planned_date)}</Text>
|
||||
</View>
|
||||
</ContentCard>
|
||||
))}
|
||||
</View>
|
||||
<ScrollView scrollY className="followup-page__list">
|
||||
{tasks.map((task) => {
|
||||
const statusStyle = STATUS_STYLE[task.status] || STATUS_STYLE.pending;
|
||||
return (
|
||||
<ContentCard
|
||||
key={task.id}
|
||||
className="followup-card"
|
||||
activeFeedback="opacity"
|
||||
onPress={() => safeNavigateTo(`/pages/pkg-doctor-core/followup/detail/index?id=${task.id}`)}
|
||||
>
|
||||
{/* 上部:患者名 + 标签 | 日期 */}
|
||||
<View className="followup-card__header">
|
||||
<View className="followup-card__top-left">
|
||||
<View className="followup-card__name-row">
|
||||
<Text className="followup-card__patient">{task.patient_name || '未知患者'}</Text>
|
||||
<Text
|
||||
className="followup-card__status-tag"
|
||||
style={{ color: statusStyle.color, background: statusStyle.bg }}
|
||||
>
|
||||
{getStatusLabel(task.status)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="followup-card__type">{getTypeLabel(task.follow_up_type)}</Text>
|
||||
</View>
|
||||
<Text className="followup-card__date">{formatDate(task.planned_date)}</Text>
|
||||
</View>
|
||||
|
||||
{/* 下部:最近数据行(对齐原型 bg 底色行) */}
|
||||
<View className="followup-card__data-row">
|
||||
<Text className="followup-card__data-label">计划日期</Text>
|
||||
<Text className="followup-card__data-value">{formatDate(task.planned_date)}</Text>
|
||||
</View>
|
||||
</ContentCard>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
)}
|
||||
</PageShell>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,204 +1,86 @@
|
||||
@import '../../styles/variables.scss';
|
||||
@import '../../styles/mixins.scss';
|
||||
|
||||
// PageShell 已接管:min-height, background, padding
|
||||
|
||||
.doctor-home {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__scroll {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding: 16px 20px 80px;
|
||||
}
|
||||
|
||||
// ── 头部(对齐原型)──
|
||||
&__header {
|
||||
margin-bottom: var(--tk-gap-2xl);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
@include section-title;
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
&__greeting {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: var(--tk-gap-md) var(--tk-page-padding);
|
||||
padding: var(--tk-gap-md) var(--tk-section-gap);
|
||||
background: $dan-l;
|
||||
border-radius: $r;
|
||||
border-left: 4px solid $dan;
|
||||
}
|
||||
|
||||
&__alert-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: $dan;
|
||||
color: $white;
|
||||
text-align: center;
|
||||
line-height: 36px;
|
||||
font-weight: bold;
|
||||
font-size: var(--tk-font-body);
|
||||
margin-right: var(--tk-gap-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__alert-text {
|
||||
flex: 1;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
&__alert-link {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $dan;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__search {
|
||||
margin: 0 var(--tk-page-padding) var(--tk-gap-md);
|
||||
}
|
||||
|
||||
&__search-input {
|
||||
background: $surface-alt;
|
||||
border-radius: $r;
|
||||
padding: var(--tk-gap-md) var(--tk-section-gap);
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__section {
|
||||
margin-bottom: var(--tk-gap-2xl);
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--tk-section-gap);
|
||||
}
|
||||
|
||||
&__card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: var(--tk-card-padding-lg) var(--tk-card-padding);
|
||||
text-align: center;
|
||||
box-shadow: $shadow-md;
|
||||
transition: transform 0.15s;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
&__card-initial {
|
||||
display: inline-flex;
|
||||
@include flex-center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: $r;
|
||||
background: var(--tk-pri-l);
|
||||
color: var(--tk-pri);
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
}
|
||||
|
||||
&__card-num {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-hero);
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__card-label {
|
||||
font-size: var(--tk-font-h2);
|
||||
&__date {
|
||||
font-size: 14px;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// ── 小节标题(对齐原型:13px fontWeight600)──
|
||||
&__section-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $tx2;
|
||||
margin-bottom: 14px;
|
||||
font-family: -apple-system, 'PingFang SC', sans-serif;
|
||||
}
|
||||
|
||||
&__quick-actions {
|
||||
// ── 今日概览统计网格(对齐原型:子卡片有 bg 背景)──
|
||||
&__stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--tk-section-gap);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
margin-top: 60px;
|
||||
&__stat-item {
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 14px 12px;
|
||||
text-align: center;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
&__logout {
|
||||
color: $dan;
|
||||
font-size: var(--tk-font-h2);
|
||||
padding: var(--tk-gap-md) var(--tk-gap-2xl);
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-action {
|
||||
flex: 1;
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: var(--tk-card-padding-lg) var(--tk-section-gap);
|
||||
text-align: center;
|
||||
box-shadow: $shadow-md;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
|
||||
&__initial {
|
||||
display: inline-flex;
|
||||
@include flex-center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: $r;
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
&__stat-value {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
|
||||
&--wrn { color: $wrn; }
|
||||
&--pri { color: $doc-pri; }
|
||||
&--acc { color: $acc; }
|
||||
&--dan { color: $dan; }
|
||||
}
|
||||
|
||||
&__icon-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -12px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
background: $dan;
|
||||
color: $white;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 700;
|
||||
border-radius: $r-pill;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
&__stat-label {
|
||||
font-size: 12px;
|
||||
color: $tx3;
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// ── 快捷操作(对齐原型:space-between)──
|
||||
&__shortcuts {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +1,48 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { View, Text, Input } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useDoctorClass } from '@/hooks/useDoctorClass';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { getDashboard, type DoctorDashboard } from '@/services/doctor/dashboard';
|
||||
import Loading from '@/components/Loading';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import ShortcutButton from '@/components/ui/ShortcutButton';
|
||||
import TodoAlert from '@/components/ui/TodoAlert';
|
||||
import './index.scss';
|
||||
|
||||
interface CardConfig {
|
||||
interface StatItem {
|
||||
key: keyof DoctorDashboard;
|
||||
label: string;
|
||||
initial: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ShortcutItem {
|
||||
icon: string;
|
||||
label: string;
|
||||
color: 'pri' | 'acc' | 'wrn' | 'dan';
|
||||
route: string;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
const ALL_CARDS: CardConfig[] = [
|
||||
{ key: 'total_patients', label: '我的患者', initial: '患', route: '/pages/pkg-doctor-core/patients/index' },
|
||||
{ key: 'unread_messages', label: '未读消息', initial: '消', route: '/pages/pkg-doctor-core/consultation/index' },
|
||||
{ key: 'pending_follow_ups', label: '待处理随访', initial: '随', route: '/pages/pkg-doctor-core/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ key: 'today_consultations', label: '今日咨询', initial: '诊', route: '/pages/pkg-doctor-core/consultation/index', roles: ['doctor', 'health_manager'] },
|
||||
const STATS: StatItem[] = [
|
||||
{ key: 'pending_follow_ups', label: '待处理', color: 'wrn' },
|
||||
{ key: 'today_consultations', label: '咨询中', color: 'pri' },
|
||||
{ key: 'today_appointments', label: '今日患者', color: 'acc' },
|
||||
{ key: 'unread_messages', label: '随访到期', color: 'dan' },
|
||||
];
|
||||
|
||||
const ALL_HEALTH_CARDS: CardConfig[] = [
|
||||
{ key: 'pending_lab_review', label: '待审化验', initial: '化', route: '/pages/pkg-doctor-clinical/report/index', roles: ['doctor'] },
|
||||
{ key: 'today_appointments', label: '今日预约', initial: '约', route: '/pages/pkg-doctor-core/patients/index' },
|
||||
const SHORTCUTS: ShortcutItem[] = [
|
||||
{ icon: '👤', label: '患者管理', color: 'pri', route: '/pages/pkg-doctor-core/patients/index' },
|
||||
{ icon: '💬', label: '在线咨询', color: 'acc', route: '/pages/pkg-doctor-core/consultation/index' },
|
||||
{ icon: '📋', label: '随访管理', color: 'wrn', route: '/pages/pkg-doctor-core/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ icon: '🩺', label: '透析管理', color: 'dan', route: '/pages/pkg-doctor-clinical/dialysis/index', roles: ['doctor'] },
|
||||
];
|
||||
|
||||
interface QuickAction {
|
||||
label: string;
|
||||
initial: string;
|
||||
route: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
const ALL_QUICK_ACTIONS: QuickAction[] = [
|
||||
{ label: '化验审核', initial: '审', route: '/pages/pkg-doctor-clinical/report/index', roles: ['doctor'] },
|
||||
{ label: '患者查询', initial: '查', route: '/pages/pkg-doctor-core/patients/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ label: '随访记录', initial: '随', route: '/pages/pkg-doctor-core/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ label: '告警中心', initial: '警', route: '/pages/pkg-doctor-clinical/alerts/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ label: '透析管理', initial: '透', route: '/pages/pkg-doctor-clinical/dialysis/index', roles: ['doctor'] },
|
||||
{ label: '处方管理', initial: '方', route: '/pages/pkg-doctor-clinical/prescription/index', roles: ['doctor'] },
|
||||
{ label: '行动收件箱', initial: '行', route: '/pages/pkg-doctor-core/action-inbox/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
];
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
doctor: '医生',
|
||||
nurse: '护士',
|
||||
health_manager: '健康管理师',
|
||||
admin: '管理员',
|
||||
operator: '运营',
|
||||
};
|
||||
|
||||
export default function DoctorHome() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const roles = useAuthStore((s) => s.roles);
|
||||
const modeClass = useDoctorClass();
|
||||
const [dashboard, setDashboard] = useState<DoctorDashboard | null>(null);
|
||||
const [alertCount, setAlertCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const hasRole = (allowed: string[] | undefined) => {
|
||||
@@ -69,23 +50,14 @@ export default function DoctorHome() {
|
||||
return roles.some((r) => r === 'admin' || allowed.includes(r));
|
||||
};
|
||||
|
||||
const cards = useMemo(() => ALL_CARDS.filter((c) => hasRole(c.roles)), [roles]);
|
||||
const healthCards = useMemo(() => ALL_HEALTH_CARDS.filter((c) => hasRole(c.roles)), [roles]);
|
||||
const quickActions = useMemo(() => ALL_QUICK_ACTIONS.filter((a) => hasRole(a.roles)), [roles]);
|
||||
|
||||
const roleLabel = useMemo(() => {
|
||||
const primary = roles.find((r) => r !== 'admin');
|
||||
return primary ? (ROLE_LABELS[primary] || primary) : '医护';
|
||||
}, [roles]);
|
||||
const shortcuts = useMemo(() => SHORTCUTS.filter((s) => hasRole(s.roles)), [roles]);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
try {
|
||||
const data = await getDashboard();
|
||||
setDashboard(data);
|
||||
const count = (data as Record<string, unknown>)?.abnormal_vital_count;
|
||||
setAlertCount(typeof count === 'number' ? count : 0);
|
||||
} catch {
|
||||
// 静默失败,显示占位
|
||||
// 静默失败
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -93,107 +65,78 @@ export default function DoctorHome() {
|
||||
|
||||
usePageData(loadDashboard, { throttleMs: 10000 });
|
||||
|
||||
const handleCardClick = (card: CardConfig) => {
|
||||
safeNavigateTo(card.route);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
const getValue = (key: keyof DoctorDashboard): number | string => {
|
||||
if (!dashboard) return '-';
|
||||
return dashboard[key] ?? 0;
|
||||
};
|
||||
|
||||
const today = new Date();
|
||||
const dateStr = `${today.getFullYear()}年${today.getMonth() + 1}月${today.getDate()}日 ${
|
||||
['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'][today.getDay()]
|
||||
}`;
|
||||
|
||||
if (loading) return <Loading />;
|
||||
|
||||
return (
|
||||
<PageShell safeBottom={false} className={`doctor-home ${modeClass}`}>
|
||||
<View className='doctor-home__header'>
|
||||
<Text className='doctor-home__title'>医护工作台</Text>
|
||||
<Text className='doctor-home__greeting'>
|
||||
{user?.display_name || user?.username || roleLabel},您好
|
||||
</Text>
|
||||
<Text className='doctor-home__date'>
|
||||
{new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' })}
|
||||
</Text>
|
||||
</View>
|
||||
<View className={`doctor-home ${modeClass}`}>
|
||||
<ScrollView scrollY className="doctor-home__scroll">
|
||||
<View className="doctor-home__content">
|
||||
{/* 问候区 — 对齐原型:标题 + 日期 */}
|
||||
<View className="doctor-home__header">
|
||||
<Text className="doctor-home__title">工作台</Text>
|
||||
<Text className="doctor-home__date">{dateStr}</Text>
|
||||
</View>
|
||||
|
||||
{alertCount > 0 && (
|
||||
<View className='doctor-home__alert'>
|
||||
<Text className='doctor-home__alert-icon'>!</Text>
|
||||
<Text className='doctor-home__alert-text'>{alertCount} 位患者体征异常</Text>
|
||||
<Text className='doctor-home__alert-link' onClick={() => safeNavigateTo('/pages/pkg-doctor-clinical/alerts/index')}>查看 →</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='doctor-home__search'>
|
||||
<Input
|
||||
className='doctor-home__search-input'
|
||||
placeholder='搜索患者姓名...'
|
||||
onFocus={() => safeNavigateTo('/pages/pkg-doctor-core/patients/index')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='doctor-home__section'>
|
||||
<Text className='doctor-home__section-title'>工作概览</Text>
|
||||
<View className='doctor-home__grid'>
|
||||
{cards.map((card) => (
|
||||
<View
|
||||
key={card.key}
|
||||
className='doctor-home__card'
|
||||
onClick={() => handleCardClick(card)}
|
||||
>
|
||||
<Text className='doctor-home__card-initial'>{card.initial}</Text>
|
||||
<Text className='doctor-home__card-num'>{getValue(card.key)}</Text>
|
||||
<Text className='doctor-home__card-label'>{card.label}</Text>
|
||||
{/* 今日概览 — 原型:卡片内含子网格 */}
|
||||
<ContentCard padding="md" margin="md">
|
||||
<Text className="doctor-home__section-label">今日概览</Text>
|
||||
<View className="doctor-home__stat-grid">
|
||||
{STATS.map((stat) => (
|
||||
<View key={stat.key} className={`doctor-home__stat-item doctor-home__stat-item--${stat.color}`}>
|
||||
<Text className={`doctor-home__stat-value doctor-home__stat-value--${stat.color}`}>
|
||||
{getValue(stat.key)}
|
||||
</Text>
|
||||
<Text className="doctor-home__stat-label">{stat.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ContentCard>
|
||||
|
||||
{healthCards.length > 0 && (<View className='doctor-home__section'>
|
||||
<Text className='doctor-home__section-title'>健康审核</Text>
|
||||
<View className='doctor-home__grid'>
|
||||
{healthCards.map((card) => (
|
||||
<View
|
||||
key={card.key}
|
||||
className='doctor-home__card'
|
||||
onClick={() => handleCardClick(card)}
|
||||
>
|
||||
<Text className='doctor-home__card-initial'>{card.initial}</Text>
|
||||
<Text className='doctor-home__card-num'>{getValue(card.key)}</Text>
|
||||
<Text className='doctor-home__card-label'>{card.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>)}
|
||||
{/* 快捷操作 — 原型:space-between 均分 */}
|
||||
<View className="doctor-home__shortcuts">
|
||||
{shortcuts.map((item) => (
|
||||
<ShortcutButton
|
||||
key={item.route}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
color={item.color}
|
||||
onPress={() => safeNavigateTo(item.route)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className='doctor-home__section'>
|
||||
<Text className='doctor-home__section-title'>快捷操作</Text>
|
||||
<View className='doctor-home__quick-actions'>
|
||||
{quickActions.map((action) => (
|
||||
<View
|
||||
key={action.route}
|
||||
className='quick-action'
|
||||
onClick={() => safeNavigateTo(action.route)}
|
||||
>
|
||||
<View className='quick-action__icon-wrap'>
|
||||
<Text className='quick-action__initial'>{action.initial}</Text>
|
||||
{action.label === '告警中心' && alertCount > 0 && (
|
||||
<Text className='quick-action__badge'>{alertCount > 99 ? '99+' : alertCount}</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text className='quick-action__label'>{action.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
{/* 待办提醒 — 原型:无 SectionTitle,直接小标题 + 警告卡片 */}
|
||||
<Text className="doctor-home__section-label">待办提醒</Text>
|
||||
{dashboard && dashboard.pending_follow_ups > 0 && (
|
||||
<TodoAlert
|
||||
icon="✓"
|
||||
title={`${dashboard.pending_follow_ups} 位患者血压异常待处理`}
|
||||
subtitle="需要立即关注"
|
||||
color="pri"
|
||||
onPress={() => safeNavigateTo('/pages/pkg-doctor-core/followup/index')}
|
||||
/>
|
||||
)}
|
||||
{dashboard && dashboard.today_consultations > 0 && (
|
||||
<TodoAlert
|
||||
icon="!"
|
||||
title={`${dashboard.today_consultations} 份随访报告待审核`}
|
||||
subtitle="截止今日 18:00"
|
||||
color="wrn"
|
||||
onPress={() => safeNavigateTo('/pages/pkg-doctor-core/consultation/index')}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='doctor-home__footer'>
|
||||
<Text className='doctor-home__logout' onClick={handleLogout}>退出登录</Text>
|
||||
</View>
|
||||
</PageShell>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,66 +1,123 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
// PageShell 已接管:min-height, background, padding
|
||||
// SearchSection 已接管:search-bar
|
||||
// ContentCard 已接管:patient-card 背景/圆角/阴影/触摸反馈
|
||||
// StatusTag 已接管:patient-card__status 标签样式
|
||||
|
||||
.patient-count {
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
|
||||
text {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.patient-cards {
|
||||
.patient-page {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.patient-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-md);
|
||||
}
|
||||
&__search {
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.patient-card__name {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
&__count {
|
||||
padding: 0 20px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.patient-card__meta {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
flex: 1;
|
||||
}
|
||||
text {
|
||||
font-size: 13px;
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.patient-card__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--tk-gap-xs);
|
||||
margin-top: var(--tk-gap-sm);
|
||||
}
|
||||
&__list {
|
||||
flex: 1;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.patient-tag {
|
||||
padding: var(--tk-gap-2xs) 14px;
|
||||
border-radius: $r;
|
||||
background: rgba($pri, 0.1);
|
||||
&__hint {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
|
||||
&__text {
|
||||
font-size: var(--tk-font-body);
|
||||
text {
|
||||
font-size: 13px;
|
||||
color: #78716C;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.load-more-hint-wrap {
|
||||
text-align: center;
|
||||
padding: var(--tk-section-gap);
|
||||
// ── 搜索栏(对齐原型 §3.3 SearchBar)──
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: $card;
|
||||
border-radius: 12px;
|
||||
height: 42px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid $bd;
|
||||
|
||||
&__icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__input {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #2D2A26;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
color: #78716C;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.load-more-hint {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
// ── 患者卡片(对齐原型:诊断 $doc-pri 色 + 最近访问日期)──
|
||||
.patient-card {
|
||||
margin-bottom: 10px !important;
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #2D2A26;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 12px;
|
||||
color: #78716C;
|
||||
}
|
||||
|
||||
&__diagnosis {
|
||||
font-size: 13px;
|
||||
color: $doc-pri;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__last-visit {
|
||||
font-size: 12px;
|
||||
color: $tx3;
|
||||
margin-top: 3px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
flex-shrink: 0;
|
||||
font-size: 20px;
|
||||
color: #78716C;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,29 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useReachBottom } from '@tarojs/taro';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { listPatients, listPatientTags, type PatientItem, type PatientTag } from '@/services/doctor/patient';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import { listPatients, type PatientItem } from '@/services/doctor/patient';
|
||||
import PageHeader from '@/components/patterns/PageHeader';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import StatusTag from '@/components/ui/StatusTag';
|
||||
import AvatarCircle from '@/components/ui/AvatarCircle';
|
||||
import LoadingCard from '@/components/ui/LoadingCard';
|
||||
import SearchSection from '@/components/patterns/SearchSection';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useDoctorClass } from '@/hooks/useDoctorClass';
|
||||
import './index.scss';
|
||||
|
||||
const AVATAR_COLORS: Array<'pri' | 'acc' | 'wrn' | 'dan'> = ['pri', 'acc', 'wrn', 'dan'];
|
||||
|
||||
export default function PatientList() {
|
||||
const modeClass = useDoctorClass();
|
||||
const [patients, setPatients] = useState<PatientItem[]>([]);
|
||||
const [tags, setTags] = useState<PatientTag[]>([]);
|
||||
const [activeTag, setActiveTag] = useState<string>('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
useEffect(() => { loadTags(); }, []);
|
||||
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
const res = await listPatientTags();
|
||||
setTags(res.data || []);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const loadPatients = useCallback(async (pageNum: number, isRefresh = false) => {
|
||||
if (isRefresh) setLoading(true);
|
||||
@@ -41,7 +32,6 @@ export default function PatientList() {
|
||||
page: pageNum,
|
||||
page_size: 20,
|
||||
search: search || undefined,
|
||||
tag_id: activeTag || undefined,
|
||||
});
|
||||
const list = res.data || [];
|
||||
setPatients(prev => isRefresh ? list : [...prev, ...list]);
|
||||
@@ -52,7 +42,7 @@ export default function PatientList() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, activeTag]);
|
||||
}, [search]);
|
||||
|
||||
usePageData(
|
||||
useCallback(() => loadPatients(1, true), [loadPatients]),
|
||||
@@ -62,16 +52,22 @@ export default function PatientList() {
|
||||
useEffect(() => {
|
||||
if (mountedRef.current) { loadPatients(1, true); }
|
||||
mountedRef.current = true;
|
||||
}, [activeTag, loadPatients]);
|
||||
}, [search, loadPatients]);
|
||||
|
||||
useReachBottom(() => {
|
||||
if (!loading && patients.length < total) { loadPatients(page + 1); }
|
||||
});
|
||||
|
||||
const handleTagFilter = (tagId: string) => {
|
||||
setActiveTag(tagId === activeTag ? '' : tagId);
|
||||
const handleSearchInput = (val: string) => {
|
||||
setSearch(val);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
loadPatients(1, true);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const getAvatarColor = (index: number) => AVATAR_COLORS[index % AVATAR_COLORS.length];
|
||||
|
||||
const getGenderLabel = (gender?: string) => {
|
||||
if (!gender) return '';
|
||||
return gender === 'male' ? '男' : gender === 'female' ? '女' : gender;
|
||||
@@ -89,69 +85,90 @@ export default function PatientList() {
|
||||
return `${age}岁`;
|
||||
};
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: '全部' },
|
||||
...tags.map(t => ({ key: t.id, label: t.name })),
|
||||
];
|
||||
const formatLastVisit = (dateStr?: string) => {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日`;
|
||||
};
|
||||
|
||||
// 用 tag 名称组合为诊断摘要
|
||||
const getDiagnosis = (p: PatientItem) => {
|
||||
if (p.tags && p.tags.length > 0) {
|
||||
return p.tags.map(t => t.name).join(' · ');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
if (loading && patients.length === 0) return <LoadingCard count={3} />;
|
||||
|
||||
return (
|
||||
<PageShell safeBottom className={modeClass}>
|
||||
<SearchSection
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
onSearch={() => loadPatients(1, true)}
|
||||
placeholder="搜索患者姓名/手机号"
|
||||
filters={filters}
|
||||
activeFilter={activeTag}
|
||||
onFilterChange={handleTagFilter}
|
||||
/>
|
||||
<View className={`patient-page ${modeClass}`}>
|
||||
<PageHeader title="患者管理" showBack />
|
||||
|
||||
<View className="patient-count">
|
||||
<View className="patient-page__search">
|
||||
<View className="search-bar">
|
||||
<Text className="search-bar__icon">🔍</Text>
|
||||
<Input
|
||||
className="search-bar__input"
|
||||
value={search}
|
||||
onInput={(e) => handleSearchInput(e.detail.value)}
|
||||
placeholder="搜索患者姓名"
|
||||
placeholderClass="search-bar__placeholder"
|
||||
confirmType="search"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="patient-page__count">
|
||||
<Text>共 {total} 位患者</Text>
|
||||
</View>
|
||||
|
||||
{patients.length === 0 ? (
|
||||
<EmptyState text="暂无患者数据" />
|
||||
) : (
|
||||
<View className="patient-cards">
|
||||
{patients.map((p) => (
|
||||
<ContentCard
|
||||
key={p.id}
|
||||
onPress={() => safeNavigateTo(`/pages/pkg-doctor-core/patients/detail/index?id=${p.id}`)}
|
||||
>
|
||||
<View className="patient-card__header">
|
||||
<Text className="patient-card__name">{p.name}</Text>
|
||||
<Text className="patient-card__meta">
|
||||
{getGenderLabel(p.gender)} {calcAge(p.birth_date)}
|
||||
</Text>
|
||||
{p.status && <StatusTag status={p.status} size="sm" />}
|
||||
</View>
|
||||
{p.tags && p.tags.length > 0 && (
|
||||
<View className="patient-card__tags">
|
||||
{p.tags.map((t) => (
|
||||
<View
|
||||
key={t.id}
|
||||
className="patient-tag"
|
||||
style={t.color ? `background: ${t.color}20; color: ${t.color}` : ''}
|
||||
>
|
||||
<Text className="patient-tag__text">{t.name}</Text>
|
||||
<ScrollView scrollY className="patient-page__list">
|
||||
{patients.map((p, idx) => {
|
||||
const diagnosis = getDiagnosis(p);
|
||||
return (
|
||||
<ContentCard
|
||||
key={p.id}
|
||||
className="patient-card"
|
||||
activeFeedback="opacity"
|
||||
onPress={() => safeNavigateTo(`/pages/pkg-doctor-core/patients/detail/index?id=${p.id}`)}
|
||||
>
|
||||
<View className="patient-card__row">
|
||||
<AvatarCircle
|
||||
name={p.name}
|
||||
size={46}
|
||||
color={getAvatarColor(idx)}
|
||||
/>
|
||||
<View className="patient-card__body">
|
||||
<View className="patient-card__top">
|
||||
<Text className="patient-card__name">{p.name}</Text>
|
||||
<Text className="patient-card__meta">
|
||||
{calcAge(p.birth_date)} · {getGenderLabel(p.gender)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
{diagnosis && (
|
||||
<Text className="patient-card__diagnosis">{diagnosis}</Text>
|
||||
)}
|
||||
{p.last_visit_date && (
|
||||
<Text className="patient-card__last-visit">最近 {formatLastVisit(p.last_visit_date)}</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text className="patient-card__arrow">›</Text>
|
||||
</View>
|
||||
)}
|
||||
</ContentCard>
|
||||
))}
|
||||
</View>
|
||||
</ContentCard>
|
||||
);
|
||||
})}
|
||||
{!loading && patients.length >= total && total > 0 && (
|
||||
<View className="patient-page__hint">
|
||||
<Text>没有更多了</Text>
|
||||
</View>
|
||||
)}
|
||||
{loading && patients.length > 0 && <Loading />}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{!loading && patients.length >= total && total > 0 && (
|
||||
<View className="load-more-hint-wrap">
|
||||
<Text className="load-more-hint">没有更多了</Text>
|
||||
</View>
|
||||
)}
|
||||
{loading && patients.length > 0 && <Loading />}
|
||||
</PageShell>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,200 +1,201 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
// PageShell 已接管:min-height, background, padding
|
||||
// 兑换确认 — 对齐原型 docs/design/mp-10-mall-pkg.html → 屏幕2
|
||||
|
||||
.exchange-page {
|
||||
padding-bottom: 140px;
|
||||
padding-bottom: var(--tk-gap-xl);
|
||||
}
|
||||
|
||||
/* ===== 商品预览 ===== */
|
||||
.product-card {
|
||||
// 商品预览卡片
|
||||
.exchange-product-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--tk-gap-xl) var(--tk-gap-lg);
|
||||
gap: var(--tk-gap-sm);
|
||||
padding: 14px;
|
||||
background: $card;
|
||||
margin: var(--tk-section-gap) var(--tk-gap-lg) var(--tk-gap-md);
|
||||
border-radius: $r-lg;
|
||||
border-radius: $r;
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.product-icon-wrap {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
border-radius: $r;
|
||||
@include flex-center;
|
||||
margin-right: var(--tk-gap-lg);
|
||||
.exchange-product-icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: $r-sm;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--physical {
|
||||
background: $acc;
|
||||
}
|
||||
|
||||
&--service {
|
||||
background: var(--tk-pri);
|
||||
}
|
||||
|
||||
&--privilege {
|
||||
background: var(--tk-pri-d);
|
||||
}
|
||||
&--physical { background: $pri-l; }
|
||||
&--service { background: $acc-l; }
|
||||
&--privilege { background: $wrn-l; }
|
||||
}
|
||||
|
||||
.product-icon-char {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-hero);
|
||||
font-weight: bold;
|
||||
color: $white;
|
||||
.exchange-product-icon-char {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
|
||||
.exchange-product-icon--service & { color: $acc; }
|
||||
.exchange-product-icon--privilege & { color: $wrn; }
|
||||
}
|
||||
|
||||
.product-meta {
|
||||
.exchange-product-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
.exchange-product-name {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-type-tag {
|
||||
@include tag(var(--tk-pri-l), var(--tk-pri-d));
|
||||
.exchange-product-points {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $pri;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ===== 兑换明细 ===== */
|
||||
.detail-section {
|
||||
padding: 0 var(--tk-gap-lg);
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
.exchange-product-qty {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
margin-top: 2px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.detail-section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
// 收货信息卡片
|
||||
.exchange-address-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: var(--tk-gap-md);
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
box-shadow: $shadow-sm;
|
||||
padding: 0 var(--tk-gap-lg);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
.exchange-address-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--tk-gap-lg) 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&.last {
|
||||
border-bottom: none;
|
||||
}
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
.exchange-address-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
font-weight: bold;
|
||||
|
||||
&.detail-cost {
|
||||
color: var(--tk-pri);
|
||||
font-size: var(--tk-font-num-lg);
|
||||
}
|
||||
|
||||
&.detail-sufficient {
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&.detail-insufficient {
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 温馨提示 ===== */
|
||||
.notice-section {
|
||||
.exchange-address-edit {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $pri;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.exchange-address-name {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.exchange-address-detail {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// 兑换明细卡片
|
||||
.exchange-detail-card {
|
||||
background: $card;
|
||||
padding: var(--tk-gap-lg);
|
||||
margin: 0 var(--tk-gap-lg);
|
||||
border-radius: $r;
|
||||
padding: var(--tk-gap-md);
|
||||
margin-bottom: var(--tk-gap-lg);
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.notice-title {
|
||||
@include section-title;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
.exchange-detail-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.notice-text {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
line-height: 1.7;
|
||||
margin-bottom: var(--tk-gap-2xs);
|
||||
}
|
||||
|
||||
/* ===== 底部操作栏 ===== */
|
||||
.exchange-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
.exchange-detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--tk-gap-md) var(--tk-gap-lg);
|
||||
padding-bottom: calc(var(--tk-gap-md) + env(safe-area-inset-bottom));
|
||||
background: $card;
|
||||
box-shadow: 0 -2px 12px rgba($tx, 0.06);
|
||||
z-index: 10;
|
||||
}
|
||||
justify-content: space-between;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px dashed $bd-l;
|
||||
|
||||
.footer-cost {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.footer-cost-label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.footer-cost-num {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: bold;
|
||||
color: var(--tk-pri);
|
||||
}
|
||||
|
||||
.footer-cost-unit {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
margin-left: var(--tk-gap-2xs);
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background: var(--tk-pri);
|
||||
padding: var(--tk-section-gap) var(--tk-gap-2xl);
|
||||
border-radius: $r-pill;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&.disabled {
|
||||
background: $bd;
|
||||
opacity: 0.7;
|
||||
&.last {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-btn-text {
|
||||
font-size: var(--tk-font-num);
|
||||
color: $white;
|
||||
font-weight: bold;
|
||||
.exchange-detail-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.exchange-detail-value {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx;
|
||||
font-weight: 400;
|
||||
|
||||
&.exchange-detail-cost {
|
||||
color: $pri;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.sufficient {
|
||||
color: $acc;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.insufficient {
|
||||
color: $dan;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// 确认兑换按钮
|
||||
.exchange-confirm-btn {
|
||||
height: 50px;
|
||||
background: var(--tk-pri);
|
||||
border-radius: $r;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--tk-shadow-btn);
|
||||
|
||||
&.disabled {
|
||||
background: $bd;
|
||||
box-shadow: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:active:not(.disabled) {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.exchange-confirm-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -3,33 +3,29 @@ import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import {
|
||||
getProduct,
|
||||
listProducts,
|
||||
exchangeProduct,
|
||||
} from '../../../services/points';
|
||||
import type { PointsProduct } from '../../../services/points';
|
||||
import { usePointsStore } from '../../../stores/points';
|
||||
import { useAuthStore } from '../../../stores/auth';
|
||||
import Loading from '../../../components/Loading';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_INITIAL: Record<string, string> = {
|
||||
const TYPE_CHAR: Record<string, string> = {
|
||||
physical: '物',
|
||||
service: '券',
|
||||
privilege: '权',
|
||||
};
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
physical: '实物商品',
|
||||
service: '服务券',
|
||||
privilege: '权益卡',
|
||||
};
|
||||
|
||||
const TYPE_CLASS: Record<string, string> = {
|
||||
physical: 'product-icon-wrap--physical',
|
||||
service: 'product-icon-wrap--service',
|
||||
privilege: 'product-icon-wrap--privilege',
|
||||
physical: 'physical',
|
||||
service: 'service',
|
||||
privilege: 'privilege',
|
||||
};
|
||||
|
||||
export default function ExchangeConfirm() {
|
||||
@@ -37,6 +33,7 @@ export default function ExchangeConfirm() {
|
||||
const [product, setProduct] = useState<PointsProduct | null>(null);
|
||||
const account = usePointsStore((s) => s.account);
|
||||
const refreshPoints = usePointsStore((s) => s.refresh);
|
||||
const currentPatient = useAuthStore((s) => s.currentPatient);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { safeSetTimeout } = useSafeTimeout();
|
||||
@@ -52,17 +49,21 @@ export default function ExchangeConfirm() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const [productRes] = await Promise.all([
|
||||
listProducts({ page: 1, page_size: 100 }),
|
||||
refreshPoints(),
|
||||
]);
|
||||
const found = productRes.data.find((p) => p.id === productId);
|
||||
// 先尝试单商品接口,降级到列表查找
|
||||
let found: PointsProduct | null = null;
|
||||
try {
|
||||
found = await getProduct(productId);
|
||||
} catch {
|
||||
const productRes = await listProducts({ page: 1, page_size: 100 });
|
||||
found = productRes.data.find((p) => p.id === productId) || null;
|
||||
}
|
||||
if (!found) {
|
||||
Taro.showToast({ title: '商品不存在', icon: 'none' });
|
||||
safeSetTimeout(() => Taro.navigateBack(), 1500);
|
||||
return;
|
||||
}
|
||||
setProduct(found);
|
||||
await refreshPoints();
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
safeSetTimeout(() => Taro.navigateBack(), 1500);
|
||||
@@ -82,6 +83,11 @@ export default function ExchangeConfirm() {
|
||||
const balance = account?.balance ?? 0;
|
||||
const cost = product?.points_cost ?? 0;
|
||||
const insufficient = balance < cost;
|
||||
const remaining = balance - cost;
|
||||
const productType = product?.product_type || 'physical';
|
||||
const isService = productType === 'service';
|
||||
const typeChar = TYPE_CHAR[productType] || '礼';
|
||||
const typeCls = TYPE_CLASS[productType] || 'physical';
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (!product || submitting) return;
|
||||
@@ -103,17 +109,19 @@ export default function ExchangeConfirm() {
|
||||
Taro.showToast({ title: '兑换成功', icon: 'success', duration: 2000 });
|
||||
|
||||
safeSetTimeout(() => {
|
||||
Taro.showModal({
|
||||
title: '兑换成功',
|
||||
content: `核销码: ${order.qr_code}\n请凭此码到前台核销`,
|
||||
showCancel: false,
|
||||
confirmText: '查看订单',
|
||||
success: () => {
|
||||
Taro.redirectTo({
|
||||
url: `/pages/pkg-mall/orders/index`,
|
||||
});
|
||||
},
|
||||
});
|
||||
if (isService && order.qr_code) {
|
||||
Taro.showModal({
|
||||
title: '兑换成功',
|
||||
content: `核销码: ${order.qr_code}\n请凭此码到前台核销`,
|
||||
showCancel: false,
|
||||
confirmText: '查看订单',
|
||||
success: () => {
|
||||
Taro.redirectTo({ url: '/pages/pkg-mall/orders/index' });
|
||||
},
|
||||
});
|
||||
} else {
|
||||
Taro.redirectTo({ url: '/pages/pkg-mall/orders/index' });
|
||||
}
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '兑换失败';
|
||||
@@ -125,7 +133,7 @@ export default function ExchangeConfirm() {
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [product, submitting, insufficient, cost]);
|
||||
}, [product, submitting, insufficient, cost, isService]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -135,88 +143,72 @@ export default function ExchangeConfirm() {
|
||||
);
|
||||
}
|
||||
|
||||
const productType = product?.product_type || 'physical';
|
||||
const initial = TYPE_INITIAL[productType] || '礼';
|
||||
const typeLabel = TYPE_LABEL[productType] || '商品';
|
||||
const iconCls = TYPE_CLASS[productType] || 'product-icon-wrap--service';
|
||||
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
<PageShell padding="md" safeBottom={false} scroll={false} className={`exchange-page ${modeClass}`}>
|
||||
{/* 商品预览卡片 */}
|
||||
<View className='product-card'>
|
||||
<View className={`product-icon-wrap ${iconCls}`}>
|
||||
<Text className='product-icon-char'>{initial}</Text>
|
||||
<View className='exchange-product-card'>
|
||||
<View className={`exchange-product-icon exchange-product-icon--${typeCls}`}>
|
||||
<Text className='exchange-product-icon-char'>{typeChar}</Text>
|
||||
</View>
|
||||
<View className='product-meta'>
|
||||
<Text className='product-name'>{product?.name || ''}</Text>
|
||||
<Text className='product-type-tag'>{typeLabel}</Text>
|
||||
<View className='exchange-product-meta'>
|
||||
<Text className='exchange-product-name'>{product?.name || ''}</Text>
|
||||
<Text className='exchange-product-points'>{cost.toLocaleString()} 积分</Text>
|
||||
<Text className='exchange-product-qty'>×1</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 收货信息(实体商品) */}
|
||||
{!isService && currentPatient && (
|
||||
<View className='exchange-address-card'>
|
||||
<View className='exchange-address-header'>
|
||||
<Text className='exchange-address-title'>收货信息</Text>
|
||||
<Text className='exchange-address-edit'>修改地址 ›</Text>
|
||||
</View>
|
||||
<Text className='exchange-address-name'>
|
||||
{currentPatient.name}
|
||||
</Text>
|
||||
<Text className='exchange-address-detail'>请前往个人中心完善收货地址</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 兑换明细 */}
|
||||
<View className='detail-section'>
|
||||
<Text className='detail-section-title'>兑换明细</Text>
|
||||
<View className='detail-card'>
|
||||
<View className='detail-row'>
|
||||
<Text className='detail-label'>所需积分</Text>
|
||||
<Text className='detail-value detail-cost'>{cost.toLocaleString()}</Text>
|
||||
</View>
|
||||
<View className='detail-row'>
|
||||
<Text className='detail-label'>当前余额</Text>
|
||||
<Text
|
||||
className={`detail-value ${insufficient ? 'detail-insufficient' : 'detail-sufficient'}`}
|
||||
>
|
||||
{balance.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
{insufficient && (
|
||||
<View className='detail-row'>
|
||||
<Text className='detail-label'>差额</Text>
|
||||
<Text className='detail-value detail-insufficient'>
|
||||
-{(cost - balance).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='detail-row last'>
|
||||
<Text className='detail-label'>库存</Text>
|
||||
<Text className='detail-value'>
|
||||
{product && product.stock > 0 ? `剩余 ${product.stock} 件` : '已兑完'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='exchange-detail-card'>
|
||||
<Text className='exchange-detail-title'>兑换明细</Text>
|
||||
<View className='exchange-detail-row'>
|
||||
<Text className='exchange-detail-label'>商品积分</Text>
|
||||
<Text className='exchange-detail-value'>{cost.toLocaleString()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 温馨提示 */}
|
||||
<View className='notice-section'>
|
||||
<Text className='notice-title'>温馨提示</Text>
|
||||
<Text className='notice-text'>
|
||||
兑换成功后将生成核销码,请凭核销码到前台核销领取。
|
||||
</Text>
|
||||
<Text className='notice-text'>积分一经兑换不可退回。</Text>
|
||||
</View>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<View className='exchange-footer'>
|
||||
<View className='footer-cost'>
|
||||
<Text className='footer-cost-label'>合计</Text>
|
||||
<Text className='footer-cost-num'>{cost.toLocaleString()}</Text>
|
||||
<Text className='footer-cost-unit'>积分</Text>
|
||||
<View className='exchange-detail-row'>
|
||||
<Text className='exchange-detail-label'>{isService ? '核销方式' : '运费'}</Text>
|
||||
<Text className='exchange-detail-value'>{isService ? '到院核销' : '¥0.00'}</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`confirm-btn ${insufficient || (product?.stock ?? 0) <= 0 || submitting ? 'disabled' : ''}`}
|
||||
onClick={insufficient || (product?.stock ?? 0) <= 0 || submitting ? undefined : handleConfirm}
|
||||
>
|
||||
<Text className='confirm-btn-text'>
|
||||
{submitting
|
||||
? '兑换中...'
|
||||
: insufficient
|
||||
? '积分不足'
|
||||
: (product?.stock ?? 0) <= 0
|
||||
? '已兑完'
|
||||
: '确认兑换'}
|
||||
<View className='exchange-detail-row'>
|
||||
<Text className='exchange-detail-label'>应扣积分</Text>
|
||||
<Text className='exchange-detail-value exchange-detail-cost'>{cost.toLocaleString()}</Text>
|
||||
</View>
|
||||
<View className='exchange-detail-row last'>
|
||||
<Text className='exchange-detail-label'>剩余积分</Text>
|
||||
<Text className={`exchange-detail-value ${remaining >= 0 ? 'sufficient' : 'insufficient'}`}>
|
||||
{remaining.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 确认兑换按钮 */}
|
||||
<View
|
||||
className={`exchange-confirm-btn ${insufficient || (product?.stock ?? 0) <= 0 || submitting ? 'disabled' : ''}`}
|
||||
onClick={insufficient || (product?.stock ?? 0) <= 0 || submitting ? undefined : handleConfirm}
|
||||
>
|
||||
<Text className='exchange-confirm-text'>
|
||||
{submitting
|
||||
? '兑换中...'
|
||||
: insufficient
|
||||
? '积分不足'
|
||||
: (product?.stock ?? 0) <= 0
|
||||
? '已兑完'
|
||||
: '确认兑换'}
|
||||
</Text>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,114 +1,184 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
// PageShell 已接管:min-height, background, safe-bottom
|
||||
// ContentCard 已接管:order-card 背景/圆角/阴影
|
||||
// 订单列表 — 对齐原型 docs/design/mp-10-mall-pkg.html → 屏幕3
|
||||
|
||||
/* ===== 订单列表 ===== */
|
||||
.order-list {
|
||||
padding: 0 var(--tk-gap-lg);
|
||||
.orders-page {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
// 状态筛选 Tab
|
||||
.orders-tabs {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
background: $card;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
}
|
||||
|
||||
.orders-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: var(--tk-gap-sm) 0;
|
||||
position: relative;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
&.active {
|
||||
border-bottom-color: var(--tk-pri);
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.orders-tab-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
|
||||
.orders-tab.active & {
|
||||
color: var(--tk-pri);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// 订单列表
|
||||
.order-list {
|
||||
padding: var(--tk-gap-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
// 订单卡片
|
||||
.order-card {
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
overflow: hidden;
|
||||
padding: var(--tk-gap-md);
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.order-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--tk-gap-lg) var(--tk-gap-lg) var(--tk-gap-md);
|
||||
border-bottom: 1px solid $bd-l;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.order-product {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
.order-id {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// 状态标签
|
||||
.order-status-tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
|
||||
&--pending { background: $wrn-l; }
|
||||
&--approved { background: $pri-l; }
|
||||
&--shipped { background: $acc-l; }
|
||||
&--completed { background: $surface-alt; }
|
||||
&--verified { background: $acc-l; }
|
||||
&--cancelled { background: $dan-l; }
|
||||
&--expired { background: $surface-alt; }
|
||||
}
|
||||
|
||||
.order-status-text {
|
||||
font-size: var(--tk-font-micro);
|
||||
font-weight: 600;
|
||||
|
||||
.order-status-tag--pending & { color: $wrn; }
|
||||
.order-status-tag--approved & { color: $pri; }
|
||||
.order-status-tag--shipped & { color: $acc; }
|
||||
.order-status-tag--completed & { color: $tx3; }
|
||||
.order-status-tag--verified & { color: $acc; }
|
||||
.order-status-tag--cancelled & { color: $dan; }
|
||||
.order-status-tag--expired & { color: $tx3; }
|
||||
}
|
||||
|
||||
// 订单主体
|
||||
.order-body {
|
||||
// layout handled by children
|
||||
}
|
||||
|
||||
.order-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.order-product-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.order-product-name {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.order-status-tag {
|
||||
padding: var(--tk-gap-2xs) var(--tk-gap-md);
|
||||
border-radius: $r-pill;
|
||||
margin-left: var(--tk-gap-sm);
|
||||
flex-shrink: 0;
|
||||
|
||||
&--pending {
|
||||
@include tag($wrn-l, $wrn);
|
||||
}
|
||||
|
||||
&--verified {
|
||||
@include tag($acc-l, $acc);
|
||||
}
|
||||
|
||||
&--cancelled {
|
||||
@include tag($dan-l, $dan);
|
||||
}
|
||||
|
||||
&--expired {
|
||||
@include tag($bd-l, $tx3);
|
||||
}
|
||||
}
|
||||
|
||||
.order-status-text {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.order-body {
|
||||
padding: var(--tk-gap-md) var(--tk-gap-lg) var(--tk-section-gap);
|
||||
}
|
||||
|
||||
.order-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--tk-gap-xs) 0;
|
||||
}
|
||||
|
||||
.order-row-label {
|
||||
font-size: var(--tk-font-h1);
|
||||
.order-date {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.order-row-value {
|
||||
.order-points {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.order-points-value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
|
||||
&.order-cost {
|
||||
color: var(--tk-pri);
|
||||
font-weight: bold;
|
||||
}
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
/* ===== 核销码 ===== */
|
||||
.order-points-unit {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $pri;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
// 核销码
|
||||
.order-qrcode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--tk-gap-md);
|
||||
padding: var(--tk-gap-sm);
|
||||
margin-top: var(--tk-gap-sm);
|
||||
background: var(--tk-pri-l);
|
||||
background: $pri-l;
|
||||
border-radius: $r-sm;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.qrcode-label {
|
||||
font-size: var(--tk-font-h2);
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
margin-right: var(--tk-gap-xs);
|
||||
}
|
||||
|
||||
.qrcode-value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: var(--tk-pri-d);
|
||||
font-weight: bold;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $pri-d;
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -116,8 +186,7 @@
|
||||
}
|
||||
|
||||
.qrcode-tap {
|
||||
font-size: var(--tk-font-body);
|
||||
color: var(--tk-pri);
|
||||
margin-left: var(--tk-gap-xs);
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $pri;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -7,21 +7,22 @@ import type { PointsOrder } from '../../../services/points';
|
||||
import EmptyState from '../../../components/EmptyState';
|
||||
import ErrorState from '../../../components/ErrorState';
|
||||
import Loading from '../../../components/Loading';
|
||||
import SegmentTabs from '../../../components/SegmentTabs';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import './index.scss';
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'pending', label: '待核销' },
|
||||
{ key: 'verified', label: '已核销' },
|
||||
{ key: 'expired', label: '已过期' },
|
||||
{ key: 'pending', label: '待处理' },
|
||||
{ key: 'shipped', label: '已发货' },
|
||||
{ key: 'verified', label: '已完成' },
|
||||
];
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; cls: string }> = {
|
||||
pending: { label: '待核销', cls: 'order-status-tag--pending' },
|
||||
pending: { label: '待处理', cls: 'order-status-tag--pending' },
|
||||
approved: { label: '已审核', cls: 'order-status-tag--approved' },
|
||||
shipped: { label: '已发货', cls: 'order-status-tag--shipped' },
|
||||
completed: { label: '已完成', cls: 'order-status-tag--completed' },
|
||||
verified: { label: '已核销', cls: 'order-status-tag--verified' },
|
||||
cancelled: { label: '已取消', cls: 'order-status-tag--cancelled' },
|
||||
expired: { label: '已过期', cls: 'order-status-tag--expired' },
|
||||
@@ -76,7 +77,7 @@ export default function MallOrders() {
|
||||
|
||||
usePageData(
|
||||
useCallback(async () => {
|
||||
Taro.setNavigationBarTitle({ title: '我的订单' });
|
||||
Taro.setNavigationBarTitle({ title: '兑换记录' });
|
||||
await loadAll();
|
||||
}, [loadAll]),
|
||||
{ throttleMs: 10000, enablePullDown: true },
|
||||
@@ -103,19 +104,29 @@ export default function MallOrders() {
|
||||
};
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
return STATUS_CONFIG[status] || { label: status, cls: 'order-status-tag--expired' };
|
||||
return STATUS_CONFIG[status] || { label: status, cls: 'order-status-tag--pending' };
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell padding="none" className={modeClass}>
|
||||
<PageShell padding="none" safeBottom={false} scroll={false} className={`orders-page ${modeClass}`}>
|
||||
{/* 状态筛选标签 */}
|
||||
<SegmentTabs tabs={STATUS_TABS} activeKey={activeTab} onChange={handleTabChange} variant="underline" />
|
||||
<View className='orders-tabs'>
|
||||
{STATUS_TABS.map((tab) => (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`orders-tab ${activeTab === tab.key ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange(tab.key)}
|
||||
>
|
||||
<Text className='orders-tab-text'>{tab.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 订单列表 */}
|
||||
{error ? (
|
||||
@@ -133,30 +144,25 @@ export default function MallOrders() {
|
||||
{orders.map((order) => {
|
||||
const statusCfg = getStatusConfig(order.status);
|
||||
return (
|
||||
<ContentCard className='order-card' key={order.id}>
|
||||
<View className='order-card' key={order.id}>
|
||||
<View className='order-header'>
|
||||
<Text className='order-product'>商品 {order.product_id.slice(0, 8)}</Text>
|
||||
<View
|
||||
className={`order-status-tag ${statusCfg.cls}`}
|
||||
>
|
||||
<Text className='order-id'>订单号 {order.id.slice(0, 12).toUpperCase()}</Text>
|
||||
<View className={`order-status-tag ${statusCfg.cls}`}>
|
||||
<Text className='order-status-text'>{statusCfg.label}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='order-body'>
|
||||
<View className='order-row'>
|
||||
<Text className='order-row-label'>消耗积分</Text>
|
||||
<Text className='order-row-value order-cost'>
|
||||
{order.points_cost.toLocaleString()}
|
||||
</Text>
|
||||
<View className='order-main'>
|
||||
<View className='order-product-info'>
|
||||
<Text className='order-product-name'>商品 {order.product_id.slice(0, 8)}</Text>
|
||||
<Text className='order-date'>{formatDate(order.created_at)}</Text>
|
||||
</View>
|
||||
<View className='order-points'>
|
||||
<Text className='order-points-value'>{order.points_cost.toLocaleString()}</Text>
|
||||
<Text className='order-points-unit'>积分</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='order-row'>
|
||||
<Text className='order-row-label'>兑换时间</Text>
|
||||
<Text className='order-row-value'>
|
||||
{formatDate(order.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
{order.status === 'pending' && (
|
||||
{order.status === 'pending' && order.qr_code && (
|
||||
<View className='order-qrcode' onClick={() => handleShowQrCode(order.qr_code)}>
|
||||
<Text className='qrcode-label'>核销码</Text>
|
||||
<Text className='qrcode-value'>{order.qr_code}</Text>
|
||||
@@ -164,7 +170,7 @@ export default function MallOrders() {
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ContentCard>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{loading && <Loading />}
|
||||
|
||||
@@ -1,28 +1,56 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
// PageShell 已接管:min-height, background, padding
|
||||
// 就诊人建档/编辑 — 对齐原型 docs/design/mp-13-family-profile.html → FamilyAdd
|
||||
|
||||
.family-add-page {
|
||||
padding-bottom: 160px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@include section-title;
|
||||
.family-add-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
display: block;
|
||||
padding-left: var(--tk-gap-2xs);
|
||||
}
|
||||
|
||||
// 提示卡片
|
||||
.family-add-tip {
|
||||
background: $pri-l;
|
||||
border-radius: $r;
|
||||
padding: 14px var(--tk-gap-md);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
border-left: 4px solid $pri;
|
||||
}
|
||||
|
||||
.family-add-tip-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $pri;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.family-add-tip-desc {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// 表单卡片
|
||||
.form-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: var(--tk-gap-2xs) var(--tk-card-padding-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
// 表单项 — 垂直布局(标签在上,输入在下)
|
||||
.form-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--tk-card-padding-lg) 0;
|
||||
padding: 14px var(--tk-gap-md);
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
@@ -31,39 +59,60 @@
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
flex-shrink: 0;
|
||||
width: 140px;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.form-required {
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
// 带边框的输入容器
|
||||
.form-input-wrap {
|
||||
height: 44px;
|
||||
background: $bg;
|
||||
border: 1.5px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-size: 15px;
|
||||
color: $tx;
|
||||
text-align: right;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.form-placeholder {
|
||||
font-size: 15px;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.form-picker {
|
||||
// 选择器容器
|
||||
.form-picker-wrap {
|
||||
height: 44px;
|
||||
background: $bg;
|
||||
border: 1.5px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-between;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.form-picker-text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-size: 15px;
|
||||
color: $tx;
|
||||
margin-right: var(--tk-gap-sm);
|
||||
|
||||
&.placeholder {
|
||||
color: $tx3;
|
||||
@@ -71,30 +120,33 @@
|
||||
}
|
||||
|
||||
.form-picker-arrow {
|
||||
font-size: var(--tk-font-h2);
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
}
|
||||
|
||||
// 提交按钮
|
||||
.submit-btn {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: var(--tk-gap-lg);
|
||||
height: var(--tk-btn-primary-h);
|
||||
border-radius: $r;
|
||||
background: var(--tk-pri);
|
||||
padding: var(--tk-card-padding-lg);
|
||||
text-align: center;
|
||||
box-shadow: 0 -2px 12px rgba($pri, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--tk-shadow-btn);
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.submit-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: $white;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
@@ -67,66 +67,102 @@ export default function FamilyAdd() {
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
<Text className='page-title'>{editId ? '编辑就诊人' : '添加就诊人'}</Text>
|
||||
<PageShell padding="md" safeBottom={false} scroll={false} className={`family-add-page ${modeClass}`}>
|
||||
<Text className='family-add-title'>{editId ? '编辑就诊人' : '添加就诊人'}</Text>
|
||||
|
||||
{/* 提示卡片 */}
|
||||
<View className='family-add-tip'>
|
||||
<Text className='family-add-tip-title'>完善个人信息</Text>
|
||||
<Text className='family-add-tip-desc'>
|
||||
完善信息后即可使用积分商城、签到等功能。请填写真实信息。
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 表单 */}
|
||||
<View className='form-card'>
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>姓名</Text>
|
||||
<Input
|
||||
className='form-input'
|
||||
placeholder='请输入姓名'
|
||||
placeholderClass='form-placeholder'
|
||||
value={name}
|
||||
onInput={(e) => setName(e.detail.value)}
|
||||
/>
|
||||
<Text className='form-label'>姓名<Text className='form-required'>*</Text></Text>
|
||||
<View className='form-input-wrap'>
|
||||
<Input
|
||||
className='form-input'
|
||||
placeholder='请输入真实姓名'
|
||||
placeholderClass='form-placeholder'
|
||||
value={name}
|
||||
onInput={(e) => setName(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>关系</Text>
|
||||
<Text className='form-label'>关系<Text className='form-required'>*</Text></Text>
|
||||
<Picker
|
||||
mode='selector'
|
||||
range={RELATION_OPTIONS}
|
||||
value={relationIdx}
|
||||
onChange={(e) => setRelationIdx(Number(e.detail.value))}
|
||||
>
|
||||
<View className='form-picker'>
|
||||
<View className='form-picker-wrap'>
|
||||
<Text className='form-picker-text'>{RELATION_OPTIONS[relationIdx]}</Text>
|
||||
<Text className='form-picker-arrow'>{'>'}</Text>
|
||||
<Text className='form-picker-arrow'>›</Text>
|
||||
</View>
|
||||
</Picker>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>性别</Text>
|
||||
<Text className='form-label'>性别<Text className='form-required'>*</Text></Text>
|
||||
<Picker
|
||||
mode='selector'
|
||||
range={GENDER_OPTIONS}
|
||||
value={genderIdx}
|
||||
onChange={(e) => setGenderIdx(Number(e.detail.value))}
|
||||
>
|
||||
<View className='form-picker'>
|
||||
<View className='form-picker-wrap'>
|
||||
<Text className='form-picker-text'>{GENDER_OPTIONS[genderIdx]}</Text>
|
||||
<Text className='form-picker-arrow'>{'>'}</Text>
|
||||
<Text className='form-picker-arrow'>›</Text>
|
||||
</View>
|
||||
</Picker>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>出生日期</Text>
|
||||
<Text className='form-label'>出生日期<Text className='form-required'>*</Text></Text>
|
||||
<Picker
|
||||
mode='date'
|
||||
value={birthDate || '2000-01-01'}
|
||||
onChange={(e) => setBirthDate(e.detail.value)}
|
||||
>
|
||||
<View className='form-picker'>
|
||||
<View className='form-picker-wrap'>
|
||||
<Text className={`form-picker-text ${!birthDate ? 'placeholder' : ''}`}>
|
||||
{birthDate || '请选择'}
|
||||
</Text>
|
||||
<Text className='form-picker-arrow'>{'>'}</Text>
|
||||
<Text className='form-picker-arrow'>›</Text>
|
||||
</View>
|
||||
</Picker>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>手机号</Text>
|
||||
<View className='form-input-wrap'>
|
||||
<Input
|
||||
className='form-input'
|
||||
placeholder='选填,用于接收通知'
|
||||
placeholderClass='form-placeholder'
|
||||
type='number'
|
||||
maxlength={11}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>身份证号</Text>
|
||||
<View className='form-input-wrap'>
|
||||
<Input
|
||||
className='form-input'
|
||||
placeholder='选填,用于医保对接'
|
||||
placeholderClass='form-placeholder'
|
||||
maxlength={18}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
// PageShell 已接管:min-height, background, padding
|
||||
// 就诊人列表 — 对齐原型 docs/design/mp-13-family-profile.html → FamilyList
|
||||
|
||||
.family-page {
|
||||
padding-bottom: 160px;
|
||||
padding-bottom: var(--tk-gap-xl);
|
||||
}
|
||||
|
||||
.family-page-title {
|
||||
@include section-title;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
display: block;
|
||||
padding-left: var(--tk-gap-2xs);
|
||||
}
|
||||
|
||||
.family-hint {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
line-height: 1.5;
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
padding: 0 var(--tk-gap-2xs);
|
||||
}
|
||||
|
||||
.family-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tk-gap-md);
|
||||
gap: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.family-item {
|
||||
@@ -22,7 +35,7 @@
|
||||
align-items: center;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: var(--tk-card-padding);
|
||||
padding: var(--tk-gap-md);
|
||||
box-shadow: $shadow-sm;
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
@@ -31,25 +44,45 @@
|
||||
}
|
||||
|
||||
&.active {
|
||||
box-shadow: $shadow-md;
|
||||
border: 2px solid var(--tk-pri);
|
||||
}
|
||||
}
|
||||
|
||||
// 关系渐变色头像
|
||||
.family-avatar {
|
||||
@include flex-center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: $r;
|
||||
background: var(--tk-pri-l);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 24px;
|
||||
flex-shrink: 0;
|
||||
margin-right: var(--tk-section-gap);
|
||||
margin-right: 14px;
|
||||
|
||||
// 关系渐变色
|
||||
&--self {
|
||||
background: linear-gradient(135deg, $pri-l 0%, $pri 100%);
|
||||
}
|
||||
&--spouse {
|
||||
background: linear-gradient(135deg, $acc-l 0%, $acc 100%);
|
||||
}
|
||||
&--parent {
|
||||
background: linear-gradient(135deg, $wrn-l 0%, $wrn 100%);
|
||||
}
|
||||
&--child {
|
||||
background: linear-gradient(135deg, #EDE8F4 0%, #8B7ACC 100%);
|
||||
}
|
||||
&--other {
|
||||
background: linear-gradient(135deg, $surface-alt 0%, $tx3 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.family-avatar-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: bold;
|
||||
color: var(--tk-pri-d);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: $white;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.family-info {
|
||||
@@ -62,47 +95,65 @@
|
||||
.family-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-sm);
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.family-name {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.family-current-tag {
|
||||
@include tag(var(--tk-pri), $white);
|
||||
font-size: var(--tk-font-body-sm);
|
||||
padding: 2px 10px;
|
||||
font-size: var(--tk-font-micro);
|
||||
padding: 2px 8px;
|
||||
border-radius: $r-pill;
|
||||
background: $pri-l;
|
||||
color: $pri;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.family-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-sm);
|
||||
gap: 8px;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.family-relation-tag {
|
||||
@include tag(var(--tk-pri-l), var(--tk-pri-d));
|
||||
font-size: var(--tk-font-body);
|
||||
padding: 2px 12px;
|
||||
}
|
||||
padding: 1px 6px;
|
||||
border-radius: $r-xs;
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 500;
|
||||
|
||||
.family-gender {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
&--self {
|
||||
background: $pri-l;
|
||||
color: $pri;
|
||||
}
|
||||
&--spouse {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
&--parent {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
&--child {
|
||||
background: #EDE8F4;
|
||||
color: #8B7ACC;
|
||||
}
|
||||
&--other {
|
||||
background: $surface-alt;
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.family-edit {
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--tk-gap-md);
|
||||
padding: var(--tk-gap-md) var(--tk-gap-lg);
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-pill;
|
||||
min-height: 48px;
|
||||
@include flex-center;
|
||||
margin-left: var(--tk-gap-sm);
|
||||
padding: var(--tk-gap-xs) var(--tk-gap-sm);
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
@@ -110,25 +161,34 @@
|
||||
}
|
||||
|
||||
.family-edit-text {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: var(--tk-pri);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.family-add-btn {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--tk-pri);
|
||||
padding: var(--tk-card-padding-lg);
|
||||
text-align: center;
|
||||
box-shadow: 0 -2px 12px rgba($pri, 0.15);
|
||||
margin-top: var(--tk-gap-sm);
|
||||
height: 52px;
|
||||
border-radius: $r;
|
||||
border: 2px dashed $bd;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.family-add-icon {
|
||||
font-size: 20px;
|
||||
color: var(--tk-pri);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.family-add-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
color: $white;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: var(--tk-pri);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,18 @@ import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import './index.scss';
|
||||
|
||||
const RELATION_CLASS: Record<string, string> = {
|
||||
'本人': 'self',
|
||||
'配偶': 'spouse',
|
||||
'父母': 'parent',
|
||||
'子女': 'child',
|
||||
'其他': 'other',
|
||||
};
|
||||
|
||||
function getRelationClass(relation: string): string {
|
||||
return RELATION_CLASS[relation] || 'other';
|
||||
}
|
||||
|
||||
export default function FamilyList() {
|
||||
const modeClass = useElderClass();
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
@@ -57,25 +69,28 @@ export default function FamilyList() {
|
||||
return '未知';
|
||||
};
|
||||
|
||||
const relationInitial = (relation: string) => {
|
||||
return relation ? relation.charAt(0) : '本';
|
||||
const birthYear = (d?: string) => {
|
||||
if (!d) return '';
|
||||
return d.slice(0, 4) + '年';
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
<PageShell padding="md" safeBottom={false} scroll={false} className={`family-page ${modeClass}`}>
|
||||
<Text className='family-page-title'>就诊人管理</Text>
|
||||
<Text className='family-hint'>完善信息后即可使用积分商城、签到等功能。可添加多位家庭成员。</Text>
|
||||
|
||||
<View className='family-list'>
|
||||
{patients.map((p) => {
|
||||
const isActive = currentPatient?.id === p.id;
|
||||
const relClass = getRelationClass(p.relation || '本人');
|
||||
return (
|
||||
<View
|
||||
className={`family-item ${isActive ? 'active' : ''}`}
|
||||
key={p.id}
|
||||
onClick={() => handleSelect(p)}
|
||||
>
|
||||
<View className='family-avatar'>
|
||||
<Text className='family-avatar-text'>{relationInitial(p.relation || '本人')}</Text>
|
||||
<View className={`family-avatar family-avatar--${relClass}`}>
|
||||
<Text className='family-avatar-text'>{p.name.charAt(0)}</Text>
|
||||
</View>
|
||||
<View className='family-info'>
|
||||
<View className='family-name-row'>
|
||||
@@ -83,8 +98,11 @@ export default function FamilyList() {
|
||||
{isActive && <Text className='family-current-tag'>当前</Text>}
|
||||
</View>
|
||||
<View className='family-meta'>
|
||||
<Text className='family-relation-tag'>{p.relation || '本人'}</Text>
|
||||
<Text className='family-gender'>{genderText(p.gender)}</Text>
|
||||
<Text className={`family-relation-tag family-relation-tag--${relClass}`}>
|
||||
{p.relation || '本人'}
|
||||
</Text>
|
||||
<Text>{genderText(p.gender)}</Text>
|
||||
{birthYear(p.birth_date) && <Text>{birthYear(p.birth_date)}</Text>}
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
@@ -103,6 +121,7 @@ export default function FamilyList() {
|
||||
)}
|
||||
|
||||
<View className='family-add-btn' onClick={goToAdd}>
|
||||
<Text className='family-add-icon'>+</Text>
|
||||
<Text className='family-add-text'>添加就诊人</Text>
|
||||
</View>
|
||||
</PageShell>
|
||||
|
||||
@@ -56,6 +56,10 @@ export async function listProducts(params?: {
|
||||
return api.get<ProductListResponse>('/health/points/products', params);
|
||||
}
|
||||
|
||||
export async function getProduct(productId: string) {
|
||||
return api.get<PointsProduct>(`/health/points/products/${productId}`);
|
||||
}
|
||||
|
||||
// ===== 兑换订单 =====
|
||||
|
||||
export interface PointsOrder {
|
||||
|
||||
Reference in New Issue
Block a user