Compare commits

...
46 Commits
Author SHA1 Message Date
Your Name 30b6037752 更新 2026-06-05 17:15:42 +08:00
Your Name 5a235280a1 更新 2026-06-05 17:15:15 +08:00
Your Name b9d36d8d04 更新 2026-06-05 15:51:48 +08:00
Your Name f37310ec50 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-06-05 15:13:10 +08:00
Your Name a440702c07 更新 2026-06-05 15:12:57 +08:00
long b9486f32fe Merge branch '20260604-long' 2026-06-05 12:02:40 +08:00
long 07018598ad add
素材分发 增加用户备注
add_asset_user_remark.sql
2026-06-05 12:02:35 +08:00
Your Name c554e08f2e 更新 2026-06-05 09:38:31 +08:00
long e6209c8912 Merge branch '20260604-long' 2026-06-04 17:37:29 +08:00
Your Name e49f806c3a 更新 2026-06-04 16:51:46 +08:00
Your Name b566e60624 更新 2026-06-04 16:51:25 +08:00
long 123e9075dd 处方增加禁用修改 2026-06-04 15:11:16 +08:00
Your Name 84eef45553 更新 2026-06-04 14:10:22 +08:00
Your Name 9873ac5e87 更新 2026-06-04 11:54:02 +08:00
long cdf8b1f76d udpate 2026-06-04 11:46:32 +08:00
Your Name b0b78c82ab 更新 2026-06-04 11:43:13 +08:00
long ff61e815d8 时间范围筛选优化 2026-06-04 10:51:35 +08:00
Your Name dd93ffe683 更新 2026-06-04 10:22:15 +08:00
Your Name ef014e0d9d 更新 2026-06-04 10:17:47 +08:00
Your Name 32f8e19bf1 更新 2026-06-04 09:57:14 +08:00
Your Name 26b2cde699 更新 2026-06-04 09:34:34 +08:00
Your Name 5ab3be1fdd 新增 2026-06-04 09:25:00 +08:00
long 6b528c34c8 add DoctorNote
诊单 增加医生备注入口
2026-06-03 15:18:12 +08:00
Your Name 2138626519 更新 2026-06-03 14:48:57 +08:00
Your Name 37df208706 更新 2026-06-03 14:42:48 +08:00
Your Name ec4e0b9f29 更新 2026-06-03 11:39:36 +08:00
Your Name a3bae1555a Merge branch 'master-6-2' 2026-06-02 18:35:14 +08:00
Your Name 15e9c486f9 更新 2026-06-02 18:33:25 +08:00
Your Name eadc82535d 更新 2026-06-02 16:54:07 +08:00
Your Name 62f74888f3 Merge branch 'master' into master-6-2 2026-06-02 16:49:42 +08:00
Your Name 79719b801e 更新 2026-06-02 16:47:50 +08:00
long 07dacbcb28 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-06-02 14:20:26 +08:00
long d364fe506a 1 2026-06-02 14:20:20 +08:00
Your Name 860b550180 更新 2026-06-02 14:15:49 +08:00
Your Name 707e4888ad 更新bug 2026-06-02 10:16:03 +08:00
Your Name b0a8bd83bc 更新 2026-06-01 18:39:09 +08:00
Your Name 574d37908a Merge branch 'master' into master-6-1 2026-06-01 18:36:15 +08:00
Your Name 62512d30f9 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-06-01 18:35:28 +08:00
long 1dd5133202 Merge branch '20260601-long' 2026-06-01 18:30:37 +08:00
long 51b34a98b5 Update list.vue 2026-06-01 18:30:29 +08:00
Your Name 1755185f50 Merge branch 'master' into master-5-28 2026-06-01 18:17:46 +08:00
Your Name b3d3048100 更新 2026-06-01 18:11:05 +08:00
Your Name a38af5bb13 更新 2026-06-01 18:04:23 +08:00
long 450be5852f Merge branch '20260601-long' 2026-06-01 17:02:47 +08:00
long 3c35ab67cd update
h5 :问诊列表
add 完成诊单  +医生备注
2026-06-01 17:00:38 +08:00
Your Name 7cfa4d269a 更新 2026-06-01 09:34:27 +08:00
399 changed files with 16197 additions and 1547 deletions
+1 -4
View File
@@ -26,11 +26,8 @@ bin-release/
/.cursor
/.codex
/.agents
/.claude
/.claude
/.claude
/.kbctx
/server/.spool
/server/.claude
/.spool
TUICallKit-Vue3/.env
/.codegraph
@@ -0,0 +1,137 @@
<template>
<view class="tab-dock">
<view class="tab-dock__inner">
<view class="tab-dock__item" @click="goHome">
<image
class="tab-dock__icon"
:src="active === 0 ? '/static/user/home_no.png' : '/static/user/home.png'"
mode="aspectFit"
/>
<text class="tab-dock__label" :style="{ color: active === 0 ? activeColor : color }">首页</text>
</view>
<view class="tab-dock__item tab-dock__item--center" @click="goWeekly">
<view class="tab-dock__fab" :class="{ 'tab-dock__fab--active': active === 1 }">
<text class="tab-dock__fab-text"></text>
</view>
<text class="tab-dock__label tab-dock__label--center" :style="{ color: active === 1 ? activeColor : color }">血糖</text>
</view>
<view class="tab-dock__item" @click="goUser">
<image
class="tab-dock__icon"
:src="active === 2 ? '/static/user/user_no.png' : '/static/user/user.png'"
mode="aspectFit"
/>
<text class="tab-dock__label" :style="{ color: active === 2 ? activeColor : color }">我的</text>
</view>
</view>
<view class="tab-dock__safe" />
</view>
</template>
<script setup>
const props = defineProps({
/** 0 首页 1 血糖 2 我的 */
active: {
type: Number,
default: 0
}
})
const color = '#8a8a8a'
const activeColor = '#204e2b'
function goHome() {
if (props.active === 0) return
uni.redirectTo({ url: '/pages/index/index' })
}
function goUser() {
if (props.active === 2) return
uni.redirectTo({ url: '/pages/user/user' })
}
function goWeekly() {
if (props.active === 1) return
uni.redirectTo({ url: '/tongji/pages/weekly' })
}
</script>
<style scoped lang="scss">
.tab-dock {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
background: #ffffff;
border-top: 1rpx solid rgba(0, 0, 0, 0.06);
box-shadow: 0 -6rpx 20rpx rgba(0, 0, 0, 0.04);
}
.tab-dock__inner {
display: flex;
align-items: flex-end;
height: 100rpx;
}
.tab-dock__item {
flex: 1;
height: 100rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.tab-dock__item--center {
justify-content: flex-end;
padding-bottom: 6rpx;
}
.tab-dock__icon {
width: 46rpx;
height: 46rpx;
}
.tab-dock__label {
margin-top: 6rpx;
font-size: 22rpx;
line-height: 1.2;
}
.tab-dock__label--center {
margin-top: 8rpx;
}
.tab-dock__fab {
width: 96rpx;
height: 96rpx;
margin-top: -48rpx;
border-radius: 50%;
background: linear-gradient(180deg, #2d7340 0%, #204e2b 100%);
border: 6rpx solid #ffffff;
box-shadow: 0 10rpx 24rpx rgba(32, 78, 43, 0.35);
display: flex;
align-items: center;
justify-content: center;
}
.tab-dock__fab--active {
transform: scale(1.04);
box-shadow: 0 12rpx 28rpx rgba(32, 78, 43, 0.42);
}
.tab-dock__fab-text {
color: #ffffff;
font-size: 38rpx;
font-weight: 700;
line-height: 1;
}
.tab-dock__safe {
height: env(safe-area-inset-bottom);
background: #ffffff;
}
</style>
@@ -1,5 +1,68 @@
<template>
<view v-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
<!-- 页面内嵌模块 weekly 统计页主内容区 -->
<view
v-if="visible && props.inline"
class="dev-entry-inline"
:class="{ 'dev-entry-inline--weekly': props.tone === 'weekly' }"
>
<view class="inline-hero">
<view class="inline-hero-icon" aria-hidden="true">
<view class="inline-hero-wave" />
</view>
<view class="inline-hero-text">
<view class="inline-hero-title-row">
<text class="inline-title">居家耗糖训练</text>
<text v-if="props.tone === 'weekly'" class="inline-pill">体验</text>
</view>
<text class="inline-sub">低强度动起来帮助身体消耗糖分</text>
</view>
</view>
<scroll-view
scroll-x
class="inline-scroll"
:show-scrollbar="false"
:enable-flex="true"
>
<view class="inline-scroll-track">
<view
v-for="item in inlineItems"
:key="item.url"
class="inline-card"
@click="goto(item.url)"
>
<view v-if="item.featured" class="inline-card-badge">
<text>推荐</text>
</view>
<view class="menu-icon" :class="item.iconClass">
<view v-if="item.icon === 'dumbbell'" class="dumbbell-shape">
<view class="dumbbell-plate dumbbell-plate-left" />
<view class="dumbbell-bar" />
<view class="dumbbell-plate dumbbell-plate-right" />
</view>
<view v-else-if="item.icon === 'pedal'" class="foot-pedal-shape">
<view class="pedal-bar pedal-bar-left" />
<view class="pedal-bar pedal-bar-right" />
</view>
<view v-else-if="item.icon === 'pilates'" class="pilates-ring-shape" />
<view v-else-if="item.icon === 'grip'" class="grip-ring-shape" />
<view v-else-if="item.icon === 'metro'" class="menu-icon--metro-inner">
<view class="metro-bar metro-bar-1" />
<view class="metro-bar metro-bar-2" />
<view class="metro-bar metro-bar-3" />
</view>
</view>
<text class="inline-card-title">{{ item.title }}</text>
<text class="inline-card-sub">{{ item.sub }}</text>
<text class="inline-card-go" aria-hidden="true">进入</text>
</view>
</view>
</scroll-view>
<text v-if="props.tone === 'weekly'" class="inline-scroll-hint">左右滑动选择适合您的训练</text>
</view>
<view v-else-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
<!-- 展开后的菜单卡片(从下往上动画) -->
<view v-if="expanded" class="menu-list" @click.stop>
<view class="menu-item" @click="goto('/training/pages/dumbbell')">
@@ -84,15 +147,45 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
type TrainingItem = {
url: string
title: string
sub: string
icon: 'dumbbell' | 'pedal' | 'pilates' | 'grip' | 'metro'
iconClass: string
featured?: boolean
}
const trainingItems: TrainingItem[] = [
{ url: '/training/pages/dumbbell', title: '哑铃', sub: '力量塑形', icon: 'dumbbell', iconClass: 'menu-icon--dumbbell' },
{ url: '/training/pages/foot-pedal', title: '脚蹬器', sub: '下肢有氧', icon: 'pedal', iconClass: 'menu-icon--pedal' },
{ url: '/training/pages/pilates-ring', title: '瑜伽环', sub: '核心塑形', icon: 'pilates', iconClass: 'menu-icon--pilates' },
{ url: '/training/pages/grip-ring', title: '握力环', sub: '握力训练', icon: 'grip', iconClass: 'menu-icon--grip' },
{ url: '/training/pages/metronome', title: '耗糖节拍器', sub: '健走配速', icon: 'metro', iconClass: 'menu-icon--metro', featured: true },
]
const props = withDefaults(
defineProps<{
/** 悬浮按钮距底部 rpx(仅非 inline */
bottom?: number
/** 内嵌到页面主内容区,非 fixed 悬浮 */
inline?: boolean
/** weekly 页:与统计页 Vital Mint 视觉一致 */
tone?: 'default' | 'weekly'
}>(),
{ bottom: 200 },
{ bottom: 200, inline: false, tone: 'default' },
)
/** weekly 将健走节拍器置前并标推荐 */
const inlineItems = computed(() => {
if (props.tone !== 'weekly') return trainingItems
const metro = trainingItems.find((i) => i.icon === 'metro')
const rest = trainingItems.filter((i) => i.icon !== 'metro')
return metro ? [metro, ...rest] : trainingItems
})
const isDevMode = (): boolean => {
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */
try {
@@ -449,4 +542,196 @@ $brand-light: #34d399;
line-height: 1;
margin-left: 4rpx;
}
.menu-icon--metro-inner {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4rpx;
width: 100%;
height: 100%;
}
/* ============================================================
* 内嵌模块(weekly 等)
* ============================================================ */
.dev-entry-inline {
width: 100%;
box-sizing: border-box;
}
.dev-entry-inline--weekly {
padding: 32rpx 28rpx 24rpx;
border-radius: 48rpx;
background: var(--surface-container-lowest, #fff);
border: 1rpx solid rgba(0, 108, 73, 0.12);
box-shadow:
0 24rpx 60rpx -20rpx rgba(0, 108, 73, 0.08),
0 8rpx 20rpx -8rpx rgba(0, 0, 0, 0.03);
}
.inline-hero {
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 24rpx;
}
.inline-hero-icon {
width: 88rpx;
height: 88rpx;
border-radius: 24rpx;
background: rgba(0, 108, 73, 0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.inline-hero-wave {
width: 52rpx;
height: 52rpx;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23006c49' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'><polyline points='2,12 7,12 9.5,7 12,17 14.5,9 16.5,12 22,12'/></svg>");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
.inline-hero-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.inline-hero-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10rpx;
}
.inline-title {
font-size: 32rpx;
font-weight: 700;
color: #0f172a;
line-height: 1.25;
}
.inline-pill {
font-size: 20rpx;
font-weight: 700;
color: #006c49;
padding: 4rpx 12rpx;
border-radius: 999rpx;
background: rgba(0, 108, 73, 0.1);
line-height: 1.2;
}
.inline-sub {
font-size: 26rpx;
font-weight: 500;
color: #475569;
line-height: 1.4;
}
.inline-scroll {
width: 100%;
white-space: nowrap;
}
.inline-scroll-track {
display: inline-flex;
flex-direction: row;
align-items: stretch;
gap: 16rpx;
padding: 4rpx 2rpx 8rpx;
}
.inline-card {
position: relative;
display: inline-flex;
flex-direction: column;
align-items: flex-start;
width: 212rpx;
min-height: 220rpx;
padding: 20rpx 18rpx 18rpx;
border-radius: 28rpx;
background: #f4f7f5;
border: 1rpx solid rgba(0, 108, 73, 0.1);
box-sizing: border-box;
flex-shrink: 0;
transition: transform 0.2s ease, background 0.2s ease;
&:active {
transform: scale(0.98);
background: #e8f3ee;
}
.menu-icon {
width: 72rpx;
height: 72rpx;
margin-bottom: 14rpx;
flex-shrink: 0;
}
}
.inline-card-badge {
position: absolute;
top: 12rpx;
right: 12rpx;
padding: 4rpx 10rpx;
border-radius: 8rpx;
background: #006c49;
text {
font-size: 18rpx;
font-weight: 700;
color: #fff;
line-height: 1.2;
}
}
.inline-card-title {
font-size: 28rpx;
font-weight: 700;
color: #0f172a;
line-height: 1.25;
width: 100%;
}
.inline-card-sub {
margin-top: 6rpx;
font-size: 22rpx;
font-weight: 500;
color: #64748b;
line-height: 1.35;
width: 100%;
}
.inline-card-go {
margin-top: auto;
padding-top: 12rpx;
font-size: 22rpx;
font-weight: 700;
color: #006c49;
line-height: 1.2;
}
.inline-scroll-hint {
display: block;
margin-top: 14rpx;
font-size: 22rpx;
font-weight: 600;
color: #94a3b8;
text-align: center;
line-height: 1.35;
}
@media (prefers-reduced-motion: reduce) {
.inline-card:active {
transform: none;
}
}
</style>
-69
View File
@@ -4142,9 +4142,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4161,9 +4158,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4180,9 +4174,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4199,9 +4190,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4433,9 +4421,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4456,9 +4441,6 @@
"cpu": [
"arm"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4479,9 +4461,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4502,9 +4481,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4525,9 +4501,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4548,9 +4521,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4729,9 +4699,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4745,9 +4712,6 @@
"cpu": [
"arm"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4761,9 +4725,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4777,9 +4738,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4793,9 +4751,6 @@
"cpu": [
"loong64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4809,9 +4764,6 @@
"cpu": [
"loong64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4825,9 +4777,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4841,9 +4790,6 @@
"cpu": [
"ppc64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4857,9 +4803,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4873,9 +4816,6 @@
"cpu": [
"riscv64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4889,9 +4829,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4905,9 +4842,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4921,9 +4855,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
+24 -29
View File
@@ -3,7 +3,9 @@
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "甄养堂"
"navigationBarTitleText": "甄养堂",
"backgroundColor": "#F8F8F8",
"backgroundColorTop": "#F8F8F8"
}
},
{
@@ -43,7 +45,9 @@
{
"path": "pages/user/user",
"style": {
"navigationBarTitleText": "我的"
"navigationBarTitleText": "我的",
"backgroundColor": "#e8eaed",
"backgroundColorTop": "#e8eaed"
}
},
{
@@ -177,16 +181,6 @@
}
}
},
{
"path": "pages/more",
"style": {
"navigationBarTitleText": "日常护理",
"navigationBarBackgroundColor": "#204e2b",
"navigationBarTextStyle": "white",
"backgroundColor": "#faf9f5",
"enablePullDownRefresh": true
}
},
{
"path": "pages/weekly",
"style": {
@@ -203,27 +197,28 @@
}
}
}
},
{
"path": "pages/more",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "日常护理",
"backgroundColor": "#f4fbf4",
"enablePullDownRefresh": true
}
},
{
"path": "pages/game",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "糖分突袭",
"backgroundColor": "#c7d2fe",
"disableScroll": true
}
}
]
}
],
"tabBar": {
"color": "#8a8a8a",
"selectedColor": "#204e2b",
"borderStyle": "black",
"backgroundColor": "#ffffff",
"list": [{
"pagePath": "pages/index/index",
"iconPath": "/static/user/home.png",
"selectedIconPath": "/static/user/home_no.png",
"text": "首页"
},{
"pagePath": "pages/user/user",
"iconPath": "/static/user/user.png",
"selectedIconPath": "/static/user/user_no.png",
"text": "我的"
}]
},
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "甄养堂互联网医院",
+5 -2
View File
@@ -118,13 +118,15 @@
</view>
</scroll-view>
</view>
<TabBarDock :active="0" />
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getCurrentInstance } from 'vue'
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
import { onShareAppMessage } from '@dcloudio/uni-app'
import TabBarDock from '@/components/app-tab-bar/tab-bar-dock.vue'
const { proxy } = getCurrentInstance()
const doctors = ref([])
@@ -263,7 +265,8 @@ $outline: #727970;
.container {
background: $background;
min-height: 100vh;
padding: 24rpx 32rpx 120rpx;
padding: 24rpx 32rpx;
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
+8 -5
View File
@@ -1,5 +1,5 @@
<template>
<div class="mine-page">
<view class="mine-page">
<!-- 顶部用户信息卡片 -->
<div class="user-card" @click="navigateTo('/pages/user/userinfo')">
<div class="user-header">
@@ -90,16 +90,18 @@
</div>
<!-- DEV 开发悬浮入口生产环境自动隐藏 -->
<dev-training-entry />
</div>
<dev-training-entry :bottom="360" />
<TabBarDock :active="2" />
</view>
</template>
<script>
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
import DevTrainingEntry from '@/components/dev-training-entry/index.vue'
import TabBarDock from '@/components/app-tab-bar/tab-bar-dock.vue'
export default {
name: 'profileB',
components: { DevTrainingEntry },
components: { DevTrainingEntry, TabBarDock },
data() {
return {
userInfo: {
@@ -170,6 +172,7 @@ export default {
min-height: 100vh;
background: #e8eaed;
padding: 40rpx;
padding-bottom: calc(40rpx + 200rpx + env(safe-area-inset-bottom));
}
.user-card {
@@ -420,4 +423,4 @@ export default {
.bg-cyan { background: #13c2c2; }
.bg-pink { background: #eb2f96; }
.bg-gray { background: #666666; }
</style>
</style>
@@ -0,0 +1,121 @@
<template>
<view class="food-tile-icon" :class="[`food-tile-icon--${size}`]">
<text class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
<image
v-if="src && !imageFailed"
class="food-tile-icon__img"
:src="src"
mode="aspectFit"
@error="onImageError"
/>
</view>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { getFoodImgUrl } from '../data/gameFoodImg.js'
const props = defineProps({
food: { type: [Object, String], default: null },
size: { type: String, default: 'tile' }
})
const imageFailed = ref(false)
const fallbackIcon = computed(() => {
if (typeof props.food === 'object' && props.food?.icon) return props.food.icon
return '🍽'
})
const src = computed(() => {
if (typeof props.food === 'object' && props.food?.img) return props.food.img
if (typeof props.food === 'object' && props.food?.icon) return getFoodImgUrl(props.food.icon)
return ''
})
watch(
() => src.value,
() => {
imageFailed.value = false
}
)
function onImageError() {
imageFailed.value = true
}
</script>
<style lang="scss" scoped>
.food-tile-icon {
position: relative;
box-sizing: border-box;
overflow: hidden;
}
.food-tile-icon--tile {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
font-size: calc(var(--tile-emoji-size, 48px) * 0.6);
}
.food-tile-icon--tile .food-tile-icon__emoji,
.food-tile-icon--tile .food-tile-icon__img {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 75%;
height: 75%;
z-index: 2;
}
/* 玻璃格上的食材图片:投影增强立体感(参考稿) */
.food-tile-icon--tile .food-tile-icon__img {
filter: drop-shadow(0 6rpx 10rpx rgba(0, 0, 0, 0.16));
}
.food-tile-icon--goal {
width: 40rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
}
.food-tile-icon--tooltip {
width: 36rpx;
height: 36rpx;
display: flex;
align-items: center;
justify-content: center;
}
.food-tile-icon__emoji {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: inherit;
line-height: 1;
z-index: 1;
}
.food-tile-icon--goal .food-tile-icon__emoji,
.food-tile-icon--tooltip .food-tile-icon__emoji {
position: static;
transform: none;
font-size: 34rpx;
}
.food-tile-icon__img {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 2;
display: block;
}
</style>
@@ -8,12 +8,12 @@
mode="aspectFit"
@error="onImageError"
/>
<text
<!-- <text
v-else
class="tj-icon-fallback"
:class="[`tj-icon-fallback--${size}`]"
:style="{ color }"
>{{ fallbackGlyph }}</text>
>{{ fallbackGlyph }}</text> -->
</view>
</template>
@@ -66,7 +66,12 @@ const ICON_PATHS = {
egg: '<path d="M12 22c4.97 0 8-3.27 8-7.31C20 9.65 16.42 2 12 2S4 9.65 4 14.69C4 18.73 7.03 22 12 22Z"/>',
home: '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
'plus-circle': '<circle cx="12" cy="12" r="10"/><path d="M8 12h8M12 8v8"/>',
'chevron-right': '<path d="m9 18 6-6-6-6"/>'
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
'chevron-left': '<path d="m15 18-6-6 6-6"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
syringe: '<path d="m18 2 4 4"/><path d="m17 7 3-3"/><path d="M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5"/><path d="m9 11 4 4"/><path d="m5 19-3 3"/><path d="m14 4 6 6"/>',
zap: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>'
}
/** 图片加载失败时的 emoji 回退 */
@@ -106,7 +111,12 @@ const ICON_FALLBACK = {
egg: '🍳',
home: '🏠',
'plus-circle': '⊕',
'chevron-right': ''
'chevron-right': '',
'chevron-left': '',
check: '✓',
settings: '⚙',
syringe: '💉',
zap: '⚡'
}
const strokeColor = computed(() => {
@@ -155,6 +165,10 @@ function onImageError() {
width: 100%;
height: 100%;
}
/* Lucide send 路径重心偏右上,微调使圆形按钮内视觉居中 */
.tj-icon--send {
transform: translate(-10%, 10%);
}
.tj-icon-fallback {
display: block;
line-height: 1;
@@ -0,0 +1,203 @@
import { ref, computed } from 'vue'
import { requestAiStream, parsePartialBloodCarePlainText } from '../utils/aiStreamRequest.js'
const emptyBloodCarePlan = () => ({
summary: '',
blood_advice: '',
tcm_plan: '',
watch_points: [],
next_steps: '',
control_level: '',
control_label: '',
disclaimer: '',
analysis: [],
source: '',
date: ''
})
/** AI 血糖照护建议(累计 7 天有记录后) */
export function useBloodCareAi(proxy, { diagnosisId, chartUseMockData, showUserToast, formatUserMessage }) {
const bloodCarePlan = ref(emptyBloodCarePlan())
const bloodCareLoading = ref(false)
const bloodCareStreaming = ref(false)
const bloodCareStreamField = ref('')
let careStreamBuffer = ''
/** 用户点击后才展开照护建议卡片 */
const bloodCarePanelOpen = ref(false)
const bloodCareEligible = computed(() => {
return !!diagnosisId.value && !chartUseMockData.value
})
const hasBloodCareContent = computed(() => {
const p = bloodCarePlan.value
return !!(p.summary || p.blood_advice || p.tcm_plan)
})
const bloodCareAnalysis = computed(() => {
const list = bloodCarePlan.value?.analysis
return Array.isArray(list) ? list : []
})
const bloodCareControlLabel = computed(() => {
const label = String(bloodCarePlan.value?.control_label || '').trim()
return label
})
const bloodCareDisclaimer = computed(() => {
return String(bloodCarePlan.value?.disclaimer || '').trim()
})
function detectCareStreamField(text) {
const tail = String(text || '').match(/(?:^|\n)(总评|血糖|中医|留意|复诊)[:]\s*([^\n]*)$/)
if (!tail) return 'summary'
const map = { '总评': 'summary', '血糖': 'blood_advice', '中医': 'tcm_plan', '留意': 'watch_points', '复诊': 'next_steps' }
return map[tail[1]] || 'summary'
}
function mergePartialCareFromStream() {
const partial = parsePartialBloodCarePlainText(careStreamBuffer)
const next = { ...bloodCarePlan.value }
for (const key of ['summary', 'blood_advice', 'tcm_plan', 'next_steps']) {
if (partial[key] != null) next[key] = partial[key]
}
if (Array.isArray(partial.watch_points)) next.watch_points = partial.watch_points
bloodCarePlan.value = next
bloodCareStreamField.value = detectCareStreamField(careStreamBuffer)
}
function applyBloodCarePayload(payload) {
if (!payload) return
const prevAnalysis = bloodCarePlan.value.analysis
bloodCarePlan.value = {
...emptyBloodCarePlan(),
...payload,
watch_points: Array.isArray(payload.watch_points) ? payload.watch_points : [],
analysis: Array.isArray(payload.analysis) && payload.analysis.length
? payload.analysis
: (Array.isArray(prevAnalysis) ? prevAnalysis : [])
}
}
function onBloodCareStreamEvent(event, payload) {
if (event === 'analysis') {
if (Array.isArray(payload?.analysis)) {
bloodCarePlan.value = { ...bloodCarePlan.value, analysis: payload.analysis }
}
} else if (event === 'delta') {
bloodCareStreaming.value = true
if (payload.buffer != null) {
careStreamBuffer = payload.buffer
} else {
careStreamBuffer += payload.text || ''
}
mergePartialCareFromStream()
} else if (event === 'done') {
applyBloodCarePayload(payload)
careStreamBuffer = ''
bloodCareStreamField.value = ''
} else if (event === 'error') {
showUserToast(formatUserMessage(payload?.message, '照护建议加载失败'))
}
}
async function fetchBloodCareAiFallback(refresh) {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyBloodCareAiRecommend',
method: 'GET',
timeout: 35000,
data: {
diagnosis_id: diagnosisId.value,
refresh: refresh ? 1 : 0,
...(refresh ? { _t: Date.now() } : {})
}
}, false)
if (res?.code === 1 && res.data) {
applyBloodCarePayload(res.data)
} else if (res?.msg) {
showUserToast(res.msg)
}
}
function closeBloodCarePanel() {
bloodCarePanelOpen.value = false
}
/** 趋势图 AI 按钮:未展开则打开并生成;已展开则收起 */
async function toggleBloodCareFromChart() {
if (!bloodCareEligible.value || bloodCareLoading.value) return
if (bloodCarePanelOpen.value) {
closeBloodCarePanel()
return
}
bloodCarePanelOpen.value = true
if (!hasBloodCareContent.value) {
await fetchBloodCareAi(false)
}
}
async function fetchBloodCareAi(refresh = false) {
if (!bloodCareEligible.value || bloodCareLoading.value) return
bloodCareLoading.value = true
bloodCareStreaming.value = false
careStreamBuffer = ''
bloodCareStreamField.value = ''
if (refresh) {
applyBloodCarePayload(emptyBloodCarePlan())
}
try {
await requestAiStream({
baseUrl: proxy.$url,
url: '/api/tcm/dailyBloodCareAiRecommendStream',
method: 'GET',
data: {
diagnosis_id: diagnosisId.value,
refresh: refresh ? 1 : 0,
...(refresh ? { _t: Date.now() } : {})
},
onEvent: onBloodCareStreamEvent,
fallback: () => fetchBloodCareAiFallback(refresh)
})
} catch (e) {
try {
await fetchBloodCareAiFallback(refresh)
} catch (e2) {
showUserToast('照护建议加载失败')
}
} finally {
bloodCareLoading.value = false
bloodCareStreaming.value = false
bloodCareStreamField.value = ''
}
}
function refreshBloodCareAi() {
if (bloodCareLoading.value) return
fetchBloodCareAi(true)
}
function resetBloodCarePlan() {
applyBloodCarePayload(emptyBloodCarePlan())
careStreamBuffer = ''
bloodCareStreamField.value = ''
bloodCarePanelOpen.value = false
}
return {
bloodCarePlan,
bloodCareLoading,
bloodCareStreaming,
bloodCareStreamField,
bloodCareEligible,
bloodCarePanelOpen,
hasBloodCareContent,
bloodCareAnalysis,
bloodCareControlLabel,
bloodCareDisclaimer,
fetchBloodCareAi,
toggleBloodCareFromChart,
closeBloodCarePanel,
refreshBloodCareAi,
resetBloodCarePlan
}
}
@@ -5,6 +5,7 @@ const DIET_AI_PREFILL_KEY = 'tongji_diet_ai_prefill'
const emptyDietAiRecommend = () => ({
breakfast: '',
drinks: '',
lunch: '',
dinner: '',
tips: '',
@@ -31,20 +32,20 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
const hasDietAiMealContent = computed(() => {
const r = dietAiRecommend.value
return !!(r.breakfast || r.lunch || r.dinner || r.tips)
return !!(r.breakfast || r.drinks || r.lunch || r.dinner || r.tips)
})
function detectDietStreamField(text) {
const tail = String(text || '').match(/(?:^|\n)(早餐|午餐|晚餐|提示|少碰)[:]\s*([^\n]*)$/)
const tail = String(text || '').match(/(?:^|\n)(早餐|喝的|午餐|晚餐|提示|少碰)[:]\s*([^\n]*)$/)
if (!tail) return 'breakfast'
const map = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' }
const map = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' }
return map[tail[1]] || 'breakfast'
}
function mergePartialDietFromStream() {
const partial = parsePartialDietPlainText(dietStreamBuffer)
const next = { ...dietAiRecommend.value }
for (const key of ['breakfast', 'lunch', 'dinner', 'tips']) {
for (const key of ['breakfast', 'drinks', 'lunch', 'dinner', 'tips']) {
if (partial[key] != null) next[key] = partial[key]
}
if (Array.isArray(partial.avoid)) next.avoid = partial.avoid
@@ -80,6 +81,7 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
...emptyDietAiRecommend(),
...payload,
breakfast: streamed.breakfast,
drinks: streamed.drinks || payload.drinks || '',
lunch: streamed.lunch || payload.lunch || '',
dinner: streamed.dinner || payload.dinner || '',
tips: streamed.tips || payload.tips || '',
@@ -259,6 +261,7 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
try {
uni.setStorageSync(DIET_AI_PREFILL_KEY, {
breakfast: r.breakfast,
drinks: r.drinks || '',
lunch: r.lunch,
dinner: r.dinner,
tips: r.tips || ''
@@ -0,0 +1,188 @@
/**
* 糖分突袭 · 视觉特效状态(飘字、粒子、连消横幅、震屏、闪屏)
* 特效风格参考「开心消消乐」:糖果色星屑、弹性扩散光环、大消除闪屏。
*/
import { ref } from 'vue'
let fxSeq = 0
function nextFxId(prefix) {
fxSeq += 1
return `${prefix}-${fxSeq}-${Date.now()}`
}
// 开心消消乐式糖果色板(彩虹爆裂用)
const CANDY_COLORS = ['#FF5E9C', '#FFC93C', '#5BD16A', '#4D9DFF', '#FF8A3D', '#B57BFF', '#FF6F91', '#2FE6C8']
// 星屑字形
const STAR_GLYPHS = ['✦', '✧', '★', '✨']
export function useGameFx() {
const floatingTexts = ref([])
const burstParticles = ref([])
const ripples = ref([])
const comboBanner = ref({ show: false, text: '', tier: 1 })
const screenShaking = ref(false)
const flash = ref({ active: false, color: '#ffffff', opacity: 0 })
let comboBannerTimer = null
let shakeTimer = null
let flashTimer = null
let flashFadeTimer = null
function spawnFloatingText(payload) {
const id = nextFxId('ft')
floatingTexts.value.push({ id, ...payload })
const duration = payload.danger ? 1500 : payload.isCombo ? 1400 : 1200
setTimeout(() => {
floatingTexts.value = floatingTexts.value.filter((f) => f.id !== id)
}, duration)
return id
}
/**
* 糖果爆裂粒子(星屑 + 圆点混合,上抛后受重力下落,带旋转与缓动)。
* @param {number} cx,cy 爆裂中心(屏幕坐标)
* @param {string} color 基础颜色
* @param {number} count 粒子数量
* @param {object} opts { star, rainbow, gravity, sizeMin, sizeMax, distMin, distMax, life }
*/
function spawnBurst(cx, cy, color, count = 8, opts = {}) {
const {
star = true,
rainbow = false,
gravity = 64,
sizeMin = 8,
sizeMax = 16,
distMin = 36,
distMax = 110,
life = 850
} = opts
const dur = (life / 1000).toFixed(2)
for (let i = 0; i < count; i++) {
const id = nextFxId('p')
const isStar = star && Math.random() < 0.65
const pSize = Math.random() * (sizeMax - sizeMin) + sizeMin
const angle = Math.random() * Math.PI * 2
const distance = Math.random() * (distMax - distMin) + distMin
const tx = Math.cos(angle) * distance
// 先上抛、再叠加重力下落,形成喷泉抛物线
const ty = Math.sin(angle) * distance - distance * 0.35 + gravity
const half = pSize / 2
const pColor = rainbow
? CANDY_COLORS[Math.floor(Math.random() * CANDY_COLORS.length)]
: color
const rot = Math.random() * 720 - 360
const glyph = isStar ? STAR_GLYPHS[Math.floor(Math.random() * STAR_GLYPHS.length)] : ''
const particle = {
id,
left: cx - half,
top: cy - half,
size: pSize,
color: pColor,
char: glyph,
opacity: 1,
transform: 'scale(0.4) rotate(0deg)',
transition: 'none'
}
burstParticles.value.push(particle)
setTimeout(() => {
particle.transition =
`left ${dur}s cubic-bezier(0.16,0.7,0.3,1), top ${dur}s cubic-bezier(0.3,0.1,0.4,1), opacity ${dur}s ease-in, transform ${dur}s ease-out`
particle.left = cx - half + tx
particle.top = cy - half + ty
particle.opacity = 0
particle.transform = `scale(${isStar ? 1.15 : 0.5}) rotate(${rot}deg)`
}, 16)
setTimeout(() => {
burstParticles.value = burstParticles.value.filter((p) => p.id !== id)
}, life + 80)
}
}
/**
* 弹性扩散发光光环(开心消消乐式的扩散光圈)。
* @param {object} opts { thickness, life }
*/
function spawnRipple(cx, cy, color, tier = 1, opts = {}) {
const id = nextFxId('rp')
const size = 36 + tier * 18
const { thickness = Math.max(3, tier + 2), life = 600 } = opts
ripples.value.push({
id,
left: cx,
top: cy,
size,
color,
thickness,
opacity: 0.95
})
setTimeout(() => {
ripples.value = ripples.value.filter((r) => r.id !== id)
}, life)
}
/**
* 全屏闪光(大消除瞬间的爆闪),先点亮再淡出。
*/
function triggerFlash(color = '#ffffff', intensity = 0.5, life = 280) {
if (flashFadeTimer) clearTimeout(flashFadeTimer)
if (flashTimer) clearTimeout(flashTimer)
flash.value = { active: true, color, opacity: intensity }
flashFadeTimer = setTimeout(() => {
flash.value = { ...flash.value, opacity: 0 }
}, 24)
flashTimer = setTimeout(() => {
flash.value = { active: false, color, opacity: 0 }
}, life)
}
function showComboBanner(chain, points) {
if (chain <= 1) return
const tier = chain >= 4 ? 3 : chain >= 3 ? 2 : 1
comboBanner.value = {
show: true,
text: `连消 ×${chain} +${points}`,
tier
}
if (comboBannerTimer) clearTimeout(comboBannerTimer)
comboBannerTimer = setTimeout(() => {
comboBanner.value = { ...comboBanner.value, show: false }
}, 1100)
}
function triggerShake(intensity = 1) {
screenShaking.value = true
if (shakeTimer) clearTimeout(shakeTimer)
shakeTimer = setTimeout(() => {
screenShaking.value = false
}, intensity >= 2 ? 420 : 280)
}
function clearAll() {
floatingTexts.value = []
burstParticles.value = []
ripples.value = []
comboBanner.value = { show: false, text: '', tier: 1 }
screenShaking.value = false
flash.value = { active: false, color: '#ffffff', opacity: 0 }
}
return {
floatingTexts,
burstParticles,
ripples,
comboBanner,
screenShaking,
flash,
spawnFloatingText,
spawnBurst,
spawnRipple,
showComboBanner,
triggerShake,
triggerFlash,
clearAll
}
}
@@ -0,0 +1,112 @@
/**
* 游戏进度持久化:关卡、星级、连胜、每日打卡
*/
import { ref } from 'vue'
import { calcLevelStars } from '../data/gameLevels.js'
const STORAGE_KEY = 'tongji_game_progress_v1'
const DEFAULT = {
currentLevel: 1,
maxUnlocked: 1,
stars: {},
winStreak: 0,
dailyStreak: 0,
lastPlayDate: '',
totalWins: 0,
bestCombo: 0,
totalStars: 0
}
function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function sumStars(starsMap) {
return Object.values(starsMap || {}).reduce((s, n) => s + (Number(n) || 0), 0)
}
function loadRaw() {
try {
const raw = uni.getStorageSync(STORAGE_KEY)
if (raw && typeof raw === 'object') return { ...DEFAULT, ...raw }
} catch (_) {}
return { ...DEFAULT }
}
function saveRaw(data) {
try {
uni.setStorageSync(STORAGE_KEY, data)
} catch (_) {}
}
export function useGameProgress() {
const progress = ref(loadRaw())
function persist() {
progress.value.totalStars = sumStars(progress.value.stars)
saveRaw(progress.value)
}
/** 进入游戏时更新每日打卡 */
function touchDailyPlay() {
const today = todayStr()
const last = progress.value.lastPlayDate
if (last === today) return progress.value.dailyStreak
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, '0')}-${String(yesterday.getDate()).padStart(2, '0')}`
if (last === yStr) {
progress.value.dailyStreak = (progress.value.dailyStreak || 0) + 1
} else {
progress.value.dailyStreak = 1
}
progress.value.lastPlayDate = today
persist()
return progress.value.dailyStreak
}
function getStars(levelId) {
return progress.value.stars[String(levelId)] || 0
}
function recordWin({ levelId, movesLeft, glucoseLevel, score, levelConfig, maxCombo }) {
const stars = calcLevelStars({ movesLeft, glucoseLevel, score, levelConfig })
const key = String(levelId)
const prev = progress.value.stars[key] || 0
if (stars > prev) progress.value.stars[key] = stars
progress.value.winStreak = (progress.value.winStreak || 0) + 1
progress.value.totalWins = (progress.value.totalWins || 0) + 1
if (maxCombo > (progress.value.bestCombo || 0)) progress.value.bestCombo = maxCombo
const next = levelId + 1
if (next > progress.value.maxUnlocked) progress.value.maxUnlocked = next
progress.value.currentLevel = next
persist()
return { stars, newBest: stars > prev }
}
function recordLoss() {
progress.value.winStreak = 0
persist()
}
function setCurrentLevel(levelId) {
progress.value.currentLevel = Math.max(1, Math.min(progress.value.maxUnlocked, levelId))
persist()
}
return {
progress,
touchDailyPlay,
getStars,
recordWin,
recordLoss,
setCurrentLevel,
persist
}
}
@@ -0,0 +1,303 @@
/**
* 糖分突袭 · 游戏音效(InnerAudioContext 池化,H5/小程序通用)
* 每种操作独立音轨 + playbackRate 变调,避免“全是同一个声”
*/
import { ref, onUnmounted } from 'vue'
const STORAGE_KEY = 'tongji_game_sfx_enabled'
const COS = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file'
/** 9 条基础素材(已上传 COS,小程序域名白名单内) */
const SRC = {
tap: `${COS}/20260526/20260526105128557ef8669.mp3`,
swoosh: `${COS}/20260527/202605271539371ebe13672.mp3`,
pop: `${COS}/20260526/202605261051282a0a94508.mp3`,
buzz: `${COS}/20260527/202605271539376ff628472.mp3`,
chime: `${COS}/20260528/202605280937077bad76716.mp3`,
fanfare: `${COS}/20260528/20260528093707b021f5794.mp3`,
blast: `${COS}/20260528/20260528093707ebd4d5733.mp3`,
sparkle: `${COS}/20260528/20260528093707016f07427.mp3`,
stinger: `${COS}/20260528/20260528093709c669f4659.mp3`
}
/**
* 音效表:src / volume / rate(playbackRate) / pool
* rate 不同可在同一素材上听出明显差异
*/
const SFX = {
// —— 交互 ——
select: { src: SRC.tap, volume: 0.32, rate: 1.05, pool: 3 },
deselect: { src: SRC.tap, volume: 0.22, rate: 0.82, pool: 2 },
hint: { src: SRC.tap, volume: 0.26, rate: 1.28, pool: 2 },
swap: { src: SRC.swoosh, volume: 0.38, rate: 1.0, pool: 3 },
swapFail: { src: SRC.buzz, volume: 0.36, rate: 1.18, pool: 2 },
drop: { src: SRC.pop, volume: 0.3, rate: 0.88, pool: 4 },
dropLight: { src: SRC.pop, volume: 0.22, rate: 1.22, pool: 2 },
// —— 消除(按连数)——
match3: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 },
match4: { src: SRC.fanfare, volume: 0.54, rate: 1.06, pool: 3 },
match5: { src: SRC.blast, volume: 0.62, rate: 1.0, pool: 3 },
// —— 消除(按 GI 主色)——
matchLowGi: { src: SRC.chime, volume: 0.42, rate: 0.86, pool: 2 },
matchMidGi: { src: SRC.chime, volume: 0.46, rate: 1.02, pool: 2 },
matchHighGi: { src: SRC.buzz, volume: 0.44, rate: 0.92, pool: 2 },
// —— 连消 ——
combo2: { src: SRC.fanfare, volume: 0.56, rate: 1.12, pool: 2 },
combo3: { src: SRC.blast, volume: 0.64, rate: 1.08, pool: 2 },
comboMega: { src: SRC.blast, volume: 0.72, rate: 1.22, pool: 2 },
// —— 道具 ——
insulin: { src: SRC.sparkle, volume: 0.5, rate: 1.15, pool: 2 },
fiber: { src: SRC.swoosh, volume: 0.48, rate: 1.32, pool: 2 },
meal: { src: SRC.stinger, volume: 0.42, rate: 1.35, pool: 1 },
reshuffle: { src: SRC.swoosh, volume: 0.46, rate: 0.72, pool: 2 },
// —— 反馈 ——
score: { src: SRC.tap, volume: 0.28, rate: 1.38, pool: 2 },
goalTick: { src: SRC.chime, volume: 0.36, rate: 1.45, pool: 2 },
meterWarn: { src: SRC.buzz, volume: 0.5, rate: 1.0, pool: 2 },
meterDanger: { src: SRC.buzz, volume: 0.58, rate: 0.78, pool: 2 },
meterStable: { src: SRC.pop, volume: 0.24, rate: 1.05, pool: 1 },
win: { src: SRC.fanfare, volume: 0.66, rate: 1.0, pool: 1 },
winStinger: { src: SRC.stinger, volume: 0.38, rate: 1.5, pool: 1 },
lose: { src: SRC.buzz, volume: 0.52, rate: 0.68, pool: 1 },
// 兼容旧名
match: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 },
combo: { src: SRC.fanfare, volume: 0.56, rate: 1.1, pool: 3 },
heal: { src: SRC.pop, volume: 0.44, rate: 1.18, pool: 2 },
boost: { src: SRC.sparkle, volume: 0.52, rate: 1.0, pool: 2 },
invalid: { src: SRC.buzz, volume: 0.4, rate: 1.15, pool: 2 },
danger: { src: SRC.blast, volume: 0.58, rate: 0.95, pool: 2 }
}
class SfxPool {
constructor(src, size, volume, rate = 1) {
this.src = src
this.size = size
this.volume = volume
this.rate = rate
this.list = []
this.cursor = 0
this.warmedUp = false
}
create() {
for (let i = 0; i < this.size; i++) {
const ctx = uni.createInnerAudioContext()
ctx.src = this.src
ctx.obeyMuteSwitch = false
ctx.autoplay = false
ctx.volume = this.volume
try {
ctx.playbackRate = this.rate
} catch (_) {}
this.list.push(ctx)
}
}
warmUp() {
if (this.warmedUp) return
if (!this.list.length) this.create()
this.list.forEach((ctx) => {
try {
ctx.volume = 0
ctx.play()
setTimeout(() => {
try {
ctx.stop()
ctx.volume = this.volume
} catch (_) {}
}, 60)
} catch (_) {}
})
this.warmedUp = true
}
play(scale = 1, rateMul = 1) {
if (!this.list.length) this.create()
const ctx = this.list[this.cursor % this.list.length]
this.cursor += 1
const vol = Math.min(1, this.volume * scale)
const rate = Math.min(2, Math.max(0.5, this.rate * rateMul))
try {
ctx.volume = vol
try {
ctx.playbackRate = rate
} catch (_) {}
ctx.stop()
ctx.seek(0)
ctx.play()
} catch (_) {
try {
ctx.play()
} catch (e) {}
}
}
destroy() {
this.list.forEach((ctx) => {
try {
ctx.stop()
ctx.destroy()
} catch (_) {}
})
this.list = []
this.warmedUp = false
}
}
function dominantGi(tiles = []) {
const cnt = { low: 0, mid: 0, high: 0 }
tiles.forEach((t) => {
const g = t?.type?.gi
if (g && cnt[g] !== undefined) cnt[g] += 1
})
let best = 'mid'
let max = 0
Object.entries(cnt).forEach(([k, v]) => {
if (v > max) {
max = v
best = k
}
})
return best
}
export function useGameSfx() {
const enabled = ref(true)
const pools = {}
try {
const stored = uni.getStorageSync(STORAGE_KEY)
if (stored === false || stored === '0' || stored === 0) {
enabled.value = false
}
} catch (_) {}
function ensureAudioOption() {
// #ifdef MP-WEIXIN
try {
uni.setInnerAudioOption({
obeyMuteSwitch: false,
mixWithOther: true
})
} catch (_) {}
// #endif
}
function getPool(name) {
const cfg = SFX[name]
if (!cfg) return null
if (!pools[name]) {
pools[name] = new SfxPool(cfg.src, cfg.pool, cfg.volume, cfg.rate ?? 1)
}
return pools[name]
}
function warmUp() {
if (!enabled.value) return
ensureAudioOption()
Object.keys(SFX).forEach((name) => {
getPool(name)?.warmUp()
})
}
function play(name, scale = 1, rateMul = 1) {
if (!enabled.value) return
getPool(name)?.play(scale, rateMul)
}
function playLayer(primary, secondary, delayMs = 70, secScale = 0.6) {
play(primary)
if (secondary) {
setTimeout(() => play(secondary, secScale), delayMs)
}
}
function playMatch({ matchCount = 3, chain = 0, tiles = [] } = {}) {
if (!enabled.value) return
const giTrack = (() => {
const gi = dominantGi(tiles)
if (gi === 'low') return 'matchLowGi'
if (gi === 'high') return 'matchHighGi'
return 'matchMidGi'
})()
if (chain >= 3) {
playLayer('comboMega', giTrack, 90, 0.55)
return
}
if (chain === 2) {
playLayer('combo3', giTrack, 80, 0.5)
return
}
if (chain === 1) {
playLayer('combo2', giTrack, 70, 0.45)
return
}
if (matchCount >= 5) {
playLayer('match5', 'matchHighGi', 85, 0.5)
} else if (matchCount >= 4) {
playLayer('match4', giTrack, 65, 0.48)
} else {
play(giTrack, 1, 1)
setTimeout(() => play('match3', 0.85), 40)
}
}
function playDrop(count = 1) {
if (!enabled.value || count <= 0) return
const name = count >= 4 ? 'drop' : 'dropLight'
play(name, Math.min(1.2, 0.85 + count * 0.04), count >= 6 ? 0.9 : 1)
}
function playMeter(delta, level) {
if (!enabled.value) return
if (level > 70) play('meterDanger', 1 + (level - 70) * 0.02)
else if (level < 30) play('meterWarn', 0.9, 0.95)
else if (delta > 8) play('meterWarn', 0.75)
else if (delta < -5) play('meterStable', 1.1)
}
function toggleEnabled() {
enabled.value = !enabled.value
try {
uni.setStorageSync(STORAGE_KEY, enabled.value ? '1' : '0')
} catch (_) {}
if (enabled.value) {
warmUp()
play('select')
}
}
function destroyAll() {
Object.keys(pools).forEach((key) => {
pools[key]?.destroy()
delete pools[key]
})
}
onUnmounted(() => {
destroyAll()
})
return {
enabled,
play,
playLayer,
playMatch,
playDrop,
playMeter,
warmUp,
toggleEnabled,
destroyAll
}
}
@@ -0,0 +1,260 @@
import { ref, onUnmounted } from 'vue'
const MIN_HOLD_MS = 280
/**
* 语音转文字(长按说话):微信小程序 WechatSIH5 Web Speech API。
*
* @param {{ onResult?: (text: string) => void, showToast?: (msg: string) => void }} options
*/
export function useSpeechToText({ onResult, showToast } = {}) {
const sttListening = ref(false)
const sttHolding = ref(false)
const sttSupported = ref(false)
let wxRecordManager = null
let h5Recognition = null
let holdStartTs = 0
let holdSessionId = 0
let startRequested = false
let recordAuthorized = null
function notify(msg) {
if (!msg) return
if (typeof showToast === 'function') {
showToast(msg)
return
}
uni.showToast({ title: msg, icon: 'none', duration: 2000 })
}
function emitResult(text) {
const t = String(text || '').trim()
const heldMs = Date.now() - holdStartTs
if (!t) {
if (heldMs < MIN_HOLD_MS) return
notify('没听清,请再试一次')
return
}
if (typeof onResult === 'function') {
onResult(t)
}
}
function stopListening() {
// #ifdef MP-WEIXIN
if (wxRecordManager && (sttListening.value || startRequested)) {
try {
wxRecordManager.stop()
} catch (e) {}
}
// #endif
// #ifdef H5
if (h5Recognition && sttListening.value) {
try {
h5Recognition.stop()
} catch (e) {}
}
// #endif
startRequested = false
sttListening.value = false
}
function ensureRecordAuth(onGranted) {
// #ifdef MP-WEIXIN
if (recordAuthorized === true) {
onGranted()
return
}
uni.authorize({
scope: 'scope.record',
success() {
recordAuthorized = true
onGranted()
},
fail() {
recordAuthorized = false
sttHolding.value = false
uni.showModal({
title: '需要麦克风权限',
content: '请在设置中允许使用麦克风,以便长按说话',
confirmText: '去设置',
success(res) {
if (res.confirm) {
uni.openSetting({})
}
}
})
}
})
// #endif
// #ifndef MP-WEIXIN
onGranted()
// #endif
}
function startWechatRecord(session) {
// #ifdef MP-WEIXIN
if (!wxRecordManager || session !== holdSessionId || !sttHolding.value) {
return
}
try {
startRequested = true
wxRecordManager.start({
duration: 60000,
lang: 'zh_CN'
})
} catch (e) {
startRequested = false
sttHolding.value = false
notify('无法开始录音')
}
// #endif
}
function startH5Record() {
// #ifdef H5
if (!h5Recognition) {
notify('当前浏览器不支持语音输入')
sttHolding.value = false
return
}
try {
startRequested = true
h5Recognition.start()
sttListening.value = true
} catch (e) {
startRequested = false
sttHolding.value = false
notify('无法开始语音识别,请检查麦克风权限')
}
// #endif
}
/** 长按开始:手指按下 */
function beginHoldSpeech() {
if (!sttSupported.value) {
notify('当前环境不支持语音输入')
return
}
if (sttHolding.value) return
const session = ++holdSessionId
holdStartTs = Date.now()
sttHolding.value = true
// #ifdef MP-WEIXIN
ensureRecordAuth(() => startWechatRecord(session))
// #endif
// #ifdef H5
startH5Record()
// #endif
// #ifndef MP-WEIXIN
// #ifndef H5
sttHolding.value = false
notify('当前端暂不支持语音输入')
// #endif
// #endif
}
/** 松手结束:手指抬起 */
function endHoldSpeech() {
if (!sttHolding.value && !sttListening.value && !startRequested) {
return
}
sttHolding.value = false
if (sttListening.value || startRequested) {
stopListening()
return
}
holdSessionId += 1
}
// #ifdef MP-WEIXIN
try {
// eslint-disable-next-line no-undef
const plugin = requirePlugin('WechatSI')
wxRecordManager = plugin.getRecordRecognitionManager()
sttSupported.value = !!wxRecordManager
wxRecordManager.onStart = () => {
if (!sttHolding.value) {
try {
wxRecordManager.stop()
} catch (e) {}
return
}
startRequested = false
sttListening.value = true
}
wxRecordManager.onStop = (res) => {
sttListening.value = false
startRequested = false
emitResult(res?.result)
}
wxRecordManager.onError = (res) => {
sttListening.value = false
startRequested = false
sttHolding.value = false
notify(res?.msg || '语音识别失败')
}
} catch (e) {
console.warn('WechatSI record recognition not available', e)
}
// #endif
// #ifdef H5
try {
if (typeof window !== 'undefined') {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition
if (SR) {
h5Recognition = new SR()
h5Recognition.lang = 'zh-CN'
h5Recognition.interimResults = false
h5Recognition.continuous = false
h5Recognition.maxAlternatives = 1
sttSupported.value = true
h5Recognition.onresult = (event) => {
const item = event.results?.[0]?.[0]
emitResult(item?.transcript || '')
}
h5Recognition.onend = () => {
sttListening.value = false
startRequested = false
sttHolding.value = false
}
h5Recognition.onerror = (event) => {
sttListening.value = false
startRequested = false
sttHolding.value = false
if (event?.error === 'not-allowed') {
notify('请允许浏览器使用麦克风')
} else if (event?.error !== 'aborted') {
notify('语音识别失败')
}
}
}
}
} catch (e) {
console.warn('H5 SpeechRecognition not available', e)
}
// #endif
onUnmounted(() => {
sttHolding.value = false
holdSessionId += 1
stopListening()
})
return {
sttListening,
sttHolding,
sttSupported,
beginHoldSpeech,
endHoldSpeech,
stopSpeechToText: endHoldSpeech
}
}
+654
View File
@@ -0,0 +1,654 @@
/**
* 棋盘食材 · 仿真插画 SVG(渐变 + 高光 + 阴影,开心消消乐式 2.5D)
*/
import { svgToDataUrl } from '../utils/svgDataUrl.js'
function gid(key, suffix) {
return `f-${String(key).replace(/[^a-z0-9]/gi, '')}-${suffix}`
}
function wrap(key, body) {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">${body}</svg>`
}
function shadow(key) {
const id = gid(key, 'sh')
return `<filter id="${id}"><feDropShadow dx="0" dy="2.5" stdDeviation="2.2" flood-color="#1a1a2e" flood-opacity="0.28"/></filter>`
}
function shine(x, y, rx, ry) {
return `<ellipse cx="${x}" cy="${y}" rx="${rx}" ry="${ry}" fill="#fff" opacity="0.38"/>`
}
/** 圆形水果 */
function artRound(key, base, dark, light, extra = '') {
const g = gid(key, 'g')
return wrap(key, `
<defs>
<radialGradient id="${g}" cx="36%" cy="30%" r="68%">
<stop offset="0%" stop-color="${light}"/>
<stop offset="55%" stop-color="${base}"/>
<stop offset="100%" stop-color="${dark}"/>
</radialGradient>
${shadow(key)}
</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="36" rx="21" ry="23" fill="url(#${g})"/>
${shine(24, 28, 7, 5)}
<path d="M32 13 Q35 9 37 15" stroke="#5d4037" stroke-width="2" fill="none" stroke-linecap="round"/>
<ellipse cx="38" cy="15" rx="5.5" ry="3" fill="#66bb6a" transform="rotate(28 38 15)"/>
${extra}
</g>`)
}
/** 叶菜 */
function artLeafy(key, light, mid, dark) {
const g1 = gid(key, 'g1')
const g2 = gid(key, 'g2')
return wrap(key, `
<defs>
<linearGradient id="${g1}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${mid}"/>
</linearGradient>
<linearGradient id="${g2}" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="${mid}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>
${shadow(key)}
</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 8 C18 18 14 34 20 48 C24 54 32 56 32 56 C32 56 40 54 44 48 C50 34 46 18 32 8Z" fill="url(#${g1})"/>
<path d="M32 14 C26 22 24 32 28 42 C30 46 32 48 32 48 C32 48 34 46 36 42 C40 32 38 22 32 14Z" fill="url(#${g2})" opacity="0.85"/>
${shine(26, 24, 6, 8)}
</g>`)
}
/** 西兰花 */
function artBroccoli(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs>
<radialGradient id="${g}" cx="50%" cy="40%" r="55%">
<stop offset="0%" stop-color="#7cb342"/><stop offset="100%" stop-color="#33691e"/>
</radialGradient>
${shadow(key)}
</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="28" y="38" width="8" height="16" rx="3" fill="#558b2f"/>
<circle cx="22" cy="28" r="9" fill="url(#${g})"/><circle cx="32" cy="22" r="10" fill="url(#${g})"/>
<circle cx="42" cy="28" r="9" fill="url(#${g})"/><circle cx="32" cy="32" r="8" fill="#689f38"/>
${shine(28, 20, 5, 4)}
</g>`)
}
/** 长条(黄瓜/香蕉/玉米) */
function artLong(key, base, dark, light, type = 'cucumber') {
const g = gid(key, 'g')
if (type === 'banana') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M18 46 Q14 30 28 14 Q38 8 46 16 Q52 24 44 38 Q36 52 22 50 Q16 48 18 46Z" fill="url(#${g})"/>
<path d="M24 42 Q20 32 30 20" stroke="${dark}" stroke-width="1.2" fill="none" opacity="0.5"/>
${shine(30, 26, 5, 3)}
</g>`)
}
if (type === 'corn') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${base}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="34" rx="14" ry="22" fill="url(#${g})"/>
<g fill="${dark}" opacity="0.55">${Array.from({ length: 5 }, (_, r) =>
Array.from({ length: 4 }, (_, c) =>
`<circle cx="${22 + c * 5}" cy="${20 + r * 5}" r="1.2"/>`
).join('')
).join('')}</g>
<path d="M32 10 L34 18 M32 10 L30 18" stroke="#558b2f" stroke-width="2" stroke-linecap="round"/>
${shine(26, 26, 5, 8)}
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="32" rx="10" ry="24" fill="url(#${g})" transform="rotate(-12 32 32)"/>
<ellipse cx="28" cy="22" rx="3" ry="5" fill="#fff" opacity="0.25" transform="rotate(-12 28 22)"/>
</g>`)
}
/** 茄子 */
function artEggplant(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="20%" y1="0%" x2="80%" y2="100%">
<stop offset="0%" stop-color="#9575cd"/><stop offset="100%" stop-color="#4527a0"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="38" rx="13" ry="20" fill="url(#${g})"/>
<path d="M32 18 Q34 12 32 8 Q30 12 32 18" fill="#558b2f"/>
${shine(26, 32, 5, 7)}
</g>`)
}
/** 番茄 */
function artTomato(key) {
return artRound(key, '#e53935', '#b71c1c', '#ef5350',
'<path d="M24 16 Q32 12 40 16" stroke="#2e7d32" stroke-width="2.5" fill="none" stroke-linecap="round"/>')
}
/** 甜椒 */
function artPepper(key, color, dark) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="30%" y1="0%" x2="70%" y2="100%">
<stop offset="0%" stop-color="${color}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 10 Q26 14 24 24 Q22 38 28 48 Q32 54 36 48 Q42 38 40 24 Q38 14 32 10Z" fill="url(#${g})"/>
<path d="M30 12 Q32 8 34 12" stroke="#33691e" stroke-width="2" fill="none"/>
${shine(28, 26, 5, 6)}
</g>`)
}
/** 洋葱 */
function artOnion(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="45%" cy="35%" r="60%">
<stop offset="0%" stop-color="#ffe0b2"/><stop offset="100%" stop-color="#bc8f5a"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="36" rx="18" ry="20" fill="url(#${g})"/>
<path d="M32 16 Q28 22 32 28 Q36 22 32 16" fill="#d7ccc8"/>
${shine(24, 30, 6, 5)}
</g>`)
}
/** 葡萄串 */
function artGrapes(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="65%">
<stop offset="0%" stop-color="#ba68c8"/><stop offset="100%" stop-color="#6a1b9a"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 10 L32 18" stroke="#558b2f" stroke-width="2"/>
<ellipse cx="32" cy="22" rx="4" ry="4.5" fill="url(#${g})"/>
<ellipse cx="26" cy="28" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="38" cy="28" rx="4" ry="4.5" fill="url(#${g})"/>
<ellipse cx="22" cy="34" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="32" cy="34" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="42" cy="34" rx="4" ry="4.5" fill="url(#${g})"/>
<ellipse cx="28" cy="40" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="36" cy="40" rx="4" ry="4.5" fill="url(#${g})"/>
${shine(28, 24, 3, 2)}
</g>`)
}
/** 樱桃 */
function artCherry(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="35%" cy="30%" r="70%">
<stop offset="0%" stop-color="#f06292"/><stop offset="100%" stop-color="#880e4f"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 12 Q24 18 22 26 M32 12 Q40 18 42 26" stroke="#558b2f" stroke-width="2" fill="none"/>
<circle cx="22" cy="32" r="9" fill="url(#${g})"/><circle cx="42" cy="32" r="9" fill="url(#${g})"/>
${shine(19, 29, 3, 2)}${shine(39, 29, 3, 2)}
</g>`)
}
/** 草莓 */
function artStrawberry(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#ef5350"/><stop offset="100%" stop-color="#c62828"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 14 Q18 28 22 46 Q26 54 32 56 Q38 54 42 46 Q46 28 32 14Z" fill="url(#${g})"/>
<path d="M22 16 Q32 10 42 16 Q32 20 22 16Z" fill="#43a047"/>
${shine(26, 28, 4, 6)}
<g fill="#ffeb3b" opacity="0.85">${[28, 34, 38, 30, 36].map((x, i) =>
`<circle cx="${x}" cy="${32 + (i % 3) * 5}" r="1.1"/>`
).join('')}</g>
</g>`)
}
/** 豆类 */
function artBeans(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#d7ccc8"/><stop offset="100%" stop-color="#8d6e63"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="24" cy="32" rx="9" ry="12" fill="url(#${g})" transform="rotate(-20 24 32)"/>
<ellipse cx="36" cy="30" rx="9" ry="12" fill="url(#${g})" transform="rotate(15 36 30)"/>
<ellipse cx="32" cy="42" rx="9" ry="12" fill="url(#${g})" transform="rotate(-5 32 42)"/>
</g>`)
}
/** 饮品杯 */
function artCup(key, liquid, cup = '#eceff1', type = 'glass') {
const g = gid(key, 'g')
if (type === 'bottle') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${liquid}"/><stop offset="100%" stop-color="${cup}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="24" y="14" width="16" height="6" rx="2" fill="#b0bec5"/>
<path d="M22 20 L24 52 Q32 56 40 52 L42 20Z" fill="url(#${g})"/>
${shine(28, 30, 4, 10)}
</g>`)
}
if (type === 'wine') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="100%" x2="0%" y2="0%">
<stop offset="0%" stop-color="#4a0e16"/><stop offset="100%" stop-color="${liquid}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M28 18 L26 38 Q32 46 38 38 L36 18Z" fill="url(#${g})"/>
<rect x="30" y="38" width="4" height="12" fill="#cfd8dc"/>
<ellipse cx="32" cy="52" rx="8" ry="2" fill="#cfd8dc"/>
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${liquid}"/><stop offset="100%" stop-color="${cup}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M18 24 L20 48 Q32 54 44 48 L46 24Z" fill="#eceff1" stroke="#b0bec5" stroke-width="1"/>
<path d="M20 28 L22 46 Q32 50 42 46 L44 28Z" fill="url(#${g})"/>
${shine(26, 32, 4, 8)}
</g>`)
}
/** 蘑菇 */
function artMushroom(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
<stop offset="0%" stop-color="#a1887f"/><stop offset="100%" stop-color="#5d4037"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="28" y="34" width="8" height="16" rx="3" fill="#efebe9"/>
<ellipse cx="32" cy="30" rx="18" ry="14" fill="url(#${g})"/>
<ellipse cx="24" cy="26" rx="3" ry="2" fill="#efebe9" opacity="0.7"/>
<ellipse cx="36" cy="24" rx="2.5" ry="1.8" fill="#efebe9" opacity="0.7"/>
${shine(24, 24, 6, 4)}
</g>`)
}
/** 芹菜 */
function artCelery(key) {
return artLeafy(key, '#aed581', '#7cb342', '#558b2f')
}
/** 四季豆 */
function artGreenBean(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#9ccc65"/><stop offset="100%" stop-color="#33691e"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="26" cy="32" rx="5" ry="18" fill="url(#${g})" transform="rotate(-15 26 32)"/>
<ellipse cx="38" cy="34" rx="5" ry="18" fill="url(#${g})" transform="rotate(12 38 34)"/>
</g>`)
}
/** 饭碗 */
function artRiceBowl(key, rice, bowl = '#e0e0e0', dark = '#bdbdbd') {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${rice}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M12 36 Q32 58 52 36 L48 48 Q32 56 16 48Z" fill="${bowl}"/>
<ellipse cx="32" cy="36" rx="20" ry="8" fill="url(#${g})"/>
${shine(24, 34, 6, 3)}
</g>`)
}
/** 薯类 */
function artRoot(key, base, dark, light) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="36" rx="16" ry="20" fill="url(#${g})"/>
<path d="M22 28 Q32 22 42 28" stroke="${base}" stroke-width="1.5" fill="none" opacity="0.4"/>
${shine(24, 30, 5, 6)}
</g>`)
}
/** 肉类 */
function artMeat(key, base, dark, type = 'steak') {
const g = gid(key, 'g')
if (type === 'chicken') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="30" cy="38" rx="14" ry="16" fill="url(#${g})"/>
<ellipse cx="38" cy="28" rx="8" ry="10" fill="url(#${g})"/>
<circle cx="42" cy="24" r="3" fill="#efebe9"/>
${shine(26, 32, 5, 6)}
</g>`)
}
if (type === 'fish') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="34" rx="20" ry="12" fill="url(#${g})"/>
<path d="M52 34 L60 28 L60 40Z" fill="${dark}"/>
<circle cx="22" cy="32" r="2.5" fill="#fff"/>
${shine(28, 30, 6, 4)}
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M16 40 Q20 22 36 20 Q50 20 48 38 Q46 50 32 52 Q18 52 16 40Z" fill="url(#${g})"/>
<ellipse cx="28" cy="32" rx="4" ry="3" fill="#efebe9" opacity="0.5"/>
${shine(26, 28, 6, 4)}
</g>`)
}
/** 虾/蟹 */
function artSeafood(key, type = 'shrimp') {
const g = gid(key, 'g')
if (type === 'crab') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ff7043"/><stop offset="100%" stop-color="#bf360c"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="36" rx="14" ry="12" fill="url(#${g})"/>
<circle cx="28" cy="32" r="2" fill="#fff"/><circle cx="36" cy="32" r="2" fill="#fff"/>
<path d="M18 30 L10 24 M18 38 L10 44 M46 30 L54 24 M46 38 L54 44" stroke="#bf360c" stroke-width="2.5" stroke-linecap="round"/>
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ff8a65"/><stop offset="100%" stop-color="#e64a19"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M16 40 Q28 16 48 28 Q42 36 38 44 Q28 52 16 40Z" fill="url(#${g})"/>
${shine(30, 28, 5, 4)}
</g>`)
}
/** 菠萝 */
function artPineapple(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffca28"/><stop offset="100%" stop-color="#f57f17"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="38" rx="16" ry="18" fill="url(#${g})"/>
<g stroke="#e65100" stroke-width="1" opacity="0.45">${[-8, 0, 8].map(o =>
`<line x1="${24 + o}" y1="24" x2="${24 + o}" y2="52"/>`
).join('')}</g>
<path d="M24 18 L32 8 L40 18" fill="#43a047"/>
${shine(26, 32, 5, 6)}
</g>`)
}
/** 奇异果 */
function artKiwi(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#dce775"/><stop offset="60%" stop-color="#9ccc65"/><stop offset="100%" stop-color="#558b2f"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<circle cx="32" cy="34" r="18" fill="#8d6e63"/>
<circle cx="32" cy="34" r="14" fill="url(#${g})"/>
<circle cx="32" cy="34" r="4" fill="#fff" opacity="0.5"/>
${shine(26, 28, 4, 3)}
</g>`)
}
/** 蜜瓜 */
function artMelon(key) {
return artRound(key, '#aed581', '#689f38', '#c5e1a5', '')
}
/** 蜂蜜 */
function artHoney(key) {
return artCup(key, '#ffb300', '#ff8f00', 'bottle')
}
/** 面包类 */
function artBread(key, base, dark, type = 'loaf') {
const g = gid(key, 'g')
if (type === 'donut') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<circle cx="32" cy="34" r="18" fill="url(#${g})"/>
<circle cx="32" cy="34" r="7" fill="#fff5f5"/>
<path d="M20 28 Q32 18 44 28" stroke="#f48fb1" stroke-width="4" fill="none" stroke-linecap="round"/>
${shine(24, 28, 5, 4)}
</g>`)
}
if (type === 'mantou') {
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="38" rx="16" ry="14" fill="url(#${g})"/>
<path d="M18 34 Q32 26 46 34" stroke="${dark}" stroke-width="1" fill="none" opacity="0.3"/>
${shine(24, 32, 6, 4)}
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="14" y="22" width="36" height="28" rx="8" fill="url(#${g})"/>
${shine(22, 28, 8, 5)}
</g>`)
}
/** 面条 */
function artNoodle(key, soup = '#d7ccc8') {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${soup}"/><stop offset="100%" stop-color="#a1887f"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="42" rx="20" ry="10" fill="#eceff1"/>
<ellipse cx="32" cy="38" rx="18" ry="8" fill="url(#${g})"/>
<path d="M20 36 Q26 32 32 36 Q38 40 44 36" stroke="#ffe082" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<path d="M22 40 Q30 44 38 40" stroke="#ffe082" stroke-width="2" fill="none" stroke-linecap="round"/>
</g>`)
}
/** 南瓜 */
function artPumpkin(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
<stop offset="0%" stop-color="#ffb74d"/><stop offset="100%" stop-color="#e65100"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="24" cy="36" rx="10" ry="16" fill="url(#${g})"/>
<ellipse cx="32" cy="34" rx="11" ry="18" fill="url(#${g})"/>
<ellipse cx="40" cy="36" rx="10" ry="16" fill="url(#${g})"/>
<path d="M32 16 Q34 10 32 8" stroke="#558b2f" stroke-width="2.5" fill="none"/>
${shine(26, 28, 5, 6)}
</g>`)
}
/** 薯条 */
function artFries(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffe082"/><stop offset="100%" stop-color="#f9a825"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M18 48 L22 24 L46 24 L50 48Z" fill="#ef5350"/>
${[26, 32, 38, 44].map(x => `<rect x="${x}" y="18" width="4" height="22" rx="2" fill="url(#${g})"/>`).join('')}
</g>`)
}
/** 西瓜 */
function artWatermelon(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ef5350"/><stop offset="100%" stop-color="#c62828"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M12 40 Q32 8 52 40 Q32 56 12 40Z" fill="#2e7d32"/>
<path d="M16 40 Q32 14 48 40 Q32 52 16 40Z" fill="url(#${g})"/>
${[24, 32, 40].map(x => `<circle cx="${x}" cy="36" r="1.2" fill="#212121"/>`).join('')}
${shine(24, 30, 4, 3)}
</g>`)
}
/** 糖果 */
function artCandy(key, base, dark, type = 'lollipop') {
const g = gid(key, 'g')
if (type === 'wrap') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="20" y="22" width="24" height="20" rx="4" fill="url(#${g})"/>
<path d="M20 26 L16 30 L20 34 M44 26 L48 30 L44 34" stroke="${dark}" stroke-width="2" fill="none"/>
</g>`)
}
return wrap(key, `
<defs><radialGradient id="${g}" cx="35%" cy="30%" r="70%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<circle cx="32" cy="28" r="14" fill="url(#${g})"/>
<rect x="30" y="40" width="4" height="14" rx="2" fill="#eceff1"/>
${shine(26, 24, 4, 3)}
</g>`)
}
/** 糯米团 */
function artDango(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
<stop offset="0%" stop-color="#f8bbd0"/><stop offset="100%" stop-color="#ec407a"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<circle cx="22" cy="36" r="8" fill="#fff"/>
<circle cx="32" cy="32" r="8" fill="url(#${g})"/>
<circle cx="42" cy="36" r="8" fill="#fff"/>
<rect x="14" y="44" width="36" height="3" rx="1.5" fill="#8d6e63"/>
</g>`)
}
/** 爆米花 */
function artPopcorn(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
<stop offset="0%" stop-color="#fff9c4"/><stop offset="100%" stop-color="#fff176"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M16 48 L20 36 L48 36 L52 48Z" fill="#ef5350"/>
<circle cx="24" cy="30" r="7" fill="url(#${g})"/><circle cx="34" cy="24" r="8" fill="url(#${g})"/>
<circle cx="44" cy="30" r="7" fill="url(#${g})"/><circle cx="32" cy="32" r="6" fill="url(#${g})"/>
</g>`)
}
const FOOD_ART = {
lettuce: () => artLeafy('lettuce', '#c5e1a5', '#81c784', '#388e3c'),
broccoli: () => artBroccoli('broccoli'),
apple: () => artRound('apple', '#e53935', '#b71c1c', '#ef5350'),
cucumber: () => artLong('cucumber', '#66bb6a', '#2e7d32', '#a5d6a7'),
eggplant: () => artEggplant('eggplant'),
tomato: () => artTomato('tomato'),
pepper: () => artPepper('pepper', '#43a047', '#1b5e20'),
onion: () => artOnion('onion'),
pear: () => artRound('pear', '#c0ca33', '#827717', '#dce775', ''),
orange: () => artRound('orange', '#fb8c00', '#e65100', '#ffb74d'),
peach: () => artRound('peach', '#ff8a65', '#e64a19', '#ffab91'),
cherry: () => artCherry('cherry'),
grape: () => artGrapes('grape'),
strawberry: () => artStrawberry('strawberry'),
soybean: () => artBeans('soybean'),
milk: () => artCup('milk', '#eceff1', '#b0bec5'),
tea: () => artCup('tea', '#a1887f', '#6d4c41'),
mushroom: () => artMushroom('mushroom'),
celery: () => artCelery('celery'),
greenBean: () => artGreenBean('greenBean'),
brownRice: () => artRiceBowl('brownRice', '#bcaaa4', '#8d6e63', '#6d4c41'),
sweetPotato: () => artRoot('sweetPotato', '#c75b39', '#8d2600', '#e57373'),
taro: () => artRoot('taro', '#b39ddb', '#5e35b1', '#d1c4e9'),
fish: () => artMeat('fish', '#4fc3f7', '#0277bd', 'fish'),
chicken: () => artMeat('chicken', '#ffb74d', '#e65100', 'chicken'),
beef: () => artMeat('beef', '#c0504d', '#7f0000', 'steak'),
leanMeat: () => artMeat('leanMeat', '#b5654d', '#8d4000', 'steak'),
shrimp: () => artSeafood('shrimp', 'shrimp'),
crab: () => artSeafood('crab', 'crab'),
banana: () => artLong('banana', '#fdd835', '#f9a825', '#fff176', 'banana'),
mango: () => artRound('mango', '#ffb300', '#ff6f00', '#ffca28'),
pineapple: () => artPineapple('pineapple'),
kiwi: () => artKiwi('kiwi'),
melon: () => artMelon('melon'),
honey: () => artHoney('honey'),
wine: () => artCup('wine', '#9b2c3b', '#4a0e16', 'wine'),
beer: () => artCup('beer', '#ffb300', '#ff8f00'),
coffee: () => artCup('coffee', '#6f4e37', '#3e2723'),
corn: () => artLong('corn', '#f9c513', '#f57f17', '#fff176', 'corn'),
udon: () => artNoodle('udon', '#d9b88f'),
whiteRice: () => artRiceBowl('whiteRice', '#f5f5f5', '#eeeeee', '#bdbdbd'),
whiteBread: () => artBread('whiteBread', '#d9a85c', '#a1887f', 'loaf'),
mantou: () => artBread('mantou', '#ece0c8', '#bcaaa4', 'mantou'),
youtiao: () => artBread('youtiao', '#d4943f', '#a1660a', 'loaf'),
donut: () => artBread('donut', '#e87fb0', '#ad1457', 'donut'),
popcorn: () => artPopcorn('popcorn'),
ramen: () => artNoodle('ramen', '#e0a96d'),
pumpkin: () => artPumpkin('pumpkin'),
bakedPotato: () => artFries('bakedPotato'),
watermelon: () => artWatermelon('watermelon'),
sugar: () => artCandy('sugar', '#ff79b0', '#c2185b', 'lollipop'),
maltose: () => artCandy('maltose', '#ffa726', '#e65100', 'wrap'),
soda: () => artCup('soda', '#c62828', '#880e0e', 'bottle'),
orangeJuice: () => artCup('orangeJuice', '#ff9f1c', '#e65100', 'bottle'),
stickyRice: () => artDango('stickyRice')
}
const imgCache = {}
/** 获取食材插画 data URL(带缓存) */
export function getFoodImg(key) {
if (imgCache[key]) return imgCache[key]
const builder = FOOD_ART[key]
if (!builder) return ''
imgCache[key] = svgToDataUrl(builder())
return imgCache[key]
}
export function warmUpFoodImgs(keys) {
keys.forEach((k) => getFoodImg(k))
}
@@ -0,0 +1,50 @@
/**
* 食材图标 URLTwemoji PNG,小程序 / H5 通用)
* 微信小程序 <image> 不支持 SVG data URL,必须用 https PNG
*/
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72'
/** emoji → Twemoji PNG 地址 */
export function emojiToTwemojiUrl(emoji) {
if (!emoji) return ''
const parts = []
for (let i = 0; i < emoji.length;) {
const cp = emoji.codePointAt(i)
if (!cp) break
parts.push(cp.toString(16))
i += cp > 0xffff ? 2 : 1
}
return `${TWEMOJI_BASE}/${parts.join('-')}.png`
}
const imgCache = {}
export function getFoodImgUrl(emoji) {
if (!emoji) return ''
if (imgCache[emoji]) return imgCache[emoji]
const url = emojiToTwemojiUrl(emoji)
imgCache[emoji] = url
return url
}
/** 预加载本局食材图标(小程序提前拉取,减少首屏空白) */
export function warmUpFoodImgs(foods) {
const list = Array.isArray(foods) ? foods : []
list.forEach((f) => {
const icon = typeof f === 'string' ? f : f?.icon
// 优先预热自定义图片素材,缺省回退 emoji PNG
const url = (typeof f === 'object' && f?.img) ? f.img : getFoodImgUrl(icon)
if (!url) return
// #ifdef MP-WEIXIN
try {
uni.getImageInfo({ src: url, fail: () => {} })
} catch (_) {}
// #endif
// #ifdef H5
try {
const img = new Image()
img.src = url
} catch (_) {}
// #endif
})
}
@@ -0,0 +1,189 @@
/**
* 全量食材库:key 用于消除匹配,img 为 Twemoji PNG(小程序可用)
*/
import { getFoodImgUrl } from './gameFoodImg.js'
export const GI_LABEL = { low: '低血糖', mid: '中血糖', high: '高血糖' }
/** 若实际食用时的升糖幅度(教育提示用,正值=升糖) */
export function getEatVal(food) {
if (food.eatVal != null) return food.eatVal
if (food.gi === 'high') return Math.abs(food.val)
if (food.gi === 'mid') return Math.max(1, food.val)
return Math.max(1, Math.abs(food.val))
}
/** 从餐盘消除该食物后的血糖变化(负值=控糖有益) */
export function getClearVal(food) {
if (food.clearVal != null) return food.clearVal
const eat = getEatVal(food)
if (food.gi === 'high') return -Math.max(8, Math.round(eat * 0.5))
if (food.gi === 'mid') return -1
return 0
}
/** 点击食材时的升糖说明文案 */
export function formatEatLabel(food) {
if (food.gi === 'low') return '升糖低'
return `若食用 +${getEatVal(food)}`
}
/** 含糖等级简称:低糖 / 中糖 / 高糖 */
export const GI_SHORT = { low: '低糖', mid: '中糖', high: '高糖' }
/** 含糖等级对应的提示主色(绿 / 橙 / 红) */
export const GI_COLOR = { low: '#16a34a', mid: '#ea580c', high: '#dc2626' }
/** 部分代表食物的专属科普文案(一句话,便于老人记忆) */
const FOOD_TIP_OVERRIDE = {
whiteRice: '精制主食,升糖快,建议小份',
stickyRice: '糯米黏软,升糖很快,要少吃',
whiteBread: '精制面食,升糖快',
mantou: '白面馒头,升糖快,配菜一起吃更稳',
youtiao: '油炸又高糖,少吃为好',
sugar: '纯糖,升糖最快,尽量不吃',
maltose: '糖分很高,升糖快',
soda: '含糖饮料,升糖很快,不建议喝',
orangeJuice: '果汁含糖高,吃整果更好',
watermelon: '很甜,升糖快,一次别吃太多',
donut: '又甜又油,升糖高',
banana: '偏甜水果,适量吃',
sweetPotato: '粗粮但有糖,可代替部分主食',
corn: '粗粮主食,适量吃',
honey: '本质是糖,别当健康品多吃',
broccoli: '新鲜蔬菜,升糖很慢,可多吃',
cucumber: '清爽蔬菜,几乎不升糖',
lettuce: '绿叶菜,升糖很低,可多吃',
celery: '高纤维蔬菜,升糖低',
apple: '带皮整果,升糖较慢',
milk: '低糖,含蛋白,适量喝好',
fish: '优质蛋白,升糖低',
beef: '蛋白为主,升糖低',
milkOats: '燕麦加奶,升糖中等,选无糖燕麦更好',
grainMantou: '杂粮做的,比白馒头稳,仍要控量',
riceNoodleSoup: '米粉升糖快,汤粉要少吃',
friedNoodles: '油多又是精面,升糖快',
centuryEggCongee: '白粥熬得软烂,升糖很快,糖友要少喝'
}
/** 含糖等级通用科普文案 */
const GI_TIP_DEFAULT = {
low: '升糖慢,相对放心,可常吃',
mid: '升糖中等,注意分量',
high: '含糖高、升糖快,要少吃'
}
/** 点击食物时展示的一句话科普(认知教育用) */
export function getFoodTip(food) {
if (!food) return ''
if (food.tip) return food.tip
if (FOOD_TIP_OVERRIDE[food.key]) return FOOD_TIP_OVERRIDE[food.key]
return GI_TIP_DEFAULT[food.gi] || ''
}
/** 自定义图片素材目录(腾讯云 COS,优先使用真实图片而非 emoji) */
const GAMES_IMG_BASE = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games'
const RAW_FOODS = [
// 低 GI
{ key: 'lettuce', icon: '🥬', name: '生菜', gi: 'low', val: -3, color: '#8BC34A' },
{ key: 'broccoli', icon: '🥦', name: '西兰花', gi: 'low', val: -3, color: '#4E8A3A' },
{ key: 'apple', icon: '🍎', name: '苹果', gi: 'low', val: -2, color: '#E53935' },
{ key: 'cucumber', icon: '🥒', name: '黄瓜', gi: 'low', val: -4, color: '#66A82F' },
{ key: 'eggplant', icon: '🍆', name: '茄子', gi: 'low', val: -3, color: '#7E57C2' },
{ key: 'tomato', icon: '🍅', name: '番茄', gi: 'low', val: -2, color: '#EF5350' },
{ key: 'pepper', icon: '🫑', name: '青椒', gi: 'low', val: -3, color: '#43A047' },
{ key: 'onion', icon: '🧅', name: '洋葱', gi: 'low', val: -2, color: '#CDA06B' },
{ key: 'pear', icon: '🍐', name: '雪梨', gi: 'low', val: -2, color: '#C0CA33' },
{ key: 'orange', icon: '🍊', name: '橙子', gi: 'low', val: -2, color: '#FB8C00' },
{ key: 'peach', icon: '🍑', name: '桃子', gi: 'low', val: -2, color: '#FF8A65' },
{ key: 'cherry', icon: '🍒', name: '樱桃', gi: 'low', val: -2, color: '#C2185B' },
{ key: 'grape', icon: '🍇', name: '葡萄', gi: 'low', val: -1, color: '#8E24AA' },
{ key: 'strawberry', icon: '🍓', name: '草莓', gi: 'low', val: -2, color: '#EC407A' },
{ key: 'soybean', icon: '🫘', name: '黄豆', gi: 'low', val: -3, color: '#C9A063' },
{ key: 'milk', icon: '🥛', name: '牛奶', gi: 'low', val: -2, color: '#B0BEC5' },
{ key: 'tea', icon: '🍵', name: '红茶', gi: 'low', val: -1, color: '#A1573B' },
{ key: 'mushroom', icon: '🍄', name: '香菇', gi: 'low', val: -3, color: '#8D6E63' },
{ key: 'celery', icon: '🌿', name: '芹菜', gi: 'low', val: -4, color: '#7CB342' },
{ key: 'greenBean', icon: '🫛', name: '四季豆', gi: 'low', val: -3, color: '#689F38' },
// 中 GI
{ key: 'brownRice', icon: '🍙', name: '糙米饭', gi: 'mid', val: 4, color: '#C9A26B' },
{ key: 'sweetPotato', icon: '🍠', name: '番薯', gi: 'mid', val: 5, color: '#C75B39' },
{ key: 'taro', icon: '🥔', name: '芋头', gi: 'mid', val: 5, color: '#B39DDB' },
{ key: 'fish', icon: '🐟', name: '鱼肉', gi: 'mid', val: 2, color: '#4FA3C7' },
{ key: 'chicken', icon: '🍗', name: '鸡肉', gi: 'mid', val: 3, color: '#D6A15A' },
{ key: 'beef', icon: '🥩', name: '牛肉', gi: 'mid', val: 3, color: '#C0504D' },
{ key: 'leanMeat', icon: '🍖', name: '瘦肉', gi: 'mid', val: 3, color: '#B5654D' },
{ key: 'shrimp', icon: '🦐', name: '虾仁', gi: 'mid', val: 2, color: '#FF7043' },
{ key: 'crab', icon: '🦀', name: '螃蟹', gi: 'mid', val: 2, color: '#F4623A' },
{ key: 'banana', icon: '🍌', name: '香蕉', gi: 'mid', val: 6, color: '#FDD835' },
{ key: 'mango', icon: '🥭', name: '芒果', gi: 'mid', val: 6, color: '#FFB300' },
{ key: 'pineapple', icon: '🍍', name: '菠萝', gi: 'mid', val: 6, color: '#FBC02D' },
{ key: 'kiwi', icon: '🥝', name: '奇异果', gi: 'mid', val: 4, color: '#9CCC65' },
{ key: 'melon', icon: '🍈', name: '哈密瓜', gi: 'mid', val: 6, color: '#AED581' },
{ key: 'honey', icon: '🍯', name: '蜂蜜', gi: 'mid', val: 6, color: '#F9A825' },
{ key: 'wine', icon: '🍷', name: '红酒', gi: 'mid', val: 4, color: '#9B2C3B' },
{ key: 'beer', icon: '🍺', name: '啤酒', gi: 'mid', val: 5, color: '#E0A83E' },
{ key: 'coffee', icon: '☕', name: '咖啡', gi: 'mid', val: 2, color: '#6F4E37' },
{ key: 'corn', icon: '🌽', name: '玉米', gi: 'mid', val: 5, color: '#F9C513' },
{ key: 'udon', icon: '🍲', name: '乌冬面', gi: 'mid', val: 5, color: '#D9B88F' },
{ key: 'milkOats', icon: '🥣', name: '奶冲麦片', gi: 'mid', val: 6, color: '#E8D9B5', img: `${GAMES_IMG_BASE}/%E5%A5%B6%E5%86%B2%E9%BA%A6%E7%89%87.png` },
{ key: 'grainMantou', icon: '🫓', name: '杂粮馒头', gi: 'mid', val: 5, color: '#C8A878', img: `${GAMES_IMG_BASE}/%E6%9D%82%E7%B2%AE%E9%A6%92%E5%A4%B4.png` },
// 高 GI
{ key: 'whiteRice', icon: '🍚', name: '白米饭', gi: 'high', val: 18, color: '#E0E0E0' },
{ key: 'whiteBread', icon: '🍞', name: '白面包', gi: 'high', val: 16, color: '#D9A85C' },
{ key: 'mantou', icon: '🥯', name: '馒头', gi: 'high', val: 17, color: '#ECE0C8' },
{ key: 'youtiao', icon: '🥖', name: '油条', gi: 'high', val: 20, color: '#D4943F' },
{ key: 'donut', icon: '🍩', name: '甜甜圈', gi: 'high', val: 22, color: '#E87FB0' },
{ key: 'popcorn', icon: '🍿', name: '爆米花', gi: 'high', val: 18, color: '#F5E6B3' },
{ key: 'ramen', icon: '🍜', name: '拉面', gi: 'high', val: 17, color: '#E0A96D' },
{ key: 'pumpkin', icon: '🎃', name: '南瓜', gi: 'high', val: 15, color: '#EF6C00' },
{ key: 'bakedPotato', icon: '🍟', name: '焗薯', gi: 'high', val: 20, color: '#F2C14E' },
{ key: 'watermelon', icon: '🍉', name: '西瓜', gi: 'high', val: 16, color: '#F0506A' },
{ key: 'sugar', icon: '🍭', name: '砂糖', gi: 'high', val: 25, color: '#FF79B0' },
{ key: 'maltose', icon: '🍬', name: '麦芽糖', gi: 'high', val: 24, color: '#FFA726' },
{ key: 'soda', icon: '🥤', name: '汽水', gi: 'high', val: 25, color: '#C62828' },
{ key: 'orangeJuice', icon: '🧃', name: '柳橙汁', gi: 'high', val: 22, color: '#FF9F1C' },
{ key: 'stickyRice', icon: '🍡', name: '糯米饭', gi: 'high', val: 19, color: '#F2A7B8' },
{ key: 'riceNoodleSoup', icon: '🍜', name: '汤粉', gi: 'high', val: 16, color: '#E8C9A0', img: `${GAMES_IMG_BASE}/%E6%B1%A4%E7%B2%89.png` },
{ key: 'friedNoodles', icon: '🍝', name: '炒面', gi: 'high', val: 18, color: '#C8843C', img: `${GAMES_IMG_BASE}/%E7%82%92%E9%9D%A2.png` },
{ key: 'centuryEggCongee', icon: '🥣', name: '皮蛋瘦肉粥', gi: 'high', val: 15, color: '#D9CBB0', img: `${GAMES_IMG_BASE}/%E7%9A%AE%E8%9B%8B%E7%98%A6%E8%82%89%E7%B2%A5.png` }
]
export const FOOD_LIBRARY = RAW_FOODS.map((f, i) => {
const eatVal = getEatVal(f)
const clearVal = getClearVal({ ...f, eatVal })
return {
id: i + 1,
...f,
eatVal,
clearVal,
giLabel: GI_LABEL[f.gi],
get img() {
// 优先使用自定义图片素材,缺省回退到 emoji PNG
return f.img || getFoodImgUrl(f.icon)
}
}
})
/** 按 key 查找食材(目标栏等用) */
export function getFoodByKey(key) {
return FOOD_LIBRARY.find((f) => f.key === key) || null
}
/** 为棋盘 tile 使用的 plain 对象(img 预计算,避免 getter 在响应式里反复触发) */
export function cloneFoodType(food) {
return {
id: food.id,
key: food.key,
icon: food.icon,
name: food.name,
gi: food.gi,
giLabel: food.giLabel,
val: food.val,
color: food.color,
img: food.img || getFoodImgUrl(food.icon)
}
}
+43
View File
@@ -0,0 +1,43 @@
/**
* 关卡配置:面向 45+ 的认知练习,节奏放慢、无失败惩罚。
* 每组目标只比上一组略增,步数充裕(仅展示,不会因耗尽判负)。
*/
/**
* 指定关卡固定食材(按 key)。未配置的关卡走随机抽取逻辑。
* 第一关固定为这 5 种带真实图片素材的食物。
*/
const FIXED_LEVEL_FOODS = {
1: ['milkOats', 'grainMantou', 'riceNoodleSoup', 'friedNoodles', 'centuryEggCongee']
}
export function getLevelConfig(levelId) {
const lv = Math.max(1, Math.min(999, Number(levelId) || 1))
const tier = Math.floor((lv - 1) / 5)
return {
id: lv,
// 步数充裕:仅作展示,认全目标即可通关
moves: Math.max(40, 60 - tier * 2),
// 认识目标平缓增长,避免给老人压力
goalTargets: [6 + tier, 8 + tier],
scoreTarget: 2000 + lv * 300,
startGlucose: 50,
// 固定食材(可选):存在时本关只用这些食物
foods: FIXED_LEVEL_FOODS[lv] || null,
boosters: {
insulin: 3,
fiber: 1,
meal: 5
}
}
}
/** 根据本局表现计算 1~3 星:完成即得星,步数越省星越多(不依赖血糖) */
export function calcLevelStars({ movesLeft, levelConfig }) {
const totalMoves = levelConfig?.moves || 1
const ratio = totalMoves > 0 ? movesLeft / totalMoves : 0
let stars = 1
if (ratio >= 0.3) stars = 2
if (ratio >= 0.55) stars = 3
return stars
}
@@ -0,0 +1,596 @@
<!DOCTYPE html><html class="light" lang="zh-CN"><head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>Daily Care - Health Dashboard</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&amp;display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet">
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"secondary-fixed": "#d9e6dd",
"surface-container": "#e8f0e9",
"surface-dim": "#d4dcd5",
"surface-variant": "#dde4dd",
"error-container": "#ffdad6",
"inverse-surface": "#2b322d",
"secondary": "#55615a",
"primary": "#006c49",
"on-secondary-fixed": "#131e19",
"surface-bright": "#f4fbf4",
"on-tertiary-container": "#711419",
"on-primary-fixed-variant": "#005236",
"outline-variant": "#bbcabf",
"primary-container": "#10b981",
"surface": "#f4fbf4",
"on-tertiary-fixed-variant": "#842225",
"tertiary-fixed-dim": "#ffb3af",
"tertiary-fixed": "#ffdad7",
"error": "#ba1a1a",
"surface-container-lowest": "#ffffff",
"surface-container-highest": "#dde4dd",
"primary-fixed": "#6ffbbe",
"on-primary-container": "#00422b",
"secondary-fixed-dim": "#bdcac1",
"on-tertiary": "#ffffff",
"on-primary-fixed": "#002113",
"on-error-container": "#93000a",
"tertiary-container": "#fc7c78",
"on-error": "#ffffff",
"inverse-primary": "#4edea3",
"inverse-on-surface": "#ebf3eb",
"on-secondary-container": "#5b6760",
"surface-container-high": "#e3eae3",
"surface-container-low": "#eef6ee",
"on-secondary-fixed-variant": "#3e4943",
"outline": "#6c7a71",
"secondary-container": "#d9e6dd",
"on-primary": "#ffffff",
"on-surface": "#161d19",
"on-background": "#161d19",
"tertiary": "#a43a3a",
"primary-fixed-dim": "#4edea3",
"on-secondary": "#ffffff",
"background": "#f4fbf4",
"on-surface-variant": "#3c4a42",
"surface-tint": "#006c49",
"on-tertiary-fixed": "#410005"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"2xl": "1rem",
"3xl": "1.5rem",
"full": "9999px"
},
"spacing": {
"card-padding": "20px",
"section-margin": "32px",
"inline-gap": "12px",
"stack-gap": "16px",
"container-margin": "20px"
},
"fontFamily": {
"headline-md": ["Manrope"],
"headline-lg-mobile": ["Manrope"],
"body-lg": ["Manrope"],
"headline-lg": ["Manrope"],
"display-lg": ["Manrope"],
"body-md": ["Manrope"],
"label-md": ["Manrope"]
},
"fontSize": {
"headline-md": ["20px", {"lineHeight": "28px", "fontWeight": "700"}],
"headline-lg-mobile": ["22px", {"lineHeight": "28px", "fontWeight": "700"}],
"body-lg": ["16px", {"lineHeight": "24px", "fontWeight": "500"}],
"headline-lg": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"display-lg": ["32px", {"lineHeight": "40px", "letterSpacing": "-0.02em", "fontWeight": "800"}],
"body-md": ["14px", {"lineHeight": "20px", "fontWeight": "400"}],
"label-md": ["12px", {"lineHeight": "16px", "letterSpacing": "0.02em", "fontWeight": "600"}]
}
}
}
}
</script>
<style>
body { font-family: 'Manrope', sans-serif; -webkit-tap-highlight-color: transparent; }
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; display: inline-block; vertical-align: middle; }
.ambient-shadow { box-shadow: 0 20px 30px -10px rgba(0, 108, 73, 0.08); }
.active-scale:active { transform: scale(0.98); transition: all 0.2s ease; }
.hide-scrollbar::-webkit-scrollbar { display: none; }
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
/* Watering Animation Styles */
@keyframes fall {
0% { transform: translateY(-20px); opacity: 0; }
50% { opacity: 1; }
100% { transform: translateY(40px); opacity: 0; }
}
.droplet {
position: absolute;
width: 4px;
height: 10px;
background: #3b82f6;
border-radius: 50%;
animation: fall 0.6s linear infinite;
}
@keyframes pulse-growth {
0% { transform: scale(1); filter: drop-shadow(0 0 0px rgba(78, 222, 163, 0)); }
50% { transform: scale(1.15); filter: drop-shadow(0 0 15px rgba(78, 222, 163, 0.6)); }
100% { transform: scale(1); filter: drop-shadow(0 0 0px rgba(78, 222, 163, 0)); }
}
.growth-pulse {
animation: pulse-growth 0.8s ease-out;
}
@keyframes shimmer {
0% { opacity: 0; transform: scale(0) rotate(0deg); }
50% { opacity: 1; transform: scale(1.2) rotate(180deg); }
100% { opacity: 0; transform: scale(0) rotate(360deg); }
}
.shimmer-particle {
position: absolute;
color: #4edea3;
font-size: 14px;
pointer-events: none;
animation: shimmer 1s ease-out forwards;
}
/* Blood Sugar Decrease Animation */
@keyframes float-up-fade {
0% { transform: translateY(20px); opacity: 0; }
20% { opacity: 1; }
80% { opacity: 1; }
100% { transform: translateY(-100px); opacity: 0; }
}
.sugar-decrease-text {
position: fixed;
color: #10b981;
font-weight: 800;
font-size: 24px;
pointer-events: none;
z-index: 100;
text-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
animation: float-up-fade 2s ease-out forwards;
}
@keyframes fall-arrow {
0% { transform: translateY(-50vh) scale(0.5); opacity: 0; }
50% { opacity: 0.8; }
100% { transform: translateY(110vh) scale(1.2); opacity: 0; }
}
.sugar-particle {
position: fixed;
color: #10b981;
pointer-events: none;
z-index: 90;
animation: fall-arrow 1.5s linear forwards;
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-background text-on-background min-h-screen pb-24">
<!-- Top App Bar -->
<header class="bg-primary docked full-width top-0 h-48 flex items-end pb-6 px-5 fixed left-0 right-0 z-40">
<div class="flex justify-between items-center w-full">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full border-2 border-on-primary overflow-hidden">
<img class="w-full h-full object-cover" data-alt="A professional studio portrait" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCXIPbeivladmw0-SmLno2vkwVvsObW2D5HUtodItlBnxdhFF8TvZdQYrTt14QCk3n5VXXIpdk3cuWTJHXJgDXd_clQyjLsb477e5h2313zh76yRVns01Q5pLpM-2_RbheRcUioLvObwJtIPJbkDcbkNA61-LyBnbStRcPoh6YOvNHSHjUUR_nDJpPm18ga87ggphbFWF-pt7lRF027swmyitUeVF7HXQDfcG7Mq7E0pqsbhyA7jKxtzmbN-HLh7GIrhc6LOAUaXiX9">
</div>
<div>
<h1 class="text-on-primary font-bold text-headline-lg-mobile leading-tight">Daily Care</h1>
<p class="text-on-primary/80 text-body-md">吕帅</p>
</div>
</div>
<div class="flex gap-2">
<button class="bg-on-primary/10 hover:bg-on-primary/20 text-on-primary rounded-full px-4 py-1.5 flex items-center gap-2 active-scale transition-all">
<span class="material-symbols-outlined text-[18px]" style="font-variation-settings: 'FILL' 1;">bloodtype</span>
<span class="text-label-md">血糖</span>
</button>
<button class="bg-on-primary/10 hover:bg-on-primary/20 text-on-primary w-10 h-10 rounded-full flex items-center justify-center active-scale transition-all">
<span class="material-symbols-outlined text-[20px]">refresh</span>
</button>
</div>
</div>
</header>
<main class="pt-52 px-container-margin space-y-6">
<!-- Toggle Switch -->
<div class="bg-surface-container-high rounded-full p-1 flex items-center">
<button class="flex-1 bg-primary text-on-primary rounded-full py-2 text-label-md font-bold shadow-md transition-all">最近 7 天</button>
<button class="flex-1 text-on-surface-variant py-2 text-label-md font-bold transition-all">最近 30 天</button>
</div>
<!-- Health Calendar -->
<section class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow">
<div class="flex justify-between items-start mb-4">
<div>
<h2 class="text-headline-md font-headline-md text-on-surface">健康日历</h2>
<p class="text-body-md text-outline">2026年5月 · 已记录 3 / 7 天</p>
</div>
<button class="text-primary"><span class="material-symbols-outlined">chevron_right</span></button>
</div>
<div class="grid grid-cols-7 gap-1 text-center mb-4">
<span class="text-label-md text-outline"></span>
<span class="text-label-md text-outline"></span>
<span class="text-label-md text-outline"></span>
<span class="text-label-md text-outline"></span>
<span class="text-label-md text-outline"></span>
<span class="text-label-md text-outline"></span>
<span class="text-label-md text-tertiary"></span>
<span class="py-2 text-body-md text-outline/30">18</span>
<span class="py-2 text-body-md text-outline/30">19</span>
<span class="py-2 text-body-md text-outline/30">20</span>
<span class="py-2 text-body-md text-outline/30">21</span>
<span class="py-2 text-body-md text-outline/30">22</span>
<div class="py-2 relative"><span class="text-body-md font-bold text-on-surface">23</span></div>
<div class="py-2 relative bg-primary-container/10 rounded-lg">
<span class="text-body-md font-bold text-primary">24</span>
<span class="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary rounded-full"></span>
</div>
<div class="py-2 relative bg-primary-container/10 rounded-lg">
<span class="text-body-md font-bold text-primary">25</span>
<span class="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary rounded-full"></span>
</div>
<div class="py-2 relative bg-primary-container/10 rounded-lg">
<span class="text-body-md font-bold text-primary">26</span>
<span class="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary rounded-full"></span>
</div>
<span class="py-2 text-body-md text-on-surface">27</span>
<span class="py-2 text-body-md text-on-surface">28</span>
<div class="py-2 relative bg-tertiary-container rounded-lg">
<span class="text-body-md font-bold text-on-tertiary">29</span>
</div>
<span class="py-2 text-body-md text-outline/40">30</span>
<span class="py-2 text-body-md text-outline/40">31</span>
</div>
<div class="flex items-center justify-between border-t border-outline-variant/30 pt-4 mt-2">
<span class="text-body-md text-outline">今日还没有记录</span>
<button class="bg-primary text-on-primary px-6 py-3 rounded-2xl text-label-md font-bold active-scale transition-all flex items-center gap-2">去记血糖</button>
</div>
</section>
<!-- Blood Sugar Details Section -->
<section class="space-y-4">
<div class="flex justify-between items-center">
<h3 class="text-headline-md font-headline-md text-on-surface">血糖明细</h3>
<span class="text-body-md text-outline">共 3 天</span>
</div>
<div class="space-y-4">
<div class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow">
<div class="flex justify-between items-center mb-4">
<span class="text-headline-md font-bold">05-26</span>
<span class="text-body-md text-outline">周二</span>
</div>
<div class="space-y-2">
<div class="bg-background/50 border border-outline-variant/20 rounded-2xl p-4 flex justify-between items-center">
<span class="text-body-md text-outline">空腹</span>
<div class="text-right">
<span class="text-headline-lg font-extrabold text-error">10.4</span>
<span class="text-label-md text-outline ml-1">mmol/L</span>
</div>
</div>
<div class="bg-background/50 border border-outline-variant/20 rounded-2xl p-4 flex justify-between items-center">
<span class="text-body-md text-outline">餐后</span>
<span class="text-headline-md font-bold text-outline-variant">—— <small class="text-label-md">mmol/L</small></span>
</div>
</div>
</div>
<div class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow">
<div class="flex justify-between items-center mb-4">
<span class="text-headline-md font-bold">05-25</span>
<span class="text-body-md text-outline">周一</span>
</div>
<div class="bg-background/50 border border-outline-variant/20 rounded-2xl p-4 flex justify-between items-center">
<span class="text-body-md text-outline">空腹</span>
<div class="text-right">
<span class="text-headline-lg font-extrabold text-error">10.2</span>
<span class="text-label-md text-outline ml-1">mmol/L</span>
</div>
</div>
</div>
</div>
</section>
<!-- Reward Section: 稳糖乐园 -->
<section class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow overflow-hidden relative" id="sugar-paradise">
<div class="flex justify-between items-start mb-6">
<div>
<h3 class="text-headline-md font-headline-md text-on-surface">稳糖乐园</h3>
<p class="text-body-md text-outline">用健康打卡浇灌你的稳糖树</p>
</div>
<div class="bg-primary/10 rounded-xl px-3 py-1 text-center">
<span class="block text-headline-lg font-extrabold text-primary" id="total-points">110</span>
<span class="text-[10px] text-primary/70 font-bold">稳糖积分</span>
</div>
</div>
<!-- Mini Quick Actions -->
<div class="grid grid-cols-4 gap-2 mb-8">
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
<span class="material-symbols-outlined text-primary" style="font-variation-settings: 'FILL' 1;">bloodtype</span>
<span class="text-label-md text-on-surface">血糖</span>
</button>
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
<span class="material-symbols-outlined text-primary">monitoring</span>
<span class="text-label-md text-on-surface">血压</span>
</button>
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
<span class="material-symbols-outlined text-primary">restaurant</span>
<span class="text-label-md text-on-surface">饮食</span>
</button>
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
<span class="material-symbols-outlined text-primary">exercise</span>
<span class="text-label-md text-on-surface">运动</span>
</button>
</div>
<h4 class="text-label-md font-bold text-on-surface mb-3">今日控糖任务</h4>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full border-2 border-primary flex items-center justify-center">
<span class="material-symbols-outlined text-[16px] text-primary" style="font-variation-settings: 'wght' 700;">check</span>
</div>
<span class="text-body-md text-on-surface">测血糖血压</span>
</div>
<div class="flex items-center gap-3">
<span class="text-label-md text-primary font-bold">+10积分</span>
<span class="text-outline text-label-md font-bold italic">已完成</span>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full border-2 border-outline-variant/30 flex items-center justify-center">
<span class="w-2 h-2 rounded-full bg-outline-variant"></span>
</div>
<span class="text-body-md text-on-surface">记今日饮食</span>
</div>
<div class="flex items-center gap-3">
<span class="text-label-md text-primary font-bold">+10积分</span>
<button class="bg-on-surface text-surface rounded-full px-4 py-1 text-label-md font-bold active-scale transition-all">去记录</button>
</div>
</div>
</div>
<!-- Optimized Level System & Gamification -->
<div class="mt-8 bg-gradient-to-br from-primary/5 to-primary/20 rounded-3xl p-6 border border-primary/10 relative overflow-hidden">
<!-- Level Indicator Header -->
<div class="flex justify-between items-end mb-6">
<div class="space-y-1">
<div class="flex items-center gap-2">
<span class="bg-primary text-on-primary text-[10px] px-2 py-0.5 rounded-full font-extrabold" id="level-label">Lv.4</span>
<span class="text-body-lg font-extrabold text-primary" id="stage-name">枝繁叶茂</span>
</div>
<p class="text-[11px] text-primary/60">还差 <span class="font-bold">15 XP</span> 升级至 Lv.4</p>
</div>
<div class="text-right">
<span class="text-label-md font-bold text-primary" id="xp-text">5/50 XP</span>
</div>
</div>
<!-- Progress Bar -->
<div class="w-full h-2.5 bg-white/50 rounded-full mb-8 overflow-hidden backdrop-blur-sm border border-white/20">
<div class="h-full bg-gradient-to-r from-primary to-primary-container rounded-full transition-all duration-500 shadow-inner" id="xp-progress" style="width: 10%;"></div>
</div>
<!-- Plant Growth Display -->
<div class="flex items-center justify-center py-8 relative min-h-[160px]">
<!-- Watering Animation Container -->
<div class="absolute top-0 left-0 w-full h-full pointer-events-none z-10" id="rain-container"></div>
<!-- Visual Stage Indicator Background -->
<div class="absolute inset-0 flex items-center justify-center opacity-10 pointer-events-none">
<span class="text-[120px] material-symbols-outlined text-primary" style="font-variation-settings: 'FILL' 1;">eco</span>
</div>
<!-- Current Plant State -->
<div class="relative z-20 transition-all duration-300" id="plant-container">
<span class="text-7xl drop-shadow-2xl filter block" id="plant-emoji">🪴</span>
</div>
</div>
<!-- Watering Action -->
<div class="relative z-30">
<button class="w-full bg-primary text-on-primary py-4 rounded-2xl text-body-lg font-bold flex items-center justify-center gap-3 shadow-lg active:scale-95 transition-all active:bg-primary-container group" id="water-btn">
<span class="material-symbols-outlined text-[24px] transition-transform group-active:-rotate-12 group-hover:scale-110">opacity</span>
给小树浇水
</button>
<p class="text-[11px] text-center text-outline mt-3 italic">浇水可消耗 5 积分,获得 10 XP</p>
</div>
</div>
</section>
<!-- Social Interaction -->
<section class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-tertiary-container/20 rounded-2xl flex items-center justify-center">
<span class="material-symbols-outlined text-tertiary" style="font-variation-settings: 'FILL' 1;">favorite</span>
</div>
<div>
<h4 class="text-body-md font-bold text-on-surface">邀请家人点赞</h4>
<p class="text-[12px] text-outline">分享战报给家人,邀请他们为您点赞鼓励</p>
</div>
</div>
<span class="material-symbols-outlined text-outline">chevron_right</span>
</section>
<!-- Status Tip -->
<div class="flex items-start gap-3 px-2">
<span class="material-symbols-outlined text-primary-container text-[20px]">chat_bubble</span>
<div>
<p class="text-body-md text-on-surface">先完成血糖记录,<span class="text-primary font-bold">今日第一格就亮起来</span></p>
<button class="text-outline text-label-md hover:text-primary transition-colors">轻触换一句鼓励 </button>
</div>
</div>
<!-- Care List Horizontal -->
<div class="flex items-center gap-3 overflow-x-auto hide-scrollbar -mx-container-margin px-container-margin pb-2">
<div class="shrink-0 flex items-center gap-2 bg-white px-3 py-2 rounded-xl shadow-sm border border-outline-variant/20">
<span class="text-label-md text-outline">就诊卡</span>
<span class="text-label-md font-extrabold text-on-surface">10 张</span>
</div>
<div class="shrink-0 flex items-center gap-2 bg-primary text-on-primary px-3 py-2 rounded-xl shadow-md">
<span class="text-label-md font-bold">吕帅</span>
<span class="text-[10px] bg-white/20 rounded px-1">23岁</span>
</div>
<div class="shrink-0 flex items-center gap-2 bg-surface-container-high text-on-surface-variant px-3 py-2 rounded-xl border border-outline-variant/30">
<span class="text-label-md font-bold">霍爱玲</span>
<span class="text-[10px] text-outline">57岁</span>
</div>
</div>
</main>
<!-- Footer Note -->
<footer class="mt-8 text-center px-container-margin pb-12">
<p class="text-[11px] text-outline-variant/60">数据每日同步 · 阈值仅作参考,请遵医嘱</p>
</footer>
<!-- Navigation Bar -->
<script>
// State Management
let currentXP = 35;
let currentLevel = 3;
let totalPoints = 120;
const maxLevel = 10;
const xpPerLevel = 50;
const stageNames = [
"种子眠", "破土而出", "茁壮成长", "枝繁叶茂", "初绽花蕾",
"繁花似锦", "硕果初步", "丰收在望", "参天大树", "森林守护"
];
const plantEmojis = [
"🫘", "🌱", "🌿", "🪴", "🎍",
"🌸", "🍒", "🍎", "🌳", "🌲"
];
// DOM Elements
const waterBtn = document.getElementById('water-btn');
const rainContainer = document.getElementById('rain-container');
const plantContainer = document.getElementById('plant-container');
const plantEmoji = document.getElementById('plant-emoji');
const xpProgress = document.getElementById('xp-progress');
const xpText = document.getElementById('xp-text');
const levelLabel = document.getElementById('level-label');
const stageNameLabel = document.getElementById('stage-name');
const totalPointsLabel = document.getElementById('total-points');
// Watering Animation Logic
function createRain() {
for (let i = 0; i < 15; i++) {
const droplet = document.createElement('div');
droplet.className = 'droplet';
droplet.style.left = `${Math.random() * 100}%`;
droplet.style.animationDelay = `${Math.random() * 0.4}s`;
rainContainer.appendChild(droplet);
// Cleanup
setTimeout(() => droplet.remove(), 600);
}
}
function triggerGrowthPulse() {
plantContainer.classList.add('growth-pulse');
setTimeout(() => {
plantContainer.classList.remove('growth-pulse');
}, 800);
}
function createSparkles() {
for (let i = 0; i < 12; i++) {
const sparkle = document.createElement('span');
sparkle.className = 'material-symbols-outlined shimmer-particle';
sparkle.textContent = 'sparkles';
sparkle.style.left = `${40 + (Math.random() - 0.5) * 40}%`;
sparkle.style.top = `${40 + (Math.random() - 0.5) * 40}%`;
sparkle.style.animationDuration = `${0.6 + Math.random() * 0.4}s`;
plantContainer.appendChild(sparkle);
setTimeout(() => sparkle.remove(), 1000);
}
}
function triggerSugarDecreaseEffect() {
// 1. Floating Text
const floatingText = document.createElement('div');
floatingText.className = 'sugar-decrease-text';
floatingText.textContent = '-0.5 mmol/L';
// Randomize spawn position near the plant
const rect = plantContainer.getBoundingClientRect();
floatingText.style.left = `${rect.left + rect.width / 2}px`;
floatingText.style.top = `${rect.top}px`;
document.body.appendChild(floatingText);
setTimeout(() => floatingText.remove(), 2000);
// 2. Full-screen Downward Particles
for (let i = 0; i < 20; i++) {
setTimeout(() => {
const particle = document.createElement('div');
particle.className = 'sugar-particle text-primary-container';
// Mix arrows and symbols
particle.innerHTML = Math.random() > 0.5 ? '<span class="material-symbols-outlined">south</span>' : '▼';
particle.style.left = `${Math.random() * 100}vw`;
particle.style.fontSize = `${16 + Math.random() * 16}px`;
particle.style.opacity = '0';
document.body.appendChild(particle);
setTimeout(() => particle.remove(), 1500);
}, i * 50);
}
}
function updateDisplay() {
const progress = (currentXP / xpPerLevel) * 100;
xpProgress.style.width = `${progress}%`;
xpText.textContent = `${currentXP}/${xpPerLevel} XP`;
levelLabel.textContent = `Lv.${currentLevel}`;
stageNameLabel.textContent = stageNames[currentLevel - 1];
plantEmoji.textContent = plantEmojis[currentLevel - 1];
totalPointsLabel.textContent = totalPoints;
}
// Action Logic
waterBtn.addEventListener('click', () => {
if (totalPoints < 5) {
alert("积分不足,快去完成任务领积分吧!");
return;
}
// Lock button briefly
waterBtn.disabled = true;
waterBtn.classList.add('opacity-50');
// 1. Consume Points
totalPoints -= 5;
// 2. Trigger Animations
createRain();
setTimeout(() => {
triggerGrowthPulse();
createSparkles();
triggerSugarDecreaseEffect(); // New Effect
// 3. Update State
currentXP += 10;
if (currentXP >= xpPerLevel && currentLevel < maxLevel) {
currentXP -= xpPerLevel;
currentLevel++;
}
updateDisplay();
// Unlock button
waterBtn.disabled = false;
waterBtn.classList.remove('opacity-50');
}, 600);
});
// Initialize
updateDisplay();
// Simple button micro-interactions for others
document.querySelectorAll('button:not(#water-btn)').forEach(btn => {
btn.addEventListener('click', () => {
if(!btn.classList.contains('cursor-not-allowed')) {
// Interaction logic
}
});
});
</script>
</body></html>
File diff suppressed because it is too large Load Diff
+30 -10
View File
@@ -419,7 +419,7 @@
<view v-else class="ai-diet-body" :class="{ 'is-streaming': dietAiStreaming }">
<view class="ai-diet-section-head">
<text class="ai-diet-section-title">今日三餐</text>
<text class="ai-diet-section-title">今日饮食</text>
<text v-if="dietAiStreaming" class="ai-diet-section-tag">生成中</text>
</view>
<view class="ai-diet-meal-list">
@@ -434,6 +434,20 @@
{{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'breakfast'" class="ai-stream-cursor"></text>
</text>
</view>
<view
v-if="dietAiRecommend.drinks || (dietAiLoading && dietAiStreamField === 'drinks')"
class="ai-diet-meal-card meal-drinks"
>
<view class="ai-diet-meal-head">
<view class="ai-diet-meal-icon" aria-hidden="true">
<TongjiIcon name="droplet" size="sm" color="#ffffff" />
</view>
<text class="ai-diet-meal-label">喝的</text>
</view>
<text class="ai-diet-meal-text">
{{ dietAiRecommend.drinks || '…' }}<text v-if="dietAiLoading && dietAiStreamField === 'drinks'" class="ai-stream-cursor"></text>
</text>
</view>
<view class="ai-diet-meal-card meal-lunch">
<view class="ai-diet-meal-head">
<view class="ai-diet-meal-icon" aria-hidden="true">
@@ -2657,16 +2671,13 @@ async function bootstrap(options) {
if (!target) target = list[0]
await applyCard(target)
} else {
if (passedDiagnosisId) diagnosisId.value = passedDiagnosisId
if (passedPatientId) patientId.value = passedPatientId
if (passedPatientName) patientName.value = passedPatientName
if (passedAge) age.value = passedAge
if (passedGender) gender.value = passedGender
if (!diagnosisId.value || !patientId.value) {
readUserContext()
}
diagnosisId.value = 0
patientId.value = 0
patientName.value = ''
age.value = 0
gender.value = 0
readUserMobile()
if (!diagnosisId.value && userMobile.value) {
if (userMobile.value) {
await ensureDailyContextByPhone()
}
}
@@ -6558,6 +6569,12 @@ async function onFamilyLike() {
.ai-diet-meal-card.meal-breakfast::before {
background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%);
}
.ai-diet-meal-card.meal-drinks {
background: linear-gradient(120deg, rgba(14, 165, 233, 0.14) 0%, rgba(255, 255, 255, 0) 45%);
}
.ai-diet-meal-card.meal-drinks::before {
background: linear-gradient(180deg, #38bdf8 0%, #0ea5e9 100%);
}
.ai-diet-meal-card.meal-lunch {
background: linear-gradient(120deg, rgba(16, 185, 129, 0.16) 0%, rgba(255, 255, 255, 0) 45%);
}
@@ -6588,6 +6605,9 @@ async function onFamilyLike() {
.meal-breakfast .ai-diet-meal-icon {
background: linear-gradient(150deg, #fde68a 0%, #fbbf24 100%);
}
.meal-drinks .ai-diet-meal-icon {
background: linear-gradient(150deg, #7dd3fc 0%, #0ea5e9 100%);
}
.meal-lunch .ai-diet-meal-icon {
background: linear-gradient(150deg, #6ee7b7 0%, #10b981 100%);
}
File diff suppressed because it is too large Load Diff
+332 -154
View File
@@ -45,19 +45,21 @@
</view>
</scroll-view>
<view class="st-main">
<!-- 1. 录入今日血糖 -->
<view class="st-cta" @click="openInputForm()">
<TongjiIcon name="plus" size="md" color="#FFFFFF" />
<text>录入今日血糖</text>
<!-- 快捷建档失败提示toast 字数有限完整文案展示在页面 -->
<view v-if="dailyContextError && !diagnosisId" class="st-daily-context-error" role="alert">
<text class="st-daily-context-error-text">{{ dailyContextError }}</text>
</view>
<!-- 2. 血糖趋势 -->
<view v-if="diagnosisId" class="st-chart-card">
<!-- 2. 血糖趋势无就诊卡或累计录入不足 7 天显示示例曲线 -->
<view class="st-chart-card">
<view class="st-chart-head">
<view>
<view style="padding: 0 20rpx;">
<text class="st-chart-title">血糖趋势</text>
<text class="st-chart-sub">{{ rangeText }} · mmol/L</text>
</view>
<view class="st-chart-legend">
<view
@@ -78,82 +80,39 @@
</view>
</view>
</view>
<view v-if="bloodHasData" class="st-chart-summary">
<view class="st-chart-summary-item">
<text class="st-chart-summary-num is-high">{{ bloodHighDayCount }}</text>
<text class="st-chart-summary-label">天偏高</text>
</view>
<view class="st-chart-summary-item">
<text class="st-chart-summary-num is-good">{{ bloodNormalDayCount }}</text>
<text class="st-chart-summary-label">天正常</text>
</view>
<view class="st-chart-summary-item">
<text class="st-chart-summary-num">{{ bloodRecordDayCount }}</text>
<text class="st-chart-summary-label">天有记录</text>
</view>
</view>
<view class="st-chart-area">
<view class="st-chart-area" @click="onChartTap">
<canvas
v-if="!recordOverlayOpen"
id="bloodWaveChart"
type="2d"
class="st-chart-canvas"
@touchstart="onChartTouch"
@touchmove="onChartTouch"
@click.stop="onChartTap"
/>
<view v-if="recordOverlayOpen" class="st-chart-placeholder">
<text>图表已暂停显示</text>
</view>
<view v-if="!bloodHasData && !loading && !recordOverlayOpen" class="st-chart-empty">
<view v-if="!chartHasDisplayData && !loading && !recordOverlayOpen" class="st-chart-empty">
<text>当前时间范围暂无血糖数据</text>
</view>
<cover-view v-if="hoverInfo && !recordOverlayOpen" class="chart-tip" :style="{ left: hoverInfo.left + 'px', top: hoverInfo.top + 'px' }">
<cover-view class="chart-tip-date">{{ hoverInfo.date }}</cover-view>
<cover-view v-if="hoverInfo.fasting != null" class="chart-tip-row">
<cover-view class="st-dot st-dot-fasting" />
<cover-view>空腹 {{ hoverInfo.fasting }}</cover-view>
</cover-view>
<cover-view v-if="hoverInfo.postprandial != null" class="chart-tip-row">
<cover-view class="st-dot st-dot-post" />
<cover-view>餐后 {{ hoverInfo.postprandial }}</cover-view>
</cover-view>
</cover-view>
</view>
<view v-if="chartUseMockData" class="st-chart-foot">
<text class="st-chart-mock-hint">示例数据累计记录满 7 天后显示您的真实趋势</text>
</view>
<view class="st-game-entry st-game-entry--in-card" @click="goGamePage" style="margin: 0 20rpx 0 20rpx;">
<view class="st-game-entry-icon">
<TongjiIcon name="activity" size="sm" color="#006c49" />
</view>
<view class="st-game-entry-body">
<text class="st-game-entry-title">识糖小课堂</text>
<text class="st-game-entry-sub">认一认哪些食物含糖高</text>
</view>
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
</view>
</view>
<!-- 3. 最新测量 & 概览 -->
<view class="st-latest-section">
<view
v-if="latestMeasurement.hasValue"
class="st-latest-hero"
:class="{ 'is-high': latestMeasurement.isHigh }"
>
<view class="st-latest-hero-pattern" aria-hidden="true" />
<text class="st-latest-label">最新测量 · {{ latestMeasurement.typeLabel }}</text>
<view class="st-latest-value-row">
<text class="st-latest-value">{{ latestMeasurement.value }}</text>
<text class="st-latest-unit">mmol/L</text>
</view>
<view v-if="latestMeasurement.isHigh" class="st-latest-flag">
<TongjiIcon name="alert-triangle" size="sm" color="#93000a" />
<text>偏高请遵医嘱</text>
</view>
</view>
<view class="st-summary-grid">
<view class="st-summary-card">
<text class="st-summary-label">空腹</text>
<text class="st-summary-value" :class="{ 'is-high': elderFasting.isHigh }">{{ elderFasting.value }}</text>
<text v-if="elderFasting.isHigh" class="st-summary-tag">偏高</text>
</view>
<view class="st-summary-card">
<text class="st-summary-label">餐后</text>
<text class="st-summary-value" :class="{ 'is-high': elderPostprandial.isHigh }">{{ elderPostprandial.value }}</text>
<text v-if="elderPostprandial.isHigh" class="st-summary-tag">偏高</text>
</view>
</view>
<view class="st-training-section">
<dev-training-entry inline tone="weekly" />
</view>
<!-- 4. AI 饮食建议 -->
<!-- 3. AI 饮食建议 -->
<view v-if="diagnosisId" class="st-ai-section">
<view class="st-ai-deco" aria-hidden="true" />
<view class="st-ai-head">
@@ -172,7 +131,7 @@
<text>{{ dietAiLoading ? '生成中' : '换一换' }}</text>
</view>
</view>
<text class="st-ai-sub">读您近 7 · 30 天血糖定制今日三餐</text>
<text class="st-ai-sub">读您近 7 · 30 天血糖定制早餐含驼奶粉喝的与三餐</text>
<view v-if="dietAiLoading && !hasDietAiMealContent && !dietAiStreaming && !dietAiReplacing" class="st-skeleton">
<view v-for="n in 2" :key="'sk-' + n" class="st-skeleton-line" />
@@ -188,6 +147,18 @@
{{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'breakfast'" class="ai-stream-cursor"></text>
</text>
</view>
<view
v-if="dietAiRecommend.drinks || (dietAiLoading && dietAiStreamField === 'drinks')"
class="st-meal-glass st-meal-glass--drinks"
>
<view class="st-meal-head">
<TongjiIcon name="droplet" size="sm" color="#0ea5e9" />
<text class="st-meal-label">喝的</text>
</view>
<text class="st-meal-text">
{{ dietAiRecommend.drinks || '…' }}<text v-if="dietAiLoading && dietAiStreamField === 'drinks'" class="ai-stream-cursor"></text>
</text>
</view>
<view class="st-meal-glass">
<view class="st-meal-head">
<TongjiIcon name="leaf" size="sm" color="#006c49" />
@@ -211,9 +182,17 @@
<view class="st-main-spacer" />
</view>
<!-- 收起后的重新唤起按钮 -->
<view v-if="diagnosisId && floatAskClosed" class="st-float-reopen" @click="floatAskClosed = false">
<TongjiIcon name="sparkles" size="sm" color="#ffffff" />
</view>
<!-- 底部浮动 AI 输入栏 -->
<view v-if="diagnosisId" class="st-float-ask">
<view v-if="diagnosisId && !floatAskClosed" class="st-float-ask">
<view class="st-float-close" @click="floatAskClosed = true">
<text class="st-float-close-icon">×</text>
</view>
<view class="st-float-inner">
<view class="st-float-icon">
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
@@ -222,12 +201,21 @@
class="st-float-input"
type="text"
:value="dietAiAskText"
placeholder="输入食物名称或健康疑问,咨询 AI..."
placeholder="长按麦克风说话,或输入文字咨询 AI..."
placeholder-class="st-float-placeholder"
confirm-type="send"
@input="onDietAiAskInput"
@confirm="askDietAiFood"
/>
<view
class="st-float-mic"
:class="{ listening: sttListening || sttHolding, disabled: dietAiAsking || !sttSupported }"
@touchstart.stop.prevent="onDietAiSpeechStart"
@touchend.stop.prevent="onDietAiSpeechEnd"
@touchcancel.stop.prevent="onDietAiSpeechEnd"
>
<TongjiIcon name="mic" size="sm" :color="(sttListening || sttHolding) ? '#ffffff' : '#006c49'" />
</view>
<view
class="st-float-send"
:class="{ disabled: dietAiAsking }"
@@ -236,6 +224,9 @@
<TongjiIcon name="send" size="sm" color="#ffffff" />
</view>
</view>
<view v-if="sttHolding && !dietAiAsking" class="st-float-hold-hint">
<text>{{ sttListening ? '松手结束' : '准备录音…' }}</text>
</view>
<view v-if="dietAiAsking || dietAiAskResult.advice" class="st-float-result">
<view v-if="dietAiAskResult.level_label" class="st-float-badge">
<text>{{ dietAiAskResult.level_label }}</text>
@@ -246,6 +237,17 @@
</view>
</view>
<!-- 语音长按状态浮层居中显示避免被键盘/底栏遮挡 -->
<view v-if="sttHolding && !dietAiAsking" class="st-voice-overlay">
<view class="st-voice-card">
<view class="st-voice-icon" :class="{ active: sttListening }">
<TongjiIcon name="mic" size="lg" color="#ffffff" />
</view>
<text class="st-voice-title">{{ sttListening ? '正在聆听…' : '准备录音…' }}</text>
<text class="st-voice-sub">松开手指结束并识别</text>
</view>
</view>
<!-- 无就诊卡授权手机号后可录入 -->
<view v-if="phoneGateVisible" class="input-modal-mask" @click="closePhoneGate">
<view class="input-modal phone-gate-modal" @click.stop>
@@ -260,6 +262,9 @@
</view>
</view>
<view class="phone-gate-body">
<view v-if="dailyContextError" class="phone-gate-error" role="alert">
<text>{{ dailyContextError }}</text>
</view>
<text class="phone-gate-tip">微信仅提供手机号请选择性别以便写入就诊档案</text>
<view class="phone-gate-gender">
<text class="phone-gate-gender-label">性别</text>
@@ -295,7 +300,7 @@
<!-- 录入今日数据弹层 -->
<view v-if="inputForm.visible" class="input-modal-mask" @click="closeInputForm">
<view class="input-modal" @click.stop>
<view class="input-modal" :style="keyboardHeight ? { marginBottom: keyboardHeight + 'px' } : {}" @click.stop>
<view class="input-modal-grip" />
<view class="input-modal-head">
<view class="input-modal-title-wrap">
@@ -324,6 +329,7 @@
<view class="input-field-input">
<input
type="digit"
:adjust-position="false"
:value="inputForm.fasting_blood_sugar"
placeholder="如 6.5"
placeholder-class="input-placeholder"
@@ -339,6 +345,7 @@
<view class="input-field-input">
<input
type="digit"
:adjust-position="false"
:value="inputForm.postprandial_blood_sugar"
placeholder="如 8.0"
placeholder-class="input-placeholder"
@@ -354,6 +361,7 @@
<view class="input-field-input">
<input
type="digit"
:adjust-position="false"
:value="inputForm.other_blood_sugar"
placeholder="如 7.2"
placeholder-class="input-placeholder"
@@ -370,6 +378,7 @@
placeholder-class="input-placeholder"
:show-confirm-bar="false"
:auto-height="true"
:adjust-position="false"
@input="onInputField('remark', $event)"
/>
</view>
@@ -395,6 +404,7 @@
</view>
</view>
</block>
<TabBarDock :active="1" />
</view>
</template>
@@ -404,6 +414,9 @@ import { onLoad, onShow, onHide, onUnload, onPullDownRefresh, onShareAppMessage
import SugarTreeGraphic from '../components/SugarTreeGraphic.vue'
import TongjiIcon from '../components/TongjiIcon.vue'
import { useDietAi } from '../composables/useDietAi.js'
import { useSpeechToText } from '../composables/useSpeechToText.js'
import TabBarDock from '@/components/app-tab-bar/tab-bar-dock.vue'
import DevTrainingEntry from '@/components/dev-training-entry/index.vue'
const { proxy } = getCurrentInstance()
@@ -436,11 +449,42 @@ function showUserToast(title, options = {}) {
uni.showToast({
title: text,
icon: options.icon || 'none',
duration: options.duration
duration: options.duration || 2800
})
}
/** 解析接口错误文案(兼容 msg / message / data.error */
function extractApiError(res, fallback = '') {
if (!res) return fallback
const raw = res.msg ?? res.message ?? res.data?.error ?? res.data?.msg ?? res.errMsg
return formatUserMessage(raw, fallback)
}
const diagnosisId = ref(0)
/** 手机号快捷建档失败:页面常驻提示(避免 toast 被 7 字截断) */
const dailyContextError = ref('')
function clearDailyContextError() {
dailyContextError.value = ''
}
function reportDailyContextError(res, fallback = '无法开始录入', options = {}) {
const text = extractApiError(res, fallback)
if (!text) return
dailyContextError.value = text
if (options.alert) {
uni.showModal({
title: '无法录入血糖',
content: text,
showCancel: false,
confirmText: '知道了'
})
} else {
const short = text.length > 14 ? `${text.slice(0, 14)}` : text
showUserToast(short, { duration: 3200 })
}
}
const patientId = ref(0)
const patientName = ref('')
const displayPatientName = computed(() => {
@@ -472,6 +516,39 @@ const {
openDietForm
} = useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage })
/** 底部 AI 浮动卡片是否被用户收起 */
const floatAskClosed = ref(false)
/** 键盘高度(px):录入弹窗据此整体上移,避免输入框被键盘遮挡 */
const keyboardHeight = ref(0)
function onKeyboardHeightChange(res) {
keyboardHeight.value = res && res.height ? Math.round(res.height) : 0
}
const {
sttListening,
sttHolding,
sttSupported,
beginHoldSpeech,
endHoldSpeech,
stopSpeechToText
} = useSpeechToText({
onResult(text) {
const prev = String(dietAiAskText.value || '').trim()
dietAiAskText.value = prev ? `${prev} ${text}` : text
},
showToast: (msg) => showUserToast(msg)
})
function onDietAiSpeechStart() {
if (dietAiAsking.value) return
beginHoldSpeech()
}
function onDietAiSpeechEnd() {
endHoldSpeech()
}
/** AI 饮食建议依据:近7天 / 近30天 血糖统计 */
const dietAnalysisList = computed(() => {
const list = dietAiRecommend.value && Array.isArray(dietAiRecommend.value.analysis)
@@ -603,6 +680,19 @@ const currentRange = computed(() => {
return { start: formatDate(start), end: formatDate(end) }
})
/** 拉取血糖时向前多看几天,用于判断「累计满 7 天」是否达标(图表仍只画 currentRange */
const BLOOD_LOOKBACK_DAYS = 30
const fetchRange = computed(() => {
const displayDays = rangeMode.value === '30' ? 30 : 7
const lookback = Math.max(BLOOD_LOOKBACK_DAYS, displayDays)
const end = new Date()
end.setHours(0, 0, 0, 0)
const start = new Date(end)
start.setDate(start.getDate() - (lookback - 1))
return { start: formatDate(start), end: formatDate(end) }
})
const visibleDates = computed(() => {
const { start, end } = currentRange.value
if (!start || !end) return []
@@ -765,18 +855,73 @@ const recordDays = computed(() => {
return result
})
function bloodDayHasSugar(b) {
if (!b) return false
return toNumber(b.fasting_blood_sugar) !== null
|| toNumber(b.postprandial_blood_sugar) !== null
|| toNumber(b.other_blood_sugar) !== null
}
const bloodHasData = computed(() => {
return chartDates.value.some((date) => {
const b = bloodByDate.value[date]
return b && (toNumber(b.fasting_blood_sugar) !== null || toNumber(b.postprandial_blood_sugar) !== null)
})
return chartDates.value.some((date) => bloodDayHasSugar(bloodByDate.value[date]))
})
/** 当前图表时间范围内有真实录入的天数(用于小结统计) */
const bloodRecordDayCount = computed(() => {
return chartDates.value.filter((date) => {
const b = bloodByDate.value[date]
return b && (toNumber(b.fasting_blood_sugar) !== null || toNumber(b.postprandial_blood_sugar) !== null)
}).length
return chartDates.value.filter((date) => bloodDayHasSugar(bloodByDate.value[date])).length
})
/** 拉取窗口内累计有血糖录入的天数(不要求连续) */
const bloodCumulativeDayCount = computed(() => {
let n = 0
for (const date of Object.keys(bloodByDate.value)) {
if (bloodDayHasSugar(bloodByDate.value[date])) n++
}
return n
})
const REAL_BLOOD_DAYS_MIN = 7
/** 无就诊卡或累计录入不足 7 天时,图表用示例数据 */
const chartUseMockData = computed(() => {
if (!diagnosisId.value) return true
return bloodCumulativeDayCount.value < REAL_BLOOD_DAYS_MIN
})
/** 示例曲线:7 天内有波动的空腹/餐后,便于老人理解趋势图形态 */
const mockBloodByDate = computed(() => {
const map = {}
const dates = chartDates.value
dates.forEach((date, i) => {
const t = dates.length > 1 ? i / (dates.length - 1) : 0
const fasting = Number((5.2 + Math.sin(t * Math.PI) * 0.5 + (i % 2) * 0.15).toFixed(1))
const post = Number((7.6 + Math.cos(t * Math.PI) * 1.1 + (i % 3) * 0.25).toFixed(1))
const row = {
record_date: date,
fasting_blood_sugar: fasting,
postprandial_blood_sugar: post,
other_blood_sugar: '',
systolic_pressure: '',
diastolic_pressure: ''
}
row.isHighFasting = isHighFasting(row.fasting_blood_sugar)
row.isHighPostprandial = isHighPostprandial(row.postprandial_blood_sugar)
row.isHighOther = false
row.isHighBp = false
row.bpText = '—'
map[date] = row
})
return map
})
/** 图表绘制用数据源:示例或真实 */
const chartBloodByDate = computed(() => (
chartUseMockData.value ? mockBloodByDate.value : bloodByDate.value
))
const chartHasDisplayData = computed(() => {
if (chartUseMockData.value) return chartDates.value.length > 0
return bloodHasData.value
})
const bloodHighDayCount = computed(() => {
@@ -1080,8 +1225,14 @@ function syncPhoneGateSexFromUser() {
}
}
function openPhoneGate() {
async function openPhoneGate() {
syncPhoneGateSexFromUser()
readUserMobile()
if (userMobile.value) {
phoneGateVisible.value = false
await resolveDailyPhoneContext(false)
return
}
phoneGateVisible.value = true
}
@@ -1098,8 +1249,11 @@ async function refreshUserMobile() {
return userMobile.value
}
async function ensureDailyContextByPhone(sex) {
if (diagnosisId.value) return true
async function ensureDailyContextByPhone(sex, options = {}) {
if (diagnosisId.value) {
clearDailyContextError()
return true
}
const payload = {}
const sexVal = sex ?? phoneGateSex.value
@@ -1114,6 +1268,7 @@ async function ensureDailyContextByPhone(sex) {
}, false)
if (res && res.code === 1 && res.data && res.data.diagnosis_id) {
clearDailyContextError()
await applyCard({
id: res.data.diagnosis_id,
patient_id: res.data.patient_id,
@@ -1121,17 +1276,32 @@ async function ensureDailyContextByPhone(sex) {
age: res.data.age || 0,
gender: res.data.gender || 0
})
if (res.data.created) {
if (res.data.created || res.data.linked_existing) {
await fetchCardList()
}
return true
}
if (res && res.data && res.data.need_mobile) {
clearDailyContextError()
return 'need_mobile'
}
showUserToast(formatUserMessage(res?.msg, '无法开始录入'))
reportDailyContextError(res, '无法开始录入', options)
return false
}
/** 已绑定手机号时直接关联档案,不再弹出授权建档 */
async function resolveDailyPhoneContext(openFormAfter = false) {
const ok = await ensureDailyContextByPhone(phoneGateSex.value, { alert: true })
if (ok === true) {
if (openFormAfter) await openInputForm(true)
return true
}
if (ok === 'need_mobile') {
syncPhoneGateSexFromUser()
phoneGateVisible.value = true
}
return false
}
@@ -1144,7 +1314,7 @@ async function ensureCanRecordGlucose() {
return false
}
const ok = await ensureDailyContextByPhone()
const ok = await ensureDailyContextByPhone(undefined, { alert: true })
if (ok === 'need_mobile') {
openPhoneGate()
return false
@@ -1177,6 +1347,13 @@ async function onPhoneGateAuthorize(e) {
phoneBinding.value = true
try {
readUserMobile()
if (userMobile.value) {
phoneGateVisible.value = false
await resolveDailyPhoneContext(true)
return
}
const res = await proxy.apiUrl({
url: '/api/user/getMobileByMnp',
method: 'POST',
@@ -1188,12 +1365,7 @@ async function onPhoneGateAuthorize(e) {
}
await refreshUserMobile()
phoneGateVisible.value = false
const ok = await ensureDailyContextByPhone(phoneGateSex.value)
if (ok === true) {
await openInputForm(true)
} else if (ok === 'need_mobile') {
openPhoneGate()
}
await resolveDailyPhoneContext(true)
} catch (err) {
showUserToast('网络异常,请稍后再试')
} finally {
@@ -1216,7 +1388,7 @@ async function onSelectCard(card) {
async function fetchAll() {
if (!hasAuthToken() || !diagnosisId.value) return
loading.value = true
const { start, end } = currentRange.value
const { start, end } = fetchRange.value
try {
const [winRes, noteRes] = await Promise.all([
proxy.apiUrl({
@@ -1290,16 +1462,47 @@ function onRefresh() {
fetchAll()
}
function buildMorePageUrl() {
if (!diagnosisId.value) return '/tongji/pages/more'
const q = [
`diagnosis_id=${diagnosisId.value}`,
patientId.value ? `patient_id=${patientId.value}` : ''
].filter(Boolean).join('&')
return `/tongji/pages/more?${q}`
}
/** 点击血糖趋势图:进入记录页 more(无就诊卡时先手机号快捷建档) */
async function onChartTap() {
if (!diagnosisId.value) {
const ok = await ensureCanRecordGlucose()
if (!ok || !diagnosisId.value) return
}
uni.navigateTo({ url: buildMorePageUrl() })
}
async function goRecordsPage() {
onChartTap()
}
async function goMorePage() {
if (!diagnosisId.value) {
const ok = await ensureCanRecordGlucose()
if (!ok || !diagnosisId.value) return
}
uni.navigateTo({ url: '/tongji/pages/more' })
uni.navigateTo({ url: buildMorePageUrl() })
}
function goGamePage() {
uni.navigateTo({
url: '/tongji/pages/game',
fail() {
uni.showToast({ title: '暂时无法打开游戏', icon: 'none' })
}
})
}
function navToUser() {
uni.switchTab({ url: '/pages/user/user' })
uni.redirectTo({ url: '/pages/user/user' })
}
// ============ / / ============
@@ -1994,7 +2197,7 @@ function scheduleChartRedraw() {
function buildSeries(field) {
return chartDates.value.map((date) => {
const b = bloodByDate.value[date]
const b = chartBloodByDate.value[date]
return b ? toNumber(b[field]) : null
})
}
@@ -2028,7 +2231,7 @@ function drawChart() {
ctx.clearRect(0, 0, w, h)
const padding = { left: 40, right: 18, top: 26, bottom: 36 }
const padding = { left: 40, right: 18, top: 12, bottom: 28 }
const cw = w - padding.left - padding.right
const ch = h - padding.top - padding.bottom
@@ -2284,59 +2487,25 @@ function toggleChartSeries(key) {
scheduleChartRedraw()
}
function onChartTouch(e) {
if (!chartCoords || !canvasWidth) return
if (!chartDates.value.length) return
let touchX = 0
let touchY = 0
if (e && e.touches && e.touches[0]) {
touchX = e.touches[0].x ?? e.touches[0].clientX ?? 0
touchY = e.touches[0].y ?? e.touches[0].clientY ?? 0
}
const { padding, cw, n } = chartCoords
if (touchX < padding.left || touchX > padding.left + cw) {
return
}
const ratio = n <= 1 ? 0 : (touchX - padding.left) / cw
let idx = Math.round(ratio * (n - 1))
if (idx < 0) idx = 0
if (idx > n - 1) idx = n - 1
const date = chartDates.value[idx]
const fp = chartSeriesVisible.value.fasting ? chartCoords.fasting[idx] : null
const pp = chartSeriesVisible.value.postprandial ? chartCoords.postprandial[idx] : null
const anchorX = fp?.x ?? pp?.x ?? padding.left + cw * (idx / Math.max(1, n - 1))
const left = Math.max(8, Math.min(canvasWidth - 130, anchorX - 60))
const top = Math.max(4, touchY - 70)
hoverInfo.value = {
date: date.slice(5),
fasting: fp?.value ?? null,
postprandial: pp?.value ?? null,
left,
top
}
if (hoverTimer) clearTimeout(hoverTimer)
hoverTimer = setTimeout(() => {
hoverInfo.value = null
}, 2500)
}
watch(rangeMode, () => {
scheduleChartRedraw()
})
watch([chartUseMockData, bloodRecords], () => {
scheduleChartRedraw()
})
// ============ ============
function readUserContext() {
try {
const userData = uni.getStorageSync('userData')
if (userData && userData.diagnosis) {
if (!patientId.value) patientId.value = Number(userData.diagnosis.patient_id) || 0
if (!diagnosisId.value) diagnosisId.value = Number(userData.diagnosis.id) || 0
if (!patientName.value) patientName.value = userData.diagnosis.patient_name || ''
if (!age.value) age.value = Number(userData.diagnosis.age) || 0
if (!gender.value) gender.value = Number(userData.diagnosis.gender) || 0
}
if (!userData) return
// diagnosis view_records
const name = String(userData.real_name || userData.nickname || '').trim()
if (!patientName.value && name) patientName.value = name
const sex = Number(userData.sex)
if (!gender.value && (sex === 1 || sex === 2)) gender.value = sex === 1 ? 1 : 0
if (!age.value && userData.age) age.value = Number(userData.age) || 0
} catch (e) {}
}
@@ -2419,23 +2588,25 @@ async function bootstrap(options) {
if (!target) target = list[0]
await applyCard(target)
} else {
if (passedDiagnosisId) diagnosisId.value = passedDiagnosisId
if (passedPatientId) patientId.value = passedPatientId
if (passedPatientName) patientName.value = passedPatientName
if (passedAge) age.value = passedAge
if (passedGender) gender.value = passedGender
if (!diagnosisId.value || !patientId.value) {
readUserContext()
}
// URL/ ID
diagnosisId.value = 0
patientId.value = 0
patientName.value = ''
age.value = 0
gender.value = 0
readUserMobile()
if (!diagnosisId.value && userMobile.value) {
await ensureDailyContextByPhone()
if (userMobile.value) {
await ensureDailyContextByPhone(undefined, { alert: false })
}
}
authChecking.value = false
if (diagnosisId.value) {
await fetchAll()
} else {
bloodRecords.value = []
await nextTick()
scheduleChartRedraw()
}
}
@@ -2445,6 +2616,7 @@ onLoad((options) => {
onShow(() => {
initHeaderSafeArea()
try { uni.onKeyboardHeightChange(onKeyboardHeightChange) } catch (e) {}
if (authChecking.value) return
resetCanvasCache()
nextTick(() => scheduleChartRedraw())
@@ -2459,10 +2631,16 @@ onPullDownRefresh(async () => {
onHide(() => {
ttsStop()
stopSpeechToText()
try { uni.offKeyboardHeightChange(onKeyboardHeightChange) } catch (e) {}
keyboardHeight.value = 0
})
onUnload(() => {
ttsStop()
stopSpeechToText()
try { uni.offKeyboardHeightChange(onKeyboardHeightChange) } catch (e) {}
keyboardHeight.value = 0
})
// ============ ============
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,7 @@
* 设计 token stitch-weekly.html tailwind.config 一一对应
*/
.weekly-page {
.stitch-vitalmint {
--primary: #006c49;
--on-primary: #ffffff;
--on-primary-fixed-variant: #005236;
@@ -29,6 +29,8 @@
--error-container: #ffdad6;
--tertiary: #a43a3a;
--tertiary-container: #fc7c78;
--on-tertiary: #ffffff;
--on-tertiary-container: #711419;
--st-container-margin: 40rpx;
--st-section-gap: 64rpx;
@@ -39,7 +41,9 @@
--st-radius-xl: 24rpx;
--st-radius-lg: 16rpx;
--st-shadow-ambient: 0 20rpx 60rpx -20rpx rgba(0, 108, 73, 0.08);
}
.weekly-page.stitch-vitalmint {
min-height: 100vh;
background: var(--surface);
color: var(--on-surface);
@@ -5,7 +5,7 @@
inset: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(12px);
z-index: 999;
z-index: 2000;
display: flex;
align-items: flex-end;
justify-content: center;
@@ -89,6 +89,22 @@
padding: 32rpx;
}
.weekly-page .phone-gate-error {
margin-bottom: 20rpx;
padding: 20rpx 22rpx;
border-radius: 16rpx;
background: #fef2f2;
border: 1rpx solid rgba(220, 38, 38, 0.2);
box-sizing: border-box;
text {
font-size: 26rpx;
font-weight: 600;
color: #991b1b;
line-height: 1.45;
}
}
.weekly-page .phone-gate-tip {
display: block;
font-size: 28rpx;
@@ -6,6 +6,8 @@
--st-shadow-ambient: 0 24rpx 60rpx -20rpx rgba(0, 108, 73, 0.08), 0 8rpx 20rpx -8rpx rgba(0, 0, 0, 0.03);
--st-shadow-cta: 0 16rpx 32rpx rgba(0, 108, 73, 0.2);
--st-shadow-float: 0 16rpx 64rpx rgba(0, 108, 73, 0.15);
--tab-dock-full-offset: calc(220rpx + env(safe-area-inset-bottom));
padding-bottom: var(--tab-dock-full-offset);
}
/* ===== Header ===== */
@@ -106,6 +108,27 @@
color: var(--on-primary);
}
/* ===== 居家训练(DEV 内嵌) ===== */
.weekly-page .st-training-section {
width: 100%;
box-sizing: border-box;
}
.weekly-page .st-training-section .dev-entry-inline--weekly {
overflow: hidden;
}
.weekly-page .st-training-section .inline-scroll {
margin-left: -4rpx;
margin-right: -4rpx;
width: calc(100% + 8rpx);
}
.weekly-page .st-training-section .inline-card:first-child {
border-color: rgba(0, 108, 73, 0.22);
background: linear-gradient(160deg, rgba(0, 108, 73, 0.08) 0%, #f4f7f5 55%);
}
/* ===== Main ===== */
.weekly-page .st-main {
padding: 0 var(--st-container-margin) 32rpx;
@@ -115,6 +138,21 @@
margin-top: 16rpx;
}
.weekly-page .st-daily-context-error {
padding: 24rpx 28rpx;
border-radius: 24rpx;
background: #fef2f2;
border: 1rpx solid rgba(220, 38, 38, 0.22);
box-sizing: border-box;
}
.weekly-page .st-daily-context-error-text {
font-size: 28rpx;
font-weight: 600;
color: #991b1b;
line-height: 1.5;
}
.weekly-page .st-main-spacer {
height: 32rpx;
}
@@ -139,25 +177,83 @@
transform: scale(0.98);
}
/* 糖分突袭游戏入口 */
.weekly-page .st-game-entry {
margin-top: 24rpx;
padding: 28rpx 32rpx;
display: flex;
align-items: center;
gap: 24rpx;
border-radius: var(--st-radius-2xl);
background: var(--surface-container-lowest);
border: 1rpx solid rgba(0, 108, 73, 0.14);
box-shadow: var(--st-shadow-ambient);
box-sizing: border-box;
}
.weekly-page .st-game-entry:active {
transform: scale(0.98);
opacity: 0.92;
}
.weekly-page .st-game-entry-icon {
width: 88rpx;
height: 88rpx;
border-radius: 24rpx;
background: rgba(0, 108, 73, 0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.weekly-page .st-game-entry-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.weekly-page .st-game-entry-title {
font-size: 32rpx;
font-weight: 700;
color: var(--on-surface);
line-height: 1.25;
}
.weekly-page .st-game-entry-sub {
font-size: 24rpx;
font-weight: 500;
color: var(--on-surface-variant, #64748b);
line-height: 1.35;
}
/* ===== Chart card — rounded-3xl ===== */
.weekly-page .st-chart-card {
padding: 40rpx;
padding: 40rpx 10rpx 40rpx 10rpx;
border-radius: 48rpx;
background: var(--surface-container-lowest);
border: 1rpx solid rgba(221, 228, 221, 0.3);
box-shadow: var(--st-shadow-ambient);
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
}
.weekly-page .st-chart-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 48rpx;
margin-bottom: 12rpx;
gap: 16rpx;
}
.weekly-page .st-chart-title {
display: block;
font-size: 40rpx;
font-size: 30rpx;
font-weight: 700;
color: var(--on-surface);
}
@@ -165,11 +261,28 @@
.weekly-page .st-chart-sub {
display: block;
margin-top: 4rpx;
font-size: 24rpx;
font-size: 22rpx;
font-weight: 600;
color: var(--on-surface-variant);
}
.weekly-page .st-chart-more {
display: inline-flex;
align-items: center;
gap: 4rpx;
margin-top: 6rpx;
}
.weekly-page .st-chart-more-text {
font-size: 22rpx;
font-weight: 600;
color: var(--primary);
}
.weekly-page .st-chart-more:active {
opacity: 0.75;
}
.weekly-page .st-chart-legend {
display: flex;
gap: var(--st-inline-gap);
@@ -243,10 +356,29 @@
color: var(--on-surface-variant);
}
.weekly-page .st-chart-foot {
padding: 20rpx 0 20rpx;
}
.weekly-page .st-chart-mock-hint {
display: block;
margin: 0;
font-size: 30rpx;
font-weight: 600;
color: var(--outline);
line-height: 1.35;
text-align: center;
}
.weekly-page .st-chart-area {
position: relative;
height: 360rpx;
border-bottom: 1rpx dashed var(--surface-container-highest);
overflow: hidden;
cursor: pointer;
}
.weekly-page .st-chart-area:active {
opacity: 0.92;
}
.weekly-page .st-chart-canvas {
@@ -255,6 +387,31 @@
display: block;
}
/* 识糖入口嵌在趋势卡内:轻量分隔,避免大块留白 */
.weekly-page .st-chart-card .st-game-entry--in-card {
margin-top: 12rpx;
padding: 16rpx 20rpx;
gap: 16rpx;
border-radius: 24rpx;
background: var(--surface-container-low);
border: 1rpx solid rgba(0, 108, 73, 0.08);
box-shadow: none;
}
.weekly-page .st-chart-card .st-game-entry--in-card .st-game-entry-icon {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
}
.weekly-page .st-chart-card .st-game-entry--in-card .st-game-entry-title {
font-size: 28rpx;
}
.weekly-page .st-chart-card .st-game-entry--in-card .st-game-entry-sub {
font-size: 22rpx;
}
.weekly-page .st-chart-empty,
.weekly-page .st-chart-placeholder {
position: absolute;
@@ -499,6 +656,11 @@
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.04);
}
.weekly-page .st-meal-glass--drinks {
background: rgba(224, 242, 254, 0.65);
border-color: rgba(14, 165, 233, 0.15);
}
.weekly-page .st-meal-head {
display: flex;
align-items: center;
@@ -536,8 +698,43 @@
position: fixed;
left: var(--st-container-margin);
right: var(--st-container-margin);
bottom: calc(24rpx + env(safe-area-inset-bottom));
z-index: 90;
bottom: var(--tab-dock-full-offset);
z-index: 210;
}
.weekly-page .st-float-close {
position: absolute;
top: -18rpx;
right: -6rpx;
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.weekly-page .st-float-close-icon {
color: #ffffff;
font-size: 30rpx;
line-height: 1;
}
.weekly-page .st-float-reopen {
position: fixed;
right: var(--st-container-margin);
bottom: var(--tab-dock-full-offset);
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: linear-gradient(180deg, #2d7340 0%, #006c49 100%);
box-shadow: var(--st-shadow-float);
display: flex;
align-items: center;
justify-content: center;
z-index: 210;
}
.weekly-page .st-float-inner {
@@ -577,6 +774,102 @@
font-size: 28rpx;
}
.weekly-page .st-float-mic {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: rgba(0, 108, 73, 0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.2s ease, transform 0.15s ease;
}
.weekly-page .st-float-mic.listening {
background: var(--primary);
animation: st-float-mic-pulse 1.2s ease-in-out infinite;
}
.weekly-page .st-float-mic.disabled {
opacity: 0.45;
pointer-events: none;
}
.weekly-page .st-float-mic:active {
transform: scale(0.94);
}
.weekly-page .st-float-hold-hint {
margin-top: 12rpx;
text-align: center;
font-size: 24rpx;
color: var(--primary);
line-height: 1.4;
}
.weekly-page .st-voice-overlay {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.32);
pointer-events: none;
}
.weekly-page .st-voice-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
padding: 56rpx 64rpx;
border-radius: 32rpx;
background: rgba(20, 24, 22, 0.86);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.35);
}
.weekly-page .st-voice-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #2d7340 0%, #006c49 100%);
}
.weekly-page .st-voice-icon.active {
animation: st-voice-pulse 1.1s ease-in-out infinite;
}
@keyframes st-voice-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 108, 73, 0.45); }
50% { box-shadow: 0 0 0 20rpx rgba(0, 108, 73, 0); }
}
.weekly-page .st-voice-title {
font-size: 32rpx;
font-weight: 700;
color: #ffffff;
line-height: 1.3;
}
.weekly-page .st-voice-sub {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
line-height: 1.3;
}
@keyframes st-float-mic-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 108, 73, 0.35); }
50% { box-shadow: 0 0 0 12rpx rgba(0, 108, 73, 0); }
}
.weekly-page .st-float-send {
width: 80rpx;
height: 80rpx;
@@ -587,6 +880,8 @@
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4rpx 12rpx rgba(0, 108, 73, 0.2);
line-height: 0;
overflow: hidden;
}
.weekly-page .st-float-send.disabled {
@@ -64,10 +64,10 @@ export function parsePartialDietPlainText(text) {
const out = {}
if (!text) return out
const lineRe = /^(早餐|午餐|晚餐|提示|少碰)[:]\s*(.*)$/gm
const lineRe = /^(早餐|喝的|午餐|晚餐|提示|少碰)[:]\s*(.*)$/gm
let match
while ((match = lineRe.exec(text)) !== null) {
const key = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[match[1]]
const key = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[match[1]]
const val = (match[2] || '').trim()
if (key === 'avoid') {
out.avoid = val.split(/[、,;\s]+/).map((s) => s.trim()).filter(Boolean)
@@ -76,9 +76,9 @@ export function parsePartialDietPlainText(text) {
}
}
const tail = text.match(/(?:^|\n)(早餐|午餐|晚餐|提示|少碰)[:]\s*([^\n]*)$/)
const tail = text.match(/(?:^|\n)(早餐|喝的|午餐|晚餐|提示|少碰)[:]\s*([^\n]*)$/)
if (tail) {
const key = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[tail[1]]
const key = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[tail[1]]
const val = (tail[2] || '').trim()
if (key === 'avoid') {
if (val) out.avoid = val.split(/[、,;\s]+/).map((s) => s.trim()).filter(Boolean)
+11 -1
View File
@@ -403,6 +403,11 @@ export function prescriptionOrderLogisticsTrace(params: {
return request.get({ url: '/tcm.prescriptionOrder/logisticsTrace', params })
}
/** 直接调用京东官方物流接口刷新轨迹并落库(绕过快递100缓存) */
export function prescriptionOrderLogisticsJdUpdate(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/logisticsJdUpdate', params })
}
export function prescriptionOrderDetail(params: { id: number }) {
return request.get({ url: '/tcm.prescriptionOrder/detail', params })
}
@@ -482,11 +487,16 @@ export function prescriptionOrderRequestCompletion(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params })
}
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态) */
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态;退款请走 refund 或完成弹窗选「退款」 */
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
}
/** 业务订单退款(须填写原因;refund_amount 可选,不传则退满可退上限) */
export function prescriptionOrderRefund(params: { id: number; reason: string; refund_amount?: number }) {
return request.post({ url: '/tcm.prescriptionOrder/refund', params })
}
/** 甘草药管家:中药处方下单(服务端 CTM_PREVIEW → CTM_SUBMIT_RECIPEL */
export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/submitGancaoRecipel', params })
@@ -0,0 +1,96 @@
<template>
<template v-if="variant === 'split'">
<el-form-item :label="label" :class="{ 'is-compact': compact }">
<el-radio-group v-model="timeType" :size="compact ? 'small' : 'default'" @change="emit('time-type-change')">
<el-radio-button v-if="showAll" label="all">全部</el-radio-button>
<el-radio-button label="today">今天</el-radio-button>
<el-radio-button label="yesterday">昨天</el-radio-button>
<el-radio-button label="week">最近7天</el-radio-button>
<el-radio-button label="month">本月</el-radio-button>
<el-radio-button label="custom">自定义</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="timeType === 'custom'">
<el-date-picker
v-model="dateRangeModel"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
:size="compact ? 'small' : 'default'"
:style="{ width: pickerWidth }"
@change="emit('custom-change')"
/>
</el-form-item>
</template>
<el-form-item v-else :label="label" :class="{ 'is-compact': compact }">
<div class="list-time-filter-inline">
<el-radio-group v-model="timeType" :size="compact ? 'small' : 'default'" @change="emit('time-type-change')">
<el-radio-button v-if="showAll" label="all">全部</el-radio-button>
<el-radio-button label="today">今天</el-radio-button>
<el-radio-button label="yesterday">昨天</el-radio-button>
<el-radio-button label="week">最近7天</el-radio-button>
<el-radio-button label="month">本月</el-radio-button>
<el-radio-button label="custom">自定义</el-radio-button>
</el-radio-group>
<el-date-picker
v-if="timeType === 'custom'"
v-model="dateRangeModel"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
:size="compact ? 'small' : 'default'"
:style="{ width: pickerWidth }"
@change="emit('custom-change')"
/>
</div>
</el-form-item>
</template>
<script lang="ts" setup>
import type { ListTimeType } from '@/hooks/useListTimeFilter'
withDefaults(
defineProps<{
label?: string
/** split:与自录统计一致,两个 form-item;inline:处方订单筛选用单行 */
variant?: 'split' | 'inline'
pickerWidth?: string
/** 紧凑模式:small 尺寸 radio / 日期 */
compact?: boolean
/** 是否显示「全部」(不限时间)选项 */
showAll?: boolean
}>(),
{
label: '时间范围',
variant: 'split',
pickerWidth: '260px',
compact: false,
showAll: false,
}
)
const timeType = defineModel<ListTimeType>('timeType', { required: true })
const dateRangeModel = defineModel<string[]>('dateRange', { default: () => [] })
const emit = defineEmits<{
'time-type-change': []
'custom-change': []
}>()
</script>
<style scoped lang="scss">
.list-time-filter-inline {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
}
:deep(.is-compact.el-form-item) {
margin-bottom: 0;
}
</style>
+84 -21
View File
@@ -88,13 +88,17 @@
<strong>{{ formData.herbs.length }}</strong> 主方 {{ mainHerbCount }} / 辅方
{{ auxHerbCount }}· 可手动添加从处方库或粘贴药方批量录入
</p>
<p v-if="herbsLocked" class="rp-toolbar__lock-tip">
<el-tag type="danger" size="small" effect="plain">禁用修改</el-tag>
禁用修改处方库模板药材已锁定不可增删改如需调整请重新从处方库导入覆盖
</p>
</div>
</div>
<div class="rp-toolbar__actions" role="toolbar" aria-label="处方药材快捷操作">
<el-button type="primary" size="small" :icon="CirclePlus" @click="handleAddHerb('主方')">
<el-button type="primary" size="small" :icon="CirclePlus" :disabled="herbsLocked" @click="handleAddHerb('主方')">
添加主方
</el-button>
<el-button type="primary" plain size="small" @click="handleAddHerb('辅方')">
<el-button type="primary" plain size="small" :disabled="herbsLocked" @click="handleAddHerb('辅方')">
添加辅方
</el-button>
<el-button
@@ -135,7 +139,7 @@
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
>
<div class="herb-editor-card__title">主方 {{ row.index + 1 }}</div>
<MedicineNameSelect v-model="formData.herbs[row.index].name" class="herb-editor-card__select" />
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
<div
v-if="isDuplicateHerbName(row.index)"
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
@@ -148,11 +152,12 @@
:min="0.1"
:step="1"
:precision="1"
:disabled="herbsLocked"
placeholder="剂量(克)"
class="herb-editor-card__dose"
/>
<span class="text-gray-500 shrink-0"></span>
<el-button type="danger" size="small" link @click="handleRemoveHerb(row.index)">
<el-button v-if="!herbsLocked" type="danger" size="small" link @click="handleRemoveHerb(row.index)">
删除
</el-button>
</div>
@@ -171,7 +176,7 @@
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
>
<div class="herb-editor-card__title">辅方</div>
<MedicineNameSelect v-model="formData.herbs[row.index].name" class="herb-editor-card__select" />
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
<div
v-if="isDuplicateHerbName(row.index)"
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
@@ -184,11 +189,12 @@
:min="0.1"
:step="1"
:precision="1"
:disabled="herbsLocked"
placeholder="剂量(克)"
class="herb-editor-card__dose"
/>
<span class="text-gray-500 shrink-0"></span>
<el-button type="danger" size="small" link @click="handleRemoveHerb(row.index)">
<el-button v-if="!herbsLocked" type="danger" size="small" link @click="handleRemoveHerb(row.index)">
删除
</el-button>
</div>
@@ -553,7 +559,11 @@
<div class="rx-title">{{ prescriptionTabType === 'internal' ? '药房联' : SLIP_TITLE }}</div>
<div class="rx-notice">
<span class="rx-notice-text">
服药前请核对姓名电话医生等信息以及服法医嘱等要点
{{
prescriptionTabType === 'user'
? '服药前请核对姓名、电话、医生等信息以及医嘱等要点'
: '服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点'
}}
</span>
<span class="rx-notice-meta">
<span>日期{{ rxDateText }}</span>
@@ -672,22 +682,30 @@
</div>
<div class="rx-text">
<p v-if="prescriptionTabType === 'internal' || savedAuxUsageText">主方服法{{ rxUsageText }}</p>
<p v-else>服法{{ rxUsageText }}</p>
<p v-if="savedAuxUsageText">{{ prescriptionTabType === 'user' ? '辅服法' : '辅方服法' }}{{ savedAuxUsageText }}</p>
<template v-if="prescriptionTabType === 'internal'">
<p v-if="savedAuxUsageText">主方服法{{ rxUsageText }}</p>
<p v-else>服法{{ rxUsageText }}</p>
<p v-if="savedAuxUsageText">辅方服法{{ savedAuxUsageText }}</p>
</template>
<p v-if="rxAdviceText">医嘱{{ rxAdviceText }}</p>
<p v-if="rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="prescriptionTabType === 'internal' && rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="savedPrescription.dietary_taboo && savedPrescription.dietary_taboo.length">
忌口{{ Array.isArray(savedPrescription.dietary_taboo) ? savedPrescription.dietary_taboo.join('、') : savedPrescription.dietary_taboo }}
</p>
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">药房备注{{ rxPharmacyRemarkText }}</p>
<p v-if="rxOutPelletText && savedPrescription.prescription_type !== '饮片'" class="rx-text-warn">
<p
v-if="prescriptionTabType === 'internal' && rxOutPelletText && savedPrescription.prescription_type !== '饮片'"
class="rx-text-warn"
>
出丸{{ rxOutPelletText }}
</p>
</div>
<div class="rx-bottom">
<div class="rx-bot-row rx-bot-row-1">
<div
class="rx-bot-row rx-bot-row-1"
:class="{ 'rx-bot-row-1--user': prescriptionTabType === 'user' }"
>
<div class="rx-bot-cell rx-bot-stack rx-bot-doctor">
<div class="rx-bot-label">医师</div>
<div class="rx-bot-doctor-body">
@@ -697,10 +715,12 @@
alt="医师签名"
class="rx-bot-sign-img"
/>
<div class="rx-bot-doctor-name">{{ savedPrescription.doctor_name || '—' }}</div>
<div v-if="!savedPrescription.doctor_signature" class="rx-bot-doctor-name">
{{ savedPrescription.doctor_name || '—' }}
</div>
</div>
</div>
<div class="rx-bot-cell rx-bot-meta">
<div v-if="prescriptionTabType === 'internal'" class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">类型:</span>
<span class="rx-bot-meta-val">{{ rxTypeText }}</span>
</div>
@@ -708,7 +728,7 @@
<span class="rx-bot-meta-key">天数:</span>
<span class="rx-bot-meta-val">{{ rxSlipMedicationDaysText }}</span>
</div>
<div class="rx-bot-cell rx-bot-meta">
<div v-if="prescriptionTabType === 'internal'" class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">剂量:</span>
<span class="rx-bot-meta-val">{{ rxPerDoseAmount }}</span>
</div>
@@ -1028,6 +1048,8 @@ interface Herb {
name: string
dosage: number
formula_type?: FormulaType
/** 来自「禁用修改」处方库模板:锁定后不可增删改,只能再次导入覆盖 */
locked?: boolean
}
type AuxUsageForm = {
@@ -1044,11 +1066,15 @@ function normalizeFormulaType(v: unknown): FormulaType {
}
function normalizeHerbRow(raw: any): Herb {
return {
const row: Herb = {
name: String(raw?.name ?? '').trim(),
dosage: Number(raw?.dosage) || 0,
formula_type: normalizeFormulaType(raw?.formula_type)
}
if (raw?.locked === true || raw?.locked === 1) {
row.locked = true
}
return row
}
function defaultAuxUsage(prescriptionType = '浓缩水丸'): AuxUsageForm {
@@ -1430,6 +1456,12 @@ const auxHerbRows = computed(() =>
const mainHerbCount = computed(() => mainHerbRows.value.length)
const auxHerbCount = computed(() => auxHerbRows.value.length)
/**
* 整张处方是否被锁定只要任一药材来自禁用修改模板即锁定
* 锁定后不可增删改任意药材仅可再次从处方库导入覆盖
*/
const herbsLocked = computed(() => formData.herbs.some((h) => (h as Herb).locked === true))
const libraryDoctorId = computed(() => {
const uid = Number(userStore.userInfo?.id)
return uid > 0 ? uid : 0
@@ -2029,10 +2061,18 @@ function findDuplicateHerbNames(): string[] {
}
const handleAddHerb = (formulaType: FormulaType = '主方') => {
if (herbsLocked.value) {
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,请重新从处方库导入覆盖')
return
}
formData.herbs.push({ name: '', dosage: 6, formula_type: formulaType })
}
const handleRemoveHerb = (index: number) => {
if (herbsLocked.value) {
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,不可删除')
return
}
formData.herbs.splice(index, 1)
}
@@ -2089,10 +2129,18 @@ const handleImportLibrary = (row: any) => {
}
const formulaType = normalizeFormulaType(row.formula_type)
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => ({
...normalizeHerbRow(h),
formula_type: formulaType
}))
//
const isLockedTemplate = Number(row?.disable_edit) === 1
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => {
const herb = normalizeHerbRow(h)
herb.formula_type = formulaType
if (isLockedTemplate) {
herb.locked = true
} else {
delete herb.locked
}
return herb
})
if (libraryImportMode.value === 'replace') {
const kept = formData.herbs.filter((h) => normalizeFormulaType(h.formula_type) !== formulaType)
formData.herbs = [...kept, ...imported]
@@ -2532,6 +2580,17 @@ defineExpose({
color: var(--el-text-color-regular);
}
.rp-toolbar__lock-tip {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin: 6px 0 0;
font-size: 12px;
line-height: 1.55;
color: var(--el-color-danger);
}
.rp-toolbar__actions {
display: flex;
flex-wrap: wrap;
@@ -2925,6 +2984,10 @@ defineExpose({
min-height: 70px;
}
.rx-bot-row-1--user {
grid-template-columns: 1.4fr 0.6fr;
}
.rx-hospital-row {
display: flex;
flex-direction: column;
+88
View File
@@ -0,0 +1,88 @@
export type ListTimeType = 'all' | 'today' | 'yesterday' | 'week' | 'month' | 'custom'
export function formatYmd(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
/** 列表时间筛选:month 为本自然月 1 日~月末 */
export function resolveListTimeRange(timeType: ListTimeType, dateRange: string[]): [string, string] {
const today = formatYmd(new Date())
switch (timeType) {
case 'all':
return ['', '']
case 'yesterday': {
const d = new Date()
d.setDate(d.getDate() - 1)
const y = formatYmd(d)
return [y, y]
}
case 'week': {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 6)
return [formatYmd(start), formatYmd(end)]
}
case 'month': {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), 1)
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0)
return [formatYmd(start), formatYmd(end)]
}
case 'custom': {
let start = dateRange[0] || today
let end = dateRange[1] || today
if (start > end) {
;[start, end] = [end, start]
}
return [start, end]
}
case 'today':
default:
return [today, today]
}
}
export function useListTimeFilter(defaultType: ListTimeType = 'today') {
const time_type = ref<ListTimeType>(defaultType)
const dateRange = ref<string[]>([])
const resolve = () => resolveListTimeRange(time_type.value, dateRange.value)
const handleTimeTypeChange = (onQuery: () => void) => {
if (time_type.value !== 'custom') {
dateRange.value = []
onQuery()
}
}
const handleCustomDateChange = (onQuery: () => void) => {
if (dateRange.value.length === 2) {
onQuery()
}
}
const resetTimeFilter = () => {
time_type.value = defaultType
dateRange.value = []
}
const applyCustomRange = (start: string, end: string) => {
if (!start || !end) return
time_type.value = 'custom'
dateRange.value = [start, end]
}
return {
time_type,
dateRange,
resolve,
handleTimeTypeChange,
handleCustomDateChange,
resetTimeFilter,
applyCustomRange,
}
}
+76 -2
View File
@@ -27,6 +27,20 @@
<el-table :data="tableData" v-loading="loading">
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
<el-table-column prop="phone" label="手机号" />
<el-table-column label="备注" min-width="200">
<template #default="{ row }">
<div class="flex items-center gap-1 min-w-0">
<span class="truncate text-gray-600" :title="row.remark || ''">
{{ row.remark || '—' }}
</span>
<el-tooltip content="编辑备注" placement="top">
<el-icon class="shrink-0 cursor-pointer text-gray-400 hover:text-primary" @click="openQuickRemark(row)">
<Edit />
</el-icon>
</el-tooltip>
</div>
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="180" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
@@ -70,6 +84,16 @@
<el-form-item label="密码" prop="password">
<el-input v-model="formData.password" placeholder="为空则默认为 123456" show-password />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="可填写用户相关信息,仅管理员可见"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="formData.status" :active-value="1" :inactive-value="0" />
</el-form-item>
@@ -79,12 +103,30 @@
<el-button type="primary" @click="submitForm" :loading="submitLoading">确定</el-button>
</template>
</el-dialog>
<!-- 快捷备注 -->
<el-dialog v-model="remarkDialogVisible" title="快捷备注" width="420px" destroy-on-close>
<div class="text-sm text-gray-500 mb-3">账号{{ remarkTargetPhone }}</div>
<el-input
v-model="remarkDraft"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="可填写用户相关信息,仅管理员可见"
/>
<template #footer>
<el-button @click="remarkDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="remarkSaving" @click="saveQuickRemark">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Edit } from '@element-plus/icons-vue'
import { apiAssetUserList, apiAssetUserAdd, apiAssetUserEdit, apiAssetUserDelete } from '@/api/asset'
const loading = ref(false)
@@ -105,10 +147,17 @@ const dialogTitle = ref('')
const submitLoading = ref(false)
const formRef = ref()
const remarkDialogVisible = ref(false)
const remarkSaving = ref(false)
const remarkDraft = ref('')
const remarkTargetId = ref<number | string>('')
const remarkTargetPhone = ref('')
const formData = reactive({
id: '',
phone: '',
password: '',
remark: '',
status: 1
})
@@ -154,16 +203,41 @@ const handleStatusChange = async (row: any) => {
const handleAdd = () => {
dialogTitle.value = '新增账号'
Object.assign(formData, { id: '', phone: '', password: '', status: 1 })
Object.assign(formData, { id: '', phone: '', password: '', remark: '', status: 1 })
dialogVisible.value = true
}
const handleEdit = (row: any) => {
dialogTitle.value = '编辑账号'
Object.assign(formData, { id: row.id, phone: row.phone, password: '', status: row.status })
Object.assign(formData, { id: row.id, phone: row.phone, password: '', remark: row.remark || '', status: row.status })
dialogVisible.value = true
}
const openQuickRemark = (row: any) => {
remarkTargetId.value = row.id
remarkTargetPhone.value = row.phone || ''
remarkDraft.value = row.remark || ''
remarkDialogVisible.value = true
}
const saveQuickRemark = async () => {
if (!remarkTargetId.value) return
remarkSaving.value = true
try {
await apiAssetUserEdit({ id: remarkTargetId.value, remark: remarkDraft.value })
const target = tableData.value.find((item: any) => item.id === remarkTargetId.value)
if (target) {
target.remark = remarkDraft.value
}
ElMessage.success('备注已保存')
remarkDialogVisible.value = false
} catch (e) {
console.error(e)
} finally {
remarkSaving.value = false
}
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除该账号及其关联资源吗?', '提示', { type: 'warning' })
+49 -13
View File
@@ -458,7 +458,9 @@
alt="医师签名"
class="rx-bot-sign-img"
/>
<div class="rx-bot-doctor-name">{{ slipView.doctor_name || '—' }}</div>
<div v-if="!slipView.doctor_signature" class="rx-bot-doctor-name">
{{ slipView.doctor_name || '—' }}
</div>
</div>
</div>
<div class="rx-bot-cell rx-bot-meta">
@@ -679,17 +681,21 @@
粘贴文本解析药名与剂量须与药品库名称完全一致才录入导入至主方
</span>
</div>
<div v-if="herbsLocked" class="flex items-center gap-2 flex-wrap mt-2 text-xs text-[var(--el-color-danger)]">
<el-tag type="danger" size="small" effect="plain">禁用修改</el-tag>
禁用修改处方库模板药材已锁定不可增删改如需调整请重新从处方库导入覆盖
</div>
<div class="herb-formula-block mt-3">
<div class="herb-formula-block__head">
<el-tag type="primary" size="small">主方</el-tag>
<el-button type="primary" size="small" @click="addHerb('主方')">添加主方药材</el-button>
<el-button type="primary" size="small" :disabled="herbsLocked" @click="addHerb('主方')">添加主方药材</el-button>
</div>
<el-table :data="mainHerbRows" class="mt-2" border empty-text="暂无主方药材">
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="editForm.herbs[row.index].name" class="w-full" />
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
@@ -699,6 +705,7 @@
:min="0"
:precision="1"
:step="0.5"
:disabled="herbsLocked"
placeholder="剂量"
class="w-full"
/>
@@ -706,7 +713,8 @@
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button type="danger" link @click="removeHerb(row.index)">删除</el-button>
<el-button v-if="!herbsLocked" type="danger" link @click="removeHerb(row.index)">删除</el-button>
<span v-else class="text-gray-400 text-xs">已锁定</span>
</template>
</el-table-column>
</el-table>
@@ -715,7 +723,7 @@
<div class="herb-formula-block mt-4">
<div class="herb-formula-block__head">
<el-tag type="warning" size="small">辅方</el-tag>
<el-button type="primary" size="small" plain @click="addHerb('辅方')">
<el-button type="primary" size="small" plain :disabled="herbsLocked" @click="addHerb('辅方')">
添加辅方药材
</el-button>
</div>
@@ -723,7 +731,7 @@
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="editForm.herbs[row.index].name" class="w-full" />
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
@@ -733,6 +741,7 @@
:min="0"
:precision="1"
:step="0.5"
:disabled="herbsLocked"
placeholder="剂量"
class="w-full"
/>
@@ -740,7 +749,8 @@
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button type="danger" link @click="removeHerb(row.index)">删除</el-button>
<el-button v-if="!herbsLocked" type="danger" link @click="removeHerb(row.index)">删除</el-button>
<span v-else class="text-gray-400 text-xs">已锁定</span>
</template>
</el-table-column>
</el-table>
@@ -1698,7 +1708,7 @@ import jsPDF from 'jspdf'
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
type FormulaType = '主方' | '辅方'
type HerbRow = { name: string; dosage: number; formula_type: FormulaType }
type HerbRow = { name: string; dosage: number; formula_type: FormulaType; locked?: boolean }
type AuxUsageForm = {
dosage_amount?: number
@@ -1798,11 +1808,15 @@ function normalizeFormulaType(v: unknown): FormulaType {
}
function normalizeHerbRow(raw: any): HerbRow {
return {
const row: HerbRow = {
name: String(raw?.name ?? '').trim(),
dosage: Number(raw?.dosage) || 0,
formula_type: normalizeFormulaType(raw?.formula_type)
}
if (raw?.locked === true || raw?.locked === 1) {
row.locked = true
}
return row
}
function normalizeSlipHerbs(raw: unknown): HerbRow[] {
@@ -2481,6 +2495,12 @@ const auxHerbRows = computed(() =>
.filter(({ herb }) => normalizeFormulaType(herb.formula_type) === '辅方')
)
/**
* 整张处方是否被锁定只要任一药材来自禁用修改处方库模板即锁定
* 锁定后不可增删改任意药材只能再次从处方库导入覆盖
*/
const herbsLocked = computed(() => editForm.herbs.some((h) => (h as HerbRow).locked === true))
const slipDietaryText = computed(() => {
const d = slipView.value?.dietary_taboo
if (Array.isArray(d)) return d.filter(Boolean).join('、')
@@ -3441,10 +3461,18 @@ const handleImportLibrary = (row: any) => {
return
}
const formulaType = normalizeFormulaType(row.formula_type)
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => ({
...normalizeHerbRow(h),
formula_type: formulaType
}))
//
const isLockedTemplate = Number(row?.disable_edit) === 1
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => {
const herb = normalizeHerbRow(h)
herb.formula_type = formulaType
if (isLockedTemplate) {
herb.locked = true
} else {
delete herb.locked
}
return herb
})
if (libraryImportMode.value === 'replace') {
const kept = editForm.herbs.filter((h) => normalizeFormulaType(h.formula_type) !== formulaType)
editForm.herbs = [...kept, ...imported]
@@ -3650,6 +3678,10 @@ async function handlePasteRecipeImport() {
// /
const addHerb = (formulaType: FormulaType = '主方') => {
if (herbsLocked.value) {
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,请重新从处方库导入覆盖')
return
}
editForm.herbs.push({
name: '',
dosage: 0,
@@ -3659,6 +3691,10 @@ const addHerb = (formulaType: FormulaType = '主方') => {
//
const removeHerb = (index: number) => {
if (herbsLocked.value) {
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,不可删除')
return
}
editForm.herbs.splice(index, 1)
}
+27 -1
View File
@@ -71,6 +71,13 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="禁用修改" min-width="100">
<template #default="{ row }">
<el-tag :type="row.disable_edit ? 'danger' : 'info'">
{{ row.disable_edit ? '已禁用' : '可修改' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建人" prop="creator_name" min-width="100" />
<el-table-column label="创建时间" prop="create_time" min-width="160" />
<el-table-column label="操作" width="180" fixed="right">
@@ -193,6 +200,21 @@
勾选后所有医生都可以查看和使用此处方模板
</div>
</el-form-item>
<el-form-item label="禁用修改" prop="disable_edit">
<div class="flex flex-col gap-1 leading-none">
<el-switch
v-model="editForm.disable_edit"
:active-value="1"
:inactive-value="0"
active-text="开启"
inactive-text="关闭"
/>
<div class="text-xs text-gray-400 leading-relaxed">
开启后导入此模板到处方时将锁定整张处方的药材不可增删改只能再次导入覆盖
</div>
</div>
</el-form-item>
</el-form>
<template #footer v-if="editMode !== 'view'">
@@ -236,7 +258,8 @@ const editForm = reactive({
prescription_name: '',
formula_type: '主方',
herbs: [] as Array<{ name: string; dosage: number }>,
is_public: 0
is_public: 0,
disable_edit: 0
})
//
@@ -293,6 +316,7 @@ const resetForm = () => {
editForm.formula_type = '主方'
editForm.herbs = []
editForm.is_public = 0
editForm.disable_edit = 0
}
//
@@ -310,6 +334,7 @@ const handleView = (row: any) => {
editForm.formula_type = row.formula_type || '主方'
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
editForm.is_public = row.is_public ?? 0
editForm.disable_edit = row.disable_edit ?? 0
showEdit.value = true
}
@@ -321,6 +346,7 @@ const handleEdit = (row: any) => {
editForm.formula_type = row.formula_type || '主方'
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
editForm.is_public = row.is_public ?? 0
editForm.disable_edit = row.disable_edit ?? 0
showEdit.value = true
}
@@ -173,17 +173,16 @@
</div>
</div>
<el-form class="ls-form" :model="queryParams" inline>
<el-form-item class="w-[360px] min-w-[240px] max-w-full" label="创建时间">
<DaterangePicker
v-model:startTime="queryParams.start_time"
v-model:endTime="queryParams.end_time"
picker-type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
class="!w-full"
/>
</el-form-item>
<list-time-filter
v-model:time-type="time_type"
v-model:date-range="dateRange"
label="创建时间"
variant="inline"
show-all
picker-width="230px"
@time-type-change="onTimeTypeChange"
@custom-change="onCustomDateChange"
/>
<el-form-item class="w-[260px]" label="订单号">
<el-input
v-model="queryParams.order_no"
@@ -293,7 +292,7 @@
:fetch-fun="prescriptionOrderExport"
:params="prescriptionOrderExportParams"
:page-size="pager.size"
export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。「服务套餐」按字典 server_order 解析展示。「签收日期」与详情/业绩看板同源:优先库表轨迹;库内无轨迹时导出会按 logisticsTrace 口径补查快递100 并落库(如 PO 单在详情已签收但库内为空)。"
export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。「服务套餐」按字典 server_order 解析展示。「签收日期」与详情/业绩看板同源,仅读物流库(轨迹/签收时间):导出不再实时查快递100,速度只取决于数据库;签收时间由 `tcm:backfill-sign-time` 命令与物流自动更新定时任务落库,刚发货尚未同步的单子会暂时为空,待下次回填/定时任务刷新后显示。"
/>
</el-form-item>
</el-form>
@@ -530,6 +529,13 @@
link
@click="confirmComplete(row)"
>完成订单</el-button>
<el-button
v-if="canRefundRow(row)"
v-perms="['tcm.prescriptionOrder/refund']"
type="danger"
link
@click="openRefundOrder(row)"
>退款</el-button>
<el-button
v-if="canWithdrawRow(row)"
v-perms="['tcm.prescriptionOrder/withdraw']"
@@ -672,6 +678,16 @@
>
完成订单
</el-button>
<el-button
v-if="canRefundRow(detailData)"
v-perms="['tcm.prescriptionOrder/refund']"
type="danger"
size="small"
plain
@click="openRefundOrder(detailData)"
>
退款
</el-button>
</div>
</div>
</template>
@@ -780,49 +796,50 @@
</div>
<!-- 顶部数据概览卡片 -->
<el-row :gutter="16" class="mb-5">
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="flex items-center justify-between mb-1">
<div class="stat-title text-gray-500 text-xs">总金额</div>
<el-button
v-if="canUpdateAmount(detailData)"
v-perms="['tcm.prescriptionOrder/updateAmount']"
type="primary"
size="small"
link
@click="openUpdateAmount"
>
修改
</el-button>
</div>
<div class="stat-value text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">已付总额</div>
<div class="stat-value text-green-600 font-bold text-xl">¥{{ detailData.linked_pay_paid_total || 0 }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">需代收</div>
<div
class="stat-value font-bold text-xl"
:class="detailAgencyToCollect > 0 ? 'text-amber-600' : 'text-gray-500'"
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-5">
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="flex items-center justify-between mb-1">
<div class="stat-title text-gray-500 text-xs">总金额</div>
<el-button
v-if="canUpdateAmount(detailData)"
v-perms="['tcm.prescriptionOrder/updateAmount']"
type="primary"
size="small"
link
@click="openUpdateAmount"
>
¥{{ formatMoney(detailAgencyToCollect) }}
</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">已付笔数</div>
<div class="stat-value text-primary font-bold text-xl">{{ detailLinkedPayOrders.length }} </div>
</el-card>
</el-col>
</el-row>
修改
</el-button>
</div>
<div class="stat-value text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
</el-card>
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">已付总额</div>
<div class="stat-value text-green-600 font-bold text-xl">¥{{ detailData.linked_pay_paid_total || 0 }}</div>
</el-card>
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">退款金额</div>
<div
class="stat-value font-bold text-xl"
:class="Number(detailData.refund_amount) > 0 ? 'text-rose-500' : 'text-gray-400'"
>
¥{{ formatMoney(detailData.refund_amount || 0) }}
</div>
</el-card>
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">需代收</div>
<div
class="stat-value font-bold text-xl"
:class="detailAgencyToCollect > 0 ? 'text-amber-600' : 'text-gray-500'"
>
¥{{ formatMoney(detailAgencyToCollect) }}
</div>
</el-card>
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">已付笔数</div>
<div class="stat-value text-primary font-bold text-xl">{{ detailLinkedPayOrders.length }} </div>
</el-card>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<!-- 左侧处方单 -->
@@ -1336,6 +1353,17 @@
<template #icon><el-icon><Refresh /></el-icon></template>
刷新轨迹
</el-button>
<el-button
v-perms="['tcm.prescriptionOrder/logisticsTrace']"
type="warning"
plain
size="default"
:loading="jdLogisticsUpdating"
@click="updateJdLogistics"
>
<template #icon><el-icon><Van /></el-icon></template>
京东接口更新
</el-button>
<div class="ml-2 flex items-center gap-4 border-l pl-4 border-gray-200">
<el-link :href="detailOfficialUrls.sf" target="_blank" type="primary" :underline="false" class="text-[13px]">顺丰官网</el-link>
<el-link :href="detailOfficialUrls.jd" target="_blank" type="primary" :underline="false" class="text-[13px]">京东物流</el-link>
@@ -2082,7 +2110,9 @@
</div>
</template>
</el-alert>
<p class="text-sm text-gray-600 mb-3">请选择本单结案状态选择已完成实付将按已关联的支付单金额汇总</p>
<p class="text-sm text-gray-600 mb-3">
请选择本单结案状态选择已完成实付将按已关联的支付单金额汇总选择退款时将关联收款单标记为已退款并清零本单已付
</p>
<el-select
v-model="completeFulfillmentStatus"
class="w-full"
@@ -2095,11 +2125,139 @@
:value="opt.value"
/>
</el-select>
<template v-if="completeFulfillmentStatus === 10">
<el-alert
type="warning"
:closable="false"
show-icon
class="mt-3 !items-start"
title="退款将处理本单全部关联收款单,已付总额清零,且不可撤销。"
/>
<div v-loading="completeOrderPayLoading" class="mt-3">
<div
v-if="completeOrderLinkedPays.length"
class="rounded-lg border border-amber-200/80 bg-amber-50/50 p-3"
>
<div class="text-xs font-semibold text-amber-900 mb-2">
将退款的关联收款{{ completeOrderLinkedPays.length }} · 合计 ¥{{ formatMoney(completeOrderRefundTotal) }}
</div>
<el-table :data="completeOrderLinkedPays" size="small" border stripe max-height="200">
<el-table-column prop="order_no" label="单号" min-width="120" show-overflow-tooltip />
<el-table-column label="类型" width="88">
<template #default="{ row }">
{{ row.order_type_desc || feeTypeText(row.order_type) }}
</template>
</el-table-column>
<el-table-column label="金额" width="88" align="right">
<template #default="{ row }">
<span class="text-red-500">¥{{ row.amount }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="92" align="center">
<template #default="{ row }">
<el-tag :type="payOrderStatusTag(row.status)" size="small">
{{ row.status_desc || orderStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<p v-else-if="!completeOrderPayLoading" class="text-xs text-gray-500 mt-2">
当前无已关联的收款单提交后仍将本单标记为退款并清零已付
</p>
</div>
<el-form
ref="completeRefundFormRef"
:model="completeRefundForm"
:rules="completeRefundRules"
label-width="88px"
class="mt-3"
>
<el-form-item label="退款金额" prop="refund_amount">
<el-input-number
v-model="completeRefundForm.refund_amount"
:min="0.01"
:max="completeOrderRefundMax > 0 ? completeOrderRefundMax : undefined"
:step="0.01"
:precision="2"
class="w-full"
controls-position="right"
/>
<div class="text-xs text-gray-500 mt-1">
可退上限 ¥{{ formatMoney(completeOrderRefundMax) }}
</div>
</el-form-item>
<el-form-item label="退款原因" prop="reason">
<el-input
v-model="completeRefundForm.reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请填写退款原因(必填)"
/>
</el-form-item>
</el-form>
</template>
</div>
<template #footer>
<el-button @click="completeOrderDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="completeOrderSubmitting" @click="submitCompleteOrder">
确定
<el-button
:type="completeFulfillmentStatus === 10 ? 'danger' : 'primary'"
:loading="completeOrderSubmitting"
@click="submitCompleteOrder"
>
{{ completeFulfillmentStatus === 10 ? '确认退款' : '确定' }}
</el-button>
</template>
</el-dialog>
<!-- 订单退款须填写原因 -->
<el-dialog
v-model="refundOrderDialogVisible"
title="订单退款"
width="480px"
:close-on-click-modal="false"
destroy-on-close
@closed="onRefundOrderDialogClosed"
>
<el-alert
type="warning"
:closable="false"
show-icon
class="mb-4 !items-start"
:title="refundOrderAlertTitle"
/>
<el-form ref="refundOrderFormRef" :model="refundOrderForm" :rules="refundOrderRules" label-width="88px">
<el-form-item label="退款金额" prop="refund_amount">
<el-input-number
v-model="refundOrderForm.refund_amount"
:min="0.01"
:max="refundOrderMaxAmount > 0 ? refundOrderMaxAmount : undefined"
:step="0.01"
:precision="2"
class="w-full"
controls-position="right"
/>
<div class="text-xs text-gray-500 mt-1">
可退上限 ¥{{ formatMoney(refundOrderMaxAmount) }}默认全额部分退款时按金额从关联收款单依次标记已退款并扣减本单已付
</div>
</el-form-item>
<el-form-item label="退款原因" prop="reason">
<el-input
v-model="refundOrderForm.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请填写退款原因(必填)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="refundOrderDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="refundOrderSubmitting" @click="submitRefundOrder">
确认退款
</el-button>
</template>
</el-dialog>
@@ -2366,7 +2524,11 @@
<div class="rx-title">{{ prescriptionTabType === 'internal' ? '药房联' : SLIP_TITLE }}</div>
<div class="rx-notice">
<span class="rx-notice-text">
服药前请核对姓名电话医生等信息以及服法医嘱等要点
{{
prescriptionTabType === 'user'
? '服药前请核对姓名、电话、医生等信息以及医嘱等要点'
: '服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点'
}}
</span>
<div class="rx-notice-meta">
<div class="rx-meta-item">
@@ -2490,21 +2652,29 @@
</div>
<div class="rx-text">
<p v-if="prescriptionTabType === 'internal' || rxAuxUsageText">主服法{{ rxUsageText }}</p>
<p v-else>服法{{ rxUsageText }}</p>
<p v-if="rxAuxUsageText">服法{{ rxAuxUsageText }}</p>
<template v-if="prescriptionTabType === 'internal'">
<p v-if="rxAuxUsageText">服法{{ rxUsageText }}</p>
<p v-else>服法{{ rxUsageText }}</p>
<p v-if="rxAuxUsageText">辅服法{{ rxAuxUsageText }}</p>
</template>
<p v-if="rxAdviceText">医嘱{{ rxAdviceText }}</p>
<p v-if="rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="prescriptionTabType === 'internal' && rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
药房备注{{ rxPharmacyRemarkText }}
</p>
<p v-if="rxOutPelletText && prescriptionViewData?.prescription_type !== '饮片'" class="rx-text-warn">
<p
v-if="prescriptionTabType === 'internal' && rxOutPelletText && prescriptionViewData?.prescription_type !== '饮片'"
class="rx-text-warn"
>
出丸{{ rxOutPelletText }}
</p>
</div>
<div class="rx-bottom">
<div class="rx-bot-row rx-bot-row-1">
<div
class="rx-bot-row rx-bot-row-1"
:class="{ 'rx-bot-row-1--user': prescriptionTabType === 'user' }"
>
<div class="rx-bot-cell rx-bot-doctor">
<div class="rx-bot-label rx-bot-label--doctor">医师</div>
<div class="rx-bot-doctor-body">
@@ -2514,11 +2684,14 @@
alt="医师签名"
class="rx-bot-sign-img"
/>
<!-- <span class="rx-bot-doctor-name">{{ prescriptionViewData.doctor_name || '—' }}</span> -->
<span
v-if="!prescriptionViewData.doctor_signature"
class="rx-bot-doctor-name"
>{{ prescriptionViewData.doctor_name || '—' }}</span>
</div>
</div>
<div class="rx-bot-cell rx-bot-meta">
<div v-if="prescriptionTabType === 'internal'" class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">类型:</span>
<span class="rx-bot-meta-val">{{ rxTypeText }}</span>
</div>
@@ -2532,7 +2705,7 @@
: '—'
}} </span>
</div>
<div class="rx-bot-cell rx-bot-meta">
<div v-if="prescriptionTabType === 'internal'" class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">剂量:</span>
<span class="rx-bot-meta-val">{{ rxPerDoseAmount }}</span>
</div>
@@ -2708,8 +2881,9 @@
<script lang="ts" setup name="prescriptionOrderList">
import { computed, onMounted, reactive, ref, nextTick, watch, defineAsyncComponent } from 'vue'
import { useRoute } from 'vue-router'
import { Refresh, Loading, ArrowDown, InfoFilled, QuestionFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
import DaterangePicker from '@/components/daterange-picker/index.vue'
import { Refresh, Loading, ArrowDown, InfoFilled, QuestionFilled, Search, Calendar, Document, Link as LinkIcon, Wallet, Van } from '@element-plus/icons-vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
import {
prescriptionOrderAuditPayment,
prescriptionOrderAuditPrescription,
@@ -2720,6 +2894,7 @@ import {
prescriptionOrderPaidPayOrders,
prescriptionOrderWithdraw,
prescriptionOrderLogisticsTrace,
prescriptionOrderLogisticsJdUpdate,
prescriptionOrderShip,
prescriptionOrderLogs,
prescriptionOrderUpdateAmount,
@@ -2727,6 +2902,7 @@ import {
prescriptionOrderAddPayOrder,
prescriptionOrderRequestCompletion,
prescriptionOrderComplete,
prescriptionOrderRefund,
prescriptionOrderRevokeRxAudit,
prescriptionOrderRevokePayAudit,
prescriptionOrderPatchPrescriptionPatient,
@@ -3014,9 +3190,6 @@ function handleExpressCompanyTabClick(value: '' | 'sf' | 'jd') {
}
const queryParams = reactive({
/** 创建时间区间(与列表筛选一致;不影响「今日」统计) */
start_time: '' as string,
end_time: '' as string,
order_no: '',
prescription_id: '' as string | number,
/** 诊单患者姓名/手机号(后台关联 diagnosis */
@@ -3104,6 +3277,24 @@ function handleFocusBoardSearch(key: 'pendingRx' | 'pendingPay' | 'pendingShip'
resetPage()
}
const {
time_type,
dateRange,
resolve: resolveCreateTimeRange,
handleTimeTypeChange,
handleCustomDateChange,
resetTimeFilter,
applyCustomRange,
} = useListTimeFilter('all')
const handleQuery = () => {
pager.page = 1
getLists()
}
const onTimeTypeChange = () => handleTimeTypeChange(handleQuery)
const onCustomDateChange = () => handleCustomDateChange(handleQuery)
function normalizePrescriptionOrderListQuery(params: Record<string, unknown>): Record<string, unknown> {
const p: Record<string, unknown> = { ...params }
if (p.prescription_id === '' || p.prescription_id === undefined) {
@@ -3183,11 +3374,15 @@ function normalizePrescriptionOrderListQuery(params: Record<string, unknown>): R
}
async function fetchLists(params: Record<string, unknown>) {
const p = normalizePrescriptionOrderListQuery({ ...params })
const [start_time, end_time] = resolveCreateTimeRange()
const p = normalizePrescriptionOrderListQuery({ ...params, start_time, end_time })
return prescriptionOrderLists(p)
}
const prescriptionOrderExportParams = computed(() => normalizePrescriptionOrderListQuery({ ...queryParams }))
const prescriptionOrderExportParams = computed(() => {
const [start_time, end_time] = resolveCreateTimeRange()
return normalizePrescriptionOrderListQuery({ ...queryParams, start_time, end_time })
})
const { pager, getLists, resetPage, resetParams } = usePaging({
fetchFun: fetchLists,
@@ -3222,8 +3417,9 @@ function applyRouteQueryToPrescriptionOrderList() {
}
const st = normDate(q.start_time)
const et = normDate(q.end_time)
if (st) queryParams.start_time = st
if (et) queryParams.end_time = et
if (st && et) {
applyCustomRange(st, et)
}
const ad = q.assistant_dept_id
if (ad !== undefined && ad !== null && String(ad) !== '') {
const n = Number(ad)
@@ -3314,9 +3510,7 @@ const listStats = computed(() => {
scopeHint =
'统计口径:本人创建的订单 + 关联处方开方人为本人的订单(含医助代建);与下方筛选项(不含「创建时间」)一致'
}
const hasCustomTime =
String(queryParams.start_time || '').trim() !== '' && String(queryParams.end_time || '').trim() !== ''
const headPrefix = hasCustomTime ? '区间' : '今日'
const headPrefix = time_type.value === 'all' ? '全部' : time_type.value === 'today' ? '今日' : '区间'
return {
order: n(o),
pay: n(pay),
@@ -3388,8 +3582,7 @@ watch(
function handleReset() {
activeFocusKey.value = ''
queryParams.start_time = ''
queryParams.end_time = ''
resetTimeFilter()
queryParams.order_no = ''
queryParams.prescription_id = ''
queryParams.patient_keyword = ''
@@ -3686,6 +3879,12 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s
return (fs === 5 || fs === 6) && Number(row.payment_slip_audit_status) === 1
}
function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) {
// (5) / (6) / (3) / (9)(1) 退
const fs = Number(row.fulfillment_status)
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
}
function canQuickTrackRow(row: { fulfillment_status?: number }) {
// (5) (6)
return Number(row.fulfillment_status) === 5
@@ -3921,18 +4120,25 @@ const detailLinkedAppointmentChannelText = computed(() => {
const detailRxHerbs = computed(() => normalizeSlipHerbs(detailPrescription.value?.herbs))
/** 详情:是否含辅方药材(用于决定是否展示辅方用量 / 用法 */
const detailHasAuxHerbs = computed(() => {
const herbs = detailPrescription.value?.herbs
if (!Array.isArray(herbs)) return false
return herbs.some((h: any) => normalizeSlipFormulaType(h?.formula_type) === '辅方')
})
/** 详情:是否含辅方药材(仅 formula_type=辅方;医助无药材权限时 herbs 被剥离,用后端 has_aux_formula */
function prescriptionHasAuxFormula(rx: Record<string, unknown> | null | undefined): boolean {
if (!rx) return false
const herbs = rx.herbs
if (Array.isArray(herbs)) {
return herbs.some((h: any) => normalizeSlipFormulaType(h?.formula_type) === '辅方')
}
return Number(rx.has_aux_formula) === 1
}
const detailHasAuxHerbs = computed(() => prescriptionHasAuxFormula(detailPrescription.value as any))
/** 详情:辅方用法(aux_usage JSON 经规范化后的对象,与处方类型一致) */
const detailAuxUsage = computed(() => {
const rx = detailPrescription.value as any
if (!rx) return null
return normalizeSlipAuxUsageForm(rx.aux_usage, rx.prescription_type || '浓缩水丸')
if (!rx || !prescriptionHasAuxFormula(rx)) return null
const raw = rx.aux_usage
if (raw == null || raw === '' || (Array.isArray(raw) && raw.length === 0)) return null
return normalizeSlipAuxUsageForm(raw, rx.prescription_type || '浓缩水丸')
})
/** false=无权限;true/缺省兼容旧接口(旧版未下发该字段时仍展示药材) */
@@ -3987,6 +4193,7 @@ function formatOrderTime(v: unknown) {
const detailLogisticsExpress = ref<string>('auto')
const logisticsTraceLoading = ref(false)
const jdLogisticsUpdating = ref(false)
const logisticsTracePayload = ref<Record<string, any> | null>(null)
/** 顺丰/快递100:与运单一致的收件手机后四位(可覆盖订单收货手机) */
const logisticsTracePhoneTail = ref('')
@@ -4087,7 +4294,8 @@ function logActionText(act: string) {
gancao_submit: '甘草下单',
patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额',
complete: '完成订单'
complete: '完成订单',
refund: '退款'
}
return m[act] || act
}
@@ -4219,6 +4427,26 @@ async function fetchLogisticsTrace() {
}
}
//
async function updateJdLogistics() {
const orderId = Number(detailData.value?.id)
const trackingNumber = String(detailData.value?.tracking_number || '').trim()
if (!orderId || !trackingNumber) {
feedback.msgWarning('当前订单未填写快递单号')
return
}
jdLogisticsUpdating.value = true
try {
const res: any = await prescriptionOrderLogisticsJdUpdate({ id: orderId })
feedback.msgSuccess(res?.message || '京东物流轨迹已更新')
await fetchLogisticsTrace()
} catch (e: any) {
feedback.msgError(e?.message || '京东物流更新失败')
} finally {
jdLogisticsUpdating.value = false
}
}
const patchRxPatientVisible = ref(false)
const patchRxPatientSaving = ref(false)
const patchRxPatientFormRef = ref<FormInstance>()
@@ -5202,15 +5430,72 @@ const completeOrderDialogVisible = ref(false)
const completeOrderId = ref(0)
const completeFulfillmentStatus = ref<number>(3)
const completeOrderSubmitting = ref(false)
const completeOrderPayLoading = ref(false)
const completeOrderLinkedPays = ref<any[]>([])
const completeRefundFormRef = ref<FormInstance>()
const completeRefundForm = reactive({ reason: '', refund_amount: undefined as number | undefined })
const completeOrderRefundMax = ref(0)
const completeOrderRefundTotal = computed(() => calcRefundableFromLinkedPays(completeOrderLinkedPays.value))
const completeRefundRules = computed<FormRules>(() => ({
refund_amount: [
{ required: true, message: '请输入退款金额', trigger: 'change' },
{
validator: (_rule, v, cb) => {
const n = Number(v)
const cap = completeOrderRefundMax.value
if (!Number.isFinite(n) || n <= 0) {
cb(new Error('退款金额须大于 0'))
return
}
if (cap > 0 && n > cap + 0.009) {
cb(new Error(`不能超过可退上限 ¥${formatMoney(cap)}`))
return
}
cb()
},
trigger: 'change'
}
],
reason: [{ required: true, message: '请填写退款原因', trigger: 'blur' }]
}))
let completeOrderLogisticsRequestSeq = 0
function onCompleteOrderDialogClosed() {
completeOrderId.value = 0
completeOrderLinkedPays.value = []
completeOrderPayLoading.value = false
completeRefundForm.reason = ''
completeRefundForm.refund_amount = undefined
completeOrderRefundMax.value = 0
completeRefundFormRef.value?.clearValidate()
completeOrderLogisticsRequestSeq += 1
completeOrderLogisticsPayload.value = null
completeOrderLogisticsLoading.value = false
}
async function loadCompleteOrderPayContext(orderId: number) {
if (!orderId) {
completeOrderLinkedPays.value = []
return
}
completeOrderPayLoading.value = true
try {
const r: any = await prescriptionOrderDetail({ id: orderId })
const d = r?.data ?? r ?? null
const arr = d?.linked_pay_orders
completeOrderLinkedPays.value = Array.isArray(arr) ? arr : []
const linkedMax = calcRefundableFromLinkedPays(completeOrderLinkedPays.value)
const paid = Number(d?.paid) || 0
completeOrderRefundMax.value = linkedMax > 0 ? linkedMax : paid
completeRefundForm.refund_amount = completeOrderRefundMax.value
} catch {
completeOrderLinkedPays.value = []
completeOrderRefundMax.value = 0
} finally {
completeOrderPayLoading.value = false
}
}
async function fetchCompleteOrderLogisticsForDialog(row: {
id: number
tracking_number?: unknown
@@ -5249,6 +5534,36 @@ async function fetchCompleteOrderLogisticsForDialog(row: {
async function submitCompleteOrder() {
const id = completeOrderId.value
if (!id) return
if (completeFulfillmentStatus.value === 10) {
const valid = await completeRefundFormRef.value?.validate().catch(() => false)
if (!valid) return
completeOrderSubmitting.value = true
try {
await prescriptionOrderRefund({
id,
reason: completeRefundForm.reason.trim(),
refund_amount: Number(completeRefundForm.refund_amount)
})
feedback.msgSuccess('退款成功')
completeOrderDialogVisible.value = false
getLists()
if (detailVisible.value && detailData.value?.id === id) {
try {
const r: any = await prescriptionOrderDetail({ id })
const nd = r?.data ?? r ?? null
if (nd) detailData.value = nd
await fetchLogs(id)
} catch { /* 静默 */ }
}
} catch {
/* 拦截器已提示 */
} finally {
completeOrderSubmitting.value = false
}
return
}
completeOrderSubmitting.value = true
try {
await prescriptionOrderComplete({ id, fulfillment_status: completeFulfillmentStatus.value })
@@ -5269,6 +5584,112 @@ async function submitCompleteOrder() {
}
}
// 退/ +
function calcRefundableFromLinkedPays(list: any[]) {
let sum = 0
for (const o of list || []) {
const st = Number(o?.status)
if (st === 2 || st === 5) {
sum += Number(o?.amount) || 0
}
}
return Math.round(sum * 100) / 100
}
const refundOrderDialogVisible = ref(false)
const refundOrderId = ref(0)
const refundOrderSubmitting = ref(false)
const refundOrderFormRef = ref<FormInstance>()
const refundOrderMaxAmount = ref(0)
const refundOrderForm = reactive({ reason: '', refund_amount: undefined as number | undefined })
const refundOrderAlertTitle = computed(() => {
const cap = formatMoney(refundOrderMaxAmount.value)
const amt = formatMoney(refundOrderForm.refund_amount)
if (Number(refundOrderForm.refund_amount) >= refundOrderMaxAmount.value - 0.009) {
return `确认全额退款 ¥${cap}:关联收款将标记已退款,本单已付清零,且不可撤销。`
}
return `确认部分退款 ¥${amt}(可退上限 ¥${cap}):将扣减本单已付,并按金额依次标记关联收款已退款。`
})
const refundOrderRules = computed<FormRules>(() => ({
refund_amount: [
{ required: true, message: '请输入退款金额', trigger: 'change' },
{
validator: (_rule, v, cb) => {
const n = Number(v)
const cap = refundOrderMaxAmount.value
if (!Number.isFinite(n) || n <= 0) {
cb(new Error('退款金额须大于 0'))
return
}
if (cap > 0 && n > cap + 0.009) {
cb(new Error(`不能超过可退上限 ¥${formatMoney(cap)}`))
return
}
cb()
},
trigger: 'change'
}
],
reason: [{ required: true, message: '请填写退款原因', trigger: 'blur' }]
}))
function onRefundOrderDialogClosed() {
refundOrderId.value = 0
refundOrderForm.reason = ''
refundOrderForm.refund_amount = undefined
refundOrderMaxAmount.value = 0
refundOrderFormRef.value?.clearValidate()
}
async function openRefundOrder(row: { id: number }) {
refundOrderId.value = row.id
refundOrderForm.reason = ''
refundOrderForm.refund_amount = undefined
refundOrderMaxAmount.value = 0
refundOrderDialogVisible.value = true
try {
const r: any = await prescriptionOrderDetail({ id: row.id })
const d = r?.data ?? r ?? null
const linked = Array.isArray(d?.linked_pay_orders) ? d.linked_pay_orders : []
const linkedMax = calcRefundableFromLinkedPays(linked)
const paid = Number(d?.paid) || 0
refundOrderMaxAmount.value = linkedMax > 0 ? linkedMax : paid
refundOrderForm.refund_amount = refundOrderMaxAmount.value
} catch {
refundOrderMaxAmount.value = 0
}
}
async function submitRefundOrder() {
const id = refundOrderId.value
if (!id) return
const valid = await refundOrderFormRef.value?.validate().catch(() => false)
if (!valid) return
refundOrderSubmitting.value = true
try {
await prescriptionOrderRefund({
id,
reason: refundOrderForm.reason.trim(),
refund_amount: Number(refundOrderForm.refund_amount)
})
feedback.msgSuccess('退款成功')
refundOrderDialogVisible.value = false
getLists()
if (detailVisible.value && detailData.value?.id === id) {
try {
const r: any = await prescriptionOrderDetail({ id })
const nd = r?.data ?? r ?? null
if (nd) detailData.value = nd
await fetchLogs(id)
} catch { /* 静默 */ }
}
} catch {
/* 拦截器已提示 */
} finally {
refundOrderSubmitting.value = false
}
}
//
const addPayOrderVisible = ref(false)
const addPayOrderSaving = ref(false)
@@ -5435,8 +5856,12 @@ function confirmComplete(row: {
}) {
completeOrderId.value = row.id
completeFulfillmentStatus.value = 3
completeRefundForm.reason = ''
completeRefundForm.refund_amount = undefined
completeOrderLinkedPays.value = []
completeOrderLogisticsPayload.value = null
completeOrderDialogVisible.value = true
void loadCompleteOrderPayContext(row.id)
if (String(row.tracking_number || '').trim()) {
void fetchCompleteOrderLogisticsForDialog(row)
}
@@ -6732,6 +7157,10 @@ async function downloadPrescriptionSlipPdf() {
min-height: 70px;
}
.rx-bot-row-1--user {
grid-template-columns: 1.6fr 0.6fr;
}
.rx-bot-row-2 {
grid-template-columns: repeat(6, 1fr);
}
@@ -190,18 +190,17 @@
<ArrowDown />
</el-icon>
</div>
<el-form v-show="!h5FilterCollapsed" class="ls-form" :model="queryParams" inline>
<el-form-item class="w-[360px] min-w-[240px] max-w-full" label="创建时间">
<DaterangePicker
v-model:startTime="queryParams.start_time"
v-model:endTime="queryParams.end_time"
picker-type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
class="!w-full"
/>
</el-form-item>
<el-form v-show="!h5FilterCollapsed" class="ls-form stats-filter-form" :model="queryParams" inline>
<list-time-filter
v-model:time-type="time_type"
v-model:date-range="dateRange"
label="创建时间"
variant="inline"
show-all
picker-width="260px"
@time-type-change="onTimeTypeChange"
@custom-change="onCustomDateChange"
/>
<el-form-item class="w-[260px]" label="订单号">
<el-input
v-model="queryParams.order_no"
@@ -498,6 +497,9 @@
<el-dropdown-menu>
<el-dropdown-item v-if="canAddPayOrderRow(row)" command="addPayOrder">补齐支付单</el-dropdown-item>
<el-dropdown-item v-if="canCompleteRow(row)" command="complete">完成订单</el-dropdown-item>
<el-dropdown-item v-if="canRefundRow(row)" command="refund">
<span class="text-red-500">退款</span>
</el-dropdown-item>
<el-dropdown-item v-if="canUploadGancaoRow(row)" command="submitGancao">上传药方</el-dropdown-item>
<el-dropdown-item v-if="canWithdrawRow(row)" command="withdraw" divided>
<span class="text-red-500">撤回订单</span>
@@ -612,6 +614,16 @@
>
完成订单
</el-button>
<el-button
v-if="canRefundRow(detailData)"
v-perms="['tcm.prescriptionOrder/refund']"
type="danger"
size="small"
plain
@click="openRefundOrder(detailData)"
>
退款
</el-button>
</div>
</div>
</template>
@@ -690,49 +702,50 @@
</div>
<!-- 顶部数据概览卡片 -->
<el-row :gutter="16" class="mb-5">
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="flex items-center justify-between mb-1">
<div class="stat-title text-gray-500 text-xs">总金额</div>
<el-button
v-if="canUpdateAmount(detailData)"
v-perms="['tcm.prescriptionOrder/updateAmount']"
type="primary"
size="small"
link
@click="openUpdateAmount"
>
修改
</el-button>
</div>
<div class="stat-value text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">已付总额</div>
<div class="stat-value text-green-600 font-bold text-xl">¥{{ detailData.linked_pay_paid_total || 0 }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">需代收</div>
<div
class="stat-value font-bold text-xl"
:class="detailAgencyToCollect > 0 ? 'text-amber-600' : 'text-gray-500'"
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-5">
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="flex items-center justify-between mb-1">
<div class="stat-title text-gray-500 text-xs">总金额</div>
<el-button
v-if="canUpdateAmount(detailData)"
v-perms="['tcm.prescriptionOrder/updateAmount']"
type="primary"
size="small"
link
@click="openUpdateAmount"
>
¥{{ formatMoney(detailAgencyToCollect) }}
</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">已付笔数</div>
<div class="stat-value text-primary font-bold text-xl">{{ detailLinkedPayOrders.length }} </div>
</el-card>
</el-col>
</el-row>
修改
</el-button>
</div>
<div class="stat-value text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
</el-card>
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">已付总额</div>
<div class="stat-value text-green-600 font-bold text-xl">¥{{ detailData.linked_pay_paid_total || 0 }}</div>
</el-card>
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">退款金额</div>
<div
class="stat-value font-bold text-xl"
:class="Number(detailData.refund_amount) > 0 ? 'text-rose-500' : 'text-gray-400'"
>
¥{{ formatMoney(detailData.refund_amount || 0) }}
</div>
</el-card>
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">需代收</div>
<div
class="stat-value font-bold text-xl"
:class="detailAgencyToCollect > 0 ? 'text-amber-600' : 'text-gray-500'"
>
¥{{ formatMoney(detailAgencyToCollect) }}
</div>
</el-card>
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">已付笔数</div>
<div class="stat-value text-primary font-bold text-xl">{{ detailLinkedPayOrders.length }} </div>
</el-card>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<!-- 左侧处方单 -->
@@ -1832,9 +1845,11 @@
:close-on-click-modal="false"
destroy-on-close
class="po-h5-dialog"
@closed="completeOrderId = 0"
@closed="onCompleteOrderDialogClosed"
>
<p class="text-sm text-gray-600 mb-3">请选择本单结案状态选择已完成实付将按已关联的支付单金额汇总</p>
<p class="text-sm text-gray-600 mb-3">
选择已完成按关联收款汇总实付选择退款将关联收款标记已退款并清零已付
</p>
<el-select
v-model="completeFulfillmentStatus"
class="w-full"
@@ -1847,10 +1862,101 @@
:value="opt.value"
/>
</el-select>
<template v-if="completeFulfillmentStatus === 10">
<el-alert
type="warning"
:closable="false"
show-icon
class="mt-3"
title="关联收款将标记已退款,本单已付清零,不可撤销。"
/>
<p v-if="completeOrderLinkedPays.length" class="text-xs text-amber-800 mt-2">
将退款 {{ completeOrderLinkedPays.length }} 笔关联收款合计 ¥{{ formatMoney(completeOrderRefundTotal) }}
</p>
<el-form
ref="completeRefundFormRef"
:model="completeRefundForm"
:rules="completeRefundRules"
label-width="80px"
class="mt-2"
>
<el-form-item label="退款金额" prop="refund_amount">
<el-input-number
v-model="completeRefundForm.refund_amount"
:min="0.01"
:max="completeOrderRefundMax > 0 ? completeOrderRefundMax : undefined"
:step="0.01"
:precision="2"
class="w-full"
/>
<div class="text-xs text-gray-500 mt-1">可退上限 ¥{{ formatMoney(completeOrderRefundMax) }}</div>
</el-form-item>
<el-form-item label="退款原因" prop="reason">
<el-input
v-model="completeRefundForm.reason"
type="textarea"
:rows="3"
maxlength="500"
placeholder="必填"
/>
</el-form-item>
</el-form>
</template>
<template #footer>
<el-button @click="completeOrderDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="completeOrderSubmitting" @click="submitCompleteOrder">
确定
<el-button
:type="completeFulfillmentStatus === 10 ? 'danger' : 'primary'"
:loading="completeOrderSubmitting"
@click="submitCompleteOrder"
>
{{ completeFulfillmentStatus === 10 ? '确认退款' : '确定' }}
</el-button>
</template>
</el-dialog>
<!-- 订单退款须填写原因 -->
<el-dialog
v-model="refundOrderDialogVisible"
title="订单退款"
width="92%"
:close-on-click-modal="false"
destroy-on-close
class="po-h5-dialog"
@closed="onRefundOrderDialogClosed"
>
<el-alert
type="warning"
:closable="false"
show-icon
class="mb-4 !items-start"
:title="refundOrderAlertTitle"
/>
<el-form ref="refundOrderFormRef" :model="refundOrderForm" :rules="refundOrderRules" label-width="88px">
<el-form-item label="退款金额" prop="refund_amount">
<el-input-number
v-model="refundOrderForm.refund_amount"
:min="0.01"
:max="refundOrderMaxAmount > 0 ? refundOrderMaxAmount : undefined"
:step="0.01"
:precision="2"
class="w-full"
/>
<div class="text-xs text-gray-500 mt-1">可退上限 ¥{{ formatMoney(refundOrderMaxAmount) }}</div>
</el-form-item>
<el-form-item label="退款原因" prop="reason">
<el-input
v-model="refundOrderForm.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请填写退款原因(必填)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="refundOrderDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="refundOrderSubmitting" @click="submitRefundOrder">
确认退款
</el-button>
</template>
</el-dialog>
@@ -2194,7 +2300,10 @@
alt="医师签名"
class="rx-bot-sign-img"
/>
<!-- <span class="rx-bot-doctor-name">{{ prescriptionViewData.doctor_name || '—' }}</span> -->
<span
v-if="!prescriptionViewData.doctor_signature"
class="rx-bot-doctor-name"
>{{ prescriptionViewData.doctor_name || '—' }}</span>
</div>
</div>
@@ -2401,7 +2510,8 @@ import {
Wallet,
User
} from '@element-plus/icons-vue'
import DaterangePicker from '@/components/daterange-picker/index.vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
import {
prescriptionOrderAuditPayment,
prescriptionOrderAuditPrescription,
@@ -2416,6 +2526,7 @@ import {
prescriptionOrderUpdateAmount,
prescriptionOrderAddPayOrder,
prescriptionOrderComplete,
prescriptionOrderRefund,
prescriptionOrderRevokeRxAudit,
prescriptionOrderRevokePayAudit,
prescriptionOrderPatchPrescriptionPatient,
@@ -2698,9 +2809,6 @@ function handleExpressCompanyTabClick(value: '' | 'sf' | 'jd') {
}
const queryParams = reactive({
/** 创建时间区间(与列表筛选一致;不影响「今日」统计) */
start_time: '' as string,
end_time: '' as string,
order_no: '',
prescription_id: '' as string | number,
/** 诊单患者姓名/手机号(后台关联 diagnosis */
@@ -2779,8 +2887,26 @@ function handleFocusBoardSearch(key: 'pendingRx' | 'pendingPay' | 'pendingShip'
resetPage()
}
const {
time_type,
dateRange,
resolve: resolveCreateTimeRange,
handleTimeTypeChange,
handleCustomDateChange,
resetTimeFilter,
} = useListTimeFilter('all')
const handleQuery = () => {
pager.page = 1
getLists()
}
const onTimeTypeChange = () => handleTimeTypeChange(handleQuery)
const onCustomDateChange = () => handleCustomDateChange(handleQuery)
async function fetchLists(params: Record<string, unknown>) {
const p: Record<string, unknown> = { ...params }
const [start_time, end_time] = resolveCreateTimeRange()
const p: Record<string, unknown> = { ...params, start_time, end_time }
if (p.prescription_id === '' || p.prescription_id === undefined) {
delete p.prescription_id
} else {
@@ -2934,9 +3060,7 @@ const listStats = computed(() => {
scopeHint =
'统计口径:本人创建的订单 + 关联处方开方人为本人的订单(含医助代建);与下方筛选项(不含「创建时间」)一致'
}
const hasCustomTime =
String(queryParams.start_time || '').trim() !== '' && String(queryParams.end_time || '').trim() !== ''
const headPrefix = hasCustomTime ? '区间' : '今日'
const headPrefix = time_type.value === 'all' ? '全部' : time_type.value === 'today' ? '今日' : '区间'
return {
order: n(o),
pay: n(pay),
@@ -3007,8 +3131,7 @@ watch(
function handleReset() {
activeFocusKey.value = ''
queryParams.start_time = ''
queryParams.end_time = ''
resetTimeFilter()
queryParams.order_no = ''
queryParams.prescription_id = ''
queryParams.patient_keyword = ''
@@ -3237,6 +3360,11 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s
return (fs === 5 || fs === 6) && Number(row.payment_slip_audit_status) === 1
}
function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) {
const fs = Number(row.fulfillment_status)
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
}
function canQuickTrackRow(row: { fulfillment_status?: number }) {
return Number(row.fulfillment_status) === 5
}
@@ -3269,11 +3397,11 @@ const h5FilterCollapsed = ref(true)
const h5ActiveFilterCount = computed(() => {
const q = queryParams as any
const keys = [
'start_time', 'end_time', 'order_no', 'prescription_id',
'order_no', 'prescription_id',
'patient_keyword', 'express_keyword', 'doctor_id',
'assistant_dept_id', 'assistant_id'
]
let n = 0
let n = time_type.value !== 'all' ? 1 : 0
for (const k of keys) {
const v = q[k]
if (v === null || v === undefined) continue
@@ -3286,13 +3414,14 @@ const h5ActiveFilterCount = computed(() => {
// H5:
function hasMoreCardActions(row: Record<string, any>) {
return canAddPayOrderRow(row) || canCompleteRow(row) || canUploadGancaoRow(row) || canWithdrawRow(row)
return canAddPayOrderRow(row) || canCompleteRow(row) || canRefundRow(row) || canUploadGancaoRow(row) || canWithdrawRow(row)
}
// H5:
function handleCardMoreCommand(cmd: string, row: Record<string, any>) {
if (cmd === 'addPayOrder') openAddPayOrder(row as any)
else if (cmd === 'complete') confirmComplete(row as any)
else if (cmd === 'refund') openRefundOrder(row as any)
else if (cmd === 'submitGancao') confirmSubmitGancaoRecipel(row as any)
else if (cmd === 'withdraw') confirmWithdraw(row as any)
}
@@ -3607,7 +3736,8 @@ function logActionText(act: string) {
gancao_submit: '甘草下单',
patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额',
complete: '完成订单'
complete: '完成订单',
refund: '退款'
}
return m[act] || act
}
@@ -4562,10 +4692,98 @@ const completeOrderDialogVisible = ref(false)
const completeOrderId = ref(0)
const completeFulfillmentStatus = ref<number>(3)
const completeOrderSubmitting = ref(false)
const completeOrderLinkedPays = ref<any[]>([])
const completeRefundFormRef = ref<FormInstance>()
function calcRefundableFromLinkedPays(list: any[]) {
let sum = 0
for (const o of list || []) {
const st = Number(o?.status)
if (st === 2 || st === 5) sum += Number(o?.amount) || 0
}
return Math.round(sum * 100) / 100
}
const completeRefundForm = reactive({ reason: '', refund_amount: undefined as number | undefined })
const completeOrderRefundMax = ref(0)
const completeOrderRefundTotal = computed(() => calcRefundableFromLinkedPays(completeOrderLinkedPays.value))
const completeRefundRules = computed<FormRules>(() => ({
refund_amount: [
{ required: true, message: '请输入退款金额', trigger: 'change' },
{
validator: (_r, v, cb) => {
const n = Number(v)
const cap = completeOrderRefundMax.value
if (!Number.isFinite(n) || n <= 0) return cb(new Error('退款金额须大于 0'))
if (cap > 0 && n > cap + 0.009) return cb(new Error(`不能超过 ¥${formatMoney(cap)}`))
cb()
},
trigger: 'change'
}
],
reason: [{ required: true, message: '请填写退款原因', trigger: 'blur' }]
}))
function onCompleteOrderDialogClosed() {
completeOrderId.value = 0
completeOrderLinkedPays.value = []
completeRefundForm.reason = ''
completeRefundForm.refund_amount = undefined
completeOrderRefundMax.value = 0
completeRefundFormRef.value?.clearValidate()
}
async function loadCompleteOrderPayContext(orderId: number) {
if (!orderId) {
completeOrderLinkedPays.value = []
return
}
try {
const r: any = await prescriptionOrderDetail({ id: orderId })
const d = r?.data ?? r ?? null
completeOrderLinkedPays.value = Array.isArray(d?.linked_pay_orders) ? d.linked_pay_orders : []
const linkedMax = calcRefundableFromLinkedPays(completeOrderLinkedPays.value)
const paid = Number(d?.paid) || 0
completeOrderRefundMax.value = linkedMax > 0 ? linkedMax : paid
completeRefundForm.refund_amount = completeOrderRefundMax.value
} catch {
completeOrderLinkedPays.value = []
completeOrderRefundMax.value = 0
}
}
async function submitCompleteOrder() {
const id = completeOrderId.value
if (!id) return
if (completeFulfillmentStatus.value === 10) {
const valid = await completeRefundFormRef.value?.validate().catch(() => false)
if (!valid) return
completeOrderSubmitting.value = true
try {
await prescriptionOrderRefund({
id,
reason: completeRefundForm.reason.trim(),
refund_amount: Number(completeRefundForm.refund_amount)
})
feedback.msgSuccess('退款成功')
completeOrderDialogVisible.value = false
getLists()
if (detailVisible.value && detailData.value?.id === id) {
try {
const r: any = await prescriptionOrderDetail({ id })
const nd = r?.data ?? r ?? null
if (nd) detailData.value = nd
await fetchLogs(id)
} catch { /* 静默 */ }
}
} catch {
/* 拦截器已提示 */
} finally {
completeOrderSubmitting.value = false
}
return
}
completeOrderSubmitting.value = true
try {
await prescriptionOrderComplete({ id, fulfillment_status: completeFulfillmentStatus.value })
@@ -4586,6 +4804,93 @@ async function submitCompleteOrder() {
}
}
const refundOrderDialogVisible = ref(false)
const refundOrderId = ref(0)
const refundOrderSubmitting = ref(false)
const refundOrderFormRef = ref<FormInstance>()
const refundOrderMaxAmount = ref(0)
const refundOrderForm = reactive({ reason: '', refund_amount: undefined as number | undefined })
const refundOrderAlertTitle = computed(() => {
const cap = formatMoney(refundOrderMaxAmount.value)
const amt = formatMoney(refundOrderForm.refund_amount)
if (Number(refundOrderForm.refund_amount) >= refundOrderMaxAmount.value - 0.009) {
return `全额退款 ¥${cap}:关联收款标记已退款,已付清零。`
}
return `部分退款 ¥${amt}(上限 ¥${cap}`
})
const refundOrderRules = computed<FormRules>(() => ({
refund_amount: [
{ required: true, message: '请输入退款金额', trigger: 'change' },
{
validator: (_r, v, cb) => {
const n = Number(v)
const cap = refundOrderMaxAmount.value
if (!Number.isFinite(n) || n <= 0) return cb(new Error('退款金额须大于 0'))
if (cap > 0 && n > cap + 0.009) return cb(new Error(`不能超过 ¥${formatMoney(cap)}`))
cb()
},
trigger: 'change'
}
],
reason: [{ required: true, message: '请填写退款原因', trigger: 'blur' }]
}))
function onRefundOrderDialogClosed() {
refundOrderId.value = 0
refundOrderForm.reason = ''
refundOrderForm.refund_amount = undefined
refundOrderMaxAmount.value = 0
refundOrderFormRef.value?.clearValidate()
}
async function openRefundOrder(row: { id: number }) {
refundOrderId.value = row.id
refundOrderForm.reason = ''
refundOrderMaxAmount.value = 0
refundOrderDialogVisible.value = true
try {
const r: any = await prescriptionOrderDetail({ id: row.id })
const d = r?.data ?? r ?? null
const linked = Array.isArray(d?.linked_pay_orders) ? d.linked_pay_orders : []
const linkedMax = calcRefundableFromLinkedPays(linked)
const paid = Number(d?.paid) || 0
refundOrderMaxAmount.value = linkedMax > 0 ? linkedMax : paid
refundOrderForm.refund_amount = refundOrderMaxAmount.value
} catch {
refundOrderMaxAmount.value = 0
}
}
async function submitRefundOrder() {
const id = refundOrderId.value
if (!id) return
const valid = await refundOrderFormRef.value?.validate().catch(() => false)
if (!valid) return
refundOrderSubmitting.value = true
try {
await prescriptionOrderRefund({
id,
reason: refundOrderForm.reason.trim(),
refund_amount: Number(refundOrderForm.refund_amount)
})
feedback.msgSuccess('退款成功')
refundOrderDialogVisible.value = false
getLists()
if (detailVisible.value && detailData.value?.id === id) {
try {
const r: any = await prescriptionOrderDetail({ id })
const nd = r?.data ?? r ?? null
if (nd) detailData.value = nd
await fetchLogs(id)
} catch { /* 静默 */ }
}
} catch {
/* 拦截器已提示 */
} finally {
refundOrderSubmitting.value = false
}
}
//
const addPayOrderVisible = ref(false)
const addPayOrderSaving = ref(false)
@@ -4728,7 +5033,10 @@ async function submitAddPayOrder() {
function confirmComplete(row: { id: number }) {
completeOrderId.value = row.id
completeFulfillmentStatus.value = 3
completeRefundForm.reason = ''
completeOrderLinkedPays.value = []
completeOrderDialogVisible.value = true
void loadCompleteOrderPayContext(row.id)
}
//
@@ -5228,6 +5536,12 @@ async function downloadPrescriptionSlipPdf() {
</script>
<style scoped lang="scss">
.stats-filter-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.prescription-order-page {
margin: 0 auto;
+50 -17
View File
@@ -14,13 +14,13 @@
</el-card>
<el-card class="!border-none" shadow="never">
<el-form class="mb-[-16px]" :model="queryParams" inline>
<el-form-item label="日期范围">
<daterange-picker
v-model:startTime="queryParams.start_date"
v-model:endTime="queryParams.end_date"
/>
</el-form-item>
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
<list-time-filter
v-model:time-type="time_type"
v-model:date-range="dateRange"
@time-type-change="onTimeTypeChange"
@custom-change="onCustomDateChange"
/>
<el-form-item label="自媒体渠道">
<el-select
v-model="queryParams.media_channel_code"
@@ -67,11 +67,11 @@
v-model="queryParams.remark"
placeholder="备注 / 创建人 / 修改人"
clearable
@keyup.enter="resetPage"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
@@ -148,7 +148,9 @@
<script lang="ts" setup name="accountCostList">
import { accountCostDelete, accountCostLists } from '@/api/finance'
import { usePaging } from '@/hooks/usePaging'
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
import feedback from '@/utils/feedback'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import EditPopup from './edit.vue'
@@ -172,18 +174,40 @@ const editRef = shallowRef<InstanceType<typeof EditPopup>>()
const showEdit = ref(false)
const queryParams = reactive({
start_date: '',
end_date: '',
media_channel_code: '',
dept_id: '',
remark: '',
})
const { pager, getLists, resetPage } = usePaging({
fetchFun: accountCostLists,
const {
time_type,
dateRange,
resolve,
handleTimeTypeChange,
handleCustomDateChange,
resetTimeFilter,
} = useListTimeFilter('today')
const { pager, getLists } = usePaging({
fetchFun: async (params: Record<string, any>) => {
const [start_date, end_date] = resolve()
return accountCostLists({
...params,
start_date,
end_date,
})
},
params: queryParams,
})
const handleQuery = () => {
pager.page = 1
getLists()
}
const onTimeTypeChange = () => handleTimeTypeChange(handleQuery)
const onCustomDateChange = () => handleCustomDateChange(handleQuery)
const mediaChannelOptionsFlat = computed<MediaChannelOption[]>(() => {
const options = pager.extend.media_channel_options
if (!Array.isArray(options)) return []
@@ -258,18 +282,27 @@ const handleDelete = async (id: number) => {
}
const handleReset = () => {
queryParams.start_date = ''
queryParams.end_date = ''
resetTimeFilter()
queryParams.media_channel_code = ''
queryParams.dept_id = ''
queryParams.remark = ''
resetPage()
pager.page = 1
pager.size = 15
getLists()
}
getLists()
onMounted(() => {
getLists()
})
</script>
<style scoped lang="scss">
.stats-filter-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.account-cost-channel-select {
:deep(.channel-opt-row) {
display: flex;
@@ -1,6 +1,15 @@
<template>
<div class="note-timeline-wrap">
<div v-if="!readonly && diagnosisId" class="timeline-actions">
<el-button
v-if="hasAddDoctorNotePermission"
type="primary"
plain
size="small"
@click="showNoteDialog = true"
>
添加备注
</el-button>
<material-picker
v-model="tongueModel"
:limit="99"
@@ -95,16 +104,32 @@
</div>
</div>
<el-empty v-if="!notes.length && readonly" description="暂无备注" :image-size="48" />
<el-dialog v-model="showNoteDialog" title="添加备注" width="480px" append-to-body>
<el-input
v-model="noteContent"
type="textarea"
:rows="4"
placeholder="输入今日备注..."
maxlength="500"
show-word-limit
/>
<template #footer>
<el-button @click="showNoteDialog = false">取消</el-button>
<el-button type="primary" :loading="noteSaving" @click="handleSaveNote">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { CircleClose, Document } from '@element-plus/icons-vue'
import { ElMessageBox } from 'element-plus'
import useAppStore from '@/stores/modules/app'
import { addDoctorNote, deleteDoctorNoteImage } from '@/api/patient'
import feedback from '@/utils/feedback'
import { hasPermission } from '@/utils/perm'
export interface DoctorNoteItem {
id: number
@@ -122,6 +147,13 @@ const props = defineProps<{
const emit = defineEmits<{ refresh: [] }>()
/** 与接诊台悬浮「备注」按钮一致 */
const hasAddDoctorNotePermission = computed(() => hasPermission(['doctor.appointment/addDoctorNote']))
const showNoteDialog = ref(false)
const noteContent = ref('')
const noteSaving = ref(false)
const appStore = useAppStore()
const getImageUrl = (url: string) => appStore.getImageUrl(url)
@@ -201,6 +233,30 @@ watch(() => props.notes, () => {
prevReportCount.value = 0
})
const handleSaveNote = async () => {
if (!props.diagnosisId) {
feedback.msgWarning('当前无诊单,无法添加备注')
return
}
const text = noteContent.value.trim()
if (!text) {
feedback.msgWarning('请输入备注内容')
return
}
noteSaving.value = true
try {
await addDoctorNote({ diagnosis_id: props.diagnosisId, content: text })
feedback.msgSuccess('备注保存成功')
noteContent.value = ''
showNoteDialog.value = false
emit('refresh')
} catch (e: any) {
feedback.msgError(e?.message || '保存失败')
} finally {
noteSaving.value = false
}
}
const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'report_files', imagePath: string) => {
try {
await ElMessageBox.confirm('确认删除?', '提示', { type: 'warning' })
@@ -217,6 +273,7 @@ const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'r
.timeline-actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
padding-bottom: 12px;
@@ -14,13 +14,14 @@
</el-card>
<el-card class="!border-none" shadow="never">
<el-form class="mb-[-16px]" :model="queryParams" inline>
<el-form-item label="日期范围">
<daterange-picker
v-model:startTime="queryParams.start_date"
v-model:endTime="queryParams.end_date"
/>
</el-form-item>
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
<list-time-filter
v-model:time-type="time_type"
v-model:date-range="dateRange"
@time-type-change="onTimeTypeChange"
@custom-change="onCustomDateChange"
/>
<el-form-item label="部门">
<el-tree-select
v-model="queryParams.dept_id"
@@ -33,7 +34,7 @@
clearable
filterable
class="w-[220px]"
@change="resetPage"
@change="handleQuery"
/>
</el-form-item>
<el-form-item label="自媒体来源">
@@ -44,7 +45,7 @@
placeholder="全部来源"
:allow-create="false"
select-class="w-[220px]"
@change="resetPage"
@change="handleQuery"
@visible-change="onMediaSourceDropdownVisible"
/>
</el-form-item>
@@ -53,11 +54,11 @@
v-model="queryParams.remark"
placeholder="备注 / 创建人"
clearable
@keyup.enter="resetPage"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button v-if="selfInputPath" class="ml-2" @click="goSelfInput">返回统计</el-button>
</el-form-item>
@@ -146,6 +147,8 @@ import feedback from '@/utils/feedback'
import CostEditPopup from './cost-edit.vue'
import MediaSourceSelect from './MediaSourceSelect.vue'
import { useMediaSourceOptions } from './useMediaSourceOptions'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
const router = useRouter()
const editRef = shallowRef<InstanceType<typeof CostEditPopup>>()
@@ -173,25 +176,48 @@ const goSelfInput = () => {
}
const queryParams = reactive({
start_date: '',
end_date: '',
media_source: '',
dept_id: undefined as number | undefined,
remark: '',
})
const {
time_type,
dateRange,
resolve,
handleTimeTypeChange,
handleCustomDateChange,
resetTimeFilter,
} = useListTimeFilter('today')
const { pager, getLists, resetPage } = usePaging({
fetchFun: personalAccountCostLists,
fetchFun: async (params: Record<string, any>) => {
const [start_date, end_date] = resolve()
return personalAccountCostLists({
...params,
start_date,
end_date,
})
},
params: queryParams,
})
const handleQuery = () => {
pager.page = 1
getLists()
}
const onTimeTypeChange = () => handleTimeTypeChange(handleQuery)
const onCustomDateChange = () => handleCustomDateChange(handleQuery)
const handleReset = () => {
queryParams.start_date = ''
queryParams.end_date = ''
resetTimeFilter()
queryParams.media_source = ''
queryParams.dept_id = undefined
queryParams.remark = ''
resetPage()
pager.page = 1
pager.size = 15
getLists()
}
const onMediaSourceDropdownVisible = (visible: boolean) => {
@@ -236,9 +262,10 @@ const loadDeptOptions = async () => {
}
}
onMounted(() => {
onMounted(async () => {
loadDeptOptions()
loadMediaSourceOptions()
await getLists()
})
</script>
@@ -247,6 +274,12 @@ onMounted(() => {
padding: 20px;
}
.stats-filter-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.dept-cell {
cursor: help;
border-bottom: 1px dashed #c0c4cc;
+79 -1
View File
@@ -509,6 +509,32 @@
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="completeDialogVisible"
title="完成问诊"
width="420px"
append-to-body
:close-on-click-modal="false"
destroy-on-close
>
<div class="complete-dialog">
<p class="complete-dialog-tip">是否添加医生备注</p>
<el-input
v-if="canAddCompleteNote"
v-model="completeNoteContent"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="填写完成备注,将追加到医生备注时间轴"
/>
</div>
<template #footer>
<el-button @click="completeDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="completeSubmitting" @click="submitComplete">确认完成</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -518,7 +544,9 @@ import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
import { getCallSignature, generateMiniProgramQrcode, tcmDiagnosisDetail, prescriptionGetByAppointment } from '@/api/tcm'
import { getDictData } from '@/api/app'
import { addDoctorNote } from '@/api/patient'
import feedback from '@/utils/feedback'
import { hasPermission } from '@/utils/perm'
import {
formatDiabetesDiscoveryDisplay,
isDiabetesDiscoveryFilled
@@ -797,6 +825,12 @@ const ensureDetailDictOptions = async () => {
const editRef = ref()
const prescriptionRef = ref()
const chatDialogRef = ref()
const COMPLETE_NOTE_PERMISSION = ['doctor.appointment/addDoctorNote']
const completeDialogVisible = ref(false)
const completeSubmitting = ref(false)
const completeNoteContent = ref('')
const completeRow = ref<any>(null)
const canAddCompleteNote = computed(() => hasPermission(COMPLETE_NOTE_PERMISSION))
//
const qrcodeDialogVisible = ref(false)
@@ -866,8 +900,14 @@ const handleCancel = async (row: any) => {
//
const handleComplete = async (row: any) => {
completeRow.value = row
completeNoteContent.value = ''
if (canAddCompleteNote.value) {
completeDialogVisible.value = true
return
}
try {
await feedback.confirm('确认患者已就诊完成')
await feedback.confirm('是否添加医生备注')
await completeAppointment({ id: row.id })
feedback.msgSuccess('操作成功')
loadData()
@@ -875,6 +915,37 @@ const handleComplete = async (row: any) => {
//
}
}
const submitComplete = async () => {
const row = completeRow.value
if (!row?.id || completeSubmitting.value) return
completeSubmitting.value = true
try {
const note = completeNoteContent.value.trim()
await completeAppointment({ id: row.id })
let noteFailureMsg = ''
if (canAddCompleteNote.value && note) {
const diagnosisId = row.patient_id || row.diagnosis_id
if (diagnosisId) {
try {
await addDoctorNote({ diagnosis_id: Number(diagnosisId), content: note })
} catch (e) {
noteFailureMsg = '问诊已完成,但备注未保存'
}
} else {
noteFailureMsg = '问诊已完成,但该预约没有关联诊单,备注未保存'
}
}
noteFailureMsg ? feedback.msgWarning(noteFailureMsg) : feedback.msgSuccess('操作成功')
completeDialogVisible.value = false
completeNoteContent.value = ''
completeRow.value = null
loadData()
} catch (e: any) {
feedback.msgError(e?.msg || e?.message || '操作失败')
} finally {
completeSubmitting.value = false
}
}
//
const handleEditPatient = (row: any) => {
@@ -1330,6 +1401,13 @@ onUnmounted(() => {
color: var(--el-color-danger);
}
.complete-dialog-tip {
margin: 0 0 12px;
color: var(--el-text-color-primary);
font-size: 14px;
line-height: 22px;
}
.qrcode-content {
padding: 16px 0;
min-height: 200px;
File diff suppressed because it is too large Load Diff
@@ -147,7 +147,7 @@ const props = withDefaults(defineProps<Props>(), {
const caseTypeLabel = computed(() => {
const d: any = props.diag || {}
return d.consultation_type === 'follow_up' ? '复诊' : '诊'
return d.consultation_type === 'follow_up' ? '复诊' : '诊'
})
const textOf = makeTextOf(() => props.diag || {})
@@ -16,7 +16,7 @@
预约{{ apt?.appointment_date }} {{ apt?.appointment_time }} ·
{{ formatPeriod(apt?.period) }}
</div>
<div>医生{{ apt?.doctor_name || '—' }} 医助{{ apt?.assistant_name || '—' }}</div>
<div>医生{{ apt?.doctor_name || '—' }} 客服{{ apt?.assistant_name || '—' }}</div>
<div>
状态{{ apt?.status_desc || '—' }} ·
{{ apt?.has_prescription ? '已开方' : '未开方' }}
+21 -1
View File
@@ -290,6 +290,23 @@
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="统计端就诊卡" prop="show_card">
<el-switch
v-model="formData.show_card"
:active-value="1"
:inactive-value="0"
active-text="显示"
inactive-text="隐藏"
inline-prompt
/>
<span class="form-tips ml-3">
关闭后该就诊卡将不会出现在小程序统计端
</span>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
@@ -874,6 +891,7 @@ const formData = ref({
current_medications: '',
status: 1,
create_source: 'admin',
show_card: 1,
external_userid: ''
})
@@ -1155,6 +1173,7 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
formData.value = data
formData.value.create_source = data.create_source ?? ''
formData.value.show_card = Number(data.show_card) === 0 ? 0 : 1
const y = data.diabetes_discovery_year
formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y)
}
@@ -1243,7 +1262,7 @@ const handleSubmit = async () => {
}
const handleViewCase = (row: any) => {
prescriptionRef.value?.openById(row.id, { slipType: 'user', hideCaseRecord: true })
prescriptionRef.value?.openById(row.id, { slipType: 'user' })
}
//
@@ -1317,6 +1336,7 @@ const handleClose = () => {
current_medications: '',
status: 1,
create_source: 'admin',
show_card: 1,
external_userid: ''
}
@@ -57,6 +57,7 @@ class AssetUserController extends BaseAdminController
'phone' => $params['phone'],
'password' => $passwordHash,
'status' => $params['status'] ?? 1,
'remark' => trim((string)($params['remark'] ?? '')),
]);
return $this->success('添加成功', ['id' => $user->id]);
@@ -92,6 +93,10 @@ class AssetUserController extends BaseAdminController
if (isset($params['status'])) {
$user->status = $params['status'];
}
if (array_key_exists('remark', $params)) {
$user->remark = trim((string)$params['remark']);
}
$user->save();
return $this->success('修改成功');
@@ -102,6 +102,24 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('', $data);
}
/**
* 直接调用京东官方物流接口刷新轨迹并落库(绕过快递100缓存)
*/
public function logisticsJdUpdate()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('logisticsJdUpdate');
$data = PrescriptionOrderLogic::logisticsJdUpdate(
(int) $params['id'],
$this->adminId,
$this->adminInfo
);
if ($data === null) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success((string) ($data['message'] ?? '京东物流轨迹已更新'), $data);
}
public function edit()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('edit');
@@ -345,6 +363,31 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('操作成功', $result);
}
/**
* 业务订单退款(须填写原因;关联收款单标记已退款)
*/
public function refund()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('refund');
$rawRefundAmount = $params['refund_amount'] ?? null;
$refundAmount = ($rawRefundAmount === null || $rawRefundAmount === '')
? null
: round((float) $rawRefundAmount, 2);
$result = PrescriptionOrderLogic::refund(
(int) $params['id'],
(string) ($params['reason'] ?? ''),
$this->adminId,
$this->adminInfo,
$refundAmount
);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('退款成功', $result);
}
/**
* 甘草药管家:处方下单(CTM_PREVIEW CTM_SUBMIT_RECIPEL
*
@@ -126,6 +126,12 @@ class AuthMiddleware
return true;
}
// 京东物流接口刷新:与「物流轨迹」同一数据域,复用 logisticsTrace 权限
if ($accessUri === 'tcm.prescriptionorder/logisticsjdupdate'
&& in_array('tcm.prescriptionorder/logisticstrace', $adminUris, true)) {
return true;
}
// 挂号列表批量改渠道:与单条编辑同一数据域,复用 doctor.appointment/edit
if ($accessUri === 'doctor.appointment/batcheditchannel'
&& in_array('doctor.appointment/edit', $adminUris, true)) {
@@ -56,7 +56,7 @@ class PrescriptionLibraryLists extends BaseAdminDataLists implements ListsSearch
public function lists(): array
{
$field = [
'id', 'prescription_name', 'formula_type', 'herbs', 'is_public',
'id', 'prescription_name', 'formula_type', 'herbs', 'is_public', 'disable_edit',
'creator_id', 'creator_name', 'create_time', 'update_time'
];
@@ -560,6 +560,8 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$poIds = array_values(array_filter(array_map('intval', array_column($lists, 'id'))));
$paidSumByPo = [];
// 关联支付单数量:批量统计,避免后续逐行 count() 造成 N+1 查询(导出/大列表明显变慢)
$linkCountByPo = [];
if ($poIds !== []) {
$links = PrescriptionOrderPayOrder::whereIn('prescription_order_id', $poIds)
->field(['prescription_order_id', 'pay_order_id'])
@@ -569,6 +571,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
foreach ($links as $l) {
$poid = (int) ($l['prescription_order_id'] ?? 0);
$oid = (int) ($l['pay_order_id'] ?? 0);
if ($poid > 0) {
$linkCountByPo[$poid] = ($linkCountByPo[$poid] ?? 0) + 1;
}
if ($poid > 0 && $oid > 0) {
$byPo[$poid][] = $oid;
}
@@ -664,10 +669,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
PrescriptionOrderLogic::maskInternalCostIfNeeded($item, $this->adminInfo);
PrescriptionOrderLogic::maskRemarkExtraIfNeeded($item, $this->adminInfo);
$pid = (int) ($item['id'] ?? 0);
$item['linked_pay_order_count'] = (int) PrescriptionOrderPayOrder::where(
'prescription_order_id',
$pid
)->count();
$item['linked_pay_order_count'] = (int) ($linkCountByPo[$pid] ?? 0);
$item['linked_pay_paid_total'] = $paidSumByPo[$pid] ?? 0.0;
$item['deposit_min_amount'] = $depMin;
$item['can_upload_gancao_reciperl'] = PrescriptionOrderLogic::canUploadGancaoRecipel(
@@ -776,10 +778,12 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
'export_channel' => '渠道',
'export_guahao_channel_source' => '自媒体渠道(挂号渠道来源)',
'export_medication_form' => '药品形态',
'export_prescription_name' => '药方名称',
'export_service_package' => '服务套餐',
'export_medication_days' => '天数',
'export_amount' => '总金额',
'export_paid_amount' => '已付金额',
'export_refund_amount' => '退款金额',
'export_agency_collect' => '代收金额',
'export_tracking_number' => '快递单号',
'export_sign_time' => '签收日期',
@@ -5638,20 +5638,33 @@ class YejiStatsLogic
*/
private static function commissionSettlementExpressRowSignExpr(string $etTable, string $traceTable): string
{
$sigTraceCond = "`tr_sig`.`status_code` IN ('3','301','302','304') OR `tr_sig`.`status` LIKE '%签收%' "
// 与 ExpressTrackingService::contextLooksSigned 对齐:
// 正向「签收/妥投/送达/本人签收/已放在」(顺丰末端 status=送达、context=快件已放在…)
// 先排除「准备签收/待签收/即将送达/预计」等「尚未签收」文案(含「签收」「送达」二字易误判)。
$sigByCode = "`tr_sig`.`status_code` IN ('3','301','302','304')";
$sigByText = "(`tr_sig`.`status` LIKE '%签收%' OR `tr_sig`.`status` LIKE '%送达%' "
. "OR `tr_sig`.`trace_context` LIKE '%签收%' OR `tr_sig`.`trace_context` LIKE '%妥投%' "
. "OR `tr_sig`.`trace_context` LIKE '%送达%' OR `tr_sig`.`trace_context` LIKE '%本人签收%'";
. "OR `tr_sig`.`trace_context` LIKE '%送达%' OR `tr_sig`.`trace_context` LIKE '%本人签收%' "
. "OR `tr_sig`.`trace_context` LIKE '%已放在%')";
$sigNotText = "(`tr_sig`.`trace_context` LIKE '%准备签收%' OR `tr_sig`.`trace_context` LIKE '%待签收%' "
. "OR `tr_sig`.`trace_context` LIKE '%等待签收%' OR `tr_sig`.`trace_context` LIKE '%即将送达%' "
. "OR `tr_sig`.`trace_context` LIKE '%预计%')";
$sigTraceCond = $sigByCode . ' OR (' . $sigByText . ' AND NOT ' . $sigNotText . ')';
return 'GREATEST('
. 'IF(IFNULL(`et`.`sign_time`, 0) > 0, `et`.`sign_time`, 0), '
. 'IFNULL((SELECT MAX(`tr_sig`.`trace_time_stamp`) FROM `' . $traceTable . '` `tr_sig` '
// 与 effectiveSignUnixFromTrackingDetail 一致:轨迹时间最权威,sign_time 列仅兜底。
// 用 COALESCE(NULLIF(...,0)) 实现「优先签收轨迹 → 已签收态最新轨迹 → sign_time 列」的取值顺序,
// 避免历史同步写入 sign_time 的「拉取当天」把签收时间顶高(旧 GREATEST 会取最大值)。
return 'COALESCE('
. 'NULLIF((SELECT MAX(`tr_sig`.`trace_time_stamp`) FROM `' . $traceTable . '` `tr_sig` '
. 'WHERE `tr_sig`.`tracking_id` = `et`.`id` AND IFNULL(`tr_sig`.`trace_time_stamp`, 0) > 0 AND ('
. $sigTraceCond
. ')), 0), '
. 'IF((`et`.`current_state` = \'3\' OR IFNULL(`et`.`is_signed`, 0) = 1), '
. 'NULLIF(IF((`et`.`current_state` = \'3\' OR IFNULL(`et`.`is_signed`, 0) = 1), '
. 'IFNULL((SELECT MAX(`tr_any`.`trace_time_stamp`) FROM `' . $traceTable . '` `tr_any` '
. 'WHERE `tr_any`.`tracking_id` = `et`.`id` AND IFNULL(`tr_any`.`trace_time_stamp`, 0) > 0), 0), '
. '0)'
. '0), 0), '
. 'NULLIF(IF(IFNULL(`et`.`sign_time`, 0) > 0, `et`.`sign_time`, 0), 0), '
. '0'
. ')';
}
@@ -210,6 +210,10 @@ class DiagnosisLogic extends BaseLogic
$params['diagnosis_date'] = strtotime($params['diagnosis_date']);
}
if (array_key_exists('show_card', $params)) {
$params['show_card'] = (int) $params['show_card'] === 0 ? 0 : 1;
}
Diagnosis::update($params);
// 如果是编辑,也确保患者有 TRTC 账号
@@ -3028,9 +3032,10 @@ class DiagnosisLogic extends BaseLogic
return [];
}
// 根据诊断ID列表查询诊单详情
// 根据诊断ID列表查询诊单详情show_card=0 的诊单不在统计端就诊卡列表中出现)
$cardList = Diagnosis::whereIn('id', $viewRecords)
->where('delete_time', null)
->where('show_card', 1)
->field([
'id', 'patient_id', 'patient_name', 'gender', 'age',
'diagnosis_date', 'diagnosis_type', 'syndrome_type',
@@ -11,6 +11,7 @@ use app\common\model\Order;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisAssignLog;
use app\common\model\tcm\Prescription;
use app\common\model\tcm\PrescriptionLibrary;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\PrescriptionOrderLog;
use app\common\model\tcm\PrescriptionOrderPayOrder;
@@ -1036,7 +1037,17 @@ class PrescriptionOrderLogic
$canHerbs = self::canViewPrescriptionHerbsInOrderDetail($adminInfo);
$arr['prescription_detail_herbs_visible'] = $canHerbs;
if (! $canHerbs && isset($arr['prescription']) && is_array($arr['prescription'])) {
unset($arr['prescription']['herbs']);
$rx = &$arr['prescription'];
$herbs = is_array($rx['herbs'] ?? null) ? $rx['herbs'] : [];
$hasAux = false;
foreach ($herbs as $h) {
if (is_array($h) && (string) ($h['formula_type'] ?? '') === '辅方') {
$hasAux = true;
break;
}
}
$rx['has_aux_formula'] = $hasAux ? 1 : 0;
unset($rx['herbs']);
}
// 诊单医助 / 诊单创建人姓名(tcm_diagnosis.admin_id);部门见下方:优先业务订单 creator_id
@@ -1514,6 +1525,48 @@ class PrescriptionOrderLogic
return $payload;
}
/**
* 直接调用京东官方物流接口刷新轨迹并落库(后台「京东接口更新」按钮)。
*
* @return array{success:bool, message:string, traces_count:int, state:string}|null
*/
public static function logisticsJdUpdate(int $id, int $adminId, array $adminInfo): ?array
{
self::$error = '';
$row = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
if (!$row) {
self::$error = '订单不存在';
return null;
}
if (!self::canAccessOrder($row, $adminId, $adminInfo)) {
self::$error = '无权限操作';
return null;
}
$num = trim((string) ($row->tracking_number ?? ''));
if ($num === '') {
self::$error = '未填写快递单号';
return null;
}
$ret = \app\common\service\ExpressTrackingService::queryJdOfficialAndUpdate($num);
if (empty($ret['success'])) {
self::$error = (string) ($ret['message'] ?? '京东物流更新失败');
return null;
}
return [
'success' => true,
'message' => (string) ($ret['message'] ?? '京东物流轨迹已更新'),
'traces_count' => (int) ($ret['traces_count'] ?? 0),
'state' => (string) ($ret['state'] ?? ''),
];
}
/**
* @param array<string,mixed> $params
* @return array<string,mixed>|false
@@ -2534,8 +2587,8 @@ class PrescriptionOrderLogic
return $out;
}
/** 完成订单时可选的履约终态:3=已完成,7-12=业务结案类状态 */
private const COMPLETE_FULFILLMENT_TARGETS = [3, 7, 8, 9, 10, 11, 12];
/** 完成订单时可选的履约终态:3=已完成,7-12 业务结案类状态(退款 10 走独立 refund 接口) */
private const COMPLETE_FULFILLMENT_TARGETS = [3, 7, 8, 9, 11, 12];
/** 完成时是否按「实付=关联支付单金额」重算并清空诊单医助(与 clearDiagnosisAssistantOnComplete 的终态判断一致) */
private const COMPLETE_FULFILLMENT_TERMINAL = [3, 8, 9, 10, 11, 12];
@@ -2592,13 +2645,7 @@ class PrescriptionOrderLogic
$order->fulfillment_status = $targetFulfillmentStatus;
}
$refundedPayCount = 0;
try {
if ($targetFulfillmentStatus === 10 && $linkedPayOrderIds !== []) {
$refundedPayCount = Order::whereIn('id', $linkedPayOrderIds)
->whereNull('delete_time')
->update(['status' => 4]);
}
$order->save();
} catch (\Throwable $e) {
self::$error = $e->getMessage();
@@ -2614,9 +2661,6 @@ class PrescriptionOrderLogic
$logLine = '订单完成,状态变更为「' . $label . '」,实付金额更新为 ¥' . $linkedPayPaidTotal . $unassignSummary;
} else {
$logLine = '订单处理完成,状态变更为「' . $label . '」' . $unassignSummary;
if ($targetFulfillmentStatus === 10 && $refundedPayCount > 0) {
$logLine .= ',关联支付单 ' . $refundedPayCount . ' 笔已标记为已退款';
}
}
self::writeLog(
$id,
@@ -2634,6 +2678,184 @@ class PrescriptionOrderLogic
return $out;
}
/**
* 关联收款单中可退款的记录(已支付/待审核)
*
* @return array<int, array<string, mixed>>
*/
private static function linkedPayRefundableRows(int $prescriptionOrderId): array
{
$ids = self::linkedPayOrderIdList($prescriptionOrderId);
if ($ids === []) {
return [];
}
return Order::whereIn('id', $ids)
->whereNull('delete_time')
->whereIn('status', [2, 5])
->field(['id', 'amount'])
->order('id', 'asc')
->select()
->toArray();
}
private static function linkedPayRefundableTotal(int $prescriptionOrderId): float
{
$sum = 0.0;
foreach (self::linkedPayRefundableRows($prescriptionOrderId) as $row) {
$sum += (float) ($row['amount'] ?? 0);
}
return round($sum, 2);
}
/**
* 业务订单退款:须填写原因;可指定退款金额(部分退)或退满可退上限(全额)。
*
* @param float|null $refundAmount 为空或 0 表示退满可退上限
* @return array<string,mixed>|false
*/
public static function refund(int $id, string $reason, int $adminId, array $adminInfo, ?float $refundAmount = null)
{
self::$error = '';
$reason = trim($reason);
if ($reason === '') {
self::$error = '请填写退款原因';
return false;
}
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
if (!$order) {
self::$error = '订单不存在';
return false;
}
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
self::$error = '无权限操作';
return false;
}
$curFs = (int) $order->fulfillment_status;
if (!in_array($curFs, [3, 5, 6, 9], true)) {
self::$error = '仅「已发货」「已签收」「已完成」或「拒收」状态可退款';
return false;
}
if ((int) $order->payment_slip_audit_status !== 1) {
self::$error = '支付单审核尚未通过,不可退款';
return false;
}
$maxRefundable = self::linkedPayRefundableTotal($id);
$currentPaid = round((float) $order->paid, 2);
$cap = $maxRefundable > 0 ? $maxRefundable : $currentPaid;
if ($cap <= 0) {
self::$error = '当前无可退金额';
return false;
}
if ($refundAmount === null || $refundAmount <= 0) {
$refundAmount = $cap;
} else {
$refundAmount = round($refundAmount, 2);
if ($refundAmount > $cap + 0.009) {
self::$error = '退款金额不能超过可退上限 ¥' . $cap;
return false;
}
}
$isFullRefund = $refundAmount >= $cap - 0.009;
$toRefundRows = self::linkedPayRefundableRows($id);
$refundedPayCount = 0;
$markedPayTotal = 0.0;
try {
if ($isFullRefund && $toRefundRows !== []) {
$ids = array_map('intval', array_column($toRefundRows, 'id'));
$refundedPayCount = Order::whereIn('id', $ids)
->whereNull('delete_time')
->update(['status' => 4]);
foreach ($toRefundRows as $pr) {
$markedPayTotal += (float) ($pr['amount'] ?? 0);
}
$markedPayTotal = round($markedPayTotal, 2);
} elseif (!$isFullRefund && $toRefundRows !== []) {
$remaining = $refundAmount;
foreach ($toRefundRows as $pr) {
if ($remaining < 0.01) {
break;
}
$amt = round((float) ($pr['amount'] ?? 0), 2);
if ($amt <= $remaining + 0.001) {
Order::where('id', (int) $pr['id'])
->whereNull('delete_time')
->update(['status' => 4]);
$remaining -= $amt;
$markedPayTotal += $amt;
$refundedPayCount++;
}
}
$markedPayTotal = round($markedPayTotal, 2);
}
if ($isFullRefund) {
$order->fulfillment_status = 10;
$order->paid = 0;
} else {
$order->paid = round(max(0, $currentPaid - $refundAmount), 2);
if ($order->paid <= 0.009) {
$order->paid = 0;
$order->fulfillment_status = 10;
}
}
$prevStoredRefund = round((float) ($order->refund_amount ?? 0), 2);
$order->refund_amount = round($prevStoredRefund + $refundAmount, 2);
$order->save();
} catch (\Throwable $e) {
self::$error = $e->getMessage();
return false;
}
$unassignSummary = '';
if ($isFullRefund || (float) $order->paid <= 0.009) {
$unassignSummary = self::clearDiagnosisAssistantOnComplete((int) $order->diagnosis_id);
}
if ($isFullRefund) {
$logLine = '订单全额退款,状态变更为「退款」;原因:' . $reason . ';退款金额 ¥' . $refundAmount;
} else {
$logLine = '订单部分退款;原因:' . $reason . ';本次退款 ¥' . $refundAmount . ';已付余额 ¥' . $order->paid;
}
if ($refundedPayCount > 0) {
$logLine .= ';关联收款单 ' . $refundedPayCount . ' 笔已标记为已退款';
if ($markedPayTotal > 0) {
$logLine .= '(收款侧合计 ¥' . $markedPayTotal . '';
}
} elseif ($toRefundRows !== []) {
$logLine .= ';关联收款单无匹配的已支付/待审核记录可标记';
}
if ($isFullRefund) {
$logLine .= ';已付总额清零' . $unassignSummary;
}
self::writeLog($id, $adminId, $adminInfo, 'refund', $logLine);
$out = $order->toArray();
$out['this_refund_amount'] = $refundAmount;
$out['refund_full'] = $isFullRefund;
$out['refundable_cap'] = $cap;
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
}
public static function fulfillmentStatusLabel(int $fs): string
{
$m = [
@@ -2937,7 +3159,7 @@ class PrescriptionOrderLogic
$rxById = [];
if ($rxIdList !== []) {
$rxRows = Prescription::whereIn('id', $rxIdList)->whereNull('delete_time')
->field(['id', 'prescription_type', 'need_decoction', 'dose_unit', 'assistant_id', 'appointment_id'])
->field(['id', 'prescription_type', 'need_decoction', 'dose_unit', 'assistant_id', 'appointment_id', 'prescription_name', 'aux_usage', 'herbs', 'creator_id'])
->select()
->toArray();
foreach ($rxRows as $xr) {
@@ -2945,6 +3167,10 @@ class PrescriptionOrderLogic
}
}
// 药方名称反查:处方库(开方医师 creator_id + 公开模板),按 formula_type + 药材集合匹配,
// 复刻前端 resolveSlipAuxLibraryName 口径(主方/辅方均如此;处方记录 prescription_name 仅做兜底)。
[$libByDoctor, $libPublic] = self::buildPrescriptionLibraryNameIndexForExport($rxById);
if ($needGuahaoChannel) {
$yejiChannelHighlightByDiag = [];
if (
@@ -3086,7 +3312,9 @@ class PrescriptionOrderLogic
}
}
// 签收时间:与详情 logisticsTrace / 业绩提成同源;库内无轨迹时导出阶段补查快递100 并落库
// 签收时间:纯读物流库(express_tracking / express_trace),不在导出阶段发起任何快递100 HTTP
// 签收时间的落库由 `php think tcm:backfill-sign-time` 与 `express:auto-update` 定时任务负责,
// 导出只读脚本/定时任务处理好的结果——避免逐单 HTTP 让导出变慢、耗时不可控。
/** @var array<int, int> $signTsByPoId order_id => Unix 秒 */
$signTsByPoId = [];
if ($poIds !== []) {
@@ -3107,24 +3335,6 @@ class PrescriptionOrderLogic
Log::warning('appendPrescriptionOrderListExportRows batch sign_time failed: ' . $e->getMessage());
$signTsByPoId = [];
}
foreach ($lists as $row) {
$oid = (int) ($row['id'] ?? 0);
if ($oid <= 0 || (int) ($signTsByPoId[$oid] ?? 0) > 0) {
continue;
}
if (trim((string) ($row['tracking_number'] ?? '')) === '') {
continue;
}
try {
$synced = ExpressTrackingService::syncSignUnixFromLogisticsForPrescriptionOrder($oid, true);
if ($synced > 0) {
$signTsByPoId[$oid] = $synced;
}
} catch (\Throwable $e) {
Log::warning('appendPrescriptionOrderListExportRows sync sign_time order ' . $oid . ': ' . $e->getMessage());
}
}
}
$firstVisitAssistantByDiag = self::batchFirstVisitAssistantNameByDiagnosis($diagIdList, $diagById);
@@ -3166,6 +3376,22 @@ class PrescriptionOrderLogic
}
$item['export_medication_form'] = self::formatMedicationFormForExport(\is_array($rx) ? $rx : []);
// 药方名称:主方/辅方均反查处方库取名称(与处方笺一致),带标签;甘草/无匹配留空
[$mainRxName, $auxRxName] = self::resolvePrescriptionNamesForExport(
\is_array($rx) ? $rx : [],
$libByDoctor,
$libPublic
);
$rxNameParts = [];
if ($mainRxName !== '') {
$rxNameParts[] = '主方:' . $mainRxName;
}
if ($auxRxName !== '') {
$rxNameParts[] = '辅方:' . $auxRxName;
}
$item['export_prescription_name'] = implode(' ', $rxNameParts);
$item['export_service_package'] = self::formatServicePackageForExport(
$item['service_package'] ?? '',
$packageNameByValue
@@ -3185,6 +3411,10 @@ class PrescriptionOrderLogic
$paid = round((float) ($item['linked_pay_paid_total'] ?? 0), 2);
$item['export_amount'] = number_format($amt, 2, '.', '');
$item['export_paid_amount'] = number_format($paid, 2, '.', '');
$refundStored = round((float) ($item['refund_amount'] ?? 0), 2);
$item['export_refund_amount'] = $refundStored > 0
? number_format($refundStored, 2, '.', '')
: '';
$item['export_agency_collect'] = number_format(round($amt - $paid, 2), 2, '.', '');
$item['export_tracking_number'] = (string) ($item['tracking_number'] ?? '');
@@ -3234,6 +3464,186 @@ class PrescriptionOrderLogic
unset($item);
}
/**
* 导出药方名称反查:批量加载相关开方医师处方库(creator_id 命中 + 公开模板),
* 建立 [creator_id][formula_type][herbKey] => name 公开 [formula_type][herbKey] => name 两级索引。
*
* @param array<int, array> $rxById 处方 id => 处方行(需含 herbs / creator_id
*
* @return array{0: array<int, array<string, array<string, string>>>, 1: array<string, array<string, string>>}
*/
private static function buildPrescriptionLibraryNameIndexForExport(array $rxById): array
{
$libByDoctor = [];
$libPublic = [];
if ($rxById === []) {
return [$libByDoctor, $libPublic];
}
$doctorIds = [];
foreach ($rxById as $rx) {
$cid = (int) ($rx['creator_id'] ?? 0);
if ($cid > 0) {
$doctorIds[$cid] = true;
}
}
$doctorIdList = array_keys($doctorIds);
try {
$libRows = PrescriptionLibrary::whereNull('delete_time')
->where(function ($q) use ($doctorIdList) {
if ($doctorIdList !== []) {
$q->whereIn('creator_id', $doctorIdList)->whereOr('is_public', 1);
} else {
$q->where('is_public', 1);
}
})
->field(['prescription_name', 'formula_type', 'herbs', 'creator_id', 'is_public'])
->select()
->toArray();
} catch (\Throwable $e) {
Log::warning('buildPrescriptionLibraryNameIndexForExport failed: ' . $e->getMessage());
return [$libByDoctor, $libPublic];
}
foreach ($libRows as $lib) {
$name = trim((string) ($lib['prescription_name'] ?? ''));
if ($name === '') {
continue;
}
$herbsRaw = $lib['herbs'] ?? null;
if (\is_string($herbsRaw) && $herbsRaw !== '') {
$decoded = json_decode($herbsRaw, true);
$herbsRaw = \is_array($decoded) ? $decoded : [];
}
if (!\is_array($herbsRaw) || $herbsRaw === []) {
continue;
}
$key = self::herbSetMatchKeyForExport($herbsRaw);
if ($key === '') {
continue;
}
$ft = ((string) ($lib['formula_type'] ?? '')) === '辅方' ? '辅方' : '主方';
if ((int) ($lib['is_public'] ?? 0) === 1) {
$libPublic[$ft][$key] ??= $name;
}
$cid = (int) ($lib['creator_id'] ?? 0);
if ($cid > 0) {
$libByDoctor[$cid][$ft][$key] ??= $name;
}
}
return [$libByDoctor, $libPublic];
}
/**
* 反查单张处方的主方 / 辅方名称(与处方笺展示一致):
* - 主方:主方药材集合匹配开方医师/公开处方库(formula_type=主方);兜底取处方 prescription_name。
* - 辅方:优先 aux_usage.prescription_name / library_name,否则辅方药材集合匹配(formula_type=辅方)。
*
* @param array $rx 处方行(含 herbs / creator_id / prescription_name / aux_usage
* @param array<int, array<string, array<string, string>>> $libByDoctor
* @param array<string, array<string, string>> $libPublic
*
* @return array{0: string, 1: string} [主方名称, 辅方名称]
*/
private static function resolvePrescriptionNamesForExport(array $rx, array $libByDoctor, array $libPublic): array
{
if ($rx === []) {
return ['', ''];
}
$doctorId = (int) ($rx['creator_id'] ?? 0);
$herbs = $rx['herbs'] ?? null;
if (\is_string($herbs) && $herbs !== '') {
$decoded = json_decode($herbs, true);
$herbs = \is_array($decoded) ? $decoded : [];
}
if (!\is_array($herbs)) {
$herbs = [];
}
$mainHerbs = [];
$auxHerbs = [];
foreach ($herbs as $h) {
if (!\is_array($h)) {
continue;
}
if (((string) ($h['formula_type'] ?? '')) === '辅方') {
$auxHerbs[] = $h;
} else {
$mainHerbs[] = $h;
}
}
$lookup = static function (string $ft, array $hs) use ($doctorId, $libByDoctor, $libPublic): string {
if ($hs === []) {
return '';
}
$key = self::herbSetMatchKeyForExport($hs);
if ($key === '') {
return '';
}
if ($doctorId > 0 && isset($libByDoctor[$doctorId][$ft][$key])) {
return $libByDoctor[$doctorId][$ft][$key];
}
return (string) ($libPublic[$ft][$key] ?? '');
};
// 主方
$mainName = $lookup('主方', $mainHerbs);
if ($mainName === '') {
$mainName = trim((string) ($rx['prescription_name'] ?? ''));
}
// 辅方:先读持久化字段
$auxName = '';
$auxUsage = $rx['aux_usage'] ?? null;
if (\is_string($auxUsage) && $auxUsage !== '') {
$decodedAux = json_decode($auxUsage, true);
$auxUsage = \is_array($decodedAux) ? $decodedAux : null;
}
if (\is_array($auxUsage)) {
$auxName = trim((string) ($auxUsage['prescription_name'] ?? ($auxUsage['library_name'] ?? '')));
}
if ($auxName === '') {
$auxName = $lookup('辅方', $auxHerbs);
}
return [$mainName, $auxName];
}
/**
* 药材集合匹配 key:每味 name:dosagedosage 规整去尾零)排序后拼接,与处方库反查双方同口径。
*/
private static function herbSetMatchKeyForExport(array $herbs): string
{
$parts = [];
foreach ($herbs as $h) {
if (!\is_array($h)) {
continue;
}
$name = trim((string) ($h['name'] ?? ''));
if ($name === '') {
continue;
}
$dosage = (float) ($h['dosage'] ?? 0);
if (floor($dosage) === $dosage) {
$dStr = (string) (int) $dosage;
} else {
$dStr = rtrim(rtrim(number_format($dosage, 4, '.', ''), '0'), '.');
}
$parts[] = $name . ':' . $dStr;
}
sort($parts);
return implode('|', $parts);
}
/**
* 完成订单时把关联诊单的 assistant_id 清空,让患者回到「待分配」状态
* 仅当该患者没有其它进行中的处方订单且无指派日志时才清空,避免误覆盖
@@ -33,6 +33,7 @@ class DiagnosisValidate extends BaseValidate
'age' => 'require|number|between:0,150',
'diagnosis_type' => 'require',
'status' => 'in:0,1',
'show_card' => 'in:0,1',
'create_source' => 'max:32',
'current_medications' => 'max:2000',
'tongue_images' => 'array',
@@ -78,7 +79,7 @@ class DiagnosisValidate extends BaseValidate
public function sceneEdit()
{
return $this->only(['id', 'patient_name', 'id_card', 'phone', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'marital_status', 'height', 'weight', 'region', 'systolic_pressure', 'diastolic_pressure', 'fasting_blood_sugar', 'diabetes_discovery_year', 'local_hospital_diagnosis', 'local_hospital_name', 'past_history', 'symptoms', 'tongue_coating', 'pulse', 'treatment_principle', 'prescription', 'doctor_advice', 'remark', 'current_medications', 'status', 'create_source']);
return $this->only(['id', 'patient_name', 'id_card', 'phone', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'marital_status', 'height', 'weight', 'region', 'systolic_pressure', 'diastolic_pressure', 'fasting_blood_sugar', 'diabetes_discovery_year', 'local_hospital_diagnosis', 'local_hospital_name', 'past_history', 'symptoms', 'tongue_coating', 'pulse', 'treatment_principle', 'prescription', 'doctor_advice', 'remark', 'current_medications', 'status', 'show_card', 'create_source']);
}
public function sceneId()
@@ -16,7 +16,8 @@ class PrescriptionLibraryValidate extends BaseValidate
'prescription_name' => 'require|max:100',
'formula_type' => 'in:主方,辅方',
'herbs' => 'require|array',
'is_public' => 'in:0,1'
'is_public' => 'in:0,1',
'disable_edit' => 'in:0,1'
];
protected $message = [
@@ -27,7 +28,8 @@ class PrescriptionLibraryValidate extends BaseValidate
'formula_type.in' => '处方类型只能是主方或辅方',
'herbs.require' => '药材列表不能为空',
'herbs.array' => '药材列表格式错误',
'is_public.in' => '是否公开参数错误'
'is_public.in' => '是否公开参数错误',
'disable_edit.in' => '禁用修改参数错误'
];
/**
@@ -35,7 +37,7 @@ class PrescriptionLibraryValidate extends BaseValidate
*/
public function sceneAdd()
{
return $this->only(['prescription_name', 'formula_type', 'herbs', 'is_public']);
return $this->only(['prescription_name', 'formula_type', 'herbs', 'is_public', 'disable_edit']);
}
/**
@@ -43,7 +45,7 @@ class PrescriptionLibraryValidate extends BaseValidate
*/
public function sceneEdit()
{
return $this->only(['id', 'prescription_name', 'formula_type', 'herbs', 'is_public']);
return $this->only(['id', 'prescription_name', 'formula_type', 'herbs', 'is_public', 'disable_edit']);
}
/**
@@ -32,7 +32,9 @@ class PrescriptionOrderValidate extends BaseValidate
'remark_assistant' => 'max:500',
'action' => 'require|in:approve,reject',
'remark' => 'max:500',
'fulfillment_status' => 'require|integer|in:3,7,8,9,10,11,12',
'fulfillment_status' => 'require|integer|in:3,7,8,9,11,12',
'reason' => 'require|max:500',
'refund_amount' => 'float|egt:0',
'patient_name' => 'require|max:50',
'phone' => 'require|max:20',
'phone_tail' => 'max:20|regex:/^\\d*$/',
@@ -50,6 +52,7 @@ class PrescriptionOrderValidate extends BaseValidate
'patient_name.require' => '请输入患者姓名',
'phone.require' => '请输入手机号',
'phone_tail.regex' => '手机后四位仅支持数字',
'reason.require' => '请填写退款原因',
];
protected $scene = [
@@ -65,6 +68,7 @@ class PrescriptionOrderValidate extends BaseValidate
'tracking_number', 'express_company', 'fee_type', 'amount', 'remark_extra', 'remark_assistant', 'pay_order_ids',
],
'logisticsTrace' => ['id', 'phone_tail'],
'logisticsJdUpdate' => ['id'],
'auditPrescription' => ['id', 'action', 'remark'],
'auditPayment' => ['id', 'action', 'remark'],
'withdraw' => ['id'],
@@ -75,6 +79,7 @@ class PrescriptionOrderValidate extends BaseValidate
'linkPayOrder' => ['id', 'pay_order_id'],
'requestCompletion' => ['id'],
'complete' => ['id', 'fulfillment_status'],
'refund' => ['id', 'reason', 'refund_amount'],
'submitGancaoRecipel' => ['id'],
'previewGancaoRecipel' => ['id'],
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
+40 -1
View File
@@ -457,6 +457,10 @@ class TcmController extends BaseApiController
return ['ok' => false, 'error' => '诊单不存在'];
}
if ((int) ($diagnosis['show_card'] ?? 1) !== 1) {
return ['ok' => false, 'error' => '该就诊卡已在统计端隐藏'];
}
// 通过 diagnosis_view_records 校验当前小程序用户与该诊单的归属关系
// 该表是「就诊卡列表」的来源(参考 DiagnosisLogic::getCardList
$owned = \think\facade\Db::name('diagnosis_view_records')
@@ -623,7 +627,42 @@ class TcmController extends BaseApiController
];
if ($existing) {
$payload['id'] = $existing['id'];
// 分表单项更新时保留未提交字段,避免血糖/血压互相覆盖
$keepDecimal = function ($new, string $oldKey) use ($parseDecimal, $existing) {
if ($new !== null) {
return $new;
}
return $parseDecimal($existing[$oldKey] ?? null);
};
$keepInt = function ($new, string $oldKey) use ($parseInt, $existing) {
if ($new !== null) {
return $new;
}
return $parseInt($existing[$oldKey] ?? null);
};
$fasting = $keepDecimal($fasting, 'fasting_blood_sugar');
$postprandial = $keepDecimal($postprandial, 'postprandial_blood_sugar');
$other = $keepDecimal($other, 'other_blood_sugar');
$systolic = $keepInt($systolic, 'systolic_pressure');
$diastolic = $keepInt($diastolic, 'diastolic_pressure');
if ($remark === '') {
$remark = trim((string) ($existing['remark'] ?? ''));
}
$payload = [
'diagnosis_id' => $diagnosisId,
'patient_id' => (int) $diagnosis['patient_id'],
'record_date' => $todayStart,
'record_time' => date('H:i'),
'fasting_blood_sugar' => $fasting,
'postprandial_blood_sugar' => $postprandial,
'other_blood_sugar' => $other,
'systolic_pressure' => $systolic,
'diastolic_pressure' => $diastolic,
'remark' => $remark,
'source' => 1,
'id' => $existing['id'],
];
BloodRecord::update($payload);
return $this->success('已更新今日记录', ['id' => $existing['id']]);
}
@@ -0,0 +1,442 @@
<?php
namespace app\api\logic\tcm;
use app\common\model\tcm\Diagnosis;
use app\common\service\AiChatService;
use think\facade\Cache;
/**
* 日常护理 - 血糖照护 AI(累计 7 天有记录后:血糖建议 + 中医调理方向)
*/
class DailyBloodCareAiLogic
{
private const CACHE_TTL = 86400;
private const MIN_RECORD_DAYS = 7;
private const AI_TIMEOUT_SEC = 14;
private const AI_STREAM_TIMEOUT_SEC = 22;
/**
* @return array{ok:bool,error?:string,data?:array}
*/
public static function getDailyCarePlan(int $diagnosisId, bool $refresh = false): array
{
if ($diagnosisId <= 0) {
return ['ok' => false, 'error' => '缺少就诊卡'];
}
$context = self::buildCareContext($diagnosisId);
if (!$context['ok']) {
return ['ok' => false, 'error' => $context['error'] ?? '获取档案失败'];
}
$gate = self::ensureEnoughBloodDays($context['data']);
if (!$gate['ok']) {
return ['ok' => false, 'error' => $gate['error']];
}
$analysis = self::buildAnalysisPayload($context['data']);
$cacheKey = self::careCacheKey($diagnosisId);
if (!$refresh) {
$cached = Cache::get($cacheKey);
if (is_array($cached) && !empty($cached['summary'])) {
$cached['cached'] = true;
$cached['analysis'] = $analysis;
return ['ok' => true, 'data' => $cached];
}
}
$data = null;
if (AiChatService::isEnabled()) {
$raw = self::aiDailyCarePlan($context['data'], $refresh);
if (is_array($raw) && !empty($raw['summary'])) {
$data = self::finalizeCarePlan($raw);
}
}
if (!$data) {
$data = GiKnowledge::fallbackBloodCarePlan(
$context['data']['stats_7d'] ?? [],
$context['data']['stats_30d'] ?? [],
$context['data']
);
}
$data['date'] = date('Y-m-d');
$data['cached'] = false;
$data['analysis'] = $analysis;
Cache::set($cacheKey, $data, self::CACHE_TTL);
return ['ok' => true, 'data' => $data];
}
/**
* @param callable(string, array<string, mixed>):void $emit
*/
public static function streamDailyCarePlan(int $diagnosisId, bool $refresh, callable $emit): void
{
if ($diagnosisId <= 0) {
$emit('error', ['message' => '缺少就诊卡']);
return;
}
$context = self::buildCareContext($diagnosisId);
if (!$context['ok']) {
$emit('error', ['message' => $context['error'] ?? '获取档案失败']);
return;
}
$gate = self::ensureEnoughBloodDays($context['data']);
if (!$gate['ok']) {
$emit('error', ['message' => $gate['error']]);
return;
}
$analysis = self::buildAnalysisPayload($context['data']);
$emit('analysis', ['analysis' => $analysis]);
$cacheKey = self::careCacheKey($diagnosisId);
if (!$refresh) {
$cached = Cache::get($cacheKey);
if (is_array($cached) && !empty($cached['summary'])) {
$cached['cached'] = true;
$cached['analysis'] = $analysis;
$emit('done', $cached);
return;
}
}
$data = null;
$fullText = '';
if (AiChatService::isEnabled()) {
[$system, $user] = self::buildCareStreamPrompts($context['data'], $refresh);
$streamRes = AiChatService::streamChat([
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
], static function (string $delta) use (&$fullText, $emit): void {
$fullText .= $delta;
$emit('delta', ['text' => $delta, 'buffer' => $fullText]);
}, [
'temperature' => $refresh ? 0.75 : 0.4,
'max_tokens' => 720,
'timeout' => self::AI_STREAM_TIMEOUT_SEC,
]);
if ($streamRes['ok'] && trim($fullText) !== '') {
$parsed = self::parseCarePlainText(trim($fullText));
if ($parsed && !empty($parsed['summary'])) {
$data = self::finalizeCarePlan($parsed);
}
}
}
if (!$data) {
$data = GiKnowledge::fallbackBloodCarePlan(
$context['data']['stats_7d'] ?? [],
$context['data']['stats_30d'] ?? [],
$context['data']
);
}
$data['date'] = date('Y-m-d');
$data['cached'] = false;
$data['analysis'] = $analysis;
Cache::set($cacheKey, $data, self::CACHE_TTL);
$emit('done', $data);
}
private static function careCacheKey(int $diagnosisId): string
{
return 'daily_blood_care_ai_v1:' . $diagnosisId . ':' . date('Y-m-d');
}
/**
* @return array{ok:bool,error?:string,data?:array}
*/
private static function buildCareContext(int $diagnosisId): array
{
$base = DailyDietAiLogic::getPatientContext($diagnosisId);
if (!$base['ok']) {
return $base;
}
$diagnosis = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
if ($diagnosis) {
$row = $diagnosis->toArray();
$base['data']['syndrome_type'] = trim((string) ($row['syndrome_type'] ?? ''));
$base['data']['symptoms'] = trim((string) ($row['symptoms'] ?? ''));
$base['data']['remark'] = trim((string) ($row['remark'] ?? ''));
}
return $base;
}
/**
* @param array<string,mixed> $context
* @return array{ok:bool,error?:string}
*/
private static function ensureEnoughBloodDays(array $context): array
{
$days30 = (int) (($context['stats_30d']['record_days'] ?? 0));
$days7 = (int) (($context['stats_7d']['record_days'] ?? 0));
$total = max($days30, $days7);
if ($total < self::MIN_RECORD_DAYS) {
return ['ok' => false, 'error' => '累计记录满7天后可生成照护建议'];
}
return ['ok' => true];
}
/**
* @param array<string,mixed> $context
* @return array<int,array<string,mixed>>
*/
private static function buildAnalysisPayload(array $context): array
{
$out = [];
foreach (['stats_7d', 'stats_30d'] as $key) {
$s = $context[$key] ?? null;
if (is_array($s)) {
$out[] = $s;
}
}
return $out;
}
/**
* @param array<string,mixed> $context
* @return array<string,mixed>|null
*/
private static function aiDailyCarePlan(array $context, bool $refresh): ?array
{
[$system, $user] = self::buildCareJsonPrompts($context, $refresh);
$res = AiChatService::chat([
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
], [
'temperature' => $refresh ? 0.7 : 0.35,
'max_tokens' => 650,
'timeout' => self::AI_TIMEOUT_SEC,
'response_format' => ['type' => 'json_object'],
]);
if (empty($res['ok']) || empty($res['content'])) {
return null;
}
$json = self::parseJsonObject((string) $res['content']);
return is_array($json) ? $json : null;
}
/**
* @param array<string,mixed> $context
* @return array{0:string,1:string}
*/
private static function buildCareJsonPrompts(array $context, bool $refresh): array
{
$bloodLine = self::bloodLine($context);
$stat7Line = self::statsLine($context['stats_7d'] ?? []);
$stat30Line = self::statsLine($context['stats_30d'] ?? []);
$syndrome = trim((string) ($context['syndrome_type'] ?? ''));
$symptoms = trim((string) ($context['symptoms'] ?? ''));
$system = <<<SYS
你是基层中医糖尿病照护助手,面向农村中老年患者,用通俗中文给「血糖管理 + 中医调理方向」建议。
硬性要求:
1. 不得开具具体中药处方名、剂量、加减;不得指导自行增减西药/胰岛素。
2. 中医部分写调理思路、食疗方向、穴位艾灸注意事项,强调需面诊脉诊后由医师定方。
3. 结合提供的血糖统计与逐日记录,给出可执行的监测与生活方式建议。
4. 只输出 JSON
{"summary":"一句话总评","blood_advice":"血糖监测与饮食运动","tcm_plan":"中医辨证与调理方案","watch_points":["留意1","留意2"],"next_steps":"下一步复诊或就医提醒"}
SYS;
$user = sprintf(
"患者:%s,%s,%s岁。证型登记:%s。症状摘要:%s。\n日期:%s。\n近7日血糖:%s。\n统计:%s%s。\n请根据控制好坏给出个性化 JSON。",
$context['patient_name'] ?: '患者',
$context['gender_text'] ?? '',
$context['age'] ?? 0,
$syndrome !== '' ? $syndrome : '未登记',
$symptoms !== '' ? mb_substr($symptoms, 0, 120) : '无',
$context['today'] ?? date('Y-m-d'),
$bloodLine,
$stat7Line,
$stat30Line
);
if ($refresh) {
$user .= "\n\n用户点了「换一换」,请换一套不同表述但同样严谨的建议,编号" . substr(md5((string) microtime(true)), 0, 8) . '。';
}
return [$system, $user];
}
/**
* @param array<string,mixed> $context
* @return array{0:string,1:string}
*/
private static function buildCareStreamPrompts(array $context, bool $refresh): array
{
$bloodLine = self::bloodLine($context);
$stat7Line = self::statsLine($context['stats_7d'] ?? []);
$stat30Line = self::statsLine($context['stats_30d'] ?? []);
$system = <<<SYS
你是基层中医糖尿病照护助手,给农村中老年患者写血糖与中医调理建议。
硬性要求:
1. 不得写具体中药方名剂量、不得指导自行调药。
2. 中医写辨证思路与调理方向,强调面诊后由医师定方。
3. 严格按下面 5 行输出,不要 JSON、不要 markdown
总评:(一句话)
血糖:(监测、饮食、运动,2-4句)
中医:(辨证思路、食疗艾灸方向,2-4句)
留意:(用顿号隔开 2-4 条)
复诊:(下一步)
SYS;
$user = sprintf(
"患者:%s,%s,%s岁。证型:%s。症状:%s。\n近7日血糖:%s。\n%s%s。",
$context['patient_name'] ?: '患者',
$context['gender_text'] ?? '',
$context['age'] ?? 0,
($context['syndrome_type'] ?? '') ?: '未登记',
mb_substr((string) ($context['symptoms'] ?? ''), 0, 80) ?: '无',
$bloodLine,
$stat7Line,
$stat30Line
);
if ($refresh) {
$user .= "\n\n换一换,重新写一套,编号" . substr(md5((string) microtime(true)), 0, 8) . '。';
}
return [$system, $user];
}
/**
* @param array<string,mixed> $context
*/
private static function bloodLine(array $context): string
{
$recent = $context['blood_recent'] ?? [];
if (!is_array($recent) || empty($recent)) {
return '近7日无逐日明细';
}
return implode('', $recent);
}
/**
* @param array<string,mixed> $stats
*/
private static function statsLine(array $stats): string
{
$label = (string) ($stats['label'] ?? '统计');
if (($stats['record_days'] ?? 0) <= 0) {
return $label . '无记录';
}
$parts = [
$label . "有记录{$stats['record_days']}",
"偏高{$stats['high_days']}",
"达标率{$stats['compliance']}%",
];
if ($stats['fasting_avg'] !== null) {
$parts[] = "空腹均值{$stats['fasting_avg']}";
}
if ($stats['postprandial_avg'] !== null) {
$parts[] = "餐后均值{$stats['postprandial_avg']}";
}
return implode('', $parts);
}
/**
* @return array<string,mixed>|null
*/
private static function parseCarePlainText(string $text): ?array
{
$text = trim($text);
if ($text === '') {
return null;
}
if ($text[0] === '{') {
$json = self::parseJsonObject($text);
return is_array($json) ? $json : null;
}
$map = [];
if (preg_match('/总评[:]\s*(.+)$/mu', $text, $m)) {
$map['summary'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
}
if (preg_match('/血糖[:]\s*(.+)$/mu', $text, $m)) {
$map['blood_advice'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
}
if (preg_match('/中医[:]\s*(.+)$/mu', $text, $m)) {
$map['tcm_plan'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
}
if (preg_match('/留意[:]\s*(.+)$/mu', $text, $m)) {
$line = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
$parts = preg_split('/[、,;\s]+/u', $line) ?: [];
$map['watch_points'] = array_values(array_filter(array_map('trim', $parts)));
}
if (preg_match('/复诊[:]\s*(.+)$/mu', $text, $m)) {
$map['next_steps'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
}
if (empty($map['summary'])) {
return null;
}
return $map;
}
/**
* @param array<string,mixed> $raw
* @return array<string,mixed>
*/
private static function finalizeCarePlan(array $raw): array
{
$watch = $raw['watch_points'] ?? [];
if (!is_array($watch)) {
$watch = preg_split('/[、,;\n]+/u', (string) $watch) ?: [];
}
$watch = array_values(array_filter(array_map('trim', $watch)));
return [
'summary' => trim((string) ($raw['summary'] ?? '')),
'blood_advice' => trim((string) ($raw['blood_advice'] ?? '')),
'tcm_plan' => trim((string) ($raw['tcm_plan'] ?? '')),
'watch_points' => $watch,
'next_steps' => trim((string) ($raw['next_steps'] ?? '')),
'control_level' => (string) ($raw['control_level'] ?? ''),
'control_label' => (string) ($raw['control_label'] ?? ''),
'disclaimer' => trim((string) ($raw['disclaimer'] ?? 'AI 建议供参考,不能替代医师面诊、开方与调药。')),
'source' => (string) ($raw['source'] ?? 'ai'),
];
}
/**
* @return array<string,mixed>|null
*/
private static function parseJsonObject(string $text): ?array
{
$text = trim($text);
if ($text === '') {
return null;
}
if (preg_match('/\{[\s\S]*\}/u', $text, $m)) {
$text = $m[0];
}
$data = json_decode($text, true);
return is_array($data) ? $data : null;
}
}
+37 -4
View File
@@ -499,8 +499,10 @@ class DailyDietAiLogic
3. 严禁:白馒头、油条、粘豆包、糯米饭、西瓜、含糖饮料、白稀饭(杂面窝头、玉米碴粥除外)。
4. GI 优先;农村家常;禁止轻食/沙拉/藜麦等词。
5. lunch 字段写法:蛋白菜放前面,淀粉(如有)放最后且注明「小半碗/一个」。
6. 只输出 JSON
{"breakfast":"...","lunch":"...","dinner":"...","tips":"...","avoid":["...","..."]}
6. 早餐必须包含「驼奶粉」(温水冲服,适量),可与粥、蛋、菜并列写。
7. drinks 单独写全天喝什么:温开水为主;可写驼奶粉冲饮时间;严禁含糖饮料、果汁、酒。
8. 只输出 JSON
{"breakfast":"...","drinks":"...","lunch":"...","dinner":"...","tips":"...","avoid":["...","..."]}
SYS;
$user = sprintf(
@@ -544,9 +546,11 @@ SYS;
2. 淀粉(粥////窝头//南瓜)每餐最多 1 种,早中晚互不相同。
3. 严禁白馒头、油条、粘豆包、糯米饭、西瓜、含糖饮料。
4. 农村家常饭,禁止轻食/沙拉/藜麦。
5. 早餐必须含驼奶粉(温水冲服);喝的单独一行。
严格按下面 5 行格式输出,不要 JSON、不要 markdown、不要多余解释:
早餐:(具体食物)
严格按下面 6 行格式输出,不要 JSON、不要 markdown、不要多余解释:
早餐:(具体食物,须含驼奶粉
喝的:(温开水、驼奶粉冲饮安排等,忌甜饮)
午餐:(具体食物,蛋白放前)
晚餐:(具体食物)
提示:(一句土话提醒)
@@ -593,6 +597,7 @@ SYS;
$map = [
'breakfast' => null,
'drinks' => null,
'lunch' => null,
'dinner' => null,
'tips' => null,
@@ -602,6 +607,9 @@ SYS;
if (preg_match('/早餐[:]\s*(.+)$/mu', $text, $m)) {
$map['breakfast'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
}
if (preg_match('/喝的[:]\s*(.+)$/mu', $text, $m)) {
$map['drinks'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
}
if (preg_match('/午餐[:]\s*(.+)$/mu', $text, $m)) {
$map['lunch'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
}
@@ -623,6 +631,7 @@ SYS;
$out = [
'breakfast' => (string) $map['breakfast'],
'drinks' => (string) ($map['drinks'] ?? ''),
'lunch' => (string) ($map['lunch'] ?? ''),
'dinner' => (string) ($map['dinner'] ?? ''),
'tips' => (string) ($map['tips'] ?? ''),
@@ -632,6 +641,29 @@ SYS;
return $out;
}
/**
* 产品要求:早餐含驼奶粉,并单独给出「喝的」
*
* @param array<string, mixed> $plan
* @return array<string, mixed>
*/
private static function applyDietProductDefaults(array $plan): array
{
$breakfast = trim((string) ($plan['breakfast'] ?? ''));
if ($breakfast !== '' && mb_strpos($breakfast, '驼奶粉') === false && mb_strpos($breakfast, '驼奶') === false) {
$plan['breakfast'] = '驼奶粉(温水冲服)、' . $breakfast;
}
$drinks = trim((string) ($plan['drinks'] ?? ''));
if ($drinks === '') {
$plan['drinks'] = '白天多喝温开水;早餐按量冲驼奶粉,别喝甜饮料、果汁。';
} elseif (mb_strpos($drinks, '驼奶') === false) {
$plan['drinks'] = $drinks . ';早餐已安排驼奶粉';
}
return $plan;
}
/**
* 规范化 AI 三餐方案;校验未过也保留 AI 文案(避免流式展示后被规则模板覆盖)
*
@@ -640,6 +672,7 @@ SYS;
*/
private static function finalizeAiRecommendPlan(array $parsed): array
{
$parsed = self::applyDietProductDefaults($parsed);
$checked = DietMealValidator::validateAndNormalize($parsed);
$data = $checked['plan'];
$data['source'] = 'ai';
@@ -4,6 +4,7 @@ namespace app\api\logic\tcm;
use app\common\model\tcm\DailyFamilyLike;
use app\common\model\tcm\DailyShareInvite;
use app\common\model\tcm\Diagnosis;
/**
* 家人点赞(邀请观看页 患者日常页展示)
@@ -204,6 +205,11 @@ class DailyFamilyLikeLogic
return self::setError('无权查看该诊单') ? false : false;
}
$diagnosis = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->field('show_card')->find();
if (!$diagnosis || (int) ($diagnosis['show_card'] ?? 1) !== 1) {
return self::setError('该就诊卡已在统计端隐藏') ? false : false;
}
$likeDate = date('Y-m-d');
$count = self::countToday($diagnosisId, $likeDate);
+42 -9
View File
@@ -16,9 +16,10 @@ class DailyGamifyLogic
/** @var array<string,array{name:string,points:int}> */
protected static array $taskDefs = [
'blood' => ['name' => '测血糖血压', 'points' => 10],
'diet' => ['name' => '记今日饮食', 'points' => 10],
'exercise' => ['name' => '记今日运动', 'points' => 10],
'glucose' => ['name' => '测血糖', 'points' => 10],
'bp' => ['name' => '测血压', 'points' => 10],
'diet' => ['name' => '饮食', 'points' => 10],
'exercise' => ['name' => '运动', 'points' => 10],
];
public static function getError(): string
@@ -48,6 +49,13 @@ class DailyGamifyLogic
if (!$owned) {
return self::setError('无权操作该诊单') ? false : false;
}
$diagnosis = \app\common\model\tcm\Diagnosis::where('id', $diagnosisId)
->where('delete_time', null)
->field('show_card')
->find();
if (!$diagnosis || (int) ($diagnosis['show_card'] ?? 1) !== 1) {
return self::setError('该就诊卡已在统计端隐藏') ? false : false;
}
return true;
}
@@ -83,12 +91,13 @@ class DailyGamifyLogic
->where('record_date', '<=', $end)
->find();
$bloodDone = false;
$glucoseDone = false;
$bpDone = false;
if ($blood) {
$bloodDone = self::hasValue($blood['fasting_blood_sugar'] ?? null)
$glucoseDone = self::hasValue($blood['fasting_blood_sugar'] ?? null)
|| self::hasValue($blood['postprandial_blood_sugar'] ?? null)
|| self::hasValue($blood['other_blood_sugar'] ?? null)
|| self::hasValue($blood['systolic_pressure'] ?? null)
|| self::hasValue($blood['other_blood_sugar'] ?? null);
$bpDone = self::hasValue($blood['systolic_pressure'] ?? null)
|| self::hasValue($blood['diastolic_pressure'] ?? null);
}
@@ -118,7 +127,8 @@ class DailyGamifyLogic
}
return [
'blood' => $bloodDone,
'glucose' => $glucoseDone,
'bp' => $bpDone,
'diet' => $dietDone,
'exercise' => $exerciseDone,
];
@@ -141,13 +151,36 @@ class DailyGamifyLogic
'name' => $def['name'],
'points' => $def['points'],
'completed' => !empty($completion[$id]),
'claimed' => !empty($awards[$id]),
'claimed' => self::isTaskClaimed($id, $awards, $completion),
];
}
return $list;
}
/**
* 任务是否已领取(兼容旧版 blood 合并任务)
*
* @param array<string,bool> $awards
* @param array<string,bool> $completion
*/
protected static function isTaskClaimed(string $id, array $awards, array $completion = []): bool
{
if (!empty($awards[$id])) {
return true;
}
// 旧版 blood 一次性领取:对应分项当日已有记录则视为已领,避免拆分后重复领奖/轮换引导
if (!empty($awards['blood'])) {
if ($id === 'glucose' && !empty($completion['glucose'])) {
return true;
}
if ($id === 'bp' && !empty($completion['bp'])) {
return true;
}
}
return false;
}
/** 与前端 tongji/utils/treeLevels.js 保持一致 */
protected const TREE_MAX_LEVEL = 9;
protected const TREE_XP_PER_LEVEL = 50;
+85 -15
View File
@@ -3,6 +3,8 @@
namespace app\api\logic\tcm;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\common\model\DiagnosisViewRecord;
use app\common\model\tcm\Diagnosis;
use app\common\model\user\User;
/**
@@ -74,19 +76,38 @@ class DailyPhoneLogic
if (!empty($cardList)) {
$card = $cardList[0];
return self::buildContextOk($card, $mobile, false, false);
}
// 该手机号已在诊单库建档:关联查看记录,禁止重复创建轻量档案
$existing = Diagnosis::where('phone', $mobile)
->where('delete_time', null)
->order('id', 'desc')
->find();
if ($existing && (int) ($existing['show_card'] ?? 1) !== 1) {
return [
'ok' => true,
'ok' => false,
'need_mobile' => false,
'created' => false,
'diagnosis_id' => (int) $card['id'],
'patient_id' => (int) $card['patient_id'],
'patient_name' => (string) ($card['patient_name'] ?? ''),
'gender' => (int) ($card['gender'] ?? 0),
'age' => (int) ($card['age'] ?? 0),
'mobile' => $mobile,
'create_source' => (string) ($card['create_source'] ?? ''),
'error' => '该手机号对应就诊卡因隐私设置已隐藏,请联系医生',
];
}
if ($existing) {
if (!self::ensureUserViewRecord($userId, (int) $existing['id'], (int) $existing['patient_id'])) {
return [
'ok' => false,
'need_mobile' => false,
'error' => '该手机号已建档,暂无法关联到当前账号',
];
}
return self::buildContextOk([
'id' => (int) $existing['id'],
'patient_id' => (int) $existing['patient_id'],
'patient_name' => (string) ($existing['patient_name'] ?? ''),
'gender' => (int) ($existing['gender'] ?? 0),
'age' => (int) ($existing['age'] ?? 0),
'create_source' => (string) ($existing['create_source'] ?? ''),
], $mobile, false, true);
}
$displayName = trim((string) (($user['real_name'] ?? '') ?: ($user['nickname'] ?? '')));
if ($displayName === '') {
@@ -114,17 +135,66 @@ class DailyPhoneLogic
];
}
return [
'ok' => true,
'need_mobile' => false,
'created' => true,
'diagnosis_id' => (int) $result['id'],
return self::buildContextOk([
'id' => (int) $result['id'],
'patient_id' => (int) $result['patient_id'],
'patient_name' => $displayName,
'gender' => $gender,
'age' => max(0, (int) ($user['age'] ?? 0)),
'mobile' => $mobile,
'create_source' => self::CREATE_SOURCE_MNP_DAILY,
], $mobile, true, false);
}
/**
* @param array{id:int,patient_id:int,patient_name?:string,gender?:int,age?:int,create_source?:string} $card
*/
private static function buildContextOk(array $card, string $mobile, bool $created, bool $linkedExisting): array
{
return [
'ok' => true,
'need_mobile' => false,
'created' => $created,
'linked_existing' => $linkedExisting,
'diagnosis_id' => (int) $card['id'],
'patient_id' => (int) $card['patient_id'],
'patient_name' => (string) ($card['patient_name'] ?? ''),
'gender' => (int) ($card['gender'] ?? 0),
'age' => (int) ($card['age'] ?? 0),
'mobile' => $mobile,
'create_source' => (string) ($card['create_source'] ?? ''),
];
}
/** 将已有诊单挂到当前小程序用户(diagnosis_view_records */
private static function ensureUserViewRecord(int $userId, int $diagnosisId, int $patientId): bool
{
if ($userId <= 0 || $diagnosisId <= 0) {
return false;
}
try {
$exists = DiagnosisViewRecord::where('user_id', $userId)
->where('diagnosis_id', $diagnosisId)
->where('delete_time', null)
->find();
if ($exists) {
return true;
}
$now = time();
DiagnosisViewRecord::create([
'user_id' => $userId,
'diagnosis_id' => $diagnosisId,
'patient_id' => $patientId,
'share_user_id' => 0,
'view_count' => 1,
'first_view_time' => $now,
'last_view_time' => $now,
'is_confirmed' => 0,
'create_time' => $now,
'update_time' => $now,
]);
return true;
} catch (\Throwable $e) {
return false;
}
}
}
@@ -50,6 +50,9 @@ class DailyShareLogic
if (!$diagnosis) {
return self::setError('诊单不存在') ? false : false;
}
if ((int) ($diagnosis['show_card'] ?? 1) !== 1) {
return self::setError('该就诊卡已在统计端隐藏') ? false : false;
}
$inviteDate = date('Y-m-d');
$code = self::generateUniqueCode();
@@ -114,6 +114,7 @@ class DietMealValidator
$normalized = [
'breakfast' => $breakfast,
'drinks' => trim((string) ($plan['drinks'] ?? '')),
'lunch' => $lunch,
'dinner' => $dinner,
'tips' => $tips,
+19 -9
View File
@@ -18,7 +18,7 @@ class GiKnowledge
'蔬菜类' => ['魔芋', '粟米', '大白菜', '黄瓜', '芹菜', '茄子', '青椒', '海带', '金针菇', '香菇', '菠菜', '番茄', '豆芽', '芦笋', '花椰菜', '洋葱', '生菜'],
'豆类' => ['黄豆', '眉豆', '鸡心豆', '豆腐', '豆角', '绿豆', '扁豆', '四季豆'],
'水果类' => ['苹果', '水梨', '橙子', '桃', '提子', '沙田柚', '雪梨', '车厘子', '柚子', '草莓', '樱桃', '金桔', '葡萄'],
'奶类' => ['牛奶', '低脂奶', '脱脂奶', '低脂乳酪', '红茶', '优格', '无糖豆浆'],
'奶类' => ['牛奶', '低脂奶', '脱脂奶', '驼奶粉', '驼奶', '低脂乳酪', '红茶', '优格', '无糖豆浆'],
],
self::LEVEL_MEDIUM => [
'主食类' => ['红米饭', '糙米饭', '西米', '乌冬面', '面包', '麦片', '番薯', '芋头'],
@@ -36,40 +36,46 @@ class GiKnowledge
],
];
/** @var list<array{breakfast:string,lunch:string,dinner:string,tips:string}> */
/** @var list<array{breakfast:string,drinks:string,lunch:string,dinner:string,tips:string}> */
private static array $mealTemplates = [
[
'breakfast' => '玉米碴子粥、煮鸡蛋、拌黄瓜',
'breakfast' => '驼奶粉(温水冲服)、玉米碴子粥、煮鸡蛋、拌黄瓜',
'drinks' => '白天多喝温开水;早餐冲驼奶粉,别喝甜饮料、果汁。',
'lunch' => '清炖鲤鱼、豆腐炖白菜、清炒豆角、米饭小半碗',
'dinner' => '玉米面饼一个、瘦肉丝、凉拌茄子',
'tips' => '中午鱼豆肉吃足;早中晚淀粉别重复,一顿一种主食就够。',
],
[
'breakfast' => '小米粥、煮鸡蛋、小碟咸菜(少盐)',
'breakfast' => '驼奶粉(温水冲服)、小米粥、煮鸡蛋、小碟咸菜(少盐)',
'drinks' => '温开水为主;驼奶粉跟早餐一起吃,下午也记得喝水。',
'lunch' => '去皮炖鸡肉、芹菜炒豆干、木耳炒鸡蛋',
'dinner' => '蒸红薯(小半块)、清炒菠菜、豆腐汤',
'tips' => '午餐最讲究蛋白;晚上才吃薯,别跟中午再配大米饭。',
],
[
'breakfast' => '豆腐脑(少卤)、鸡蛋、一个苹果',
'breakfast' => '驼奶粉(温水冲服)、豆腐脑(少卤)、鸡蛋',
'drinks' => '早餐饮驼奶粉;白天喝白开水,别碰含糖饮料。',
'lunch' => '清炖鸡肉、炖芸豆、番茄炒蛋、贴饼子一个',
'dinner' => '挂面一小碗、清蒸小鱼、拌海带丝',
'tips' => '中午饼配肉豆,蛋白管够;面条放晚上,别三顿都是粥饭。',
],
[
'breakfast' => '高粱米粥、茶叶蛋、拌萝卜丝',
'breakfast' => '驼奶粉(温水冲服)、高粱米粥、茶叶蛋、拌萝卜丝',
'drinks' => '温开水;早餐驼奶粉按说明冲,别加糖。',
'lunch' => '炖牛肉(瘦)、豆腐炒韭菜、清炒油菜、杂豆饭小半碗',
'dinner' => '蒸南瓜(小半碗)、鸡蛋羹、拍黄瓜',
'tips' => '中午肉蛋豆要多吃;南瓜算晚饭主食,别再加馒头稀饭。',
],
[
'breakfast' => '棒子面粥、水煮蛋、大拌菜(少油)',
'breakfast' => '驼奶粉(温水冲服)、棒子面粥、水煮蛋、大拌菜(少油)',
'drinks' => '多喝温开水;驼奶粉放早餐,忌可乐汽水果汁。',
'lunch' => '清炖鲫鱼、番茄炒蛋、炖扁豆、糙米饭小半碗',
'dinner' => '玉米面饼一个、清炒豆芽、豆腐脑',
'tips' => '午饭鱼蛋豆齐上;玉米面放晚上,跟中午米饭分开。',
],
[
'breakfast' => '小米粥、煮鸡蛋、拌生菜',
'breakfast' => '驼奶粉(温水冲服)、小米粥、煮鸡蛋、拌生菜',
'drinks' => '白天小口多次喝温水;早餐冲好驼奶粉再吃饭。',
'lunch' => '白切鸡(去皮)、炖豆腐、清炒豆角、二米饭小半碗',
'dinner' => '杂面馒头一个、瘦肉炒芹菜、凉拌黄瓜',
'tips' => '中午蛋白打主力;三顿别都喝粥吃面,一顿一种淀粉。',
@@ -92,7 +98,8 @@ class GiKnowledge
private static array $aliases = [
'棒子面' => '荞麦面', '玉米碴' => '粟米', '地瓜' => '番薯', '红薯' => '番薯',
'洋芋' => '芋头', '土豆' => '芋头', '窝头' => '通心粉', '贴饼子' => '通心粉',
'二米饭' => '红米饭', '稀饭' => '白饭', '白稀饭' => '白饭', '糖糕' => '麦芽糖',
'二米饭' => '红米饭', '稀饭' => '白饭', '白稀饭' => '白饭', '糖糕' => '麦芽糖',
'驼奶' => '驼奶粉',
];
/**
@@ -132,6 +139,8 @@ class GiKnowledge
public static function ruralMealHints(): string
{
return implode("\n", [
'【早餐】须安排驼奶粉温水冲服(低 GI 奶类,稳血糖),可与粥、蛋、菜同餐。',
'【喝的】单独说明:温开水为主;早餐驼奶粉;严禁含糖饮料、果汁、酒。',
'【午餐】蛋白质要最高:至少 2 样高蛋白(鱼/鸡鸭/瘦肉/蛋/豆腐/豆干),肉蛋豆是午饭主角。',
'【淀粉不重复】粥/饭/面/饼/窝头/红薯/土豆/玉米/南瓜等算淀粉;早中晚各最多 1 种,且三顿不能同一种、不要顿顿大主食。',
'推荐蛋白:鸡蛋、豆腐、豆干、自家鸡鸭蛋、瘦肉、鲫鱼鲤鱼;',
@@ -177,6 +186,7 @@ class GiKnowledge
$plan = [
'breakfast' => $meal['breakfast'],
'drinks' => $meal['drinks'] ?? '白天多喝温开水;早餐冲驼奶粉,别喝甜饮。',
'lunch' => $meal['lunch'],
'dinner' => $meal['dinner'],
'tips' => $meal['tips'],
+4 -3
View File
@@ -15,7 +15,7 @@ use think\console\Output;
* 使用方法:
* php think express:auto-update
*
* 配置 crontab(每 10 分钟):拉快递 100 + 按履约「已发货/已签收」核对释放诊单医助(与是否甘草单无关)
* 配置 crontab(每 10 分钟):拉快递 100 + 按履约「已发货」核对释放诊单医助(跳过已完成/已签收业务单,与是否甘草单无关)
* 0,10,20,30,40,50 * * * * cd /path/to/server && php think express:auto-update >> /dev/null 2>&1
*
* 已对「业务订单创建人非二中心」(或创建人为开方医生时按待释放身份判定)执行发货/签收自动释放的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at
@@ -38,7 +38,8 @@ class ExpressAutoUpdate extends Command
try {
$result = ExpressTrackingService::autoUpdateBatch(1000);
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(1000);
$duration = round(microtime(true) - $startTime, 2);
$output->writeln("更新完成!");
@@ -46,7 +47,7 @@ class ExpressAutoUpdate extends Command
$output->writeln("成功: {$result['success']}");
$output->writeln("失败: {$result['failed']}");
$output->writeln("医助已移除(物流任务+指派日志): " . (int) ($result['assistant_cleared'] ?? 0));
$output->writeln("医助已移除(履约已发货/签收核对): " . (int) ($recon['cleared'] ?? 0) . " (扫描 " . (int) ($recon['scanned'] ?? 0) . " 单)");
$output->writeln("医助已移除(履约已发货核对): " . (int) ($recon['cleared'] ?? 0) . " (扫描 " . (int) ($recon['scanned'] ?? 0) . " 单)");
$lines = array_merge($result['assistant_lines'] ?? [], $recon['lines'] ?? []);
if ($lines === []) {
$output->writeln('医助明细: (无)');
+73 -14
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\command;
use app\common\model\tcm\PrescriptionOrder;
use app\common\service\ExpressTrackingService;
use app\common\service\gancao\GancaoLogisticsRouteService;
use app\common\service\gancao\GancaoScmRecipelService;
@@ -18,18 +19,21 @@ use think\console\Output;
* 数据流:
* zyt_tcm_prescription_order (甘草已上传)
* 调用 igc_scm.logistics.client_opt.pull / GET_TASK_ROUTE_LIST
* 甘草报快递任务不存在等(如 10101)且业务单已有运单号 降级快递100
* zyt_express_tracking + zyt_express_trace + zyt_express_state_log + zyt_express_query_log
*
* 使用方法:
* php think gancao:sync-logistics 默认拉 100
* php think gancao:sync-logistics --limit=200 自定义拉取上限
* php think gancao:sync-logistics --order-id=123 只跑指定订单
* php think gancao:sync-logistics 默认拉 2000 (跳过已完成/已签收)
* php think gancao:sync-logistics --limit=500 自定义拉取上限
* php think gancao:sync-logistics --order-id=1424 只跑指定订单(数字 id
* php think gancao:sync-logistics --order-id=PO20260530165158645023 PO 业务单号
* php think gancao:sync-logistics -t SF1234567890 按快递单号走快递100 查询并落库
* php think gancao:sync-logistics --detail 打印每单详细
*
* 建议 crontab(每 30 分钟执行一次):
* 0,30 * * * * cd /path/to/server && php think gancao:sync-logistics --limit=200 >> runtime/log/gancao_sync.log 2>&1
* 0,30 * * * * cd /path/to/server && php think gancao:sync-logistics >> runtime/log/gancao_sync.log 2>&1
*
* 跳过履约状态为已完成(3)、已签收(6) 的业务订单,不再拉取甘草路由。
* 已对「业务订单创建人非二中心」(发货/签收自动释放规则见 ExpressTrackingService)的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at
* 后续履约核对不再重复扫描(需先执行 sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql)。
*/
@@ -39,8 +43,8 @@ class GancaoSyncLogisticsRoute extends Command
{
$this->setName('gancao:sync-logistics')
->setDescription('同步甘草订单的物流路由(GET_TASK_ROUTE_LIST)到本地物流追踪表')
->addOption('limit', 'l', Option::VALUE_OPTIONAL, '本次最多处理多少条订单', 100)
->addOption('order-id', null, Option::VALUE_OPTIONAL, '只同步指定 prescription_order.id', null)
->addOption('limit', 'l', Option::VALUE_OPTIONAL, '本次最多处理多少条订单(跳过已完成/已签收)', 2000)
->addOption('order-id', null, Option::VALUE_OPTIONAL, '只同步指定订单:prescription_order.id 或 PO 业务单号 order_no', null)
->addOption('tracking-number', 't', Option::VALUE_REQUIRED, '按快递单号走快递100 查询并落库')
->addOption('detail', 'd', Option::VALUE_NONE, '打印每单详细结果');
}
@@ -48,8 +52,19 @@ class GancaoSyncLogisticsRoute extends Command
protected function execute(Input $input, Output $output)
{
$limit = max(1, (int) $input->getOption('limit'));
$onlyOrderId = $input->getOption('order-id');
$onlyOrderId = $onlyOrderId !== null ? (int) $onlyOrderId : null;
$onlyOrderId = null;
$onlyOrderNo = '';
$orderIdArg = $input->getOption('order-id');
if ($orderIdArg !== null && trim((string) $orderIdArg) !== '') {
$resolved = self::resolvePrescriptionOrderId((string) $orderIdArg);
if ($resolved === null) {
$output->error('未找到订单:' . trim((string) $orderIdArg) . '--order-id 支持数字 id 或 PO 业务单号)');
return 1;
}
$onlyOrderId = $resolved['id'];
$onlyOrderNo = $resolved['order_no'];
}
$trackingNumber = trim((string) $input->getOption('tracking-number'));
$verbose = (bool) $input->getOption('detail');
@@ -71,7 +86,11 @@ class GancaoSyncLogisticsRoute extends Command
if ($trackingNumber !== '') {
$output->writeln('开始查询...(快递100tracking_number=' . $trackingNumber . '');
} else {
$output->writeln('开始同步...limit=' . $limit . ($onlyOrderId ? ', order_id=' . $onlyOrderId : '') . '');
if ($onlyOrderId !== null) {
$output->writeln('开始同步...(指定单 order_id=' . $onlyOrderId . ($onlyOrderNo !== '' ? ', order_no=' . $onlyOrderNo : '') . '');
} else {
$output->writeln('开始同步...limit=' . $limit . '');
}
}
try {
@@ -82,10 +101,16 @@ class GancaoSyncLogisticsRoute extends Command
$stats['reconcile_lines'] = [];
} else {
$stats = GancaoLogisticsRouteService::syncBatch($limit, $onlyOrderId);
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(2000);
$stats['reconcile_cleared'] = (int) ($recon['cleared'] ?? 0);
$stats['reconcile_scanned'] = (int) ($recon['scanned'] ?? 0);
$stats['reconcile_lines'] = $recon['lines'] ?? [];
if ($onlyOrderId !== null) {
$stats['reconcile_cleared'] = 0;
$stats['reconcile_scanned'] = 0;
$stats['reconcile_lines'] = [];
} else {
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(2000);
$stats['reconcile_cleared'] = (int) ($recon['cleared'] ?? 0);
$stats['reconcile_scanned'] = (int) ($recon['scanned'] ?? 0);
$stats['reconcile_lines'] = $recon['lines'] ?? [];
}
}
} catch (\Throwable $e) {
$output->error('同步异常:' . $e->getMessage());
@@ -120,10 +145,13 @@ class GancaoSyncLogisticsRoute extends Command
$output->writeln('同步完成');
$output->writeln('========================================');
$output->writeln('总数:' . $stats['total']);
if (isset($stats['skipped'])) {
$output->writeln('跳过:' . (int) $stats['skipped'] . '(已完成/已签收)');
}
$output->writeln('成功:' . $stats['success']);
$output->writeln('失败:' . $stats['failed']);
$output->writeln('医助已移除(甘草同步+指派日志):' . (int) ($stats['assistant_cleared'] ?? 0));
$output->writeln('医助已移除(履约已发货/签收核对):' . (int) ($stats['reconcile_cleared'] ?? 0) . '(扫描 ' . (int) ($stats['reconcile_scanned'] ?? 0) . ' 单)');
$output->writeln('医助已移除(履约已发货核对):' . (int) ($stats['reconcile_cleared'] ?? 0) . '(扫描 ' . (int) ($stats['reconcile_scanned'] ?? 0) . ' 单)');
$lines = array_merge($stats['assistant_lines'] ?? [], $stats['reconcile_lines'] ?? []);
if ($lines === []) {
$output->writeln('医助明细:(无)');
@@ -137,4 +165,35 @@ class GancaoSyncLogisticsRoute extends Command
return 0;
}
/**
* @return array{id:int, order_no:string}|null
*/
private static function resolvePrescriptionOrderId(string $raw): ?array
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
$q = PrescriptionOrder::whereNull('delete_time');
if (preg_match('/^\d+$/', $raw) === 1) {
$id = (int) $raw;
if ($id <= 0) {
return null;
}
$row = (clone $q)->where('id', $id)->field('id,order_no')->find();
} else {
$row = (clone $q)->where('order_no', $raw)->field('id,order_no')->find();
}
if (!$row) {
return null;
}
return [
'id' => (int) $row->id,
'order_no' => (string) $row->order_no,
];
}
}
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\model\tcm\PrescriptionOrder;
use app\common\service\ExpressTrackingService;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
/**
* 批量回填处方业务订单「签收时间」到物流库(express_tracking / express_trace)。
*
* 背景:导出列表的「签收日期」来自库表(ExpressTrackingService::batchResolveSignUnixFromDbForOrders,无 HTTP)。
* 终态订单(已完成3/已签收6)被 express:auto-update 排除、或从未创建过物流追踪记录时,库内就没有签收时间,
* 导出便显示为空。本命令对这些订单逐单走一次快递100 并落库(queryAndUpdate 会写入 sign_time + 轨迹),
* 之后导出直接读库即可,既快又不空。
*
* 使用方法:
* php think tcm:backfill-sign-time --dry-run
* php think tcm:backfill-sign-time --limit=300 --sleep-ms=200
* php think tcm:backfill-sign-time --fulfillment-status=3,5,6 --days=90
* php think tcm:backfill-sign-time --order-id=474
* php think tcm:backfill-sign-time --force # 库内已有签收时间也重查
* php think tcm:backfill-sign-time --include-gancao # 同时处理甘草托管单(默认跳过,由甘草侧拉取)
*
* 履约状态代码(fulfillment_status):1待双审通过 2待发货 3已完成 4已取消 5已发货 6已签收
* 7进行中 8暂不制药 9拒收 10退款 11保留药方 12制药缓发
*
* 建议配 crontab(如每小时一次,限量推进历史欠数据):
* 0 * * * * cd /path/to/server && php think tcm:backfill-sign-time --limit=300 >> /dev/null 2>&1
*/
class TcmBackfillPrescriptionOrderSignTime extends Command
{
/**
* 默认不按履约状态过滤:只要订单有运单号(tracking_number)即视为已发货,纳入回填。
* fulfillment_status 过滤会漏掉「物流已送达、但业务状态滞后(如进行中)」的单子。
* 仍可用 --fulfillment-status 显式收窄。
*/
private const DEFAULT_FULFILLMENT_STATUSES = [];
protected function configure(): void
{
$this->setName('tcm:backfill-sign-time')
->setDescription('批量回填处方业务订单签收时间到物流库(导出读库即可,不再逐单 HTTP)')
->addOption('dry-run', null, Option::VALUE_NONE, '只列出待处理订单,不发起查询/不落库')
->addOption('limit', 'l', Option::VALUE_OPTIONAL, '本次最多补查(HTTP)订单数', 300)
->addOption('order-id', null, Option::VALUE_OPTIONAL, '仅处理指定 prescription_order.id', null)
->addOption('min-id', null, Option::VALUE_OPTIONAL, '仅 id>=该值', null)
->addOption('max-id', null, Option::VALUE_OPTIONAL, '仅 id<=该值', null)
->addOption('days', null, Option::VALUE_OPTIONAL, '仅处理近 N 天创建的订单(0=不限)', 0)
->addOption(
'fulfillment-status',
null,
Option::VALUE_OPTIONAL,
'仅处理指定履约状态,逗号分隔数字(如 3,5,6);默认不限(有运单号即处理)',
null
)
->addOption('repair-db', null, Option::VALUE_NONE, '仅按库内轨迹重算并修复 sign_time 列(零 HTTP,修复历史「拉取当天」污染)')
->addOption('include-gancao', null, Option::VALUE_NONE, '同时处理甘草托管单(默认跳过)')
->addOption('force', 'f', Option::VALUE_NONE, '库内已有签收时间也重新查询落库')
->addOption('sleep-ms', null, Option::VALUE_OPTIONAL, '每次 HTTP 查询后休眠毫秒,减轻快递100 压力', 200)
->addOption('detail', 'd', Option::VALUE_NONE, '打印每条明细');
}
/**
* @return int[]|null 解析失败(传了非法值)返回 null;未传返回默认集
*/
private static function parseFulfillmentStatusOption(?string $raw): ?array
{
if ($raw === null || trim($raw) === '') {
return self::DEFAULT_FULFILLMENT_STATUSES;
}
$seen = [];
foreach (explode(',', $raw) as $part) {
$n = (int) trim($part);
if ($n >= 1 && $n <= 12) {
$seen[$n] = true;
}
}
$ids = array_keys($seen);
sort($ids);
return $ids !== [] ? $ids : null;
}
protected function execute(Input $input, Output $output): int
{
$dryRun = (bool) $input->getOption('dry-run');
$verbose = (bool) $input->getOption('detail');
$force = (bool) $input->getOption('force');
$includeGancao = (bool) $input->getOption('include-gancao');
$limit = max(1, (int) $input->getOption('limit'));
$sleepMs = max(0, (int) $input->getOption('sleep-ms'));
$days = max(0, (int) $input->getOption('days'));
$onlyOrderId = $input->getOption('order-id');
$onlyOrderId = $onlyOrderId !== null && $onlyOrderId !== '' ? (int) $onlyOrderId : null;
$minId = $input->getOption('min-id');
$minId = $minId !== null && $minId !== '' ? (int) $minId : null;
$maxId = $input->getOption('max-id');
$maxId = $maxId !== null && $maxId !== '' ? (int) $maxId : null;
$fsRaw = $input->getOption('fulfillment-status');
$fulfillmentStatuses = self::parseFulfillmentStatusOption($fsRaw !== null ? (string) $fsRaw : null);
if ($fulfillmentStatuses === null) {
$output->error('--fulfillment-status 格式无效,请传入 1–12 的数字,英文逗号分隔,例如:3,5,6');
return 1;
}
$output->writeln('========================================');
$output->writeln('批量回填业务订单签收时间(快递100 落库)');
$output->writeln('========================================');
// 纯库内修复模式:用现有轨迹重算签收时间,覆盖被写成「同步当天」的 sign_time 列(零 HTTP
if ((bool) $input->getOption('repair-db')) {
$output->writeln('模式:仅修复 sign_time 列(按库内轨迹重算,不查快递100)...');
$stat = ExpressTrackingService::repairSignTimeFromDbTraces($dryRun, $dryRun ? 0 : $limit);
$output->writeln('');
$output->writeln('========================================');
$output->writeln(sprintf(
'修复完成:扫描已签收运单 %d,修正 sign_time %d,无需修正 %ddry-run=%s',
$stat['scanned'],
$stat['updated'],
$stat['skipped'],
$dryRun ? 'yes' : 'no'
));
$output->writeln('========================================');
return 0;
}
$q = PrescriptionOrder::whereNull('delete_time')
->whereRaw("TRIM(COALESCE(`tracking_number`, '')) <> ''")
->order('id', 'desc');
if ($onlyOrderId !== null && $onlyOrderId > 0) {
$q->where('id', $onlyOrderId);
} else {
// 空集 = 不按履约状态过滤(默认):有运单号即纳入,避免漏掉业务状态滞后的已送达单
if ($fulfillmentStatuses !== []) {
$q->whereIn('fulfillment_status', $fulfillmentStatuses);
}
if (!$includeGancao) {
$q->whereRaw("TRIM(COALESCE(`gancao_reciperl_order_no`, '')) = ''");
}
if ($minId !== null && $minId > 0) {
$q->where('id', '>=', $minId);
}
if ($maxId !== null && $maxId > 0) {
$q->where('id', '<=', $maxId);
}
if ($days > 0) {
$q->where('create_time', '>=', time() - $days * 86400);
}
// 多取一些候选,DB 已可解析的会被跳过,确保「需要补查」的能凑够 limit
$q->limit($limit * 5);
}
$rows = $q->field(['id', 'order_no', 'tracking_number', 'fulfillment_status', 'create_time'])
->select()
->toArray();
if ($rows === []) {
$output->warning('没有符合条件的订单。');
return 0;
}
// 先批量从库表解析,跳过已有签收时间的(除非 --force),避免无谓 HTTP
$dbSignByPo = [];
if (!$force) {
$signItems = [];
foreach ($rows as $r) {
$signItems[] = [
'order_id' => (int) ($r['id'] ?? 0),
'tracking_number' => trim((string) ($r['tracking_number'] ?? '')),
];
}
try {
$dbSignByPo = ExpressTrackingService::batchResolveSignUnixFromDbForOrders($signItems);
} catch (\Throwable $e) {
$output->warning('批量库表解析失败,将逐单补查:' . $e->getMessage());
$dbSignByPo = [];
}
}
$candidates = [];
$alreadyInDb = 0;
foreach ($rows as $r) {
$id = (int) ($r['id'] ?? 0);
if ($id <= 0) {
continue;
}
if (!$force && (int) ($dbSignByPo[$id] ?? 0) > 0) {
$alreadyInDb++;
continue;
}
$candidates[] = $r;
if (count($candidates) >= $limit) {
break;
}
}
$output->writeln(sprintf(
'候选 %d 条(库内已有签收 %d 条已跳过);本次最多补查 %d 条(dry-run=%s force=%s sleep-ms=%d',
count($rows),
$alreadyInDb,
$limit,
$dryRun ? 'yes' : 'no',
$force ? 'yes' : 'no',
$sleepMs
));
if ($candidates === []) {
$output->writeln('无需补查:候选订单签收时间均已在库。');
return 0;
}
$resolved = 0; // 查到并落库了签收时间
$empty = 0; // 查询成功但暂无签收(在途等)
$failed = 0;
$totalToDo = count($candidates);
// 每单一次快递100 HTTP + sleep,串行较慢;定期打印进度,避免看起来卡住。
$progressEvery = max(1, (int) min(20, max(5, (int) ceil($totalToDo / 20))));
if (!$dryRun) {
$output->writeln(sprintf(
'开始逐单补查(共 %d 单,每单一次快递100 查询 + %dms 间隔,请耐心等待,每 %d 单报告一次进度)...',
$totalToDo,
$sleepMs,
$progressEvery
));
}
$startTs = microtime(true);
$done = 0;
foreach ($candidates as $r) {
$id = (int) ($r['id'] ?? 0);
$tn = trim((string) ($r['tracking_number'] ?? ''));
if ($dryRun) {
if ($verbose) {
$output->writeln(sprintf('#%d %s 运单=%s dry-run,跳过查询)', $id, (string) ($r['order_no'] ?? ''), $tn));
}
continue;
}
try {
$signTs = ExpressTrackingService::syncSignUnixFromLogisticsForPrescriptionOrder($id, true);
if ($signTs > 0) {
$resolved++;
if ($verbose) {
$output->writeln(sprintf('#%d 运单=%s → 签收 %s', $id, $tn, date('Y-m-d H:i:s', $signTs)));
}
} else {
$empty++;
if ($verbose) {
$output->writeln(sprintf('#%d 运单=%s → 暂无签收(在途/未匹配)', $id, $tn));
}
}
} catch (\Throwable $e) {
$failed++;
if ($verbose) {
$output->writeln("<error>#{$id} 运单={$tn} 失败:{$e->getMessage()}</error>");
}
}
$done++;
// 非明细模式也给出心跳进度(明细模式已逐单打印,无需重复)
if (!$verbose && ($done % $progressEvery === 0 || $done === $totalToDo)) {
$elapsed = max(0.001, microtime(true) - $startTs);
$rate = $done / $elapsed;
$eta = $rate > 0 ? (int) round(($totalToDo - $done) / $rate) : 0;
$output->writeln(sprintf(
' 进度 %d/%d(落库 %d,暂无 %d,失败 %d)已用 %ds,预计剩余 %ds',
$done,
$totalToDo,
$resolved,
$empty,
$failed,
(int) round($elapsed),
$eta
));
}
if ($sleepMs > 0) {
usleep($sleepMs * 1000);
}
}
$output->writeln('');
$output->writeln('========================================');
$output->writeln(sprintf('完成:落库签收 %d,暂无签收 %d,失败 %d', $resolved, $empty, $failed));
$output->writeln('========================================');
return $failed > 0 ? 2 : 0;
}
}
@@ -63,29 +63,95 @@ class ExpressTrackService
$cfg = Config::get('logistics.kuaidi100', []);
$enable = !empty($cfg['enable']);
$result = $out;
if (! $enable) {
$out['hint'] = '未配置快递100查询密钥或已关闭(LOGISTICS_KUAIDI100_DISABLE),仅可打开官网查件。请在 .env 中配置 LOGISTICS_KUAIDI100_CUSTOMER、LOGISTICS_KUAIDI100_KEY';
return $out;
}
// 顺丰(及快递100 要求电话的承运商)无 phone 时不请求接口,避免无效调用
if ($phoneForKuaidi === '' && self::kuaidiPhoneRequired($kuaidiCom)) {
$out['hint'] = '顺丰查询需在快递100 中同时提交单号与收/寄件人电话(可与面单一致的完整手机号或后四位)。请填写收件电话后点「刷新轨迹」。';
return $out;
}
$lastFail = null;
foreach ($comCandidates as $tryCom) {
$tryOut = self::queryKuaidiOnce($cfg, $tryCom, $num, $phoneForKuaidi, $carrier, $label);
if (!empty($tryOut['traces']) || ($tryOut['state'] ?? '') !== '') {
return $tryOut;
$result['hint'] = '未配置快递100查询密钥或已关闭(LOGISTICS_KUAIDI100_DISABLE),仅可打开官网查件。请在 .env 中配置 LOGISTICS_KUAIDI100_CUSTOMER、LOGISTICS_KUAIDI100_KEY';
} elseif ($phoneForKuaidi === '' && self::kuaidiPhoneRequired($kuaidiCom)) {
// 顺丰(及快递100 要求电话的承运商)无 phone 时不请求接口,避免无效调用
$result['hint'] = '顺丰查询需在快递100 中同时提交单号与收/寄件人电话(可与面单一致的完整手机号或后四位)。请填写收件电话后点「刷新轨迹」。';
} else {
$matched = null;
$lastFail = null;
foreach ($comCandidates as $tryCom) {
$tryOut = self::queryKuaidiOnce($cfg, $tryCom, $num, $phoneForKuaidi, $carrier, $label);
if (!empty($tryOut['traces']) || ($tryOut['state'] ?? '') !== '') {
$matched = $tryOut;
break;
}
$lastFail = $tryOut;
}
$lastFail = $tryOut;
$result = $matched ?? $lastFail ?? $out;
}
return $lastFail ?? $out;
// 京东自营单(JDVE…)兜底:快递100 无轨迹/陈旧时,用京东官方接口(更新或更全才采用)。
// 未配置京东官方接口时 isConfigured()=false,本段跳过,行为与原先一致。
if ($carrier === 'jd' && JdLogisticsService::isConfigured()) {
try {
$jdPhone = $overrideDigits !== '' ? $overrideDigits : $recipientDigits;
$jd = JdLogisticsService::queryTrace($num, $jdPhone);
if ($jd !== null && !empty($jd['traces']) && self::jdResultPreferred($jd, $result)) {
$result['traces'] = $jd['traces'];
$result['state'] = (string) $jd['state'];
$result['state_text'] = (string) $jd['state_text'];
$result['source'] = 'jd_official';
$result['hint'] = '';
// carrier / carrier_label / official_url / kuaidi_com 保留原值
}
} catch (\Throwable $e) {
Log::warning('ExpressTrackService jd official fallback failed', [
'num' => $num,
'error' => $e->getMessage(),
]);
}
}
return $result;
}
/**
* 京东官方轨迹是否应优先于快递100 结果采用:
* 快递100 无轨迹 直接用;否则京东更「新」(最新轨迹时间更晚)或同样新但条目更多 用。
*
* @param array{traces?:array,newest_unix?:int} $jd
* @param array{traces?:array} $kuaidi
*/
private static function jdResultPreferred(array $jd, array $kuaidi): bool
{
$kuaidiTraces = is_array($kuaidi['traces'] ?? null) ? $kuaidi['traces'] : [];
if ($kuaidiTraces === []) {
return true;
}
$jdNewest = (int) ($jd['newest_unix'] ?? 0);
$kuaidiNewest = self::newestUnixFromTraces($kuaidiTraces);
if ($jdNewest > $kuaidiNewest) {
return true;
}
if ($jdNewest === $kuaidiNewest && $jdNewest > 0) {
return count($jd['traces'] ?? []) > count($kuaidiTraces);
}
return false;
}
/**
* @param array<int, array{time?:string}> $traces
*/
private static function newestUnixFromTraces(array $traces): int
{
$best = 0;
foreach ($traces as $t) {
if (!is_array($t)) {
continue;
}
$p = strtotime((string) ($t['time'] ?? ''));
if ($p !== false && (int) $p > $best) {
$best = (int) $p;
}
}
return $best;
}
/**
@@ -162,6 +162,76 @@ class ExpressTrackingService
return $stats;
}
/**
* 甘草路由不可用时的降级:按业务订单运单号走快递100 查询并落库(自发货等场景)
*
* @return array{
* success: bool,
* message: string,
* traces_count: int,
* state: string,
* source: string,
* assistant_sync?: array|null
* }
*/
public static function queryKuaidiForPrescriptionOrder(PrescriptionOrder $order, bool $isAuto = true): array
{
$fail = static fn (string $msg): array => [
'success' => false,
'message' => $msg,
'traces_count' => 0,
'state' => '',
'source' => 'kuaidi100_fallback',
'assistant_sync' => null,
];
$tn = trim((string) ($order->tracking_number ?? ''));
if ($tn === '') {
return $fail('甘草无快递任务且业务单未填写快递单号,无法走快递100');
}
$tracking = self::resolveTrackingByNumber($tn);
if (!$tracking) {
return $fail('未找到运单对应的物流追踪记录');
}
self::backfillRecipientPhoneFromPrescriptionOrder($tracking);
self::syncTrackingExpressCompanyFromNumber($tracking);
try {
$result = self::queryAndUpdate((int) $tracking->id, $isAuto);
$ok = self::isKuaidiQuerySuccessful($result);
$tracking = ExpressTracking::where('id', (int) $tracking->id)->whereNull('delete_time')->find();
$state = $tracking ? (string) ($tracking->current_state ?? '') : '';
$hint = trim((string) ($result['hint'] ?? ''));
$message = $ok
? 'ok(甘草无快递任务,已降级快递100)'
: ($hint !== '' ? $hint : '快递100 查询未返回有效轨迹');
$assistantSync = null;
if (is_array($result) && isset($result['_assistant_sync']) && is_array($result['_assistant_sync'])) {
$assistantSync = $result['_assistant_sync'];
}
return [
'success' => $ok,
'message' => $message,
'traces_count' => count($result['traces'] ?? []),
'state' => $state,
'source' => 'kuaidi100_fallback',
'assistant_sync' => $assistantSync,
];
} catch (\Throwable $e) {
Log::error('queryKuaidiForPrescriptionOrder failed', [
'order_id' => (int) $order->id,
'tracking_number' => $tn,
'error' => $e->getMessage(),
]);
return $fail('快递100 降级异常:' . $e->getMessage());
}
}
/**
* 确保 express_tracking 存在(不发起查询)
*/
@@ -283,6 +353,93 @@ class ExpressTrackingService
$responseTime = (int) ((microtime(true) - $startTime) * 1000);
return self::persistTrackingResult($tracking, $result, $isAuto, $responseTime);
}
/**
* 直接调用京东官方物流接口(绕过快递100),将轨迹落库到 express_tracking / express_trace。
* 供后台「京东接口更新」按钮使用;未配置京东官方接口或无轨迹时返回失败。
*
* @return array{success:bool, message:string, traces_count:int, state:string, source:string}
*/
public static function queryJdOfficialAndUpdate(string $trackingNumber): array
{
$fail = static fn (string $msg): array => [
'success' => false,
'message' => $msg,
'traces_count' => 0,
'state' => '',
'source' => 'jd_official',
];
$tn = trim($trackingNumber);
if ($tn === '') {
return $fail('快递单号不能为空');
}
if (!JdLogisticsService::isConfigured()) {
return $fail('未配置京东官方物流接口(请在 .env 配置 JD_LOGISTICS_* 后重试)');
}
$tracking = self::resolveTrackingByNumber($tn);
if (!$tracking) {
return $fail('未找到该快递单号对应的业务订单或物流追踪记录');
}
$startTime = microtime(true);
try {
$jd = JdLogisticsService::queryTrace($tn, (string) ($tracking->recipient_phone ?? ''));
} catch (\Throwable $e) {
Log::error('queryJdOfficialAndUpdate failed', [
'tracking_number' => $tn,
'error' => $e->getMessage(),
]);
return $fail('京东官方接口请求异常:' . $e->getMessage());
}
$responseTime = (int) ((microtime(true) - $startTime) * 1000);
if ($jd === null || empty($jd['traces'])) {
self::logQuery($tn, (string) $tracking->express_company, 'manual', [
'source' => 'jd_official',
'hint' => '京东官方接口未返回有效轨迹',
'traces' => [],
], $responseTime);
return $fail('京东官方接口未返回有效轨迹,请确认运单号或稍后重试');
}
$result = [
'carrier' => 'jd',
'carrier_label' => '京东快递',
'kuaidi_com' => 'jd',
'traces' => $jd['traces'],
'state' => (string) ($jd['state'] ?? '0'),
'state_text' => (string) ($jd['state_text'] ?? ''),
'source' => 'jd_official',
'hint' => '',
'official_url' => '',
];
self::persistTrackingResult($tracking, $result, false, $responseTime);
return [
'success' => true,
'message' => '京东物流轨迹已更新',
'traces_count' => count($jd['traces']),
'state' => (string) ($jd['state_text'] ?? ''),
'source' => 'jd_official',
];
}
/**
* 将一次轨迹查询结果(快递100 / 京东官方等)落库并推进订单状态。
*
* @param array<string, mixed> $result query 返回的轨迹结构
* @param bool $isAuto 是否定时任务触发(影响查询日志类型与医助释放)
* @return array<string, mixed>|null 查询结果;自动任务时可能含 _assistant_sync
*/
private static function persistTrackingResult(ExpressTracking $tracking, array $result, bool $isAuto, int $responseTime): ?array
{
// 记录查询日志
self::logQuery(
$tracking->tracking_number,
@@ -333,7 +490,14 @@ class ExpressTrackingService
// 检查是否签收
if (ExpressTracking::isFinalState($tracking->current_state)) {
$tracking->is_signed = 1;
$tracking->sign_time = $now;
// 签收时间只取真实「妥投/签收/送达」轨迹时间;解析不到就保持原值,
// 绝不用同步当天 $now 冒充签收日(否则 sign_time 列会被写成「拉取当天」,导出/详情/业绩全错)。
$signFromTrace = (!empty($result['traces']) && is_array($result['traces']))
? self::effectiveSignUnixFromKuaidiPayload($result)
: 0;
if ($signFromTrace > 0) {
$tracking->sign_time = $signFromTrace;
}
$tracking->auto_update = 0; // 停止自动更新
}
@@ -866,7 +1030,7 @@ class ExpressTrackingService
}
/**
* 按处方订单履约核对:已发货(5)/已签收(6) 时符合条件的订单调用 applyAssistantReleaseForShippedPrescriptionOrder。
* 按处方订单履约核对:已发货(5) 时符合条件的订单调用 applyAssistantReleaseForShippedPrescriptionOrder(已完成/已签收不再扫描)
* 与快递 100 / 甘草来自哪个物流渠道无关;是否清空医助以该方法为准(**二中心豁免看业务单创建人**参见 apply;已 shipped 释放且 assistant_id=0 视为已处理)。
*
* @return array{scanned: int, cleared: int, lines: list<string>}
@@ -881,7 +1045,7 @@ class ExpressTrackingService
->whereNull('po.delete_time')
->whereNull('d.delete_time')
->whereRaw('(IFNULL(d.shipped_non_er_assistant_cleared_at, 0) = 0 OR d.assistant_id > 0)')
->whereIn('po.fulfillment_status', [5, 6])
->where('po.fulfillment_status', 5)
->where('po.diagnosis_id', '>', 0)
->where(function ($w) {
$w->where('d.assistant_id', '>', 0)
@@ -970,7 +1134,7 @@ class ExpressTrackingService
->field('et.*')
->limit($limit)
->select();
$success = 0;
$failed = 0;
$assistantCleared = 0;
@@ -1247,8 +1411,24 @@ class ExpressTrackingService
*/
private static function effectiveSignUnixFromTrackingDetail(array $detail): int
{
$a = (int) ($detail['sign_time'] ?? 0);
$traceBased = self::traceBasedSignUnixFromTrackingDetail($detail);
if ($traceBased > 0) {
return $traceBased;
}
// 最后兜底才用 sign_time 列(历史同步可能把「拉取当天」写进该列,故放最后)。
return (int) ($detail['sign_time'] ?? 0);
}
/**
* 纯轨迹/主表推导的签收时间(不含 sign_time 列兜底):
* 签收/妥投/送达轨迹($b)→ 已签收态下最新轨迹($c)→ 主表 latest_trace_time 回填。
* 修复 sign_time 列时用它判断「列值是否被污染」(与轨迹真实时间不符)。
*
* @param array<string, mixed> $detail
*/
private static function traceBasedSignUnixFromTrackingDetail(array $detail): int
{
$traces = $detail['traces'] ?? [];
if (!is_array($traces)) {
$traces = [];
@@ -1283,7 +1463,12 @@ class ExpressTrackingService
}
}
return max($a, $b, $c, self::signedTrackingHeadFallbackUnix($detail, $stateOk));
$traceBased = $b > 0 ? $b : $c;
if ($traceBased > 0) {
return $traceBased;
}
return self::signedTrackingHeadFallbackUnix($detail, $stateOk);
}
/**
@@ -1316,8 +1501,28 @@ class ExpressTrackingService
}
$st = (string) ($tr['status'] ?? '');
$ctx = (string) ($tr['trace_context'] ?? '');
$hay = $st . ' ' . $ctx;
foreach (['签收', '妥投', '已送达', '本人签收'] as $k) {
return self::contextLooksSigned($st . ' ' . $ctx);
}
/**
* 文案是否表示「已签收/已送达」。
* 先排除「派送中,请您准备签收 / 待签收」等「尚未签收」文案(含「签收」二字易误判),再匹配正向关键词。
*/
private static function contextLooksSigned(string $hay): bool
{
if ($hay === '') {
return false;
}
// 负向:出库派送阶段的「准备签收/待签收」等,并非真正签收
foreach (['准备签收', '待签收', '等待签收', '预计', '即将送达'] as $neg) {
if (mb_stripos($hay, $neg) !== false) {
return false;
}
}
// 「送达」不带「已」(顺丰末端 status=送达 / context=快件已放在…)也算签收;
// 「预计送达 / 即将送达」已在上面负向词排除,不会误判。
foreach (['签收', '妥投', '送达', '本人签收', '已放在'] as $k) {
if (mb_stripos($hay, $k) !== false) {
return true;
}
@@ -1363,13 +1568,14 @@ class ExpressTrackingService
}
$tstr = (string) ($row['time'] ?? '');
$ctx = (string) ($row['context'] ?? '');
// 顺丰等末端把签收信息放在 status(如「送达」)而非 context,故连同 status 一起判断
$st = (string) ($row['status'] ?? '');
$p = strtotime($tstr);
$ts = $p !== false ? (int) $p : 0;
if ($ts <= 0) {
continue;
}
$signedLike = self::plainContextLooksSigned($ctx);
if ($signedLike) {
if (self::contextLooksSigned($ctx . ' ' . $st)) {
$best = max($best, $ts);
}
}
@@ -1395,17 +1601,6 @@ class ExpressTrackingService
return $best;
}
private static function plainContextLooksSigned(string $context): bool
{
foreach (['签收', '妥投', '已送达', '本人签收', '快件已送达'] as $k) {
if (mb_stripos($context, $k) !== false) {
return true;
}
}
return false;
}
/**
* 提成结算 overview 等大批量场景:按订单批量从库表推导签收时间(不调用快递100)。
* 语义与 YejiStatsLogic 物流子查询 / effectiveSignUnixFromTrackingDetail 对齐。
@@ -1443,27 +1638,35 @@ class ExpressTrackingService
return [];
}
$trackingById = [];
// 仅保留「每个运单算好的签收 Unix」(int),不保留整行轨迹,避免一次性 hydrate 大量 express_trace
// 行导致内存溢出(导出全量 / 批量回填会传入上千运单,每单又有几十条轨迹)。
$signUnixByTrackingId = [];
$orderToTrackingIds = [];
$tnToTrackingIds = [];
$attachTrackingRow = static function (array $etRow) use (&$trackingById, &$orderToTrackingIds, &$tnToTrackingIds): void {
$tid = (int) ($etRow['id'] ?? 0);
$consumeTracking = static function ($tracking) use (&$signUnixByTrackingId, &$orderToTrackingIds, &$tnToTrackingIds): void {
$tid = (int) ($tracking->id ?? 0);
if ($tid <= 0) {
return;
}
$trackingById[$tid] = $etRow;
$oid = (int) ($etRow['order_id'] ?? 0);
if (!array_key_exists($tid, $signUnixByTrackingId)) {
$row = $tracking->toArray();
$row['traces'] = $tracking->traces ? $tracking->traces->toArray() : [];
// 算完即丢弃整行轨迹,仅留下结果 int
$signUnixByTrackingId[$tid] = (int) self::effectiveSignUnixFromTrackingDetail($row);
}
$oid = (int) ($tracking->order_id ?? 0);
if ($oid > 0) {
$orderToTrackingIds[$oid][$tid] = true;
}
$tn = trim((string) ($etRow['tracking_number'] ?? ''));
$tn = trim((string) ($tracking->tracking_number ?? ''));
if ($tn !== '') {
$tnToTrackingIds[$tn][$tid] = true;
}
};
foreach (array_chunk($orderIds, 800) as $chunk) {
// 小批量流式处理,每批结束后大对象即可回收,峰值内存只与单批运单的轨迹量相关
foreach (array_chunk($orderIds, 200) as $chunk) {
$byOrderId = ExpressTracking::with(['traces' => static function ($query): void {
$query->order('trace_time_stamp', 'desc');
}])
@@ -1471,15 +1674,14 @@ class ExpressTrackingService
->whereNull('delete_time')
->select();
foreach ($byOrderId as $tracking) {
$row = $tracking->toArray();
$row['traces'] = $tracking->traces ? $tracking->traces->toArray() : [];
$attachTrackingRow($row);
$consumeTracking($tracking);
}
unset($byOrderId);
}
$trackingNumList = array_keys($trackingNums);
if ($trackingNumList !== []) {
foreach (array_chunk($trackingNumList, 400) as $chunk) {
foreach (array_chunk($trackingNumList, 200) as $chunk) {
$byTn = ExpressTracking::with(['traces' => static function ($query): void {
$query->order('trace_time_stamp', 'desc');
}])
@@ -1487,10 +1689,9 @@ class ExpressTrackingService
->whereNull('delete_time')
->select();
foreach ($byTn as $tracking) {
$row = $tracking->toArray();
$row['traces'] = $tracking->traces ? $tracking->traces->toArray() : [];
$attachTrackingRow($row);
$consumeTracking($tracking);
}
unset($byTn);
}
}
@@ -1509,11 +1710,7 @@ class ExpressTrackingService
}
$best = 0;
foreach (array_keys($candidateIds) as $tid) {
$detail = $trackingById[(int) $tid] ?? null;
if (!is_array($detail)) {
continue;
}
$best = max($best, self::effectiveSignUnixFromTrackingDetail($detail));
$best = max($best, (int) ($signUnixByTrackingId[(int) $tid] ?? 0));
}
if ($best > 0) {
$out[$oid] = $best;
@@ -1522,4 +1719,61 @@ class ExpressTrackingService
return $out;
}
/**
* 纯库内修复:按现有 express_trace 轨迹重算每个「已签收」运单的真实签收时间,
* 回写 express_tracking.sign_time。不调用快递100(零 HTTP)。
*
* 处理历史污染:早期 queryAndUpdate 把「同步当天 $now」写进 sign_time,签收后 auto_update=0 不再纠正,
* 导致 sign_time 列=拉取当天而非真实送达日。本方法用轨迹时间覆盖被污染的列值。
*
* @return array{scanned:int, updated:int, skipped:int} 统计
*/
public static function repairSignTimeFromDbTraces(bool $dryRun = false, int $limit = 0): array
{
$scanned = 0;
$updated = 0;
$skipped = 0;
$query = ExpressTracking::with(['traces' => static function ($q): void {
$q->order('trace_time_stamp', 'desc');
}])
->whereNull('delete_time')
->where(function ($q): void {
$q->where('is_signed', 1)
->whereOr('current_state', ExpressTracking::STATE_SIGNED);
})
->order('id', 'asc');
$query->chunk(200, function ($trackings) use (&$scanned, &$updated, &$skipped, $dryRun, $limit): bool {
foreach ($trackings as $tracking) {
$scanned++;
$detail = $tracking->toArray();
$detail['traces'] = $tracking->traces ? $tracking->traces->toArray() : [];
$trueSign = self::traceBasedSignUnixFromTrackingDetail($detail);
$cur = (int) ($tracking->sign_time ?? 0);
// 仅当轨迹能推导出真实签收时间、且与现列值不同(按秒)时才修复
if ($trueSign <= 0 || $trueSign === $cur) {
$skipped++;
continue;
}
if (!$dryRun) {
$tracking->sign_time = $trueSign;
$tracking->save();
}
$updated++;
if ($limit > 0 && $updated >= $limit) {
return false; // 终止 chunk 遍历
}
}
return true;
}, 'id');
return ['scanned' => $scanned, 'updated' => $updated, 'skipped' => $skipped];
}
}
@@ -0,0 +1,461 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use think\facade\Config;
use think\facade\Log;
/**
* 京东官方物流轨迹查询(京东物流开放平台 LOPhttps://api.jdl.com
*
* 作用:作为快递100 对「京东自营运单(JDVE…)」轨迹陈旧/缺失时的兜底数据源,
* 同时供后台「京东接口更新」按钮直接拉取并落库。
* 仅在 config/logistics.php jd.enable=true(填好 app_key/app_secret/access_token)时生效;
* 未配置时 isConfigured()=falseExpressTrackService 完全沿用快递100 逻辑,互不影响。
*
* 接口:京东物流标准轨迹服务 /jd/tracking/query2025-04-29 改版,对接方案编码 Tracking_JD)。
* 调用走 LOP 统一网关,鉴权/签名规则与官方 SDK(IsvFilter) 完全一致:
* - 公共参数(app_key/access_token/timestamp/v/sign/algorithm/LOP-DN) query string 拼到 URL
* - 业务参数 JSON 字符串作为请求体;待签串固定顺序拼接并首尾包 app_secret。
* 加签算法由 .env JD_LOGISTICS_ALGORITHM 控制(默认 md5-salt=md5(content),另支持 HMacMD5/SHA1/SHA256/SHA512)。
* 注意:后台「报文加解密密钥」的 RSA 公私钥仅用于报文加解密,与本网关签名无关。
*
* 网关/path/对接方案编码/单号类型 .envJD_LOGISTICS_GATEWAY / METHOD / LOP_DN / REFERENCE_TYPE)。
* 响应解析采用「递归找轨迹行」的宽松策略,兼容多种返回结构。
*/
final class JdLogisticsService
{
/** 轨迹行「时间」候选字段(按优先级) */
private const TIME_FIELDS = [
'operationTime', 'operatorTime', 'opeTime', 'operateTime', 'msgTime', 'scanTime',
'time', 'createTime', 'waybillStateTime', 'orderTime',
];
/** 轨迹行「描述」候选字段(按优先级) */
private const CONTEXT_FIELDS = [
'remark', 'operateRemark', 'opeRemark', 'content', 'opeTitle',
'operationCodeName', 'operationTypeName', 'scanTypeName', 'waybillStateName', 'message', 'desc', 'msg',
];
public static function isConfigured(): bool
{
$cfg = Config::get('logistics.jd', []);
// LOP 网关签名用 app_secretmd5-salt / HMAC),不需要 RSA 私钥(私钥仅用于报文加解密)
return !empty($cfg['enable'])
&& trim((string) ($cfg['app_key'] ?? '')) !== ''
&& trim((string) ($cfg['app_secret'] ?? '')) !== ''
&& trim((string) ($cfg['access_token'] ?? '')) !== '';
}
/**
* 查询京东官方轨迹,返回与 ExpressTrackService::query 兼容的结构(失败/未配置返回 null)。
*
* @return array{
* traces: list<array{time:string,context:string,status:string}>,
* state: string,
* state_text: string,
* source: string,
* hint: string,
* newest_unix: int
* }|null
*/
public static function queryTrace(string $waybillCode, string $phoneTail = ''): ?array
{
$num = trim($waybillCode);
if ($num === '' || !self::isConfigured()) {
return null;
}
$cfg = Config::get('logistics.jd', []);
// 京东物流标准轨迹服务 /jd/tracking/querybody 为 JSON 数组 [{referenceNumber, referenceType, phone}]
$row = [
(string) ($cfg['reference_field'] ?? 'referenceNumber') => $num,
'referenceType' => (string) ($cfg['reference_type'] ?? '20000'),
];
$tail = substr(preg_replace('/\D/', '', $phoneTail) ?? '', -4);
if ($tail !== '') {
$row['phone'] = $tail;
}
$customerCode = trim((string) ($cfg['customer_code'] ?? ''));
if ($customerCode !== '') {
$row['customerCode'] = $customerCode;
}
$body = json_encode([$row], JSON_UNESCAPED_UNICODE);
$raw = self::request($cfg, (string) $body);
if ($raw === null) {
return null;
}
$json = json_decode($raw, true);
if (!is_array($json)) {
Log::warning('JdLogisticsService invalid json', ['raw' => mb_substr($raw, 0, 500), 'num' => $num]);
return null;
}
// LOP 网关/业务错误:code 非 1000(成功)时记录原始报文,便于排查鉴权/单号/权限问题
$code = (string) ($json['code'] ?? $json['resultCode'] ?? '');
if ($code !== '' && !in_array($code, ['1000', '0000', '0'], true)) {
Log::warning('JdLogisticsService lop error', [
'num' => $num,
'code' => $code,
'message' => (string) ($json['msg'] ?? $json['message'] ?? $json['resultMessage'] ?? ''),
'raw' => mb_substr($raw, 0, 500),
]);
return null;
}
// 兼容 JOS 网关层错误结构
if (isset($json['error_response'])) {
Log::warning('JdLogisticsService gateway error', [
'num' => $num,
'error' => $json['error_response'],
]);
return null;
}
$rows = self::extractTraceRows($json);
if ($rows === []) {
Log::info('JdLogisticsService no trace rows', ['num' => $num, 'json' => mb_substr($raw, 0, 800)]);
return null;
}
$traces = self::normalizeRows($rows);
if ($traces === []) {
return null;
}
// 时间倒序(最新在前),与快递100 输出一致
usort($traces, static function (array $a, array $b): int {
return ($b['_unix'] ?? 0) <=> ($a['_unix'] ?? 0);
});
$newestUnix = (int) ($traces[0]['_unix'] ?? 0);
$signed = false;
foreach ($traces as $t) {
if (self::looksSigned((string) $t['context'])) {
$signed = true;
break;
}
}
$state = $signed ? '3' : '0';
// 去掉内部辅助字段
$clean = [];
foreach ($traces as $t) {
$clean[] = [
'time' => (string) $t['time'],
'context' => (string) $t['context'],
'status' => (string) ($t['status'] ?? ''),
];
}
return [
'traces' => $clean,
'state' => $state,
'state_text' => $signed ? '已签收' : '在途',
'source' => 'jd_official',
'hint' => '',
'newest_unix' => $newestUnix,
];
}
/**
* 调用 LOP 网关(统一鉴权/签名,与官方 SDK IsvFilter 一致)。
*
* 公共参数以 query string 拼到 URL;业务参数(JSON 字符串)作为请求体;
* 网关靠 LOP-DN(对接方案编码) 路由到对应服务。
*
* @param array<string,mixed> $cfg
* @param string $body 业务参数 JSON 字符串(param_json
*/
private static function request(array $cfg, string $body): ?string
{
$appKey = (string) ($cfg['app_key'] ?? '');
$appSecret = (string) ($cfg['app_secret'] ?? '');
$accessToken = (string) ($cfg['access_token'] ?? '');
$path = (string) ($cfg['method'] ?? '/jd/tracking/query');
$version = (string) ($cfg['api_version'] ?? '2.0');
$algorithm = trim((string) ($cfg['algorithm'] ?? 'md5-salt')) ?: 'md5-salt';
$lopDn = (string) ($cfg['lop_dn'] ?? 'Tracking_JD');
// 时间戳与时区必须自洽(否则网关报 471 时间戳已失效):统一用北京时间 + lop-tz=8
$now = new \DateTime('now', new \DateTimeZone('Asia/Shanghai'));
$timestamp = $now->format('Y-m-d H:i:s');
// 待签串:固定顺序拼接,首尾包 appSecretmethod=接口pathparam_json=业务体)
$content = implode('', [
$appSecret,
'access_token', $accessToken,
'app_key', $appKey,
'method', $path,
'param_json', $body,
'timestamp', $timestamp,
'v', $version,
$appSecret,
]);
$sign = self::sign($algorithm, $content, $appSecret);
if ($sign === null) {
return null;
}
$query = [
'LOP-DN' => $lopDn,
'app_key' => $appKey,
'access_token' => $accessToken,
'timestamp' => $timestamp,
'v' => $version,
'sign' => $sign,
'algorithm' => $algorithm,
];
$base = rtrim((string) ($cfg['gateway'] ?? 'https://api.jdl.com'), '/');
$url = $base . $path . '?' . http_build_query($query);
// lop-tz:与 timestamp 同源(北京时间 = 东八区 = 8)
$offsetHours = (int) ($now->getOffset() / 3600);
return self::httpPostJson($url, $body, [
'Content-Type: application/json;charset=utf-8',
'User-Agent: lop-http/php',
'lop-tz: ' . $offsetHours,
]);
}
/**
* LOP 网关签名(与官方 SDK Utils::sign 一致):
* - md5-salt md5(content) 的小写十六进制
* - HMacMD5 / HMacSHA1 / HMacSHA256 / HMacSHA512base64(hmac(算法, content, appSecret))
* 不支持的算法返回 null
*/
private static function sign(string $algorithm, string $content, string $secret): ?string
{
switch (trim($algorithm)) {
case 'md5-salt':
return md5($content);
case 'HMacMD5':
return base64_encode(hash_hmac('md5', $content, $secret, true));
case 'HMacSHA1':
return base64_encode(hash_hmac('sha1', $content, $secret, true));
case 'HMacSHA256':
return base64_encode(hash_hmac('sha256', $content, $secret, true));
case 'HMacSHA512':
return base64_encode(hash_hmac('sha512', $content, $secret, true));
default:
Log::warning('JdLogisticsService unsupported algorithm', ['algorithm' => $algorithm]);
return null;
}
}
/**
* 递归在响应 JSON 中找出「轨迹行数组」:取出现轨迹行最多的一组。
* 兼容字段被序列化成 JSON 字符串(如 querytrace_result string)的情况。
*
* @param mixed $node
* @return list<array<string,mixed>>
*/
private static function extractTraceRows($node): array
{
$best = [];
$walk = function ($n) use (&$walk, &$best): void {
if (is_string($n)) {
$trimmed = trim($n);
if ($trimmed !== '' && ($trimmed[0] === '{' || $trimmed[0] === '[')) {
$decoded = json_decode($trimmed, true);
if (is_array($decoded)) {
$walk($decoded);
}
}
return;
}
if (!is_array($n)) {
return;
}
// 是否为「轨迹行的列表」:连续数字键、且元素是带时间/描述字段的关联数组
if (self::isList($n)) {
$rows = [];
foreach ($n as $item) {
if (is_array($item) && self::rowHasTraceFields($item)) {
$rows[] = $item;
}
}
if (count($rows) > count($best)) {
$best = $rows;
}
}
foreach ($n as $v) {
$walk($v);
}
};
$walk($node);
return $best;
}
/**
* @param array<string,mixed> $row
*/
private static function rowHasTraceFields(array $row): bool
{
$hasTime = false;
foreach (self::TIME_FIELDS as $f) {
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
$hasTime = true;
break;
}
}
if (!$hasTime) {
return false;
}
foreach (self::CONTEXT_FIELDS as $f) {
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
return true;
}
}
return false;
}
/**
* @param array<int, array<string,mixed>> $rows
* @return list<array{time:string,context:string,status:string,_unix:int}>
*/
private static function normalizeRows(array $rows): array
{
$out = [];
foreach ($rows as $row) {
$time = '';
foreach (self::TIME_FIELDS as $f) {
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
$time = trim((string) $row[$f]);
break;
}
}
$context = '';
foreach (self::CONTEXT_FIELDS as $f) {
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
$context = trim((string) $row[$f]);
break;
}
}
if ($time === '' && $context === '') {
continue;
}
$unix = self::parseTimeToUnix($time);
$out[] = [
'time' => $time !== '' ? self::formatTime($time, $unix) : '',
'context' => $context,
'status' => '',
'_unix' => $unix,
];
}
return $out;
}
private static function parseTimeToUnix(string $time): int
{
$t = trim($time);
if ($t === '') {
return 0;
}
// 毫秒时间戳
if (preg_match('/^\d{13}$/', $t)) {
return (int) ((int) $t / 1000);
}
// 秒时间戳
if (preg_match('/^\d{10}$/', $t)) {
return (int) $t;
}
$p = strtotime($t);
return $p !== false ? (int) $p : 0;
}
private static function formatTime(string $raw, int $unix): string
{
// 纯时间戳统一格式化成可读时间,便于落库/前端展示
if ($unix > 0 && preg_match('/^\d{10,13}$/', trim($raw))) {
return date('Y-m-d H:i:s', $unix);
}
return $raw;
}
/**
* @param array<mixed> $arr
*/
private static function isList(array $arr): bool
{
if ($arr === []) {
return false;
}
if (function_exists('array_is_list')) {
return array_is_list($arr);
}
return array_keys($arr) === range(0, count($arr) - 1);
}
private static function looksSigned(string $hay): bool
{
if ($hay === '') {
return false;
}
foreach (['准备签收', '待签收', '等待签收', '预计', '即将送达'] as $neg) {
if (mb_stripos($hay, $neg) !== false) {
return false;
}
}
foreach (['签收', '妥投', '送达', '已放在'] as $k) {
if (mb_stripos($hay, $k) !== false) {
return true;
}
}
return false;
}
/**
* @param list<string> $headers
*/
private static function httpPostJson(string $url, string $body, array $headers): ?string
{
if (!function_exists('curl_init')) {
return null;
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$resp = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($resp === false) {
Log::warning('JdLogisticsService http error', ['url' => $url, 'error' => $err]);
return null;
}
return (string) $resp;
}
}
@@ -69,7 +69,11 @@ final class GancaoLogisticsRouteService
$resp = self::callRouteApi($appOrderNo);
if (!$resp['success']) {
self::logQuery($order, '', 'auto', false, 0, $resp['message']);
self::logQuery($order, '', 'auto', false, (int) ($resp['runtime_ms'] ?? 0), $resp['message']);
if (self::shouldFallbackToKuaidi100($resp)) {
return self::syncOneViaKuaidi100($order, (string) $resp['message']);
}
return ['success' => false, 'message' => $resp['message'], 'traces_count' => 0, 'state' => ''];
}
@@ -87,6 +91,10 @@ final class GancaoLogisticsRouteService
if ($trackingNumber === '') {
self::logQuery($order, '', 'auto', false, $resp['runtime_ms'], '甘草未返回 sp_order_no(快递单号),可能尚未发货');
if (trim((string) ($order->tracking_number ?? '')) !== '') {
return self::syncOneViaKuaidi100($order, '甘草未返回快递单号,业务单已填写运单号');
}
return ['success' => false, 'message' => '尚未发货(无快递单号)', 'traces_count' => 0, 'state' => ''];
}
@@ -136,11 +144,54 @@ final class GancaoLogisticsRouteService
'message' => 'ok',
'traces_count' => $tracesCount,
'state' => $newState,
'source' => 'gancao',
'raw' => $result,
'assistant_sync' => $assistantSync,
];
}
/**
* 甘草侧无快递任务 / 路由拉取失败时,按业务单运单号降级快递100(自发货)
*
* @return array{success:bool, message:string, traces_count:int, state:string, source?:string, assistant_sync?:array|null}
*/
private static function syncOneViaKuaidi100(PrescriptionOrder $order, string $gancaoReason): array
{
$ret = ExpressTrackingService::queryKuaidiForPrescriptionOrder($order, true);
if (!$ret['success']) {
$ret['message'] = trim((string) $ret['message']) . ';甘草:' . mb_substr($gancaoReason, 0, 200);
}
return $ret;
}
/**
* 是否因甘草「快递任务」类错误降级走快递100
*
* @param array{success?:bool, message?:string, api_code?:string} $gancaoResp
*/
private static function shouldFallbackToKuaidi100(array $gancaoResp): bool
{
$code = trim((string) ($gancaoResp['api_code'] ?? ''));
if ($code === '10101') {
return true;
}
$msg = (string) ($gancaoResp['message'] ?? '');
if ($msg === '') {
return false;
}
$needles = ['快递任务', '任务id不存在', '任务不存在', '无快递任务', '物流任务'];
foreach ($needles as $needle) {
if (mb_strpos($msg, $needle) !== false) {
return true;
}
}
return false;
}
/**
* 调用甘草 GET_TASK_ROUTE_LIST 接口
*
@@ -181,6 +232,7 @@ final class GancaoLogisticsRouteService
return [
'success' => false,
'message' => 'API 错误:' . GancaoScmRecipelService::apiStatusMessage($body),
'api_code' => (string) ($body['status']['code'] ?? ''),
'runtime_ms' => $runtimeMs,
];
}
@@ -435,7 +487,7 @@ final class GancaoLogisticsRouteService
}
/**
* 批量同步:选取候选订单(已上传甘草、且未签收/未取消的)
* 批量同步:选取候选订单(已上传甘草、且未完成/签收/未取消的)
*
* @return array{total:int, success:int, failed:int, skipped:int, assistant_cleared:int, assistant_skipped_assign_log:int, assistant_lines:list<string>, details:array<int, array<string,mixed>>}
*/
@@ -464,8 +516,8 @@ final class GancaoLogisticsRouteService
$w->whereIn('gancao_order_state', [110, 20, 30, 90])
->whereOr('tracking_number', '<>', '');
});
// 排除已取消(4)、已签收(6);已完成(3)仍跑一次以补齐路由
$q->whereNotIn('fulfillment_status', [4, 6]);
// 排除已完成(3)、已取消(4)、已签收(6)
$q->whereNotIn('fulfillment_status', [3, 4, 6]);
}
$orders = $q->order('id', 'desc')->limit($limit)->select();
@@ -481,6 +533,12 @@ final class GancaoLogisticsRouteService
*/
private static function appendSyncOneResult(array &$stats, PrescriptionOrder $order): void
{
$fs = (int) ($order->fulfillment_status ?? 0);
if (in_array($fs, [3, 6], true)) {
$stats['skipped']++;
return;
}
$stats['total']++;
try {
$r = self::syncOne($order);
@@ -493,6 +551,7 @@ final class GancaoLogisticsRouteService
'message' => $r['message'],
'traces' => $r['traces_count'],
'state' => $r['state'],
'source' => $r['source'] ?? 'gancao',
];
if ($r['success']) {
$stats['success']++;
@@ -501,8 +560,10 @@ final class GancaoLogisticsRouteService
$act = (string) ($sync['action'] ?? '');
if ($act === 'cleared') {
$stats['assistant_cleared']++;
$channel = (($r['source'] ?? '') === 'kuaidi100_fallback') ? '快递100降级' : '甘草路由';
$stats['assistant_lines'][] = sprintf(
'[移除医助+指派日志][甘草路由] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
'[移除医助+指派日志][%s] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
$channel,
(int) ($sync['diagnosis_id'] ?? 0),
(int) ($sync['prescription_order_id'] ?? 0),
(string) ($sync['tracking_number'] ?? ''),
+2
View File
@@ -34,6 +34,8 @@ return [
'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute',
// 历史 internal_cost:批量甘草预报价回填(CTM_PREVIEW
'tcm:backfill-internal-cost' => 'app\\command\\TcmBackfillPrescriptionOrderInternalCost',
// 批量回填业务订单签收时间到物流库(导出读库即可,不再逐单 HTTP)
'tcm:backfill-sign-time' => 'app\\command\\TcmBackfillPrescriptionOrderSignTime',
// 迁移诊单图片到医生备注表
'migrate:images-to-doctor-note' => 'app\\command\\MigrateImagesToDoctorNote',
// 诊单待办事项:扫描到点的待执行项并向创建人发送企业微信消息
+47
View File
@@ -41,6 +41,34 @@ $forceDisable = filter_var($forceDisableRaw, FILTER_VALIDATE_BOOLEAN);
// 已填 CUSTOMER+KEY 即启用;需临时关闭时设 LOGISTICS_KUAIDI100_DISABLE=trueLOGISTICS_KUAIDI100_ENABLE 已废弃,可删)
$kuaidiEnabled = ! $forceDisable && $hasCreds;
// 京东物流开放平台 LOPhttps://api.jdl.com
// 仅当填好 APP_KEY / APP_SECRET / ACCESS_TOKENRSA2 模式还需 RSA_PRIVATE_KEY)时启用;
// 作为快递100 对京东自营单(JDVE…)轨迹陈旧时的兜底数据源,并供后台「京东接口更新」按钮使用。
// 未配置时本段 enable=falseExpressTrackService 完全沿用现有快递100 逻辑,互不影响。
// 加签方式由 JD_LOGISTICS_ALGORITHM 控制:rsa2(默认,需私钥) / md5(摘要,无需私钥)。
$envJd = static function (string $suffix, $default = '') use ($stripQuotes) {
$top = 'JD_LOGISTICS_' . $suffix;
$v = env($top, null);
if ($v !== null && $v !== '') {
return $stripQuotes((string) $v);
}
// 兼容误写进 [trtc] 等分区的情况
$nested = env('trtc.' . $top, $default);
return $stripQuotes((string) $nested);
};
$jdAppKey = $envJd('APP_KEY', '');
$jdAppSecret = $envJd('APP_SECRET', '');
$jdAccessToken = $envJd('ACCESS_TOKEN', '');
$jdHasCreds = $jdAppKey !== '' && $jdAppSecret !== '' && $jdAccessToken !== '';
$jdDisableRaw = env('JD_LOGISTICS_DISABLE', null);
if ($jdDisableRaw === null || $jdDisableRaw === '') {
$jdDisableRaw = env('trtc.JD_LOGISTICS_DISABLE', false);
}
$jdDisabled = filter_var($jdDisableRaw, FILTER_VALIDATE_BOOLEAN);
$jdEnabled = ! $jdDisabled && $jdHasCreds;
return [
'kuaidi100' => [
'enable' => $kuaidiEnabled,
@@ -48,4 +76,23 @@ return [
'key' => $key,
'query_url' => $envKuaidi('QUERY_URL', 'https://poll.kuaidi100.com/poll/query.do'),
],
'jd' => [
'enable' => $jdEnabled,
'app_key' => $jdAppKey,
'app_secret' => $jdAppSecret,
'access_token' => $jdAccessToken,
// LOP 网关签名算法(与官方 SDK 一致):md5-salt(默认) / HMacMD5 / HMacSHA1 / HMacSHA256 / HMacSHA512
'algorithm' => $envJd('ALGORITHM', 'md5-salt'),
// 生产网关基址(不含路径)
'gateway' => $envJd('GATEWAY', 'https://api.jdl.com'),
// 接口 path(即签名里的 method);京东物流标准轨迹服务(2025-04-29 改版)
'method' => $envJd('METHOD', '/jd/tracking/query'),
// LOP-DN:对接方案编码(订阅记录可查),标准轨迹服务为 Tracking_JD
'lop_dn' => $envJd('LOP_DN', 'Tracking_JD'),
// 业务体字段:单号字段名 / 单号类型(20000=运单号) / 商家编码(可选)
'reference_field' => $envJd('REFERENCE_FIELD', 'referenceNumber'),
'reference_type' => $envJd('REFERENCE_TYPE', '20000'),
'customer_code' => $envJd('CUSTOMER_CODE', ''),
'api_version' => $envJd('API_VERSION', '2.0'),
],
];
@@ -1 +1 @@
import r from"./error-NnOocA9Z.js";import{f as p,ak as i,I as m,a as e,aN as s,J as o}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-lurTijPg.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-HOEUG8sr.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-hTkp7Jz4.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default};
import r from"./error-Baib8GsN.js";import{f as p,ak as i,I as m,a as e,aN as s,J as o}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-lurTijPg.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-HOEUG8sr.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-DKxtBL_b.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default};
@@ -1 +1 @@
import o from"./error-NnOocA9Z.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-lurTijPg.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-HOEUG8sr.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-hTkp7Jz4.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default};
import o from"./error-Baib8GsN.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-lurTijPg.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-HOEUG8sr.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-DKxtBL_b.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default};
@@ -1 +1 @@
import{M as T,N as C,T as $,r as D,d as L,L as k}from"./element-plus-lurTijPg.js";import{W as E}from"./@element-plus/icons-vue-HOEUG8sr.js";import{a3 as P}from"./tcm-BWcL4HTv.js";import{f as A,w as B,ak as p,I as b,aP as F,G as h,aN as n,a as i,O as m,J as M}from"./@vue/runtime-core-C6bnekPw.js";import{Q as c}from"./@vue/shared-mAAVTE9n.js";import{y as V,n as w}from"./@vue/reactivity-DiY1c2vO.js";import{_ as K}from"./index-hTkp7Jz4.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const q={class:"assign-log-panel"},z={key:1,class:"text-gray-400"},G=A({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(N,{expose:v}){const _=N,d=w(!1),u=w([]);function y(o){const e=o.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(o.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(o){const e=o.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(o.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const a=new Date(t*1e3);if(Number.isNaN(a.getTime()))return"—";const r=l=>String(l).padStart(2,"0");return`${a.getFullYear()}-${r(a.getMonth()+1)}-${r(a.getDate())} ${r(a.getHours())}:${r(a.getMinutes())}:${r(a.getSeconds())}`}function f(o,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",a=e==="from"?"from_assistant_id":"to_assistant_id",r=o[t];if(r!=null&&String(r).trim()!==""&&String(r)!=="—")return String(r);const l=Number(o[a]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(_.diagnosisId){d.value=!0;try{const o=await P({id:_.diagnosisId}),e=Array.isArray(o)?o:[];u.value=e}catch(o){console.error(o),u.value=[]}finally{d.value=!1}}};return B(()=>_.diagnosisId,()=>{g()},{immediate:!0}),v({refresh:g}),(o,e)=>{const t=C,a=$,r=L,l=D,S=T,I=k;return p(),b("div",q,[F((p(),h(S,{data:u.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:n(()=>[i(t,{label:"操作时间",width:"175",prop:"create_time_text"}),i(t,{label:"原医助","min-width":"120"},{default:n(({row:s})=>[m(c(f(s,"from")),1)]),_:1}),i(t,{label:"新医助","min-width":"120"},{default:n(({row:s})=>[m(c(f(s,"to")),1)]),_:1}),i(t,{label:"继承",width:"72",align:"center"},{default:n(({row:s})=>[Number(s.is_inherit)===1?(p(),h(a,{key:0,type:"success",size:"small"},{default:n(()=>[...e[0]||(e[0]=[m("是",-1)])]),_:1})):(p(),b("span",z,"否"))]),_:1}),i(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:n(({row:s})=>[m(c(y(s)),1)]),_:1}),i(t,{label:"快照·业务单创建时间",width:"190"},{header:n(()=>[e[1]||(e[1]=M("span",null,"快照·业务单创建时间",-1)),i(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:n(()=>[i(r,{class:"assign-log-col-hint"},{default:n(()=>[i(V(E))]),_:1})]),_:1})]),default:n(({row:s})=>[m(c(x(s)),1)]),_:1}),i(t,{label:"操作人",width:"110",prop:"operator_name"}),i(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),i(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,d.value]])])}}}),Ct=K(G,[["__scopeId","data-v-f670e3e6"]]);export{Ct as default};
import{M as T,N as C,T as $,r as D,d as L,L as k}from"./element-plus-lurTijPg.js";import{W as E}from"./@element-plus/icons-vue-HOEUG8sr.js";import{a4 as P}from"./tcm-GRQUYn2R.js";import{f as A,w as B,ak as p,I as b,aP as F,G as h,aN as n,a as i,O as m,J as M}from"./@vue/runtime-core-C6bnekPw.js";import{Q as c}from"./@vue/shared-mAAVTE9n.js";import{y as V,n as w}from"./@vue/reactivity-DiY1c2vO.js";import{_ as K}from"./index-DKxtBL_b.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const q={class:"assign-log-panel"},z={key:1,class:"text-gray-400"},G=A({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(N,{expose:v}){const _=N,d=w(!1),u=w([]);function y(o){const e=o.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(o.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(o){const e=o.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(o.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const a=new Date(t*1e3);if(Number.isNaN(a.getTime()))return"—";const r=l=>String(l).padStart(2,"0");return`${a.getFullYear()}-${r(a.getMonth()+1)}-${r(a.getDate())} ${r(a.getHours())}:${r(a.getMinutes())}:${r(a.getSeconds())}`}function f(o,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",a=e==="from"?"from_assistant_id":"to_assistant_id",r=o[t];if(r!=null&&String(r).trim()!==""&&String(r)!=="—")return String(r);const l=Number(o[a]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(_.diagnosisId){d.value=!0;try{const o=await P({id:_.diagnosisId}),e=Array.isArray(o)?o:[];u.value=e}catch(o){console.error(o),u.value=[]}finally{d.value=!1}}};return B(()=>_.diagnosisId,()=>{g()},{immediate:!0}),v({refresh:g}),(o,e)=>{const t=C,a=$,r=L,l=D,S=T,I=k;return p(),b("div",q,[F((p(),h(S,{data:u.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:n(()=>[i(t,{label:"操作时间",width:"175",prop:"create_time_text"}),i(t,{label:"原医助","min-width":"120"},{default:n(({row:s})=>[m(c(f(s,"from")),1)]),_:1}),i(t,{label:"新医助","min-width":"120"},{default:n(({row:s})=>[m(c(f(s,"to")),1)]),_:1}),i(t,{label:"继承",width:"72",align:"center"},{default:n(({row:s})=>[Number(s.is_inherit)===1?(p(),h(a,{key:0,type:"success",size:"small"},{default:n(()=>[...e[0]||(e[0]=[m("是",-1)])]),_:1})):(p(),b("span",z,"否"))]),_:1}),i(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:n(({row:s})=>[m(c(y(s)),1)]),_:1}),i(t,{label:"快照·业务单创建时间",width:"190"},{header:n(()=>[e[1]||(e[1]=M("span",null,"快照·业务单创建时间",-1)),i(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:n(()=>[i(r,{class:"assign-log-col-hint"},{default:n(()=>[i(V(E))]),_:1})]),_:1})]),default:n(({row:s})=>[m(c(x(s)),1)]),_:1}),i(t,{label:"操作人",width:"110",prop:"operator_name"}),i(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),i(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,d.value]])])}}}),Ct=K(G,[["__scopeId","data-v-f670e3e6"]]);export{Ct as default};
@@ -1 +1 @@
import{i as L,L as T,N as V,T as D,M as z,W as M}from"./element-plus-lurTijPg.js";import{ab as O}from"./tcm-BWcL4HTv.js";import{f as P,b as F,w as j,ak as a,I as n,a as i,aN as s,O as m,H as w,aP as A,G as g,F as H,J as R}from"./@vue/runtime-core-C6bnekPw.js";import{y as d,n as k}from"./@vue/reactivity-DiY1c2vO.js";import{Q as c}from"./@vue/shared-mAAVTE9n.js";import{_ as G}from"./index-hTkp7Jz4.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@element-plus/icons-vue-HOEUG8sr.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const J={class:"case-record-list"},Q={key:0,class:"mb-3 flex justify-end"},W={key:0},Y={key:1,class:"text-gray-400"},q={class:"void-detail text-xs text-gray-500 mt-1"},K=P({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const _=y,b=C,l=k([]),p=k(!1),u=async()=>{if(_.diagnosisId){p.value=!0;try{const e=await O({diagnosis_id:_.diagnosisId});l.value=Array.isArray(e)?e:[]}catch(e){console.error("获取病历记录失败:",e),l.value=[]}finally{p.value=!1}}},S=e=>{if(!e)return"";const t=new Date(e*1e3);return`${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,"0")}-${String(t.getDate()).padStart(2,"0")} ${String(t.getHours()).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`},$=e=>{b("view",e)},B=()=>{b("openPrescription")};return F(()=>{u()}),j(()=>_.diagnosisId,()=>{u()}),x({refresh:u}),(e,t)=>{const h=L,r=V,v=D,E=z,N=M,I=T;return a(),n("div",J,[y.readOnly?w("",!0):(a(),n("div",Q,[i(h,{type:"primary",size:"small",onClick:B},{default:s(()=>[...t[0]||(t[0]=[m("开方",-1)])]),_:1})])),A((a(),g(E,{data:d(l),border:""},{default:s(()=>[i(r,{prop:"prescription_date",label:"就诊日期",width:"120"}),i(r,{prop:"visit_no",label:"门诊号",width:"120"}),i(r,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),i(r,{label:"处方摘要","min-width":"180"},{default:s(({row:o})=>[o.herbs&&o.herbs.length?(a(),n("span",W,c(o.herbs.slice(0,3).map(f=>`${f.name}${f.dosage}`).join("、"))+c(o.herbs.length>3?"...":""),1)):(a(),n("span",Y,"—"))]),_:1}),i(r,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),i(r,{label:"状态",width:"140",align:"center"},{default:s(({row:o})=>[o.void_status===1?(a(),n(H,{key:0},[i(v,{type:"danger",size:"small"},{default:s(()=>[...t[1]||(t[1]=[m("已作废",-1)])]),_:1}),R("div",q,c(o.void_by_name||"—")+" "+c(S(o.void_time)),1)],64)):(a(),g(v,{key:1,type:"success",size:"small"},{default:s(()=>[...t[2]||(t[2]=[m("正常",-1)])]),_:1}))]),_:1}),i(r,{label:"操作",width:"120",fixed:"right"},{default:s(({row:o})=>[i(h,{link:"",type:"primary",size:"small",onClick:f=>$(o)},{default:s(()=>[...t[3]||(t[3]=[m(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[I,d(p)]]),!d(p)&&d(l).length===0?(a(),g(N,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):w("",!0)])}}}),zt=G(K,[["__scopeId","data-v-043d2738"]]);export{zt as default};
import{i as L,L as T,N as V,T as D,M as z,W as M}from"./element-plus-lurTijPg.js";import{ac as O}from"./tcm-GRQUYn2R.js";import{f as P,b as F,w as j,ak as a,I as n,a as i,aN as s,O as m,H as w,aP as A,G as g,F as H,J as R}from"./@vue/runtime-core-C6bnekPw.js";import{y as d,n as k}from"./@vue/reactivity-DiY1c2vO.js";import{Q as c}from"./@vue/shared-mAAVTE9n.js";import{_ as G}from"./index-DKxtBL_b.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@element-plus/icons-vue-HOEUG8sr.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const J={class:"case-record-list"},Q={key:0,class:"mb-3 flex justify-end"},W={key:0},Y={key:1,class:"text-gray-400"},q={class:"void-detail text-xs text-gray-500 mt-1"},K=P({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const _=y,h=C,l=k([]),p=k(!1),u=async()=>{if(_.diagnosisId){p.value=!0;try{const e=await O({diagnosis_id:_.diagnosisId});l.value=Array.isArray(e)?e:[]}catch(e){console.error("获取病历记录失败:",e),l.value=[]}finally{p.value=!1}}},S=e=>{if(!e)return"";const t=new Date(e*1e3);return`${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,"0")}-${String(t.getDate()).padStart(2,"0")} ${String(t.getHours()).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`},$=e=>{h("view",e)},B=()=>{h("openPrescription")};return F(()=>{u()}),j(()=>_.diagnosisId,()=>{u()}),x({refresh:u}),(e,t)=>{const b=L,r=V,v=D,E=z,N=M,I=T;return a(),n("div",J,[y.readOnly?w("",!0):(a(),n("div",Q,[i(b,{type:"primary",size:"small",onClick:B},{default:s(()=>[...t[0]||(t[0]=[m("开方",-1)])]),_:1})])),A((a(),g(E,{data:d(l),border:""},{default:s(()=>[i(r,{prop:"prescription_date",label:"就诊日期",width:"120"}),i(r,{prop:"visit_no",label:"门诊号",width:"120"}),i(r,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),i(r,{label:"处方摘要","min-width":"180"},{default:s(({row:o})=>[o.herbs&&o.herbs.length?(a(),n("span",W,c(o.herbs.slice(0,3).map(f=>`${f.name}${f.dosage}`).join("、"))+c(o.herbs.length>3?"...":""),1)):(a(),n("span",Y,"—"))]),_:1}),i(r,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),i(r,{label:"状态",width:"140",align:"center"},{default:s(({row:o})=>[o.void_status===1?(a(),n(H,{key:0},[i(v,{type:"danger",size:"small"},{default:s(()=>[...t[1]||(t[1]=[m("已作废",-1)])]),_:1}),R("div",q,c(o.void_by_name||"—")+" "+c(S(o.void_time)),1)],64)):(a(),g(v,{key:1,type:"success",size:"small"},{default:s(()=>[...t[2]||(t[2]=[m("正常",-1)])]),_:1}))]),_:1}),i(r,{label:"操作",width:"120",fixed:"right"},{default:s(({row:o})=>[i(b,{link:"",type:"primary",size:"small",onClick:f=>$(o)},{default:s(()=>[...t[3]||(t[3]=[m(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[I,d(p)]]),!d(p)&&d(l).length===0?(a(),g(N,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):w("",!0)])}}}),zt=G(K,[["__scopeId","data-v-043d2738"]]);export{zt as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,2 +0,0 @@
import{W as Q,v as Z,d as q,a as K}from"./element-plus-lurTijPg.js";import{_ as Y}from"./picker-CIEIqcz7.js";import{e as ee,c as te,i as S,_ as ie}from"./index-hTkp7Jz4.js";import{s as A}from"./@vue/runtime-dom-DDAG46FW.js";import{b as B,G as se}from"./@element-plus/icons-vue-HOEUG8sr.js";import{d as oe,a as T}from"./patient-rB-xF1_j.js";import{f as ne,w as re,ak as i,I as n,a as l,aN as c,J as a,H as d,F as v,ap as k,G as I}from"./@vue/runtime-core-C6bnekPw.js";import{n as w,y as C}from"./@vue/reactivity-DiY1c2vO.js";import{Q as z}from"./@vue/shared-mAAVTE9n.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./lodash-D3kF6u-c.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./index-NODwdV06.js";import"./index-DQyvoLLY.js";import"./index.vue_vue_type_script_setup_true_lang-CB1JgtXC.js";import"./index-Qf24LWMn.js";import"./index-By8JT914.js";import"./cos-js-sdk-v5-DSMN0LUT.js";import"./file-CdkUkN_s.js";import"./index.vue_vue_type_script_setup_true_lang-SfFZFG0h.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./usePaging-VsbTxSU0.js";import"./vuedraggable-5bKFmC7X.js";import"./vue-BVs68q8v.js";import"./@vue/compiler-dom-CqayqxS7.js";import"./@vue/compiler-core-CvbQOXIO.js";import"./sortablejs-dsEAUXlE.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const le={class:"note-timeline-wrap"},ae={key:0,class:"timeline-actions"},me={class:"upload-trigger"},de={class:"upload-trigger"},pe={key:1,class:"note-timeline"},ce={class:"timeline-date"},ue={class:"timeline-body"},ge={key:0,class:"timeline-content"},_e={key:1,class:"timeline-images"},fe={key:2,class:"timeline-images"},ve={key:0,class:"thumb-wrap"},he={key:1,class:"file-wrap"},ye=["href","title"],ke={class:"file-name"},G=8e3,Ie=ne({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(m,{emit:L}){const u=m,b=L,R=ee(),g=t=>R.getImageUrl(t),x=w([]),E=w([]),_=w(0),f=w(0),$=["jpg","jpeg","png","gif","bmp","webp","svg"],N=t=>{var s;const e=((s=t.split(".").pop())==null?void 0:s.toLowerCase().split("?")[0])||"";return $.includes(e)},M=t=>{var s;const e=t.split("/");return decodeURIComponent(((s=e[e.length-1])==null?void 0:s.split("?")[0])||"文件")},j=t=>t.filter(N).map(g),O=(t,e)=>{const s=t.filter(N),h=t[e];return s.indexOf(h)},W=t=>t?t.split(`
`).filter(Boolean):[],X=t=>{if(!u.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=_.value){_.value=e.length;return}const s=e.slice(_.value);_.value=e.length,s.length>0&&T({diagnosis_id:u.diagnosisId,tongue_images:s}).then(()=>{S.msgSuccess("舌苔照片已添加"),b("refresh")})},H=t=>{if(!u.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=f.value){f.value=e.length;return}const s=e.slice(f.value);f.value=e.length,s.length>0&&T({diagnosis_id:u.diagnosisId,report_files:s}).then(()=>{S.msgSuccess("检查报告已添加"),b("refresh")})};re(()=>u.notes,()=>{x.value=[],E.value=[],_.value=0,f.value=0});const V=async(t,e,s)=>{try{await K.confirm("确认删除?","提示",{type:"warning"})}catch{return}await oe({note_id:t,image_type:e,image_path:s}),S.msgSuccess("已删除"),b("refresh")};return(t,e)=>{const s=te,h=Y,U=Z,y=q,J=Q;return i(),n("div",le,[!m.readonly&&m.diagnosisId?(i(),n("div",ae,[l(h,{modelValue:x.value,"onUpdate:modelValue":e[0]||(e[0]=o=>x.value=o),limit:99,type:"image","exclude-domain":!0,onChange:X},{upload:c(()=>[a("div",me,[l(s,{size:20,name:"el-icon-Plus"}),e[2]||(e[2]=a("span",null,"舌苔照片",-1))])]),_:1},8,["modelValue"]),l(h,{modelValue:E.value,"onUpdate:modelValue":e[1]||(e[1]=o=>E.value=o),limit:99,type:"file","exclude-domain":!0,onChange:H},{upload:c(()=>[a("div",de,[l(s,{size:20,name:"el-icon-Plus"}),e[3]||(e[3]=a("span",null,"检查报告",-1))])]),_:1},8,["modelValue"])])):d("",!0),m.notes.length?(i(),n("div",pe,[(i(!0),n(v,null,k(m.notes,o=>{var D,F;return i(),n("div",{key:o.id,class:"timeline-node"},[e[6]||(e[6]=a("div",{class:"timeline-dot"},null,-1)),a("div",ce,z(o.note_date),1),a("div",ue,[o.content?(i(),n("div",ge,[(i(!0),n(v,null,k(W(o.content),(r,p)=>(i(),n("div",{key:p,class:"content-line"},z(r),1))),128))])):d("",!0),(D=o.tongue_images)!=null&&D.length?(i(),n("div",_e,[e[4]||(e[4]=a("span",{class:"images-label"},"舌苔照片",-1)),(i(!0),n(v,null,k(o.tongue_images,(r,p)=>(i(),n("div",{key:p,class:"thumb-wrap"},[l(U,{src:g(r),"preview-src-list":o.tongue_images.map(g),"initial-index":p,"z-index":G,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),m.readonly?d("",!0):(i(),I(y,{key:0,class:"thumb-delete",onClick:A(P=>V(o.id,"tongue_images",r),["stop"])},{default:c(()=>[l(C(B))]),_:1},8,["onClick"]))]))),128))])):d("",!0),(F=o.report_files)!=null&&F.length?(i(),n("div",fe,[e[5]||(e[5]=a("span",{class:"images-label"},"检查报告",-1)),(i(!0),n(v,null,k(o.report_files,(r,p)=>(i(),n(v,{key:p},[N(r)?(i(),n("div",ve,[l(U,{src:g(r),"preview-src-list":j(o.report_files),"initial-index":O(o.report_files,p),"z-index":G,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),m.readonly?d("",!0):(i(),I(y,{key:0,class:"thumb-delete",onClick:A(P=>V(o.id,"report_files",r),["stop"])},{default:c(()=>[l(C(B))]),_:1},8,["onClick"]))])):(i(),n("div",he,[a("a",{href:g(r),target:"_blank",class:"file-link",title:M(r)},[l(y,{size:20},{default:c(()=>[l(C(se))]),_:1}),a("span",ke,z(M(r)),1)],8,ye),m.readonly?d("",!0):(i(),I(y,{key:0,class:"file-delete",onClick:A(P=>V(o.id,"report_files",r),["stop"])},{default:c(()=>[l(C(B))]),_:1},8,["onClick"]))]))],64))),128))])):d("",!0)])])}),128))])):d("",!0),!m.notes.length&&m.readonly?(i(),I(J,{key:2,description:"暂无备注","image-size":48})):d("",!0)])}}}),It=ie(Ie,[["__scopeId","data-v-f77bb3f4"]]);export{It as default};
@@ -1 +0,0 @@
.timeline-actions[data-v-f77bb3f4]{display:flex;flex-direction:column;gap:12px;margin-bottom:12px;padding-bottom:12px;border-bottom:1px dashed #ebeef5}.upload-trigger[data-v-f77bb3f4]{width:90px;height:90px;border:1px dashed #dcdfe6;border-radius:6px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;cursor:pointer;color:#909399;font-size:12px;transition:border-color .2s}.upload-trigger[data-v-f77bb3f4]:hover{border-color:#409eff;color:#409eff}.note-timeline[data-v-f77bb3f4]{position:relative;padding-left:22px}.note-timeline[data-v-f77bb3f4]:before{content:"";position:absolute;left:6px;top:4px;bottom:4px;width:2px;background:#ebeef5;border-radius:1px}.timeline-node[data-v-f77bb3f4]{position:relative;padding-bottom:16px}.timeline-node[data-v-f77bb3f4]:last-child{padding-bottom:0}.timeline-dot[data-v-f77bb3f4]{position:absolute;left:-19px;top:5px;width:10px;height:10px;border-radius:50%;background:#409eff;border:2px solid #ecf5ff}.timeline-date[data-v-f77bb3f4]{font-size:13px;font-weight:600;color:#303133;margin-bottom:6px;font-family:IBM Plex Mono,Consolas,monospace}.timeline-body[data-v-f77bb3f4]{display:flex;flex-direction:column;gap:6px}.timeline-content[data-v-f77bb3f4]{display:flex;flex-direction:column;gap:2px}.content-line[data-v-f77bb3f4]{font-size:12.5px;color:#606266;line-height:1.6;word-break:break-word}.timeline-images[data-v-f77bb3f4]{display:flex;flex-wrap:wrap;align-items:center;gap:6px}.images-label[data-v-f77bb3f4]{font-size:12px;color:#909399;flex-shrink:0}.thumb-wrap[data-v-f77bb3f4]{position:relative;display:inline-block}.timeline-thumb[data-v-f77bb3f4]{width:64px;height:64px;border-radius:6px;border:1px solid #ebeef5;cursor:pointer;transition:transform .15s ease}.timeline-thumb[data-v-f77bb3f4]:hover{transform:scale(1.05)}.thumb-delete[data-v-f77bb3f4]{position:absolute;top:-6px;right:-6px;font-size:16px;color:#f56c6c;background:#fff;border-radius:50%;cursor:pointer;display:none}.thumb-wrap:hover .thumb-delete[data-v-f77bb3f4]{display:block}.file-wrap[data-v-f77bb3f4]{position:relative;display:inline-flex;align-items:center}.file-link[data-v-f77bb3f4]{display:inline-flex;align-items:center;gap:4px;padding:6px 10px;border:1px solid #ebeef5;border-radius:6px;text-decoration:none;color:#409eff;font-size:12px;max-width:180px;transition:border-color .2s}.file-link[data-v-f77bb3f4]:hover{border-color:#409eff}.file-name[data-v-f77bb3f4]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:120px}.file-delete[data-v-f77bb3f4]{position:absolute;top:-6px;right:-6px;font-size:16px;color:#f56c6c;background:#fff;border-radius:50%;cursor:pointer;display:none}.file-wrap:hover .file-delete[data-v-f77bb3f4]{display:block}
@@ -0,0 +1,2 @@
import{i as se,W as ne,C as le,V as ae,v as re,d as me,a as de}from"./element-plus-lurTijPg.js";import{_ as pe}from"./picker-CgNjB8GY.js";import{e as ue,c as ce,i as g,_ as ge}from"./index-DKxtBL_b.js";import{s as U}from"./@vue/runtime-dom-DDAG46FW.js";import{b as z,G as fe}from"./@element-plus/icons-vue-HOEUG8sr.js";import{a as P,d as ve}from"./patient-CWXcCXvQ.js";import{h as _e}from"./perm-cJuZHqXB.js";import{f as ye,w as he,ak as o,I as n,G as k,aN as r,O as M,H as p,a as l,J as m,F as w,ap as E,A as ke}from"./@vue/runtime-core-C6bnekPw.js";import{n as f,y as N}from"./@vue/reactivity-DiY1c2vO.js";import{Q as T}from"./@vue/shared-mAAVTE9n.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./lodash-D3kF6u-c.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./index-DM0Uukea.js";import"./index-CHT25lVJ.js";import"./index.vue_vue_type_script_setup_true_lang-CB1JgtXC.js";import"./index-tZCDKLYp.js";import"./index-BfzWyWIF.js";import"./cos-js-sdk-v5-DSMN0LUT.js";import"./file-DXygoiIs.js";import"./index.vue_vue_type_script_setup_true_lang-SfFZFG0h.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./usePaging-VsbTxSU0.js";import"./vuedraggable-5bKFmC7X.js";import"./vue-BVs68q8v.js";import"./@vue/compiler-dom-CqayqxS7.js";import"./@vue/compiler-core-CvbQOXIO.js";import"./sortablejs-dsEAUXlE.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},xe={class:"upload-trigger"},Ve={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ne={class:"timeline-body"},be={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},Ae={key:2,class:"timeline-images"},De={key:0,class:"thumb-wrap"},Be={key:1,class:"file-wrap"},Ue=["href","title"],ze={class:"file-name"},O=8e3,Pe=ye({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(d,{emit:j}){const u=d,I=j,X=ke(()=>_e(["doctor.appointment/addDoctorNote"])),v=f(!1),C=f(""),b=f(!1),H=ue(),_=t=>H.getImageUrl(t),S=f([]),A=f([]),y=f(0),h=f(0),J=["jpg","jpeg","png","gif","bmp","webp","svg"],D=t=>{var s;const e=((s=t.split(".").pop())==null?void 0:s.toLowerCase().split("?")[0])||"";return J.includes(e)},$=t=>{var s;const e=t.split("/");return decodeURIComponent(((s=e[e.length-1])==null?void 0:s.split("?")[0])||"文件")},Q=t=>t.filter(D).map(_),Z=(t,e)=>{const s=t.filter(D),x=t[e];return s.indexOf(x)},q=t=>t?t.split(`
`).filter(Boolean):[],K=t=>{if(!u.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=y.value){y.value=e.length;return}const s=e.slice(y.value);y.value=e.length,s.length>0&&P({diagnosis_id:u.diagnosisId,tongue_images:s}).then(()=>{g.msgSuccess("舌苔照片已添加"),I("refresh")})},Y=t=>{if(!u.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=h.value){h.value=e.length;return}const s=e.slice(h.value);h.value=e.length,s.length>0&&P({diagnosis_id:u.diagnosisId,report_files:s}).then(()=>{g.msgSuccess("检查报告已添加"),I("refresh")})};he(()=>u.notes,()=>{S.value=[],A.value=[],y.value=0,h.value=0});const ee=async()=>{if(!u.diagnosisId){g.msgWarning("当前无诊单,无法添加备注");return}const t=C.value.trim();if(!t){g.msgWarning("请输入备注内容");return}b.value=!0;try{await P({diagnosis_id:u.diagnosisId,content:t}),g.msgSuccess("备注保存成功"),C.value="",v.value=!1,I("refresh")}catch(e){g.msgError((e==null?void 0:e.message)||"保存失败")}finally{b.value=!1}},B=async(t,e,s)=>{try{await de.confirm("确认删除?","提示",{type:"warning"})}catch{return}await ve({note_id:t,image_type:e,image_path:s}),g.msgSuccess("已删除"),I("refresh")};return(t,e)=>{const s=se,x=ce,F=pe,G=re,V=me,te=ne,ie=le,oe=ae;return o(),n("div",we,[!d.readonly&&d.diagnosisId?(o(),n("div",Ie,[X.value?(o(),k(s,{key:0,type:"primary",plain:"",size:"small",onClick:e[0]||(e[0]=i=>v.value=!0)},{default:r(()=>[...e[6]||(e[6]=[M(" 添加备注 ",-1)])]),_:1})):p("",!0),l(F,{modelValue:S.value,"onUpdate:modelValue":e[1]||(e[1]=i=>S.value=i),limit:99,type:"image","exclude-domain":!0,onChange:K},{upload:r(()=>[m("div",Ce,[l(x,{size:20,name:"el-icon-Plus"}),e[7]||(e[7]=m("span",null,"舌苔照片",-1))])]),_:1},8,["modelValue"]),l(F,{modelValue:A.value,"onUpdate:modelValue":e[2]||(e[2]=i=>A.value=i),limit:99,type:"file","exclude-domain":!0,onChange:Y},{upload:r(()=>[m("div",xe,[l(x,{size:20,name:"el-icon-Plus"}),e[8]||(e[8]=m("span",null,"检查报告",-1))])]),_:1},8,["modelValue"])])):p("",!0),d.notes.length?(o(),n("div",Ve,[(o(!0),n(w,null,E(d.notes,i=>{var L,R;return o(),n("div",{key:i.id,class:"timeline-node"},[e[11]||(e[11]=m("div",{class:"timeline-dot"},null,-1)),m("div",Ee,T(i.note_date),1),m("div",Ne,[i.content?(o(),n("div",be,[(o(!0),n(w,null,E(q(i.content),(a,c)=>(o(),n("div",{key:c,class:"content-line"},T(a),1))),128))])):p("",!0),(L=i.tongue_images)!=null&&L.length?(o(),n("div",Se,[e[9]||(e[9]=m("span",{class:"images-label"},"舌苔照片",-1)),(o(!0),n(w,null,E(i.tongue_images,(a,c)=>(o(),n("div",{key:c,class:"thumb-wrap"},[l(G,{src:_(a),"preview-src-list":i.tongue_images.map(_),"initial-index":c,"z-index":O,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),d.readonly?p("",!0):(o(),k(V,{key:0,class:"thumb-delete",onClick:U(W=>B(i.id,"tongue_images",a),["stop"])},{default:r(()=>[l(N(z))]),_:1},8,["onClick"]))]))),128))])):p("",!0),(R=i.report_files)!=null&&R.length?(o(),n("div",Ae,[e[10]||(e[10]=m("span",{class:"images-label"},"检查报告",-1)),(o(!0),n(w,null,E(i.report_files,(a,c)=>(o(),n(w,{key:c},[D(a)?(o(),n("div",De,[l(G,{src:_(a),"preview-src-list":Q(i.report_files),"initial-index":Z(i.report_files,c),"z-index":O,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),d.readonly?p("",!0):(o(),k(V,{key:0,class:"thumb-delete",onClick:U(W=>B(i.id,"report_files",a),["stop"])},{default:r(()=>[l(N(z))]),_:1},8,["onClick"]))])):(o(),n("div",Be,[m("a",{href:_(a),target:"_blank",class:"file-link",title:$(a)},[l(V,{size:20},{default:r(()=>[l(N(fe))]),_:1}),m("span",ze,T($(a)),1)],8,Ue),d.readonly?p("",!0):(o(),k(V,{key:0,class:"file-delete",onClick:U(W=>B(i.id,"report_files",a),["stop"])},{default:r(()=>[l(N(z))]),_:1},8,["onClick"]))]))],64))),128))])):p("",!0)])])}),128))])):p("",!0),!d.notes.length&&d.readonly?(o(),k(te,{key:2,description:"暂无备注","image-size":48})):p("",!0),l(oe,{modelValue:v.value,"onUpdate:modelValue":e[5]||(e[5]=i=>v.value=i),title:"添加备注",width:"480px","append-to-body":""},{footer:r(()=>[l(s,{onClick:e[4]||(e[4]=i=>v.value=!1)},{default:r(()=>[...e[12]||(e[12]=[M("取消",-1)])]),_:1}),l(s,{type:"primary",loading:b.value,onClick:ee},{default:r(()=>[...e[13]||(e[13]=[M("保存",-1)])]),_:1},8,["loading"])]),default:r(()=>[l(ie,{modelValue:C.value,"onUpdate:modelValue":e[3]||(e[3]=i=>C.value=i),type:"textarea",rows:4,placeholder:"输入今日备注...",maxlength:"500","show-word-limit":""},null,8,["modelValue"])]),_:1},8,["modelValue"])])}}}),Mt=ge(Pe,[["__scopeId","data-v-530ad386"]]);export{Mt as default};
@@ -0,0 +1 @@
.timeline-actions[data-v-530ad386]{display:flex;flex-direction:column;align-items:flex-start;gap:12px;margin-bottom:12px;padding-bottom:12px;border-bottom:1px dashed #ebeef5}.upload-trigger[data-v-530ad386]{width:90px;height:90px;border:1px dashed #dcdfe6;border-radius:6px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;cursor:pointer;color:#909399;font-size:12px;transition:border-color .2s}.upload-trigger[data-v-530ad386]:hover{border-color:#409eff;color:#409eff}.note-timeline[data-v-530ad386]{position:relative;padding-left:22px}.note-timeline[data-v-530ad386]:before{content:"";position:absolute;left:6px;top:4px;bottom:4px;width:2px;background:#ebeef5;border-radius:1px}.timeline-node[data-v-530ad386]{position:relative;padding-bottom:16px}.timeline-node[data-v-530ad386]:last-child{padding-bottom:0}.timeline-dot[data-v-530ad386]{position:absolute;left:-19px;top:5px;width:10px;height:10px;border-radius:50%;background:#409eff;border:2px solid #ecf5ff}.timeline-date[data-v-530ad386]{font-size:13px;font-weight:600;color:#303133;margin-bottom:6px;font-family:IBM Plex Mono,Consolas,monospace}.timeline-body[data-v-530ad386]{display:flex;flex-direction:column;gap:6px}.timeline-content[data-v-530ad386]{display:flex;flex-direction:column;gap:2px}.content-line[data-v-530ad386]{font-size:12.5px;color:#606266;line-height:1.6;word-break:break-word}.timeline-images[data-v-530ad386]{display:flex;flex-wrap:wrap;align-items:center;gap:6px}.images-label[data-v-530ad386]{font-size:12px;color:#909399;flex-shrink:0}.thumb-wrap[data-v-530ad386]{position:relative;display:inline-block}.timeline-thumb[data-v-530ad386]{width:64px;height:64px;border-radius:6px;border:1px solid #ebeef5;cursor:pointer;transition:transform .15s ease}.timeline-thumb[data-v-530ad386]:hover{transform:scale(1.05)}.thumb-delete[data-v-530ad386]{position:absolute;top:-6px;right:-6px;font-size:16px;color:#f56c6c;background:#fff;border-radius:50%;cursor:pointer;display:none}.thumb-wrap:hover .thumb-delete[data-v-530ad386]{display:block}.file-wrap[data-v-530ad386]{position:relative;display:inline-flex;align-items:center}.file-link[data-v-530ad386]{display:inline-flex;align-items:center;gap:4px;padding:6px 10px;border:1px solid #ebeef5;border-radius:6px;text-decoration:none;color:#409eff;font-size:12px;max-width:180px;transition:border-color .2s}.file-link[data-v-530ad386]:hover{border-color:#409eff}.file-name[data-v-530ad386]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:120px}.file-delete[data-v-530ad386]{position:absolute;top:-6px;right:-6px;font-size:16px;color:#f56c6c;background:#fff;border-radius:50%;cursor:pointer;display:none}.file-wrap:hover .file-delete[data-v-530ad386]{display:block}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.card[data-v-7b1b11be]{background:var(--rx-surface);border:1px solid var(--rx-line);border-radius:14px;padding:18px;display:flex;flex-direction:column;gap:14px;box-shadow:var(--rx-shadow-sm);transition:box-shadow .2s ease}.card[data-v-7b1b11be]:hover{box-shadow:var(--rx-shadow)}.card-title[data-v-7b1b11be]{font-size:15px;font-weight:700;color:var(--rx-ink);display:flex;align-items:center;gap:10px;padding-left:10px;position:relative}.card-title[data-v-7b1b11be]:before{content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);width:3px;height:16px;border-radius:2px;background:linear-gradient(180deg,#4f8cff,#2563eb)}.case-sub[data-v-7b1b11be]{font-size:12px;font-weight:400;color:var(--rx-soft);font-family:var(--rx-font-data)}.grid-block[data-v-7b1b11be]{display:flex;flex-direction:column;gap:10px;padding-top:12px;border-top:1px dashed var(--rx-line)}.grid-block[data-v-7b1b11be]:first-of-type{border-top:none;padding-top:0}.grid-title[data-v-7b1b11be]{font-size:13px;font-weight:600;color:var(--rx-accent-strong);letter-spacing:.02em;display:flex;align-items:center;gap:6px}.grid-title[data-v-7b1b11be]:before{content:"";width:4px;height:4px;border-radius:50%;background:var(--rx-accent)}.kv-grid[data-v-7b1b11be]{display:grid;gap:8px 16px}.kv-grid.one[data-v-7b1b11be]{grid-template-columns:1fr}.kv-grid.two[data-v-7b1b11be]{grid-template-columns:repeat(2,1fr)}.kv-grid.three[data-v-7b1b11be]{grid-template-columns:repeat(3,1fr)}.kv-grid.four[data-v-7b1b11be]{grid-template-columns:repeat(4,1fr)}[data-v-7b1b11be] .kv-item{display:flex;gap:6px;font-size:12.5px;line-height:1.55;min-width:0}[data-v-7b1b11be] .kv-item.multiline{flex-direction:column;gap:2px}[data-v-7b1b11be] .kv-item .kv-label{color:var(--rx-soft);flex-shrink:0;min-width:58px}[data-v-7b1b11be] .kv-item .kv-value{color:var(--rx-ink);word-break:break-word;flex:1;font-weight:500}[data-v-7b1b11be] .kv-item.empty .kv-value{color:#c0c4cc;font-weight:400}[data-v-7b1b11be] .kv-item.multiline .kv-value{white-space:pre-wrap;font-weight:400}[data-v-7b1b11be] .metric-kv-item .metric-kv-value{display:inline-flex;align-items:center;gap:4px}[data-v-7b1b11be] .metric-kv-item .metric-kv-value.high{color:#dc2626;font-weight:700}[data-v-7b1b11be] .metric-up{font-size:14px;line-height:1;color:#dc2626}.rx-note[data-v-7b1b11be]{font-size:13px;color:var(--rx-muted);line-height:1.6;padding:12px 14px;background:linear-gradient(135deg,#fafbfd,#f5f7fb);border-radius:8px;border-left:3px solid var(--rx-accent)}
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.card[data-v-5de08f8e]{background:var(--rx-surface);border:1px solid var(--rx-line);border-radius:14px;padding:18px;display:flex;flex-direction:column;gap:14px;box-shadow:var(--rx-shadow-sm);transition:box-shadow .2s ease}.card[data-v-5de08f8e]:hover{box-shadow:var(--rx-shadow)}.card-title[data-v-5de08f8e]{font-size:15px;font-weight:700;color:var(--rx-ink);display:flex;align-items:center;gap:10px;padding-left:10px;position:relative}.card-title[data-v-5de08f8e]:before{content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);width:3px;height:16px;border-radius:2px;background:linear-gradient(180deg,#4f8cff,#2563eb)}.case-sub[data-v-5de08f8e]{font-size:12px;font-weight:400;color:var(--rx-soft);font-family:var(--rx-font-data)}.grid-block[data-v-5de08f8e]{display:flex;flex-direction:column;gap:10px;padding-top:12px;border-top:1px dashed var(--rx-line)}.grid-block[data-v-5de08f8e]:first-of-type{border-top:none;padding-top:0}.grid-title[data-v-5de08f8e]{font-size:13px;font-weight:600;color:var(--rx-accent-strong);letter-spacing:.02em;display:flex;align-items:center;gap:6px}.grid-title[data-v-5de08f8e]:before{content:"";width:4px;height:4px;border-radius:50%;background:var(--rx-accent)}.kv-grid[data-v-5de08f8e]{display:grid;gap:8px 16px}.kv-grid.one[data-v-5de08f8e]{grid-template-columns:1fr}.kv-grid.two[data-v-5de08f8e]{grid-template-columns:repeat(2,1fr)}.kv-grid.three[data-v-5de08f8e]{grid-template-columns:repeat(3,1fr)}.kv-grid.four[data-v-5de08f8e]{grid-template-columns:repeat(4,1fr)}[data-v-5de08f8e] .kv-item{display:flex;gap:6px;font-size:12.5px;line-height:1.55;min-width:0}[data-v-5de08f8e] .kv-item.multiline{flex-direction:column;gap:2px}[data-v-5de08f8e] .kv-item .kv-label{color:var(--rx-soft);flex-shrink:0;min-width:58px}[data-v-5de08f8e] .kv-item .kv-value{color:var(--rx-ink);word-break:break-word;flex:1;font-weight:500}[data-v-5de08f8e] .kv-item.empty .kv-value{color:#c0c4cc;font-weight:400}[data-v-5de08f8e] .kv-item.multiline .kv-value{white-space:pre-wrap;font-weight:400}[data-v-5de08f8e] .metric-kv-item .metric-kv-value{display:inline-flex;align-items:center;gap:4px}[data-v-5de08f8e] .metric-kv-item .metric-kv-value.high{color:#dc2626;font-weight:700}[data-v-5de08f8e] .metric-up{font-size:14px;line-height:1;color:#dc2626}.rx-note[data-v-5de08f8e]{font-size:13px;color:var(--rx-muted);line-height:1.6;padding:12px 14px;background:linear-gradient(135deg,#fafbfd,#f5f7fb);border-radius:8px;border-left:3px solid var(--rx-accent)}
@@ -1 +1 @@
import{m as x,f as y,a as B}from"./diag-display-DCz_VAqj.js";import{f as w,ak as I,I as P,J as a,H as N}from"./@vue/runtime-core-C6bnekPw.js";import{Q as i}from"./@vue/shared-mAAVTE9n.js";import{y as e}from"./@vue/reactivity-DiY1c2vO.js";import{_ as V}from"./index-hTkp7Jz4.js";import"./lodash-D3kF6u-c.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./element-plus-lurTijPg.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./@element-plus/icons-vue-HOEUG8sr.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const D={class:"card patient-card"},E={class:"patient-hero"},G={class:"patient-name"},H={class:"patient-meta"},J={key:0},Q=w({__name:"PatientInfoCard",props:{apt:{default:()=>({})},diag:{default:()=>({})}},setup(t){return(S,o)=>{var m,r,n,d,p,s,c,l,g,f,u,h,v,k,C;return I(),P("div",D,[o[0]||(o[0]=a("div",{class:"card-title"},"患者信息",-1)),a("div",E,[a("div",G,i(((m=t.apt)==null?void 0:m.patient_name)||"—"),1),a("div",H,[a("div",null,i(e(x)((r=t.apt)==null?void 0:r.patient_phone))+" · "+i(e(y)((n=t.diag)==null?void 0:n.gender))+" · "+i(((d=t.diag)==null?void 0:d.age)!=null?t.diag.age+"岁":"—"),1),a("div",null,i((p=t.diag)!=null&&p.height?t.diag.height+"cm":"—")+" / "+i((s=t.diag)!=null&&s.weight?t.diag.weight+"kg":"—")+" · "+i(((c=t.diag)==null?void 0:c.region)||"—"),1),a("div",null," 预约:"+i((l=t.apt)==null?void 0:l.appointment_date)+" "+i((g=t.apt)==null?void 0:g.appointment_time)+" · "+i(e(B)((f=t.apt)==null?void 0:f.period)),1),a("div",null,"医生:"+i(((u=t.apt)==null?void 0:u.doctor_name)||"—")+" 医助"+i(((h=t.apt)==null?void 0:h.assistant_name)||"—"),1),a("div",null," 状态:"+i(((v=t.apt)==null?void 0:v.status_desc)||"—")+" · "+i((k=t.apt)!=null&&k.has_prescription?"已开方":"未开方"),1),(C=t.apt)!=null&&C.remark?(I(),P("div",J,"备注:"+i(t.apt.remark),1)):N("",!0)])])])}}}),Ct=V(Q,[["__scopeId","data-v-e3476da5"]]);export{Ct as default};
import{m as x,f as y,a as B}from"./diag-display-DCz_VAqj.js";import{f as w,ak as I,I as P,J as a,H as N}from"./@vue/runtime-core-C6bnekPw.js";import{Q as i}from"./@vue/shared-mAAVTE9n.js";import{y as e}from"./@vue/reactivity-DiY1c2vO.js";import{_ as V}from"./index-DKxtBL_b.js";import"./lodash-D3kF6u-c.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./element-plus-lurTijPg.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./@element-plus/icons-vue-HOEUG8sr.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const b={class:"card patient-card"},D={class:"patient-hero"},E={class:"patient-name"},G={class:"patient-meta"},H={key:0},J=w({__name:"PatientInfoCard",props:{apt:{default:()=>({})},diag:{default:()=>({})}},setup(t){return(Q,o)=>{var m,r,n,d,p,s,c,l,g,f,u,h,v,k,C;return I(),P("div",b,[o[0]||(o[0]=a("div",{class:"card-title"},"患者信息",-1)),a("div",D,[a("div",E,i(((m=t.apt)==null?void 0:m.patient_name)||"—"),1),a("div",G,[a("div",null,i(e(x)((r=t.apt)==null?void 0:r.patient_phone))+" · "+i(e(y)((n=t.diag)==null?void 0:n.gender))+" · "+i(((d=t.diag)==null?void 0:d.age)!=null?t.diag.age+"岁":"—"),1),a("div",null,i((p=t.diag)!=null&&p.height?t.diag.height+"cm":"—")+" / "+i((s=t.diag)!=null&&s.weight?t.diag.weight+"kg":"—")+" · "+i(((c=t.diag)==null?void 0:c.region)||"—"),1),a("div",null," 预约:"+i((l=t.apt)==null?void 0:l.appointment_date)+" "+i((g=t.apt)==null?void 0:g.appointment_time)+" · "+i(e(B)((f=t.apt)==null?void 0:f.period)),1),a("div",null,"医生:"+i(((u=t.apt)==null?void 0:u.doctor_name)||"—")+" 客服"+i(((h=t.apt)==null?void 0:h.assistant_name)||"—"),1),a("div",null," 状态:"+i(((v=t.apt)==null?void 0:v.status_desc)||"—")+" · "+i((k=t.apt)!=null&&k.has_prescription?"已开方":"未开方"),1),(C=t.apt)!=null&&C.remark?(I(),P("div",H,"备注:"+i(t.apt.remark),1)):N("",!0)])])])}}}),Ct=V(J,[["__scopeId","data-v-1b0de09c"]]);export{Ct as default};
@@ -1 +1 @@
.card[data-v-e3476da5]{background:var(--rx-surface);border:1px solid var(--rx-line);border-radius:14px;padding:18px;display:flex;flex-direction:column;gap:14px;box-shadow:var(--rx-shadow-sm);transition:box-shadow .2s ease}.card[data-v-e3476da5]:hover{box-shadow:var(--rx-shadow)}.card-title[data-v-e3476da5]{font-size:15px;font-weight:700;color:var(--rx-ink);display:flex;align-items:center;gap:10px;padding-left:10px;position:relative}.card-title[data-v-e3476da5]:before{content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);width:3px;height:16px;border-radius:2px;background:linear-gradient(180deg,#4f8cff,#2563eb)}.patient-hero[data-v-e3476da5]{background:linear-gradient(135deg,#f5f8ff,#eef3ff);border:1px solid #dde7ff;border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:6px;position:relative;overflow:hidden}.patient-hero[data-v-e3476da5]:after{content:"";position:absolute;right:-20px;top:-20px;width:100px;height:100px;border-radius:50%;background:radial-gradient(circle,rgba(79,140,255,.08) 0%,transparent 70%)}.patient-name[data-v-e3476da5]{font-size:22px;font-weight:700;color:var(--rx-ink);letter-spacing:-.01em}.patient-meta[data-v-e3476da5]{color:var(--rx-muted);font-size:13px;line-height:1.7;display:flex;flex-direction:column;gap:2px;position:relative;z-index:1}
.card[data-v-1b0de09c]{background:var(--rx-surface);border:1px solid var(--rx-line);border-radius:14px;padding:18px;display:flex;flex-direction:column;gap:14px;box-shadow:var(--rx-shadow-sm);transition:box-shadow .2s ease}.card[data-v-1b0de09c]:hover{box-shadow:var(--rx-shadow)}.card-title[data-v-1b0de09c]{font-size:15px;font-weight:700;color:var(--rx-ink);display:flex;align-items:center;gap:10px;padding-left:10px;position:relative}.card-title[data-v-1b0de09c]:before{content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);width:3px;height:16px;border-radius:2px;background:linear-gradient(180deg,#4f8cff,#2563eb)}.patient-hero[data-v-1b0de09c]{background:linear-gradient(135deg,#f5f8ff,#eef3ff);border:1px solid #dde7ff;border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:6px;position:relative;overflow:hidden}.patient-hero[data-v-1b0de09c]:after{content:"";position:absolute;right:-20px;top:-20px;width:100px;height:100px;border-radius:50%;background:radial-gradient(circle,rgba(79,140,255,.08) 0%,transparent 70%)}.patient-name[data-v-1b0de09c]{font-size:22px;font-weight:700;color:var(--rx-ink);letter-spacing:-.01em}.patient-meta[data-v-1b0de09c]{color:var(--rx-muted);font-size:13px;line-height:1.7;display:flex;flex-direction:column;gap:2px;position:relative;z-index:1}

Some files were not shown because too many files have changed in this diff Show More