fix(mp): T40 UI 审查全量修复 + 设计体系一致性优化
Phase 0 基础设施:
- statusTag.ts: getStatusInlineStyle() 移除内联 borderRadius/padding/fontSize,仅返回 {background, color}
- 新增 SEVERITY_COLORS + getSeverityStyle() + getSeverityLabel() 统一告警严重程度样式
- variables.scss: 新增 9 个语义颜色别名 ($success/$danger/$warning/$info 等)
- mixins.scss: 新增 status-inline mixin 统一状态标签样式
- 7 个消费者页面添加 @include status-inline CSS 补偿
Phase 1 HIGH 修复 (4 页面):
- P46 随访管理: 移除 getTypeStyle() 硬编码 fontSize,替换文字 Loading 为组件
- P45 咨询详情医护: 添加 Loading/ErrorState 三态模板 + error ref
- P02 健康数据: 添加 loading ref + Loading 组件 + 错误 toast 提示
- P48 告警中心: 替换本地 SEVERITY_COLORS/SEVERITY_LABELS 为 statusTag.ts 导出
Phase 2 全局一致性:
- 2.1 触控补全: 17 页面为可点击元素添加 min-height: $touch-min
- 2.2 字号替换: 19 文件 31 处硬编码 px → Design Token CSS 变量
- 2.3 颜色替换: 18 文件 ~50 处硬编码十六进制 → SCSS 语义变量
- 2.4 elder-mode.scss: 新增 9 个选择器到触控放大清单
Phase 3 LOW 修复:
- 3.1 统一 Loading: 21 页面旧式文字加载 → <Loading> 组件
- 3.2 useElderClass: 8 页面补全长者模式 class 绑定
- 3.3 零散修复: 按钮 44px→48px,诊断记录添加 scroll-view 无限加载
同时新增 UniApp (Vue 3 + Vite) 小程序完整代码库 (146 文件)
This commit is contained in:
5
apps/miniprogram-uniapp/.gitignore
vendored
Normal file
5
apps/miniprogram-uniapp/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.uno/
|
||||
.cache/
|
||||
*.log
|
||||
14
apps/miniprogram-uniapp/index.html
Normal file
14
apps/miniprogram-uniapp/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title></title>
|
||||
<!--preload-links-->
|
||||
<!--app-context-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><!--app-html--></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
12042
apps/miniprogram-uniapp/package-lock.json
generated
Normal file
12042
apps/miniprogram-uniapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
apps/miniprogram-uniapp/package.json
Normal file
28
apps/miniprogram-uniapp/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "hms-miniprogram-uniapp",
|
||||
"version": "1.0.0",
|
||||
"description": "HMS 健康管理平台患者小程序(UniApp 验证版)",
|
||||
"scripts": {
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:mp-weixin": "uni build -p mp-weixin"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-app-plus": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-components": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/types": "^3.4.8",
|
||||
"@dcloudio/uni-automator": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-stacktracey": "3.0.0-4060620250520001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
|
||||
"sass": "^1.87.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
8101
apps/miniprogram-uniapp/pnpm-lock.yaml
generated
Normal file
8101
apps/miniprogram-uniapp/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
apps/miniprogram-uniapp/project.config.json
Normal file
41
apps/miniprogram-uniapp/project.config.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"appid": "wx20f4ef9cc2ec66c5",
|
||||
"miniprogramRoot": "dist/dev/mp-weixin/",
|
||||
"compileType": "miniprogram",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"automationAudits": true,
|
||||
"es6": false,
|
||||
"enhance": false,
|
||||
"compileHotReLoad": true,
|
||||
"postcss": false,
|
||||
"minified": false,
|
||||
"bundle": false,
|
||||
"ignoreUploadUnusedFiles": true,
|
||||
"compileWorklet": false,
|
||||
"uglifyFileName": false,
|
||||
"uploadWithSourceMap": true,
|
||||
"packNpmManually": false,
|
||||
"packNpmRelationList": [],
|
||||
"minifyWXSS": true,
|
||||
"minifyWXML": true,
|
||||
"localPlugins": false,
|
||||
"disableUseStrict": false,
|
||||
"useCompilerPlugins": false,
|
||||
"condition": false,
|
||||
"swc": false,
|
||||
"disableSWC": true,
|
||||
"babelSetting": {
|
||||
"ignore": [],
|
||||
"disablePlugins": [],
|
||||
"outputPath": ""
|
||||
}
|
||||
},
|
||||
"projectname": "hms-uniapp",
|
||||
"simulatorPluginLibVersion": {},
|
||||
"packOptions": {
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"editorSetting": {}
|
||||
}
|
||||
21
apps/miniprogram-uniapp/project.private.config.json
Normal file
21
apps/miniprogram-uniapp/project.private.config.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"libVersion": "3.16.0",
|
||||
"projectname": "miniprogram-uniapp",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"coverView": false,
|
||||
"lazyloadPlaceholderEnable": false,
|
||||
"skylineRenderEnable": false,
|
||||
"preloadBackgroundData": false,
|
||||
"autoAudits": false,
|
||||
"useApiHook": true,
|
||||
"showShadowRootInWxmlPanel": false,
|
||||
"useStaticServer": false,
|
||||
"useLanDebug": false,
|
||||
"showES6CompileOption": false,
|
||||
"compileHotReLoad": true,
|
||||
"checkInvalidKey": true,
|
||||
"ignoreDevUnusedFiles": true,
|
||||
"bigPackageSizeSupport": false
|
||||
}
|
||||
}
|
||||
31
apps/miniprogram-uniapp/src/App.vue
Normal file
31
apps/miniprogram-uniapp/src/App.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
|
||||
onLaunch(() => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.restore()
|
||||
const uiStore = useUIStore()
|
||||
uiStore.restore()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.restore()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/tokens.scss';
|
||||
@import './styles/mixins.scss';
|
||||
@import './styles/elder-mode.scss';
|
||||
|
||||
page {
|
||||
background-color: #F5F0EB;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
color: #2D2A26;
|
||||
font-size: var(--tk-font-body);
|
||||
line-height: var(--tk-line-height);
|
||||
}
|
||||
</style>
|
||||
89
apps/miniprogram-uniapp/src/components/DeviceCard.vue
Normal file
89
apps/miniprogram-uniapp/src/components/DeviceCard.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<view class="device-card" @tap="handleSync">
|
||||
<view class="device-icon">{{ icon }}</view>
|
||||
<view class="device-info">
|
||||
<text class="device-name">{{ deviceName }}</text>
|
||||
<text class="device-status" :class="statusClass">{{ statusLabel }}</text>
|
||||
<text v-if="lastSyncAt" class="last-sync">最近同步: {{ lastSyncAt }}</text>
|
||||
</view>
|
||||
<view class="sync-btn">同步</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const DEVICE_ICONS: Record<string, string> = {
|
||||
blood_pressure: '🩺',
|
||||
blood_glucose: '💉',
|
||||
heart_rate: '❤️',
|
||||
blood_oxygen: '🫁',
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
deviceName: string
|
||||
deviceType: string
|
||||
lastSyncAt?: string
|
||||
status: 'connected' | 'disconnected' | 'never'
|
||||
}>()
|
||||
|
||||
const icon = computed(() => DEVICE_ICONS[props.deviceType] || '📱')
|
||||
const statusLabel = computed(() => {
|
||||
const map: Record<string, string> = { connected: '已连接', disconnected: '未连接', never: '未配对' }
|
||||
return map[props.status] || props.status
|
||||
})
|
||||
const statusClass = computed(() => props.status === 'connected' ? 'connected' : 'idle')
|
||||
|
||||
function handleSync() {
|
||||
uni.navigateTo({ url: '/pages-sub/device-sync/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.device-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
font-size: 40px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.device-status {
|
||||
font-size: var(--tk-font-cap);
|
||||
margin-bottom: 2px;
|
||||
|
||||
&.connected { color: $acc; }
|
||||
&.idle { color: $tx3; }
|
||||
}
|
||||
|
||||
.last-sync {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.sync-btn {
|
||||
background: $pri;
|
||||
color: $white;
|
||||
border-radius: $r-pill;
|
||||
padding: 8px 24px;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
</style>
|
||||
58
apps/miniprogram-uniapp/src/components/EcCanvas.vue
Normal file
58
apps/miniprogram-uniapp/src/components/EcCanvas.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<view class="ec-canvas-wrap">
|
||||
<canvas id="ec-canvas" class="ec-canvas" type="2d"
|
||||
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
option?: Record<string, any>
|
||||
width?: number
|
||||
height?: number
|
||||
}>(), {
|
||||
width: 350,
|
||||
height: 250,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'init', chart: any): void
|
||||
}>()
|
||||
|
||||
const canvasWidth = ref(props.width)
|
||||
const canvasHeight = ref(props.height)
|
||||
|
||||
// Minimal ECharts bridge — for full ECharts, use lime-echart plugin
|
||||
// This is a placeholder that renders a basic canvas with the option's title
|
||||
onMounted(() => {
|
||||
nextTick(initCanvas)
|
||||
})
|
||||
|
||||
function nextTick(fn: () => void) {
|
||||
setTimeout(fn, 50)
|
||||
}
|
||||
|
||||
function initCanvas() {
|
||||
const query = uni.createSelectorQuery()
|
||||
query.select('#ec-canvas').fields({ node: true, size: true }).exec((res) => {
|
||||
if (!res || !res[0] || !res[0].node) return
|
||||
emit('init', { canvas: res[0].node, width: res[0].width, height: res[0].height })
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.option, () => {
|
||||
nextTick(initCanvas)
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ec-canvas-wrap {
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
.ec-canvas {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
53
apps/miniprogram-uniapp/src/components/EmptyState.vue
Normal file
53
apps/miniprogram-uniapp/src/components/EmptyState.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<view class="empty-wrap">
|
||||
<text class="empty-icon">{{ icon }}</text>
|
||||
<text class="empty-title">{{ title }}</text>
|
||||
<text v-if="description" class="empty-desc">{{ description }}</text>
|
||||
<view v-if="actionText" class="empty-action" @tap="$emit('action')">
|
||||
{{ actionText }}
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
icon?: string
|
||||
title: string
|
||||
description?: string
|
||||
actionText?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{ action: [] }>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.empty-wrap {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
padding: 80px 40px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: var(--tk-font-hero);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-action {
|
||||
@include btn-primary;
|
||||
margin-top: 32px;
|
||||
width: auto;
|
||||
padding: 0 48px;
|
||||
}
|
||||
</style>
|
||||
76
apps/miniprogram-uniapp/src/components/ErrorBoundary.vue
Normal file
76
apps/miniprogram-uniapp/src/components/ErrorBoundary.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<slot v-if="!hasError" />
|
||||
<view v-else class="error-boundary">
|
||||
<view class="error-icon-wrap">
|
||||
<text class="error-icon-text">!</text>
|
||||
</view>
|
||||
<text class="error-title">页面出了点问题</text>
|
||||
<text class="error-desc">请返回重试</text>
|
||||
<view class="error-retry-btn" @tap="handleRetry">
|
||||
<text class="error-retry-text">重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onErrorCaptured } from 'vue'
|
||||
|
||||
const hasError = ref(false)
|
||||
|
||||
onErrorCaptured((err) => {
|
||||
console.error('[ErrorBoundary]', err)
|
||||
hasError.value = true
|
||||
return false
|
||||
})
|
||||
|
||||
function handleRetry() {
|
||||
hasError.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.error-boundary {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
padding: 80px 40px;
|
||||
}
|
||||
|
||||
.error-icon-wrap {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: $dan-l;
|
||||
@include flex-center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.error-icon-text {
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.error-desc {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.error-retry-btn {
|
||||
background: $pri;
|
||||
border-radius: $r-pill;
|
||||
padding: 16px 48px;
|
||||
}
|
||||
|
||||
.error-retry-text {
|
||||
color: $white;
|
||||
font-size: var(--tk-font-body);
|
||||
}
|
||||
</style>
|
||||
48
apps/miniprogram-uniapp/src/components/ErrorState.vue
Normal file
48
apps/miniprogram-uniapp/src/components/ErrorState.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<view class="error-state">
|
||||
<text class="error-state-icon">⚠️</text>
|
||||
<text class="error-state-text">{{ text }}</text>
|
||||
<view v-if="onRetry" class="error-state-retry" @tap="onRetry">
|
||||
<text class="error-state-retry-text">重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
text?: string
|
||||
onRetry?: () => void
|
||||
}>(), {
|
||||
text: '加载失败,请稍后重试',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.error-state {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
padding: 80px 40px;
|
||||
}
|
||||
|
||||
.error-state-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-state-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.error-state-retry {
|
||||
background: $pri;
|
||||
border-radius: $r-pill;
|
||||
padding: 12px 40px;
|
||||
}
|
||||
|
||||
.error-state-retry-text {
|
||||
color: $white;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
</style>
|
||||
39
apps/miniprogram-uniapp/src/components/GuestGuard.vue
Normal file
39
apps/miniprogram-uniapp/src/components/GuestGuard.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<view v-if="!authStore.user" class="guest-wrap">
|
||||
<slot name="guest">
|
||||
<text class="guest-text">请先登录</text>
|
||||
<view class="guest-login-btn" @tap="goLogin">去登录</view>
|
||||
</slot>
|
||||
</view>
|
||||
<slot v-else />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
function goLogin() {
|
||||
uni.navigateTo({ url: '/pages/login/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.guest-wrap {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
padding: 80px 40px;
|
||||
}
|
||||
|
||||
.guest-text {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.guest-login-btn {
|
||||
@include btn-primary;
|
||||
width: auto;
|
||||
padding: 0 48px;
|
||||
}
|
||||
</style>
|
||||
37
apps/miniprogram-uniapp/src/components/Loading.vue
Normal file
37
apps/miniprogram-uniapp/src/components/Loading.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<view class="loading-wrap">
|
||||
<view class="loading-spinner" />
|
||||
<text class="loading-text">{{ text }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ text?: string }>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.loading-wrap {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid $bd;
|
||||
border-top-color: $pri;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 20px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
67
apps/miniprogram-uniapp/src/components/ProgressRing.vue
Normal file
67
apps/miniprogram-uniapp/src/components/ProgressRing.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<view class="progress-ring" :style="{ width: size + 'px', height: size + 'px' }">
|
||||
<svg :width="size" :height="size" :viewBox="`0 0 ${size} ${size}`">
|
||||
<circle
|
||||
:cx="size / 2" :cy="size / 2" :r="radius"
|
||||
fill="none" :stroke="bgColor" :stroke-width="strokeWidth"
|
||||
/>
|
||||
<circle
|
||||
:cx="size / 2" :cy="size / 2" :r="radius"
|
||||
fill="none" :stroke="color" :stroke-width="strokeWidth"
|
||||
:stroke-dasharray="circumference"
|
||||
:stroke-dashoffset="offset"
|
||||
stroke-linecap="round"
|
||||
:transform="`rotate(-90 ${size / 2} ${size / 2})`"
|
||||
/>
|
||||
</svg>
|
||||
<view class="progress-text">
|
||||
<text class="progress-value">{{ Math.round(percent) }}</text>
|
||||
<text class="progress-unit">%</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
percent: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
color?: string
|
||||
bgColor?: string
|
||||
}>(), {
|
||||
size: 120,
|
||||
strokeWidth: 8,
|
||||
color: '#C4623A',
|
||||
bgColor: '#E8E2DC',
|
||||
})
|
||||
|
||||
const radius = computed(() => (props.size - props.strokeWidth) / 2)
|
||||
const circumference = computed(() => 2 * Math.PI * radius.value)
|
||||
const offset = computed(() => circumference.value * (1 - props.percent / 100))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.progress-ring {
|
||||
position: relative;
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
position: absolute;
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
.progress-value {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.progress-unit {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
margin-left: 2px;
|
||||
}
|
||||
</style>
|
||||
96
apps/miniprogram-uniapp/src/components/StepIndicator.vue
Normal file
96
apps/miniprogram-uniapp/src/components/StepIndicator.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<view class="step-indicator">
|
||||
<view v-for="(step, idx) in steps" :key="step.label" class="step-item">
|
||||
<view v-if="idx > 0" class="step-line" :class="{ 'step-line-done': idx < current }" />
|
||||
<view class="step-dot"
|
||||
:class="{ 'step-current': idx === current, 'step-done': idx < current }"
|
||||
@tap="idx < current && onChange && onChange(idx)">
|
||||
<text v-if="idx < current" class="step-check">✓</text>
|
||||
<text v-else class="step-num">{{ idx + 1 }}</text>
|
||||
</view>
|
||||
<text class="step-label" :class="{ 'step-current': idx === current, 'step-done': idx < current }">
|
||||
{{ step.label }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
steps: { label: string }[]
|
||||
current: number
|
||||
onChange?: (index: number) => void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.step-indicator {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step-line {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: -50%;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
.step-line-done {
|
||||
background: $pri;
|
||||
}
|
||||
|
||||
.step-dot {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid $bd-l;
|
||||
@include flex-center;
|
||||
margin-bottom: 8px;
|
||||
background: $white;
|
||||
}
|
||||
|
||||
.step-current {
|
||||
border-color: $pri;
|
||||
|
||||
&.step-dot { background: $pri; }
|
||||
&.step-label { color: $pri; font-weight: 600; }
|
||||
}
|
||||
|
||||
.step-done {
|
||||
border-color: $pri;
|
||||
background: $pri;
|
||||
|
||||
&.step-label { color: $acc; }
|
||||
}
|
||||
|
||||
.step-check {
|
||||
color: $white;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
text-align: center;
|
||||
max-width: 80px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
175
apps/miniprogram-uniapp/src/components/TrendChart.vue
Normal file
175
apps/miniprogram-uniapp/src/components/TrendChart.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<view v-if="!data || data.length === 0" class="trend-chart-empty">
|
||||
<text class="trend-chart-empty-text">暂无数据</text>
|
||||
</view>
|
||||
<view v-else class="trend-chart" :style="{ height: (height || 500) + 'rpx' }">
|
||||
<canvas type="2d" id="trend-chart-canvas" class="trend-canvas"
|
||||
:style="{ width: '100%', height: '100%' }" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch, onMounted, nextTick, ref } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
data: { date: string; value: number }[]
|
||||
referenceMin?: number
|
||||
referenceMax?: number
|
||||
unit?: string
|
||||
height?: number
|
||||
}>(), {
|
||||
unit: '',
|
||||
height: 500,
|
||||
})
|
||||
|
||||
const canvasReady = ref(false)
|
||||
|
||||
function drawLine(ctx: any, points: { x: number; y: number }[]) {
|
||||
if (points.length < 2) return
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(points[0].x, points[0].y)
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = points[i - 1]
|
||||
const curr = points[i]
|
||||
const cpx = (prev.x + curr.x) / 2
|
||||
ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y)
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (!props.data || props.data.length === 0) return
|
||||
|
||||
const query = uni.createSelectorQuery()
|
||||
query.select('#trend-chart-canvas').fields({ node: true, size: true }).exec((res) => {
|
||||
if (!res || !res[0] || !res[0].node) return
|
||||
const canvas = res[0].node
|
||||
const ctx = canvas.getContext('2d')
|
||||
const dpr = uni.getSystemInfoSync().pixelRatio || 2
|
||||
const w = res[0].width
|
||||
const h = res[0].height
|
||||
canvas.width = w * dpr
|
||||
canvas.height = h * dpr
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
const pad = { top: 20, right: 16, bottom: 32, left: 48 }
|
||||
const cw = w - pad.left - pad.right
|
||||
const ch = h - pad.top - pad.bottom
|
||||
|
||||
const values = props.data.map(d => d.value)
|
||||
let yMin = Math.min(...values)
|
||||
let yMax = Math.max(...values)
|
||||
if (props.referenceMin !== undefined) yMin = Math.min(yMin, props.referenceMin)
|
||||
if (props.referenceMax !== undefined) yMax = Math.max(yMax, props.referenceMax)
|
||||
const yPad = (yMax - yMin) * 0.1 || 1
|
||||
yMin -= yPad
|
||||
yMax += yPad
|
||||
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
// Reference band
|
||||
if (props.referenceMin !== undefined && props.referenceMax !== undefined) {
|
||||
const ry1 = pad.top + ch * (1 - (props.referenceMax - yMin) / (yMax - yMin))
|
||||
const ry2 = pad.top + ch * (1 - (props.referenceMin - yMin) / (yMax - yMin))
|
||||
ctx.fillStyle = 'rgba(91, 122, 94, 0.1)'
|
||||
ctx.fillRect(pad.left, ry1, cw, ry2 - ry1)
|
||||
}
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = '#e5e5e5'
|
||||
ctx.lineWidth = 0.5
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = pad.top + (ch / 4) * i
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(pad.left, y)
|
||||
ctx.lineTo(pad.left + cw, y)
|
||||
ctx.stroke()
|
||||
const val = yMax - ((yMax - yMin) / 4) * i
|
||||
ctx.fillStyle = '#78716C'
|
||||
ctx.font = '10px sans-serif'
|
||||
ctx.textAlign = 'right'
|
||||
ctx.fillText(val.toFixed(1), pad.left - 6, y + 3)
|
||||
}
|
||||
|
||||
// X labels
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillStyle = '#78716C'
|
||||
ctx.font = '10px sans-serif'
|
||||
const step = Math.max(1, Math.floor(props.data.length / 6))
|
||||
for (let i = 0; i < props.data.length; i += step) {
|
||||
const x = pad.left + (cw / Math.max(1, props.data.length - 1)) * i
|
||||
ctx.fillText(props.data[i].date.slice(5), x, h - 8)
|
||||
}
|
||||
|
||||
// Data points
|
||||
const points = props.data.map((d, i) => ({
|
||||
x: pad.left + (cw / Math.max(1, props.data.length - 1)) * i,
|
||||
y: pad.top + ch * (1 - (d.value - yMin) / (yMax - yMin)),
|
||||
}))
|
||||
|
||||
// Area fill
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(points[0].x, points[0].y)
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const cpx = (points[i - 1].x + points[i].x) / 2
|
||||
ctx.bezierCurveTo(cpx, points[i - 1].y, cpx, points[i].y, points[i].x, points[i].y)
|
||||
}
|
||||
ctx.lineTo(points[points.length - 1].x, pad.top + ch)
|
||||
ctx.lineTo(points[0].x, pad.top + ch)
|
||||
ctx.closePath()
|
||||
const grad = ctx.createLinearGradient(0, pad.top, 0, pad.top + ch)
|
||||
grad.addColorStop(0, 'rgba(196, 98, 58, 0.3)')
|
||||
grad.addColorStop(1, 'rgba(196, 98, 58, 0.02)')
|
||||
ctx.fillStyle = grad
|
||||
ctx.fill()
|
||||
|
||||
// Line
|
||||
ctx.strokeStyle = '#C4623A'
|
||||
ctx.lineWidth = 2
|
||||
drawLine(ctx, points)
|
||||
|
||||
// Dots
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const d = props.data[i]
|
||||
const outOfRange =
|
||||
(props.referenceMin !== undefined && d.value < props.referenceMin) ||
|
||||
(props.referenceMax !== undefined && d.value > props.referenceMax)
|
||||
ctx.beginPath()
|
||||
ctx.arc(points[i].x, points[i].y, outOfRange ? 5 : 3, 0, Math.PI * 2)
|
||||
ctx.fillStyle = outOfRange ? '#B54A4A' : '#C4623A'
|
||||
ctx.fill()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => { canvasReady.value = true; draw() })
|
||||
})
|
||||
|
||||
watch(() => [props.data, props.referenceMin, props.referenceMax], () => {
|
||||
if (canvasReady.value) nextTick(draw)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.trend-chart {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.trend-canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.trend-chart-empty {
|
||||
@include flex-center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.trend-chart-empty-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
</style>
|
||||
152
apps/miniprogram-uniapp/src/components/WeekCalendar.vue
Normal file
152
apps/miniprogram-uniapp/src/components/WeekCalendar.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<view class="week-calendar">
|
||||
<view class="week-nav">
|
||||
<text class="week-arrow" @tap="weekOffset--">◀</text>
|
||||
<text class="week-label">{{ dates[0]?.slice(5) }} ~ {{ dates[6]?.slice(5) }}</text>
|
||||
<text class="week-arrow" @tap="weekOffset++">▶</text>
|
||||
</view>
|
||||
<view class="week-grid">
|
||||
<view v-for="(day, idx) in WEEKDAYS" :key="dates[idx]"
|
||||
class="week-cell"
|
||||
:class="{
|
||||
'cell-selected': dates[idx] === selectedDate,
|
||||
'cell-empty': !isScheduled(dates[idx]),
|
||||
'cell-past': dates[idx] < today
|
||||
}"
|
||||
@tap="onCellTap(dates[idx])">
|
||||
<text class="cell-weekday">{{ day }}</text>
|
||||
<text class="cell-date" :class="{ 'cell-today': dates[idx] === today }">
|
||||
{{ parseInt(dates[idx]?.slice(8) || '0') }}
|
||||
</text>
|
||||
<view v-if="isScheduled(dates[idx])" class="cell-dot" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
scheduledDates: string[]
|
||||
selectedDate: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'selectDate', date: string): void
|
||||
}>()
|
||||
|
||||
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日']
|
||||
const weekOffset = ref(0)
|
||||
|
||||
const today = computed(() => {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
})
|
||||
|
||||
const dates = computed(() => {
|
||||
const result: string[] = []
|
||||
const now = new Date()
|
||||
const monday = new Date(now)
|
||||
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7) + weekOffset.value * 7)
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(monday)
|
||||
d.setDate(monday.getDate() + i)
|
||||
result.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function isScheduled(date: string): boolean {
|
||||
return props.scheduledDates.includes(date)
|
||||
}
|
||||
|
||||
function onCellTap(date: string) {
|
||||
if (isScheduled(date) && date >= today.value) {
|
||||
emit('selectDate', date)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.week-calendar {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.week-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.week-arrow {
|
||||
font-size: 28px;
|
||||
color: $pri;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.week-label {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.week-grid {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.week-cell {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
border-radius: $r-sm;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cell-weekday {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cell-date {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cell-today {
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.cell-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: $pri;
|
||||
}
|
||||
|
||||
.cell-selected {
|
||||
background: $pri-l;
|
||||
|
||||
.cell-date { color: $pri-d; }
|
||||
.cell-dot { background: $pri-d; }
|
||||
}
|
||||
|
||||
.cell-empty {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.cell-past {
|
||||
opacity: 0.5;
|
||||
|
||||
.cell-dot { background: $tx3; }
|
||||
}
|
||||
</style>
|
||||
8
apps/miniprogram-uniapp/src/composables/useElderClass.ts
Normal file
8
apps/miniprogram-uniapp/src/composables/useElderClass.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { computed } from 'vue'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
|
||||
export function useElderClass() {
|
||||
const uiStore = useUIStore()
|
||||
const elderClass = computed(() => uiStore.elderMode ? 'elder-mode' : '')
|
||||
return { elderClass }
|
||||
}
|
||||
7
apps/miniprogram-uniapp/src/env.d.ts
vendored
Normal file
7
apps/miniprogram-uniapp/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="@dcloudio/types" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
10
apps/miniprogram-uniapp/src/main.ts
Normal file
10
apps/miniprogram-uniapp/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
return { app }
|
||||
}
|
||||
21
apps/miniprogram-uniapp/src/manifest.json
Normal file
21
apps/miniprogram-uniapp/src/manifest.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "hms-uniapp",
|
||||
"appid": "__UNI__HMS_VERIFY",
|
||||
"description": "HMS 健康管理平台(UniApp 验证版)",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "100",
|
||||
"transformPx": false,
|
||||
"mp-weixin": {
|
||||
"appid": "wx20f4ef9cc2ec66c5",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"automationAudits": true,
|
||||
"es6": false,
|
||||
"enhance": false,
|
||||
"postcss": false,
|
||||
"minified": false,
|
||||
"compileHotReLoad": true
|
||||
},
|
||||
"usingComponents": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<view :class="['detail-page', elderClass]">
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-else-if="!analysis" class="empty-wrap"><text class="empty-text">报告不存在</text></view>
|
||||
<template v-else>
|
||||
<view class="detail-card">
|
||||
<text class="detail-type">{{ TYPE_LABELS[analysis.analysis_type] || analysis.analysis_type }}</text>
|
||||
<view class="detail-meta">
|
||||
<text class="meta-item">模型: {{ analysis.model_used }}</text>
|
||||
<text class="meta-item">{{ new Date(analysis.created_at).toLocaleString('zh-CN') }}</text>
|
||||
</view>
|
||||
<view v-if="isAutoAnalysis" class="auto-badge">
|
||||
<text class="auto-badge-text">系统自动分析</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="isTrendAnalysis" class="trend-tip-card">
|
||||
<text class="trend-tip-text">趋势分析基于最小二乘法线性回归和 2 倍标准差异常检测。R² 越接近 1 表示趋势拟合越好。</text>
|
||||
</view>
|
||||
<view class="content-card">
|
||||
<rich-text class="report-content" :nodes="htmlContent" />
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getAiAnalysisDetail, type AiAnalysisItem } from '@/services/ai-analysis'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
lab_report_interpretation: '化验单解读', health_trend_analysis: '趋势分析',
|
||||
personalized_checkup_plan: '体检方案', report_summary_generation: '报告摘要',
|
||||
}
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.replace(/<\/?(?:iframe|object|embed|form|input|textarea|style)\b[^>]*>/gi, '')
|
||||
.replace(/<\/?(?:link|meta)\b[^>]*>/gi, '')
|
||||
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
||||
}
|
||||
|
||||
function markdownToHtml(md: string): string {
|
||||
const escaped = sanitizeHtml(md)
|
||||
return escaped
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(<li>[\s\S]*?<\/li>)/g, '<ul>$1</ul>')
|
||||
.replace(/\n\n/g, '<br/><br/>')
|
||||
.replace(/\n/g, '<br/>')
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const analysis = ref<AiAnalysisItem | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const htmlContent = computed(() => analysis.value?.result_content ? markdownToHtml(analysis.value.result_content) : '<p>暂无分析结果</p>')
|
||||
const isTrendAnalysis = computed(() => analysis.value?.analysis_type === 'trend')
|
||||
const isAutoAnalysis = computed(() => (analysis.value?.result_metadata as Record<string, unknown>)?.auto_analysis === true)
|
||||
|
||||
onLoad((query) => {
|
||||
const id = query?.id || ''
|
||||
if (!id) { loading.value = false; return }
|
||||
getAiAnalysisDetail(id).then(data => { analysis.value = data }).catch(() => uni.showToast({ title: '加载失败', icon: 'none' })).finally(() => { loading.value = false })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page { min-height: 100vh; background: $bg; padding: 24px; }
|
||||
.empty-wrap { @include flex-center; padding: 120px 0; }
|
||||
.empty-text { font-size: var(--tk-font-body); color: $tx3; }
|
||||
.detail-card { @include card; margin-bottom: 16px; }
|
||||
.detail-type { font-size: var(--tk-font-title); font-weight: 600; color: $tx; display: block; margin-bottom: 8px; }
|
||||
.detail-meta { display: flex; gap: 16px; }
|
||||
.meta-item { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.auto-badge { display: inline-block; margin-top: 8px; padding: 2px 10px; background: rgba($pri, 0.1); border-radius: 4px; }
|
||||
.auto-badge-text { font-size: var(--tk-font-micro); color: $pri; }
|
||||
.trend-tip-card { @include card; margin-bottom: 16px; background: rgba(250,173,20,0.08); }
|
||||
.trend-tip-text { font-size: var(--tk-font-cap); color: $tx2; line-height: 1.6; }
|
||||
.content-card { @include card; }
|
||||
.report-content { font-size: var(--tk-font-body); line-height: 1.8; color: $tx; }
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<view :class="['ai-report-page', elderClass]">
|
||||
<view class="page-title">AI 分析报告</view>
|
||||
|
||||
<view v-if="list.length === 0 && !loading" class="empty-wrap">
|
||||
<EmptyState icon="" title="暂无 AI 分析报告" />
|
||||
</view>
|
||||
|
||||
<scroll-view v-else scroll-y class="report-scroll" @scrolltolower="loadMore">
|
||||
<view v-for="item in list" :key="item.id" class="report-card" @tap="goDetail(item)">
|
||||
<view class="card-header">
|
||||
<text class="card-type">{{ TYPE_LABELS[item.analysis_type] || item.analysis_type }}</text>
|
||||
<text :class="['card-status', (STATUS_MAP[item.status] || { className: '' }).className]">
|
||||
{{ (STATUS_MAP[item.status] || { text: item.status }).text }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="card-footer">
|
||||
<text class="card-time">{{ new Date(item.created_at).toLocaleString('zh-CN') }}</text>
|
||||
<text class="card-model">{{ item.model_used }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-if="!loading && !hasMore && list.length > 0" class="no-more"><text class="no-more-text">没有更多了</text></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onMounted } from 'vue'
|
||||
import { listAiAnalysis, type AiAnalysisItem } from '@/services/ai-analysis'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
lab_report_interpretation: '化验单解读', health_trend_analysis: '趋势分析',
|
||||
personalized_checkup_plan: '体检方案', report_summary_generation: '报告摘要',
|
||||
}
|
||||
const STATUS_MAP: Record<string, { text: string; className: string }> = {
|
||||
completed: { text: '已完成', className: 'status-completed' },
|
||||
streaming: { text: '分析中', className: 'status-streaming' },
|
||||
failed: { text: '失败', className: 'status-failed' },
|
||||
pending: { text: '等待中', className: 'status-pending' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const list = ref<AiAnalysisItem[]>([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const hasMore = ref(true)
|
||||
|
||||
const loadList = async (p: number) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listAiAnalysis(p, 20)
|
||||
const items = res.data || []
|
||||
list.value = p === 1 ? items : [...list.value, ...items]
|
||||
page.value = p
|
||||
hasMore.value = items.length >= 20
|
||||
} catch { uni.showToast({ title: '加载失败', icon: 'none' }) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const goDetail = (item: AiAnalysisItem) => {
|
||||
if (item.status === 'completed') uni.navigateTo({ url: `/pages-sub/ai-report/detail/index?id=${item.id}` })
|
||||
}
|
||||
const loadMore = () => { if (hasMore.value && !loading.value) loadList(page.value + 1) }
|
||||
|
||||
onMounted(() => loadList(1))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ai-report-page { min-height: 100vh; background: $bg; }
|
||||
.page-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; padding: 24px 24px 16px; }
|
||||
.report-scroll { height: calc(100vh - 64px); padding: 0 24px; }
|
||||
.report-card { @include card; margin-bottom: 12px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.card-type { font-size: var(--tk-font-body); font-weight: 500; color: $tx; }
|
||||
.card-status { font-size: var(--tk-font-cap); padding: 2px 8px; border-radius: 4px; }
|
||||
.status-completed { color: $acc; background: rgba(82,196,26,0.1); }
|
||||
.status-streaming { color: $pri; background: rgba($pri, 0.1); }
|
||||
.status-failed { color: $dan; background: rgba(255,77,79,0.1); }
|
||||
.status-pending { color: $tx3; background: rgba(0,0,0,0.05); }
|
||||
.card-footer { display: flex; justify-content: space-between; }
|
||||
.card-time, .card-model { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.empty-wrap { padding-top: 120px; }
|
||||
.no-more { @include flex-center; padding: 20px; }
|
||||
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<scroll-view scroll-y class="page-scroll">
|
||||
<view class="page-content">
|
||||
<text class="page-title">新建预约</text>
|
||||
|
||||
<view class="form-group">
|
||||
<text class="form-label">选择医生</text>
|
||||
<picker :range="doctorNames" @change="onDoctorChange">
|
||||
<view class="form-picker">
|
||||
{{ selectedDoctorName || '请选择医生' }}
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<text class="form-label">预约日期</text>
|
||||
<picker mode="date" :value="date" @change="(e: any) => date = e.detail.value">
|
||||
<view class="form-picker">
|
||||
{{ date || '请选择日期' }}
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<text class="form-label">预约时间</text>
|
||||
<picker mode="time" :value="time" @change="(e: any) => time = e.detail.value">
|
||||
<view class="form-picker">
|
||||
{{ time || '请选择时间' }}
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<text class="form-label">备注</text>
|
||||
<textarea v-model="notes" class="form-textarea" placeholder="请输入备注信息" />
|
||||
</view>
|
||||
|
||||
<view class="submit-btn" @tap="handleSubmit">
|
||||
{{ submitting ? '提交中...' : '提交预约' }}
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { api } from '@/services/request'
|
||||
|
||||
const doctors = ref<any[]>([])
|
||||
const selectedDoctorIdx = ref(-1)
|
||||
const date = ref('')
|
||||
const time = ref('')
|
||||
const notes = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
const doctorNames = computed(() => doctors.value.map(d => d.name || d.display_name || '医生'))
|
||||
const selectedDoctorName = computed(() => selectedDoctorIdx.value >= 0 ? doctorNames.value[selectedDoctorIdx.value] : '')
|
||||
|
||||
function onDoctorChange(e: any) {
|
||||
selectedDoctorIdx.value = e.detail.value
|
||||
}
|
||||
|
||||
async function fetchDoctors() {
|
||||
try { doctors.value = await api.get<any[]>('/health/doctors') || [] }
|
||||
catch { doctors.value = [] }
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (selectedDoctorIdx.value < 0) {
|
||||
uni.showToast({ title: '请选择医生', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!date.value || !time.value) {
|
||||
uni.showToast({ title: '请选择日期和时间', icon: 'none' })
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await api.post('/health/appointments', {
|
||||
doctor_id: doctors.value[selectedDoctorIdx.value].id,
|
||||
appointment_time: `${date.value} ${time.value}`,
|
||||
notes: notes.value,
|
||||
})
|
||||
uni.showToast({ title: '预约成功', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
} catch (err: any) {
|
||||
uni.showToast({ title: err.message || '预约失败', icon: 'none' })
|
||||
}
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
onMounted(fetchDoctors)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { height: 100vh; background: $bg; }
|
||||
.page-content { padding: 28px 24px 120px; }
|
||||
.page-title { @include section-title; }
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-picker {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
padding: 18px 20px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.picker-arrow {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
min-height: 160px;
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
padding: 18px 20px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
@include btn-primary;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<view :class="['detail-page', elderClass]">
|
||||
<view class="detail-header">
|
||||
<view class="back-btn" @tap="goBack"><text class="back-text">返回</text></view>
|
||||
<text class="header-title">预约详情</text>
|
||||
<view class="header-placeholder" />
|
||||
</view>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<ErrorState v-else-if="error || !appointment" text="未找到预约信息" />
|
||||
<template v-else>
|
||||
<view class="status-card">
|
||||
<view :class="['status-tag', statusInfo.className]">
|
||||
<text class="status-tag-text">{{ statusInfo.label }}</text>
|
||||
</view>
|
||||
<text class="status-doctor">{{ appointment.doctor_name }}</text>
|
||||
<text class="status-dept">{{ appointment.department || '' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="info-section">
|
||||
<text class="section-title">预约信息</text>
|
||||
<view class="info-item">
|
||||
<view class="info-label-wrap"><text class="info-icon-serif">患</text><text class="info-label">就诊人</text></view>
|
||||
<text class="info-value">{{ appointment.patient_name }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<view class="info-label-wrap"><text class="info-icon-serif">日</text><text class="info-label">就诊日期</text></view>
|
||||
<text class="info-value info-date">{{ appointment.appointment_date }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<view class="info-label-wrap"><text class="info-icon-serif">时</text><text class="info-label">就诊时段</text></view>
|
||||
<text class="info-value info-time">{{ appointment.start_time }} - {{ appointment.end_time }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<view class="info-label-wrap"><text class="info-icon-serif">号</text><text class="info-label">预约单号</text></view>
|
||||
<text class="info-value info-id">{{ appointment.id }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="appointment.status === 'pending' || appointment.status === 'confirmed'" class="tips-card">
|
||||
<text class="tips-title">温馨提示</text>
|
||||
<text class="tips-text">请按预约时间提前15分钟到达,携带有效身份证件和医保卡。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="canCancel" class="bottom-bar">
|
||||
<view :class="['cancel-btn', cancelling ? 'cancel-disabled' : '']" @tap="cancelling ? undefined : handleCancel">
|
||||
<text class="cancel-text">{{ cancelling ? '处理中...' : '取消预约' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getAppointment, cancelAppointment, type Appointment } from '@/services/appointment'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||
pending: { label: '待确认', className: 'tag-pending' },
|
||||
confirmed: { label: '已确认', className: 'tag-confirmed' },
|
||||
cancelled: { label: '已取消', className: 'tag-cancelled' },
|
||||
completed: { label: '已完成', className: 'tag-completed' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const appointment = ref<Appointment | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const cancelling = ref(false)
|
||||
let id = ''
|
||||
|
||||
const statusInfo = computed(() => appointment.value ? (STATUS_MAP[appointment.value.status] || { label: appointment.value.status, className: 'tag-pending' }) : { label: '未知', className: 'tag-pending' })
|
||||
const canCancel = computed(() => appointment.value && (appointment.value.status === 'pending' || appointment.value.status === 'confirmed'))
|
||||
const goBack = () => uni.navigateBack()
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!appointment.value || cancelling.value) return
|
||||
const res = await uni.showModal({ title: '确认取消', content: '确定要取消此预约吗?取消后需重新预约。' })
|
||||
if (!res.confirm) return
|
||||
cancelling.value = true
|
||||
try {
|
||||
await cancelAppointment(appointment.value.id, appointment.value.version)
|
||||
uni.showToast({ title: '已取消预约', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
} catch { uni.showToast({ title: '取消失败', icon: 'none' }) }
|
||||
finally { cancelling.value = false }
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
id = query?.id || ''
|
||||
if (!id) { error.value = true; loading.value = false; return }
|
||||
loading.value = true
|
||||
getAppointment(id).then(data => { appointment.value = data }).catch(() => { error.value = true }).finally(() => { loading.value = false })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page { min-height: 100vh; background: $bg; }
|
||||
.detail-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 24px; background: $card; }
|
||||
.back-btn { padding: 6px 12px; }
|
||||
.back-text { font-size: var(--tk-font-body); color: $pri; }
|
||||
.header-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; }
|
||||
.header-placeholder { width: 50px; }
|
||||
.status-card { @include card; margin: 16px 24px; text-align: center; }
|
||||
.status-tag { display: inline-block; padding: 4px 16px; border-radius: 20px; margin-bottom: 8px; }
|
||||
.tag-pending { background: rgba(250,173,20,0.15); }
|
||||
.tag-confirmed { background: rgba($pri, 0.1); }
|
||||
.tag-cancelled { background: rgba(0,0,0,0.05); }
|
||||
.tag-completed { background: rgba(82,196,26,0.1); }
|
||||
.status-tag-text { font-size: var(--tk-font-cap); }
|
||||
.status-doctor { font-size: var(--tk-font-title); font-weight: 600; color: $tx; display: block; margin-top: 8px; }
|
||||
.status-dept { font-size: var(--tk-font-caption); color: $tx3; display: block; margin-top: 4px; }
|
||||
.info-section { @include card; margin: 0 24px 16px; }
|
||||
.section-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; margin-bottom: 12px; display: block; }
|
||||
.info-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid rgba(0,0,0,0.04); }
|
||||
.info-item:last-child { border-bottom: none; }
|
||||
.info-label-wrap { display: flex; align-items: center; gap: 8px; }
|
||||
.info-icon-serif { width: 24px; height: 24px; border-radius: 4px; background: rgba($pri, 0.08); @include flex-center; font-size: var(--tk-font-micro); color: $pri; }
|
||||
.info-label { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.info-value { font-size: var(--tk-font-body); color: $tx; }
|
||||
.tips-card { @include card; margin: 0 24px 16px; background: rgba(250,173,20,0.08); }
|
||||
.tips-title { font-size: var(--tk-font-cap); font-weight: 500; color: $wrn; display: block; margin-bottom: 6px; }
|
||||
.tips-text { font-size: var(--tk-font-cap); color: $tx2; line-height: 1.6; }
|
||||
.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; padding: 12px 24px; background: $card; box-shadow: $shadow-sm; }
|
||||
.cancel-btn { height: $touch-min; border: 1px solid $dan; border-radius: $r; @include flex-center; }
|
||||
.cancel-disabled { opacity: 0.5; }
|
||||
.cancel-text { font-size: var(--tk-font-body); color: $dan; }
|
||||
</style>
|
||||
103
apps/miniprogram-uniapp/src/pages-sub/appointment/index.vue
Normal file
103
apps/miniprogram-uniapp/src/pages-sub/appointment/index.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<text class="page-title">我的预约</text>
|
||||
<Loading v-if="loading && list.length === 0" text="加载中..." />
|
||||
<EmptyState v-else-if="list.length === 0" icon="📅" title="暂无预约" action-text="去预约" @action="navigateTo('/pages-sub/appointment/create/index')" />
|
||||
<template v-else>
|
||||
<view v-for="item in list" :key="item.id" class="appt-card">
|
||||
<view class="appt-header">
|
||||
<text class="appt-doctor">{{ item.doctor_name || '医生' }}</text>
|
||||
<text :class="['appt-status', item.status]">{{ item.status_text || item.status }}</text>
|
||||
</view>
|
||||
<text class="appt-time">{{ formatDate(item.appointment_time, 'YYYY-MM-DD HH:mm') }}</text>
|
||||
<text class="appt-dept">{{ item.department || '' }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 新建预约按钮 -->
|
||||
<view class="create-btn" @tap="navigateTo('/pages-sub/appointment/create/index')">
|
||||
新建预约
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '@/services/request'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const loading = ref(false)
|
||||
const list = ref<any[]>([])
|
||||
|
||||
function navigateTo(url: string) { uni.navigateTo({ url }) }
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try { list.value = await api.get<any[]>('/health/appointments') || [] }
|
||||
catch { list.value = [] }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { height: 100vh; background: $bg; }
|
||||
.page-content { padding: 28px 24px 120px; }
|
||||
.page-title { @include section-title; }
|
||||
|
||||
.appt-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.appt-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.appt-doctor {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.appt-status {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-pill;
|
||||
|
||||
&.confirmed { background: $acc-l; color: $acc; }
|
||||
&.pending { background: $wrn-l; color: $wrn; }
|
||||
&.cancelled { background: $bd-l; color: $tx3; }
|
||||
}
|
||||
|
||||
.appt-time {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.appt-dept {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
@include btn-primary;
|
||||
margin-top: 28px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<template v-else-if="article">
|
||||
<text class="article-title">{{ article.title }}</text>
|
||||
<view class="article-meta">
|
||||
<text class="article-date">{{ formatDate(article.created_at) }}</text>
|
||||
<text v-if="article.author" class="article-author">{{ article.author }}</text>
|
||||
</view>
|
||||
<view class="article-body">
|
||||
<rich-text :nodes="article.content || ''" />
|
||||
</view>
|
||||
</template>
|
||||
<EmptyState v-else icon="📰" title="文章不存在" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { api } from '@/services/request'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const loading = ref(false)
|
||||
const article = ref<any>(null)
|
||||
|
||||
onLoad(async (query: any) => {
|
||||
const id = query?.id
|
||||
if (!id) return
|
||||
loading.value = true
|
||||
try { article.value = await api.get<any>(`/health/articles/${id}`) }
|
||||
catch { article.value = null }
|
||||
loading.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { height: 100vh; background: $bg; }
|
||||
.page-content { padding: 28px 24px 120px; }
|
||||
|
||||
.article-title {
|
||||
display: block;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
}
|
||||
|
||||
.article-date, .article-author {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.article-body {
|
||||
font-size: var(--tk-font-body);
|
||||
line-height: var(--tk-line-height);
|
||||
color: $tx;
|
||||
}
|
||||
</style>
|
||||
99
apps/miniprogram-uniapp/src/pages-sub/article/index.vue
Normal file
99
apps/miniprogram-uniapp/src/pages-sub/article/index.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['page-scroll', elderClass]" @scrolltolower="loadMore">
|
||||
<view class="page-content">
|
||||
<text class="page-title">健康文章</text>
|
||||
<Loading v-if="loading && list.length === 0" text="加载中..." />
|
||||
<EmptyState v-else-if="list.length === 0" icon="📰" title="暂无文章" />
|
||||
<template v-else>
|
||||
<view v-for="item in list" :key="item.id" class="article-card" @tap="goDetail(item.id)">
|
||||
<text class="article-title">{{ item.title }}</text>
|
||||
<text class="article-summary">{{ item.summary || item.content?.substring(0, 60) }}</text>
|
||||
<view class="article-meta">
|
||||
<text class="article-date">{{ formatDate(item.created_at) }}</text>
|
||||
<text v-if="item.category" class="article-tag">{{ item.category }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '@/services/request'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const loading = ref(false)
|
||||
const list = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
|
||||
function goDetail(id: string) {
|
||||
uni.navigateTo({ url: `/pages-sub/article/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try { list.value = await api.get<any[]>('/health/articles', { page: page.value, limit: 20 }) || [] }
|
||||
catch { list.value = [] }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
page.value++
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { height: 100vh; background: $bg; }
|
||||
.page-content { padding: 28px 24px 120px; }
|
||||
.page-title { @include section-title; }
|
||||
|
||||
.article-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.article-summary {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.article-date {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.article-tag {
|
||||
@include tag($pri-l, $pri);
|
||||
font-size: var(--tk-font-micro);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<view :class="['chat-page', elderClass]">
|
||||
<!-- 消息列表 -->
|
||||
<scroll-view scroll-y class="chat-scroll" :scroll-top="scrollTop" :scroll-with-animation="true">
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<template v-else>
|
||||
<view v-for="msg in messages" :key="msg.id" :class="['msg-bubble', msg.sender === 'user' ? 'right' : 'left']">
|
||||
<text class="msg-text">{{ msg.content }}</text>
|
||||
<text class="msg-time">{{ formatDate(msg.created_at, 'HH:mm') }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 输入框 -->
|
||||
<view class="input-bar">
|
||||
<input v-model="inputText" class="chat-input" placeholder="输入消息..." confirm-type="send" @confirm="send" />
|
||||
<view class="send-btn" @tap="send">
|
||||
<text class="send-text">发送</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { api } from '@/services/request'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const consultationId = ref('')
|
||||
const loading = ref(false)
|
||||
const messages = ref<any[]>([])
|
||||
const inputText = ref('')
|
||||
const scrollTop = ref(0)
|
||||
|
||||
async function fetchMessages() {
|
||||
loading.value = true
|
||||
try {
|
||||
messages.value = await api.get<any[]>(`/health/consultations/${consultationId.value}/messages`) || []
|
||||
scrollTop.value = 99999
|
||||
} catch { messages.value = [] }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const text = inputText.value.trim()
|
||||
if (!text || !consultationId.value) return
|
||||
inputText.value = ''
|
||||
try {
|
||||
await api.post(`/health/consultations/${consultationId.value}/messages`, { content: text })
|
||||
await fetchMessages()
|
||||
} catch {
|
||||
uni.showToast({ title: '发送失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query: any) => {
|
||||
consultationId.value = query?.id || ''
|
||||
if (consultationId.value) fetchMessages()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.chat-scroll {
|
||||
flex: 1;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.msg-bubble {
|
||||
max-width: 75%;
|
||||
padding: 16px 20px;
|
||||
border-radius: $r;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&.left {
|
||||
background: $card;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
&.right {
|
||||
background: $pri;
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-text {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body);
|
||||
|
||||
.left & { color: $tx; }
|
||||
.right & { color: $white; }
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
display: block;
|
||||
font-size: var(--tk-font-micro);
|
||||
margin-top: 4px;
|
||||
|
||||
.left & { color: $tx3; }
|
||||
.right & { color: rgba(255,255,255,0.7); }
|
||||
}
|
||||
|
||||
.input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: $card;
|
||||
border-top: 1px solid $bd-l;
|
||||
@include safe-bottom;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
height: 72px;
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 0 16px;
|
||||
font-size: var(--tk-font-body);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
margin-left: 12px;
|
||||
background: $pri;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.send-text {
|
||||
color: $white;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
95
apps/miniprogram-uniapp/src/pages-sub/consultation/index.vue
Normal file
95
apps/miniprogram-uniapp/src/pages-sub/consultation/index.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['page-scroll', elderClass]" @scrolltolower="loadMore">
|
||||
<view class="page-content">
|
||||
<text class="page-title">咨询列表</text>
|
||||
<Loading v-if="loading && list.length === 0" text="加载中..." />
|
||||
<EmptyState v-else-if="list.length === 0" icon="💬" title="暂无咨询记录" action-text="发起咨询" @action="navigateTo('/pages-sub/consultation/create')" />
|
||||
<template v-else>
|
||||
<view v-for="item in list" :key="item.id" class="consult-card" @tap="goDetail(item.id)">
|
||||
<view class="consult-header">
|
||||
<text class="consult-doctor">{{ item.doctor_name || '医生' }}</text>
|
||||
<text :class="['consult-status', item.status]">{{ item.status_text || item.status }}</text>
|
||||
</view>
|
||||
<text class="consult-preview">{{ item.last_message || '暂无消息' }}</text>
|
||||
<text class="consult-time">{{ getRelativeTime(item.updated_at) }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '@/services/request'
|
||||
import { getRelativeTime } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const loading = ref(false)
|
||||
const list = ref<any[]>([])
|
||||
|
||||
function navigateTo(url: string) { uni.navigateTo({ url }) }
|
||||
function goDetail(id: string) { uni.navigateTo({ url: `/pages-sub/consultation/detail/index?id=${id}` }) }
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try { list.value = await api.get<any[]>('/health/consultations') || [] }
|
||||
catch { list.value = [] }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function loadMore() { /* 预留 */ }
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { height: 100vh; background: $bg; }
|
||||
.page-content { padding: 28px 24px 120px; }
|
||||
.page-title { @include section-title; }
|
||||
|
||||
.consult-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.consult-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.consult-doctor {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.consult-status {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-pill;
|
||||
|
||||
&.active { background: $acc-l; color: $acc; }
|
||||
&.closed { background: $bd-l; color: $tx3; }
|
||||
}
|
||||
|
||||
.consult-preview {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.consult-time {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
</style>
|
||||
199
apps/miniprogram-uniapp/src/pages-sub/device-sync/index.vue
Normal file
199
apps/miniprogram-uniapp/src/pages-sub/device-sync/index.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<view :class="['device-sync-page', elderClass]">
|
||||
<view class="sync-header">
|
||||
<text class="sync-header-title">设备同步</text>
|
||||
</view>
|
||||
|
||||
<view v-if="errorMsg" class="sync-error">
|
||||
<text class="sync-error-text">{{ errorMsg }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="pageState === 'scanning' || pageState === 'connecting' || pageState === 'syncing'" class="sync-loading">
|
||||
<text class="sync-loading-text">
|
||||
{{ pageState === 'scanning' && '正在扫描设备...' || pageState === 'connecting' && '正在连接设备...' || '正在上传数据...' }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 空闲状态 -->
|
||||
<template v-if="pageState === 'idle' || pageState === 'error'">
|
||||
<view class="sync-section">
|
||||
<view class="sync-hero">
|
||||
<text class="sync-hero-icon">D</text>
|
||||
<text class="sync-hero-title">设备同步</text>
|
||||
<text class="sync-hero-desc">连接智能手环、血压计、血糖仪,自动采集健康数据</text>
|
||||
</view>
|
||||
<view v-if="lastSyncAt || pendingCount > 0" class="sync-status-info">
|
||||
<text v-if="lastSyncAt" class="sync-status-time">上次同步: {{ new Date(lastSyncAt).toLocaleTimeString() }}</text>
|
||||
<text v-if="pendingCount > 0" class="sync-status-pending">{{ pendingCount }} 条数据待上传</text>
|
||||
</view>
|
||||
<view class="sync-action" @tap="handleScan">
|
||||
<text class="sync-action-text">扫描设备</text>
|
||||
</view>
|
||||
<view v-if="devices.length > 0" class="sync-device-list">
|
||||
<text class="sync-section-title">发现的设备</text>
|
||||
<view v-for="d in devices" :key="d.deviceId" class="sync-device-item" @tap="handleConnect(d)">
|
||||
<view class="sync-device-info">
|
||||
<text class="sync-device-name">{{ d.name }}</text>
|
||||
<text class="sync-device-adapter">{{ d.adapter?.name }}</text>
|
||||
</view>
|
||||
<text class="sync-device-rssi">信号 {{ d.RSSI > -60 ? '强' : d.RSSI > -80 ? '中' : '弱' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 已连接 -->
|
||||
<template v-if="pageState === 'connected'">
|
||||
<view class="sync-section">
|
||||
<view class="sync-status-card">
|
||||
<text class="sync-status-dot sync-status-dot--connected" />
|
||||
<text class="sync-status-text">已连接: {{ selectedDevice?.name }}</text>
|
||||
</view>
|
||||
<view v-if="liveReadings.length > 0" class="sync-readings-panel">
|
||||
<text class="sync-section-title">实时数据</text>
|
||||
<view v-for="(r, i) in liveReadings.slice(-5).reverse()" :key="i" class="sync-reading-item">
|
||||
<text class="sync-reading-type">
|
||||
{{ r.device_type === 'heart_rate' ? '心率' : r.device_type === 'blood_pressure' ? `血压(${r.metric === 'systolic' ? '收缩压' : r.metric === 'diastolic' ? '舒张压' : 'MAP'})` : r.device_type === 'blood_glucose' ? '血糖' : r.device_type }}
|
||||
</text>
|
||||
<text class="sync-reading-value">
|
||||
{{ r.device_type === 'heart_rate' ? `${r.values.heart_rate} bpm` : r.metric ? `${r.values.value} ${r.values.unit}` : JSON.stringify(r.values) }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="sync-readings-count">已采集 {{ liveReadings.length }} 条数据</text>
|
||||
</view>
|
||||
<view class="sync-actions-row">
|
||||
<view class="sync-action sync-action--primary" @tap="handleSync"><text class="sync-action-text">上传数据</text></view>
|
||||
<view class="sync-action sync-action--danger" @tap="handleDisconnect"><text class="sync-action-text">断开连接</text></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 完成 -->
|
||||
<template v-if="pageState === 'done'">
|
||||
<view class="sync-section">
|
||||
<view class="sync-result-card">
|
||||
<text class="sync-result-icon">V</text>
|
||||
<text class="sync-result-title">同步完成</text>
|
||||
<text class="sync-result-count">成功上传 {{ syncCount }} 条数据</text>
|
||||
</view>
|
||||
<view class="sync-action" @tap="handleDone">
|
||||
<text class="sync-action-text">{{ returnTo === 'input' ? '返回录入' : '完成' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { BLEDevice, NormalizedReading } from '@/services/ble'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const pageState = ref<PageState>('idle')
|
||||
const devices = ref<BLEDevice[]>([])
|
||||
const selectedDevice = ref<BLEDevice | null>(null)
|
||||
const liveReadings = ref<NormalizedReading[]>([])
|
||||
const syncCount = ref(0)
|
||||
const errorMsg = ref('')
|
||||
const lastSyncAt = ref<number | null>(null)
|
||||
const pendingCount = ref(0)
|
||||
let returnTo = ''
|
||||
|
||||
const handleScan = () => {
|
||||
pageState.value = 'scanning'; devices.value = []; errorMsg.value = ''
|
||||
// BLE 扫描需要完整 BLE 适配器实现,此处预留
|
||||
setTimeout(() => {
|
||||
if (devices.value.length === 0) errorMsg.value = '未发现支持的设备,请确认设备已开启蓝牙并靠近手机'
|
||||
pageState.value = 'idle'
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const handleConnect = (_device: BLEDevice) => {
|
||||
selectedDevice.value = _device; pageState.value = 'connecting'; errorMsg.value = ''
|
||||
setTimeout(() => { pageState.value = 'connected' }, 2000)
|
||||
}
|
||||
|
||||
const handleSync = () => {
|
||||
if (!authStore.currentPatient || !selectedDevice.value) return
|
||||
pageState.value = 'syncing'; errorMsg.value = ''
|
||||
setTimeout(() => {
|
||||
syncCount.value = liveReadings.value.length || 1
|
||||
lastSyncAt.value = Date.now()
|
||||
pageState.value = 'done'
|
||||
if (returnTo === 'input' && liveReadings.value.length > 0) {
|
||||
const mapped: Record<string, number> = {}
|
||||
for (const r of liveReadings.value) {
|
||||
if (r.device_type === 'blood_pressure') {
|
||||
if (r.metric === 'systolic' && typeof r.values.value === 'number') mapped.systolic = r.values.value
|
||||
if (r.metric === 'diastolic' && typeof r.values.value === 'number') mapped.diastolic = r.values.value
|
||||
} else if (r.device_type === 'blood_glucose' && typeof r.values.blood_glucose === 'number') {
|
||||
mapped.blood_sugar = r.values.blood_glucose as number
|
||||
} else if (r.device_type === 'heart_rate' && typeof r.values.heart_rate === 'number') {
|
||||
mapped.heart_rate = r.values.heart_rate as number
|
||||
}
|
||||
}
|
||||
if (Object.keys(mapped).length > 0) uni.setStorageSync('device_sync_result', JSON.stringify(mapped))
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const handleDisconnect = () => {
|
||||
pageState.value = 'idle'; selectedDevice.value = null; liveReadings.value = []; syncCount.value = 0; errorMsg.value = ''
|
||||
}
|
||||
|
||||
const handleDone = () => {
|
||||
handleDisconnect()
|
||||
if (returnTo === 'input') uni.navigateBack()
|
||||
}
|
||||
|
||||
onLoad((query) => { returnTo = query?.returnTo || '' })
|
||||
onShow(() => { /* BLE manager lifecycle placeholder */ })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.device-sync-page { min-height: 100vh; background: $bg; }
|
||||
.sync-header { padding: 16px 24px; background: $card; }
|
||||
.sync-header-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; }
|
||||
.sync-error { margin: 12px 24px; padding: 10px 16px; background: rgba(255,77,79,0.08); border-radius: $r; }
|
||||
.sync-error-text { font-size: var(--tk-font-cap); color: $dan; }
|
||||
.sync-loading { @include flex-center; padding: 80px 0; }
|
||||
.sync-loading-text { font-size: var(--tk-font-body); color: $tx3; }
|
||||
.sync-section { padding: 24px; }
|
||||
.sync-hero { @include flex-center; flex-direction: column; padding: 40px 0; }
|
||||
.sync-hero-icon { font-size: var(--tk-font-hero); font-weight: 700; color: $pri; }
|
||||
.sync-hero-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; margin-top: 8px; }
|
||||
.sync-hero-desc { font-size: var(--tk-font-caption); color: $tx3; margin-top: 4px; text-align: center; }
|
||||
.sync-status-info { display: flex; gap: 16px; justify-content: center; margin-bottom: 20px; }
|
||||
.sync-status-time, .sync-status-pending { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.sync-action { height: 48px; background: $pri; border-radius: $r; @include flex-center; margin-bottom: 12px; }
|
||||
.sync-action--primary { background: $pri; }
|
||||
.sync-action--danger { background: $dan; }
|
||||
.sync-action-text { color: $white; font-size: var(--tk-font-body); font-weight: 500; }
|
||||
.sync-device-list { margin-top: 20px; }
|
||||
.sync-section-title { font-size: var(--tk-font-cap); font-weight: 500; color: $tx2; margin-bottom: 12px; display: block; }
|
||||
.sync-device-item { @include card; display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.sync-device-info { flex: 1; }
|
||||
.sync-device-name { font-size: var(--tk-font-body); color: $tx; display: block; }
|
||||
.sync-device-adapter { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-top: 2px; }
|
||||
.sync-device-rssi { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.sync-status-card { @include card; display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }
|
||||
.sync-status-dot { width: 10px; height: 10px; border-radius: 50%; background: $acc; }
|
||||
.sync-status-text { font-size: var(--tk-font-body); color: $tx; }
|
||||
.sync-readings-panel { @include card; margin-bottom: 20px; }
|
||||
.sync-reading-item { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid rgba(0,0,0,0.04); }
|
||||
.sync-reading-type { font-size: var(--tk-font-cap); color: $tx2; }
|
||||
.sync-reading-value { font-size: var(--tk-font-body); color: $tx; font-weight: 500; }
|
||||
.sync-readings-count { font-size: var(--tk-font-cap); color: $tx3; margin-top: 8px; display: block; text-align: center; }
|
||||
.sync-actions-row { display: flex; gap: 12px; }
|
||||
.sync-actions-row .sync-action { flex: 1; }
|
||||
.sync-result-card { @include card; @include flex-center; flex-direction: column; padding: 40px 24px; margin-bottom: 20px; }
|
||||
.sync-result-icon { font-size: var(--tk-font-hero); font-weight: 700; color: $acc; }
|
||||
.sync-result-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; margin-top: 8px; }
|
||||
.sync-result-count { font-size: var(--tk-font-body); color: $tx2; margin-top: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,448 @@
|
||||
<template>
|
||||
<Loading v-if="pageLoading" text="加载中..." />
|
||||
<scroll-view v-else scroll-y class="page-scroll">
|
||||
<view :class="['page-content', elderClass]">
|
||||
<!-- Tab 筛选 -->
|
||||
<scroll-view scroll-x class="tab-bar">
|
||||
<view
|
||||
v-for="tab in FILTER_TABS"
|
||||
:key="tab.key"
|
||||
:class="['tab-chip', { 'tab-chip--active': activeFilter === tab.key }]"
|
||||
@tap="handleFilterChange(tab.key)"
|
||||
>
|
||||
<text class="tab-chip__text">{{ tab.label }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 我的统计 -->
|
||||
<view v-if="stats" class="section">
|
||||
<text class="section-title">我的统计</text>
|
||||
<view class="stats-grid">
|
||||
<view class="stat-item">
|
||||
<text class="stat-item__value">{{ stats.pending }}</text>
|
||||
<text class="stat-item__label">待处理</text>
|
||||
</view>
|
||||
<view class="stat-item stat-item--warn">
|
||||
<text class="stat-item__value">{{ stats.overdue }}</text>
|
||||
<text class="stat-item__label">紧急事项</text>
|
||||
</view>
|
||||
<view class="stat-item stat-item--success">
|
||||
<text class="stat-item__value">{{ stats.completed_today }}</text>
|
||||
<text class="stat-item__label">今日完成</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 团队概览 -->
|
||||
<view v-if="team" class="section">
|
||||
<text class="section-title">团队概览</text>
|
||||
<view class="team-row">
|
||||
<text class="team-row__label">团队待处理</text>
|
||||
<text class="team-row__value">{{ team.total_pending }}</text>
|
||||
</view>
|
||||
<view class="team-row">
|
||||
<text class="team-row__label">平均响应时间</text>
|
||||
<text class="team-row__value">{{ formatResponseTime(team.avg_response_time) }}</text>
|
||||
</view>
|
||||
<view v-if="team.members.length > 0" class="team-members">
|
||||
<view
|
||||
v-for="member in team.members"
|
||||
:key="member.user_id"
|
||||
class="member-item"
|
||||
>
|
||||
<text class="member-item__name">{{ member.user_name }}</text>
|
||||
<text class="member-item__role">{{ member.role }}</text>
|
||||
<text
|
||||
:class="['member-item__tasks', member.active_tasks > 0 ? 'member-item__tasks--active' : '']"
|
||||
>
|
||||
{{ member.active_tasks }} 项
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的患者 -->
|
||||
<view class="section">
|
||||
<text class="section-title">我的患者</text>
|
||||
<EmptyState v-if="patients.length === 0" icon="👥" title="暂无患者" />
|
||||
<view v-else class="patient-cards">
|
||||
<view
|
||||
v-for="p in filteredPatients"
|
||||
:key="p.patient_id"
|
||||
class="patient-card"
|
||||
@tap="goPatientDetail(p.patient_id)"
|
||||
>
|
||||
<view class="patient-card__header">
|
||||
<text class="patient-card__name">{{ p.patient_name }}</text>
|
||||
<text v-if="p.bed_number" class="patient-card__bed">
|
||||
{{ p.bed_number }}床
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="p.primary_diagnosis" class="patient-card__diagnosis">
|
||||
<text class="patient-card__diagnosis-text">{{ p.primary_diagnosis }}</text>
|
||||
</view>
|
||||
<view class="patient-card__footer">
|
||||
<view v-if="p.open_action_count > 0" class="patient-card__actions">
|
||||
<text class="patient-card__actions-text">
|
||||
{{ p.open_action_count }} 项待办
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="p.care_plan_status" class="patient-card__plan">
|
||||
{{ p.care_plan_status }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import {
|
||||
getWorkbenchStats,
|
||||
getTeamOverview,
|
||||
getMyPatients,
|
||||
} from '@/services/doctor/actionInbox'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
interface WorkbenchStats {
|
||||
pending: number
|
||||
in_progress: number
|
||||
completed_today: number
|
||||
overdue: number
|
||||
}
|
||||
|
||||
interface TeamMember {
|
||||
user_id: string
|
||||
user_name: string
|
||||
role: string
|
||||
active_tasks: number
|
||||
}
|
||||
|
||||
interface TeamOverviewData {
|
||||
team_name: string
|
||||
members: TeamMember[]
|
||||
total_pending: number
|
||||
avg_response_time: number
|
||||
}
|
||||
|
||||
interface NursePatientSummary {
|
||||
patient_id: string
|
||||
patient_name: string
|
||||
bed_number?: string
|
||||
primary_diagnosis?: string
|
||||
care_plan_status?: string
|
||||
open_action_count: number
|
||||
}
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'pending', label: '待处理' },
|
||||
{ key: 'urgent', label: '紧急' },
|
||||
] as const
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const stats = ref<WorkbenchStats | null>(null)
|
||||
const team = ref<TeamOverviewData | null>(null)
|
||||
const patients = ref<NursePatientSummary[]>([])
|
||||
const activeFilter = ref('')
|
||||
const pageLoading = ref(true)
|
||||
|
||||
const filteredPatients = computed(() => {
|
||||
const list = patients.value
|
||||
if (activeFilter.value === 'pending') {
|
||||
return list.filter((p) => p.open_action_count > 0)
|
||||
}
|
||||
if (activeFilter.value === 'urgent') {
|
||||
return list.filter((p) => p.open_action_count >= 3)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
function formatResponseTime(minutes: number): string {
|
||||
if (minutes < 60) return `${Math.round(minutes)} 分钟`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const mins = Math.round(minutes % 60)
|
||||
return mins > 0 ? `${hours} 小时 ${mins} 分钟` : `${hours} 小时`
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const [s, t, p] = await Promise.all([
|
||||
getWorkbenchStats(true),
|
||||
getTeamOverview(),
|
||||
getMyPatients(),
|
||||
])
|
||||
stats.value = s
|
||||
team.value = t as TeamOverviewData
|
||||
patients.value = p || []
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilterChange(key: string) {
|
||||
activeFilter.value = key
|
||||
}
|
||||
|
||||
function goPatientDetail(patientId: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages-sub/doctor/patients/detail/index?id=${patientId}`,
|
||||
})
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadData().finally(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 24px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
// ── 标签栏 ──
|
||||
.tab-bar {
|
||||
white-space: nowrap;
|
||||
margin-bottom: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 10px 28px;
|
||||
min-height: $touch-min;
|
||||
border-radius: $r-pill;
|
||||
background: $card;
|
||||
box-shadow: $shadow-sm;
|
||||
margin-right: 12px;
|
||||
|
||||
&--active {
|
||||
background: $pri;
|
||||
|
||||
.tab-chip__text {
|
||||
color: $card;
|
||||
}
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 通用区块 ──
|
||||
.section {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 28px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
// ── 统计网格 ──
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
background: $pri-l;
|
||||
border-radius: $r;
|
||||
padding: 20px 12px;
|
||||
|
||||
&--warn {
|
||||
background: $wrn-l;
|
||||
}
|
||||
|
||||
&--success {
|
||||
background: $acc-l;
|
||||
}
|
||||
|
||||
&__value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.stat-item--warn & {
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
.stat-item--success & {
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 团队概览 ──
|
||||
.team-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
&__value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
}
|
||||
|
||||
.team-members {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid $bd-l;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__role {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
&__tasks {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
|
||||
&--active {
|
||||
color: $wrn;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 患者卡片 ──
|
||||
.patient-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.patient-card {
|
||||
background: $bg;
|
||||
border-radius: $r;
|
||||
padding: 20px;
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__bed {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
background: $card;
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-xs;
|
||||
}
|
||||
|
||||
&__diagnosis {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&__diagnosis-text {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
background: $wrn-l;
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-xs;
|
||||
}
|
||||
|
||||
&__actions-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $wrn;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__plan {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,383 @@
|
||||
<template>
|
||||
<Loading v-if="pageLoading" text="加载中..." />
|
||||
<view v-else-if="!alert" :class="['error-wrap', elderClass]">
|
||||
<text class="error-text">告警信息加载失败</text>
|
||||
</view>
|
||||
<scroll-view v-else scroll-y class="page-scroll">
|
||||
<view :class="['page-content', elderClass]">
|
||||
<!-- 告警标题 + 严重程度 -->
|
||||
<view class="section">
|
||||
<view class="alert-header">
|
||||
<text class="alert-header__title">{{ alert.title }}</text>
|
||||
<view
|
||||
class="alert-header__severity"
|
||||
:style="getSeverityStyle(alert.severity)"
|
||||
>
|
||||
<text class="alert-header__severity-text">
|
||||
{{ getSeverityLabel(alert.severity) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="status-row">
|
||||
<text class="status-row__label">状态</text>
|
||||
<view
|
||||
class="status-row__badge"
|
||||
:style="getStatusInlineStyle(alert.status)"
|
||||
>
|
||||
<text class="status-row__badge-text">
|
||||
{{ getStatusLabel(alert.status) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 患者与指标信息 -->
|
||||
<view class="section">
|
||||
<text class="section-title">告警详情</text>
|
||||
<view class="info-grid">
|
||||
<view v-if="alert.detail?.patient_name" class="info-item">
|
||||
<text class="info-label">患者</text>
|
||||
<text class="info-value">{{ alert.detail.patient_name }}</text>
|
||||
</view>
|
||||
<view v-if="alert.detail?.indicator_name" class="info-item">
|
||||
<text class="info-label">指标</text>
|
||||
<text class="info-value">{{ alert.detail.indicator_name }}</text>
|
||||
</view>
|
||||
<view v-if="alert.detail?.threshold_value != null" class="info-item">
|
||||
<text class="info-label">阈值</text>
|
||||
<text class="info-value">{{ alert.detail.threshold_value }}</text>
|
||||
</view>
|
||||
<view v-if="alert.detail?.actual_value != null" class="info-item">
|
||||
<text class="info-label">实际值</text>
|
||||
<text class="info-value info-value--warn">
|
||||
{{ alert.detail.actual_value }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 时间信息 -->
|
||||
<view class="section">
|
||||
<text class="section-title">时间线</text>
|
||||
<view class="timeline">
|
||||
<view class="timeline-item">
|
||||
<text class="timeline-item__label">创建时间</text>
|
||||
<text class="timeline-item__value">
|
||||
{{ formatDate(alert.created_at, 'YYYY-MM-DD HH:mm') }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="alert.acknowledged_at" class="timeline-item">
|
||||
<text class="timeline-item__label">确认时间</text>
|
||||
<text class="timeline-item__value">
|
||||
{{ formatDate(alert.acknowledged_at, 'YYYY-MM-DD HH:mm') }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="alert.resolved_at" class="timeline-item">
|
||||
<text class="timeline-item__label">解决时间</text>
|
||||
<text class="timeline-item__value">
|
||||
{{ formatDate(alert.resolved_at, 'YYYY-MM-DD HH:mm') }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view v-if="hasActions" class="section">
|
||||
<text class="section-title">操作</text>
|
||||
<view class="action-buttons">
|
||||
<button
|
||||
v-if="alert.status === 'pending'"
|
||||
class="btn btn--primary"
|
||||
:loading="actionLoading === 'acknowledge'"
|
||||
@tap="handleAcknowledge"
|
||||
>
|
||||
确认告警
|
||||
</button>
|
||||
<button
|
||||
v-if="alert.status === 'pending'"
|
||||
class="btn btn--outline"
|
||||
:loading="actionLoading === 'dismiss'"
|
||||
@tap="handleDismiss"
|
||||
>
|
||||
忽略
|
||||
</button>
|
||||
<button
|
||||
v-if="alert.status === 'acknowledged'"
|
||||
class="btn btn--primary"
|
||||
:loading="actionLoading === 'resolve'"
|
||||
@tap="handleResolve"
|
||||
>
|
||||
标记已解决
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import {
|
||||
listAlerts,
|
||||
acknowledgeAlert,
|
||||
dismissAlert,
|
||||
resolveAlert,
|
||||
getCachedAlert,
|
||||
updateCachedAlert,
|
||||
} from '@/services/doctor/alerts'
|
||||
import type { Alert } from '@/services/doctor/alerts'
|
||||
import { getStatusInlineStyle, getStatusLabel, getSeverityStyle, getSeverityLabel } from '@/utils/statusTag'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const alert = ref<Alert | null>(null)
|
||||
const pageLoading = ref(true)
|
||||
const actionLoading = ref<string | null>(null)
|
||||
const alertId = ref('')
|
||||
|
||||
const hasActions = computed(() => {
|
||||
const s = alert.value?.status
|
||||
return s === 'pending' || s === 'acknowledged'
|
||||
})
|
||||
|
||||
async function loadAlert() {
|
||||
if (!alertId.value) return
|
||||
pageLoading.value = true
|
||||
try {
|
||||
// 优先从列表页缓存读取
|
||||
const cached = getCachedAlert(alertId.value)
|
||||
if (cached) {
|
||||
alert.value = cached
|
||||
return
|
||||
}
|
||||
// 缓存未命中时回退到列表查询
|
||||
const res = await listAlerts({ page: 1, page_size: 100 })
|
||||
const found = (res.data || []).find((a) => a.id === alertId.value)
|
||||
alert.value = found || null
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAcknowledge() {
|
||||
if (!alert.value || actionLoading.value) return
|
||||
actionLoading.value = 'acknowledge'
|
||||
try {
|
||||
const updated = await acknowledgeAlert(alert.value.id, alert.value.version)
|
||||
alert.value = { ...alert.value, ...updated }
|
||||
updateCachedAlert(alert.value)
|
||||
uni.showToast({ title: '已确认', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
} finally {
|
||||
actionLoading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDismiss() {
|
||||
if (!alert.value || actionLoading.value) return
|
||||
actionLoading.value = 'dismiss'
|
||||
try {
|
||||
const updated = await dismissAlert(alert.value.id, alert.value.version)
|
||||
alert.value = { ...alert.value, ...updated }
|
||||
updateCachedAlert(alert.value)
|
||||
uni.showToast({ title: '已忽略', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
} finally {
|
||||
actionLoading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResolve() {
|
||||
if (!alert.value || actionLoading.value) return
|
||||
actionLoading.value = 'resolve'
|
||||
try {
|
||||
const updated = await resolveAlert(alert.value.id, alert.value.version)
|
||||
alert.value = { ...alert.value, ...updated }
|
||||
updateCachedAlert(alert.value)
|
||||
uni.showToast({ title: '已解决', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
} finally {
|
||||
actionLoading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
alertId.value = query?.id || ''
|
||||
loadAlert()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 24px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.error-wrap {
|
||||
@include flex-center;
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// ── 通用区块 ──
|
||||
.section {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 28px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
// ── 告警头部 ──
|
||||
.alert-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__title {
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
flex: 1;
|
||||
margin-right: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__severity {
|
||||
@include status-inline;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__severity-text {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 状态行 ──
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
&__label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
@include status-inline;
|
||||
}
|
||||
|
||||
&__badge-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 信息网格 ──
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
|
||||
&--warn {
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 时间线 ──
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 操作按钮 ──
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
height: $btn-primary-h;
|
||||
border-radius: $r;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
@include touch-target;
|
||||
|
||||
&--primary {
|
||||
@include btn-primary;
|
||||
}
|
||||
|
||||
&--outline {
|
||||
@include btn-outline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
317
apps/miniprogram-uniapp/src/pages-sub/doctor/alerts/index.vue
Normal file
317
apps/miniprogram-uniapp/src/pages-sub/doctor/alerts/index.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<Loading v-if="pageLoading && alerts.length === 0" text="加载中..." />
|
||||
<scroll-view
|
||||
v-else
|
||||
scroll-y
|
||||
class="page-scroll"
|
||||
@scrolltolower="onLoadMore"
|
||||
>
|
||||
<view :class="['page-content', elderClass]">
|
||||
<!-- 严重程度筛选 -->
|
||||
<scroll-view scroll-x class="tab-bar">
|
||||
<view
|
||||
v-for="tab in SEVERITY_TABS"
|
||||
:key="tab.key"
|
||||
:class="['tab-chip', { 'tab-chip--active': activeSeverity === tab.key }]"
|
||||
@tap="handleSeverityChange(tab.key)"
|
||||
>
|
||||
<text class="tab-chip__text">{{ tab.label }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 列表统计 -->
|
||||
<view v-if="filteredAlerts.length > 0" class="list-meta">
|
||||
<text class="list-meta__text">共 {{ filteredAlerts.length }} 条告警</text>
|
||||
</view>
|
||||
|
||||
<!-- 告警卡片 -->
|
||||
<EmptyState v-if="!pageLoading && filteredAlerts.length === 0" icon="🔔" title="暂无告警" />
|
||||
<view v-else class="alert-cards">
|
||||
<view
|
||||
v-for="alert in filteredAlerts"
|
||||
:key="alert.id"
|
||||
class="alert-card"
|
||||
@tap="goDetail(alert.id)"
|
||||
>
|
||||
<view class="alert-card__header">
|
||||
<text class="alert-card__title">{{ alert.title }}</text>
|
||||
<view
|
||||
class="alert-card__severity"
|
||||
:style="getSeverityStyle(alert.severity)"
|
||||
>
|
||||
<text class="alert-card__severity-text">
|
||||
{{ getSeverityLabel(alert.severity) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="alert-card__body">
|
||||
<text v-if="alert.detail?.patient_name" class="alert-card__patient">
|
||||
{{ alert.detail.patient_name }}
|
||||
</text>
|
||||
<text v-if="alert.detail?.indicator_name" class="alert-card__indicator">
|
||||
{{ alert.detail.indicator_name }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="alert-card__footer">
|
||||
<text class="alert-card__time">{{ formatAlertTime(alert.created_at) }}</text>
|
||||
<view
|
||||
class="alert-card__status"
|
||||
:style="getStatusInlineStyle(alert.status)"
|
||||
>
|
||||
<text class="alert-card__status-text">{{ getStatusLabel(alert.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<view v-if="!loadingMore && alerts.length >= total && total > 0" class="load-hint-wrap">
|
||||
<text class="load-hint">没有更多了</text>
|
||||
</view>
|
||||
<Loading v-if="loadingMore" text="加载中..." />
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { listAlerts, cacheAlerts } from '@/services/doctor/alerts'
|
||||
import type { Alert } from '@/services/doctor/alerts'
|
||||
import { getStatusInlineStyle, getStatusLabel, getSeverityStyle, getSeverityLabel } from '@/utils/statusTag'
|
||||
import { formatDate, getRelativeTime } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const SEVERITY_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'info', label: getSeverityLabel('info') },
|
||||
{ key: 'warning', label: getSeverityLabel('warning') },
|
||||
{ key: 'critical', label: getSeverityLabel('critical') },
|
||||
{ key: 'urgent', label: getSeverityLabel('urgent') },
|
||||
] as const
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const alerts = ref<Alert[]>([])
|
||||
const activeSeverity = ref('')
|
||||
const pageLoading = ref(true)
|
||||
const loadingMore = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
|
||||
const filteredAlerts = computed(() => {
|
||||
if (!activeSeverity.value) return alerts.value
|
||||
return alerts.value.filter((a) => a.severity === activeSeverity.value)
|
||||
})
|
||||
|
||||
function formatAlertTime(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffDays = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (diffDays < 1) return getRelativeTime(dateStr)
|
||||
if (diffDays < 7) return `${diffDays}天前`
|
||||
return formatDate(dateStr, 'MM-DD HH:mm')
|
||||
}
|
||||
|
||||
async function loadAlerts(pageNum: number, isRefresh = false) {
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
if (isRefresh) {
|
||||
pageLoading.value = true
|
||||
} else {
|
||||
loadingMore.value = true
|
||||
}
|
||||
try {
|
||||
const res = await listAlerts({
|
||||
page: pageNum,
|
||||
page_size: 20,
|
||||
})
|
||||
const list = res.data || []
|
||||
if (isRefresh) {
|
||||
alerts.value = list
|
||||
} else {
|
||||
alerts.value = [...alerts.value, ...list]
|
||||
}
|
||||
cacheAlerts(list)
|
||||
total.value = res.total || 0
|
||||
page.value = pageNum
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
loadingMore.value = false
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSeverityChange(key: string) {
|
||||
activeSeverity.value = key
|
||||
}
|
||||
|
||||
function goDetail(id: string) {
|
||||
uni.navigateTo({ url: `/pages-sub/doctor/alerts/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
function onLoadMore() {
|
||||
if (!isLoading.value && alerts.value.length < total.value) {
|
||||
loadAlerts(page.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
loadAlerts(1, true)
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadAlerts(1, true).finally(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 24px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
// ── 标签栏 ──
|
||||
.tab-bar {
|
||||
white-space: nowrap;
|
||||
margin-bottom: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 10px 28px;
|
||||
min-height: $touch-min;
|
||||
border-radius: $r-pill;
|
||||
background: $card;
|
||||
box-shadow: $shadow-sm;
|
||||
margin-right: 12px;
|
||||
|
||||
&--active {
|
||||
background: $pri;
|
||||
|
||||
.tab-chip__text {
|
||||
color: $card;
|
||||
}
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 列表统计 ──
|
||||
.list-meta {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 告警卡片 ──
|
||||
.alert-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.alert-card {
|
||||
@include card;
|
||||
position: relative;
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
flex: 1;
|
||||
margin-right: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__severity {
|
||||
@include status-inline;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__severity-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__patient {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__indicator {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__status {
|
||||
@include status-inline;
|
||||
}
|
||||
|
||||
&__status-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 加载提示 ──
|
||||
.load-hint-wrap {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.load-hint {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,314 @@
|
||||
<template>
|
||||
<view :class="['chat-page', elderClass]">
|
||||
<!-- Header -->
|
||||
<view class="chat-header">
|
||||
<text class="chat-header__title">{{ session?.subject || '在线咨询' }}</text>
|
||||
<text v-if="isOpen" class="chat-header__close" @tap="handleClose">关闭会话</text>
|
||||
</view>
|
||||
|
||||
<!-- Loading -->
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
|
||||
<!-- Error -->
|
||||
<ErrorState v-else-if="error" :text="error" @retry="loadData" />
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<scroll-view v-else
|
||||
scroll-y
|
||||
class="chat-messages"
|
||||
:scroll-into-view="scrollInto"
|
||||
scroll-with-animation
|
||||
>
|
||||
<template v-if="messages.length > 0">
|
||||
<view
|
||||
v-for="(msg, idx) in messages"
|
||||
:key="msg.id"
|
||||
:id="`msg-${idx + 1}`"
|
||||
:class="['msg-row', msg.sender_role === 'doctor' ? 'msg-row--self' : '']"
|
||||
>
|
||||
<view :class="['msg-bubble', msg.sender_role === 'doctor' ? 'msg-bubble--self' : 'msg-bubble--other']">
|
||||
<text class="msg-text">{{ msg.content }}</text>
|
||||
<text class="msg-time">{{ formatTime(msg.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<view v-else class="chat-empty">
|
||||
<text class="chat-empty__text">暂无消息,发送第一条消息开始对话</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 输入栏(会话进行中) -->
|
||||
<view v-if="!loading && !error && isOpen" class="chat-input-bar">
|
||||
<input
|
||||
class="chat-input"
|
||||
placeholder="输入消息..."
|
||||
:value="inputText"
|
||||
@input="(e: any) => inputText = e.detail.value"
|
||||
confirm-type="send"
|
||||
@confirm="handleSend"
|
||||
:disabled="sending"
|
||||
/>
|
||||
<view
|
||||
:class="['chat-send-btn', (!inputText.trim() || sending) ? 'chat-send-btn--disabled' : '']"
|
||||
@tap="handleSend"
|
||||
>
|
||||
<text class="chat-send-btn__text">{{ sending ? '...' : '发送' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 已关闭提示 -->
|
||||
<view v-else-if="!loading && !error" class="chat-closed-bar">
|
||||
<text class="chat-closed-bar__text">会话已关闭</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import * as doctorApi from '@/services/doctor'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const sessionId = ref('')
|
||||
const session = ref<doctorApi.ConsultationSession | null>(null)
|
||||
const messages = ref<doctorApi.ConsultationMessage[]>([])
|
||||
const inputText = ref('')
|
||||
const sending = ref(false)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const scrollInto = ref('')
|
||||
|
||||
const isOpen = computed(() => session.value?.status !== 'closed')
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
const h = String(d.getHours()).padStart(2, '0')
|
||||
const m = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${h}:${m}`
|
||||
}
|
||||
|
||||
function scrollToBottom(count: number) {
|
||||
nextTick(() => {
|
||||
scrollInto.value = `msg-${count}`
|
||||
})
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!sessionId.value) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [s, m] = await Promise.all([
|
||||
doctorApi.getSession(sessionId.value),
|
||||
doctorApi.listMessages(sessionId.value, { page: 1, page_size: 50 }),
|
||||
])
|
||||
session.value = s
|
||||
messages.value = m.data || []
|
||||
scrollToBottom(messages.value.length)
|
||||
} catch {
|
||||
error.value = '加载失败,请重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead() {
|
||||
if (!sessionId.value) return
|
||||
try {
|
||||
await doctorApi.markSessionRead(sessionId.value)
|
||||
} catch {
|
||||
// 静默忽略
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const text = inputText.value.trim()
|
||||
if (!text || sending.value) return
|
||||
sending.value = true
|
||||
inputText.value = ''
|
||||
try {
|
||||
const msg = await doctorApi.sendMessage(sessionId.value, text)
|
||||
messages.value = [...messages.value, msg]
|
||||
scrollToBottom(messages.value.length)
|
||||
} catch {
|
||||
uni.showToast({ title: '发送失败', icon: 'none' })
|
||||
inputText.value = text
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
uni.showModal({
|
||||
title: '确认关闭',
|
||||
content: '关闭后将无法继续对话,确认关闭?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await doctorApi.closeSession(sessionId.value, session.value?.version ?? 0)
|
||||
uni.showToast({ title: '已关闭', icon: 'success' })
|
||||
loadData()
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
sessionId.value = query?.id || ''
|
||||
if (sessionId.value) {
|
||||
loadData()
|
||||
markRead()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 32px;
|
||||
background: $card;
|
||||
border-bottom: 1px solid $bd;
|
||||
|
||||
&__title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__close {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $dan;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.msg-row {
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&--self {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-bubble {
|
||||
max-width: 70%;
|
||||
padding: 20px 24px;
|
||||
border-radius: $r-lg;
|
||||
position: relative;
|
||||
|
||||
&--other {
|
||||
background: $card;
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
|
||||
&--self {
|
||||
background: $pri;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
display: block;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
|
||||
.msg-bubble--self & {
|
||||
color: $card;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
text-align: right;
|
||||
|
||||
.msg-bubble--self & {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
text-align: center;
|
||||
padding: 120px 32px;
|
||||
|
||||
&__text {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: $card;
|
||||
border-top: 1px solid $bd;
|
||||
@include safe-bottom;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
background: $bd-l;
|
||||
border-radius: $r;
|
||||
padding: 16px 20px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.chat-send-btn {
|
||||
background: $pri;
|
||||
border-radius: $r;
|
||||
padding: 16px 28px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $card;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-closed-bar {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
background: $card;
|
||||
border-top: 1px solid $bd;
|
||||
|
||||
&__text {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['consultation-page', elderClass]">
|
||||
<!-- Tab 筛选 -->
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="t in TABS"
|
||||
:key="t.key"
|
||||
:class="['tab', activeTab === t.key ? 'tab--active' : '']"
|
||||
@tap="handleTabChange(t.key)"
|
||||
>
|
||||
<text>{{ t.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载态 -->
|
||||
<Loading v-if="loading && sessions.length === 0" text="加载中..." />
|
||||
|
||||
<!-- 空态 -->
|
||||
<EmptyState v-else-if="sessions.length === 0" icon="💬" title="暂无咨询会话" />
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<view v-else class="session-list">
|
||||
<view
|
||||
v-for="s in sessions"
|
||||
:key="s.id"
|
||||
class="session-card"
|
||||
@tap="goDetail(s.id)"
|
||||
>
|
||||
<view class="session-card__top">
|
||||
<text class="session-card__subject">{{ s.subject || '在线咨询' }}</text>
|
||||
<view class="session-card__status" :style="getStatusInlineStyle(s.status)">
|
||||
<text class="session-card__status-text">{{ getStatusLabel(s.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="session-card__info">
|
||||
<text class="session-card__type">
|
||||
{{ s.consultation_type === 'text' ? '图文' : s.consultation_type === 'video' ? '视频' : '咨询' }}
|
||||
</text>
|
||||
<text class="session-card__time">{{ formatTime(s.last_message_at) }}</text>
|
||||
</view>
|
||||
<text v-if="s.last_message" class="session-card__preview">{{ s.last_message }}</text>
|
||||
<view v-if="(s.unread_count_doctor ?? 0) > 0" class="session-card__badge">
|
||||
<text class="session-card__badge-text">{{ s.unread_count_doctor }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 分页 -->
|
||||
<view v-if="total > 20" class="pagination">
|
||||
<text
|
||||
:class="['pagination__btn', page <= 1 ? 'disabled' : '']"
|
||||
@tap="page > 1 && (page = page - 1)"
|
||||
>上一页</text>
|
||||
<text class="pagination__info">{{ page }} / {{ totalPages }}</text>
|
||||
<text
|
||||
:class="['pagination__btn', page >= totalPages ? 'disabled' : '']"
|
||||
@tap="page < totalPages && (page = page + 1)"
|
||||
>下一页</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import * as doctorApi from '@/services/doctor'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'active', label: '进行中' },
|
||||
{ key: 'waiting', label: '等待中' },
|
||||
{ key: 'closed', label: '已关闭' },
|
||||
] as const
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const sessions = ref<doctorApi.ConsultationSession[]>([])
|
||||
const activeTab = ref('')
|
||||
const loading = ref(true)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
|
||||
const totalPages = computed(() => Math.ceil(total.value / 20))
|
||||
|
||||
function goDetail(id: string) {
|
||||
uni.navigateTo({ url: `/pages-sub/doctor/consultation/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
function handleTabChange(key: string) {
|
||||
activeTab.value = key
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
function formatTime(dateStr?: string | null): string {
|
||||
if (!dateStr) return ''
|
||||
return formatDate(dateStr, 'MM-DD HH:mm')
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await doctorApi.listSessions({
|
||||
page: page.value,
|
||||
page_size: 20,
|
||||
status: activeTab.value || undefined,
|
||||
})
|
||||
sessions.value = res.data || []
|
||||
total.value = res.total || 0
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([page, activeTab], () => { loadSessions() })
|
||||
|
||||
onShow(() => {
|
||||
loadSessions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.consultation-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: $card;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid $bd;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx2;
|
||||
position: relative;
|
||||
|
||||
&--active {
|
||||
color: $pri;
|
||||
font-weight: 600;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 30%;
|
||||
right: 30%;
|
||||
height: 4px;
|
||||
background: $pri;
|
||||
border-radius: $r-xs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.session-list {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
@include card;
|
||||
position: relative;
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
&__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__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: 16px;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: inline-block;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__status-text {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__type {
|
||||
@include tag($pri-l, $pri);
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__preview {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
background: $dan;
|
||||
border-radius: $r-pill;
|
||||
@include flex-center;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
&__badge-text {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $card;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
|
||||
&__btn {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $pri;
|
||||
padding: 12px 24px;
|
||||
|
||||
&.disabled {
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,435 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<!-- 患者选择 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">选择患者</text>
|
||||
<view class="patient-search">
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="搜索患者姓名"
|
||||
:value="patientSearch"
|
||||
@input="(e: any) => { patientSearch = e.detail.value; searchPatients() }"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="searchingPatient" class="loading-hint">
|
||||
<text class="loading-hint__text">搜索中...</text>
|
||||
</view>
|
||||
<view v-else-if="patientResults.length > 0" class="patient-list">
|
||||
<view
|
||||
v-for="p in patientResults"
|
||||
:key="p.id"
|
||||
:class="['patient-item', form.patient_id === p.id ? 'patient-item--selected' : '']"
|
||||
@tap="selectPatient(p)"
|
||||
>
|
||||
<text class="patient-item__name">{{ p.name }}</text>
|
||||
<text class="patient-item__info">{{ p.gender === 'male' ? '男' : p.gender === 'female' ? '女' : '' }}</text>
|
||||
<view v-if="form.patient_id === p.id" class="patient-item__check">
|
||||
<text class="check-icon">✓</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="patientSearch && !searchingPatient" class="empty-hint">
|
||||
<text class="empty-hint__text">未找到患者</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 透析信息 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">透析信息</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">透析日期 <text class="required">*</text></text>
|
||||
<picker mode="date" :value="form.dialysis_date" @change="(e: any) => form.dialysis_date = e.detail.value">
|
||||
<view :class="['picker-display', form.dialysis_date ? '' : 'placeholder']">
|
||||
{{ form.dialysis_date || '请选择日期' }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">透析方式 <text class="required">*</text></text>
|
||||
<picker :range="dialysisTypes" :range-key="'label'" @change="(e: any) => form.dialysis_type = dialysisTypes[e.detail.value].value">
|
||||
<view :class="['picker-display', form.dialysis_type ? '' : 'placeholder']">
|
||||
{{ currentDialysisTypeLabel || '请选择透析方式' }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">开始时间</text>
|
||||
<picker mode="time" :value="form.start_time" @change="(e: any) => form.start_time = e.detail.value">
|
||||
<view :class="['picker-display', form.start_time ? '' : 'placeholder']">
|
||||
{{ form.start_time || '选择时间' }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">结束时间</text>
|
||||
<picker mode="time" :value="form.end_time" @change="(e: any) => form.end_time = e.detail.value">
|
||||
<view :class="['picker-display', form.end_time ? '' : 'placeholder']">
|
||||
{{ form.end_time || '选择时间' }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 体征输入 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">体征数据</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">透前体重 (kg)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="请输入"
|
||||
:value="form.pre_weight ?? ''"
|
||||
@input="(e: any) => updateNumericField('pre_weight', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">干体重 (kg)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="请输入"
|
||||
:value="form.dry_weight ?? ''"
|
||||
@input="(e: any) => updateNumericField('dry_weight', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">超滤目标 (ml)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="请输入"
|
||||
:value="form.ultrafiltration_volume ?? ''"
|
||||
@input="(e: any) => updateNumericField('ultrafiltration_volume', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">透前收缩压</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="mmHg"
|
||||
:value="form.pre_bp_systolic ?? ''"
|
||||
@input="(e: any) => updateNumericField('pre_bp_systolic', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">透前舒张压</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="mmHg"
|
||||
:value="form.pre_bp_diastolic ?? ''"
|
||||
@input="(e: any) => updateNumericField('pre_bp_diastolic', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 备注 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">备注</text>
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
placeholder="并发症记录或其他备注(选填)"
|
||||
:value="form.complication_notes"
|
||||
@input="(e: any) => form.complication_notes = e.detail.value"
|
||||
:maxlength="1000"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 提交 -->
|
||||
<view class="submit-wrap">
|
||||
<view :class="['action-btn', submitting ? 'disabled' : '']" @tap="submitting ? undefined : handleSubmit">
|
||||
<text class="action-btn-text">{{ submitting ? '提交中...' : '提交记录' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { createDialysisRecord } from '@/services/doctor/dialysis'
|
||||
import { listPatients } from '@/services/doctor/patient'
|
||||
import type { PatientItem } from '@/services/doctor/patient'
|
||||
|
||||
const DIALYSIS_TYPES = [
|
||||
{ label: '血液透析', value: 'hemodialysis' },
|
||||
{ label: '腹膜透析', value: 'peritoneal' },
|
||||
] as const
|
||||
|
||||
const dialysisTypes = DIALYSIS_TYPES
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const form = reactive({
|
||||
patient_id: '',
|
||||
dialysis_date: '',
|
||||
dialysis_type: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
pre_weight: undefined as number | undefined,
|
||||
dry_weight: undefined as number | undefined,
|
||||
ultrafiltration_volume: undefined as number | undefined,
|
||||
pre_bp_systolic: undefined as number | undefined,
|
||||
pre_bp_diastolic: undefined as number | undefined,
|
||||
complication_notes: '',
|
||||
})
|
||||
|
||||
const patientSearch = ref('')
|
||||
const patientResults = ref<PatientItem[]>([])
|
||||
const searchingPatient = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const currentDialysisTypeLabel = computed(() => {
|
||||
const found = DIALYSIS_TYPES.find((t) => t.value === form.dialysis_type)
|
||||
return found ? found.label : ''
|
||||
})
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function searchPatients() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
if (!patientSearch.value.trim()) {
|
||||
patientResults.value = []
|
||||
return
|
||||
}
|
||||
searchTimer = setTimeout(async () => {
|
||||
searchingPatient.value = true
|
||||
try {
|
||||
const res = await listPatients({ search: patientSearch.value.trim(), page_size: 10 })
|
||||
patientResults.value = res.data || []
|
||||
} catch {
|
||||
patientResults.value = []
|
||||
} finally {
|
||||
searchingPatient.value = false
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function selectPatient(p: PatientItem) {
|
||||
form.patient_id = form.patient_id === p.id ? '' : p.id
|
||||
}
|
||||
|
||||
function updateNumericField(field: keyof typeof form, raw: string) {
|
||||
const val = raw.trim() === '' ? undefined : Number(raw)
|
||||
;(form as any)[field] = isNaN(val as number) ? undefined : val
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.patient_id) {
|
||||
uni.showToast({ title: '请选择患者', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!form.dialysis_date) {
|
||||
uni.showToast({ title: '请选择透析日期', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!form.dialysis_type) {
|
||||
uni.showToast({ title: '请选择透析方式', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await createDialysisRecord({
|
||||
patient_id: form.patient_id,
|
||||
dialysis_date: form.dialysis_date,
|
||||
dialysis_type: form.dialysis_type,
|
||||
start_time: form.start_time || undefined,
|
||||
end_time: form.end_time || undefined,
|
||||
pre_weight: form.pre_weight,
|
||||
dry_weight: form.dry_weight,
|
||||
ultrafiltration_volume: form.ultrafiltration_volume,
|
||||
pre_bp_systolic: form.pre_bp_systolic,
|
||||
pre_bp_diastolic: form.pre_bp_diastolic,
|
||||
complication_notes: form.complication_notes.trim() || undefined,
|
||||
})
|
||||
uni.showToast({ title: '创建成功', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 800)
|
||||
} catch {
|
||||
uni.showToast({ title: '创建失败', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { min-height: 100vh; background: $bg; }
|
||||
.page-content { padding: 24px 0 160px; }
|
||||
|
||||
.section-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Search
|
||||
.patient-search { margin-bottom: 12px; }
|
||||
|
||||
.search-input {
|
||||
height: 48px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 0 16px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
background: $card;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.loading-hint, .empty-hint {
|
||||
@include flex-center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.loading-hint__text, .empty-hint__text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.patient-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.patient-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 12px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
border-radius: $r-xs;
|
||||
|
||||
&:active { background: $bd-l; }
|
||||
|
||||
&--selected {
|
||||
background: $pri-l;
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__info {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
&__check {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@include flex-center;
|
||||
background: $pri;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: $card;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
|
||||
// Form
|
||||
.form-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-field--half {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.required { color: $dan; }
|
||||
|
||||
.form-input {
|
||||
height: 48px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 0 16px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
background: $card;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.picker-display {
|
||||
height: 48px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 0 16px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
background: $card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.picker-display.placeholder {
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 12px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
box-sizing: border-box;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
// Submit
|
||||
.submit-wrap {
|
||||
margin: 0 24px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@include btn-primary;
|
||||
}
|
||||
|
||||
.action-btn.disabled { opacity: 0.5; }
|
||||
|
||||
.action-btn-text {
|
||||
color: $card;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<Loading v-if="pageLoading" text="加载中..." />
|
||||
<ErrorState v-else-if="error || !record" text="记录加载失败" :on-retry="loadData" />
|
||||
<scroll-view v-else scroll-y :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<!-- 基本信息 -->
|
||||
<view class="info-card">
|
||||
<view class="info-header">
|
||||
<text class="patient-name">{{ recordPatientName }}</text>
|
||||
<view class="status-tag" :style="getStatusInlineStyle(record.status)">
|
||||
<text class="status-tag__text">{{ getStatusLabel(record.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="info-row">
|
||||
<text class="info-label">透析日期</text>
|
||||
<text class="info-value">{{ record.dialysis_date }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">透析方式</text>
|
||||
<text class="info-value">{{ dialysisTypeLabel(record.dialysis_type) }}</text>
|
||||
</view>
|
||||
<view v-if="record.start_time" class="info-row">
|
||||
<text class="info-label">开始时间</text>
|
||||
<text class="info-value">{{ record.start_time }}</text>
|
||||
</view>
|
||||
<view v-if="record.end_time" class="info-row">
|
||||
<text class="info-label">结束时间</text>
|
||||
<text class="info-value">{{ record.end_time }}</text>
|
||||
</view>
|
||||
<view v-if="record.dialysis_duration" class="info-row">
|
||||
<text class="info-label">透析时长</text>
|
||||
<text class="info-value">{{ record.dialysis_duration }} 分钟</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 体征数据 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">体征数据</text>
|
||||
<view class="vitals-grid">
|
||||
<view v-if="record.pre_bp_systolic != null" class="vital-item">
|
||||
<text class="vital-value">{{ record.pre_bp_systolic }}/{{ record.pre_bp_diastolic }}</text>
|
||||
<text class="vital-label">透前血压 mmHg</text>
|
||||
</view>
|
||||
<view v-if="record.post_bp_systolic != null" class="vital-item">
|
||||
<text class="vital-value">{{ record.post_bp_systolic }}/{{ record.post_bp_diastolic }}</text>
|
||||
<text class="vital-label">透后血压 mmHg</text>
|
||||
</view>
|
||||
<view v-if="record.pre_weight != null" class="vital-item">
|
||||
<text class="vital-value">{{ record.pre_weight }}</text>
|
||||
<text class="vital-label">透前体重 kg</text>
|
||||
</view>
|
||||
<view v-if="record.post_weight != null" class="vital-item">
|
||||
<text class="vital-value">{{ record.post_weight }}</text>
|
||||
<text class="vital-label">透后体重 kg</text>
|
||||
</view>
|
||||
<view v-if="record.ultrafiltration_volume != null" class="vital-item">
|
||||
<text class="vital-value">{{ record.ultrafiltration_volume }}</text>
|
||||
<text class="vital-label">超滤量 ml</text>
|
||||
</view>
|
||||
<view v-if="record.blood_flow_rate != null" class="vital-item">
|
||||
<text class="vital-value">{{ record.blood_flow_rate }}</text>
|
||||
<text class="vital-label">血流量 ml/min</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 并发症 -->
|
||||
<view v-if="record.complication_notes" class="section-card">
|
||||
<text class="section-title">并发症记录</text>
|
||||
<view class="warning-block">
|
||||
<text class="warning-block__text">{{ record.complication_notes }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 备注 -->
|
||||
<view v-if="record.symptoms && Object.keys(record.symptoms).length > 0" class="section-card">
|
||||
<text class="section-title">备注</text>
|
||||
<text class="notes-text">{{ JSON.stringify(record.symptoms, null, 2) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 审核操作 -->
|
||||
<view v-if="record.status === 'pending'" class="action-card">
|
||||
<view
|
||||
:class="['action-btn', reviewing ? 'disabled' : '']"
|
||||
@tap="reviewing ? undefined : handleReview"
|
||||
>
|
||||
<text class="action-btn-text">{{ reviewing ? '处理中...' : '审核通过' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { getDialysisRecordById, reviewDialysisRecord } from '@/services/doctor/dialysis'
|
||||
import type { DialysisRecord } from '@/services/dialysis'
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
|
||||
const DIALYSIS_TYPE_MAP: Record<string, string> = {
|
||||
hemodialysis: '血液透析',
|
||||
peritoneal: '腹膜透析',
|
||||
hemofiltration: '血液滤过',
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const record = ref<DialysisRecord | null>(null)
|
||||
const recordPatientName = ref('')
|
||||
const pageLoading = ref(true)
|
||||
const error = ref(false)
|
||||
const reviewing = ref(false)
|
||||
let recordId = ''
|
||||
|
||||
function dialysisTypeLabel(type: string): string {
|
||||
return DIALYSIS_TYPE_MAP[type] || type
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!recordId) return
|
||||
pageLoading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const data = await getDialysisRecordById(recordId)
|
||||
record.value = data
|
||||
} catch {
|
||||
error.value = true
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReview() {
|
||||
if (!record.value) return
|
||||
reviewing.value = true
|
||||
try {
|
||||
const updated = await reviewDialysisRecord(recordId, record.value.version)
|
||||
record.value = updated
|
||||
uni.showToast({ title: '审核通过', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '审核失败', icon: 'none' })
|
||||
} finally {
|
||||
reviewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
recordId = query?.id || ''
|
||||
if (!recordId) { error.value = true; pageLoading.value = false; return }
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { min-height: 100vh; background: $bg; }
|
||||
.page-content { padding: 24px 0 120px; }
|
||||
|
||||
.info-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.patient-name {
|
||||
font-size: var(--tk-font-title);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.status-tag__text {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
|
||||
.info-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.vitals-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vital-item {
|
||||
background: $pri-l;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vital-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.warning-block {
|
||||
background: $wrn-l;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.warning-block__text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $wrn;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.notes-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@include btn-primary;
|
||||
}
|
||||
|
||||
.action-btn.disabled { opacity: 0.5; }
|
||||
|
||||
.action-btn-text {
|
||||
color: $card;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
371
apps/miniprogram-uniapp/src/pages-sub/doctor/dialysis/index.vue
Normal file
371
apps/miniprogram-uniapp/src/pages-sub/doctor/dialysis/index.vue
Normal file
@@ -0,0 +1,371 @@
|
||||
<template>
|
||||
<Loading v-if="pageLoading && records.length === 0" text="加载中..." />
|
||||
<scroll-view
|
||||
v-else
|
||||
scroll-y
|
||||
class="page-scroll"
|
||||
@scrolltolower="onLoadMore"
|
||||
>
|
||||
<view :class="['page-content', elderClass]">
|
||||
<!-- 状态筛选 -->
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="tab in STATUS_TABS"
|
||||
:key="tab.key"
|
||||
:class="['tab', activeStatus === tab.key ? 'tab--active' : '']"
|
||||
@tap="handleStatusChange(tab.key)"
|
||||
>
|
||||
<text>{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 列表统计 -->
|
||||
<view v-if="records.length > 0" class="list-meta">
|
||||
<text class="list-meta__text">共 {{ total }} 条记录</text>
|
||||
</view>
|
||||
|
||||
<!-- 透析记录卡片 -->
|
||||
<EmptyState v-if="!pageLoading && records.length === 0" icon="💉" title="暂无透析记录" />
|
||||
<view v-else class="record-cards">
|
||||
<view
|
||||
v-for="record in records"
|
||||
:key="record.id"
|
||||
class="record-card"
|
||||
@tap="goDetail(record.id)"
|
||||
>
|
||||
<view class="record-card__header">
|
||||
<text class="record-card__patient">{{ record.patient_name || formatDate(record.dialysis_date, 'MM-DD') }}</text>
|
||||
<view
|
||||
class="record-card__status"
|
||||
:style="getStatusInlineStyle(record.status)"
|
||||
>
|
||||
<text class="record-card__status-text">
|
||||
{{ getStatusLabel(record.status) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="record-card__body">
|
||||
<view class="record-card__info-row">
|
||||
<text class="record-card__label">透析日期</text>
|
||||
<text class="record-card__value">{{ formatDate(record.dialysis_date, 'YYYY-MM-DD') }}</text>
|
||||
</view>
|
||||
<view class="record-card__info-row">
|
||||
<text class="record-card__label">透析方式</text>
|
||||
<text class="record-card__value">{{ dialysisTypeLabel(record.dialysis_type) }}</text>
|
||||
</view>
|
||||
<view v-if="record.dialysis_duration" class="record-card__info-row">
|
||||
<text class="record-card__label">时长</text>
|
||||
<text class="record-card__value">{{ record.dialysis_duration }} 分钟</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="record-card__type-tag">
|
||||
<text class="record-card__type-tag-text">
|
||||
{{ dialysisTypeShort(record.dialysis_type) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<view v-if="!loadingMore && records.length >= total && total > 0" class="load-hint-wrap">
|
||||
<text class="load-hint">没有更多了</text>
|
||||
</view>
|
||||
<Loading v-if="loadingMore" text="加载中..." />
|
||||
</view>
|
||||
|
||||
<!-- 新建按钮 -->
|
||||
<view class="fab" @tap="goCreate">
|
||||
<text class="fab__icon">+</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { onLoad, onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { listDialysisRecords } from '@/services/doctor/dialysis'
|
||||
import type { DialysisRecord } from '@/services/doctor/dialysis'
|
||||
|
||||
// 医生端透析列表后端返回的扩展字段
|
||||
interface DoctorDialysisRecord extends DialysisRecord {
|
||||
patient_name?: string
|
||||
}
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'in_progress', label: '进行中' },
|
||||
{ key: 'completed', label: '已完成' },
|
||||
{ key: 'cancelled', label: '已取消' },
|
||||
] as const
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const records = ref<DoctorDialysisRecord[]>([])
|
||||
const activeStatus = ref('')
|
||||
const pageLoading = ref(true)
|
||||
const loadingMore = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const patientId = ref('')
|
||||
|
||||
function dialysisTypeLabel(type: string): string {
|
||||
if (type === 'hemodialysis') return '血液透析'
|
||||
if (type === 'peritoneal') return '腹膜透析'
|
||||
return type
|
||||
}
|
||||
|
||||
function dialysisTypeShort(type: string): string {
|
||||
if (type === 'hemodialysis') return 'HD'
|
||||
if (type === 'peritoneal') return 'PD'
|
||||
return type
|
||||
}
|
||||
|
||||
async function loadRecords(pageNum: number, isRefresh = false) {
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
if (isRefresh) {
|
||||
pageLoading.value = true
|
||||
} else {
|
||||
loadingMore.value = true
|
||||
}
|
||||
try {
|
||||
const params: { page: number; page_size: number; status?: string } = {
|
||||
page: pageNum,
|
||||
page_size: 20,
|
||||
}
|
||||
if (activeStatus.value) {
|
||||
params.status = activeStatus.value
|
||||
}
|
||||
const res = await listDialysisRecords(patientId.value, params)
|
||||
const list = res.data || []
|
||||
if (isRefresh) {
|
||||
records.value = list
|
||||
} else {
|
||||
records.value = [...records.value, ...list]
|
||||
}
|
||||
total.value = res.total || 0
|
||||
page.value = pageNum
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
loadingMore.value = false
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleStatusChange(key: string) {
|
||||
activeStatus.value = key
|
||||
}
|
||||
|
||||
function goDetail(id: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages-sub/doctor/dialysis/detail/index?id=${id}`,
|
||||
})
|
||||
}
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({
|
||||
url: `/pages-sub/doctor/dialysis/create/index${patientId.value ? `?patientId=${patientId.value}` : ''}`,
|
||||
})
|
||||
}
|
||||
|
||||
function onLoadMore() {
|
||||
if (!isLoading.value && records.value.length < total.value) {
|
||||
loadRecords(page.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeStatus, () => {
|
||||
loadRecords(1, true)
|
||||
})
|
||||
|
||||
onLoad((query) => {
|
||||
patientId.value = query?.patientId || ''
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
loadRecords(1, true)
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadRecords(1, true).finally(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 24px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
// ── 状态标签 ──
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
box-shadow: $shadow-sm;
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
position: relative;
|
||||
|
||||
&--active {
|
||||
color: $pri;
|
||||
font-weight: 600;
|
||||
background: $pri-l;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
height: 3px;
|
||||
background: $pri;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 列表统计 ──
|
||||
.list-meta {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 记录卡片 ──
|
||||
.record-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
@include card;
|
||||
position: relative;
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__patient {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__status {
|
||||
@include status-inline;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__status-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__type-tag {
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
right: 28px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
&__type-tag-text {
|
||||
@include tag($pri-l, $pri);
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 加载提示 ──
|
||||
.load-hint-wrap {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.load-hint {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// ── 浮动新建按钮 ──
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
bottom: 100px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: $pri;
|
||||
@include flex-center;
|
||||
box-shadow: $shadow-lg;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $card;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<view :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<!-- Loading -->
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
|
||||
<!-- Error -->
|
||||
<ErrorState v-else-if="error || !task" text="任务不存在" />
|
||||
|
||||
<template v-else>
|
||||
<!-- Task info card -->
|
||||
<view class="info-card">
|
||||
<view class="info-header">
|
||||
<text class="patient-name">{{ task.patient_name || '未知患者' }}</text>
|
||||
<text class="status-tag" :style="getStatusInlineStyle(task.status)">
|
||||
{{ getStatusLabel(task.status) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="info-row">
|
||||
<text class="info-label">随访方式</text>
|
||||
<text class="info-value">{{ getTypeLabel(task.follow_up_type) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="info-row">
|
||||
<text class="info-label">计划日期</text>
|
||||
<text class="info-value">{{ task.planned_date }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="task.content_template" class="info-desc">
|
||||
<text class="info-desc-label">随访内容</text>
|
||||
<text class="info-desc-text">{{ task.content_template }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- History records -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">历史记录</text>
|
||||
|
||||
<view v-if="records.length === 0" class="empty-records">
|
||||
<text class="empty-text">暂无随访记录</text>
|
||||
</view>
|
||||
|
||||
<view v-for="record in records" :key="record.id" class="record-item">
|
||||
<view class="record-date-row">
|
||||
<text class="record-date">{{ record.executed_date }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="record.result" class="record-field">
|
||||
<text class="record-field-label">随访结果</text>
|
||||
<text class="record-field-value">{{ record.result }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="record.patient_condition" class="record-field">
|
||||
<text class="record-field-label">患者状况</text>
|
||||
<text class="record-field-value">{{ record.patient_condition }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="record.medical_advice" class="record-field">
|
||||
<text class="record-field-label">医嘱建议</text>
|
||||
<text class="record-field-value">{{ record.medical_advice }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Submit form (only when can submit) -->
|
||||
<view v-if="canSubmit" class="submit-card">
|
||||
<!-- Start button when pending/overdue -->
|
||||
<view v-if="task.status === 'pending' || task.status === 'overdue'" class="start-btn-wrap">
|
||||
<view
|
||||
:class="['action-btn', startingTask ? 'disabled' : '']"
|
||||
@tap="startingTask ? undefined : handleStart"
|
||||
>
|
||||
<text class="action-btn-text">{{ startingTask ? '处理中...' : '开始随访' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Form when in_progress -->
|
||||
<template v-if="task.status === 'in_progress'">
|
||||
<text class="section-title">填写随访记录</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label"><text class="required">*</text> 随访结果</text>
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
placeholder="请输入随访结果"
|
||||
:value="formData.result"
|
||||
@input="(e: any) => formData.result = e.detail.value"
|
||||
:maxlength="1000"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">患者状况</text>
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
placeholder="请描述患者当前状况(选填)"
|
||||
:value="formData.patient_condition"
|
||||
@input="(e: any) => formData.patient_condition = e.detail.value"
|
||||
:maxlength="500"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">医嘱建议</text>
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
placeholder="请输入医嘱建议(选填)"
|
||||
:value="formData.medical_advice"
|
||||
@input="(e: any) => formData.medical_advice = e.detail.value"
|
||||
:maxlength="500"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">下次随访日期</text>
|
||||
<picker
|
||||
mode="date"
|
||||
:value="formData.next_follow_up_date"
|
||||
@change="(e: any) => formData.next_follow_up_date = e.detail.value"
|
||||
>
|
||||
<view class="date-picker">
|
||||
<text :class="['date-text', formData.next_follow_up_date ? '' : 'placeholder']">
|
||||
{{ formData.next_follow_up_date || '请选择日期' }}
|
||||
</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view
|
||||
:class="['action-btn', submitting ? 'disabled' : '']"
|
||||
@tap="submitting ? undefined : handleSubmit"
|
||||
>
|
||||
<text class="action-btn-text">{{ submitting ? '提交中...' : '提交记录' }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import * as doctorApi from '@/services/doctor/followup'
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
phone: '电话',
|
||||
visit: '门诊',
|
||||
online: '线上',
|
||||
home: '家访',
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const task = ref<doctorApi.FollowUpTask | null>(null)
|
||||
const records = ref<doctorApi.FollowUpRecord[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const startingTask = ref(false)
|
||||
const submitting = ref(false)
|
||||
let taskId = ''
|
||||
|
||||
const formData = reactive({
|
||||
result: '',
|
||||
patient_condition: '',
|
||||
medical_advice: '',
|
||||
next_follow_up_date: '',
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (!task.value) return false
|
||||
return ['pending', 'in_progress', 'overdue'].includes(task.value.status)
|
||||
})
|
||||
|
||||
function getTypeLabel(type: string): string {
|
||||
return TYPE_MAP[type] || type
|
||||
}
|
||||
|
||||
async function fetchDetail() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const [taskData, recordsRes] = await Promise.all([
|
||||
doctorApi.getFollowUpTask(taskId),
|
||||
doctorApi.listFollowUpRecords({ task_id: taskId }),
|
||||
])
|
||||
task.value = taskData
|
||||
records.value = recordsRes.data || []
|
||||
} catch {
|
||||
error.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
if (!task.value) return
|
||||
startingTask.value = true
|
||||
try {
|
||||
const updated = await doctorApi.updateFollowUpTask(
|
||||
taskId,
|
||||
{ status: 'in_progress' },
|
||||
task.value.version,
|
||||
)
|
||||
task.value = updated
|
||||
uni.showToast({ title: '已开始随访', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
} finally {
|
||||
startingTask.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formData.result.trim()) {
|
||||
uni.showToast({ title: '请输入随访结果', icon: 'none' })
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await doctorApi.createFollowUpRecord(taskId, {
|
||||
result: formData.result.trim(),
|
||||
patient_condition: formData.patient_condition.trim() || undefined,
|
||||
medical_advice: formData.medical_advice.trim() || undefined,
|
||||
next_follow_up_date: formData.next_follow_up_date || undefined,
|
||||
})
|
||||
uni.showToast({ title: '提交成功', icon: 'success' })
|
||||
formData.result = ''
|
||||
formData.patient_condition = ''
|
||||
formData.medical_advice = ''
|
||||
formData.next_follow_up_date = ''
|
||||
fetchDetail()
|
||||
} catch {
|
||||
uni.showToast({ title: '提交失败', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
taskId = query?.id || ''
|
||||
if (!taskId) { error.value = true; loading.value = false; return }
|
||||
fetchDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { min-height: 100vh; background: $bg; }
|
||||
.page-content { padding: 24px 0 120px; }
|
||||
|
||||
|
||||
// Info card
|
||||
.info-card {
|
||||
@include card;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.patient-name {
|
||||
font-size: var(--tk-font-title);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
@include status-inline;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
|
||||
.info-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.info-desc {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.info-desc-label {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.info-desc-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// Section card
|
||||
.section-card {
|
||||
@include card;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Records
|
||||
.empty-records {
|
||||
@include flex-center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.record-item:last-child { border-bottom: none; }
|
||||
|
||||
.record-date-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.record-date {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.record-field {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.record-field-label {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.record-field-value {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// Submit card
|
||||
.submit-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.start-btn-wrap {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@include btn-primary;
|
||||
}
|
||||
|
||||
.action-btn.disabled { opacity: 0.5; }
|
||||
|
||||
.action-btn-text {
|
||||
color: $white;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Form
|
||||
.form-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.required { color: $dan; }
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 12px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
box-sizing: border-box;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
.date-picker {
|
||||
height: 48px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 0 12px;
|
||||
@include flex-center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.date-text {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.date-text.placeholder { color: $tx3; }
|
||||
</style>
|
||||
227
apps/miniprogram-uniapp/src/pages-sub/doctor/followup/index.vue
Normal file
227
apps/miniprogram-uniapp/src/pages-sub/doctor/followup/index.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<view :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<text class="page-title">随访任务</text>
|
||||
|
||||
<!-- Tab filter -->
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="tab in TABS" :key="tab.key"
|
||||
:class="['tab', activeTab === tab.key ? 'active' : '']"
|
||||
@tap="handleTabChange(tab.key)"
|
||||
>
|
||||
<text :class="['tab-text', activeTab === tab.key ? 'active' : '']">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Loading -->
|
||||
<Loading v-if="loading && tasks.length === 0" text="加载中..." />
|
||||
|
||||
<!-- Empty -->
|
||||
<EmptyState v-else-if="tasks.length === 0" icon="📋" title="暂无随访任务" />
|
||||
|
||||
<!-- Task list -->
|
||||
<scroll-view v-else scroll-y class="list-scroll" @scrolltolower="loadMore">
|
||||
<view
|
||||
v-for="item in tasks" :key="item.id"
|
||||
class="task-card"
|
||||
@tap="goDetail(item.id)"
|
||||
>
|
||||
<view class="card-header">
|
||||
<view class="type-badge" :style="getTypeStyle(item.follow_up_type)">
|
||||
<text class="type-text">{{ getTypeLabel(item.follow_up_type) }}</text>
|
||||
</view>
|
||||
<text :class="['status-tag', item.status]" :style="getStatusInlineStyle(item.status)">
|
||||
{{ getStatusLabel(item.status) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<text class="patient-name">{{ item.patient_name || '未知患者' }}</text>
|
||||
|
||||
<view class="card-footer">
|
||||
<text class="planned-date">计划日期:{{ item.planned_date }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-if="!loading && tasks.length >= total && total > 0" class="no-more">
|
||||
<text class="no-more-text">没有更多了</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import * as doctorApi from '@/services/doctor/followup'
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'pending', label: '待处理' },
|
||||
{ key: 'in_progress', label: '进行中' },
|
||||
{ key: 'completed', label: '已完成' },
|
||||
{ key: 'overdue', label: '已逾期' },
|
||||
]
|
||||
|
||||
const TYPE_MAP: Record<string, { label: string; bg: string; color: string }> = {
|
||||
phone: { label: '电话', bg: '#E8F0E8', color: '#5B7A5E' },
|
||||
visit: { label: '门诊', bg: '#F0DDD4', color: '#C4623A' },
|
||||
online: { label: '线上', bg: '#E0F0FF', color: '#3B82B8' },
|
||||
home: { label: '家访', bg: '#FFF3E0', color: '#C4873A' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const tasks = ref<doctorApi.FollowUpTask[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const activeTab = ref('')
|
||||
const loading = ref(false)
|
||||
let patientId = ''
|
||||
let loadingGuard = false
|
||||
|
||||
function getTypeLabel(type: string): string {
|
||||
return TYPE_MAP[type]?.label || type
|
||||
}
|
||||
|
||||
function getTypeStyle(type: string): Record<string, string> {
|
||||
const info = TYPE_MAP[type]
|
||||
if (!info) return { background: '#F1F5F9', color: '#78716C' }
|
||||
return { background: info.bg, color: info.color }
|
||||
}
|
||||
|
||||
async function fetchTasks(pageNum: number, status: string, isRefresh = false) {
|
||||
if (loadingGuard) return
|
||||
loadingGuard = true
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, unknown> = { page: pageNum, page_size: 20 }
|
||||
if (status) params.status = status
|
||||
if (patientId) params.patient_id = patientId
|
||||
const res = await doctorApi.listFollowUpTasks(params as Parameters<typeof doctorApi.listFollowUpTasks>[0])
|
||||
const list = res.data || []
|
||||
tasks.value = isRefresh ? list : [...tasks.value, ...list]
|
||||
total.value = res.total
|
||||
page.value = pageNum
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loadingGuard = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleTabChange(key: string) {
|
||||
activeTab.value = key
|
||||
fetchTasks(1, key, true)
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (!loading.value && tasks.value.length < total.value) {
|
||||
fetchTasks(page.value + 1, activeTab.value)
|
||||
}
|
||||
}
|
||||
|
||||
function goDetail(id: string) {
|
||||
uni.navigateTo({ url: `/pages-sub/doctor/followup/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
patientId = query?.patientId || ''
|
||||
fetchTasks(1, '', true)
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
fetchTasks(1, activeTab.value, true).finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { min-height: 100vh; background: $bg; }
|
||||
.page-content { padding: 28px 0 120px; }
|
||||
.page-title { @include section-title; margin-left: 24px; }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
padding: 0 24px 16px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 6px 16px;
|
||||
min-height: $touch-min;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 20px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.tab.active { background: $pri; }
|
||||
|
||||
.tab-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.tab-text.active { color: $card; }
|
||||
|
||||
.list-scroll { height: calc(100vh - 160px); }
|
||||
|
||||
.task-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
margin: 0 24px 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: $r-xs;
|
||||
}
|
||||
|
||||
.type-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
@include status-inline;
|
||||
}
|
||||
|
||||
.patient-name {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.planned-date {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.no-more { @include flex-center; padding: 20px; }
|
||||
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
</style>
|
||||
451
apps/miniprogram-uniapp/src/pages-sub/doctor/index.vue
Normal file
451
apps/miniprogram-uniapp/src/pages-sub/doctor/index.vue
Normal file
@@ -0,0 +1,451 @@
|
||||
<template>
|
||||
<Loading v-if="pageLoading" text="加载中..." />
|
||||
<scroll-view v-else scroll-y class="page-scroll">
|
||||
<view :class="['page-content', elderClass]">
|
||||
<!-- 顶部问候 -->
|
||||
<view class="header">
|
||||
<text class="header-title">医护工作台</text>
|
||||
<text class="header-greeting">{{ greeting }},{{ displayName }}</text>
|
||||
<text class="header-date">{{ todayStr }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 异常体征告警横幅 -->
|
||||
<view v-if="alertCount > 0" class="alert-banner" @tap="goAlerts">
|
||||
<text class="alert-icon">!</text>
|
||||
<text class="alert-text">{{ alertCount }} 位患者体征异常</text>
|
||||
<text class="alert-link">查看 ></text>
|
||||
</view>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<view class="search-bar">
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="搜索患者姓名..."
|
||||
placeholder-class="search-placeholder"
|
||||
:focus="false"
|
||||
@focus="goPatients"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 工作概览 -->
|
||||
<view class="section">
|
||||
<text class="section-title">工作概览</text>
|
||||
<view class="grid-2">
|
||||
<view
|
||||
v-for="card in visibleCards"
|
||||
:key="card.key"
|
||||
class="overview-card"
|
||||
@tap="navigateTo(card.route)"
|
||||
>
|
||||
<text class="overview-card__initial">{{ card.initial }}</text>
|
||||
<text class="overview-card__num">{{ getValue(card.key) }}</text>
|
||||
<text class="overview-card__label">{{ card.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 健康审核 -->
|
||||
<view v-if="visibleHealthCards.length > 0" class="section">
|
||||
<text class="section-title">健康审核</text>
|
||||
<view class="grid-2">
|
||||
<view
|
||||
v-for="card in visibleHealthCards"
|
||||
:key="card.key"
|
||||
class="overview-card"
|
||||
@tap="navigateTo(card.route)"
|
||||
>
|
||||
<text class="overview-card__initial">{{ card.initial }}</text>
|
||||
<text class="overview-card__num">{{ getValue(card.key) }}</text>
|
||||
<text class="overview-card__label">{{ card.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
<view class="section">
|
||||
<text class="section-title">快捷操作</text>
|
||||
<view class="grid-4">
|
||||
<view
|
||||
v-for="action in visibleQuickActions"
|
||||
:key="action.route"
|
||||
class="quick-action"
|
||||
@tap="navigateTo(action.route)"
|
||||
>
|
||||
<view class="quick-action__icon-wrap">
|
||||
<text class="quick-action__initial">{{ action.initial }}</text>
|
||||
<text
|
||||
v-if="action.label === '告警中心' && alertCount > 0"
|
||||
class="quick-action__badge"
|
||||
>{{ alertCount > 99 ? '99+' : alertCount }}</text>
|
||||
</view>
|
||||
<text class="quick-action__label">{{ action.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 退出登录 -->
|
||||
<view class="footer">
|
||||
<text class="logout-btn" @tap="handleLogout">退出登录</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { getDashboard } from '@/services/doctor/dashboard'
|
||||
import type { DoctorDashboard } from '@/services/doctor/dashboard'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
interface CardConfig {
|
||||
key: keyof DoctorDashboard
|
||||
label: string
|
||||
initial: string
|
||||
route: string
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
const ALL_CARDS: CardConfig[] = [
|
||||
{ key: 'total_patients', label: '我的患者', initial: '患', route: '/pages-sub/doctor/patients/index' },
|
||||
{ key: 'unread_messages', label: '未读消息', initial: '消', route: '/pages-sub/doctor/consultation/index' },
|
||||
{ key: 'pending_follow_ups', label: '待处理随访', initial: '随', route: '/pages-sub/doctor/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ key: 'today_consultations', label: '今日咨询', initial: '诊', route: '/pages-sub/doctor/consultation/index', roles: ['doctor', 'health_manager'] },
|
||||
]
|
||||
|
||||
const ALL_HEALTH_CARDS: CardConfig[] = [
|
||||
{ key: 'pending_lab_review', label: '待审化验', initial: '化', route: '/pages-sub/doctor/report/index', roles: ['doctor'] },
|
||||
{ key: 'today_appointments', label: '今日预约', initial: '约', route: '/pages-sub/doctor/patients/index' },
|
||||
]
|
||||
|
||||
interface QuickAction {
|
||||
label: string
|
||||
initial: string
|
||||
route: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
const ALL_QUICK_ACTIONS: QuickAction[] = [
|
||||
{ label: '化验审核', initial: '审', route: '/pages-sub/doctor/report/index', roles: ['doctor'] },
|
||||
{ label: '患者查询', initial: '查', route: '/pages-sub/doctor/patients/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ label: '随访记录', initial: '随', route: '/pages-sub/doctor/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ label: '告警中心', initial: '警', route: '/pages-sub/doctor/alerts/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
{ label: '透析管理', initial: '透', route: '/pages-sub/doctor/dialysis/index', roles: ['doctor'] },
|
||||
{ label: '处方管理', initial: '方', route: '/pages-sub/doctor/prescription/index', roles: ['doctor'] },
|
||||
{ label: '行动收件箱', initial: '行', route: '/pages-sub/doctor/action-inbox/index', roles: ['doctor', 'nurse', 'health_manager'] },
|
||||
]
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
doctor: '医生',
|
||||
nurse: '护士',
|
||||
health_manager: '健康管理师',
|
||||
admin: '管理员',
|
||||
operator: '运营',
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const dashboard = ref<DoctorDashboard | null>(null)
|
||||
const alertCount = ref(0)
|
||||
const pageLoading = ref(true)
|
||||
|
||||
const displayName = computed(() => {
|
||||
const user = authStore.user
|
||||
const roles = authStore.roles
|
||||
if (user?.display_name) return user.display_name
|
||||
if (user?.username) return user.username
|
||||
const primary = roles.find(r => r !== 'admin')
|
||||
return primary ? (ROLE_LABELS[primary] || primary) : '医护'
|
||||
})
|
||||
|
||||
const greeting = computed(() => {
|
||||
const h = new Date().getHours()
|
||||
if (h < 6) return '夜深了'
|
||||
if (h < 12) return '早上好'
|
||||
if (h < 14) return '中午好'
|
||||
if (h < 18) return '下午好'
|
||||
return '晚上好'
|
||||
})
|
||||
|
||||
const todayStr = computed(() => {
|
||||
return new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' })
|
||||
})
|
||||
|
||||
function hasRole(allowed: string[] | undefined): boolean {
|
||||
if (!allowed) return true
|
||||
return authStore.roles.some(r => r === 'admin' || allowed.includes(r))
|
||||
}
|
||||
|
||||
const visibleCards = computed(() => ALL_CARDS.filter(c => hasRole(c.roles)))
|
||||
const visibleHealthCards = computed(() => ALL_HEALTH_CARDS.filter(c => hasRole(c.roles)))
|
||||
const visibleQuickActions = computed(() => ALL_QUICK_ACTIONS.filter(a => hasRole(a.roles)))
|
||||
|
||||
function getValue(key: keyof DoctorDashboard): number | string {
|
||||
if (!dashboard.value) return '-'
|
||||
return dashboard.value[key] ?? 0
|
||||
}
|
||||
|
||||
function navigateTo(url: string) {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
function goPatients() {
|
||||
uni.navigateTo({ url: '/pages-sub/doctor/patients/index' })
|
||||
}
|
||||
|
||||
function goAlerts() {
|
||||
uni.navigateTo({ url: '/pages-sub/doctor/alerts/index' })
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
authStore.logout()
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const data = await getDashboard()
|
||||
dashboard.value = data
|
||||
const count = (data as Record<string, unknown>)?.abnormal_vital_count
|
||||
alertCount.value = typeof count === 'number' ? count : 0
|
||||
} catch {
|
||||
// 静默失败,显示占位
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDashboard()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
authStore.restore()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 32px 24px 120px;
|
||||
}
|
||||
|
||||
// ── 顶部问候 ──
|
||||
.header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
@include section-title;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.header-greeting {
|
||||
display: block;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header-date {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// ── 告警横幅 ──
|
||||
.alert-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 0 24px;
|
||||
padding: 16px 20px;
|
||||
min-height: $touch-min;
|
||||
background: $dan-l;
|
||||
border-radius: $r;
|
||||
border-left: 4px solid $dan;
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
@include flex-center;
|
||||
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: 12px;
|
||||
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-bar {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: $surface-alt;
|
||||
border-radius: $r;
|
||||
padding: 16px 20px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// ── 通用区块 ──
|
||||
.section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
// ── 工作概览网格 ──
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 28px 24px;
|
||||
text-align: center;
|
||||
box-shadow: $shadow-md;
|
||||
transition: transform 0.15s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
.overview-card__initial {
|
||||
display: inline-flex;
|
||||
@include flex-center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: $r;
|
||||
background: $pri-l;
|
||||
color: $pri;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.overview-card__num {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-hero);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.overview-card__label {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
// ── 快捷操作 ──
|
||||
.grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.quick-action {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 28px 20px;
|
||||
text-align: center;
|
||||
box-shadow: $shadow-md;
|
||||
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-action__icon-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.quick-action__initial {
|
||||
@include flex-center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: $r;
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.quick-action__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;
|
||||
}
|
||||
|
||||
.quick-action__label {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// ── 底部 ──
|
||||
.footer {
|
||||
margin-top: 60px;
|
||||
text-align: center;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
color: $dan;
|
||||
font-size: var(--tk-font-h2);
|
||||
padding: 16px 48px;
|
||||
min-height: $touch-min;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,402 @@
|
||||
<template>
|
||||
<Loading v-if="pageLoading" text="加载中..." />
|
||||
<view v-else-if="!patient" :class="['error-wrap', elderClass]">
|
||||
<text class="error-text">患者信息加载失败</text>
|
||||
</view>
|
||||
<scroll-view v-else scroll-y class="page-scroll">
|
||||
<view :class="['page-content', elderClass]">
|
||||
<!-- 基本信息 -->
|
||||
<view class="section">
|
||||
<text class="section-title">基本信息</text>
|
||||
<view class="info-grid">
|
||||
<view class="info-item">
|
||||
<text class="info-label">姓名</text>
|
||||
<text class="info-value">{{ patient.name }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="info-label">性别</text>
|
||||
<text class="info-value">{{ genderLabel(patient.gender) }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="info-label">年龄</text>
|
||||
<text class="info-value">{{ calcAge(patient.birth_date) }}岁</text>
|
||||
</view>
|
||||
<view v-if="patient.blood_type" class="info-item">
|
||||
<text class="info-label">血型</text>
|
||||
<text class="info-value">{{ patient.blood_type }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 医疗信息 -->
|
||||
<view v-if="patient.allergy_history || patient.medical_history_summary" class="section">
|
||||
<text class="section-title">医疗信息</text>
|
||||
<view v-if="patient.allergy_history" class="warning-card">
|
||||
<text class="warning-label">过敏史</text>
|
||||
<text class="warning-text">{{ patient.allergy_history }}</text>
|
||||
</view>
|
||||
<view v-if="patient.medical_history_summary" class="info-block">
|
||||
<text class="info-block-label">病史摘要</text>
|
||||
<text class="info-block-text">{{ patient.medical_history_summary }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 健康概览 -->
|
||||
<view v-if="summary" class="section">
|
||||
<text class="section-title">健康概览</text>
|
||||
<view v-if="summary.latest_vital_signs" class="vitals-grid">
|
||||
<view
|
||||
v-if="summary.latest_vital_signs.systolic_bp != null"
|
||||
class="vital-item"
|
||||
>
|
||||
<text class="vital-value">{{ summary.latest_vital_signs.systolic_bp }}/{{ summary.latest_vital_signs.diastolic_bp }}</text>
|
||||
<text class="vital-label">血压 mmHg</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="summary.latest_vital_signs.heart_rate != null"
|
||||
class="vital-item"
|
||||
>
|
||||
<text class="vital-value">{{ summary.latest_vital_signs.heart_rate }}</text>
|
||||
<text class="vital-label">心率 bpm</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="summary.latest_vital_signs.weight != null"
|
||||
class="vital-item"
|
||||
>
|
||||
<text class="vital-value">{{ summary.latest_vital_signs.weight }}</text>
|
||||
<text class="vital-label">体重 kg</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="summary.latest_vital_signs.blood_sugar != null"
|
||||
class="vital-item"
|
||||
>
|
||||
<text class="vital-value">{{ summary.latest_vital_signs.blood_sugar }}</text>
|
||||
<text class="vital-label">血糖 mmol/L</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="summary.pending_follow_ups != null && summary.pending_follow_ups > 0" class="stat-row">
|
||||
<text class="stat-label">待处理随访</text>
|
||||
<text class="stat-value stat-value--warn">{{ summary.pending_follow_ups }} 项</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 近期化验 -->
|
||||
<view v-if="summary?.latest_lab_report" class="section">
|
||||
<text class="section-title">近期化验</text>
|
||||
<view
|
||||
class="lab-item"
|
||||
@tap="goReportDetail(summary!.latest_lab_report!.id)"
|
||||
>
|
||||
<view class="lab-item__header">
|
||||
<text class="lab-item__type">{{ summary.latest_lab_report.report_type }}</text>
|
||||
<text class="lab-item__date">{{ summary.latest_lab_report.report_date }}</text>
|
||||
</view>
|
||||
<text
|
||||
v-if="(summary.latest_lab_report.abnormal_count ?? 0) > 0"
|
||||
class="lab-item__abnormal"
|
||||
>{{ summary.latest_lab_report.abnormal_count }} 项异常</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="section">
|
||||
<text class="section-title">操作</text>
|
||||
<view class="action-buttons">
|
||||
<view class="action-btn" @tap="goReports">
|
||||
<text class="action-btn__text">查看化验报告</text>
|
||||
</view>
|
||||
<view class="action-btn" @tap="goFollowups">
|
||||
<text class="action-btn__text">随访记录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { getPatient, getHealthSummary } from '@/services/doctor/patient'
|
||||
import type { PatientDetail, HealthSummary } from '@/services/doctor/patient'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const patient = ref<PatientDetail | null>(null)
|
||||
const summary = ref<HealthSummary | null>(null)
|
||||
const pageLoading = ref(true)
|
||||
const patientId = ref('')
|
||||
|
||||
function genderLabel(g?: string): string {
|
||||
if (g === 'male') return '男'
|
||||
if (g === 'female') return '女'
|
||||
return g || '-'
|
||||
}
|
||||
|
||||
function calcAge(bd?: string): string {
|
||||
if (!bd) return '-'
|
||||
const diff = Date.now() - new Date(bd).getTime()
|
||||
return String(Math.floor(diff / (365.25 * 24 * 3600 * 1000)))
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!patientId.value) return
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const [p, s] = await Promise.all([
|
||||
getPatient(patientId.value),
|
||||
getHealthSummary(patientId.value),
|
||||
])
|
||||
patient.value = p
|
||||
summary.value = s
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goReportDetail(reportId: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages-sub/doctor/report/detail/index?patientId=${patientId.value}&id=${reportId}`,
|
||||
})
|
||||
}
|
||||
|
||||
function goReports() {
|
||||
uni.navigateTo({
|
||||
url: `/pages-sub/doctor/report/index?patientId=${patientId.value}`,
|
||||
})
|
||||
}
|
||||
|
||||
function goFollowups() {
|
||||
uni.navigateTo({
|
||||
url: `/pages-sub/doctor/followup/index?patientId=${patientId.value}`,
|
||||
})
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
patientId.value = query?.id || ''
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 24px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.error-wrap {
|
||||
@include flex-center;
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// ── 通用区块 ──
|
||||
.section {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 28px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
// ── 信息网格 ──
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// ── 过敏警告卡 ──
|
||||
.warning-card {
|
||||
background: $wrn-l;
|
||||
border-radius: $r;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.warning-label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $wrn;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri-d;
|
||||
}
|
||||
|
||||
// ── 病史摘要 ──
|
||||
.info-block {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.info-block-label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-block-text {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// ── 体征网格 ──
|
||||
.vitals-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vital-item {
|
||||
background: $pri-l;
|
||||
border-radius: $r;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vital-label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
// ── 统计行 ──
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
|
||||
&--warn {
|
||||
color: $wrn;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 化验卡片 ──
|
||||
.lab-item {
|
||||
padding: 20px 0;
|
||||
min-height: $touch-min;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__type {
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__abnormal {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $dan;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 操作按钮 ──
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
min-height: $touch-min;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: $r;
|
||||
background: $pri;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&__text {
|
||||
color: $card;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
344
apps/miniprogram-uniapp/src/pages-sub/doctor/patients/index.vue
Normal file
344
apps/miniprogram-uniapp/src/pages-sub/doctor/patients/index.vue
Normal file
@@ -0,0 +1,344 @@
|
||||
<template>
|
||||
<Loading v-if="pageLoading && patients.length === 0" text="加载中..." />
|
||||
<scroll-view v-else scroll-y class="page-scroll" @scrolltolower="onLoadMore">
|
||||
<view :class="['page-content', elderClass]">
|
||||
<!-- 搜索栏 -->
|
||||
<view class="search-bar">
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="搜索患者姓名/手机号"
|
||||
placeholder-class="search-placeholder"
|
||||
:value="search"
|
||||
confirm-type="search"
|
||||
@input="onSearchInput"
|
||||
@confirm="handleSearch"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 标签过滤 -->
|
||||
<scroll-view v-if="tags.length > 0" scroll-x class="tag-filter">
|
||||
<view
|
||||
:class="['tag-chip', { active: !activeTag }]"
|
||||
@tap="handleTagFilter('')"
|
||||
>
|
||||
<text>全部</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
:class="['tag-chip', { active: activeTag === tag.id }]"
|
||||
:style="activeTag === tag.id && tag.color ? `background: ${tag.color}; color: white` : ''"
|
||||
@tap="handleTagFilter(tag.id)"
|
||||
>
|
||||
<text>{{ tag.name }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 患者数量 -->
|
||||
<view class="patient-count">
|
||||
<text>共 {{ total }} 位患者</text>
|
||||
</view>
|
||||
|
||||
<!-- 患者卡片列表 -->
|
||||
<EmptyState v-if="patients.length === 0" icon="📋" title="暂无患者数据" />
|
||||
<view v-else class="patient-cards">
|
||||
<view
|
||||
v-for="p in patients"
|
||||
:key="p.id"
|
||||
class="patient-card"
|
||||
@tap="goDetail(p.id)"
|
||||
>
|
||||
<view class="patient-card__header">
|
||||
<text class="patient-card__name">{{ p.name }}</text>
|
||||
<text class="patient-card__meta">{{ genderLabel(p.gender) }} {{ calcAge(p.birth_date) }}</text>
|
||||
</view>
|
||||
<view v-if="p.tags && p.tags.length > 0" class="patient-card__tags">
|
||||
<view
|
||||
v-for="t in p.tags"
|
||||
:key="t.id"
|
||||
class="patient-tag"
|
||||
:style="t.color ? `background: ${t.color}20; color: ${t.color}` : ''"
|
||||
>
|
||||
<text class="patient-tag__text">{{ t.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="p.status" :class="['patient-card__status', `patient-card__status--${p.status}`]">
|
||||
{{ p.status === 'active' ? '活跃' : p.status === 'inactive' ? '非活跃' : p.status }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多提示 -->
|
||||
<view v-if="!loadingMore && patients.length >= total && total > 0" class="load-hint-wrap">
|
||||
<text class="load-hint">没有更多了</text>
|
||||
</view>
|
||||
<Loading v-if="loadingMore" text="加载中..." />
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { listPatients, listPatientTags } from '@/services/doctor/patient'
|
||||
import type { PatientItem, PatientTag } from '@/services/doctor/patient'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const patients = ref<PatientItem[]>([])
|
||||
const tags = ref<PatientTag[]>([])
|
||||
const activeTag = ref('')
|
||||
const search = ref('')
|
||||
const pageLoading = ref(true)
|
||||
const loadingMore = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
|
||||
function genderLabel(gender?: string): string {
|
||||
if (!gender) return ''
|
||||
if (gender === 'male') return '男'
|
||||
if (gender === 'female') return '女'
|
||||
return gender
|
||||
}
|
||||
|
||||
function calcAge(birthDate?: string): string {
|
||||
if (!birthDate) return ''
|
||||
const birth = new Date(birthDate)
|
||||
const now = new Date()
|
||||
let age = now.getFullYear() - birth.getFullYear()
|
||||
if (
|
||||
now.getMonth() < birth.getMonth() ||
|
||||
(now.getMonth() === birth.getMonth() && now.getDate() < birth.getDate())
|
||||
) {
|
||||
age--
|
||||
}
|
||||
return `${age}岁`
|
||||
}
|
||||
|
||||
async function loadTags() {
|
||||
try {
|
||||
const res = await listPatientTags()
|
||||
tags.value = res.data || []
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPatients(pageNum: number, isRefresh = false) {
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
if (isRefresh) {
|
||||
pageLoading.value = true
|
||||
} else {
|
||||
loadingMore.value = true
|
||||
}
|
||||
try {
|
||||
const res = await listPatients({
|
||||
page: pageNum,
|
||||
page_size: 20,
|
||||
search: search.value || undefined,
|
||||
tag_id: activeTag.value || undefined,
|
||||
})
|
||||
const list = res.data || []
|
||||
if (isRefresh) {
|
||||
patients.value = list
|
||||
} else {
|
||||
patients.value = [...patients.value, ...list]
|
||||
}
|
||||
total.value = res.total || 0
|
||||
page.value = pageNum
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
loadingMore.value = false
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchInput(e: { detail: { value: string } }) {
|
||||
search.value = e.detail.value
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
loadPatients(1, true)
|
||||
}
|
||||
|
||||
function handleTagFilter(tagId: string) {
|
||||
activeTag.value = tagId === activeTag.value ? '' : tagId
|
||||
}
|
||||
|
||||
function goDetail(id: string) {
|
||||
uni.navigateTo({ url: `/pages-sub/doctor/patients/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
function onLoadMore() {
|
||||
if (!isLoading.value && patients.value.length < total.value) {
|
||||
loadPatients(page.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeTag, () => {
|
||||
loadPatients(1, true)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadTags()
|
||||
loadPatients(1, true)
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadPatients(1, true).finally(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
// 从详情页返回时不需要重新加载,保留列表状态
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 24px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
// ── 搜索栏 ──
|
||||
.search-bar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// ── 标签过滤 ──
|
||||
.tag-filter {
|
||||
white-space: nowrap;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 10px 24px;
|
||||
min-height: $touch-min;
|
||||
border-radius: $r-pill;
|
||||
background: $bd-l;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
margin-right: 16px;
|
||||
|
||||
&.active {
|
||||
background: $pri;
|
||||
color: $card;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 患者计数 ──
|
||||
.patient-count {
|
||||
margin-bottom: 16px;
|
||||
|
||||
text {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 患者卡片 ──
|
||||
.patient-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.patient-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 28px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__status {
|
||||
@include tag($bg, $tx2);
|
||||
|
||||
&--active {
|
||||
@include tag($acc-l, $acc);
|
||||
}
|
||||
|
||||
&--inactive {
|
||||
@include tag($bd-l, $tx3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.patient-tag {
|
||||
padding: 4px 14px;
|
||||
border-radius: $r;
|
||||
background: $pri-l;
|
||||
|
||||
&__text {
|
||||
font-size: var(--tk-font-body);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 加载提示 ──
|
||||
.load-hint-wrap {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.load-hint {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,517 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<!-- 患者选择 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">选择患者</text>
|
||||
<view class="patient-search">
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="搜索患者姓名"
|
||||
:value="patientSearch"
|
||||
@input="(e: any) => { patientSearch = e.detail.value; searchPatients() }"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="searchingPatient" class="loading-hint">
|
||||
<text class="loading-hint__text">搜索中...</text>
|
||||
</view>
|
||||
<view v-else-if="patientResults.length > 0" class="patient-list">
|
||||
<view
|
||||
v-for="p in patientResults"
|
||||
:key="p.id"
|
||||
:class="['patient-item', form.patient_id === p.id ? 'patient-item--selected' : '']"
|
||||
@tap="selectPatient(p)"
|
||||
>
|
||||
<text class="patient-item__name">{{ p.name }}</text>
|
||||
<text class="patient-item__info">{{ p.gender === 'male' ? '男' : p.gender === 'female' ? '女' : '' }}</text>
|
||||
<view v-if="form.patient_id === p.id" class="patient-item__check">
|
||||
<text class="check-icon">✓</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="patientSearch && !searchingPatient" class="empty-hint">
|
||||
<text class="empty-hint__text">未找到患者</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 透析方式与频率 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">透析方案</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">透析器型号</text>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="如 F60S"
|
||||
:value="form.dialyzer_model"
|
||||
@input="(e: any) => form.dialyzer_model = e.detail.value"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">频率(次/周) <text class="required">*</text></text>
|
||||
<input
|
||||
type="number"
|
||||
class="form-input"
|
||||
placeholder="如 3"
|
||||
:value="form.frequency_per_week ?? ''"
|
||||
@input="(e: any) => updateNumericField('frequency_per_week', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">单次时长(分钟)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="如 240"
|
||||
:value="form.duration_minutes ?? ''"
|
||||
@input="(e: any) => updateNumericField('duration_minutes', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">生效日期</text>
|
||||
<picker mode="date" :value="form.effective_from" @change="(e: any) => form.effective_from = e.detail.value">
|
||||
<view :class="['picker-display', form.effective_from ? '' : 'placeholder']">
|
||||
{{ form.effective_from || '选择日期' }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">失效日期</text>
|
||||
<picker mode="date" :value="form.effective_to" @change="(e: any) => form.effective_to = e.detail.value">
|
||||
<view :class="['picker-display', form.effective_to ? '' : 'placeholder']">
|
||||
{{ form.effective_to || '选择日期' }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 透析参数 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">透析参数</text>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">血流量 (ml/min)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="如 300"
|
||||
:value="form.blood_flow_rate ?? ''"
|
||||
@input="(e: any) => updateNumericField('blood_flow_rate', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">透析液流量 (ml/min)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="如 500"
|
||||
:value="form.dialysate_flow_rate ?? ''"
|
||||
@input="(e: any) => updateNumericField('dialysate_flow_rate', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">抗凝方式</text>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="如 肝素、低分子肝素"
|
||||
:value="form.anticoagulation_type"
|
||||
@input="(e: any) => form.anticoagulation_type = e.detail.value"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">抗凝剂量</text>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="如 2000IU"
|
||||
:value="form.anticoagulation_dose"
|
||||
@input="(e: any) => form.anticoagulation_dose = e.detail.value"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">透析液钾 (mmol/L)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="如 2.0"
|
||||
:value="form.dialysate_potassium ?? ''"
|
||||
@input="(e: any) => updateNumericField('dialysate_potassium', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">透析液钙 (mmol/L)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="如 1.5"
|
||||
:value="form.dialysate_calcium ?? ''"
|
||||
@input="(e: any) => updateNumericField('dialysate_calcium', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">膜面积 (m2)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="如 1.8"
|
||||
:value="form.membrane_area ?? ''"
|
||||
@input="(e: any) => updateNumericField('membrane_area', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 目标参数 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">目标参数</text>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">干体重 (kg)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="如 65.0"
|
||||
:value="form.target_dry_weight ?? ''"
|
||||
@input="(e: any) => updateNumericField('target_dry_weight', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-field form-field--half">
|
||||
<text class="form-label">超滤目标 (ml)</text>
|
||||
<input
|
||||
type="digit"
|
||||
class="form-input"
|
||||
placeholder="如 2000"
|
||||
:value="form.target_ultrafiltration_ml ?? ''"
|
||||
@input="(e: any) => updateNumericField('target_ultrafiltration_ml', e.detail.value)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 血管通路 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">血管通路</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">通路类型</text>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="如 动静脉内瘘、中心静脉导管"
|
||||
:value="form.vascular_access_type"
|
||||
@input="(e: any) => form.vascular_access_type = e.detail.value"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">通路位置</text>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="如 左前臂"
|
||||
:value="form.vascular_access_location"
|
||||
@input="(e: any) => form.vascular_access_location = e.detail.value"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 备注 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">备注</text>
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
placeholder="处方备注(选填)"
|
||||
:value="form.notes"
|
||||
@input="(e: any) => form.notes = e.detail.value"
|
||||
:maxlength="1000"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 提交 -->
|
||||
<view class="submit-wrap">
|
||||
<view :class="['action-btn', submitting ? 'disabled' : '']" @tap="submitting ? undefined : handleSubmit">
|
||||
<text class="action-btn-text">{{ submitting ? '提交中...' : '创建处方' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { createDialysisPrescription } from '@/services/doctor/dialysis'
|
||||
import { listPatients } from '@/services/doctor/patient'
|
||||
import type { PatientItem } from '@/services/doctor/patient'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const form = reactive({
|
||||
patient_id: '',
|
||||
dialyzer_model: '',
|
||||
frequency_per_week: undefined as number | undefined,
|
||||
duration_minutes: undefined as number | undefined,
|
||||
blood_flow_rate: undefined as number | undefined,
|
||||
dialysate_flow_rate: undefined as number | undefined,
|
||||
anticoagulation_type: '',
|
||||
anticoagulation_dose: '',
|
||||
dialysate_potassium: undefined as number | undefined,
|
||||
dialysate_calcium: undefined as number | undefined,
|
||||
dialysate_bicarbonate: undefined as number | undefined,
|
||||
membrane_area: undefined as number | undefined,
|
||||
target_dry_weight: undefined as number | undefined,
|
||||
target_ultrafiltration_ml: undefined as number | undefined,
|
||||
vascular_access_type: '',
|
||||
vascular_access_location: '',
|
||||
effective_from: '',
|
||||
effective_to: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
const patientSearch = ref('')
|
||||
const patientResults = ref<PatientItem[]>([])
|
||||
const searchingPatient = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function searchPatients() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
if (!patientSearch.value.trim()) {
|
||||
patientResults.value = []
|
||||
return
|
||||
}
|
||||
searchTimer = setTimeout(async () => {
|
||||
searchingPatient.value = true
|
||||
try {
|
||||
const res = await listPatients({ search: patientSearch.value.trim(), page_size: 10 })
|
||||
patientResults.value = res.data || []
|
||||
} catch {
|
||||
patientResults.value = []
|
||||
} finally {
|
||||
searchingPatient.value = false
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function selectPatient(p: PatientItem) {
|
||||
form.patient_id = form.patient_id === p.id ? '' : p.id
|
||||
}
|
||||
|
||||
function updateNumericField(field: keyof typeof form, raw: string) {
|
||||
const val = raw.trim() === '' ? undefined : Number(raw)
|
||||
;(form as any)[field] = isNaN(val as number) ? undefined : val
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.patient_id) {
|
||||
uni.showToast({ title: '请选择患者', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!form.frequency_per_week) {
|
||||
uni.showToast({ title: '请填写透析频率', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await createDialysisPrescription({
|
||||
patient_id: form.patient_id,
|
||||
dialyzer_model: form.dialyzer_model || undefined,
|
||||
frequency_per_week: form.frequency_per_week,
|
||||
duration_minutes: form.duration_minutes,
|
||||
blood_flow_rate: form.blood_flow_rate,
|
||||
dialysate_flow_rate: form.dialysate_flow_rate,
|
||||
anticoagulation_type: form.anticoagulation_type || undefined,
|
||||
anticoagulation_dose: form.anticoagulation_dose || undefined,
|
||||
dialysate_potassium: form.dialysate_potassium,
|
||||
dialysate_calcium: form.dialysate_calcium,
|
||||
dialysate_bicarbonate: form.dialysate_bicarbonate,
|
||||
membrane_area: form.membrane_area,
|
||||
target_dry_weight: form.target_dry_weight,
|
||||
target_ultrafiltration_ml: form.target_ultrafiltration_ml,
|
||||
vascular_access_type: form.vascular_access_type || undefined,
|
||||
vascular_access_location: form.vascular_access_location || undefined,
|
||||
effective_from: form.effective_from || undefined,
|
||||
effective_to: form.effective_to || undefined,
|
||||
notes: form.notes.trim() || undefined,
|
||||
})
|
||||
uni.showToast({ title: '创建成功', icon: 'success' })
|
||||
setTimeout(() => uni.navigateBack(), 800)
|
||||
} catch {
|
||||
uni.showToast({ title: '创建失败', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { min-height: 100vh; background: $bg; }
|
||||
.page-content { padding: 24px 0 160px; }
|
||||
|
||||
.section-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Search
|
||||
.patient-search { margin-bottom: 12px; }
|
||||
|
||||
.search-input {
|
||||
height: 48px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 0 16px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
background: $card;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.loading-hint, .empty-hint {
|
||||
@include flex-center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.loading-hint__text, .empty-hint__text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.patient-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.patient-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 12px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
border-radius: $r-xs;
|
||||
|
||||
&:active { background: $bd-l; }
|
||||
&--selected { background: $pri-l; }
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__info {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
&__check {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@include flex-center;
|
||||
background: $pri;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: $card;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
|
||||
// Form
|
||||
.form-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-field--half {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.required { color: $dan; }
|
||||
|
||||
.form-input {
|
||||
height: 48px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 0 16px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
background: $card;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.picker-display {
|
||||
height: 48px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 0 16px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
background: $card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.picker-display.placeholder { color: $tx3; }
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 12px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
box-sizing: border-box;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
// Submit
|
||||
.submit-wrap { margin: 0 24px; }
|
||||
|
||||
.action-btn {
|
||||
@include btn-primary;
|
||||
}
|
||||
|
||||
.action-btn.disabled { opacity: 0.5; }
|
||||
|
||||
.action-btn-text {
|
||||
color: $card;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<Loading v-if="pageLoading" text="加载中..." />
|
||||
<ErrorState v-else-if="error || !prescription" text="处方加载失败" :on-retry="loadData" />
|
||||
<scroll-view v-else scroll-y :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<!-- 处方信息 -->
|
||||
<view class="info-card">
|
||||
<view class="info-header">
|
||||
<text class="section-label">处方信息</text>
|
||||
<view class="status-tag" :style="getStatusInlineStyle(prescription.status)">
|
||||
<text class="status-tag__text">{{ getStatusLabel(prescription.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="info-row">
|
||||
<text class="info-label">透析方式</text>
|
||||
<text class="info-value">{{ prescription.dialyzer_model || '-' }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">频率</text>
|
||||
<text class="info-value">{{ prescription.frequency_per_week ? `${prescription.frequency_per_week} 次/周` : '-' }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">单次时长</text>
|
||||
<text class="info-value">{{ prescription.duration_minutes ? `${prescription.duration_minutes} 分钟` : '-' }}</text>
|
||||
</view>
|
||||
<view v-if="prescription.effective_from" class="info-row">
|
||||
<text class="info-label">生效日期</text>
|
||||
<text class="info-value">{{ prescription.effective_from }}</text>
|
||||
</view>
|
||||
<view v-if="prescription.effective_to" class="info-row">
|
||||
<text class="info-label">失效日期</text>
|
||||
<text class="info-value">{{ prescription.effective_to }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 透析参数 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">透析参数</text>
|
||||
|
||||
<view v-if="prescription.blood_flow_rate != null" class="info-row">
|
||||
<text class="info-label">血流量</text>
|
||||
<text class="info-value">{{ prescription.blood_flow_rate }} ml/min</text>
|
||||
</view>
|
||||
<view v-if="prescription.dialysate_flow_rate != null" class="info-row">
|
||||
<text class="info-label">透析液流量</text>
|
||||
<text class="info-value">{{ prescription.dialysate_flow_rate }} ml/min</text>
|
||||
</view>
|
||||
<view v-if="prescription.dialysate_potassium != null" class="info-row">
|
||||
<text class="info-label">透析液钾浓度</text>
|
||||
<text class="info-value">{{ prescription.dialysate_potassium }} mmol/L</text>
|
||||
</view>
|
||||
<view v-if="prescription.dialysate_calcium != null" class="info-row">
|
||||
<text class="info-label">透析液钙浓度</text>
|
||||
<text class="info-value">{{ prescription.dialysate_calcium }} mmol/L</text>
|
||||
</view>
|
||||
<view v-if="prescription.dialysate_bicarbonate != null" class="info-row">
|
||||
<text class="info-label">透析液碳酸氢盐</text>
|
||||
<text class="info-value">{{ prescription.dialysate_bicarbonate }} mmol/L</text>
|
||||
</view>
|
||||
<view v-if="prescription.anticoagulation_type" class="info-row">
|
||||
<text class="info-label">抗凝方式</text>
|
||||
<text class="info-value">{{ prescription.anticoagulation_type }}</text>
|
||||
</view>
|
||||
<view v-if="prescription.anticoagulation_dose" class="info-row">
|
||||
<text class="info-label">抗凝剂量</text>
|
||||
<text class="info-value">{{ prescription.anticoagulation_dose }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 目标参数 -->
|
||||
<view class="section-card">
|
||||
<text class="section-title">目标参数</text>
|
||||
<view class="vitals-grid">
|
||||
<view v-if="prescription.target_dry_weight != null" class="vital-item">
|
||||
<text class="vital-value">{{ prescription.target_dry_weight }}</text>
|
||||
<text class="vital-label">干体重 kg</text>
|
||||
</view>
|
||||
<view v-if="prescription.target_ultrafiltration_ml != null" class="vital-item">
|
||||
<text class="vital-value">{{ prescription.target_ultrafiltration_ml }}</text>
|
||||
<text class="vital-label">超滤目标 ml</text>
|
||||
</view>
|
||||
<view v-if="prescription.membrane_area != null" class="vital-item">
|
||||
<text class="vital-value">{{ prescription.membrane_area }}</text>
|
||||
<text class="vital-label">膜面积 m2</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 血管通路 -->
|
||||
<view v-if="prescription.vascular_access_type" class="section-card">
|
||||
<text class="section-title">血管通路</text>
|
||||
<view class="info-row">
|
||||
<text class="info-label">通路类型</text>
|
||||
<text class="info-value">{{ prescription.vascular_access_type }}</text>
|
||||
</view>
|
||||
<view v-if="prescription.vascular_access_location" class="info-row">
|
||||
<text class="info-label">通路位置</text>
|
||||
<text class="info-value">{{ prescription.vascular_access_location }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 备注 -->
|
||||
<view v-if="prescription.notes" class="section-card">
|
||||
<text class="section-title">备注</text>
|
||||
<text class="notes-text">{{ prescription.notes }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 操作 -->
|
||||
<view v-if="prescription.status === 'active'" class="action-card">
|
||||
<view
|
||||
:class="['action-btn--outline', deactivating ? 'disabled' : '']"
|
||||
@tap="deactivating ? undefined : handleDeactivate"
|
||||
>
|
||||
<text class="action-btn--outline__text">{{ deactivating ? '处理中...' : '停用处方' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { getDialysisPrescriptionById, updateDialysisPrescription } from '@/services/doctor/dialysis'
|
||||
import type { DialysisPrescription } from '@/services/dialysis'
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const prescription = ref<DialysisPrescription | null>(null)
|
||||
const pageLoading = ref(true)
|
||||
const error = ref(false)
|
||||
const deactivating = ref(false)
|
||||
let prescriptionId = ''
|
||||
|
||||
async function loadData() {
|
||||
if (!prescriptionId) return
|
||||
pageLoading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const data = await getDialysisPrescriptionById(prescriptionId)
|
||||
prescription.value = data
|
||||
} catch {
|
||||
error.value = true
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeactivate() {
|
||||
if (!prescription.value) return
|
||||
deactivating.value = true
|
||||
try {
|
||||
const updated = await updateDialysisPrescription(
|
||||
prescriptionId,
|
||||
{ status: 'inactive' },
|
||||
prescription.value.version,
|
||||
)
|
||||
prescription.value = updated
|
||||
uni.showToast({ title: '已停用', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
} finally {
|
||||
deactivating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
prescriptionId = query?.id || ''
|
||||
if (!prescriptionId) { error.value = true; pageLoading.value = false; return }
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { min-height: 100vh; background: $bg; }
|
||||
.page-content { padding: 24px 0 120px; }
|
||||
|
||||
.info-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: var(--tk-font-title);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.status-tag__text {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
|
||||
.info-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.vitals-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vital-item {
|
||||
background: $pri-l;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vital-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.notes-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.action-btn--outline {
|
||||
@include btn-outline;
|
||||
|
||||
&.disabled { opacity: 0.5; }
|
||||
|
||||
&__text {
|
||||
color: $pri;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,336 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['page-scroll', elderClass]">
|
||||
<!-- 搜索 -->
|
||||
<view class="search-bar">
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="搜索患者姓名"
|
||||
:value="searchKeyword"
|
||||
@input="(e: any) => { searchKeyword = e.detail.value; debouncedSearch() }"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- Tab 筛选 -->
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="t in TABS"
|
||||
:key="t.key"
|
||||
:class="['tab', activeTab === t.key ? 'tab--active' : '']"
|
||||
@tap="handleTabChange(t.key)"
|
||||
>
|
||||
<text>{{ t.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载态 -->
|
||||
<Loading v-if="loading && prescriptions.length === 0" text="加载中..." />
|
||||
|
||||
<!-- 空态 -->
|
||||
<EmptyState v-else-if="prescriptions.length === 0" icon="📋" title="暂无透析处方" />
|
||||
|
||||
<!-- 处方列表 -->
|
||||
<view v-else class="prescription-list">
|
||||
<view
|
||||
v-for="p in prescriptions"
|
||||
:key="p.id"
|
||||
class="prescription-card"
|
||||
@tap="goDetail(p.id)"
|
||||
>
|
||||
<view class="prescription-card__top">
|
||||
<text class="prescription-card__patient">{{ patientNameMap[p.patient_id] || '未知患者' }}</text>
|
||||
<view class="prescription-card__status" :style="getStatusInlineStyle(p.status)">
|
||||
<text class="prescription-card__status-text">{{ getStatusLabel(p.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="prescription-card__meta">
|
||||
<text class="prescription-card__type">
|
||||
{{ dialysisTypeLabel(p.dialyzer_model) }}
|
||||
</text>
|
||||
<text v-if="p.frequency_per_week" class="prescription-card__freq">
|
||||
{{ p.frequency_per_week }}次/周
|
||||
</text>
|
||||
</view>
|
||||
<text class="prescription-card__date">{{ p.created_at?.substring(0, 10) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 分页 -->
|
||||
<view v-if="total > pageSize" class="pagination">
|
||||
<text
|
||||
:class="['pagination__btn', page <= 1 ? 'disabled' : '']"
|
||||
@tap="page > 1 && (page = page - 1)"
|
||||
>上一页</text>
|
||||
<text class="pagination__info">{{ page }} / {{ totalPages }}</text>
|
||||
<text
|
||||
:class="['pagination__btn', page >= totalPages ? 'disabled' : '']"
|
||||
@tap="page < totalPages && (page = page + 1)"
|
||||
>下一页</text>
|
||||
</view>
|
||||
|
||||
<!-- 创建按钮 -->
|
||||
<view class="fab" @tap="goCreate">
|
||||
<text class="fab__icon">+</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { listDialysisPrescriptions } from '@/services/doctor/dialysis'
|
||||
import type { DialysisPrescription } from '@/services/dialysis'
|
||||
import { listPatients } from '@/services/doctor/patient'
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'active', label: '生效中' },
|
||||
{ key: 'inactive', label: '已停用' },
|
||||
{ key: 'expired', label: '已过期' },
|
||||
] as const
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const pageSize = 20
|
||||
|
||||
const prescriptions = ref<DialysisPrescription[]>([])
|
||||
const patientNameMap = ref<Record<string, string>>({})
|
||||
const activeTab = ref('')
|
||||
const searchKeyword = ref('')
|
||||
const loading = ref(true)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize))
|
||||
|
||||
function handleTabChange(key: string) {
|
||||
activeTab.value = key
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
function dialysisTypeLabel(model?: string): string {
|
||||
if (!model) return '透析处方'
|
||||
return model
|
||||
}
|
||||
|
||||
function goDetail(id: string) {
|
||||
uni.navigateTo({ url: `/pages-sub/doctor/prescription/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({ url: '/pages-sub/doctor/prescription/create/index' })
|
||||
}
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function debouncedSearch() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadPrescriptions()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function loadPrescriptions() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
page: page.value,
|
||||
page_size: pageSize,
|
||||
}
|
||||
if (activeTab.value) params.status = activeTab.value
|
||||
const res = await listDialysisPrescriptions(params)
|
||||
prescriptions.value = res.data || []
|
||||
total.value = res.total || 0
|
||||
// Resolve patient names
|
||||
const ids = [...new Set(prescriptions.value.map((p) => p.patient_id))]
|
||||
await resolvePatientNames(ids)
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePatientNames(ids: string[]) {
|
||||
const missing = ids.filter((id) => !patientNameMap.value[id])
|
||||
if (missing.length === 0) return
|
||||
try {
|
||||
// Load patients in batches to resolve names
|
||||
for (const id of missing) {
|
||||
try {
|
||||
const res = await listPatients({ page_size: 1 })
|
||||
// If we have data, try to find the patient
|
||||
const patient = res.data?.find((p) => p.id === id)
|
||||
if (patient) {
|
||||
patientNameMap.value = { ...patientNameMap.value, [id]: patient.name }
|
||||
}
|
||||
} catch { /* skip individual failures */ }
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
watch([page, activeTab], () => { loadPrescriptions() })
|
||||
|
||||
onShow(() => {
|
||||
loadPrescriptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { min-height: 100vh; background: $bg; }
|
||||
|
||||
.search-bar {
|
||||
padding: 16px 24px;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
height: 48px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-pill;
|
||||
padding: 0 24px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
background: $bg;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: $card;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid $bd;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx2;
|
||||
position: relative;
|
||||
|
||||
&--active {
|
||||
color: $pri;
|
||||
font-weight: 600;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 30%;
|
||||
right: 30%;
|
||||
height: 4px;
|
||||
background: $pri;
|
||||
border-radius: $r-xs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prescription-list {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.prescription-card {
|
||||
@include card;
|
||||
|
||||
&:active { background: $bd-l; }
|
||||
|
||||
&__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__patient {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: inline-block;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__status-text {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__type {
|
||||
@include tag($pri-l, $pri);
|
||||
}
|
||||
|
||||
&__freq {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
|
||||
&__btn {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $pri;
|
||||
padding: 12px 24px;
|
||||
|
||||
&.disabled { color: $tx3; }
|
||||
}
|
||||
|
||||
&__info {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
bottom: 100px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: $pri;
|
||||
border-radius: 50%;
|
||||
@include flex-center;
|
||||
box-shadow: $shadow-lg;
|
||||
|
||||
&:active { opacity: 0.85; }
|
||||
}
|
||||
|
||||
.fab__icon {
|
||||
color: $card;
|
||||
font-size: var(--tk-font-hero);
|
||||
font-weight: 300;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,349 @@
|
||||
<template>
|
||||
<view :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<!-- Loading -->
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
|
||||
<!-- Error -->
|
||||
<view v-else-if="!report" class="empty-wrap">
|
||||
<text class="empty-text">报告不存在</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<!-- Report info card -->
|
||||
<view class="info-card">
|
||||
<view class="info-header">
|
||||
<text class="report-type">{{ report.report_type }}</text>
|
||||
<text class="status-tag" :style="getStatusInlineStyle(report.status)">
|
||||
{{ getStatusLabel(report.status) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="info-row">
|
||||
<text class="info-label">报告日期</text>
|
||||
<text class="info-value">{{ report.report_date }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="report.abnormal_count != null" class="info-row">
|
||||
<text class="info-label">异常指标</text>
|
||||
<text :class="['info-value', report.abnormal_count > 0 ? 'abnormal' : 'normal']">
|
||||
{{ report.abnormal_count }} 项
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Indicators list -->
|
||||
<view class="indicators-card">
|
||||
<text class="section-title">检查指标</text>
|
||||
|
||||
<view v-if="indicators.length === 0" class="empty-indicators">
|
||||
<text class="empty-text">暂无指标数据</text>
|
||||
</view>
|
||||
|
||||
<view v-for="(item, idx) in indicators" :key="idx" class="indicator-item">
|
||||
<view class="indicator-left">
|
||||
<text class="indicator-name">{{ item.name }}</text>
|
||||
<text class="indicator-value">{{ item.value }}{{ item.unit ? ` ${item.unit}` : '' }}</text>
|
||||
</view>
|
||||
<view class="indicator-right">
|
||||
<text v-if="item.reference_min != null && item.reference_max != null" class="indicator-ref">
|
||||
{{ item.reference_min }}~{{ item.reference_max }}
|
||||
</text>
|
||||
<text :class="['indicator-status', getIndicatorStatusClass(item)]">
|
||||
{{ getIndicatorStatusLabel(item) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Doctor interpretation -->
|
||||
<view v-if="report.doctor_notes" class="notes-card">
|
||||
<text class="section-title">医生解读</text>
|
||||
<text class="notes-text">{{ report.doctor_notes }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Review section (for doctor) -->
|
||||
<view v-if="canReview" class="review-card">
|
||||
<text class="section-title">审核报告</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">审核意见</text>
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
placeholder="请输入审核意见(选填)"
|
||||
:value="reviewNotes"
|
||||
@input="(e: any) => reviewNotes = e.detail.value"
|
||||
:maxlength="500"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view
|
||||
:class="['action-btn', reviewing ? 'disabled' : '']"
|
||||
@tap="reviewing ? undefined : handleReview"
|
||||
>
|
||||
<text class="action-btn-text">{{ reviewing ? '提交中...' : '确认审核' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import * as doctorApi from '@/services/doctor/labReport'
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
interface IndicatorDisplay {
|
||||
name: string
|
||||
value: number
|
||||
unit?: string
|
||||
reference_min?: number
|
||||
reference_max?: number
|
||||
is_abnormal?: boolean
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const report = ref<doctorApi.LabReportDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
const reviewing = ref(false)
|
||||
const reviewNotes = ref('')
|
||||
let patientId = ''
|
||||
let reportId = ''
|
||||
|
||||
const indicators = computed<IndicatorDisplay[]>(() => {
|
||||
if (!report.value?.items) return []
|
||||
return report.value.items
|
||||
})
|
||||
|
||||
const canReview = computed(() => {
|
||||
if (!report.value) return false
|
||||
return report.value.status !== 'reviewed' && report.value.status !== 'verified'
|
||||
})
|
||||
|
||||
function getIndicatorStatusClass(item: IndicatorDisplay): string {
|
||||
if (item.is_abnormal) {
|
||||
if (item.reference_min != null && item.value < item.reference_min) return 'low'
|
||||
return 'high'
|
||||
}
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
function getIndicatorStatusLabel(item: IndicatorDisplay): string {
|
||||
if (item.is_abnormal) {
|
||||
if (item.reference_min != null && item.value < item.reference_min) return '偏低'
|
||||
return '偏高'
|
||||
}
|
||||
return '正常'
|
||||
}
|
||||
|
||||
async function fetchDetail() {
|
||||
loading.value = true
|
||||
try {
|
||||
report.value = await doctorApi.getLabReport(patientId, reportId)
|
||||
reviewNotes.value = report.value?.doctor_notes || ''
|
||||
} catch {
|
||||
report.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReview() {
|
||||
if (!report.value) return
|
||||
reviewing.value = true
|
||||
try {
|
||||
const updated = await doctorApi.reviewLabReport(patientId, reportId, {
|
||||
doctor_notes: reviewNotes.value.trim() || undefined,
|
||||
version: report.value.version,
|
||||
})
|
||||
report.value = updated
|
||||
uni.showToast({ title: '审核完成', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '审核失败', icon: 'none' })
|
||||
} finally {
|
||||
reviewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
patientId = query?.patientId || ''
|
||||
reportId = query?.reportId || ''
|
||||
if (!patientId || !reportId) { loading.value = false; return }
|
||||
fetchDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { min-height: 100vh; background: $bg; }
|
||||
.page-content { padding: 24px 0 120px; }
|
||||
|
||||
.empty-wrap { @include flex-center; padding: 120px 0; }
|
||||
.empty-text { font-size: var(--tk-font-body); color: $tx3; }
|
||||
|
||||
// Info card
|
||||
.info-card {
|
||||
@include card;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.report-type {
|
||||
font-size: var(--tk-font-h2);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
@include status-inline;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
|
||||
.info-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.info-value.abnormal { color: $dan; font-weight: 600; }
|
||||
.info-value.normal { color: $acc; }
|
||||
|
||||
// Indicators card
|
||||
.indicators-card {
|
||||
@include card;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty-indicators {
|
||||
@include flex-center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.indicator-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.indicator-item:last-child { border-bottom: none; }
|
||||
|
||||
.indicator-left { flex: 1; }
|
||||
|
||||
.indicator-name {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.indicator-value {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.indicator-right {
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.indicator-ref {
|
||||
display: block;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.indicator-status {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.indicator-status.high { color: $wrn; }
|
||||
.indicator-status.low { color: $info; }
|
||||
.indicator-status.normal { color: $acc; }
|
||||
|
||||
// Notes card
|
||||
.notes-card {
|
||||
@include card;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.notes-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
// Review card
|
||||
.review-card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.form-field { margin-bottom: 16px; }
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 12px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
box-sizing: border-box;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@include btn-primary;
|
||||
}
|
||||
|
||||
.action-btn.disabled { opacity: 0.5; }
|
||||
|
||||
.action-btn-text {
|
||||
color: $white;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
220
apps/miniprogram-uniapp/src/pages-sub/doctor/report/index.vue
Normal file
220
apps/miniprogram-uniapp/src/pages-sub/doctor/report/index.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<view :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<text class="page-title">化验报告</text>
|
||||
|
||||
<!-- Search bar -->
|
||||
<view class="search-bar">
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="搜索患者姓名"
|
||||
:value="searchText"
|
||||
@input="(e: any) => searchText = e.detail.value"
|
||||
@confirm="handleSearch"
|
||||
confirm-type="search"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- Loading -->
|
||||
<Loading v-if="loading && reports.length === 0" text="加载中..." />
|
||||
|
||||
<!-- Empty -->
|
||||
<EmptyState v-else-if="reports.length === 0 && !loading" icon="📋" title="暂无化验报告" />
|
||||
|
||||
<!-- Report list -->
|
||||
<scroll-view v-else scroll-y class="list-scroll" @scrolltolower="loadMore">
|
||||
<view
|
||||
v-for="item in reports" :key="item.id"
|
||||
class="report-card"
|
||||
@tap="goDetail(item.id)"
|
||||
>
|
||||
<view class="card-header">
|
||||
<text class="report-type">{{ item.report_type }}</text>
|
||||
<text class="status-tag" :style="getStatusInlineStyle(item.status)">
|
||||
{{ getStatusLabel(item.status) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="card-body">
|
||||
<text class="report-date">报告日期:{{ item.report_date }}</text>
|
||||
|
||||
<view v-if="item.abnormal_count != null && item.abnormal_count > 0" class="abnormal-badge">
|
||||
<text class="abnormal-text">{{ item.abnormal_count }} 项异常</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-if="!loading && reports.length >= total && total > 0" class="no-more">
|
||||
<text class="no-more-text">没有更多了</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import * as doctorApi from '@/services/doctor/labReport'
|
||||
import { listPatients } from '@/services/doctor/patient'
|
||||
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const reports = ref<doctorApi.LabReportItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
let patientId = ''
|
||||
let currentPatientId = ''
|
||||
let loadingGuard = false
|
||||
|
||||
async function fetchReports(pageNum: number, isRefresh = false) {
|
||||
if (loadingGuard || !currentPatientId) return
|
||||
loadingGuard = true
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await doctorApi.listLabReports(currentPatientId, {
|
||||
page: pageNum,
|
||||
page_size: 20,
|
||||
})
|
||||
const list = res.data || []
|
||||
reports.value = isRefresh ? list : [...reports.value, ...list]
|
||||
total.value = res.total
|
||||
page.value = pageNum
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loadingGuard = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
const keyword = searchText.value.trim()
|
||||
if (!keyword) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listPatients({ search: keyword, page_size: 1 })
|
||||
const patients = res.data || []
|
||||
if (patients.length > 0) {
|
||||
currentPatientId = patients[0].id
|
||||
fetchReports(1, true)
|
||||
} else {
|
||||
uni.showToast({ title: '未找到该患者', icon: 'none' })
|
||||
loading.value = false
|
||||
}
|
||||
} catch {
|
||||
uni.showToast({ title: '搜索失败', icon: 'none' })
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (!loading.value && reports.value.length < total.value) {
|
||||
fetchReports(page.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function goDetail(reportId: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages-sub/doctor/report/detail/index?reportId=${reportId}&patientId=${currentPatientId}`,
|
||||
})
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
patientId = query?.patientId || ''
|
||||
if (patientId) {
|
||||
currentPatientId = patientId
|
||||
fetchReports(1, true)
|
||||
}
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
if (currentPatientId) {
|
||||
fetchReports(1, true).finally(() => uni.stopPullDownRefresh())
|
||||
} else {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { min-height: 100vh; background: $bg; }
|
||||
.page-content { padding: 28px 0 120px; }
|
||||
.page-title { @include section-title; margin-left: 24px; }
|
||||
|
||||
// Search bar
|
||||
.search-bar {
|
||||
padding: 0 24px 16px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
height: 48px;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 0 16px;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
// List
|
||||
.list-scroll { height: calc(100vh - 180px); }
|
||||
|
||||
.report-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
margin: 0 24px 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.report-type {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
@include status-inline;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.report-date {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.abnormal-badge {
|
||||
padding: 2px 10px;
|
||||
border-radius: $r-pill;
|
||||
background: $dan-l;
|
||||
}
|
||||
|
||||
.abnormal-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $dan;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.no-more { @include flex-center; padding: 20px; }
|
||||
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
</style>
|
||||
115
apps/miniprogram-uniapp/src/pages-sub/events/index.vue
Normal file
115
apps/miniprogram-uniapp/src/pages-sub/events/index.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['events-page', elderClass]">
|
||||
<view class="events-header">
|
||||
<text class="events-header__title">线下活动</text>
|
||||
<text class="events-header__subtitle">参加活动赢取积分</text>
|
||||
</view>
|
||||
|
||||
<view v-if="events.length === 0 && !loading" class="empty-wrap">
|
||||
<EmptyState icon="" title="暂无可报名的活动" />
|
||||
</view>
|
||||
|
||||
<view v-else class="event-list">
|
||||
<view v-for="event in events" :key="event.id" class="event-card">
|
||||
<view class="event-card__header">
|
||||
<view :class="['event-card__status', (STATUS_MAP[event.status] || { className: '' }).className]">
|
||||
<text>{{ (STATUS_MAP[event.status] || { label: event.status }).label }}</text>
|
||||
</view>
|
||||
<text class="event-card__points">+{{ event.points_reward }} 积分</text>
|
||||
</view>
|
||||
<text class="event-card__title">{{ event.title }}</text>
|
||||
<text v-if="event.description" class="event-card__desc">{{ event.description }}</text>
|
||||
<view class="event-card__info">
|
||||
<text class="event-card__date">{{ formatDate(event.event_date) }}</text>
|
||||
<text v-if="event.location" class="event-card__location">{{ event.location }}</text>
|
||||
</view>
|
||||
<view class="event-card__footer">
|
||||
<text class="event-card__participants">
|
||||
{{ event.current_participants }}{{ event.max_participants ? `/${event.max_participants}` : '' }} 人已报名
|
||||
</text>
|
||||
<view :class="['event-card__btn', isFull(event) ? 'event-card__btn--disabled' : '']" @tap="handleRegister(event)">
|
||||
<text class="event-card__btn-text">
|
||||
{{ registering === event.id ? '报名中...' : isFull(event) ? '已满' : '立即报名' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onMounted } from 'vue'
|
||||
import { listOfflineEvents, registerEvent, type OfflineEvent } from '@/services/points'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||
published: { label: '报名中', className: 'event-card__status--published' },
|
||||
ongoing: { label: '进行中', className: 'event-card__status--ongoing' },
|
||||
completed: { label: '已结束', className: 'event-card__status--completed' },
|
||||
cancelled: { label: '已取消', className: 'event-card__status--cancelled' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const events = ref<OfflineEvent[]>([])
|
||||
const loading = ref(true)
|
||||
const registering = ref<string | null>(null)
|
||||
|
||||
const isFull = (event: OfflineEvent) => event.max_participants != null && event.current_participants >= event.max_participants
|
||||
const formatDate = (d: string) => new Date(d).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
|
||||
const loadEvents = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listOfflineEvents({ page: 1, page_size: 50, status: 'published' })
|
||||
events.value = res.data || []
|
||||
} catch { uni.showToast({ title: '加载失败', icon: 'none' }) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleRegister = async (event: OfflineEvent) => {
|
||||
if (isFull(event) || registering.value) return
|
||||
registering.value = event.id
|
||||
try {
|
||||
await registerEvent(event.id)
|
||||
uni.showToast({ title: '报名成功', icon: 'success' })
|
||||
loadEvents()
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || '报名失败'
|
||||
uni.showToast({ title: msg.substring(0, 20), icon: 'none' })
|
||||
} finally { registering.value = null }
|
||||
}
|
||||
|
||||
onMounted(loadEvents)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.events-page { min-height: 100vh; background: $bg; }
|
||||
.events-header { padding: 32px 24px 16px; }
|
||||
.events-header__title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; display: block; }
|
||||
.events-header__subtitle { font-size: var(--tk-font-caption); color: $tx3; margin-top: 4px; display: block; }
|
||||
.event-list { padding: 0 24px; }
|
||||
.event-card { @include card; margin-bottom: 16px; }
|
||||
.event-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.event-card__status { padding: 2px 10px; border-radius: 4px; }
|
||||
.event-card__status--published { background: rgba($pri, 0.1); }
|
||||
.event-card__status--ongoing { background: rgba(250,173,20,0.15); }
|
||||
.event-card__status--completed { background: rgba(0,0,0,0.05); }
|
||||
.event-card__status--cancelled { background: rgba(0,0,0,0.05); }
|
||||
.event-card__points { font-size: var(--tk-font-cap); color: $wrn; font-weight: 500; }
|
||||
.event-card__title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; display: block; }
|
||||
.event-card__desc { font-size: var(--tk-font-cap); color: $tx2; display: block; margin-top: 6px; line-height: 1.5; }
|
||||
.event-card__info { display: flex; gap: 16px; margin-top: 10px; }
|
||||
.event-card__date, .event-card__location { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.event-card__footer { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; padding-top: 12px; border-top: 1px solid rgba(0,0,0,0.05); }
|
||||
.event-card__participants { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.event-card__btn { padding: 6px 20px; min-height: $touch-min; display: flex; align-items: center; background: $pri; border-radius: $r; }
|
||||
.event-card__btn--disabled { background: rgba(0,0,0,0.1); }
|
||||
.event-card__btn-text { font-size: var(--tk-font-cap); color: $white; }
|
||||
.event-card__btn--disabled .event-card__btn-text { color: $tx3; }
|
||||
.empty-wrap { padding-top: 80px; }
|
||||
</style>
|
||||
109
apps/miniprogram-uniapp/src/pages-sub/followup/detail/index.vue
Normal file
109
apps/miniprogram-uniapp/src/pages-sub/followup/detail/index.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<view :class="['detail-page', elderClass]">
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<ErrorState v-else-if="error || !task" text="任务不存在" />
|
||||
<template v-else>
|
||||
<view class="detail-card">
|
||||
<text class="detail-title">{{ task.follow_up_type }}</text>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">状态</text>
|
||||
<text :class="['detail-value', getStatusClass(task.status)]">{{ getStatusLabel(task.status) }}</text>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">截止日期</text>
|
||||
<text class="detail-value">{{ task.planned_date }}</text>
|
||||
</view>
|
||||
<view v-if="countdown" :class="['countdown', countdown.urgent ? 'countdown-urgent' : '']">
|
||||
<text class="countdown-text">{{ countdown.text }}</text>
|
||||
</view>
|
||||
<view v-if="task.content_template" class="detail-desc">
|
||||
<text class="detail-desc-text">{{ task.content_template }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="task.status !== 'completed'" class="submit-card">
|
||||
<text class="section-title">填写随访记录</text>
|
||||
<textarea class="submit-textarea" placeholder="请输入随访内容..." :value="content" @input="(e: any) => content = e.detail.value" :maxlength="500" />
|
||||
<view :class="['submit-btn', submitting ? 'disabled' : '']" @tap="submitting ? undefined : handleSubmit">
|
||||
<text class="submit-btn-text">{{ submitting ? '提交中...' : '提交' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getTaskDetail, submitRecord, type FollowUpTask } from '@/services/followup'
|
||||
import { trackEvent } from '@/services/analytics'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const task = ref<FollowUpTask | null>(null)
|
||||
const content = ref('')
|
||||
const submitting = ref(false)
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
let id = ''
|
||||
|
||||
const getStatusLabel = (status: string) => status === 'completed' ? '已完成' : status === 'overdue' ? '已过期' : '待完成'
|
||||
const getStatusClass = (status: string) => status === 'completed' ? 'status-completed' : status === 'overdue' ? 'status-overdue' : 'status-pending'
|
||||
|
||||
const countdown = computed(() => {
|
||||
if (!task.value || task.value.status === 'completed') return null
|
||||
const now = new Date()
|
||||
const due = new Date(task.value.planned_date)
|
||||
const diffMs = due.getTime() - now.getTime()
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
|
||||
if (diffDays < 0) return { text: `已过期 ${Math.abs(diffDays)} 天`, urgent: true }
|
||||
if (diffDays === 0) return { text: '今天截止', urgent: true }
|
||||
if (diffDays <= 3) return { text: `还剩 ${diffDays} 天`, urgent: true }
|
||||
return { text: `还剩 ${diffDays} 天`, urgent: false }
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!content.value.trim()) { uni.showToast({ title: '请输入内容', icon: 'none' }); return }
|
||||
submitting.value = true
|
||||
try {
|
||||
await submitRecord(id, { result: content.value.trim(), patient_condition: content.value.trim() })
|
||||
uni.showToast({ title: '提交成功', icon: 'success' })
|
||||
trackEvent('followup_submit', { task_id: id })
|
||||
content.value = ''
|
||||
} catch { uni.showToast({ title: '提交失败', icon: 'none' }) }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
id = query?.id || ''
|
||||
if (!id) { error.value = true; loading.value = false; return }
|
||||
loading.value = true
|
||||
getTaskDetail(id).then(data => { task.value = data }).catch(() => { error.value = true }).finally(() => { loading.value = false })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page { min-height: 100vh; background: $bg; padding: 24px; }
|
||||
.detail-card { @include card; margin-bottom: 16px; }
|
||||
.detail-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; display: block; margin-bottom: 12px; }
|
||||
.detail-row { display: flex; justify-content: space-between; padding: 8px 0; }
|
||||
.detail-label { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.detail-value { font-size: var(--tk-font-body); color: $tx; }
|
||||
.status-completed { color: $acc; }
|
||||
.status-overdue { color: $dan; }
|
||||
.status-pending { color: $pri; }
|
||||
.countdown { margin-top: 8px; padding: 8px 12px; border-radius: $r; background: rgba(250,173,20,0.08); }
|
||||
.countdown-urgent { background: rgba(255,77,79,0.08); }
|
||||
.countdown-text { font-size: var(--tk-font-cap); color: $wrn; }
|
||||
.countdown-urgent .countdown-text { color: $dan; }
|
||||
.detail-desc { margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(0,0,0,0.05); }
|
||||
.detail-desc-text { font-size: var(--tk-font-cap); color: $tx2; line-height: 1.6; }
|
||||
.submit-card { @include card; }
|
||||
.section-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; margin-bottom: 12px; display: block; }
|
||||
.submit-textarea { width: 100%; min-height: 120px; border: 1px solid rgba(0,0,0,0.08); border-radius: $r; padding: 12px; font-size: var(--tk-font-body); box-sizing: border-box; }
|
||||
.submit-btn { margin-top: 16px; height: $touch-min; background: $pri; border-radius: $r; @include flex-center; }
|
||||
.submit-btn.disabled { opacity: 0.5; }
|
||||
.submit-btn-text { color: $white; font-size: var(--tk-font-body); font-weight: 500; }
|
||||
</style>
|
||||
129
apps/miniprogram-uniapp/src/pages-sub/mall/index.vue
Normal file
129
apps/miniprogram-uniapp/src/pages-sub/mall/index.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<!-- 积分概览 -->
|
||||
<view class="points-card card">
|
||||
<text class="points-label">我的积分</text>
|
||||
<text class="points-value">{{ points }}</text>
|
||||
<view class="checkin-btn" @tap="doCheckin">
|
||||
{{ checkinDone ? '已签到' : '每日签到' }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 商品列表 -->
|
||||
<text class="section-title">兑换商品</text>
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<EmptyState v-else-if="products.length === 0" icon="🎁" title="暂无可兑换商品" />
|
||||
<view v-else class="product-grid">
|
||||
<view v-for="item in products" :key="item.id" class="product-card">
|
||||
<text class="product-name">{{ item.name }}</text>
|
||||
<text class="product-points">{{ item.points }} 积分</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '@/services/request'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const points = ref(0)
|
||||
const products = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const checkinDone = ref(false)
|
||||
|
||||
async function fetchPoints() {
|
||||
try {
|
||||
const res = await api.get<any>('/health/points')
|
||||
if (res) points.value = res.points || 0
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function fetchProducts() {
|
||||
loading.value = true
|
||||
try { products.value = await api.get<any[]>('/health/points/products') || [] }
|
||||
catch { products.value = [] }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function doCheckin() {
|
||||
if (checkinDone.value) return
|
||||
try {
|
||||
await api.post('/health/points/checkin')
|
||||
checkinDone.value = true
|
||||
uni.showToast({ title: '签到成功', icon: 'success' })
|
||||
await fetchPoints()
|
||||
} catch {
|
||||
uni.showToast({ title: '签到失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { fetchPoints(); fetchProducts() })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { height: 100vh; background: $bg; }
|
||||
.page-content { padding: 28px 24px 120px; }
|
||||
|
||||
.card { @include card; }
|
||||
|
||||
.points-card {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.points-label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.points-value {
|
||||
font-size: var(--tk-font-display);
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
@include serif-number;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.checkin-btn {
|
||||
@include btn-primary;
|
||||
width: auto;
|
||||
padding: 0 48px;
|
||||
}
|
||||
|
||||
.section-title { @include section-title; }
|
||||
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px;
|
||||
box-shadow: $shadow-sm;
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.product-points {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $pri;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<view :class="['alerts-page', elderClass]">
|
||||
<template v-if="!authStore.currentPatient">
|
||||
<view class="alerts-empty">
|
||||
<text class="alerts-empty-text">请先完善个人档案</text>
|
||||
<view class="alerts-empty-action" @tap="goAddFamily">
|
||||
<text class="alerts-empty-action-text">去建档</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<template v-else>
|
||||
<view class="alerts-tabs">
|
||||
<view
|
||||
v-for="tab in STATUS_TABS" :key="tab.key"
|
||||
:class="['alerts-tab', status === tab.key ? 'active' : '']"
|
||||
@tap="handleTabChange(tab.key)"
|
||||
>
|
||||
<text :class="['alerts-tab-text', status === tab.key ? 'active' : '']">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="alerts.length === 0 && !loading" class="alerts-empty">
|
||||
<text class="alerts-empty-text">暂无告警记录</text>
|
||||
<text class="alerts-empty-hint">您的各项指标正常</text>
|
||||
</view>
|
||||
|
||||
<scroll-view v-else scroll-y class="alerts-scroll" @scrolltolower="loadMore">
|
||||
<view class="alert-card" v-for="item in alerts" :key="item.id">
|
||||
<view class="alert-header">
|
||||
<view :class="['alert-badge', (SEVERITY_MAP[item.severity] || SEVERITY_MAP.warning).className]">
|
||||
<text class="alert-badge-text">{{ (SEVERITY_MAP[item.severity] || SEVERITY_MAP.warning).label }}</text>
|
||||
</view>
|
||||
<text class="alert-time">{{ formatDate(item.created_at) }}</text>
|
||||
</view>
|
||||
<text class="alert-title">{{ item.title }}</text>
|
||||
</view>
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-if="!loading && alerts.length >= total && total > 0" class="no-more"><text class="no-more-text">没有更多了</text></view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { listPatientAlerts, type Alert } from '@/services/alert'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const SEVERITY_MAP: Record<string, { label: string; className: string }> = {
|
||||
info: { label: '提示', className: 'sev-info' },
|
||||
warning: { label: '警告', className: 'sev-warning' },
|
||||
critical: { label: '严重', className: 'sev-critical' },
|
||||
urgent: { label: '紧急', className: 'sev-urgent' },
|
||||
}
|
||||
const STATUS_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'pending', label: '待处理' },
|
||||
{ key: 'acknowledged', label: '已确认' },
|
||||
{ key: 'resolved', label: '已恢复' },
|
||||
]
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const alerts = ref<Alert[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const status = ref('')
|
||||
const loading = ref(false)
|
||||
let loadingGuard = false
|
||||
|
||||
const fetchAlerts = async (pageNum: number, s: string, isRefresh = false) => {
|
||||
if (!authStore.currentPatient || loadingGuard) return
|
||||
loadingGuard = true
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listPatientAlerts(authStore.currentPatient.id, { page: pageNum, page_size: 20, status: s || undefined })
|
||||
const list = res.data || []
|
||||
alerts.value = isRefresh ? list : [...alerts.value, ...list]
|
||||
total.value = res.total
|
||||
page.value = pageNum
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loadingGuard = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTabChange = (key: string) => { status.value = key; fetchAlerts(1, key, true) }
|
||||
const loadMore = () => { if (!loading.value && alerts.value.length < total.value) fetchAlerts(page.value + 1, status.value) }
|
||||
const goAddFamily = () => uni.navigateTo({ url: '/pages-sub/pkg-profile/family-add/index' })
|
||||
const formatDate = (d: string) => new Date(d).toLocaleDateString()
|
||||
|
||||
onShow(() => { fetchAlerts(1, status.value, true) })
|
||||
onPullDownRefresh(() => { fetchAlerts(1, status.value, true).finally(() => uni.stopPullDownRefresh()) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.alerts-page { min-height: 100vh; background: $bg; }
|
||||
.alerts-tabs { display: flex; padding: 12px 24px; gap: 8px; background: $card; }
|
||||
.alerts-tab { padding: 6px 16px; min-height: $touch-min; display: flex; align-items: center; border-radius: 20px; background: rgba(0,0,0,0.04); }
|
||||
.alerts-tab.active { background: $pri; }
|
||||
.alerts-tab-text { font-size: var(--tk-font-cap); color: $tx2; }
|
||||
.alerts-tab-text.active { color: $white; }
|
||||
.alerts-scroll { height: calc(100vh - 52px); }
|
||||
.alert-card { @include card; margin: 0 24px 12px; }
|
||||
.alert-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.alert-badge { padding: 2px 8px; border-radius: 4px; }
|
||||
.sev-info { background: rgba(0,0,0,0.05); }
|
||||
.sev-warning { background: rgba(250,173,20,0.15); }
|
||||
.sev-critical { background: rgba(255,77,79,0.15); }
|
||||
.sev-urgent { background: rgba(114,46,209,0.15); }
|
||||
.alert-badge-text { font-size: var(--tk-font-micro); color: $tx2; }
|
||||
.alert-time { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.alert-title { font-size: var(--tk-font-body); color: $tx; line-height: 1.5; }
|
||||
.alerts-empty { @include flex-center; flex-direction: column; padding: 80px 40px; }
|
||||
.alerts-empty-text { font-size: var(--tk-font-body); color: $tx2; }
|
||||
.alerts-empty-hint { font-size: var(--tk-font-cap); color: $tx3; margin-top: 8px; }
|
||||
.alerts-empty-action { margin-top: 20px; padding: 8px 24px; min-height: $touch-min; display: flex; align-items: center; background: $pri; border-radius: $r; }
|
||||
.alerts-empty-action-text { color: $white; font-size: var(--tk-font-body); }
|
||||
.no-more { @include flex-center; padding: 20px; }
|
||||
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
</style>
|
||||
@@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['dm-page', elderClass]">
|
||||
<view class="dm-hero">
|
||||
<view class="dm-hero-icon"><text class="dm-hero-icon-text">记</text></view>
|
||||
<text class="dm-hero-title">日常监测</text>
|
||||
<text class="dm-hero-sub">每日健康数据上报</text>
|
||||
</view>
|
||||
|
||||
<view class="dm-card">
|
||||
<view class="dm-card-header">
|
||||
<text class="dm-card-title">记录日期</text>
|
||||
<text v-if="isToday" class="dm-card-badge">今日</text>
|
||||
</view>
|
||||
<picker mode="selector" :range="dateList" :value="dateIdx" @change="(e: any) => dateIdx = Number(e.detail.value)">
|
||||
<view class="dm-date-row">
|
||||
<text class="dm-date-value">{{ recordDate }}</text>
|
||||
<text class="dm-date-arrow">V</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<!-- 晨间体征 -->
|
||||
<view :class="['dm-group', collapsed.morning ? 'dm-group-collapsed' : '']">
|
||||
<view class="dm-group-header" @tap="toggleSection('morning')">
|
||||
<text class="dm-group-title">晨间体征</text>
|
||||
<text :class="['dm-group-arrow', collapsed.morning ? '' : 'dm-group-arrow-open']">▸</text>
|
||||
</view>
|
||||
<view v-if="!collapsed.morning" class="dm-group-body">
|
||||
<view class="dm-bp-group">
|
||||
<view class="dm-bp-field">
|
||||
<text class="dm-field-label">收缩压</text>
|
||||
<input type="digit" :class="['dm-input-box', morningSysAbnormal.abnormal ? 'dm-input-abnormal' : '']" placeholder="如 120" :value="morningSystolic" @input="(e: any) => morningSystolic = e.detail.value" />
|
||||
<text v-if="morningSysAbnormal.abnormal" :class="['dm-field-warning', morningSysAbnormal.direction === 'low' ? 'dm-field-warning-low' : '']">
|
||||
{{ morningSysAbnormal.direction === 'high' ? '偏高' : '偏低' }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="dm-bp-divider">
|
||||
<view class="dm-bp-line" /><text class="dm-bp-slash">/</text><view class="dm-bp-line" />
|
||||
</view>
|
||||
<view class="dm-bp-field">
|
||||
<text class="dm-field-label">舒张压</text>
|
||||
<input type="digit" :class="['dm-input-box', morningDiaAbnormal.abnormal ? 'dm-input-abnormal' : '']" placeholder="如 80" :value="morningDiastolic" @input="(e: any) => morningDiastolic = e.detail.value" />
|
||||
<text v-if="morningDiaAbnormal.abnormal" :class="['dm-field-warning', morningDiaAbnormal.direction === 'low' ? 'dm-field-warning-low' : '']">
|
||||
{{ morningDiaAbnormal.direction === 'high' ? '偏高' : '偏低' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="dm-field-unit">mmHg</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 晚间体征 -->
|
||||
<view :class="['dm-group', collapsed.evening ? 'dm-group-collapsed' : '']">
|
||||
<view class="dm-group-header" @tap="toggleSection('evening')">
|
||||
<text class="dm-group-title">晚间体征</text>
|
||||
<text :class="['dm-group-arrow', collapsed.evening ? '' : 'dm-group-arrow-open']">▸</text>
|
||||
</view>
|
||||
<view v-if="!collapsed.evening" class="dm-group-body">
|
||||
<view class="dm-bp-group">
|
||||
<view class="dm-bp-field">
|
||||
<text class="dm-field-label">收缩压</text>
|
||||
<input type="digit" :class="['dm-input-box', eveningSysAbnormal.abnormal ? 'dm-input-abnormal' : '']" placeholder="如 120" :value="eveningSystolic" @input="(e: any) => eveningSystolic = e.detail.value" />
|
||||
<text v-if="eveningSysAbnormal.abnormal" :class="['dm-field-warning', eveningSysAbnormal.direction === 'low' ? 'dm-field-warning-low' : '']">
|
||||
{{ eveningSysAbnormal.direction === 'high' ? '偏高' : '偏低' }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="dm-bp-divider">
|
||||
<view class="dm-bp-line" /><text class="dm-bp-slash">/</text><view class="dm-bp-line" />
|
||||
</view>
|
||||
<view class="dm-bp-field">
|
||||
<text class="dm-field-label">舒张压</text>
|
||||
<input type="digit" :class="['dm-input-box', eveningDiaAbnormal.abnormal ? 'dm-input-abnormal' : '']" placeholder="如 80" :value="eveningDiastolic" @input="(e: any) => eveningDiastolic = e.detail.value" />
|
||||
<text v-if="eveningDiaAbnormal.abnormal" :class="['dm-field-warning', eveningDiaAbnormal.direction === 'low' ? 'dm-field-warning-low' : '']">
|
||||
{{ eveningDiaAbnormal.direction === 'high' ? '偏高' : '偏低' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="dm-field-unit">mmHg</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 其他指标 -->
|
||||
<view :class="['dm-group', collapsed.other ? 'dm-group-collapsed' : '']">
|
||||
<view class="dm-group-header" @tap="toggleSection('other')">
|
||||
<text class="dm-group-title">其他指标</text>
|
||||
<text :class="['dm-group-arrow', collapsed.other ? '' : 'dm-group-arrow-open']">▸</text>
|
||||
</view>
|
||||
<view v-if="!collapsed.other" class="dm-group-body">
|
||||
<view class="dm-inner-field">
|
||||
<text class="dm-field-label">体重</text>
|
||||
<view class="dm-single-row">
|
||||
<input type="digit" class="dm-input-box dm-input-flex" placeholder="如 65.0" :value="weight" @input="(e: any) => weight = e.detail.value" />
|
||||
<text class="dm-unit-inline">kg</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="dm-inner-field">
|
||||
<text class="dm-field-label">血糖</text>
|
||||
<view class="dm-single-row">
|
||||
<input type="digit" :class="['dm-input-box', 'dm-input-flex', bloodSugarAbnormal.abnormal ? 'dm-input-abnormal' : '']" placeholder="如 5.6" :value="bloodSugar" @input="(e: any) => bloodSugar = e.detail.value" />
|
||||
<text class="dm-unit-inline">mmol/L</text>
|
||||
</view>
|
||||
<text v-if="bloodSugarAbnormal.abnormal" :class="['dm-field-warning', bloodSugarAbnormal.direction === 'low' ? 'dm-field-warning-low' : '']">
|
||||
{{ bloodSugarAbnormal.direction === 'high' ? '偏高' : '偏低' }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="dm-inner-field">
|
||||
<text class="dm-field-label">饮水量</text>
|
||||
<view class="dm-single-row">
|
||||
<input type="digit" class="dm-input-box dm-input-flex" placeholder="如 2000" :value="fluidIntake" @input="(e: any) => fluidIntake = e.detail.value" />
|
||||
<text class="dm-unit-inline">ml</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="dm-inner-field">
|
||||
<text class="dm-field-label">尿量</text>
|
||||
<view class="dm-single-row">
|
||||
<input type="digit" class="dm-input-box dm-input-flex" placeholder="如 1500" :value="urineOutput" @input="(e: any) => urineOutput = e.detail.value" />
|
||||
<text class="dm-unit-inline">ml</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="dm-inner-field">
|
||||
<text class="dm-field-label">备注</text>
|
||||
<input class="dm-input-box dm-input-full" placeholder="如:头晕、乏力等(可选)" :value="notes" @input="(e: any) => notes = e.detail.value" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view :class="['dm-submit', submitting ? 'dm-submit-disabled' : '']" @tap="submitting ? undefined : handleSubmit">
|
||||
<text class="dm-submit-text">{{ submitting ? '提交中...' : '提交上报' }}</text>
|
||||
</view>
|
||||
<view class="dm-reset" @tap="resetForm"><text class="dm-reset-text">清空表单</text></view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { validateNum } from '@/utils/validate'
|
||||
import { createDailyMonitoring } from '@/services/health'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useHealthStore } from '@/stores/health'
|
||||
import { usePointsStore } from '@/stores/points'
|
||||
import { clearRequestCache } from '@/services/request'
|
||||
import { trackEvent } from '@/services/analytics'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const BP_RANGE = { min: 30, minMsg: '血压值不能低于30', max: 300, maxMsg: '血压值不能高于300', optional: true }
|
||||
const WEIGHT_RANGE = { min: 1, minMsg: '体重不能低于1kg', max: 500, maxMsg: '体重不能高于500kg', optional: true }
|
||||
const SUGAR_RANGE = { min: 0.1, minMsg: '血糖值不能低于0.1', max: 50, maxMsg: '血糖值不能高于50', optional: true }
|
||||
const VOLUME_RANGE = { min: 0, minMsg: '数值不能为负', max: 10000, maxMsg: '数值超出合理范围', optional: true }
|
||||
|
||||
const REFERENCE_RANGES: Record<string, { min: number; max: number } | null> = {
|
||||
systolic: { min: 90, max: 140 }, diastolic: { min: 60, max: 90 },
|
||||
bloodSugar: { min: 3.9, max: 6.1 }, weight: null, fluidIntake: null, urineOutput: null,
|
||||
}
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
morningSystolic: '晨间收缩压', morningDiastolic: '晨间舒张压',
|
||||
eveningSystolic: '晚间收缩压', eveningDiastolic: '晚间舒张压', bloodSugar: '血糖',
|
||||
}
|
||||
|
||||
const checkAbnormal = (value: string, field: string): { abnormal: boolean; direction: 'high' | 'low' | null } => {
|
||||
const ref = REFERENCE_RANGES[field]
|
||||
if (!value || !ref) return { abnormal: false, direction: null }
|
||||
const n = parseFloat(value)
|
||||
if (isNaN(n)) return { abnormal: false, direction: null }
|
||||
if (n > ref.max) return { abnormal: true, direction: 'high' }
|
||||
if (n < ref.min) return { abnormal: true, direction: 'low' }
|
||||
return { abnormal: false, direction: null }
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const healthStore = useHealthStore()
|
||||
const pointsStore = usePointsStore()
|
||||
|
||||
const today = formatDate(new Date())
|
||||
const dateList = ref(Array.from({ length: 30 }, (_, i) => { const d = new Date(); d.setDate(d.getDate() - i); return formatDate(d) }))
|
||||
const dateIdx = ref(0)
|
||||
const recordDate = computed(() => dateList.value[dateIdx.value])
|
||||
const isToday = computed(() => recordDate.value === today)
|
||||
|
||||
const morningSystolic = ref('')
|
||||
const morningDiastolic = ref('')
|
||||
const eveningSystolic = ref('')
|
||||
const eveningDiastolic = ref('')
|
||||
const weight = ref('')
|
||||
const bloodSugar = ref('')
|
||||
const fluidIntake = ref('')
|
||||
const urineOutput = ref('')
|
||||
const notes = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
type SectionKey = 'morning' | 'evening' | 'other'
|
||||
const collapsed = ref<Record<SectionKey, boolean>>({ morning: false, evening: false, other: true })
|
||||
const toggleSection = (key: SectionKey) => { collapsed.value = { ...collapsed.value, [key]: !collapsed.value[key] } }
|
||||
|
||||
const morningSysAbnormal = computed(() => checkAbnormal(morningSystolic.value, 'systolic'))
|
||||
const morningDiaAbnormal = computed(() => checkAbnormal(morningDiastolic.value, 'diastolic'))
|
||||
const eveningSysAbnormal = computed(() => checkAbnormal(eveningSystolic.value, 'systolic'))
|
||||
const eveningDiaAbnormal = computed(() => checkAbnormal(eveningDiastolic.value, 'diastolic'))
|
||||
const bloodSugarAbnormal = computed(() => checkAbnormal(bloodSugar.value, 'bloodSugar'))
|
||||
|
||||
const resetForm = () => {
|
||||
morningSystolic.value = ''; morningDiastolic.value = ''
|
||||
eveningSystolic.value = ''; eveningDiastolic.value = ''
|
||||
weight.value = ''; bloodSugar.value = ''; fluidIntake.value = ''; urineOutput.value = ''; notes.value = ''
|
||||
}
|
||||
|
||||
const gatherAbnormalFields = (): string[] => {
|
||||
const checks: [string, string][] = [
|
||||
['morningSystolic', morningSystolic.value], ['morningDiastolic', morningDiastolic.value],
|
||||
['eveningSystolic', eveningSystolic.value], ['eveningDiastolic', eveningDiastolic.value],
|
||||
['bloodSugar', bloodSugar.value],
|
||||
]
|
||||
return checks.filter(([field, value]) => checkAbnormal(value, field).abnormal).map(([field]) => FIELD_LABELS[field])
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!authStore.currentPatient) { uni.showToast({ title: '请先选择就诊人', icon: 'none' }); return }
|
||||
const hasData = morningSystolic.value || morningDiastolic.value || eveningSystolic.value || eveningDiastolic.value || weight.value || bloodSugar.value || fluidIntake.value || urineOutput.value
|
||||
if (!hasData) { uni.showToast({ title: '请至少填写一项数据', icon: 'none' }); return }
|
||||
if ((morningSystolic.value && !morningDiastolic.value) || (!morningSystolic.value && morningDiastolic.value)) { uni.showToast({ title: '晨起血压请同时填写收缩压和舒张压', icon: 'none' }); return }
|
||||
if ((eveningSystolic.value && !eveningDiastolic.value) || (!eveningSystolic.value && eveningDiastolic.value)) { uni.showToast({ title: '晚间血压请同时填写收缩压和舒张压', icon: 'none' }); return }
|
||||
|
||||
const parseNum = (v: string) => v ? parseFloat(v) : undefined
|
||||
const fields = {
|
||||
morningSystolic: parseNum(morningSystolic.value), morningDiastolic: parseNum(morningDiastolic.value),
|
||||
eveningSystolic: parseNum(eveningSystolic.value), eveningDiastolic: parseNum(eveningDiastolic.value),
|
||||
weight: parseNum(weight.value), bloodSugar: parseNum(bloodSugar.value),
|
||||
fluidIntake: parseNum(fluidIntake.value), urineOutput: parseNum(urineOutput.value),
|
||||
}
|
||||
const validations: [number | undefined, string, typeof BP_RANGE][] = [
|
||||
[fields.morningSystolic, '晨起收缩压', BP_RANGE], [fields.morningDiastolic, '晨起舒张压', BP_RANGE],
|
||||
[fields.eveningSystolic, '晚间收缩压', BP_RANGE], [fields.eveningDiastolic, '晚间舒张压', BP_RANGE],
|
||||
[fields.weight, '体重', WEIGHT_RANGE], [fields.bloodSugar, '血糖', SUGAR_RANGE],
|
||||
[fields.fluidIntake, '饮水量', VOLUME_RANGE], [fields.urineOutput, '尿量', VOLUME_RANGE],
|
||||
]
|
||||
for (const [value, label, range] of validations) {
|
||||
const err = validateNum(value, label, range)
|
||||
if (err) { uni.showToast({ title: err, icon: 'none' }); return }
|
||||
}
|
||||
|
||||
const abnormalFields = gatherAbnormalFields()
|
||||
if (abnormalFields.length > 0) {
|
||||
const confirmed = await uni.showModal({ title: '数值异常提醒', content: `以下指标超出正常范围:${abnormalFields.join('、')}。确认提交?`, confirmText: '确认提交', cancelText: '返回修改' })
|
||||
if (!confirmed.confirm) return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await createDailyMonitoring({
|
||||
patient_id: authStore.currentPatient.id, record_date: recordDate.value,
|
||||
morning_bp_systolic: fields.morningSystolic, morning_bp_diastolic: fields.morningDiastolic,
|
||||
evening_bp_systolic: fields.eveningSystolic, evening_bp_diastolic: fields.eveningDiastolic,
|
||||
weight: fields.weight, blood_sugar: fields.bloodSugar,
|
||||
fluid_intake: fields.fluidIntake, urine_output: fields.urineOutput,
|
||||
notes: notes.value || undefined,
|
||||
})
|
||||
trackEvent('daily_monitoring_submit', { date: recordDate.value })
|
||||
healthStore.clearCache(); clearRequestCache('/health/'); pointsStore.invalidate()
|
||||
uni.showToast({ title: '上报成功', icon: 'success' })
|
||||
setTimeout(() => uni.showToast({ title: '+10 健康积分', icon: 'none', duration: 1500 }), 1600)
|
||||
setTimeout(() => uni.navigateBack(), 3200)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '上报失败'
|
||||
if (msg.includes('已有记录') || msg.includes('already exists')) {
|
||||
uni.showModal({ title: '提示', content: '该日期已有监测记录,请选择其他日期', showCancel: false })
|
||||
} else {
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => { uni.setNavigationBarTitle({ title: '日常监测上报' }) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dm-page { min-height: 100vh; background: $bg; padding: 0 24px 160px; }
|
||||
.dm-hero { @include flex-center; flex-direction: column; padding: 40px 0 24px; }
|
||||
.dm-hero-icon { width: 56px; height: 56px; border-radius: 50%; background: $pri; @include flex-center; margin-bottom: 12px; }
|
||||
.dm-hero-icon-text { color: $white; font-size: var(--tk-font-body); font-weight: 600; }
|
||||
.dm-hero-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; }
|
||||
.dm-hero-sub { font-size: var(--tk-font-caption); color: $tx3; margin-top: 4px; }
|
||||
.dm-card { @include card; margin-bottom: 16px; }
|
||||
.dm-card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.dm-card-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; }
|
||||
.dm-card-badge { font-size: var(--tk-font-micro); color: $pri; background: rgba($pri, 0.1); padding: 2px 8px; border-radius: 4px; }
|
||||
.dm-date-row { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-top: 1px solid rgba(0,0,0,0.05); margin-top: 8px; }
|
||||
.dm-date-value { font-size: var(--tk-font-body); color: $tx; }
|
||||
.dm-date-arrow { color: $tx3; font-size: var(--tk-font-micro); }
|
||||
.dm-group { @include card; margin-bottom: 12px; }
|
||||
.dm-group-header { display: flex; justify-content: space-between; align-items: center; min-height: $touch-min; }
|
||||
.dm-group-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; }
|
||||
.dm-group-arrow { font-size: var(--tk-font-cap); color: $tx3; transition: transform 0.2s; }
|
||||
.dm-group-arrow-open { transform: rotate(90deg); }
|
||||
.dm-group-body { padding-top: 12px; border-top: 1px solid rgba(0,0,0,0.05); margin-top: 10px; }
|
||||
.dm-bp-group { display: flex; align-items: flex-end; gap: 8px; }
|
||||
.dm-bp-field { flex: 1; }
|
||||
.dm-bp-divider { display: flex; flex-direction: column; align-items: center; gap: 4px; padding-bottom: 10px; }
|
||||
.dm-bp-line { width: 1px; height: 12px; background: rgba(0,0,0,0.1); }
|
||||
.dm-bp-slash { color: $tx3; font-size: var(--tk-font-cap); }
|
||||
.dm-field-label { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-bottom: 6px; }
|
||||
.dm-input-box { height: 44px; border: 1px solid rgba(0,0,0,0.08); border-radius: $r; padding: 0 12px; font-size: var(--tk-font-body); width: 100%; box-sizing: border-box; }
|
||||
.dm-input-abnormal { border-color: $wrn; }
|
||||
.dm-input-flex { flex: 1; }
|
||||
.dm-input-full { width: 100%; }
|
||||
.dm-field-unit { font-size: var(--tk-font-cap); color: $tx3; margin-top: 6px; display: block; }
|
||||
.dm-field-warning { font-size: var(--tk-font-micro); color: $wrn; margin-top: 4px; display: block; }
|
||||
.dm-field-warning-low { color: $info; }
|
||||
.dm-inner-field { margin-bottom: 16px; }
|
||||
.dm-single-row { display: flex; align-items: center; gap: 8px; }
|
||||
.dm-unit-inline { font-size: var(--tk-font-cap); color: $tx3; white-space: nowrap; }
|
||||
.dm-submit { margin-top: 24px; height: 48px; background: $pri; border-radius: $r; @include flex-center; }
|
||||
.dm-submit-disabled { opacity: 0.5; }
|
||||
.dm-submit-text { color: $white; font-size: var(--tk-font-body); font-weight: 500; }
|
||||
.dm-reset { margin-top: 12px; @include flex-center; min-height: $touch-min; }
|
||||
.dm-reset-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
</style>
|
||||
242
apps/miniprogram-uniapp/src/pages-sub/pkg-health/input/index.vue
Normal file
242
apps/miniprogram-uniapp/src/pages-sub/pkg-health/input/index.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<view :class="['input-page', elderClass]">
|
||||
<view class="input-hero">
|
||||
<view class="input-hero-icon">
|
||||
<text class="input-hero-icon-text">录</text>
|
||||
</view>
|
||||
<text class="input-hero-title">体征录入</text>
|
||||
<text class="input-hero-sub">记录今日健康数据</text>
|
||||
</view>
|
||||
|
||||
<view class="input-sync-entry" @tap="goDeviceSync">
|
||||
<text class="input-sync-entry-text">从设备同步</text>
|
||||
<text class="input-sync-entry-hint">蓝牙连接设备自动获取数据</text>
|
||||
</view>
|
||||
|
||||
<view class="input-card">
|
||||
<view class="input-card-header">
|
||||
<view class="input-card-indicator">
|
||||
<text class="input-card-indicator-char">{{ indicatorInitial }}</text>
|
||||
</view>
|
||||
<text class="input-card-label">指标类型</text>
|
||||
</view>
|
||||
<picker mode="selector" :range="indicatorLabels" :value="indicatorIdx" @change="onIndicatorChange">
|
||||
<view class="input-picker-row">
|
||||
<text class="input-picker-value">{{ INDICATORS[indicatorIdx].label }}</text>
|
||||
<text class="input-picker-arrow">V</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view v-if="isBpIndicator" class="input-card">
|
||||
<text class="input-section-title">血压数值</text>
|
||||
<view class="input-bp-group">
|
||||
<view class="input-bp-field">
|
||||
<text class="input-field-label">收缩压</text>
|
||||
<input type="digit" class="input-field-box" placeholder="如 120" :value="systolic" @input="(e: any) => systolic = e.detail.value" />
|
||||
</view>
|
||||
<view class="input-bp-divider">
|
||||
<view class="input-bp-line" />
|
||||
<text class="input-bp-slash">/</text>
|
||||
<view class="input-bp-line" />
|
||||
</view>
|
||||
<view class="input-bp-field">
|
||||
<text class="input-field-label">舒张压</text>
|
||||
<input type="digit" class="input-field-box" placeholder="如 80" :value="diastolic" @input="(e: any) => diastolic = e.detail.value" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="input-field-unit">mmHg</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="input-card">
|
||||
<text class="input-section-title">检测数值</text>
|
||||
<input type="digit" class="input-field-box input-field-full" placeholder="请输入数值" :value="val" @input="(e: any) => val = e.detail.value" />
|
||||
<text class="input-field-unit">{{ unitLabel }}</text>
|
||||
</view>
|
||||
|
||||
<view class="input-card">
|
||||
<text class="input-section-title">备注</text>
|
||||
<input class="input-field-box input-field-full" placeholder="如:饭后2小时(可选)" :value="note" @input="(e: any) => note = e.detail.value" />
|
||||
</view>
|
||||
|
||||
<view :class="['input-submit', submitting ? 'input-submit-disabled' : '']" @tap="submitting ? undefined : handleSubmit">
|
||||
<text class="input-submit-text">{{ submitting ? '提交中...' : '提交录入' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { num, validateStr } from '@/utils/validate'
|
||||
import { inputVitalSign, getHealthThresholds, findThreshold, DEFAULT_THRESHOLDS, type HealthThreshold } from '@/services/health'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useHealthStore } from '@/stores/health'
|
||||
import { usePointsStore } from '@/stores/points'
|
||||
import { clearRequestCache } from '@/services/request'
|
||||
import { trackEvent } from '@/services/analytics'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const INDICATORS = [
|
||||
{ value: 'blood_pressure', label: '晨间血压 (mmHg)' },
|
||||
{ value: 'blood_pressure_evening', label: '晚间血压 (mmHg)' },
|
||||
{ value: 'heart_rate', label: '心率 (bpm)' },
|
||||
{ value: 'blood_sugar_fasting', label: '空腹血糖 (mmol/L)' },
|
||||
{ value: 'blood_sugar_postprandial', label: '餐后血糖 (mmol/L)' },
|
||||
{ value: 'weight', label: '体重 (kg)' },
|
||||
{ value: 'temperature', label: '体温 (℃)' },
|
||||
]
|
||||
const BP_INDICATORS = ['blood_pressure', 'blood_pressure_evening']
|
||||
|
||||
const valueCheck = num({ posMsg: '请输入有效数值' })
|
||||
const systolicCheck = num({ min: 60, minMsg: '收缩压过低', max: 250, maxMsg: '收缩压过高,请及时就医', optional: true })
|
||||
const diastolicCheck = num({ min: 40, minMsg: '舒张压过低', max: 150, maxMsg: '舒张压过高,请及时就医', optional: true })
|
||||
|
||||
function getWarnForIndicator(thresholds: HealthThreshold[], indicator: string) {
|
||||
const isBp = BP_INDICATORS.includes(indicator)
|
||||
const high = findThreshold(thresholds, isBp ? 'systolic_bp' : indicator, 'high')
|
||||
const low = findThreshold(thresholds, isBp ? 'systolic_bp' : indicator, 'low')
|
||||
if (!high && !low) return null
|
||||
const warningMap: Record<string, string> = {
|
||||
blood_pressure: '收缩压偏高,建议及时就医',
|
||||
blood_pressure_evening: '收缩压偏高,建议及时就医',
|
||||
heart_rate: '心率异常,请注意休息',
|
||||
blood_sugar_fasting: '血糖偏高,建议就医检查',
|
||||
blood_sugar_postprandial: '血糖偏高,建议就医检查',
|
||||
}
|
||||
return { max: high?.threshold_value, min: low?.threshold_value, warning: warningMap[indicator] ?? '数值异常,请关注' }
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const healthStore = useHealthStore()
|
||||
const pointsStore = usePointsStore()
|
||||
|
||||
const indicatorIdx = ref(0)
|
||||
const thresholds = ref<HealthThreshold[]>(DEFAULT_THRESHOLDS)
|
||||
const val = ref('')
|
||||
const systolic = ref('')
|
||||
const diastolic = ref('')
|
||||
const note = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
const indicatorLabels = INDICATORS.map(i => i.label)
|
||||
const isBpIndicator = computed(() => BP_INDICATORS.includes(INDICATORS[indicatorIdx.value].value))
|
||||
const indicatorInitial = computed(() => INDICATORS[indicatorIdx.value].label.charAt(0))
|
||||
const unitLabel = computed(() => INDICATORS[indicatorIdx.value].label.match(/\((.+)\)/)?.[1] || '')
|
||||
|
||||
const onIndicatorChange = (e: any) => { indicatorIdx.value = Number(e.detail.value) }
|
||||
|
||||
const goDeviceSync = () => {
|
||||
uni.navigateTo({ url: '/pages-sub/device-sync/index?returnTo=input' })
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
getHealthThresholds().then(t => { if (t.length > 0) thresholds.value = t })
|
||||
try {
|
||||
const raw = uni.getStorageSync('device_sync_result')
|
||||
if (!raw) return
|
||||
uni.removeStorageSync('device_sync_result')
|
||||
const syncData: Record<string, number> = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||
if (syncData.systolic != null && syncData.diastolic != null) {
|
||||
indicatorIdx.value = 0
|
||||
systolic.value = String(syncData.systolic)
|
||||
diastolic.value = String(syncData.diastolic)
|
||||
} else if (syncData.blood_sugar != null) {
|
||||
indicatorIdx.value = 3
|
||||
val.value = String(syncData.blood_sugar)
|
||||
} else if (syncData.heart_rate != null) {
|
||||
indicatorIdx.value = 2
|
||||
val.value = String(syncData.heart_rate)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const patient = authStore.currentPatient
|
||||
if (!patient) { uni.showToast({ title: '请先选择就诊人', icon: 'none' }); return }
|
||||
|
||||
const currentIndicator = INDICATORS[indicatorIdx.value].value
|
||||
if (BP_INDICATORS.includes(currentIndicator)) {
|
||||
if (!systolic.value || !diastolic.value) { uni.showToast({ title: '请填写收缩压和舒张压', icon: 'none' }); return }
|
||||
} else {
|
||||
if (!val.value) { uni.showToast({ title: '请输入数值', icon: 'none' }); return }
|
||||
}
|
||||
|
||||
const input = BP_INDICATORS.includes(currentIndicator)
|
||||
? { indicator_type: currentIndicator as 'blood_pressure' | 'blood_pressure_evening', value: parseFloat(systolic.value), extra: { systolic: parseFloat(systolic.value), diastolic: parseFloat(diastolic.value) } }
|
||||
: { indicator_type: currentIndicator as any, value: parseFloat(val.value) }
|
||||
|
||||
const valueResult = valueCheck.safeParse(input.value)
|
||||
if (!valueResult.ok) { uni.showToast({ title: valueResult.message, icon: 'none' }); return }
|
||||
if (input.extra?.systolic !== undefined) {
|
||||
const r = systolicCheck.safeParse(input.extra.systolic)
|
||||
if (!r.ok) { uni.showToast({ title: r.message, icon: 'none' }); return }
|
||||
}
|
||||
if (input.extra?.diastolic !== undefined) {
|
||||
const r = diastolicCheck.safeParse(input.extra.diastolic)
|
||||
if (!r.ok) { uni.showToast({ title: r.message, icon: 'none' }); return }
|
||||
}
|
||||
if (note.value) {
|
||||
const err = validateStr(note.value, 200, '备注')
|
||||
if (err) { uni.showToast({ title: err, icon: 'none' }); return }
|
||||
}
|
||||
|
||||
const threshold = getWarnForIndicator(thresholds.value, currentIndicator)
|
||||
if (threshold) {
|
||||
const v = input.value
|
||||
if ((threshold.max && v > threshold.max) || (threshold.min && v < threshold.min)) {
|
||||
await uni.showModal({ title: '健康提示', content: threshold.warning, showCancel: false })
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await inputVitalSign(patient.id, { ...input, note: note.value || undefined })
|
||||
healthStore.clearCache()
|
||||
clearRequestCache('/health/')
|
||||
pointsStore.invalidate()
|
||||
uni.showToast({ title: '录入成功', icon: 'success' })
|
||||
trackEvent('health_data_input', { type: currentIndicator })
|
||||
setTimeout(() => uni.navigateBack(), 1000)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '录入失败'
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.input-page { min-height: 100vh; background: $bg; padding: 0 24px 120px; }
|
||||
.input-hero { @include flex-center; flex-direction: column; padding: 40px 0 24px; }
|
||||
.input-hero-icon { width: 56px; height: 56px; border-radius: 50%; background: $pri; @include flex-center; margin-bottom: 12px; }
|
||||
.input-hero-icon-text { color: $white; font-size: var(--tk-font-body); font-weight: 600; }
|
||||
.input-hero-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; }
|
||||
.input-hero-sub { font-size: var(--tk-font-caption); color: $tx3; margin-top: 4px; }
|
||||
.input-sync-entry { @include card; display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.input-sync-entry-text { font-size: var(--tk-font-body); color: $pri; font-weight: 500; }
|
||||
.input-sync-entry-hint { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.input-card { @include card; margin-bottom: 16px; }
|
||||
.input-card-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.input-card-indicator { width: 32px; height: 32px; border-radius: 8px; background: rgba($pri, 0.1); @include flex-center; }
|
||||
.input-card-indicator-char { color: $pri; font-size: var(--tk-font-cap); font-weight: 600; }
|
||||
.input-card-label { font-size: var(--tk-font-body); color: $tx; font-weight: 500; }
|
||||
.input-picker-row { display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-top: 1px solid rgba(0,0,0,0.05); }
|
||||
.input-picker-value { font-size: var(--tk-font-body); color: $tx; }
|
||||
.input-picker-arrow { color: $tx3; font-size: var(--tk-font-micro); }
|
||||
.input-section-title { font-size: var(--tk-font-cap); color: $tx2; margin-bottom: 10px; display: block; }
|
||||
.input-bp-group { display: flex; align-items: flex-end; gap: 8px; }
|
||||
.input-bp-field { flex: 1; }
|
||||
.input-field-label { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-bottom: 6px; }
|
||||
.input-field-box { height: 44px; border: 1px solid rgba(0,0,0,0.08); border-radius: $r; padding: 0 12px; font-size: var(--tk-font-body); width: 100%; box-sizing: border-box; }
|
||||
.input-field-full { width: 100%; }
|
||||
.input-bp-divider { display: flex; flex-direction: column; align-items: center; gap: 4px; padding-bottom: 10px; }
|
||||
.input-bp-line { width: 1px; height: 12px; background: rgba(0,0,0,0.1); }
|
||||
.input-bp-slash { color: $tx3; font-size: var(--tk-font-cap); }
|
||||
.input-field-unit { font-size: var(--tk-font-cap); color: $tx3; margin-top: 6px; display: block; }
|
||||
.input-submit { margin-top: 24px; height: 48px; background: $pri; border-radius: $r; @include flex-center; }
|
||||
.input-submit-disabled { opacity: 0.5; }
|
||||
.input-submit-text { color: $white; font-size: var(--tk-font-body); font-weight: 500; }
|
||||
</style>
|
||||
167
apps/miniprogram-uniapp/src/pages-sub/pkg-health/trend/index.vue
Normal file
167
apps/miniprogram-uniapp/src/pages-sub/pkg-health/trend/index.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['page-scroll', elderClass]">
|
||||
<view class="page-content">
|
||||
<text class="page-title">健康趋势</text>
|
||||
|
||||
<!-- 指标切换 -->
|
||||
<view class="tab-group">
|
||||
<view v-for="tab in tabs" :key="tab.key"
|
||||
:class="['tab-item', { active: activeTab === tab.key }]"
|
||||
@tap="switchTab(tab.key)"
|
||||
>
|
||||
{{ tab.name }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 趋势图(纯 CSS 柱状图) -->
|
||||
<view class="chart-card card">
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<EmptyState v-else-if="trendData.length === 0" icon="📊" title="暂无趋势数据" />
|
||||
<view v-else class="bar-chart">
|
||||
<view v-for="(item, idx) in trendData" :key="idx" class="bar-group">
|
||||
<view class="bar" :style="{ height: getBarHeight(item.value) + '%' }" />
|
||||
<text class="bar-label">{{ item.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据列表 -->
|
||||
<view v-if="trendData.length > 0" class="data-list card">
|
||||
<view v-for="(item, idx) in trendData" :key="idx" class="data-row">
|
||||
<text class="data-date">{{ item.label }}</text>
|
||||
<text class="data-value">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getTrend } from '@/services/health'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const tabs = [
|
||||
{ key: 'heart_rate', name: '心率' },
|
||||
{ key: 'blood_pressure', name: '血压' },
|
||||
{ key: 'blood_sugar', name: '血糖' },
|
||||
{ key: 'weight', name: '体重' },
|
||||
]
|
||||
|
||||
const activeTab = ref('heart_rate')
|
||||
const loading = ref(false)
|
||||
const trendData = ref<{ label: string; value: number }[]>([])
|
||||
|
||||
function switchTab(key: string) {
|
||||
activeTab.value = key
|
||||
fetchTrend()
|
||||
}
|
||||
|
||||
function getBarHeight(value: number): number {
|
||||
const max = Math.max(...trendData.value.map(d => d.value), 1)
|
||||
return (value / max) * 80
|
||||
}
|
||||
|
||||
async function fetchTrend() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getTrend(activeTab.value, '7d')
|
||||
trendData.value = (res?.data_points || []).map((item) => ({
|
||||
label: item.date || '',
|
||||
value: Number(item.value) || 0,
|
||||
}))
|
||||
} catch {
|
||||
trendData.value = []
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onLoad(() => { fetchTrend() })
|
||||
onMounted(fetchTrend)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll { height: 100vh; background: $bg; }
|
||||
.page-content { padding: 28px 24px 120px; }
|
||||
.page-title { @include section-title; }
|
||||
.card { @include card; }
|
||||
|
||||
.tab-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
padding: 12px 24px;
|
||||
min-height: $touch-min;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: $r-pill;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
background: $card;
|
||||
white-space: nowrap;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&.active {
|
||||
background: $pri;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
.bar-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-around;
|
||||
height: 240px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.bar-group {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 32px;
|
||||
background: $pri;
|
||||
border-radius: 8px 8px 0 0;
|
||||
min-height: 4px;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.data-list { margin-top: 16px; }
|
||||
|
||||
.data-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.data-date {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.data-value {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
@include serif-number;
|
||||
}
|
||||
</style>
|
||||
142
apps/miniprogram-uniapp/src/pages-sub/pkg-mall/detail/index.vue
Normal file
142
apps/miniprogram-uniapp/src/pages-sub/pkg-mall/detail/index.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<view :class="['detail-page', elderClass]">
|
||||
<view class="balance-card">
|
||||
<text class="balance-label">当前积分</text>
|
||||
<text class="balance-value">{{ balance.toLocaleString() }}</text>
|
||||
<view class="balance-stats">
|
||||
<view class="stat-item">
|
||||
<text class="stat-value stat-earn">{{ (pointsStore.account?.total_earned ?? 0).toLocaleString() }}</text>
|
||||
<text class="stat-label">累计获得</text>
|
||||
</view>
|
||||
<view class="stat-divider" />
|
||||
<view class="stat-item">
|
||||
<text class="stat-value stat-spend">{{ (pointsStore.account?.total_spent ?? 0).toLocaleString() }}</text>
|
||||
<text class="stat-label">累计消费</text>
|
||||
</view>
|
||||
<view class="stat-divider" />
|
||||
<view class="stat-item">
|
||||
<text class="stat-value stat-expired">{{ (pointsStore.account?.total_expired ?? 0).toLocaleString() }}</text>
|
||||
<text class="stat-label">已过期</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="type-tabs">
|
||||
<view v-for="tab in TYPE_TABS" :key="tab.key"
|
||||
:class="['type-tab', activeTab === tab.key ? 'active' : '']"
|
||||
@tap="handleTabChange(tab.key)">
|
||||
<text class="type-tab-text">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="transactions.length === 0 && !loading" class="empty-wrap">
|
||||
<EmptyState icon="" title="暂无积分记录" hint="签到或兑换后将显示记录" />
|
||||
</view>
|
||||
|
||||
<scroll-view v-else scroll-y class="tx-scroll" @scrolltolower="loadMore">
|
||||
<view class="transaction-item" v-for="tx in transactions" :key="tx.id">
|
||||
<view :class="['tx-badge', `tx-badge-${getTypeClass(tx.type)}`]">
|
||||
<text class="tx-badge-text">{{ getTypeLabel(tx.type) }}</text>
|
||||
</view>
|
||||
<view class="tx-info">
|
||||
<text class="tx-desc">{{ tx.description || (tx.type === 'earn' ? '积分收入' : tx.type === 'spend' ? '积分消费' : '积分过期') }}</text>
|
||||
<text class="tx-date">{{ formatDate(tx.created_at) }}</text>
|
||||
</view>
|
||||
<view class="tx-amount-col">
|
||||
<text :class="['tx-amount', `tx-amount-${tx.type === 'earn' ? 'positive' : 'negative'}`]">{{ formatAmount(tx) }}</text>
|
||||
<text class="tx-remaining">余额 {{ tx.balance_after.toLocaleString() }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-if="!loading && transactions.length >= total && total > 0" class="no-more"><text class="no-more-text">没有更多了</text></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { listMyTransactions, type PointsTransaction } from '@/services/points'
|
||||
import { usePointsStore } from '@/stores/points'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const TYPE_TABS = [{ key: '', label: '全部' }, { key: 'earn', label: '收入' }, { key: 'spend', label: '支出' }]
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const pointsStore = usePointsStore()
|
||||
const transactions = ref<PointsTransaction[]>([])
|
||||
const activeTab = ref('')
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
let loadingGuard = false
|
||||
|
||||
const balance = computed(() => pointsStore.account?.balance ?? 0)
|
||||
const getTypeLabel = (type: string) => type === 'earn' ? '收' : type === 'spend' ? '支' : '过'
|
||||
const getTypeClass = (type: string) => type === 'earn' ? 'earn' : type === 'spend' ? 'spend' : 'expired'
|
||||
const formatAmount = (tx: PointsTransaction) => tx.type === 'earn' ? `+${tx.amount.toLocaleString()}` : `-${tx.amount.toLocaleString()}`
|
||||
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')}`
|
||||
}
|
||||
|
||||
const fetchTransactions = async (pageNum: number, type: string, isRefresh = false) => {
|
||||
if (loadingGuard) return
|
||||
loadingGuard = true; loading.value = true
|
||||
try {
|
||||
const res = await listMyTransactions({ page: pageNum, page_size: 10 })
|
||||
let list = res.data || []
|
||||
if (type) list = list.filter(t => t.type === type)
|
||||
transactions.value = isRefresh ? list : [...transactions.value, ...list]
|
||||
total.value = res.total; page.value = pageNum
|
||||
} catch { uni.showToast({ title: '加载失败', icon: 'none' }) }
|
||||
finally { loadingGuard = false; loading.value = false }
|
||||
}
|
||||
|
||||
const handleTabChange = (key: string) => { activeTab.value = key; fetchTransactions(1, key, true) }
|
||||
const loadMore = () => { if (!loading.value && transactions.value.length < total.value) fetchTransactions(page.value + 1, activeTab.value) }
|
||||
|
||||
onShow(() => { uni.setNavigationBarTitle({ title: '积分明细' }); Promise.all([pointsStore.refresh(), fetchTransactions(1, activeTab.value, true)]) })
|
||||
onPullDownRefresh(() => { Promise.all([pointsStore.refresh(), fetchTransactions(1, activeTab.value, true)]).finally(() => uni.stopPullDownRefresh()) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page { min-height: 100vh; background: $bg; }
|
||||
.balance-card { background: linear-gradient(135deg, $pri, darken($pri, 10%)); padding: 24px; margin: 0; }
|
||||
.balance-label { font-size: var(--tk-font-cap); color: rgba(255,255,255,0.8); display: block; }
|
||||
.balance-value { font-size: var(--tk-font-num); font-weight: 700; color: $white; display: block; margin: 8px 0 20px; }
|
||||
.balance-stats { display: flex; align-items: center; }
|
||||
.stat-item { flex: 1; text-align: center; }
|
||||
.stat-value { font-size: var(--tk-font-body); font-weight: 500; display: block; }
|
||||
.stat-earn { color: rgba(255,255,255,0.95); }
|
||||
.stat-spend { color: rgba(255,255,255,0.95); }
|
||||
.stat-expired { color: rgba(255,255,255,0.95); }
|
||||
.stat-label { font-size: var(--tk-font-cap); color: rgba(255,255,255,0.6); display: block; margin-top: 4px; }
|
||||
.stat-divider { width: 1px; height: 24px; background: rgba(255,255,255,0.2); }
|
||||
.type-tabs { display: flex; padding: 12px 24px; gap: 8px; background: $card; }
|
||||
.type-tab { padding: 6px 16px; min-height: $touch-min; display: flex; align-items: center; border-radius: 20px; background: rgba(0,0,0,0.04); }
|
||||
.type-tab.active { background: $pri; }
|
||||
.type-tab-text { font-size: var(--tk-font-cap); color: $tx2; }
|
||||
.type-tab.active .type-tab-text { color: $white; }
|
||||
.tx-scroll { height: calc(100vh - 200px); padding: 16px 24px; }
|
||||
.transaction-item { @include card; display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
||||
.tx-badge { width: 36px; height: 36px; border-radius: 50%; @include flex-center; flex-shrink: 0; }
|
||||
.tx-badge-earn { background: rgba(82,196,26,0.1); }
|
||||
.tx-badge-spend { background: rgba(250,84,28,0.1); }
|
||||
.tx-badge-expired { background: rgba(0,0,0,0.05); }
|
||||
.tx-badge-text { font-size: var(--tk-font-micro); font-weight: 500; }
|
||||
.tx-info { flex: 1; min-width: 0; }
|
||||
.tx-desc { font-size: var(--tk-font-body); color: $tx; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tx-date { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-top: 2px; }
|
||||
.tx-amount-col { text-align: right; flex-shrink: 0; }
|
||||
.tx-amount { font-size: var(--tk-font-body); font-weight: 500; display: block; }
|
||||
.tx-amount-positive { color: $acc; }
|
||||
.tx-amount-negative { color: $wrn; }
|
||||
.tx-remaining { font-size: var(--tk-font-micro); color: $tx3; display: block; margin-top: 2px; }
|
||||
.empty-wrap { padding-top: 60px; }
|
||||
.no-more { @include flex-center; padding: 20px; }
|
||||
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<view :class="['exchange-page', elderClass]">
|
||||
<template v-if="loading">
|
||||
<Loading text="加载中..." />
|
||||
</template>
|
||||
<template v-else-if="product">
|
||||
<view class="product-card">
|
||||
<view :class="['product-icon-wrap', iconCls]">
|
||||
<text class="product-icon-char">{{ initial }}</text>
|
||||
</view>
|
||||
<view class="product-meta">
|
||||
<text class="product-name">{{ product.name }}</text>
|
||||
<text class="product-type-tag">{{ typeLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="detail-section">
|
||||
<text class="detail-section-title">兑换明细</text>
|
||||
<view class="detail-card">
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">所需积分</text>
|
||||
<text class="detail-value detail-cost">{{ cost.toLocaleString() }}</text>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">当前余额</text>
|
||||
<text :class="['detail-value', insufficient ? 'detail-insufficient' : 'detail-sufficient']">{{ balance.toLocaleString() }}</text>
|
||||
</view>
|
||||
<view v-if="insufficient" class="detail-row">
|
||||
<text class="detail-label">差额</text>
|
||||
<text class="detail-value detail-insufficient">-{{ (cost - balance).toLocaleString() }}</text>
|
||||
</view>
|
||||
<view class="detail-row last">
|
||||
<text class="detail-label">库存</text>
|
||||
<text class="detail-value">{{ product.stock > 0 ? `剩余 ${product.stock} 件` : '已兑完' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="notice-section">
|
||||
<text class="notice-title">温馨提示</text>
|
||||
<text class="notice-text">兑换成功后将生成核销码,请凭核销码到前台核销领取。</text>
|
||||
<text class="notice-text">积分一经兑换不可退回。</text>
|
||||
</view>
|
||||
|
||||
<view class="exchange-footer">
|
||||
<view class="footer-cost">
|
||||
<text class="footer-cost-label">合计</text>
|
||||
<text class="footer-cost-num">{{ cost.toLocaleString() }}</text>
|
||||
<text class="footer-cost-unit">积分</text>
|
||||
</view>
|
||||
<view :class="['confirm-btn', insufficient || product.stock <= 0 || submitting ? 'disabled' : '']" @tap="handleConfirm">
|
||||
<text class="confirm-btn-text">
|
||||
{{ submitting ? '兑换中...' : insufficient ? '积分不足' : product.stock <= 0 ? '已兑完' : '确认兑换' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { listProducts, exchangeProduct, type PointsProduct } from '@/services/points'
|
||||
import { usePointsStore } from '@/stores/points'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const TYPE_INITIAL: 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' }
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const pointsStore = usePointsStore()
|
||||
const product = ref<PointsProduct | null>(null)
|
||||
const loading = ref(true)
|
||||
const submitting = ref(false)
|
||||
let productId = ''
|
||||
|
||||
const balance = computed(() => pointsStore.account?.balance ?? 0)
|
||||
const cost = computed(() => product.value?.points_cost ?? 0)
|
||||
const insufficient = computed(() => balance.value < cost.value)
|
||||
const productType = computed(() => product.value?.product_type || 'physical')
|
||||
const initial = computed(() => TYPE_INITIAL[productType.value] || '礼')
|
||||
const typeLabel = computed(() => TYPE_LABEL[productType.value] || '商品')
|
||||
const iconCls = computed(() => TYPE_CLASS[productType.value] || 'product-icon-wrap--service')
|
||||
|
||||
const loadData = async () => {
|
||||
const instance = getCurrentPages()
|
||||
const page = instance[instance.length - 1] as any
|
||||
productId = page?.$page?.options?.product_id || page?.options?.product_id || ''
|
||||
if (!productId) {
|
||||
uni.showToast({ title: '参数错误', icon: 'none' })
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const [productRes] = await Promise.all([listProducts({ page: 1, page_size: 100 }), pointsStore.refresh()])
|
||||
const found = productRes.data.find(p => p.id === productId)
|
||||
if (!found) { uni.showToast({ title: '商品不存在', icon: 'none' }); setTimeout(() => uni.navigateBack(), 1500); return }
|
||||
product.value = found
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!product.value || submitting.value || insufficient.value || product.value.stock <= 0) return
|
||||
const modalRes = await uni.showModal({ title: '确认兑换', content: `确定花费 ${cost.value} 积分兑换「${product.value.name}」吗?` })
|
||||
if (!modalRes.confirm) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const order = await exchangeProduct(product.value.id)
|
||||
uni.showToast({ title: '兑换成功', icon: 'success', duration: 2000 })
|
||||
setTimeout(() => {
|
||||
uni.showModal({ title: '兑换成功', content: `核销码: ${order.qr_code}\n请凭此码到前台核销`, showCancel: false, confirmText: '查看订单', success: () => uni.navigateTo({ url: '/pages-sub/pkg-mall/orders/index' }) })
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '兑换失败'
|
||||
if (msg.includes('余额不足') || msg.includes('insufficient')) uni.showToast({ title: '积分不足', icon: 'none' })
|
||||
else uni.showToast({ title: msg, icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => { uni.setNavigationBarTitle({ title: '确认兑换' }); loadData() })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.exchange-page { min-height: 100vh; background: $bg; padding: 24px; }
|
||||
.product-card { @include card; display: flex; align-items: center; gap: 16px; margin-bottom: 16px; }
|
||||
.product-icon-wrap { width: 48px; height: 48px; border-radius: 12px; @include flex-center; }
|
||||
.product-icon-wrap--physical { background: rgba(250,173,20,0.15); }
|
||||
.product-icon-wrap--service { background: rgba($pri, 0.1); }
|
||||
.product-icon-wrap--privilege { background: rgba(114,46,209,0.15); }
|
||||
.product-icon-char { font-size: var(--tk-font-cap); font-weight: 600; }
|
||||
.product-meta { flex: 1; }
|
||||
.product-name { font-size: var(--tk-font-body); font-weight: 500; color: $tx; display: block; }
|
||||
.product-type-tag { font-size: var(--tk-font-cap); color: $tx3; margin-top: 4px; display: block; }
|
||||
.detail-section { @include card; margin-bottom: 16px; }
|
||||
.detail-section-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; margin-bottom: 12px; display: block; }
|
||||
.detail-card { }
|
||||
.detail-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid rgba(0,0,0,0.04); }
|
||||
.detail-row.last { border-bottom: none; }
|
||||
.detail-label { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.detail-value { font-size: var(--tk-font-body); color: $tx; }
|
||||
.detail-cost { color: $wrn; font-weight: 500; }
|
||||
.detail-sufficient { color: $acc; }
|
||||
.detail-insufficient { color: $wrn; }
|
||||
.notice-section { @include card; margin-bottom: 16px; }
|
||||
.notice-title { font-size: var(--tk-font-cap); font-weight: 500; color: $tx2; margin-bottom: 8px; display: block; }
|
||||
.notice-text { font-size: var(--tk-font-cap); color: $tx3; line-height: 1.6; display: block; }
|
||||
.exchange-footer { position: fixed; bottom: 0; left: 0; right: 0; display: flex; align-items: center; padding: 12px 24px; background: $card; box-shadow: $shadow-sm; gap: 16px; }
|
||||
.footer-cost { flex: 1; display: flex; align-items: baseline; gap: 4px; }
|
||||
.footer-cost-label { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.footer-cost-num { font-size: var(--tk-font-body); font-weight: 600; color: $wrn; }
|
||||
.footer-cost-unit { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.confirm-btn { height: $touch-min; padding: 0 32px; background: $pri; border-radius: $r; @include flex-center; }
|
||||
.confirm-btn.disabled { opacity: 0.5; }
|
||||
.confirm-btn-text { color: $white; font-size: var(--tk-font-body); font-weight: 500; }
|
||||
</style>
|
||||
130
apps/miniprogram-uniapp/src/pages-sub/pkg-mall/orders/index.vue
Normal file
130
apps/miniprogram-uniapp/src/pages-sub/pkg-mall/orders/index.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<view :class="['orders-page', elderClass]">
|
||||
<view class="status-tabs">
|
||||
<view v-for="tab in STATUS_TABS" :key="tab.key"
|
||||
:class="['status-tab', activeTab === tab.key ? 'active' : '']"
|
||||
@tap="handleTabChange(tab.key)">
|
||||
<text class="status-tab-text">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="orders.length === 0 && !loading" class="empty-wrap">
|
||||
<EmptyState icon="" title="暂无订单" hint="去商城兑换心仪商品吧" />
|
||||
</view>
|
||||
|
||||
<scroll-view v-else scroll-y class="orders-scroll" @scrolltolower="loadMore">
|
||||
<view class="order-card" v-for="order in orders" :key="order.id">
|
||||
<view class="order-header">
|
||||
<text class="order-product">商品 {{ order.product_id.slice(0, 8) }}</text>
|
||||
<view :class="['order-status-tag', getStatusConfig(order.status).cls]">
|
||||
<text class="order-status-text">{{ getStatusConfig(order.status).label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="order-body">
|
||||
<view class="order-row">
|
||||
<text class="order-row-label">消耗积分</text>
|
||||
<text class="order-row-value order-cost">{{ order.points_cost.toLocaleString() }}</text>
|
||||
</view>
|
||||
<view class="order-row">
|
||||
<text class="order-row-label">兑换时间</text>
|
||||
<text class="order-row-value">{{ formatDate(order.created_at) }}</text>
|
||||
</view>
|
||||
<view v-if="order.status === 'pending'" class="order-qrcode" @tap="handleShowQrCode(order.qr_code)">
|
||||
<text class="qrcode-label">核销码</text>
|
||||
<text class="qrcode-value">{{ order.qr_code }}</text>
|
||||
<text class="qrcode-tap">查看</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-if="!loading && orders.length >= total && total > 0" class="no-more"><text class="no-more-text">没有更多了</text></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { listMyOrders, type PointsOrder } from '@/services/points'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: '', label: '全部' }, { key: 'pending', label: '待核销' },
|
||||
{ key: 'verified', label: '已核销' }, { key: 'expired', label: '已过期' },
|
||||
]
|
||||
const STATUS_CONFIG: Record<string, { label: string; cls: string }> = {
|
||||
pending: { label: '待核销', cls: 'order-status-tag--pending' },
|
||||
verified: { label: '已核销', cls: 'order-status-tag--verified' },
|
||||
cancelled: { label: '已取消', cls: 'order-status-tag--cancelled' },
|
||||
expired: { label: '已过期', cls: 'order-status-tag--expired' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const orders = ref<PointsOrder[]>([])
|
||||
const activeTab = ref('')
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
let loadingGuard = false
|
||||
|
||||
const getStatusConfig = (status: string) => STATUS_CONFIG[status] || { label: status, cls: 'order-status-tag--expired' }
|
||||
|
||||
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')}`
|
||||
}
|
||||
|
||||
const fetchOrders = async (pageNum: number, status: string, isRefresh = false) => {
|
||||
if (loadingGuard) return
|
||||
loadingGuard = true; loading.value = true
|
||||
try {
|
||||
const res = await listMyOrders({ page: pageNum, page_size: 10 })
|
||||
let list = res.data || []
|
||||
if (status) list = list.filter(o => o.status === status)
|
||||
orders.value = isRefresh ? list : [...orders.value, ...list]
|
||||
total.value = res.total; page.value = pageNum
|
||||
} catch { uni.showToast({ title: '加载失败', icon: 'none' }) }
|
||||
finally { loadingGuard = false; loading.value = false }
|
||||
}
|
||||
|
||||
const handleTabChange = (key: string) => { activeTab.value = key; fetchOrders(1, key, true) }
|
||||
const loadMore = () => { if (!loading.value && orders.value.length < total.value) fetchOrders(page.value + 1, activeTab.value) }
|
||||
const handleShowQrCode = (qrCode: string) => uni.showModal({ title: '核销码', content: qrCode, showCancel: false, confirmText: '知道了' })
|
||||
|
||||
onShow(() => { uni.setNavigationBarTitle({ title: '我的订单' }); fetchOrders(1, activeTab.value, true) })
|
||||
onPullDownRefresh(() => { fetchOrders(1, activeTab.value, true).finally(() => uni.stopPullDownRefresh()) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.orders-page { min-height: 100vh; background: $bg; }
|
||||
.status-tabs { display: flex; padding: 12px 24px; gap: 8px; background: $card; }
|
||||
.status-tab { padding: 6px 16px; min-height: $touch-min; display: flex; align-items: center; border-radius: 20px; background: rgba(0,0,0,0.04); }
|
||||
.status-tab.active { background: $pri; }
|
||||
.status-tab-text { font-size: var(--tk-font-cap); color: $tx2; }
|
||||
.status-tab.active .status-tab-text { color: $white; }
|
||||
.orders-scroll { height: calc(100vh - 52px); padding: 16px 24px; }
|
||||
.order-card { @include card; margin-bottom: 12px; }
|
||||
.order-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.order-product { font-size: var(--tk-font-body); color: $tx; font-weight: 500; }
|
||||
.order-status-tag { padding: 2px 10px; border-radius: 4px; }
|
||||
.order-status-tag--pending { background: rgba($pri, 0.1); }
|
||||
.order-status-tag--verified { background: rgba(82,196,26,0.1); }
|
||||
.order-status-tag--cancelled { background: rgba(0,0,0,0.05); }
|
||||
.order-status-tag--expired { background: rgba(0,0,0,0.05); }
|
||||
.order-status-text { font-size: var(--tk-font-micro); color: $tx2; }
|
||||
.order-body { }
|
||||
.order-row { display: flex; justify-content: space-between; padding: 6px 0; }
|
||||
.order-row-label { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.order-row-value { font-size: var(--tk-font-cap); color: $tx; }
|
||||
.order-cost { color: $wrn; font-weight: 500; }
|
||||
.order-qrcode { display: flex; align-items: center; gap: 8px; margin-top: 8px; padding: 8px 12px; background: rgba($pri, 0.05); border-radius: $r; }
|
||||
.qrcode-label { font-size: var(--tk-font-cap); color: $tx2; }
|
||||
.qrcode-value { flex: 1; font-size: var(--tk-font-cap); color: $pri; font-weight: 500; }
|
||||
.qrcode-tap { font-size: var(--tk-font-cap); color: $pri; }
|
||||
.empty-wrap { padding-top: 120px; }
|
||||
.no-more { @include flex-center; padding: 20px; }
|
||||
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
</style>
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<view :class="['consents-page', elderClass]">
|
||||
<text class="page-title">知情同意</text>
|
||||
|
||||
<view class="consent-list">
|
||||
<view v-for="c in consents" :key="c.id" class="consent-card">
|
||||
<view class="consent-card__header">
|
||||
<text class="consent-card__type">{{ CONSENT_TYPE_MAP[c.consent_type] || c.consent_type }}</text>
|
||||
<text :class="['status-tag', (STATUS_MAP[c.status] || { cls: '' }).cls]">
|
||||
{{ (STATUS_MAP[c.status] || { label: c.status }).label }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="consent-card__scope">范围: {{ c.consent_scope }}</text>
|
||||
<text v-if="c.granted_at" class="consent-card__date">签署时间: {{ c.granted_at }}</text>
|
||||
<text v-if="c.revoked_at" class="consent-card__date">撤回时间: {{ c.revoked_at }}</text>
|
||||
<text v-if="c.expiry_date" class="consent-card__expiry">有效期至: {{ c.expiry_date }}</text>
|
||||
<view
|
||||
v-if="c.status === 'granted'"
|
||||
:class="['revoke-btn', revoking === c.id ? 'revoke-btn--disabled' : '']"
|
||||
@tap="handleRevoke(c)"
|
||||
>
|
||||
<text class="revoke-btn__text">{{ revoking === c.id ? '处理中...' : '撤回同意' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<EmptyState
|
||||
v-if="consents.length === 0 && !loading"
|
||||
:text="authStore.currentPatient ? '暂无知情同意记录' : '请先在就诊人管理中选择就诊人'"
|
||||
/>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { listConsents, revokeConsent, type Consent } from '@/services/consent'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const CONSENT_TYPE_MAP: Record<string, string> = {
|
||||
data_processing: '数据处理同意',
|
||||
health_data_collection: '健康数据采集',
|
||||
research_use: '科研使用',
|
||||
third_party_share: '第三方共享',
|
||||
genetic_testing: '基因检测',
|
||||
telemedicine: '远程医疗',
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
granted: { label: '已签署', cls: 'granted' },
|
||||
revoked: { label: '已撤回', cls: 'revoked' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const consents = ref<Consent[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const revoking = ref<string | null>(null)
|
||||
|
||||
const fetchData = async (p: number, append = false) => {
|
||||
if (!authStore.currentPatient) {
|
||||
consents.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listConsents(authStore.currentPatient.id, { page: p, page_size: 20 })
|
||||
const list = res.data || []
|
||||
consents.value = append ? [...consents.value, ...list] : list
|
||||
total.value = res.total
|
||||
page.value = p
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!loading.value && consents.value.length < total.value) {
|
||||
fetchData(page.value + 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevoke = async (consent: Consent) => {
|
||||
const res = await uni.showModal({
|
||||
title: '确认撤回',
|
||||
content: `确定要撤回「${CONSENT_TYPE_MAP[consent.consent_type] || consent.consent_type}」的同意吗?`,
|
||||
})
|
||||
if (!res.confirm) return
|
||||
revoking.value = consent.id
|
||||
try {
|
||||
const updated = await revokeConsent(consent.id, consent.version)
|
||||
consents.value = consents.value.map((c) => c.id === updated.id ? updated : c)
|
||||
uni.showToast({ title: '已撤回', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '撤回失败', icon: 'none' })
|
||||
} finally {
|
||||
revoking.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => { fetchData(1) })
|
||||
onPullDownRefresh(() => { fetchData(1).finally(() => uni.stopPullDownRefresh()) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.consents-page { min-height: 100vh; background: $bg; padding: 32px 24px; padding-bottom: 40px; }
|
||||
.page-title { @include section-title; padding-left: 4px; }
|
||||
.consent-list { display: flex; flex-direction: column; gap: 16px; }
|
||||
.consent-card { background: $card; border-radius: $r; padding: 28px; box-shadow: $shadow-sm; }
|
||||
.consent-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.consent-card__type { font-size: var(--tk-font-body-lg); font-weight: bold; color: $tx; }
|
||||
.status-tag {
|
||||
@include tag($bd-l, $tx3);
|
||||
&.granted { @include tag($acc-l, $acc); }
|
||||
&.revoked { @include tag($dan-l, $dan); }
|
||||
}
|
||||
.consent-card__scope,
|
||||
.consent-card__date,
|
||||
.consent-card__expiry {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.revoke-btn {
|
||||
margin-top: 16px;
|
||||
padding: 12px 0;
|
||||
min-height: $touch-min;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
border-radius: $r-sm;
|
||||
border: 1px solid $dan;
|
||||
&:active { background: $dan-l; }
|
||||
&--disabled { opacity: 0.5; }
|
||||
}
|
||||
.revoke-btn__text { font-size: var(--tk-font-h2); color: $dan; font-weight: 500; }
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<view :class="['diagnoses-page', elderClass]">
|
||||
<text class="page-title">诊断记录</text>
|
||||
|
||||
<scroll-view scroll-y class="diagnosis-scroll" @scrolltolower="loadMore">
|
||||
<view class="diagnosis-list">
|
||||
<view v-for="d in records" :key="d.id" class="diagnosis-card">
|
||||
<view class="diagnosis-card__header">
|
||||
<text class="diagnosis-card__name">{{ d.diagnosis_name }}</text>
|
||||
<text :class="['diagnosis-card__status', (STATUS_MAP[d.status] || { cls: '' }).cls]">
|
||||
{{ (STATUS_MAP[d.status] || { label: d.status }).label }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="diagnosis-card__meta">
|
||||
<text :class="['diagnosis-card__type', (TYPE_MAP[d.diagnosis_type] || { cls: '' }).cls]">
|
||||
{{ (TYPE_MAP[d.diagnosis_type] || { label: d.diagnosis_type }).label }}
|
||||
</text>
|
||||
<text class="diagnosis-card__code">{{ d.icd_code }}</text>
|
||||
</view>
|
||||
<text class="diagnosis-card__date">诊断日期:{{ d.diagnosed_date }}</text>
|
||||
<text v-if="d.notes" class="diagnosis-card__notes">{{ d.notes }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<EmptyState
|
||||
v-if="records.length === 0 && !loading"
|
||||
:text="authStore.currentPatient ? '暂无诊断记录' : '请先在就诊人管理中选择就诊人'"
|
||||
/>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { listDiagnoses, type Diagnosis } from '@/services/health-record'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const TYPE_MAP: Record<string, { label: string; cls: string }> = {
|
||||
primary: { label: '主要', cls: 'primary' },
|
||||
secondary: { label: '次要', cls: 'secondary' },
|
||||
comorbid: { label: '合并症', cls: 'comorbid' },
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
active: { label: '活动', cls: 'active' },
|
||||
resolved: { label: '已解决', cls: 'resolved' },
|
||||
chronic: { label: '慢性', cls: 'chronic' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const records = ref<Diagnosis[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchData = async (p: number, append = false) => {
|
||||
if (!authStore.currentPatient) {
|
||||
records.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listDiagnoses(authStore.currentPatient.id, { page: p, page_size: 20 })
|
||||
const list = res.data || []
|
||||
records.value = append ? [...records.value, ...list] : list
|
||||
total.value = res.total
|
||||
page.value = p
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!loading.value && records.value.length < total.value) {
|
||||
fetchData(page.value + 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => { fetchData(1) })
|
||||
onPullDownRefresh(() => { fetchData(1).finally(() => uni.stopPullDownRefresh()) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.diagnoses-page { min-height: 100vh; background: $bg; padding: 32px 24px 0; }
|
||||
.page-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
padding-left: 4px;
|
||||
}
|
||||
.diagnosis-scroll { height: calc(100vh - 80px); }
|
||||
.diagnosis-list { display: flex; flex-direction: column; gap: 16px; }
|
||||
.diagnosis-card { background: $card; border-radius: $r; padding: 28px; box-shadow: $shadow-sm; }
|
||||
.diagnosis-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.diagnosis-card__name { font-size: var(--tk-font-body-lg); font-weight: bold; color: $tx; flex: 1; margin-right: 12px; }
|
||||
.diagnosis-card__status {
|
||||
@include tag($bd-l, $tx3);
|
||||
&.active { @include tag($acc-l, $acc); }
|
||||
&.resolved { @include tag($acc-l, $acc); }
|
||||
&.chronic { @include tag($wrn-l, $wrn); }
|
||||
}
|
||||
.diagnosis-card__meta { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
||||
.diagnosis-card__type {
|
||||
@include tag($pri-l, $pri-d);
|
||||
&.secondary { @include tag($bd-l, $tx2); }
|
||||
&.comorbid { @include tag($wrn-l, $wrn); }
|
||||
}
|
||||
.diagnosis-card__code { font-size: var(--tk-font-body); color: $tx3; font-variant-numeric: tabular-nums; }
|
||||
.diagnosis-card__date { font-size: var(--tk-font-body); color: $tx2; display: block; }
|
||||
.diagnosis-card__notes { font-size: var(--tk-font-body); color: $tx2; display: block; margin-top: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<view :class="['detail-page', elderClass]">
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<EmptyState v-else-if="!rx" icon="📋" title="处方不存在" />
|
||||
<template v-else>
|
||||
<view class="detail-card header-card">
|
||||
<view class="header-row">
|
||||
<text class="detail-title">{{ rx.dialyzer_model || '透析处方' }}</text>
|
||||
<text :class="['status-tag', statusInfo(rx.status).cls]">{{ statusInfo(rx.status).label }}</text>
|
||||
</view>
|
||||
<text v-if="rx.effective_from || rx.effective_to" class="header-sub">
|
||||
{{ rx.effective_from || '...' }} ~ {{ rx.effective_to || '...' }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="detail-card">
|
||||
<text class="section-title">基本参数</text>
|
||||
<view v-if="rx.dialyzer_model" class="detail-row"><text class="detail-label">透析器型号</text><text class="detail-value">{{ rx.dialyzer_model }}</text></view>
|
||||
<view v-if="rx.membrane_area != null" class="detail-row"><text class="detail-label">膜面积</text><text class="detail-value">{{ rx.membrane_area }} m²</text></view>
|
||||
<view v-if="rx.blood_flow_rate != null" class="detail-row"><text class="detail-label">血流速</text><text class="detail-value">{{ rx.blood_flow_rate }} ml/min</text></view>
|
||||
<view v-if="rx.dialysate_flow_rate != null" class="detail-row"><text class="detail-label">透析液流量</text><text class="detail-value">{{ rx.dialysate_flow_rate }} ml/min</text></view>
|
||||
<view v-if="rx.frequency_per_week != null" class="detail-row"><text class="detail-label">频率</text><text class="detail-value">{{ rx.frequency_per_week }} 次/周</text></view>
|
||||
<view v-if="rx.duration_minutes != null" class="detail-row"><text class="detail-label">每次时长</text><text class="detail-value">{{ rx.duration_minutes }} 分钟</text></view>
|
||||
</view>
|
||||
|
||||
<view class="detail-card">
|
||||
<text class="section-title">透析液配比</text>
|
||||
<view v-if="rx.dialysate_potassium != null" class="detail-row"><text class="detail-label">钾浓度</text><text class="detail-value">{{ rx.dialysate_potassium }} mmol/L</text></view>
|
||||
<view v-if="rx.dialysate_calcium != null" class="detail-row"><text class="detail-label">钙浓度</text><text class="detail-value">{{ rx.dialysate_calcium }} mmol/L</text></view>
|
||||
<view v-if="rx.dialysate_bicarbonate != null" class="detail-row"><text class="detail-label">碳酸氢盐浓度</text><text class="detail-value">{{ rx.dialysate_bicarbonate }} mmol/L</text></view>
|
||||
</view>
|
||||
|
||||
<view class="detail-card">
|
||||
<text class="section-title">抗凝方案</text>
|
||||
<view v-if="rx.anticoagulation_type" class="detail-row"><text class="detail-label">抗凝类型</text><text class="detail-value">{{ rx.anticoagulation_type }}</text></view>
|
||||
<view v-if="rx.anticoagulation_dose" class="detail-row"><text class="detail-label">抗凝剂量</text><text class="detail-value">{{ rx.anticoagulation_dose }}</text></view>
|
||||
</view>
|
||||
|
||||
<view v-if="rx.vascular_access_type || rx.vascular_access_location" class="detail-card">
|
||||
<text class="section-title">血管通路</text>
|
||||
<view v-if="rx.vascular_access_type" class="detail-row"><text class="detail-label">通路类型</text><text class="detail-value">{{ rx.vascular_access_type }}</text></view>
|
||||
<view v-if="rx.vascular_access_location" class="detail-row"><text class="detail-label">通路位置</text><text class="detail-value">{{ rx.vascular_access_location }}</text></view>
|
||||
</view>
|
||||
|
||||
<view v-if="rx.target_ultrafiltration_ml != null || rx.target_dry_weight != null" class="detail-card">
|
||||
<text class="section-title">超滤目标</text>
|
||||
<view v-if="rx.target_ultrafiltration_ml != null" class="detail-row"><text class="detail-label">目标超滤量</text><text class="detail-value">{{ rx.target_ultrafiltration_ml }} ml</text></view>
|
||||
<view v-if="rx.target_dry_weight != null" class="detail-row"><text class="detail-label">目标干体重</text><text class="detail-value">{{ rx.target_dry_weight }} kg</text></view>
|
||||
</view>
|
||||
|
||||
<view v-if="rx.notes" class="detail-card">
|
||||
<text class="section-title">备注</text>
|
||||
<text class="notes-text">{{ rx.notes }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getDialysisPrescription, type DialysisPrescription } from '@/services/dialysis'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
active: { label: '生效中', cls: 'active' },
|
||||
inactive: { label: '已停用', cls: 'inactive' },
|
||||
expired: { label: '已过期', cls: 'expired' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const rx = ref<DialysisPrescription | null>(null)
|
||||
const loading = ref(true)
|
||||
let id = ''
|
||||
|
||||
const statusInfo = (s: string) => STATUS_MAP[s] || { label: s, cls: '' }
|
||||
|
||||
onLoad((query) => {
|
||||
id = query?.id || ''
|
||||
if (!id) { loading.value = false; return }
|
||||
loading.value = true
|
||||
getDialysisPrescription(id)
|
||||
.then(data => { rx.value = data })
|
||||
.catch(() => uni.showToast({ title: '加载失败', icon: 'none' }))
|
||||
.finally(() => { loading.value = false })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page { min-height: 100vh; background: $bg; padding: 24px; }
|
||||
.detail-card { @include card; margin-bottom: 16px; }
|
||||
.header-card { border-left: 4px solid $pri; }
|
||||
.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
||||
.detail-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; }
|
||||
.status-tag { padding: 2px 10px; border-radius: 4px; font-size: var(--tk-font-cap);
|
||||
&.active { background: rgba(82,196,26,0.1); color: $acc; }
|
||||
&.inactive { background: rgba(0,0,0,0.04); color: $tx3; }
|
||||
&.expired { background: rgba(255,77,79,0.1); color: $dan; }
|
||||
}
|
||||
.header-sub { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-top: 4px; }
|
||||
.section-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; margin-bottom: 12px; display: block; }
|
||||
.detail-row { display: flex; justify-content: space-between; padding: 8px 0; }
|
||||
.detail-label { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.detail-value { font-size: var(--tk-font-body); color: $tx; }
|
||||
.notes-text { font-size: var(--tk-font-cap); color: $tx2; line-height: 1.6; }
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<view :class="['dialysis-prescriptions-page', elderClass]">
|
||||
<text class="page-title">透析处方</text>
|
||||
|
||||
<template v-if="!authStore.currentPatient">
|
||||
<EmptyState icon="📋" title="请先在就诊人管理中选择就诊人" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<view v-if="prescriptions.length === 0 && !loading" class="empty-wrap">
|
||||
<EmptyState icon="📋" title="暂无透析处方" />
|
||||
</view>
|
||||
|
||||
<scroll-view v-else scroll-y class="prescription-scroll" @scrolltolower="loadMore">
|
||||
<view
|
||||
v-for="p in prescriptions" :key="p.id"
|
||||
class="prescription-card"
|
||||
@tap="goDetail(p.id)"
|
||||
>
|
||||
<view class="prescription-card-top">
|
||||
<text class="prescription-model">{{ p.dialyzer_model || '未指定型号' }}</text>
|
||||
<text :class="['status-tag', statusInfo(p.status).cls]">{{ statusInfo(p.status).label }}</text>
|
||||
</view>
|
||||
<view class="prescription-meta">
|
||||
<text v-if="p.frequency_per_week != null" class="meta-item">{{ p.frequency_per_week }}次/周</text>
|
||||
<text v-if="p.duration_minutes != null" class="meta-item">每次{{ p.duration_minutes }}分钟</text>
|
||||
</view>
|
||||
<text v-if="p.effective_from || p.effective_to" class="prescription-date">
|
||||
{{ p.effective_from || '...' }} ~ {{ p.effective_to || '...' }}
|
||||
</text>
|
||||
</view>
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-if="!loading && prescriptions.length >= total && total > 0" class="no-more"><text class="no-more-text">没有更多了</text></view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { listDialysisPrescriptions, type DialysisPrescription } from '@/services/dialysis'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
active: { label: '生效中', cls: 'active' },
|
||||
inactive: { label: '已停用', cls: 'inactive' },
|
||||
expired: { label: '已过期', cls: 'expired' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const prescriptions = ref<DialysisPrescription[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
let loadingGuard = false
|
||||
|
||||
const statusInfo = (s: string) => STATUS_MAP[s] || { label: s, cls: '' }
|
||||
|
||||
const fetchData = async (p: number, append = false) => {
|
||||
if (!authStore.currentPatient || loadingGuard) return
|
||||
loadingGuard = true
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listDialysisPrescriptions({ patient_id: authStore.currentPatient.id, page: p, page_size: 20 })
|
||||
const list = res.data || []
|
||||
prescriptions.value = append ? [...prescriptions.value, ...list] : list
|
||||
total.value = res.total
|
||||
page.value = p
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loadingGuard = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!loading.value && prescriptions.value.length < total.value) {
|
||||
fetchData(page.value + 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
const goDetail = (id: string) => {
|
||||
uni.navigateTo({ url: `/pages-sub/pkg-profile/dialysis-prescriptions/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
onShow(() => { fetchData(1) })
|
||||
onPullDownRefresh(() => { fetchData(1).finally(() => uni.stopPullDownRefresh()) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dialysis-prescriptions-page { min-height: 100vh; background: $bg; padding: 24px; }
|
||||
.page-title { @include section-title; }
|
||||
.prescription-scroll { height: calc(100vh - 80px); }
|
||||
.prescription-card { @include card; margin-bottom: 12px; }
|
||||
.prescription-card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.prescription-model { font-size: var(--tk-font-body); font-weight: 500; color: $tx; }
|
||||
.status-tag { padding: 2px 10px; border-radius: 4px; font-size: var(--tk-font-cap);
|
||||
&.active { background: rgba(82,196,26,0.1); color: $acc; }
|
||||
&.inactive { background: rgba(0,0,0,0.04); color: $tx3; }
|
||||
&.expired { background: rgba(255,77,79,0.1); color: $dan; }
|
||||
}
|
||||
.prescription-meta { display: flex; gap: 16px; margin-top: 4px; }
|
||||
.meta-item { font-size: var(--tk-font-cap); color: $tx2; }
|
||||
.prescription-date { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-top: 4px; }
|
||||
.no-more { @include flex-center; padding: 20px; }
|
||||
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.empty-wrap { padding-top: 40px; }
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<view :class="['detail-page', elderClass]">
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<EmptyState v-else-if="!record" icon="📋" title="记录不存在" />
|
||||
<template v-else>
|
||||
<view class="detail-card header-card">
|
||||
<view class="header-row">
|
||||
<text class="detail-title">{{ record.dialysis_date }}</text>
|
||||
<text :class="['status-tag', statusInfo(record.status).cls]">{{ statusInfo(record.status).label }}</text>
|
||||
</view>
|
||||
<text class="header-sub">{{ TYPE_MAP[record.dialysis_type] || record.dialysis_type }}</text>
|
||||
<text v-if="record.reviewed_at" class="review-info">审核时间 {{ record.reviewed_at }}</text>
|
||||
</view>
|
||||
|
||||
<view class="detail-card">
|
||||
<text class="section-title">基本信息</text>
|
||||
<view v-if="record.start_time" class="detail-row"><text class="detail-label">开始时间</text><text class="detail-value">{{ record.start_time }}</text></view>
|
||||
<view v-if="record.end_time" class="detail-row"><text class="detail-label">结束时间</text><text class="detail-value">{{ record.end_time }}</text></view>
|
||||
<view v-if="record.dialysis_duration != null" class="detail-row"><text class="detail-label">透析时长</text><text class="detail-value">{{ record.dialysis_duration }} 分钟</text></view>
|
||||
<view v-if="record.blood_flow_rate != null" class="detail-row"><text class="detail-label">血流速</text><text class="detail-value">{{ record.blood_flow_rate }} ml/min</text></view>
|
||||
<view v-if="record.ultrafiltration_volume != null" class="detail-row"><text class="detail-label">超滤量</text><text class="detail-value">{{ record.ultrafiltration_volume }} ml</text></view>
|
||||
</view>
|
||||
|
||||
<view class="detail-card">
|
||||
<text class="section-title">体重与血压</text>
|
||||
<view v-if="record.dry_weight != null" class="detail-row"><text class="detail-label">干体重</text><text class="detail-value">{{ record.dry_weight }} kg</text></view>
|
||||
<view v-if="record.pre_weight != null" class="detail-row"><text class="detail-label">透前体重</text><text class="detail-value">{{ record.pre_weight }} kg</text></view>
|
||||
<view v-if="record.post_weight != null" class="detail-row"><text class="detail-label">透后体重</text><text class="detail-value">{{ record.post_weight }} kg</text></view>
|
||||
<view v-if="record.pre_bp_systolic != null && record.pre_bp_diastolic != null" class="detail-row"><text class="detail-label">透前血压</text><text class="detail-value">{{ record.pre_bp_systolic }}/{{ record.pre_bp_diastolic }} mmHg</text></view>
|
||||
<view v-if="record.post_bp_systolic != null && record.post_bp_diastolic != null" class="detail-row"><text class="detail-label">透后血压</text><text class="detail-value">{{ record.post_bp_systolic }}/{{ record.post_bp_diastolic }} mmHg</text></view>
|
||||
<view v-if="record.pre_heart_rate != null" class="detail-row"><text class="detail-label">透前心率</text><text class="detail-value">{{ record.pre_heart_rate }} bpm</text></view>
|
||||
<view v-if="record.post_heart_rate != null" class="detail-row"><text class="detail-label">透后心率</text><text class="detail-value">{{ record.post_heart_rate }} bpm</text></view>
|
||||
</view>
|
||||
|
||||
<view v-if="record.symptoms || record.complication_notes" class="detail-card">
|
||||
<text class="section-title">症状与并发症</text>
|
||||
<view v-if="record.symptoms && Object.keys(record.symptoms).length > 0" class="detail-row"><text class="detail-label">症状</text><text class="detail-value">{{ JSON.stringify(record.symptoms) }}</text></view>
|
||||
<view v-if="record.complication_notes" class="detail-row"><text class="detail-label">并发症备注</text><text class="detail-value">{{ record.complication_notes }}</text></view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getDialysisRecord, type DialysisRecord } from '@/services/dialysis'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
draft: { label: '草稿', cls: 'draft' },
|
||||
completed: { label: '已完成', cls: 'completed' },
|
||||
reviewed: { label: '已审核', cls: 'reviewed' },
|
||||
}
|
||||
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
HD: '血液透析',
|
||||
HDF: '血液透析滤过',
|
||||
HF: '血液滤过',
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const record = ref<DialysisRecord | null>(null)
|
||||
const loading = ref(true)
|
||||
let id = ''
|
||||
|
||||
const statusInfo = (s: string) => STATUS_MAP[s] || { label: s, cls: '' }
|
||||
|
||||
onLoad((query) => {
|
||||
id = query?.id || ''
|
||||
if (!id) { loading.value = false; return }
|
||||
loading.value = true
|
||||
getDialysisRecord(id)
|
||||
.then(data => { record.value = data })
|
||||
.catch(() => uni.showToast({ title: '加载失败', icon: 'none' }))
|
||||
.finally(() => { loading.value = false })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page { min-height: 100vh; background: $bg; padding: 24px; }
|
||||
.detail-card { @include card; margin-bottom: 16px; }
|
||||
.header-card { border-left: 4px solid $pri; }
|
||||
.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
||||
.detail-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; }
|
||||
.status-tag { padding: 2px 10px; border-radius: 4px; font-size: var(--tk-font-cap);
|
||||
&.draft { background: rgba(0,0,0,0.04); color: $tx3; }
|
||||
&.completed { background: rgba(82,196,26,0.1); color: $acc; }
|
||||
&.reviewed { background: rgba(22,119,255,0.1); color: $info; }
|
||||
}
|
||||
.header-sub { font-size: var(--tk-font-body); color: $tx2; display: block; margin-top: 4px; }
|
||||
.review-info { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-top: 4px; }
|
||||
.section-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; margin-bottom: 12px; display: block; }
|
||||
.detail-row { display: flex; justify-content: space-between; padding: 8px 0; }
|
||||
.detail-label { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.detail-value { font-size: var(--tk-font-body); color: $tx; }
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<view :class="['dialysis-records-page', elderClass]">
|
||||
<text class="page-title">透析记录</text>
|
||||
|
||||
<template v-if="!authStore.currentPatient">
|
||||
<EmptyState icon="📋" title="请先在就诊人管理中选择就诊人" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<view v-if="records.length === 0 && !loading" class="empty-wrap">
|
||||
<EmptyState icon="📋" title="暂无透析记录" />
|
||||
</view>
|
||||
|
||||
<scroll-view v-else scroll-y class="record-scroll" @scrolltolower="loadMore">
|
||||
<view
|
||||
v-for="r in records" :key="r.id"
|
||||
class="record-card"
|
||||
@tap="goDetail(r.id)"
|
||||
>
|
||||
<view class="record-card-top">
|
||||
<text :class="['type-tag', typeInfo(r.dialysis_type).cls]">{{ typeInfo(r.dialysis_type).label }}</text>
|
||||
<text :class="['status-tag', statusInfo(r.status).cls]">{{ statusInfo(r.status).label }}</text>
|
||||
</view>
|
||||
<text class="record-date">{{ r.dialysis_date }}</text>
|
||||
<view v-if="r.pre_weight || r.post_weight" class="weight-row">
|
||||
<text v-if="r.pre_weight" class="weight-item">透前 {{ r.pre_weight }}kg</text>
|
||||
<text v-if="r.post_weight" class="weight-item">透后 {{ r.post_weight }}kg</text>
|
||||
</view>
|
||||
<text v-if="r.dialysis_duration" class="record-meta">时长 {{ r.dialysis_duration }}分钟</text>
|
||||
</view>
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-if="!loading && records.length >= total && total > 0" class="no-more"><text class="no-more-text">没有更多了</text></view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { listDialysisRecords, type DialysisRecord } from '@/services/dialysis'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const TYPE_MAP: Record<string, { label: string; cls: string }> = {
|
||||
HD: { label: 'HD', cls: 'hd' },
|
||||
HDF: { label: 'HDF', cls: 'hdf' },
|
||||
HF: { label: 'HF', cls: 'hf' },
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
draft: { label: '草稿', cls: 'draft' },
|
||||
completed: { label: '已完成', cls: 'completed' },
|
||||
reviewed: { label: '已审核', cls: 'reviewed' },
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const records = ref<DialysisRecord[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
let loadingGuard = false
|
||||
|
||||
const typeInfo = (t: string) => TYPE_MAP[t] || { label: t, cls: '' }
|
||||
const statusInfo = (s: string) => STATUS_MAP[s] || { label: s, cls: '' }
|
||||
|
||||
const fetchData = async (p: number, append = false) => {
|
||||
if (!authStore.currentPatient || loadingGuard) return
|
||||
loadingGuard = true
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listDialysisRecords(authStore.currentPatient.id, { page: p, page_size: 20 })
|
||||
const list = res.data || []
|
||||
records.value = append ? [...records.value, ...list] : list
|
||||
total.value = res.total
|
||||
page.value = p
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loadingGuard = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!loading.value && records.value.length < total.value) {
|
||||
fetchData(page.value + 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
const goDetail = (id: string) => {
|
||||
uni.navigateTo({ url: `/pages-sub/pkg-profile/dialysis-records/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
onShow(() => { fetchData(1) })
|
||||
onPullDownRefresh(() => { fetchData(1).finally(() => uni.stopPullDownRefresh()) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dialysis-records-page { min-height: 100vh; background: $bg; padding: 24px; }
|
||||
.page-title { @include section-title; }
|
||||
.record-scroll { height: calc(100vh - 80px); }
|
||||
.record-card { @include card; margin-bottom: 12px; }
|
||||
.record-card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.type-tag { padding: 2px 10px; border-radius: 4px; font-size: var(--tk-font-cap); font-weight: 500;
|
||||
&.hd { background: rgba(22,119,255,0.1); color: $info; }
|
||||
&.hdf { background: rgba(114,46,209,0.1); color: #722ed1; }
|
||||
&.hf { background: rgba(250,173,20,0.1); color: $wrn; }
|
||||
}
|
||||
.status-tag { padding: 2px 10px; border-radius: 4px; font-size: var(--tk-font-cap);
|
||||
&.draft { background: rgba(0,0,0,0.04); color: $tx3; }
|
||||
&.completed { background: rgba(82,196,26,0.1); color: $acc; }
|
||||
&.reviewed { background: rgba(22,119,255,0.1); color: $info; }
|
||||
}
|
||||
.record-date { font-size: var(--tk-font-body); color: $tx; display: block; margin-bottom: 4px; }
|
||||
.weight-row { display: flex; gap: 16px; margin-top: 4px; }
|
||||
.weight-item { font-size: var(--tk-font-cap); color: $tx2; }
|
||||
.record-meta { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-top: 4px; }
|
||||
.no-more { @include flex-center; padding: 20px; }
|
||||
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.empty-wrap { padding-top: 40px; }
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<scroll-view scroll-y class="page-scroll">
|
||||
<view :class="['elder-mode-page', elderClass]">
|
||||
<view class="elder-mode-card">
|
||||
<view class="elder-mode-header">
|
||||
<text class="elder-mode-icon">老</text>
|
||||
<view class="elder-mode-info">
|
||||
<text class="elder-mode-title">长辈模式</text>
|
||||
<text class="elder-mode-desc">放大字体和按钮,更易阅读和操作</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="elder-mode-status">
|
||||
<text class="elder-mode-status-text">
|
||||
当前状态:{{ uiStore.elderMode ? '已开启' : '已关闭' }}
|
||||
</text>
|
||||
<view
|
||||
:class="['elder-mode-switch', { 'elder-mode-switch--on': uiStore.elderMode }]"
|
||||
@tap="handleToggle"
|
||||
>
|
||||
<view class="elder-mode-switch-thumb" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="elder-mode-preview">
|
||||
<text class="elder-mode-preview-title">效果预览</text>
|
||||
<view class="elder-mode-preview-card">
|
||||
<text :class="['elder-mode-preview-sample', { 'elder-mode-preview-sample--large': uiStore.elderMode }]">
|
||||
{{ uiStore.elderMode ? '长辈模式字体示例' : '标准模式字体示例' }}
|
||||
</text>
|
||||
<text class="elder-mode-preview-note">
|
||||
{{ uiStore.elderMode ? '字号放大 1.3 倍,间距放大 1.2 倍' : '正常字号和间距' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const uiStore = useUIStore()
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
function handleToggle() {
|
||||
uiStore.toggleElderMode()
|
||||
uni.showToast({
|
||||
title: uiStore.elderMode ? '已开启长辈模式' : '已关闭长辈模式',
|
||||
icon: 'none',
|
||||
duration: 1500,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.elder-mode-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.elder-mode-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
box-shadow: $shadow-md;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.elder-mode-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.elder-mode-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: $r-sm;
|
||||
background: $acc-l;
|
||||
@include flex-center;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 700;
|
||||
color: $acc;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.elder-mode-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.elder-mode-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.elder-mode-desc {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
|
||||
.elder-mode-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0 0;
|
||||
border-top: 1px solid $bd-l;
|
||||
}
|
||||
|
||||
.elder-mode-status-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.elder-mode-switch {
|
||||
width: 52px;
|
||||
height: 30px;
|
||||
border-radius: $r-pill;
|
||||
background: $bd;
|
||||
position: relative;
|
||||
transition: background 0.25s;
|
||||
transition: background 0.25s;
|
||||
|
||||
&--on {
|
||||
background: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.elder-mode-switch-thumb {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: $r-pill;
|
||||
background: $card;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: transform 0.25s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.elder-mode-switch--on & {
|
||||
transform: translateX(22px);
|
||||
}
|
||||
}
|
||||
|
||||
.elder-mode-preview {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.elder-mode-preview-title {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 600;
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.elder-mode-preview-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.elder-mode-preview-sample {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
transition: font-size 0.25s;
|
||||
|
||||
&--large {
|
||||
font-size: var(--tk-font-body);
|
||||
}
|
||||
}
|
||||
|
||||
.elder-mode-preview-note {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<scroll-view scroll-y class="page-scroll">
|
||||
<view :class="['family-add-page', elderClass]">
|
||||
<text class="page-title">{{ editId ? '编辑就诊人' : '添加就诊人' }}</text>
|
||||
|
||||
<view class="form-card">
|
||||
<view class="form-item">
|
||||
<text class="form-label">姓名</text>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="请输入姓名"
|
||||
placeholder-class="form-placeholder"
|
||||
:value="name"
|
||||
@input="name = ($event as any).detail.value"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="form-label">关系</text>
|
||||
<picker
|
||||
mode="selector"
|
||||
:range="RELATION_OPTIONS"
|
||||
:value="relationIdx"
|
||||
@change="onRelationChange"
|
||||
>
|
||||
<view class="form-picker">
|
||||
<text class="form-picker-text">{{ RELATION_OPTIONS[relationIdx] }}</text>
|
||||
<text class="form-picker-arrow">></text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="form-label">性别</text>
|
||||
<picker
|
||||
mode="selector"
|
||||
:range="GENDER_OPTIONS"
|
||||
:value="genderIdx"
|
||||
@change="onGenderChange"
|
||||
>
|
||||
<view class="form-picker">
|
||||
<text class="form-picker-text">{{ GENDER_OPTIONS[genderIdx] }}</text>
|
||||
<text class="form-picker-arrow">></text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="form-label">出生日期</text>
|
||||
<picker
|
||||
mode="date"
|
||||
:value="birthDate || '2000-01-01'"
|
||||
@change="onDateChange"
|
||||
>
|
||||
<view class="form-picker">
|
||||
<text :class="['form-picker-text', { placeholder: !birthDate }]">
|
||||
{{ birthDate || '请选择' }}
|
||||
</text>
|
||||
<text class="form-picker-arrow">></text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
:class="['submit-btn', { disabled: submitting }]"
|
||||
@tap="submitting ? undefined : handleSubmit()"
|
||||
>
|
||||
<text class="submit-text">{{ submitting ? '提交中...' : editId ? '保存修改' : '确认添加' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
import { createPatient, updatePatient, Patient } from '@/services/patient'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const RELATION_OPTIONS = ['本人', '配偶', '父母', '子女', '其他']
|
||||
const GENDER_OPTIONS = ['男', '女']
|
||||
|
||||
const editId = ref('')
|
||||
const editData = ref<Patient | null>(null)
|
||||
|
||||
const name = ref('')
|
||||
const relationIdx = ref(0)
|
||||
const genderIdx = ref(0)
|
||||
const birthDate = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
function onRelationChange(e: any) {
|
||||
relationIdx.value = Number(e.detail.value)
|
||||
}
|
||||
|
||||
function onGenderChange(e: any) {
|
||||
genderIdx.value = Number(e.detail.value)
|
||||
}
|
||||
|
||||
function onDateChange(e: any) {
|
||||
birthDate.value = e.detail.value
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!name.value.trim()) {
|
||||
uni.showToast({ title: '请输入姓名', icon: 'none' })
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const gender = GENDER_OPTIONS[genderIdx.value] === '男' ? 'male' : 'female'
|
||||
if (editId.value && editData.value) {
|
||||
await updatePatient(editId.value, {
|
||||
name: name.value.trim(),
|
||||
gender,
|
||||
birth_date: birthDate.value || undefined,
|
||||
relation: RELATION_OPTIONS[relationIdx.value],
|
||||
}, editData.value.version)
|
||||
uni.showToast({ title: '修改成功', icon: 'success' })
|
||||
} else {
|
||||
await createPatient({
|
||||
name: name.value.trim(),
|
||||
gender,
|
||||
birth_date: birthDate.value || undefined,
|
||||
})
|
||||
uni.showToast({ title: '添加成功', icon: 'success' })
|
||||
}
|
||||
setTimeout(() => uni.navigateBack(), 1000)
|
||||
} catch {
|
||||
uni.showToast({ title: editId.value ? '修改失败' : '添加失败', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
if (query?.id) {
|
||||
editId.value = query.id
|
||||
const stored = uni.getStorageSync('edit_patient') as Patient | null
|
||||
if (stored) {
|
||||
editData.value = stored
|
||||
name.value = stored.name || ''
|
||||
relationIdx.value = stored.relation ? RELATION_OPTIONS.indexOf(stored.relation) : 0
|
||||
genderIdx.value = stored.gender === 'female' ? 1 : 0
|
||||
birthDate.value = stored.birth_date || ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnload(() => {
|
||||
uni.removeStorageSync('edit_patient')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.family-add-page {
|
||||
padding: 32px 24px;
|
||||
padding-bottom: 160px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@include section-title;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 4px 28px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
flex-shrink: 0;
|
||||
width: 140px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
text-align: right;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-placeholder {
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.form-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.form-picker-text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
margin-right: 10px;
|
||||
|
||||
&.placeholder {
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.form-picker-arrow {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: $pri;
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
box-shadow: 0 -2px 12px rgba(196, 98, 58, 0.15);
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
color: $white;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<scroll-view scroll-y class="page-scroll">
|
||||
<view :class="['family-page', elderClass]">
|
||||
<text class="family-page-title">就诊人管理</text>
|
||||
|
||||
<view class="family-list">
|
||||
<view
|
||||
v-for="p in patients"
|
||||
:key="p.id"
|
||||
:class="['family-item', { active: authStore.currentPatient?.id === p.id }]"
|
||||
@tap="handleSelect(p)"
|
||||
>
|
||||
<view class="family-avatar">
|
||||
<text class="family-avatar-text">{{ relationInitial(p.relation || '本人') }}</text>
|
||||
</view>
|
||||
<view class="family-info">
|
||||
<view class="family-name-row">
|
||||
<text class="family-name">{{ p.name }}</text>
|
||||
<text v-if="authStore.currentPatient?.id === p.id" class="family-current-tag">当前</text>
|
||||
</view>
|
||||
<view class="family-meta">
|
||||
<text class="family-relation-tag">{{ p.relation || '本人' }}</text>
|
||||
<text class="family-gender">{{ genderText(p.gender) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="family-edit" @tap.stop="goToEdit(p)">
|
||||
<text class="family-edit-text">编辑</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<EmptyState v-if="patients.length === 0 && !loading" text="暂无就诊人" />
|
||||
|
||||
<view class="family-add-btn" @tap="goToAdd">
|
||||
<text class="family-add-text">添加就诊人</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { listPatients, Patient } from '@/services/patient'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const patients = ref<Patient[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchPatients() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listPatients()
|
||||
patients.value = res.data || []
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(patient: Patient) {
|
||||
authStore.setCurrentPatient({
|
||||
id: patient.id,
|
||||
name: patient.name,
|
||||
gender: patient.gender,
|
||||
birth_date: patient.birth_date,
|
||||
relation: patient.relation || '本人',
|
||||
})
|
||||
uni.showToast({ title: `已切换为 ${patient.name}`, icon: 'success' })
|
||||
}
|
||||
|
||||
function goToAdd() {
|
||||
uni.navigateTo({ url: '/pages-sub/pkg-profile/family-add/index' })
|
||||
}
|
||||
|
||||
function goToEdit(patient: Patient) {
|
||||
uni.setStorageSync('edit_patient', patient)
|
||||
uni.navigateTo({ url: `/pages-sub/pkg-profile/family-add/index?id=${patient.id}` })
|
||||
}
|
||||
|
||||
function genderText(g?: string) {
|
||||
if (g === 'male') return '男'
|
||||
if (g === 'female') return '女'
|
||||
return '未知'
|
||||
}
|
||||
|
||||
function relationInitial(relation: string) {
|
||||
return relation ? relation.charAt(0) : '本'
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
fetchPatients()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.family-page {
|
||||
padding: 32px 24px;
|
||||
padding-bottom: 160px;
|
||||
}
|
||||
|
||||
.family-page-title {
|
||||
@include section-title;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.family-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.family-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
box-shadow: $shadow-sm;
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
&:active {
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
|
||||
&.active {
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.family-avatar {
|
||||
@include flex-center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: $r;
|
||||
background: $pri-l;
|
||||
flex-shrink: 0;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.family-avatar-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: bold;
|
||||
color: $pri-d;
|
||||
}
|
||||
|
||||
.family-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.family-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.family-name {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.family-current-tag {
|
||||
@include tag($pri, $white);
|
||||
font-size: var(--tk-font-body-sm);
|
||||
padding: 2px 10px;
|
||||
}
|
||||
|
||||
.family-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.family-relation-tag {
|
||||
@include tag($pri-l, $pri-d);
|
||||
font-size: var(--tk-font-body);
|
||||
padding: 2px 12px;
|
||||
}
|
||||
|
||||
.family-gender {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.family-edit {
|
||||
flex-shrink: 0;
|
||||
margin-left: 16px;
|
||||
padding: 14px 24px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-pill;
|
||||
min-height: 48px;
|
||||
@include flex-center;
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
}
|
||||
|
||||
.family-edit-text {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.family-add-btn {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: $pri;
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
box-shadow: 0 -2px 12px rgba(196, 98, 58, 0.15);
|
||||
}
|
||||
|
||||
.family-add-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
color: $white;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<view :class="['my-followups-page', elderClass]">
|
||||
<view class="tab-bar">
|
||||
<view
|
||||
v-for="tab in TABS" :key="tab.key"
|
||||
:class="['tab-item', activeTab === tab.key ? 'active' : '']"
|
||||
@tap="handleTabChange(tab.key)"
|
||||
>
|
||||
<text :class="['tab-text', activeTab === tab.key ? 'active' : '']">{{ tab.label }}</text>
|
||||
<view v-if="activeTab === tab.key" class="tab-indicator" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="task-list">
|
||||
<view
|
||||
v-for="t in tasks" :key="t.id"
|
||||
class="task-card"
|
||||
@tap="goToDetail(t.id)"
|
||||
>
|
||||
<view class="task-top">
|
||||
<text class="task-name">{{ t.follow_up_type }}</text>
|
||||
<text :class="['task-status', getStatusClass(t.status)]">
|
||||
{{ getStatusLabel(t.status) }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="task-desc">{{ t.content_template }}</text>
|
||||
<text class="task-due">截止: {{ t.planned_date }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<EmptyState
|
||||
v-if="tasks.length === 0 && !loading"
|
||||
:text="'暂无' + (TABS.find(t => t.key === activeTab)?.label || '') + '任务'"
|
||||
/>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { listTasks, type FollowUpTask } from '@/services/followup'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'pending', label: '待完成' },
|
||||
{ key: 'completed', label: '已完成' },
|
||||
{ key: 'overdue', label: '已过期' },
|
||||
]
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const activeTab = ref('pending')
|
||||
const tasks = ref<FollowUpTask[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchTasks = async (status: string) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const patientId = authStore.currentPatient?.id
|
||||
const res = await listTasks(patientId, status)
|
||||
tasks.value = res.data || []
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
activeTab.value = key
|
||||
fetchTasks(key)
|
||||
}
|
||||
|
||||
const goToDetail = (id: string) => {
|
||||
uni.navigateTo({ url: `/pages-sub/followup/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
if (status === 'completed') return 'completed'
|
||||
if (status === 'overdue') return 'overdue'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
if (status === 'completed') return '已完成'
|
||||
if (status === 'overdue') return '已过期'
|
||||
return '待完成'
|
||||
}
|
||||
|
||||
onShow(() => { fetchTasks(activeTab.value) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.my-followups-page { min-height: 100vh; background: $bg; }
|
||||
.tab-bar { display: flex; background: $card; padding: 0; box-shadow: $shadow-sm; }
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 24px 0 20px;
|
||||
position: relative;
|
||||
}
|
||||
.tab-text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
&.active { color: $pri; font-weight: bold; }
|
||||
}
|
||||
.tab-indicator { width: 32px; height: 4px; background: $pri; border-radius: $r-xs; }
|
||||
.task-list { padding: 24px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.task-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 28px;
|
||||
box-shadow: $shadow-sm;
|
||||
&:active { box-shadow: $shadow-md; }
|
||||
}
|
||||
.task-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.task-name {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
.task-status {
|
||||
@include tag($bd-l, $tx2);
|
||||
&.pending { @include tag($wrn-l, $wrn); }
|
||||
&.completed { @include tag($acc-l, $acc); }
|
||||
&.overdue { @include tag($dan-l, $dan); }
|
||||
}
|
||||
.task-desc {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.task-due {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<view :class="['health-records-page', elderClass]">
|
||||
<text class="page-title">健康记录</text>
|
||||
|
||||
<view class="record-list">
|
||||
<view v-for="r in records" :key="r.id" class="record-card">
|
||||
<view class="record-card__header">
|
||||
<text class="record-card__type">{{ TYPE_MAP[r.record_type] || r.record_type }}</text>
|
||||
<text class="record-card__date">{{ r.record_date }}</text>
|
||||
</view>
|
||||
<text v-if="r.overall_assessment" class="record-card__assessment">{{ r.overall_assessment }}</text>
|
||||
<text v-if="r.source" class="record-card__source">来源:{{ r.source }}</text>
|
||||
<text v-if="r.notes" class="record-card__notes">{{ r.notes }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<EmptyState
|
||||
v-if="records.length === 0 && !loading"
|
||||
:text="authStore.currentPatient ? '暂无健康记录' : '请先在就诊人管理中选择就诊人'"
|
||||
/>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { listHealthRecords, type HealthRecord } from '@/services/health-record'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
checkup: '体检',
|
||||
follow_up: '复查',
|
||||
referral: '转诊',
|
||||
}
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const records = ref<HealthRecord[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchData = async (p: number, append = false) => {
|
||||
if (!authStore.currentPatient) {
|
||||
records.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listHealthRecords(authStore.currentPatient.id, { page: p, page_size: 20 })
|
||||
const list = res.data || []
|
||||
records.value = append ? [...records.value, ...list] : list
|
||||
total.value = res.total
|
||||
page.value = p
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!loading.value && records.value.length < total.value) {
|
||||
fetchData(page.value + 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => { fetchData(1) })
|
||||
onPullDownRefresh(() => { fetchData(1).finally(() => uni.stopPullDownRefresh()) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.health-records-page { min-height: 100vh; background: $bg; padding: 32px 24px; padding-bottom: 40px; }
|
||||
.page-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
padding-left: 4px;
|
||||
}
|
||||
.record-list { display: flex; flex-direction: column; gap: 16px; }
|
||||
.record-card { background: $card; border-radius: $r; padding: 28px; box-shadow: $shadow-sm; }
|
||||
.record-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.record-card__type { font-size: var(--tk-font-body-lg); font-weight: bold; color: $tx; }
|
||||
.record-card__date { font-size: var(--tk-font-h2); color: $tx2; font-variant-numeric: tabular-nums; }
|
||||
.record-card__assessment { font-size: var(--tk-font-h2); color: $tx; display: block; margin-bottom: 4px; }
|
||||
.record-card__source { font-size: var(--tk-font-body); color: $tx3; display: block; margin-bottom: 4px; }
|
||||
.record-card__notes { font-size: var(--tk-font-body); color: $tx2; display: block; margin-top: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<view :class="['medication-page', elderClass]">
|
||||
<text class="page-title">用药提醒</text>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
|
||||
<template v-else>
|
||||
<view class="reminder-list">
|
||||
<view
|
||||
v-for="r in reminders" :key="r.id"
|
||||
:class="['reminder-card', !r.is_active ? 'disabled' : '']"
|
||||
>
|
||||
<view class="reminder-avatar">
|
||||
<text class="reminder-avatar-text">{{ nameInitial(r.medication_name) }}</text>
|
||||
</view>
|
||||
<view class="reminder-info">
|
||||
<text class="reminder-name">{{ r.medication_name }}</text>
|
||||
<text class="reminder-dosage">
|
||||
{{ r.dosage || '-' }} | {{ r.reminder_times?.join(', ') || '-' }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="reminder-actions">
|
||||
<view
|
||||
:class="['toggle', r.is_active ? 'on' : 'off']"
|
||||
@tap="handleToggle(r)"
|
||||
>
|
||||
<view class="toggle-dot" />
|
||||
</view>
|
||||
<text class="delete-btn" @tap="handleDelete(r)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<EmptyState v-if="reminders.length === 0" text="暂无用药提醒" />
|
||||
|
||||
<view v-if="showForm" class="form-card">
|
||||
<text class="form-card-title">添加提醒</text>
|
||||
<view class="form-item">
|
||||
<text class="form-label">药品名称</text>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="请输入药品名称"
|
||||
placeholder-class="form-placeholder"
|
||||
:value="formName"
|
||||
@input="formName = ($event as any).detail.value"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">剂量</text>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="如: 1片、10ml"
|
||||
placeholder-class="form-placeholder"
|
||||
:value="formDosage"
|
||||
@input="formDosage = ($event as any).detail.value"
|
||||
/>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">提醒时间</text>
|
||||
<picker mode="time" :value="formTime" @change="formTime = ($event as any).detail.value">
|
||||
<view class="time-picker-wrap">
|
||||
<text class="time-value">{{ formTime }}</text>
|
||||
<text class="time-modify">修改</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-actions">
|
||||
<view class="form-cancel" @tap="showForm = false">
|
||||
<text class="form-cancel-text">取消</text>
|
||||
</view>
|
||||
<view class="form-confirm" @tap="handleAdd">
|
||||
<text class="form-confirm-text">确认</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!showForm" class="add-btn" @tap="showForm = true">
|
||||
<text class="add-text">添加提醒</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onMounted } from 'vue'
|
||||
import {
|
||||
listReminders,
|
||||
createReminder,
|
||||
updateReminder,
|
||||
deleteReminder,
|
||||
type MedicationReminder,
|
||||
} from '@/services/medication-reminder'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const reminders = ref<MedicationReminder[]>([])
|
||||
const loading = ref(true)
|
||||
const showForm = ref(false)
|
||||
const formName = ref('')
|
||||
const formDosage = ref('')
|
||||
const formTime = ref('08:00')
|
||||
|
||||
const fetchReminders = async () => {
|
||||
try {
|
||||
const res = await listReminders()
|
||||
reminders.value = res.data ?? []
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (r: MedicationReminder) => {
|
||||
try {
|
||||
await updateReminder(r.id, { is_active: !r.is_active, version: r.version })
|
||||
fetchReminders()
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (r: MedicationReminder) => {
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个提醒吗?',
|
||||
}).then(async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await deleteReminder(r.id, r.version)
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
fetchReminders()
|
||||
} catch {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!formName.value.trim()) {
|
||||
uni.showToast({ title: '请输入药品名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const patientId = authStore.currentPatient?.id
|
||||
if (!patientId) {
|
||||
uni.showToast({ title: '请先绑定患者档案', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
await createReminder({
|
||||
patient_id: patientId,
|
||||
medication_name: formName.value.trim(),
|
||||
dosage: formDosage.value.trim() || undefined,
|
||||
reminder_times: [formTime.value],
|
||||
is_active: true,
|
||||
})
|
||||
formName.value = ''
|
||||
formDosage.value = ''
|
||||
formTime.value = '08:00'
|
||||
showForm.value = false
|
||||
uni.showToast({ title: '添加成功', icon: 'success' })
|
||||
fetchReminders()
|
||||
} catch {
|
||||
uni.showToast({ title: '添加失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const nameInitial = (name: string) => {
|
||||
return name ? name.charAt(0) : '药'
|
||||
}
|
||||
|
||||
onMounted(() => { fetchReminders() })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.medication-page { min-height: 100vh; background: $bg; padding: 32px 24px; padding-bottom: 160px; }
|
||||
.page-title { @include section-title; padding-left: 4px; }
|
||||
.reminder-list { display: flex; flex-direction: column; gap: 16px; }
|
||||
.reminder-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
box-shadow: $shadow-sm;
|
||||
&.disabled { opacity: 0.55; }
|
||||
}
|
||||
.reminder-avatar {
|
||||
@include flex-center;
|
||||
width: 72px; height: 72px;
|
||||
border-radius: $r;
|
||||
background: $acc-l;
|
||||
flex-shrink: 0;
|
||||
margin-right: 20px;
|
||||
}
|
||||
.reminder-avatar-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $acc;
|
||||
}
|
||||
.reminder-info { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.reminder-name {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.reminder-dosage {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
.reminder-actions { display: flex; align-items: center; gap: 16px; flex-shrink: 0; margin-left: 12px; }
|
||||
.toggle {
|
||||
width: 84px; height: 48px;
|
||||
border-radius: $r-pill;
|
||||
padding: 4px;
|
||||
position: relative;
|
||||
transition: background 0.3s;
|
||||
&.on { background: $pri; }
|
||||
&.off { background: $bd; }
|
||||
}
|
||||
.toggle-dot {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 50%;
|
||||
background: $card;
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
transition: left 0.3s;
|
||||
.toggle.on & { left: 40px; }
|
||||
.toggle.off & { left: 4px; }
|
||||
}
|
||||
.delete-btn {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $dan;
|
||||
padding: 14px 16px;
|
||||
min-height: 48px;
|
||||
@include flex-center;
|
||||
}
|
||||
.form-card { background: $card; border-radius: $r; padding: 28px; margin-top: 24px; box-shadow: $shadow-sm; }
|
||||
.form-card-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
.form-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
&:last-of-type { border-bottom: none; }
|
||||
}
|
||||
.form-label { font-size: var(--tk-font-body-lg); color: $tx; flex-shrink: 0; width: 160px; }
|
||||
.form-input { flex: 1; font-size: var(--tk-font-body-lg); color: $tx; text-align: right; border: none; background: transparent; outline: none; }
|
||||
.form-placeholder { color: $tx3; }
|
||||
.time-picker-wrap { flex: 1; display: flex; align-items: center; justify-content: flex-end; gap: 12px; }
|
||||
.time-value { @include serif-number; font-size: var(--tk-font-body-lg); color: $tx; }
|
||||
.time-modify { font-size: var(--tk-font-h2); color: $pri; }
|
||||
.form-actions { display: flex; gap: 16px; margin-top: 24px; }
|
||||
.form-cancel { flex: 1; background: $bd-l; border-radius: $r-sm; padding: 20px; text-align: center; }
|
||||
.form-cancel-text { font-size: var(--tk-font-body-lg); color: $tx2; }
|
||||
.form-confirm { flex: 1; background: $pri; border-radius: $r-sm; padding: 20px; text-align: center; }
|
||||
.form-confirm-text { font-size: var(--tk-font-body-lg); color: $white; font-weight: bold; }
|
||||
.add-btn {
|
||||
position: fixed;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
background: $pri;
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
box-shadow: 0 -2px 12px rgba(196, 98, 58, 0.15);
|
||||
}
|
||||
.add-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
color: $white;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<view :class="['my-reports-page', elderClass]">
|
||||
<text class="page-title">检查报告</text>
|
||||
|
||||
<view class="report-list">
|
||||
<view
|
||||
v-for="r in reports" :key="r.id"
|
||||
class="report-card"
|
||||
@tap="goToDetail(r.id)"
|
||||
>
|
||||
<view class="report-card-top">
|
||||
<view class="report-type-row">
|
||||
<view class="report-avatar">
|
||||
<text class="report-avatar-text">{{ typeInitial(r.report_type) }}</text>
|
||||
</view>
|
||||
<text class="report-type">{{ r.report_type }}</text>
|
||||
</view>
|
||||
<text :class="['report-status', formatStatus(r)]">
|
||||
{{ formatStatus(r) === 'normal' ? '正常' : formatStatus(r) === 'abnormal' ? '异常' : '未知' }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="report-date">{{ r.report_date }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<EmptyState
|
||||
v-if="reports.length === 0 && !loading"
|
||||
:text="authStore.currentPatient ? '暂无报告记录' : '请先在就诊人管理中选择就诊人'"
|
||||
/>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { listReports, type LabReport } from '@/services/report'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const reports = ref<LabReport[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchData = async (p: number, append = false) => {
|
||||
if (!authStore.currentPatient) {
|
||||
reports.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listReports(authStore.currentPatient.id, p)
|
||||
const list = res.data || []
|
||||
reports.value = append ? [...reports.value, ...list] : list
|
||||
total.value = res.total
|
||||
page.value = p
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!loading.value && reports.value.length < total.value) {
|
||||
fetchData(page.value + 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
const goToDetail = (id: string) => {
|
||||
uni.navigateTo({ url: `/pages-sub/report/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
const formatStatus = (report: LabReport) => {
|
||||
const indicators = report.indicators
|
||||
if (!indicators || typeof indicators !== 'object') return 'unknown'
|
||||
const vals = Object.values(indicators) as Array<{ status?: string }>
|
||||
const hasAbnormal = vals.some((v) => v.status === 'high' || v.status === 'low')
|
||||
return hasAbnormal ? 'abnormal' : 'normal'
|
||||
}
|
||||
|
||||
const typeInitial = (type: string) => {
|
||||
return type ? type.charAt(0) : '报'
|
||||
}
|
||||
|
||||
onShow(() => { fetchData(1) })
|
||||
onPullDownRefresh(() => { fetchData(1).finally(() => uni.stopPullDownRefresh()) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.my-reports-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 32px 24px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
.page-title { @include section-title; padding-left: 4px; }
|
||||
.report-list { display: flex; flex-direction: column; gap: 16px; }
|
||||
.report-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 28px;
|
||||
box-shadow: $shadow-sm;
|
||||
&:active { box-shadow: $shadow-md; }
|
||||
}
|
||||
.report-card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.report-type-row { display: flex; align-items: center; }
|
||||
.report-avatar {
|
||||
@include flex-center;
|
||||
width: 56px; height: 56px;
|
||||
border-radius: $r-sm;
|
||||
background: $pri-l;
|
||||
margin-right: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.report-avatar-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $pri-d;
|
||||
}
|
||||
.report-type {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
.report-status {
|
||||
@include tag($bd-l, $tx2);
|
||||
&.normal { @include tag($acc-l, $acc); }
|
||||
&.abnormal { @include tag($dan-l, $dan); }
|
||||
}
|
||||
.report-date {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
padding-left: 72px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<scroll-view scroll-y class="page-scroll">
|
||||
<view :class="['settings-page', elderClass]">
|
||||
<text class="page-title">设置</text>
|
||||
|
||||
<view class="settings-group">
|
||||
<view class="settings-item" @tap="handleClearCache">
|
||||
<view class="settings-icon">
|
||||
<text class="settings-icon-text">缓</text>
|
||||
</view>
|
||||
<text class="settings-label">清除缓存</text>
|
||||
<text class="settings-arrow">></text>
|
||||
</view>
|
||||
<view class="settings-item" @tap="handleAbout">
|
||||
<view class="settings-icon">
|
||||
<text class="settings-icon-text">关</text>
|
||||
</view>
|
||||
<text class="settings-label">关于我们</text>
|
||||
<text class="settings-arrow">></text>
|
||||
</view>
|
||||
<view class="settings-item" @tap="handlePrivacy">
|
||||
<view class="settings-icon">
|
||||
<text class="settings-icon-text">隐</text>
|
||||
</view>
|
||||
<text class="settings-label">隐私政策</text>
|
||||
<text class="settings-arrow">></text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="settings-group">
|
||||
<view class="settings-item logout-item" @tap="handleLogout">
|
||||
<text class="settings-label logout-label">退出登录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { clearRequestCache } from '@/services/request'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
function handleClearCache() {
|
||||
uni.showModal({
|
||||
title: '清除缓存',
|
||||
content: '确定要清除本地缓存数据吗?不会影响账号信息。',
|
||||
success: (res: any) => {
|
||||
if (res.confirm) {
|
||||
const preservedKeys = [
|
||||
'access_token', 'refresh_token', 'user_data', 'user_roles',
|
||||
'tenant_id', 'wechat_openid', 'current_patient', 'current_patient_id',
|
||||
]
|
||||
const preservedData: Record<string, unknown> = {}
|
||||
for (const key of preservedKeys) {
|
||||
const val = uni.getStorageSync(key)
|
||||
if (val) preservedData[key] = val
|
||||
}
|
||||
|
||||
try { uni.clearStorageSync() } catch { /* ignore */ }
|
||||
|
||||
for (const [key, val] of Object.entries(preservedData)) {
|
||||
uni.setStorageSync(key, val)
|
||||
}
|
||||
|
||||
clearRequestCache()
|
||||
uni.showToast({ title: '缓存已清除', icon: 'success' })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleAbout() {
|
||||
uni.showModal({
|
||||
title: '关于我们',
|
||||
content: 'HMS 健康管理平台 v1.0.0\n为您的健康保驾护航',
|
||||
showCancel: false,
|
||||
})
|
||||
}
|
||||
|
||||
function handlePrivacy() {
|
||||
uni.navigateTo({ url: '/pages/legal/privacy-policy' })
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
uni.showModal({
|
||||
title: '退出登录',
|
||||
content: '确定要退出登录吗?',
|
||||
success: (res: any) => {
|
||||
if (res.confirm) {
|
||||
authStore.logout()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.settings-page {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@include section-title;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.settings-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28px 24px;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.logout-item {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-icon {
|
||||
@include flex-center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: $r-sm;
|
||||
background: $pri-l;
|
||||
margin-right: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-icon-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-h2);
|
||||
font-weight: bold;
|
||||
color: $pri-d;
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
flex: 1;
|
||||
font-size: var(--tk-font-num);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.logout-label {
|
||||
color: $dan;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.settings-arrow {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<view :class="['detail-page', elderClass]">
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
<view v-else-if="!report" class="empty-wrap"><text class="empty-text">报告不存在</text></view>
|
||||
<template v-else>
|
||||
<view class="detail-card">
|
||||
<text class="detail-title">{{ report.report_type }}</text>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">报告日期</text>
|
||||
<text class="detail-value">{{ report.report_date }}</text>
|
||||
</view>
|
||||
<view v-if="report.doctor_interpretation" class="detail-row">
|
||||
<text class="detail-label">医生解读</text>
|
||||
<text class="detail-value">{{ report.doctor_interpretation }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="indicators-card">
|
||||
<text class="section-title">检查指标</text>
|
||||
<view v-for="item in indicators" :key="item.name" class="indicator-item">
|
||||
<view class="indicator-left">
|
||||
<text class="indicator-name">{{ item.name }}</text>
|
||||
<text class="indicator-value">{{ item.value }}{{ item.unit ? ` ${item.unit}` : '' }}</text>
|
||||
</view>
|
||||
<view class="indicator-right">
|
||||
<text v-if="item.reference_min != null && item.reference_max != null" class="indicator-ref">
|
||||
{{ item.reference_min }}~{{ item.reference_max }}
|
||||
</text>
|
||||
<text :class="['indicator-status', getStatusInfo(item.status).className]">
|
||||
{{ getStatusInfo(item.status).text }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { getReportDetail, type LabReport } from '@/services/report'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
interface IndicatorItem { name: string; value: number; unit?: string; reference_min?: number; reference_max?: number; status?: string }
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const report = ref<LabReport | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const indicators = computed<IndicatorItem[]>(() => {
|
||||
if (!report.value?.indicators || typeof report.value.indicators !== 'object') return []
|
||||
return Object.entries(report.value.indicators).map(([name, val]) => ({ name, value: val.value, unit: val.unit, reference_min: val.reference_min, reference_max: val.reference_max, status: val.status }))
|
||||
})
|
||||
|
||||
const getStatusInfo = (status?: string) => {
|
||||
if (status === 'high') return { text: '偏高', className: 'high' }
|
||||
if (status === 'low') return { text: '偏低', className: 'low' }
|
||||
return { text: '正常', className: 'normal' }
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
const id = query?.id || ''
|
||||
const patientId = uni.getStorageSync('current_patient_id') || ''
|
||||
if (!id || !patientId) { loading.value = false; return }
|
||||
getReportDetail(patientId, id).then(data => { report.value = data }).catch(() => uni.showToast({ title: '加载失败', icon: 'none' })).finally(() => { loading.value = false })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page { min-height: 100vh; background: $bg; padding: 24px; }
|
||||
.empty-wrap { @include flex-center; padding: 120px 0; }
|
||||
.empty-text { font-size: var(--tk-font-body); color: $tx3; }
|
||||
.detail-card { @include card; margin-bottom: 16px; }
|
||||
.detail-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; display: block; margin-bottom: 12px; }
|
||||
.detail-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid rgba(0,0,0,0.04); }
|
||||
.detail-row:last-child { border-bottom: none; }
|
||||
.detail-label { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
.detail-value { font-size: var(--tk-font-body); color: $tx; flex: 1; text-align: right; }
|
||||
.indicators-card { @include card; }
|
||||
.section-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; margin-bottom: 12px; display: block; }
|
||||
.indicator-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid rgba(0,0,0,0.04); }
|
||||
.indicator-item:last-child { border-bottom: none; }
|
||||
.indicator-left { flex: 1; }
|
||||
.indicator-name { font-size: var(--tk-font-cap); color: $tx2; display: block; }
|
||||
.indicator-value { font-size: var(--tk-font-body); color: $tx; display: block; margin-top: 2px; }
|
||||
.indicator-right { text-align: right; flex-shrink: 0; }
|
||||
.indicator-ref { font-size: var(--tk-font-micro); color: $tx3; display: block; }
|
||||
.indicator-status { font-size: var(--tk-font-cap); display: block; margin-top: 2px; }
|
||||
.indicator-status.high { color: $wrn; }
|
||||
.indicator-status.low { color: $info; }
|
||||
.indicator-status.normal { color: $acc; }
|
||||
</style>
|
||||
150
apps/miniprogram-uniapp/src/pages.json
Normal file
150
apps/miniprogram-uniapp/src/pages.json
Normal file
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"pages": [
|
||||
{ "path": "pages/index/index", "style": { "navigationBarTitleText": "健康管理" } },
|
||||
{ "path": "pages/login/index", "style": { "navigationBarTitleText": "登录" } },
|
||||
{ "path": "pages/health/index", "style": { "navigationBarTitleText": "健康数据" } },
|
||||
{ "path": "pages/messages/index", "style": { "navigationBarTitleText": "消息" } },
|
||||
{ "path": "pages/profile/index", "style": { "navigationBarTitleText": "我的" } },
|
||||
{ "path": "pages/legal/user-agreement", "style": { "navigationBarTitleText": "用户协议" } },
|
||||
{ "path": "pages/legal/privacy-policy", "style": { "navigationBarTitleText": "隐私政策" } }
|
||||
],
|
||||
"subPackages": [
|
||||
{
|
||||
"root": "pages-sub/consultation",
|
||||
"pages": [
|
||||
{ "path": "index", "style": { "navigationBarTitleText": "咨询列表" } },
|
||||
{ "path": "detail/index", "style": { "navigationBarTitleText": "咨询详情" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/mall",
|
||||
"pages": [
|
||||
{ "path": "index", "style": { "navigationBarTitleText": "积分商城" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/appointment",
|
||||
"pages": [
|
||||
{ "path": "index", "style": { "navigationBarTitleText": "预约列表" } },
|
||||
{ "path": "create/index", "style": { "navigationBarTitleText": "创建预约" } },
|
||||
{ "path": "detail/index", "style": { "navigationBarTitleText": "预约详情" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/pkg-health",
|
||||
"pages": [
|
||||
{ "path": "trend/index", "style": { "navigationBarTitleText": "健康趋势" } },
|
||||
{ "path": "input/index", "style": { "navigationBarTitleText": "健康录入" } },
|
||||
{ "path": "daily-monitoring/index", "style": { "navigationBarTitleText": "每日监测" } },
|
||||
{ "path": "alerts/index", "style": { "navigationBarTitleText": "健康告警" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/article",
|
||||
"pages": [
|
||||
{ "path": "index", "style": { "navigationBarTitleText": "健康文章" } },
|
||||
{ "path": "detail/index", "style": { "navigationBarTitleText": "文章详情" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/doctor",
|
||||
"pages": [
|
||||
{ "path": "index", "style": { "navigationBarTitleText": "医生工作台" } },
|
||||
{ "path": "patients/index", "style": { "navigationBarTitleText": "患者列表" } },
|
||||
{ "path": "patients/detail/index", "style": { "navigationBarTitleText": "患者详情" } },
|
||||
{ "path": "consultation/index", "style": { "navigationBarTitleText": "咨询管理" } },
|
||||
{ "path": "consultation/detail/index", "style": { "navigationBarTitleText": "咨询详情" } },
|
||||
{ "path": "followup/index", "style": { "navigationBarTitleText": "随访列表" } },
|
||||
{ "path": "followup/detail/index", "style": { "navigationBarTitleText": "随访详情" } },
|
||||
{ "path": "report/index", "style": { "navigationBarTitleText": "报告列表" } },
|
||||
{ "path": "report/detail/index", "style": { "navigationBarTitleText": "报告详情" } },
|
||||
{ "path": "alerts/index", "style": { "navigationBarTitleText": "告警列表" } },
|
||||
{ "path": "alerts/detail/index", "style": { "navigationBarTitleText": "告警详情" } },
|
||||
{ "path": "action-inbox/index", "style": { "navigationBarTitleText": "待办事项" } },
|
||||
{ "path": "dialysis/index", "style": { "navigationBarTitleText": "透析列表" } },
|
||||
{ "path": "dialysis/detail/index", "style": { "navigationBarTitleText": "透析详情" } },
|
||||
{ "path": "dialysis/create/index", "style": { "navigationBarTitleText": "创建透析" } },
|
||||
{ "path": "prescription/index", "style": { "navigationBarTitleText": "处方列表" } },
|
||||
{ "path": "prescription/detail/index", "style": { "navigationBarTitleText": "处方详情" } },
|
||||
{ "path": "prescription/create/index", "style": { "navigationBarTitleText": "创建处方" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/pkg-mall",
|
||||
"pages": [
|
||||
{ "path": "exchange/index", "style": { "navigationBarTitleText": "积分兑换" } },
|
||||
{ "path": "orders/index", "style": { "navigationBarTitleText": "我的订单" } },
|
||||
{ "path": "detail/index", "style": { "navigationBarTitleText": "商品详情" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/pkg-profile",
|
||||
"pages": [
|
||||
{ "path": "family/index", "style": { "navigationBarTitleText": "家庭成员" } },
|
||||
{ "path": "family-add/index", "style": { "navigationBarTitleText": "添加成员" } },
|
||||
{ "path": "reports/index", "style": { "navigationBarTitleText": "报告列表" } },
|
||||
{ "path": "followups/index", "style": { "navigationBarTitleText": "随访记录" } },
|
||||
{ "path": "medication/index", "style": { "navigationBarTitleText": "用药管理" } },
|
||||
{ "path": "settings/index", "style": { "navigationBarTitleText": "设置" } },
|
||||
{ "path": "dialysis-records/index", "style": { "navigationBarTitleText": "透析记录" } },
|
||||
{ "path": "dialysis-records/detail/index", "style": { "navigationBarTitleText": "透析详情" } },
|
||||
{ "path": "dialysis-prescriptions/index", "style": { "navigationBarTitleText": "透析处方" } },
|
||||
{ "path": "dialysis-prescriptions/detail/index", "style": { "navigationBarTitleText": "处方详情" } },
|
||||
{ "path": "consents/index", "style": { "navigationBarTitleText": "知情同意书" } },
|
||||
{ "path": "health-records/index", "style": { "navigationBarTitleText": "健康档案" } },
|
||||
{ "path": "diagnoses/index", "style": { "navigationBarTitleText": "诊断记录" } },
|
||||
{ "path": "elder-mode/index", "style": { "navigationBarTitleText": "长者模式" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/ai-report",
|
||||
"pages": [
|
||||
{ "path": "list/index", "style": { "navigationBarTitleText": "AI 分析" } },
|
||||
{ "path": "detail/index", "style": { "navigationBarTitleText": "分析详情" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/report",
|
||||
"pages": [
|
||||
{ "path": "detail/index", "style": { "navigationBarTitleText": "报告详情" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/followup",
|
||||
"pages": [
|
||||
{ "path": "detail/index", "style": { "navigationBarTitleText": "随访详情" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/events",
|
||||
"pages": [
|
||||
{ "path": "index", "style": { "navigationBarTitleText": "活动列表" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/device-sync",
|
||||
"pages": [
|
||||
{ "path": "index", "style": { "navigationBarTitleText": "设备同步" } }
|
||||
]
|
||||
}
|
||||
],
|
||||
"tabBar": {
|
||||
"color": "#A8A29E",
|
||||
"selectedColor": "#C4623A",
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"borderStyle": "white",
|
||||
"list": [
|
||||
{ "pagePath": "pages/index/index", "text": "首页", "iconPath": "static/tabbar/home.png", "selectedIconPath": "static/tabbar/home-active.png" },
|
||||
{ "pagePath": "pages/health/index", "text": "健康", "iconPath": "static/tabbar/health.png", "selectedIconPath": "static/tabbar/health-active.png" },
|
||||
{ "pagePath": "pages/messages/index", "text": "消息", "iconPath": "static/tabbar/message.png", "selectedIconPath": "static/tabbar/message-active.png" },
|
||||
{ "pagePath": "pages/profile/index", "text": "我的", "iconPath": "static/tabbar/profile.png", "selectedIconPath": "static/tabbar/profile-active.png" }
|
||||
]
|
||||
},
|
||||
"globalStyle": {
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "健康管理",
|
||||
"backgroundColor": "#F5F0EB",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
}
|
||||
156
apps/miniprogram-uniapp/src/pages/health/index.vue
Normal file
156
apps/miniprogram-uniapp/src/pages/health/index.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<scroll-view scroll-y class="health-scroll">
|
||||
<view :class="['health-page', elderClass]">
|
||||
<GuestGuard>
|
||||
<!-- 健康数据页 -->
|
||||
<text class="page-title">健康数据</text>
|
||||
|
||||
<Loading v-if="loading" text="加载中..." />
|
||||
|
||||
<!-- 体征录入卡片 -->
|
||||
<view v-if="!loading" class="card input-card">
|
||||
<text class="section-title">今日体征</text>
|
||||
<view class="vital-grid">
|
||||
<view v-for="item in vitalItems" :key="item.key" class="vital-item" @tap="goInput(item.key)">
|
||||
<text class="vital-icon">{{ item.icon }}</text>
|
||||
<text class="vital-name">{{ item.name }}</text>
|
||||
<text class="vital-value">{{ latestVitals[item.key] || '--' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 健康趋势入口 -->
|
||||
<view v-if="!loading" class="card" @tap="navigateTo('/pages-sub/pkg-health/trend/index')">
|
||||
<view class="trend-entry">
|
||||
<text class="trend-label">查看健康趋势</text>
|
||||
<text class="trend-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</GuestGuard>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { getTodaySummary } from '@/services/health'
|
||||
import GuestGuard from '@/components/GuestGuard.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const vitalItems = [
|
||||
{ key: 'heart_rate', name: '心率', icon: '❤️' },
|
||||
{ key: 'blood_pressure', name: '血压', icon: '🩸' },
|
||||
{ key: 'blood_sugar', name: '血糖', icon: '🍬' },
|
||||
{ key: 'temperature', name: '体温', icon: '🌡️' },
|
||||
{ key: 'weight', name: '体重', icon: '⚖️' },
|
||||
{ key: 'oxygen', name: '血氧', icon: '🫁' },
|
||||
]
|
||||
|
||||
const latestVitals = reactive<Record<string, string>>({})
|
||||
|
||||
function navigateTo(url: string) {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
function goInput(key: string) {
|
||||
uni.navigateTo({ url: `/pages-sub/pkg-health/input/index?type=${key}` })
|
||||
}
|
||||
|
||||
async function fetchLatestVitals() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getTodaySummary()
|
||||
if (data) {
|
||||
Object.assign(latestVitals, data)
|
||||
}
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchLatestVitals)
|
||||
onShow(() => { authStore.restore() })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.health-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.health-page {
|
||||
padding: 28px 24px 120px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.input-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vital-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.vital-item {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
background: $pri-surface;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
.vital-icon {
|
||||
font-size: var(--tk-font-h2);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vital-name {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
@include serif-number;
|
||||
}
|
||||
|
||||
.trend-entry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-height: $touch-min;
|
||||
}
|
||||
|
||||
.trend-label {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.trend-arrow {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx3;
|
||||
}
|
||||
</style>
|
||||
317
apps/miniprogram-uniapp/src/pages/index/index.vue
Normal file
317
apps/miniprogram-uniapp/src/pages/index/index.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<scroll-view scroll-y class="home-scroll" @scrolltolower="onLoadMore">
|
||||
<view :class="['home-page', elderClass]">
|
||||
<!-- 已登录模式 -->
|
||||
<template v-if="authStore.user">
|
||||
<!-- 用户问候 -->
|
||||
<view class="greeting-section">
|
||||
<text class="greeting-text">{{ greeting }},{{ authStore.user.display_name || authStore.user.username }}</text>
|
||||
<text class="greeting-date">{{ today }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 快捷功能 -->
|
||||
<view class="quick-actions">
|
||||
<view class="action-item" @tap="navigateTo('/pages-sub/appointment/create/index')">
|
||||
<text class="action-icon">📅</text>
|
||||
<text class="action-label">预约</text>
|
||||
</view>
|
||||
<view class="action-item" @tap="navigateTo('/pages-sub/consultation/index')">
|
||||
<text class="action-icon">💬</text>
|
||||
<text class="action-label">咨询</text>
|
||||
</view>
|
||||
<view class="action-item" @tap="navigateTo('/pages-sub/pkg-health/trend/index')">
|
||||
<text class="action-icon">📊</text>
|
||||
<text class="action-label">趋势</text>
|
||||
</view>
|
||||
<view class="action-item" @tap="navigateTo('/pages-sub/article/index')">
|
||||
<text class="action-icon">📰</text>
|
||||
<text class="action-label">文章</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 健康概览卡片 -->
|
||||
<view class="health-summary card">
|
||||
<text class="section-title">健康概览</text>
|
||||
<view v-if="healthSummary" class="summary-grid">
|
||||
<view class="summary-item">
|
||||
<text class="summary-value">{{ healthSummary.heart_rate || '--' }}</text>
|
||||
<text class="summary-label">心率</text>
|
||||
</view>
|
||||
<view class="summary-item">
|
||||
<text class="summary-value">{{ healthSummary.blood_pressure || '--' }}</text>
|
||||
<text class="summary-label">血压</text>
|
||||
</view>
|
||||
<view class="summary-item">
|
||||
<text class="summary-value">{{ healthSummary.blood_sugar || '--' }}</text>
|
||||
<text class="summary-label">血糖</text>
|
||||
</view>
|
||||
</view>
|
||||
<Loading v-else-if="summaryLoading" text="加载中..." />
|
||||
<EmptyState v-else icon="📋" title="暂无健康数据" action-text="录入数据" @action="switchTab('/pages/health/index')" />
|
||||
</view>
|
||||
|
||||
<!-- 最近文章 -->
|
||||
<view class="articles-section card">
|
||||
<text class="section-title">健康文章</text>
|
||||
<Loading v-if="articlesLoading" text="加载中..." />
|
||||
<template v-else-if="articles.length > 0">
|
||||
<view v-for="article in articles" :key="article.id" class="article-entry" @tap="goArticle(article.id)">
|
||||
<text class="article-title">{{ article.title }}</text>
|
||||
<text class="article-date">{{ formatDate(article.created_at) }}</text>
|
||||
</view>
|
||||
</template>
|
||||
<EmptyState v-else icon="📰" title="暂无文章" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 访客模式 -->
|
||||
<template v-else>
|
||||
<view class="guest-page">
|
||||
<text class="guest-hero-icon">🏥</text>
|
||||
<text class="guest-hero-title">健康管理</text>
|
||||
<text class="guest-hero-desc">您的专属健康管家</text>
|
||||
<view class="guest-login-btn" @tap="navigateTo('/pages/login/index')">
|
||||
登录 / 注册
|
||||
</view>
|
||||
<text class="guest-browse">浏览健康资讯</text>
|
||||
|
||||
<!-- 访客也展示文章 -->
|
||||
<view class="articles-section card">
|
||||
<text class="section-title">健康文章</text>
|
||||
<Loading v-if="articlesLoading" text="加载中..." />
|
||||
<template v-else-if="articles.length > 0">
|
||||
<view v-for="article in articles" :key="article.id" class="article-entry" @tap="goArticle(article.id)">
|
||||
<text class="article-title">{{ article.title }}</text>
|
||||
<text class="article-date">{{ formatDate(article.created_at) }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { getArticles } from '@/services/article'
|
||||
import { getTodaySummary } from '@/services/health'
|
||||
import { formatDate } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const articlesLoading = ref(false)
|
||||
const summaryLoading = ref(false)
|
||||
const articles = ref<any[]>([])
|
||||
const healthSummary = ref<any>(null)
|
||||
|
||||
const greeting = computed(() => {
|
||||
const h = new Date().getHours()
|
||||
if (h < 6) return '夜深了'
|
||||
if (h < 12) return '早上好'
|
||||
if (h < 14) return '中午好'
|
||||
if (h < 18) return '下午好'
|
||||
return '晚上好'
|
||||
})
|
||||
|
||||
const today = computed(() => formatDate(new Date(), 'YYYY年MM月DD日'))
|
||||
|
||||
function navigateTo(url: string) {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
function switchTab(url: string) {
|
||||
uni.switchTab({ url })
|
||||
}
|
||||
|
||||
function goArticle(id: string) {
|
||||
uni.navigateTo({ url: `/pages-sub/article/detail/index?id=${id}` })
|
||||
}
|
||||
|
||||
function onLoadMore() {
|
||||
// 分页加载预留
|
||||
}
|
||||
|
||||
async function fetchArticles() {
|
||||
articlesLoading.value = true
|
||||
try {
|
||||
const res = await getArticles({ limit: 5 })
|
||||
articles.value = res || []
|
||||
} catch {
|
||||
articles.value = []
|
||||
}
|
||||
articlesLoading.value = false
|
||||
}
|
||||
|
||||
async function fetchHealthSummary() {
|
||||
if (!authStore.user) return
|
||||
summaryLoading.value = true
|
||||
try {
|
||||
healthSummary.value = await getTodaySummary()
|
||||
} catch {
|
||||
healthSummary.value = null
|
||||
}
|
||||
summaryLoading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchArticles()
|
||||
fetchHealthSummary()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
authStore.restore()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.home-page {
|
||||
padding: 28px 24px 120px;
|
||||
}
|
||||
|
||||
.greeting-section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.greeting-text {
|
||||
display: block;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.greeting-date {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
padding: 20px 0;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: var(--tk-font-hero);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
@include serif-number;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.article-entry {
|
||||
padding: 16px 0;
|
||||
min-height: $touch-min;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.article-title {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.article-date {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
// 访客模式
|
||||
.guest-page {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
.guest-hero-icon {
|
||||
font-size: var(--tk-font-display);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.guest-hero-title {
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.guest-hero-desc {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.guest-login-btn {
|
||||
@include btn-primary;
|
||||
width: 280px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.guest-browse {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $pri;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
</style>
|
||||
41
apps/miniprogram-uniapp/src/pages/legal/privacy-policy.vue
Normal file
41
apps/miniprogram-uniapp/src/pages/legal/privacy-policy.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<scroll-view scroll-y class="legal-page">
|
||||
<rich-text class="legal-content" :nodes="PRIVACY_CONTENT" />
|
||||
<view class="legal-footer">
|
||||
<text class="legal-footer-text">如有疑问,请联系客服</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const PRIVACY_CONTENT = `
|
||||
<h3>隐私政策</h3>
|
||||
<p>更新日期:2026年4月24日</p>
|
||||
<p>生效日期:2026年4月24日</p>
|
||||
<h4>一、我们收集的信息</h4>
|
||||
<p>1. <b>注册信息</b>:微信授权获取的 openid、手机号码</p>
|
||||
<p>2. <b>健康数据</b>:您主动录入的血压、血糖、心率、体重等健康指标</p>
|
||||
<p>3. <b>就诊信息</b>:预约记录、就诊人信息、体检报告</p>
|
||||
<p>4. <b>设备信息</b>:设备型号、操作系统版本</p>
|
||||
<h4>二、信息使用目的</h4>
|
||||
<p>1. 提供健康数据记录和趋势分析</p>
|
||||
<p>2. 预约挂号和就诊管理</p>
|
||||
<p>3. 个性化健康建议</p>
|
||||
<p>4. 改进服务质量</p>
|
||||
<h4>三、信息保护</h4>
|
||||
<p>我们采用 AES-256 加密存储敏感数据,严格限制数据访问权限。</p>
|
||||
<h4>四、信息共享</h4>
|
||||
<p>未经您的同意,我们不会与第三方共享您的个人信息,法律法规要求除外。</p>
|
||||
<h4>五、您的权利</h4>
|
||||
<p>您有权查询、更正、删除您的个人信息。</p>
|
||||
<h4>六、隐私政策更新</h4>
|
||||
<p>我们可能会不时更新本隐私政策。更新后的政策将在平台上公布。</p>
|
||||
`
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.legal-page { height: 100vh; background: $bg; padding: 24px; box-sizing: border-box; }
|
||||
.legal-content { font-size: var(--tk-font-body); line-height: var(--tk-line-height); color: $tx; }
|
||||
.legal-footer { margin-top: 40px; text-align: center; }
|
||||
.legal-footer-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
</style>
|
||||
42
apps/miniprogram-uniapp/src/pages/legal/user-agreement.vue
Normal file
42
apps/miniprogram-uniapp/src/pages/legal/user-agreement.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<scroll-view scroll-y class="legal-page">
|
||||
<rich-text class="legal-content" :nodes="AGREEMENT_CONTENT" />
|
||||
<view class="legal-footer">
|
||||
<text class="legal-footer-text">如有疑问,请联系客服</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const AGREEMENT_CONTENT = `
|
||||
<h3>用户服务协议</h3>
|
||||
<p>更新日期:2026年4月24日</p>
|
||||
<p>生效日期:2026年4月24日</p>
|
||||
<h4>一、服务条款的确认和接纳</h4>
|
||||
<p>本平台由健康管理平台运营。用户在注册及使用本平台服务前,请务必仔细阅读并充分理解本协议。</p>
|
||||
<h4>二、服务内容</h4>
|
||||
<p>本平台为用户提供以下健康管理服务:</p>
|
||||
<p>1. 健康数据记录与查看(血压、血糖、心率等)</p>
|
||||
<p>2. 在线预约挂号</p>
|
||||
<p>3. 体检报告查看</p>
|
||||
<p>4. 随访管理</p>
|
||||
<p>5. 健康资讯阅读</p>
|
||||
<p>6. 用药提醒</p>
|
||||
<h4>三、用户账号</h4>
|
||||
<p>用户通过微信授权方式注册账号。用户应妥善保管账号信息,因用户保管不善造成的损失由用户自行承担。</p>
|
||||
<h4>四、隐私保护</h4>
|
||||
<p>我们重视用户隐私保护,具体隐私政策请参阅《隐私政策》。</p>
|
||||
<h4>五、免责声明</h4>
|
||||
<p>1. 本平台提供的健康数据仅供参考,不构成医疗诊断建议。如有健康问题,请及时就医。</p>
|
||||
<p>2. 因不可抗力导致的服务中断,本平台不承担责任。</p>
|
||||
<h4>六、协议修改</h4>
|
||||
<p>本平台有权根据需要修改本协议条款,修改后的协议一经公布即替代原协议。</p>
|
||||
`
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.legal-page { height: 100vh; background: $bg; padding: 24px; box-sizing: border-box; }
|
||||
.legal-content { font-size: var(--tk-font-body); line-height: var(--tk-line-height); color: $tx; }
|
||||
.legal-footer { margin-top: 40px; text-align: center; }
|
||||
.legal-footer-text { font-size: var(--tk-font-cap); color: $tx3; }
|
||||
</style>
|
||||
240
apps/miniprogram-uniapp/src/pages/login/index.vue
Normal file
240
apps/miniprogram-uniapp/src/pages/login/index.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<scroll-view scroll-y :class="['login-scroll', elderClass]">
|
||||
<view class="login-page">
|
||||
<!-- 品牌区 -->
|
||||
<view class="login-brand">
|
||||
<view class="login-logo">
|
||||
<text class="login-logo-mark">+</text>
|
||||
</view>
|
||||
<text class="login-title">健康管理</text>
|
||||
<text class="login-subtitle">您的专属健康管家</text>
|
||||
</view>
|
||||
|
||||
<!-- 装饰线 -->
|
||||
<view class="login-divider">
|
||||
<view class="login-divider-line" />
|
||||
</view>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<view class="login-body">
|
||||
<button v-if="!needBind" class="login-btn" :loading="authStore.loading" @tap="handleWechatLogin">
|
||||
微信一键登录
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="login-btn"
|
||||
open-type="getPhoneNumber"
|
||||
@getphonenumber="handleGetPhone"
|
||||
>
|
||||
授权手机号完成绑定
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 协议 -->
|
||||
<view class="agreement-row">
|
||||
<view :class="['agreement-check', { checked: agreed }]" @tap="agreed = !agreed">
|
||||
<text v-if="agreed" class="agreement-check-mark">✓</text>
|
||||
</view>
|
||||
<text class="agreement-text">
|
||||
我已阅读并同意
|
||||
<text class="agreement-link" @tap="navigateTo('/pages/legal/user-agreement')">《用户服务协议》</text>
|
||||
和
|
||||
<text class="agreement-link" @tap="navigateTo('/pages/legal/privacy-policy')">《隐私政策》</text>
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 暂不登录 -->
|
||||
<view class="skip-row">
|
||||
<text class="skip-btn" @tap="uni.reLaunch({ url: '/pages/index/index' })">
|
||||
暂不登录,先看看
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const { elderClass } = useElderClass()
|
||||
const authStore = useAuthStore()
|
||||
const needBind = ref(false)
|
||||
const agreed = ref(false)
|
||||
|
||||
function navigateTo(url: string) {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
function navigateAfterLogin() {
|
||||
if (authStore.isMedicalStaff) {
|
||||
uni.reLaunch({ url: '/pages-sub/doctor/index' })
|
||||
} else {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWechatLogin() {
|
||||
if (!agreed.value) {
|
||||
uni.showToast({ title: '请先阅读并同意用户协议', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await uni.login()
|
||||
const result = await authStore.login(res.code)
|
||||
if (result) {
|
||||
navigateAfterLogin()
|
||||
} else {
|
||||
needBind.value = true
|
||||
uni.showToast({ title: '请授权手机号完成绑定', icon: 'none' })
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || '登录失败,请重试'
|
||||
uni.showToast({ title: msg.substring(0, 20), icon: 'none', duration: 3000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGetPhone(e: any) {
|
||||
if (!agreed.value) {
|
||||
uni.showToast({ title: '请先阅读并同意用户协议', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
|
||||
uni.showToast({ title: '需要授权手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const { encryptedData, iv } = e.detail
|
||||
try {
|
||||
const success = await authStore.bindPhone(encryptedData, iv)
|
||||
if (success) navigateAfterLogin()
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || '绑定失败'
|
||||
uni.showModal({
|
||||
title: '绑定手机号失败',
|
||||
content: msg,
|
||||
confirmText: '重新登录',
|
||||
success: (res: any) => {
|
||||
if (res.confirm) needBind.value = false
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
padding: 80px 48px 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 50%;
|
||||
background: $pri;
|
||||
@include flex-center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-logo-mark {
|
||||
font-size: var(--tk-font-hero);
|
||||
color: $white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.login-divider {
|
||||
width: 60%;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.login-divider-line {
|
||||
height: 1px;
|
||||
background: $bd;
|
||||
}
|
||||
|
||||
.login-body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
@include btn-primary;
|
||||
}
|
||||
|
||||
.agreement-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-top: 32px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.agreement-check {
|
||||
width: $touch-min;
|
||||
height: $touch-min;
|
||||
border-radius: 50%;
|
||||
border: 2px solid $bd;
|
||||
@include flex-center;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
|
||||
&.checked {
|
||||
background: $pri;
|
||||
border-color: $pri;
|
||||
}
|
||||
}
|
||||
|
||||
.agreement-check-mark {
|
||||
color: $white;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
}
|
||||
|
||||
.agreement-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.agreement-link {
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.skip-row {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.skip-btn {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
padding: 12px 24px;
|
||||
min-height: $touch-min;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
108
apps/miniprogram-uniapp/src/pages/messages/index.vue
Normal file
108
apps/miniprogram-uniapp/src/pages/messages/index.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<scroll-view scroll-y class="messages-scroll" @scrolltolower="loadMore">
|
||||
<view :class="['messages-page', elderClass]">
|
||||
<GuestGuard>
|
||||
<text class="page-title">消息</text>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<Loading v-if="loading && messages.length === 0" text="加载中..." />
|
||||
<EmptyState v-else-if="messages.length === 0" icon="📭" title="暂无消息" />
|
||||
<template v-else>
|
||||
<view v-for="msg in messages" :key="msg.id" class="msg-card" @tap="goDetail(msg)">
|
||||
<text class="msg-title">{{ msg.title }}</text>
|
||||
<text class="msg-content">{{ msg.content }}</text>
|
||||
<text class="msg-time">{{ getRelativeTime(msg.created_at) }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</GuestGuard>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
import { notificationService } from '@/services/notification'
|
||||
import { getRelativeTime } from '@/utils/date'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import GuestGuard from '@/components/GuestGuard.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
const loading = ref(false)
|
||||
const messages = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
|
||||
async function fetchMessages() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await notificationService.list({ page: page.value, page_size: 20 })
|
||||
messages.value = res || []
|
||||
} catch {
|
||||
messages.value = []
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
page.value++
|
||||
fetchMessages()
|
||||
}
|
||||
|
||||
function goDetail(msg: any) {
|
||||
if (msg.type === 'consultation') {
|
||||
uni.navigateTo({ url: `/pages-sub/consultation/detail/index?id=${msg.ref_id}` })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchMessages)
|
||||
onShow(() => { authStore.restore() })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.messages-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.messages-page {
|
||||
padding: 28px 24px 120px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.msg-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.msg-title {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
</style>
|
||||
231
apps/miniprogram-uniapp/src/pages/profile/index.vue
Normal file
231
apps/miniprogram-uniapp/src/pages/profile/index.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<scroll-view scroll-y class="profile-scroll">
|
||||
<view :class="['profile-page', elderClass]">
|
||||
<!-- 已登录 -->
|
||||
<template v-if="authStore.user">
|
||||
<!-- 用户信息卡片 -->
|
||||
<view class="profile-card">
|
||||
<view class="avatar">
|
||||
<text class="avatar-text">{{ (authStore.user.display_name || authStore.user.username || '?')[0] }}</text>
|
||||
</view>
|
||||
<text class="profile-name">{{ authStore.user.display_name || authStore.user.username }}</text>
|
||||
<text class="profile-phone">{{ authStore.user.phone || '' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 患者切换 -->
|
||||
<view v-if="authStore.patients.length > 0" class="card patient-card">
|
||||
<text class="section-title">就诊人</text>
|
||||
<view v-for="p in authStore.patients" :key="p.id"
|
||||
:class="['patient-item', { active: authStore.currentPatient?.id === p.id }]"
|
||||
@tap="authStore.setCurrentPatient(p)"
|
||||
>
|
||||
<text class="patient-name">{{ p.name }}</text>
|
||||
<text class="patient-relation">{{ p.relation }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 菜单 -->
|
||||
<view class="card menu-card">
|
||||
<view class="menu-item" @tap="navigateTo('/pages-sub/appointment/index')">
|
||||
<text class="menu-label">我的预约</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" @tap="navigateTo('/pages-sub/consultation/index')">
|
||||
<text class="menu-label">我的咨询</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" @tap="navigateTo('/pages-sub/mall/index')">
|
||||
<text class="menu-label">积分商城</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" @tap="navigateTo('/pages-sub/article/index')">
|
||||
<text class="menu-label">健康文章</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" @tap="uiStore.toggleElderMode()">
|
||||
<text class="menu-label">{{ uiStore.elderMode ? '退出关怀模式' : '关怀模式' }}</text>
|
||||
<text class="menu-arrow">{{ uiStore.elderMode ? '✓' : '›' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 退出登录 -->
|
||||
<view class="logout-btn" @tap="handleLogout">
|
||||
退出登录
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 未登录 -->
|
||||
<template v-else>
|
||||
<view class="guest-profile">
|
||||
<view class="avatar avatar-placeholder">
|
||||
<text class="avatar-text">?</text>
|
||||
</view>
|
||||
<text class="guest-text">未登录</text>
|
||||
<view class="guest-login-btn" @tap="navigateTo('/pages/login/index')">
|
||||
去登录
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { useElderClass } from '@/composables/useElderClass'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const uiStore = useUIStore()
|
||||
const { elderClass } = useElderClass()
|
||||
|
||||
function navigateTo(url: string) {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
uni.showModal({
|
||||
title: '确认退出',
|
||||
content: '确定要退出登录吗?',
|
||||
success: (res: any) => {
|
||||
if (res.confirm) authStore.logout()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
authStore.restore()
|
||||
if (authStore.user) authStore.loadPatients()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.profile-scroll {
|
||||
height: 100vh;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
.profile-page {
|
||||
padding: 28px 24px 120px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 36px 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
background: $pri;
|
||||
@include flex-center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
background: $bd;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: var(--tk-font-h2);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.profile-phone {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.card {
|
||||
@include card;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.patient-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 0;
|
||||
min-height: $touch-min;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
&.active {
|
||||
.patient-name { color: $pri; font-weight: 600; }
|
||||
}
|
||||
}
|
||||
|
||||
.patient-name {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.patient-relation {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 18px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
min-height: $menu-item-h;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.menu-arrow {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
@include btn-outline;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
// 未登录
|
||||
.guest-profile {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
padding: 80px 0 40px;
|
||||
}
|
||||
|
||||
.guest-text {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
margin: 16px 0 32px;
|
||||
}
|
||||
|
||||
.guest-login-btn {
|
||||
@include btn-primary;
|
||||
width: 200px;
|
||||
}
|
||||
</style>
|
||||
30
apps/miniprogram-uniapp/src/services/action-inbox.ts
Normal file
30
apps/miniprogram-uniapp/src/services/action-inbox.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { api } from './request'
|
||||
|
||||
export interface ActionItem {
|
||||
id: string; action_type: string; priority: string; status: string; title: string
|
||||
summary: string; patient_id: string; patient_name: string; source_ref: string
|
||||
created_at: string; updated_at: string
|
||||
}
|
||||
|
||||
export interface ThreadEvent {
|
||||
step: string; label: string; status: string; detail?: string
|
||||
timestamp?: string; link_to?: string
|
||||
}
|
||||
|
||||
export interface ActionDef {
|
||||
key: string; label: string; variant: string; api_endpoint?: string
|
||||
}
|
||||
|
||||
export interface ThreadResponse {
|
||||
action_item: ActionItem; thread: ThreadEvent[]; available_actions: ActionDef[]
|
||||
}
|
||||
|
||||
export async function listActionItems(params?: {
|
||||
status?: string; type?: string; page?: number; page_size?: number
|
||||
}) {
|
||||
return api.get<{ data: ActionItem[]; total: number }>('/health/action-inbox', params as Record<string, string | number | undefined>)
|
||||
}
|
||||
|
||||
export async function getActionThread(sourceRef: string) {
|
||||
return api.get<ThreadResponse>(`/health/action-inbox/${encodeURIComponent(sourceRef)}/thread`)
|
||||
}
|
||||
25
apps/miniprogram-uniapp/src/services/ai-analysis.ts
Normal file
25
apps/miniprogram-uniapp/src/services/ai-analysis.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { api } from './request'
|
||||
|
||||
export interface AiAnalysisItem {
|
||||
id: string; patient_id: string; analysis_type: string; model_used: string
|
||||
status: string; result_content: string | null; result_metadata: Record<string, unknown> | null
|
||||
error_message: string | null; created_at: string
|
||||
}
|
||||
|
||||
export async function listAiAnalysis(page = 1, pageSize = 20) {
|
||||
return api.get<{ data: AiAnalysisItem[]; total: number }>('/ai/analysis/history', { page, page_size: pageSize })
|
||||
}
|
||||
|
||||
export async function getAiAnalysisDetail(id: string) {
|
||||
return api.get<AiAnalysisItem>(`/ai/analysis/${id}`)
|
||||
}
|
||||
|
||||
export interface AiSuggestionItem {
|
||||
id: string; analysis_id: string; suggestion_type: string; risk_level: string
|
||||
params: Record<string, unknown> | null; status: string; created_at: string
|
||||
}
|
||||
|
||||
export async function listPendingSuggestions() {
|
||||
const resp = await api.get<{ data: AiSuggestionItem[]; total: number }>('/ai/suggestions', { status: 'pending' })
|
||||
return resp.data || []
|
||||
}
|
||||
13
apps/miniprogram-uniapp/src/services/alert.ts
Normal file
13
apps/miniprogram-uniapp/src/services/alert.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { api } from './request'
|
||||
|
||||
export interface Alert {
|
||||
id: string; severity: string; title: string; status: string
|
||||
detail?: Record<string, unknown>; created_at: string
|
||||
acknowledged_at?: string; resolved_at?: string
|
||||
}
|
||||
|
||||
export async function listPatientAlerts(patientId: string, params?: { status?: string; page?: number; page_size?: number }) {
|
||||
return api.get<{ data: Alert[]; total: number }>('/health/alerts', {
|
||||
patient_id: patientId, page: params?.page ?? 1, page_size: params?.page_size ?? 20, status: params?.status,
|
||||
})
|
||||
}
|
||||
72
apps/miniprogram-uniapp/src/services/analytics.ts
Normal file
72
apps/miniprogram-uniapp/src/services/analytics.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { api } from './request'
|
||||
import { secureGet } from '@/utils/secure-storage'
|
||||
|
||||
type EventName =
|
||||
| 'page_view'
|
||||
| 'login'
|
||||
| 'bind_phone'
|
||||
| 'health_data_input'
|
||||
| 'health_trend_view'
|
||||
| 'appointment_create'
|
||||
| 'appointment_detail'
|
||||
| 'followup_submit'
|
||||
| 'report_view'
|
||||
| 'article_view'
|
||||
| 'article_share'
|
||||
| 'medication_add'
|
||||
| 'family_add'
|
||||
| 'profile_edit'
|
||||
|
||||
interface AnalyticsEvent {
|
||||
event: EventName | string
|
||||
properties?: Record<string, unknown>
|
||||
timestamp: number
|
||||
userId?: string
|
||||
patientId?: string
|
||||
}
|
||||
|
||||
const QUEUE_KEY = 'analytics_queue'
|
||||
const MAX_QUEUE_SIZE = 50
|
||||
|
||||
function getQueue(): AnalyticsEvent[] {
|
||||
return uni.getStorageSync(QUEUE_KEY) || []
|
||||
}
|
||||
|
||||
function setQueue(queue: AnalyticsEvent[]): void {
|
||||
uni.setStorageSync(QUEUE_KEY, queue.slice(-MAX_QUEUE_SIZE))
|
||||
}
|
||||
|
||||
export function trackEvent(event: EventName | string, properties?: Record<string, unknown>): void {
|
||||
let userId: string | undefined
|
||||
try {
|
||||
const raw = secureGet('user_data')
|
||||
userId = raw ? JSON.parse(raw).id : undefined
|
||||
} catch { /* ignore */ }
|
||||
const patientId = uni.getStorageSync('current_patient_id')
|
||||
|
||||
const evt: AnalyticsEvent = { event, properties, timestamp: Date.now(), userId, patientId }
|
||||
const queue = getQueue()
|
||||
queue.push(evt)
|
||||
setQueue(queue)
|
||||
}
|
||||
|
||||
export function trackPageView(pageName: string, properties?: Record<string, unknown>): void {
|
||||
trackEvent('page_view', { page: pageName, ...properties })
|
||||
}
|
||||
|
||||
export async function flushEvents(): Promise<void> {
|
||||
const queue = getQueue()
|
||||
if (queue.length === 0) return
|
||||
const batch = queue.slice()
|
||||
setQueue([])
|
||||
try {
|
||||
await api.post('/analytics/batch', { events: batch })
|
||||
} catch {
|
||||
const current = getQueue()
|
||||
setQueue([...batch.slice(-MAX_QUEUE_SIZE + current.length), ...current])
|
||||
}
|
||||
}
|
||||
|
||||
export function getQueueSize(): number {
|
||||
return getQueue().length
|
||||
}
|
||||
21
apps/miniprogram-uniapp/src/services/appointment.ts
Normal file
21
apps/miniprogram-uniapp/src/services/appointment.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { api } from './request'
|
||||
|
||||
export async function getAppointments(params?: Record<string, string | number | undefined>) {
|
||||
return api.get<any[]>('/health/appointments', params)
|
||||
}
|
||||
|
||||
export async function createAppointment(data: any) {
|
||||
return api.post('/health/appointments', data)
|
||||
}
|
||||
|
||||
export async function getAppointment(id: string) {
|
||||
return api.get<any>(`/health/appointments/${id}`)
|
||||
}
|
||||
|
||||
export async function cancelAppointment(id: string, version: number) {
|
||||
return api.put(`/health/appointments/${id}/cancel`, { version })
|
||||
}
|
||||
|
||||
export async function getDoctors() {
|
||||
return api.get<any[]>('/health/doctors')
|
||||
}
|
||||
9
apps/miniprogram-uniapp/src/services/article.ts
Normal file
9
apps/miniprogram-uniapp/src/services/article.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { api } from './request'
|
||||
|
||||
export async function getArticles(params?: Record<string, string | number | undefined>) {
|
||||
return api.get<any[]>('/health/articles', params)
|
||||
}
|
||||
|
||||
export async function getArticleDetail(id: string) {
|
||||
return api.get<any>(`/health/articles/${id}`)
|
||||
}
|
||||
36
apps/miniprogram-uniapp/src/services/auth.ts
Normal file
36
apps/miniprogram-uniapp/src/services/auth.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { api } from './request'
|
||||
|
||||
export interface LoginResp {
|
||||
bound: boolean
|
||||
openid: string
|
||||
token?: {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
user: { id: string; username: string; display_name?: string; phone?: string; avatar_url?: string }
|
||||
}
|
||||
}
|
||||
|
||||
export interface PatientInfo {
|
||||
id: string
|
||||
name: string
|
||||
gender?: string
|
||||
birth_date?: string
|
||||
relation: string
|
||||
}
|
||||
|
||||
export async function wechatLogin(code: string): Promise<LoginResp> {
|
||||
return api.post('/auth/wechat/login', { code })
|
||||
}
|
||||
|
||||
export async function wechatBindPhone(openid: string, encryptedData: string, iv: string) {
|
||||
return api.post('/auth/wechat/bind-phone', {
|
||||
openid,
|
||||
encrypted_data: encryptedData,
|
||||
iv,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getPatients() {
|
||||
return api.get<PatientInfo[]>('/health/patients')
|
||||
}
|
||||
1
apps/miniprogram-uniapp/src/services/ble/index.ts
Normal file
1
apps/miniprogram-uniapp/src/services/ble/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { type BLEDevice, type NormalizedReading, type BLEAdapter } from './types'
|
||||
19
apps/miniprogram-uniapp/src/services/ble/types.ts
Normal file
19
apps/miniprogram-uniapp/src/services/ble/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface NormalizedReading {
|
||||
device_type: string
|
||||
values: Record<string, number>
|
||||
measured_at: string
|
||||
}
|
||||
|
||||
export interface BLEDevice {
|
||||
deviceId: string
|
||||
name?: string
|
||||
RSSI?: number
|
||||
}
|
||||
|
||||
export interface BLEAdapter {
|
||||
deviceType: string
|
||||
connect(deviceId: string): Promise<void>
|
||||
disconnect(): Promise<void>
|
||||
readData(): Promise<NormalizedReading[]>
|
||||
isConnected(): boolean
|
||||
}
|
||||
23
apps/miniprogram-uniapp/src/services/consent.ts
Normal file
23
apps/miniprogram-uniapp/src/services/consent.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { api } from './request'
|
||||
|
||||
export interface Consent {
|
||||
id: string; patient_id: string; consent_type: string; consent_scope: string
|
||||
status: string; granted_at?: string; revoked_at?: string; expiry_date?: string
|
||||
consent_method?: string; witness_name?: string; notes?: string
|
||||
created_at: string; updated_at: string; version: number
|
||||
}
|
||||
|
||||
export async function listConsents(patientId: string, params?: { page?: number; page_size?: number }) {
|
||||
return api.get<{ data: Consent[]; total: number }>(`/health/patients/${patientId}/consents`, params)
|
||||
}
|
||||
|
||||
export async function grantConsent(data: {
|
||||
patient_id: string; consent_type: string; consent_scope: string
|
||||
expiry_date?: string; consent_method?: string; witness_name?: string; notes?: string
|
||||
}) {
|
||||
return api.post<Consent>('/health/consents', data)
|
||||
}
|
||||
|
||||
export async function revokeConsent(id: string, version: number, notes?: string) {
|
||||
return api.put<Consent>(`/health/consents/${id}/revoke`, { version, notes })
|
||||
}
|
||||
13
apps/miniprogram-uniapp/src/services/consultation.ts
Normal file
13
apps/miniprogram-uniapp/src/services/consultation.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { api } from './request'
|
||||
|
||||
export async function getConsultations(params?: Record<string, string | number | undefined>) {
|
||||
return api.get<any[]>('/health/consultations', params)
|
||||
}
|
||||
|
||||
export async function getConsultationDetail(id: string) {
|
||||
return api.get<any>(`/health/consultations/${id}`)
|
||||
}
|
||||
|
||||
export async function sendMessage(consultationId: string, content: string) {
|
||||
return api.post(`/health/consultations/${consultationId}/messages`, { content })
|
||||
}
|
||||
31
apps/miniprogram-uniapp/src/services/device-sync.ts
Normal file
31
apps/miniprogram-uniapp/src/services/device-sync.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { api } from './request'
|
||||
|
||||
interface BatchReadingRequest {
|
||||
device_id: string; device_model?: string
|
||||
readings: { device_type: string; values: Record<string, number>; measured_at: string }[]
|
||||
}
|
||||
|
||||
interface BatchResult {
|
||||
accepted: number; duplicates: number; earliest: string | null; latest: string | null
|
||||
}
|
||||
|
||||
export async function uploadReadings(
|
||||
patientId: string, deviceId: string, deviceModel: string | undefined,
|
||||
readings: { device_type: string; values: Record<string, number>; measured_at: string }[],
|
||||
): Promise<number> {
|
||||
if (readings.length === 0) return 0
|
||||
const body: BatchReadingRequest = {
|
||||
device_id: deviceId, device_model: deviceModel,
|
||||
readings: readings.map(r => ({ device_type: r.device_type, values: r.values, measured_at: r.measured_at })),
|
||||
}
|
||||
const result = await api.post<BatchResult>(`/health/patients/${patientId}/device-readings/batch`, body)
|
||||
return result.accepted
|
||||
}
|
||||
|
||||
export async function queryDeviceReadings(patientId: string, params?: { device_type?: string; hours?: number }) {
|
||||
return api.get<{ data: unknown[]; total: number }>(`/health/patients/${patientId}/device-readings`, params)
|
||||
}
|
||||
|
||||
export async function queryHourlyReadings(patientId: string, params: { device_type: string; days?: number }) {
|
||||
return api.get<{ data: unknown[]; total: number }>(`/health/patients/${patientId}/device-readings/hourly`, params)
|
||||
}
|
||||
37
apps/miniprogram-uniapp/src/services/dialysis.ts
Normal file
37
apps/miniprogram-uniapp/src/services/dialysis.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { api } from './request'
|
||||
|
||||
export interface DialysisRecord {
|
||||
id: string; patient_id: string; dialysis_date: string; start_time?: string; end_time?: string
|
||||
dry_weight?: number; pre_weight?: number; post_weight?: number
|
||||
pre_bp_systolic?: number; pre_bp_diastolic?: number; post_bp_systolic?: number; post_bp_diastolic?: number
|
||||
pre_heart_rate?: number; post_heart_rate?: number; ultrafiltration_volume?: number
|
||||
dialysis_duration?: number; blood_flow_rate?: number; dialysis_type: string
|
||||
symptoms?: Record<string, unknown>; complication_notes?: string; status: string
|
||||
reviewed_by?: string; reviewed_at?: string; created_at: string; updated_at: string; version: number
|
||||
}
|
||||
|
||||
export interface DialysisPrescription {
|
||||
id: string; patient_id: string; dialyzer_model?: string; membrane_area?: number
|
||||
dialysate_potassium?: number; dialysate_calcium?: number; dialysate_bicarbonate?: number
|
||||
anticoagulation_type?: string; anticoagulation_dose?: string; target_ultrafiltration_ml?: number
|
||||
target_dry_weight?: number; blood_flow_rate?: number; dialysate_flow_rate?: number
|
||||
frequency_per_week?: number; duration_minutes?: number; vascular_access_type?: string
|
||||
vascular_access_location?: string; effective_from?: string; effective_to?: string
|
||||
status: string; prescribed_by?: string; notes?: string; created_at: string; updated_at: string; version: number
|
||||
}
|
||||
|
||||
export async function listDialysisRecords(patientId: string, params?: { page?: number; page_size?: number }) {
|
||||
return api.get<{ data: DialysisRecord[]; total: number }>(`/health/patients/${patientId}/dialysis-records`, params)
|
||||
}
|
||||
|
||||
export async function getDialysisRecord(id: string) {
|
||||
return api.get<DialysisRecord>(`/health/dialysis-records/${id}`)
|
||||
}
|
||||
|
||||
export async function listDialysisPrescriptions(params?: { patient_id?: string; status?: string; page?: number; page_size?: number }) {
|
||||
return api.get<{ data: DialysisPrescription[]; total: number }>('/health/dialysis-prescriptions', params)
|
||||
}
|
||||
|
||||
export async function getDialysisPrescription(id: string) {
|
||||
return api.get<DialysisPrescription>(`/health/dialysis-prescriptions/${id}`)
|
||||
}
|
||||
37
apps/miniprogram-uniapp/src/services/doctor/actionInbox.ts
Normal file
37
apps/miniprogram-uniapp/src/services/doctor/actionInbox.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { api } from '@/services/request'
|
||||
import type { ActionItem, ThreadResponse } from '@/services/action-inbox'
|
||||
|
||||
interface WorkbenchStats { pending: number; in_progress: number; completed_today: number; overdue: number }
|
||||
|
||||
interface NursePatientSummary {
|
||||
patient_id: string; patient_name: string; bed_number?: string
|
||||
primary_diagnosis?: string; care_plan_status?: string; open_action_count: number
|
||||
}
|
||||
|
||||
interface TeamOverview {
|
||||
team_name: string
|
||||
members: { user_id: string; user_name: string; role: string; active_tasks: number }[]
|
||||
}
|
||||
|
||||
export async function listActionItems(params?: {
|
||||
status?: string; type?: string; page?: number; page_size?: number
|
||||
assigned_to_me?: boolean; patient_id?: string
|
||||
}) {
|
||||
return api.get<{ data: ActionItem[]; total: number }>('/health/action-inbox', params as Record<string, string | number | boolean | undefined>)
|
||||
}
|
||||
|
||||
export async function getActionThread(sourceRef: string) {
|
||||
return api.get<ThreadResponse>(`/health/action-inbox/${encodeURIComponent(sourceRef)}/thread`)
|
||||
}
|
||||
|
||||
export async function getWorkbenchStats(assignedToMe?: boolean) {
|
||||
return api.get<WorkbenchStats>('/health/action-inbox/stats', assignedToMe !== undefined ? { assigned_to_me: assignedToMe } : undefined)
|
||||
}
|
||||
|
||||
export async function getTeamOverview() {
|
||||
return api.get<TeamOverview>('/health/action-inbox/team')
|
||||
}
|
||||
|
||||
export async function getMyPatients() {
|
||||
return api.get<NursePatientSummary[]>('/health/action-inbox/my-patients')
|
||||
}
|
||||
40
apps/miniprogram-uniapp/src/services/doctor/alerts.ts
Normal file
40
apps/miniprogram-uniapp/src/services/doctor/alerts.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { api } from '@/services/request'
|
||||
|
||||
export interface Alert {
|
||||
id: string; patient_id: string; rule_id: string; severity: string; title: string
|
||||
detail?: Record<string, unknown>; status: string; acknowledged_by?: string
|
||||
acknowledged_at?: string; resolved_at?: string; created_at: string; version: number
|
||||
}
|
||||
|
||||
// 轻量级内存缓存,供列表页写入、详情页读取
|
||||
const alertCache = new Map<string, Alert>()
|
||||
|
||||
export function cacheAlerts(alerts: Alert[]): void {
|
||||
for (const a of alerts) {
|
||||
alertCache.set(a.id, a)
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedAlert(id: string): Alert | undefined {
|
||||
return alertCache.get(id)
|
||||
}
|
||||
|
||||
export function updateCachedAlert(alert: Alert): void {
|
||||
alertCache.set(alert.id, alert)
|
||||
}
|
||||
|
||||
export async function listAlerts(params?: { patient_id?: string; status?: string; page?: number; page_size?: number }) {
|
||||
return api.get<{ data: Alert[]; total: number }>('/health/alerts', params)
|
||||
}
|
||||
|
||||
export async function acknowledgeAlert(id: string, version: number) {
|
||||
return api.put<Alert>(`/health/alerts/${id}/acknowledge`, { version })
|
||||
}
|
||||
|
||||
export async function dismissAlert(id: string, version: number) {
|
||||
return api.put<Alert>(`/health/alerts/${id}/dismiss`, { version })
|
||||
}
|
||||
|
||||
export async function resolveAlert(id: string, version: number) {
|
||||
return api.put<Alert>(`/health/alerts/${id}/resolve`, { version })
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { api } from '@/services/request'
|
||||
|
||||
export async function listAppointments(params?: { page?: number; page_size?: number; status?: string; date?: string }) {
|
||||
return api.get<{ data: any[]; total: number }>('/health/appointments', params)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user