Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc1228a310 | ||
|
|
8b9df1154c | ||
|
|
5f53aa0bbd |
@@ -1,6 +1,6 @@
|
||||
import App from './App'
|
||||
|
||||
var baseUrl = 'https://admin.zhenyangtang.com.cn/'
|
||||
var baseUrl = 'https://xt.zhenyangtang.com.cn/'
|
||||
|
||||
function joinApiUrl(base, path) {
|
||||
const b = String(base || '').replace(/\/+$/, '')
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
{
|
||||
"name" : "tongji-uniapp",
|
||||
"appid" : "__UNI__TONGJI01",
|
||||
"description" : "血糖管理 / 日常护理",
|
||||
"versionName" : "1.0.0",
|
||||
"versionCode" : "100",
|
||||
"transformPx" : false,
|
||||
"app-plus" : {
|
||||
"usingComponents" : true,
|
||||
"nvueStyleCompiler" : "uni-app",
|
||||
"compilerVersion" : 3,
|
||||
"splashscreen" : {
|
||||
"alwaysShowBeforeRender" : true,
|
||||
"waiting" : true,
|
||||
"autoclose" : true,
|
||||
"delay" : 0
|
||||
},
|
||||
"modules" : {},
|
||||
"distribute" : {
|
||||
"android" : {
|
||||
"permissions" : [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>"
|
||||
]
|
||||
},
|
||||
"ios" : {},
|
||||
"sdkConfigs" : {}
|
||||
}
|
||||
},
|
||||
"quickapp" : {},
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx79b9a0bfbfe7cbcd",
|
||||
"lazyCodeLoading" : "requiredComponents",
|
||||
"setting" : {
|
||||
"urlCheck" : false,
|
||||
"minified" : true
|
||||
},
|
||||
"optimization" : {
|
||||
"subPackages" : true
|
||||
},
|
||||
"usingComponents" : true,
|
||||
"requiredBackgroundModes" : [ "audio" ],
|
||||
"plugins" : {
|
||||
"WechatSI" : {
|
||||
"version" : "0.3.5",
|
||||
"provider" : "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mp-alipay" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-baidu" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-toutiao" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"uniStatistics" : {
|
||||
"enable" : false
|
||||
},
|
||||
"vueVersion" : "3"
|
||||
}
|
||||
{
|
||||
"name" : "tongji-uniapp",
|
||||
"appid" : "__UNI__TONGJI01",
|
||||
"description" : "血糖管理 / 日常护理",
|
||||
"versionName" : "1.0.0",
|
||||
"versionCode" : "100",
|
||||
"transformPx" : false,
|
||||
"app-plus" : {
|
||||
"usingComponents" : true,
|
||||
"nvueStyleCompiler" : "uni-app",
|
||||
"compilerVersion" : 3,
|
||||
"splashscreen" : {
|
||||
"alwaysShowBeforeRender" : true,
|
||||
"waiting" : true,
|
||||
"autoclose" : true,
|
||||
"delay" : 0
|
||||
},
|
||||
"modules" : {},
|
||||
"distribute" : {
|
||||
"android" : {
|
||||
"permissions" : [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>"
|
||||
]
|
||||
},
|
||||
"ios" : {},
|
||||
"sdkConfigs" : {}
|
||||
}
|
||||
},
|
||||
"quickapp" : {},
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx0c2a9d5ebad26a3f",
|
||||
"lazyCodeLoading" : "requiredComponents",
|
||||
"setting" : {
|
||||
"urlCheck" : false,
|
||||
"minified" : true
|
||||
},
|
||||
"optimization" : {
|
||||
"subPackages" : true
|
||||
},
|
||||
"usingComponents" : true,
|
||||
"requiredBackgroundModes" : [ "audio" ],
|
||||
"plugins" : {
|
||||
"WechatSI" : {
|
||||
"version" : "0.3.5",
|
||||
"provider" : "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mp-alipay" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-baidu" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-toutiao" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"uniStatistics" : {
|
||||
"enable" : false
|
||||
},
|
||||
"vueVersion" : "3"
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"tab-bar-dock.js","sources":["components/app-tab-bar/tab-bar-dock.vue","../../../soft/HBuilderX/plugins/uniapp-cli-vite/uniComponent:/RDovd2ViL3p5dC9Ub25namlVbmlBcHAvY29tcG9uZW50cy9hcHAtdGFiLWJhci90YWItYmFyLWRvY2sudnVl"],"sourcesContent":["<template>\r\n <view class=\"tab-dock\">\r\n <view class=\"tab-dock__inner\">\r\n <view class=\"tab-dock__item tab-dock__item--center\" @click=\"goWeekly\">\r\n <view class=\"tab-dock__fab\" :class=\"{ 'tab-dock__fab--active': active === 0 }\">\r\n <text class=\"tab-dock__fab-text\">糖</text>\r\n </view>\r\n <text class=\"tab-dock__label tab-dock__label--center\" :style=\"{ color: active === 0 ? activeColor : color }\">血糖</text>\r\n </view>\r\n\r\n <view class=\"tab-dock__item\" @click=\"goUser\">\r\n <image\r\n class=\"tab-dock__icon\"\r\n :src=\"active === 1 ? '/static/user/user_no.png' : '/static/user/user.png'\"\r\n mode=\"aspectFit\"\r\n />\r\n <text class=\"tab-dock__label\" :style=\"{ color: active === 1 ? activeColor : color }\">我的</text>\r\n </view>\r\n </view>\r\n <view class=\"tab-dock__safe\" />\r\n </view>\r\n</template>\r\n\r\n<script setup>\r\nconst props = defineProps({\r\n /** 0 血糖 1 我的 */\r\n active: {\r\n type: Number,\r\n default: 0\r\n }\r\n})\r\n\r\nconst color = '#8a8a8a'\r\nconst activeColor = '#204e2b'\r\n\r\nfunction goUser() {\r\n if (props.active === 1) return\r\n uni.redirectTo({ url: '/pages/user/user' })\r\n}\r\n\r\nfunction goWeekly() {\r\n if (props.active === 0) return\r\n uni.redirectTo({ url: '/tongji/pages/weekly' })\r\n}\r\n</script>\r\n\r\n<style scoped lang=\"scss\">\r\n.tab-dock {\r\n position: fixed;\r\n left: 0;\r\n right: 0;\r\n bottom: 0;\r\n z-index: 999;\r\n background: #ffffff;\r\n border-top: 1rpx solid rgba(0, 0, 0, 0.06);\r\n box-shadow: 0 -6rpx 20rpx rgba(0, 0, 0, 0.04);\r\n}\r\n\r\n.tab-dock__inner {\r\n display: flex;\r\n align-items: flex-end;\r\n height: 100rpx;\r\n}\r\n\r\n.tab-dock__item {\r\n flex: 1;\r\n height: 100rpx;\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n.tab-dock__item--center {\r\n justify-content: flex-end;\r\n padding-bottom: 6rpx;\r\n}\r\n\r\n.tab-dock__icon {\r\n width: 46rpx;\r\n height: 46rpx;\r\n}\r\n\r\n.tab-dock__label {\r\n margin-top: 6rpx;\r\n font-size: 22rpx;\r\n line-height: 1.2;\r\n}\r\n\r\n.tab-dock__label--center {\r\n margin-top: 8rpx;\r\n}\r\n\r\n.tab-dock__fab {\r\n width: 96rpx;\r\n height: 96rpx;\r\n margin-top: -48rpx;\r\n border-radius: 50%;\r\n background: linear-gradient(180deg, #2d7340 0%, #204e2b 100%);\r\n border: 6rpx solid #ffffff;\r\n box-shadow: 0 10rpx 24rpx rgba(32, 78, 43, 0.35);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n.tab-dock__fab--active {\r\n transform: scale(1.04);\r\n box-shadow: 0 12rpx 28rpx rgba(32, 78, 43, 0.42);\r\n}\r\n\r\n.tab-dock__fab-text {\r\n color: #ffffff;\r\n font-size: 38rpx;\r\n font-weight: 700;\r\n line-height: 1;\r\n}\r\n\r\n.tab-dock__safe {\r\n height: env(safe-area-inset-bottom);\r\n background: #ffffff;\r\n}\r\n</style>\r\n","import Component from 'D:/web/zyt/TongjiUniApp/components/app-tab-bar/tab-bar-dock.vue'\nwx.createComponent(Component)"],"names":["uni"],"mappings":";;AAgCA,MAAM,QAAQ;AACd,MAAM,cAAc;;;;;;;;;;;AATpB,UAAM,QAAQ;AAWd,aAAS,SAAS;AACd,UAAI,MAAM,WAAW;AAAG;AACxBA,oBAAAA,MAAI,WAAW,EAAE,KAAK,mBAAkB,CAAE;AAAA,IAC9C;AAEA,aAAS,WAAW;AAChB,UAAI,MAAM,WAAW;AAAG;AACxBA,oBAAAA,MAAI,WAAW,EAAE,KAAK,uBAAsB,CAAE;AAAA,IAClD;;;;;;;;;;;;;;AC1CA,GAAG,gBAAgB,SAAS;"}
|
||||
{"version":3,"file":"tab-bar-dock.js","sources":["components/app-tab-bar/tab-bar-dock.vue","../../../soft/HBuilderX/plugins/uniapp-cli-vite/uniComponent:/RDovd2ViL3p5dC9Ub25namlVbmlBcHAvY29tcG9uZW50cy9hcHAtdGFiLWJhci90YWItYmFyLWRvY2sudnVl"],"sourcesContent":["<template>\r\n <view class=\"tab-dock\">\r\n <view class=\"tab-dock__inner\">\r\n <view class=\"tab-dock__item tab-dock__item--center\" @click=\"goWeekly\">\r\n <view class=\"tab-dock__fab\" :class=\"{ 'tab-dock__fab--active': active === 0 }\">\r\n <text class=\"tab-dock__fab-text\">糖</text>\r\n </view>\r\n <text class=\"tab-dock__label tab-dock__label--center\" :style=\"{ color: active === 0 ? activeColor : color }\">血糖</text>\r\n </view>\r\n\r\n <view class=\"tab-dock__item\" @click=\"goUser\">\r\n <image\r\n class=\"tab-dock__icon\"\r\n :src=\"active === 1 ? '/static/user/user_no.png' : '/static/user/user.png'\"\r\n mode=\"aspectFit\"\r\n />\r\n <text class=\"tab-dock__label\" :style=\"{ color: active === 1 ? activeColor : color }\">我的</text>\r\n </view>\r\n </view>\r\n <view class=\"tab-dock__safe\" />\r\n </view>\r\n</template>\r\n\r\n<script setup>\r\nconst props = defineProps({\r\n /** 0 血糖 1 我的 */\r\n active: {\r\n type: Number,\r\n default: 0\r\n }\r\n})\r\n\r\nconst color = '#8a8a8a'\r\nconst activeColor = '#204e2b'\r\n\r\nfunction goUser() {\r\n if (props.active === 1) return\r\n uni.redirectTo({ url: '/pages/user/user' })\r\n}\r\n\r\nfunction goWeekly() {\r\n if (props.active === 0) return\r\n uni.redirectTo({ url: '/tongji/pages/weekly' })\r\n}\r\n</script>\r\n\r\n<style scoped lang=\"scss\">\r\n.tab-dock {\r\n position: fixed;\r\n left: 0;\r\n right: 0;\r\n bottom: 0;\r\n z-index: 999;\r\n background: #ffffff;\r\n border-top: 1rpx solid rgba(0, 0, 0, 0.06);\r\n box-shadow: 0 -6rpx 20rpx rgba(0, 0, 0, 0.04);\r\n}\r\n\r\n.tab-dock__inner {\r\n display: flex;\r\n align-items: flex-end;\r\n height: 100rpx;\r\n}\r\n\r\n.tab-dock__item {\r\n flex: 1;\r\n height: 100rpx;\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n.tab-dock__item--center {\r\n justify-content: flex-end;\r\n padding-bottom: 6rpx;\r\n}\r\n\r\n.tab-dock__icon {\r\n width: 46rpx;\r\n height: 46rpx;\r\n}\r\n\r\n.tab-dock__label {\r\n margin-top: 6rpx;\r\n font-size: 22rpx;\r\n line-height: 1.2;\r\n}\r\n\r\n.tab-dock__label--center {\r\n margin-top: 8rpx;\r\n}\r\n\r\n.tab-dock__fab {\r\n width: 96rpx;\r\n height: 96rpx;\r\n margin-top: -48rpx;\r\n border-radius: 50%;\r\n background: linear-gradient(180deg, #2d7340 0%, #204e2b 100%);\r\n border: 6rpx solid #ffffff;\r\n box-shadow: 0 10rpx 24rpx rgba(32, 78, 43, 0.35);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n.tab-dock__fab--active {\r\n transform: scale(1.04);\r\n box-shadow: 0 12rpx 28rpx rgba(32, 78, 43, 0.42);\r\n}\r\n\r\n.tab-dock__fab-text {\r\n color: #ffffff;\r\n font-size: 38rpx;\r\n font-weight: 700;\r\n line-height: 1;\r\n}\r\n\r\n.tab-dock__safe {\r\n height: env(safe-area-inset-bottom);\r\n background: #ffffff;\r\n}\r\n</style>\r\n","import Component from 'D:/web/zyt/TongjiUniApp/components/app-tab-bar/tab-bar-dock.vue'\nwx.createComponent(Component)"],"names":["color","activeColor","props","__props","goUser","uni","goWeekly","Component"],"mappings":"uDAgCMA,EAAQ,UACRC,EAAc,mFATpB,MAAMC,EAAQC,EAWd,SAASC,GAAS,CACVF,EAAM,SAAW,GACrBG,EAAAA,MAAI,WAAW,CAAE,IAAK,kBAAkB,CAAE,CAC9C,CAEA,SAASC,GAAW,CACZJ,EAAM,SAAW,GACrBG,EAAAA,MAAI,WAAW,CAAE,IAAK,sBAAsB,CAAE,CAClD,iOC1CA,GAAG,gBAAgBE,CAAS"}
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"VoiceSpeakMascot.js","sources":["../../../soft/HBuilderX/plugins/uniapp-cli-vite/uniComponent:/RDovd2ViL3p5dC9Ub25namlVbmlBcHAvdG9uZ2ppL2NvbXBvbmVudHMvVm9pY2VTcGVha01hc2NvdC52dWU"],"sourcesContent":["import Component from 'D:/web/zyt/TongjiUniApp/tongji/components/VoiceSpeakMascot.vue'\nwx.createComponent(Component)"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AACA,GAAG,gBAAgB,SAAS;"}
|
||||
{"version":3,"file":"VoiceSpeakMascot.js","sources":["../../../soft/HBuilderX/plugins/uniapp-cli-vite/uniComponent:/RDovd2ViL3p5dC9Ub25namlVbmlBcHAvdG9uZ2ppL2NvbXBvbmVudHMvVm9pY2VTcGVha01hc2NvdC52dWU"],"sourcesContent":["import Component from 'D:/web/zyt/TongjiUniApp/tongji/components/VoiceSpeakMascot.vue'\nwx.createComponent(Component)"],"names":["Component"],"mappings":"8XACA,GAAG,gBAAgBA,CAAS"}
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"useGameAuth.js","sources":["tongji/endless-game/composables/useGameAuth.js"],"sourcesContent":["/**\r\n * 识糖小课堂独立登录适配层。复用主小程序账号,但不依赖 tongji 其他页面代码。\r\n */\r\nexport function useGameAuth(proxy) {\r\n let loginPromise = null\r\n\r\n function hasToken() {\r\n return !!String(uni.getStorageSync('token') || '').trim()\r\n }\r\n\r\n function clearToken() {\r\n uni.removeStorageSync('token')\r\n }\r\n\r\n function wxLoginCode() {\r\n return new Promise((resolve, reject) => {\r\n uni.login({\r\n provider: 'weixin',\r\n success: (res) => res?.code ? resolve(res.code) : reject(new Error('微信登录未返回 code')),\r\n fail: reject\r\n })\r\n })\r\n }\r\n\r\n async function loginWithCode(code) {\r\n const res = await proxy.apiUrl({\r\n url: '/api/login/mnpLogin',\r\n method: 'POST',\r\n data: { code }\r\n }, false)\r\n if (res?.code !== 1 || !res.data?.token) {\r\n clearToken()\r\n throw new Error(res?.msg || '登录失败')\r\n }\r\n uni.setStorageSync('token', res.data.token)\r\n uni.setStorageSync('userData', res.data)\r\n return true\r\n }\r\n\r\n async function verifyOrLogin() {\r\n if (hasToken()) {\r\n try {\r\n const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)\r\n if (res?.code === 1 && res.data) {\r\n uni.setStorageSync('userData', res.data)\r\n return true\r\n }\r\n // 只有明确的登录失效才重新换取 token;其他业务错误先保留原登录。\r\n if (res?.code !== -1) return true\r\n } catch (_) {\r\n // 断网不清除仍可能有效的 token,成绩由离线队列稍后补传。\r\n return true\r\n }\r\n clearToken()\r\n }\r\n const code = await wxLoginCode()\r\n return loginWithCode(code)\r\n }\r\n\r\n async function ensureLoggedIn() {\r\n if (loginPromise) return loginPromise\r\n loginPromise = verifyOrLogin()\r\n .then(ok => !!ok)\r\n .catch(() => false)\r\n // 只合并同时发生的登录;成功结果不能永久缓存,否则 token 过期后无法恢复。\r\n .finally(() => { loginPromise = null })\r\n return loginPromise\r\n }\r\n\r\n return { ensureLoggedIn }\r\n}\r\n"],"names":["uni"],"mappings":";;AAGO,SAAS,YAAY,OAAO;AAC/B,MAAI,eAAe;AAEnB,WAAS,WAAW;AAChB,WAAO,CAAC,CAAC,OAAOA,oBAAI,eAAe,OAAO,KAAK,EAAE,EAAE,KAAM;AAAA,EAC5D;AAED,WAAS,aAAa;AAClBA,kBAAG,MAAC,kBAAkB,OAAO;AAAA,EAChC;AAED,WAAS,cAAc;AACnB,WAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACpCA,oBAAAA,MAAI,MAAM;AAAA,QACN,UAAU;AAAA,QACV,SAAS,CAAC,SAAQ,2BAAK,QAAO,QAAQ,IAAI,IAAI,IAAI,OAAO,IAAI,MAAM,cAAc,CAAC;AAAA,QAClF,MAAM;AAAA,MACtB,CAAa;AAAA,IACb,CAAS;AAAA,EACJ;AAED,iBAAe,cAAc,MAAM;;AAC/B,UAAM,MAAM,MAAM,MAAM,OAAO;AAAA,MAC3B,KAAK;AAAA,MACL,QAAQ;AAAA,MACR,MAAM,EAAE,KAAM;AAAA,IACjB,GAAE,KAAK;AACR,SAAI,2BAAK,UAAS,KAAK,GAAC,SAAI,SAAJ,mBAAU,QAAO;AACrC,iBAAY;AACZ,YAAM,IAAI,OAAM,2BAAK,QAAO,MAAM;AAAA,IACrC;AACDA,kBAAAA,MAAI,eAAe,SAAS,IAAI,KAAK,KAAK;AAC1CA,kBAAAA,MAAI,eAAe,YAAY,IAAI,IAAI;AACvC,WAAO;AAAA,EACV;AAED,iBAAe,gBAAgB;AAC3B,QAAI,SAAQ,GAAI;AACZ,UAAI;AACA,cAAM,MAAM,MAAM,MAAM,OAAO,EAAE,KAAK,kBAAkB,QAAQ,OAAQ,GAAE,KAAK;AAC/E,aAAI,2BAAK,UAAS,KAAK,IAAI,MAAM;AAC7BA,wBAAAA,MAAI,eAAe,YAAY,IAAI,IAAI;AACvC,iBAAO;AAAA,QACV;AAED,aAAI,2BAAK,UAAS;AAAI,iBAAO;AAAA,MAChC,SAAQ,GAAG;AAER,eAAO;AAAA,MACV;AACD,iBAAY;AAAA,IACf;AACD,UAAM,OAAO,MAAM,YAAa;AAChC,WAAO,cAAc,IAAI;AAAA,EAC5B;AAED,iBAAe,iBAAiB;AAC5B,QAAI;AAAc,aAAO;AACzB,mBAAe,cAAe,EACzB,KAAK,QAAM,CAAC,CAAC,EAAE,EACf,MAAM,MAAM,KAAK,EAEjB,QAAQ,MAAM;AAAE,qBAAe;AAAA,KAAM;AAC1C,WAAO;AAAA,EACV;AAED,SAAO,EAAE,eAAgB;AAC7B;;"}
|
||||
{"version":3,"file":"useGameAuth.js","sources":["tongji/endless-game/composables/useGameAuth.js"],"sourcesContent":["/**\r\n * 识糖小课堂独立登录适配层。复用主小程序账号,但不依赖 tongji 其他页面代码。\r\n */\r\nexport function useGameAuth(proxy) {\r\n let loginPromise = null\r\n\r\n function hasToken() {\r\n return !!String(uni.getStorageSync('token') || '').trim()\r\n }\r\n\r\n function clearToken() {\r\n uni.removeStorageSync('token')\r\n }\r\n\r\n function wxLoginCode() {\r\n return new Promise((resolve, reject) => {\r\n uni.login({\r\n provider: 'weixin',\r\n success: (res) => res?.code ? resolve(res.code) : reject(new Error('微信登录未返回 code')),\r\n fail: reject\r\n })\r\n })\r\n }\r\n\r\n async function loginWithCode(code) {\r\n const res = await proxy.apiUrl({\r\n url: '/api/login/mnpLogin',\r\n method: 'POST',\r\n data: { code }\r\n }, false)\r\n if (res?.code !== 1 || !res.data?.token) {\r\n clearToken()\r\n throw new Error(res?.msg || '登录失败')\r\n }\r\n uni.setStorageSync('token', res.data.token)\r\n uni.setStorageSync('userData', res.data)\r\n return true\r\n }\r\n\r\n async function verifyOrLogin() {\r\n if (hasToken()) {\r\n try {\r\n const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)\r\n if (res?.code === 1 && res.data) {\r\n uni.setStorageSync('userData', res.data)\r\n return true\r\n }\r\n // 只有明确的登录失效才重新换取 token;其他业务错误先保留原登录。\r\n if (res?.code !== -1) return true\r\n } catch (_) {\r\n // 断网不清除仍可能有效的 token,成绩由离线队列稍后补传。\r\n return true\r\n }\r\n clearToken()\r\n }\r\n const code = await wxLoginCode()\r\n return loginWithCode(code)\r\n }\r\n\r\n async function ensureLoggedIn() {\r\n if (loginPromise) return loginPromise\r\n loginPromise = verifyOrLogin()\r\n .then(ok => !!ok)\r\n .catch(() => false)\r\n // 只合并同时发生的登录;成功结果不能永久缓存,否则 token 过期后无法恢复。\r\n .finally(() => { loginPromise = null })\r\n return loginPromise\r\n }\r\n\r\n return { ensureLoggedIn }\r\n}\r\n"],"names":["useGameAuth","proxy","loginPromise","hasToken","uni","clearToken","wxLoginCode","resolve","reject","res","loginWithCode","code","_a","verifyOrLogin","ensureLoggedIn","ok"],"mappings":"0DAGO,SAASA,EAAYC,EAAO,CAC/B,IAAIC,EAAe,KAEnB,SAASC,GAAW,CAChB,MAAO,CAAC,CAAC,OAAOC,QAAI,eAAe,OAAO,GAAK,EAAE,EAAE,KAAM,CAC5D,CAED,SAASC,GAAa,CAClBD,EAAG,MAAC,kBAAkB,OAAO,CAChC,CAED,SAASE,GAAc,CACnB,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CACpCJ,EAAAA,MAAI,MAAM,CACN,SAAU,SACV,QAAUK,GAAQA,GAAA,MAAAA,EAAK,KAAOF,EAAQE,EAAI,IAAI,EAAID,EAAO,IAAI,MAAM,cAAc,CAAC,EAClF,KAAMA,CACtB,CAAa,CACb,CAAS,CACJ,CAED,eAAeE,EAAcC,EAAM,OAC/B,MAAMF,EAAM,MAAMR,EAAM,OAAO,CAC3B,IAAK,sBACL,OAAQ,OACR,KAAM,CAAE,KAAAU,CAAM,CACjB,EAAE,EAAK,EACR,IAAIF,GAAA,YAAAA,EAAK,QAAS,GAAK,GAACG,EAAAH,EAAI,OAAJ,MAAAG,EAAU,OAC9B,MAAAP,EAAY,EACN,IAAI,OAAMI,GAAA,YAAAA,EAAK,MAAO,MAAM,EAEtCL,OAAAA,EAAAA,MAAI,eAAe,QAASK,EAAI,KAAK,KAAK,EAC1CL,EAAAA,MAAI,eAAe,WAAYK,EAAI,IAAI,EAChC,EACV,CAED,eAAeI,GAAgB,CAC3B,GAAIV,EAAQ,EAAI,CACZ,GAAI,CACA,MAAMM,EAAM,MAAMR,EAAM,OAAO,CAAE,IAAK,iBAAkB,OAAQ,MAAQ,EAAE,EAAK,EAC/E,IAAIQ,GAAA,YAAAA,EAAK,QAAS,GAAKA,EAAI,KACvBL,OAAAA,EAAAA,MAAI,eAAe,WAAYK,EAAI,IAAI,EAChC,GAGX,IAAIA,GAAA,YAAAA,EAAK,QAAS,GAAI,MAAO,EAChC,MAAW,CAER,MAAO,EACV,CACDJ,EAAY,CACf,CACD,MAAMM,EAAO,MAAML,EAAa,EAChC,OAAOI,EAAcC,CAAI,CAC5B,CAED,eAAeG,GAAiB,CAC5B,OAAIZ,IACJA,EAAeW,EAAe,EACzB,KAAKE,GAAM,CAAC,CAACA,CAAE,EACf,MAAM,IAAM,EAAK,EAEjB,QAAQ,IAAM,CAAEb,EAAe,KAAM,EACnCA,EACV,CAED,MAAO,CAAE,eAAAY,CAAgB,CAC7B"}
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"svgDataUrl.js","sources":["tongji/utils/svgDataUrl.js"],"sourcesContent":["/**\r\n * 将 SVG 字符串转为小程序可用的 data URL(base64 在真机上更稳定)\r\n */\r\n\r\nfunction utf8ToBytes(str) {\r\n const encoded = encodeURIComponent(str)\r\n const bytes = []\r\n for (let i = 0; i < encoded.length; i++) {\r\n if (encoded.charCodeAt(i) === 37) {\r\n bytes.push(parseInt(encoded.substring(i + 1, i + 3), 16))\r\n i += 2\r\n } else {\r\n bytes.push(encoded.charCodeAt(i))\r\n }\r\n }\r\n return new Uint8Array(bytes)\r\n}\r\n\r\nfunction bytesToBase64(bytes) {\r\n if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {\r\n return uni.arrayBufferToBase64(bytes.buffer)\r\n }\r\n if (typeof btoa !== 'undefined') {\r\n let binary = ''\r\n for (let i = 0; i < bytes.length; i++) {\r\n binary += String.fromCharCode(bytes[i])\r\n }\r\n return btoa(binary)\r\n }\r\n const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\r\n let result = ''\r\n for (let i = 0; i < bytes.length; i += 3) {\r\n const a = bytes[i]\r\n const b = i + 1 < bytes.length ? bytes[i + 1] : 0\r\n const c = i + 2 < bytes.length ? bytes[i + 2] : 0\r\n result += chars[a >> 2]\r\n result += chars[((a & 3) << 4) | (b >> 4)]\r\n result += i + 1 < bytes.length ? chars[((b & 15) << 2) | (c >> 6)] : '='\r\n result += i + 2 < bytes.length ? chars[c & 63] : '='\r\n }\r\n return result\r\n}\r\n\r\nexport function svgToDataUrl(svg) {\r\n const normalized = String(svg || '').trim()\r\n if (!normalized) return ''\r\n const base64 = bytesToBase64(utf8ToBytes(normalized))\r\n return `data:image/svg+xml;base64,${base64}`\r\n}\r\n"],"names":["uni"],"mappings":";;AAIA,SAAS,YAAY,KAAK;AACtB,QAAM,UAAU,mBAAmB,GAAG;AACtC,QAAM,QAAQ,CAAE;AAChB,WAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACrC,QAAI,QAAQ,WAAW,CAAC,MAAM,IAAI;AAC9B,YAAM,KAAK,SAAS,QAAQ,UAAU,IAAI,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;AACxD,WAAK;AAAA,IACjB,OAAe;AACH,YAAM,KAAK,QAAQ,WAAW,CAAC,CAAC;AAAA,IACnC;AAAA,EACJ;AACD,SAAO,IAAI,WAAW,KAAK;AAC/B;AAEA,SAAS,cAAc,OAAO;AAC1B,MAAI,OAAOA,cAAG,UAAK,eAAe,OAAOA,cAAAA,MAAI,wBAAwB,YAAY;AAC7E,WAAOA,oBAAI,oBAAoB,MAAM,MAAM;AAAA,EAC9C;AACD,MAAI,OAAO,SAAS,aAAa;AAC7B,QAAI,SAAS;AACb,aAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACnC,gBAAU,OAAO,aAAa,MAAM,CAAC,CAAC;AAAA,IACzC;AACD,WAAO,KAAK,MAAM;AAAA,EACrB;AACD,QAAM,QAAQ;AACd,MAAI,SAAS;AACb,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK,GAAG;AACtC,UAAM,IAAI,MAAM,CAAC;AACjB,UAAM,IAAI,IAAI,IAAI,MAAM,SAAS,MAAM,IAAI,CAAC,IAAI;AAChD,UAAM,IAAI,IAAI,IAAI,MAAM,SAAS,MAAM,IAAI,CAAC,IAAI;AAChD,cAAU,MAAM,KAAK,CAAC;AACtB,cAAU,OAAQ,IAAI,MAAM,IAAM,KAAK,CAAE;AACzC,cAAU,IAAI,IAAI,MAAM,SAAS,OAAQ,IAAI,OAAO,IAAM,KAAK,CAAE,IAAI;AACrE,cAAU,IAAI,IAAI,MAAM,SAAS,MAAM,IAAI,EAAE,IAAI;AAAA,EACpD;AACD,SAAO;AACX;AAEO,SAAS,aAAa,KAAK;AAC9B,QAAM,aAAa,OAAO,OAAO,EAAE,EAAE,KAAM;AAC3C,MAAI,CAAC;AAAY,WAAO;AACxB,QAAM,SAAS,cAAc,YAAY,UAAU,CAAC;AACpD,SAAO,6BAA6B,MAAM;AAC9C;;"}
|
||||
{"version":3,"file":"svgDataUrl.js","sources":["tongji/utils/svgDataUrl.js"],"sourcesContent":["/**\r\n * 将 SVG 字符串转为小程序可用的 data URL(base64 在真机上更稳定)\r\n */\r\n\r\nfunction utf8ToBytes(str) {\r\n const encoded = encodeURIComponent(str)\r\n const bytes = []\r\n for (let i = 0; i < encoded.length; i++) {\r\n if (encoded.charCodeAt(i) === 37) {\r\n bytes.push(parseInt(encoded.substring(i + 1, i + 3), 16))\r\n i += 2\r\n } else {\r\n bytes.push(encoded.charCodeAt(i))\r\n }\r\n }\r\n return new Uint8Array(bytes)\r\n}\r\n\r\nfunction bytesToBase64(bytes) {\r\n if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {\r\n return uni.arrayBufferToBase64(bytes.buffer)\r\n }\r\n if (typeof btoa !== 'undefined') {\r\n let binary = ''\r\n for (let i = 0; i < bytes.length; i++) {\r\n binary += String.fromCharCode(bytes[i])\r\n }\r\n return btoa(binary)\r\n }\r\n const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\r\n let result = ''\r\n for (let i = 0; i < bytes.length; i += 3) {\r\n const a = bytes[i]\r\n const b = i + 1 < bytes.length ? bytes[i + 1] : 0\r\n const c = i + 2 < bytes.length ? bytes[i + 2] : 0\r\n result += chars[a >> 2]\r\n result += chars[((a & 3) << 4) | (b >> 4)]\r\n result += i + 1 < bytes.length ? chars[((b & 15) << 2) | (c >> 6)] : '='\r\n result += i + 2 < bytes.length ? chars[c & 63] : '='\r\n }\r\n return result\r\n}\r\n\r\nexport function svgToDataUrl(svg) {\r\n const normalized = String(svg || '').trim()\r\n if (!normalized) return ''\r\n const base64 = bytesToBase64(utf8ToBytes(normalized))\r\n return `data:image/svg+xml;base64,${base64}`\r\n}\r\n"],"names":["utf8ToBytes","str","encoded","bytes","i","bytesToBase64","uni","binary","chars","result","a","b","c","svgToDataUrl","svg","normalized"],"mappings":"uDAIA,SAASA,EAAYC,EAAK,CACtB,MAAMC,EAAU,mBAAmBD,CAAG,EAChCE,EAAQ,CAAE,EAChB,QAASC,EAAI,EAAGA,EAAIF,EAAQ,OAAQE,IAC5BF,EAAQ,WAAWE,CAAC,IAAM,IAC1BD,EAAM,KAAK,SAASD,EAAQ,UAAUE,EAAI,EAAGA,EAAI,CAAC,EAAG,EAAE,CAAC,EACxDA,GAAK,GAELD,EAAM,KAAKD,EAAQ,WAAWE,CAAC,CAAC,EAGxC,OAAO,IAAI,WAAWD,CAAK,CAC/B,CAEA,SAASE,EAAcF,EAAO,CAC1B,GAAI,OAAOG,EAAG,MAAK,KAAe,OAAOA,EAAAA,MAAI,qBAAwB,WACjE,OAAOA,QAAI,oBAAoBH,EAAM,MAAM,EAE/C,GAAI,OAAO,KAAS,IAAa,CAC7B,IAAII,EAAS,GACb,QAASH,EAAI,EAAGA,EAAID,EAAM,OAAQC,IAC9BG,GAAU,OAAO,aAAaJ,EAAMC,CAAC,CAAC,EAE1C,OAAO,KAAKG,CAAM,CACrB,CACD,MAAMC,EAAQ,mEACd,IAAIC,EAAS,GACb,QAASL,EAAI,EAAGA,EAAID,EAAM,OAAQC,GAAK,EAAG,CACtC,MAAMM,EAAIP,EAAMC,CAAC,EACXO,EAAIP,EAAI,EAAID,EAAM,OAASA,EAAMC,EAAI,CAAC,EAAI,EAC1CQ,EAAIR,EAAI,EAAID,EAAM,OAASA,EAAMC,EAAI,CAAC,EAAI,EAChDK,GAAUD,EAAME,GAAK,CAAC,EACtBD,GAAUD,GAAQE,EAAI,IAAM,EAAMC,GAAK,CAAE,EACzCF,GAAUL,EAAI,EAAID,EAAM,OAASK,GAAQG,EAAI,KAAO,EAAMC,GAAK,CAAE,EAAI,IACrEH,GAAUL,EAAI,EAAID,EAAM,OAASK,EAAMI,EAAI,EAAE,EAAI,GACpD,CACD,OAAOH,CACX,CAEO,SAASI,EAAaC,EAAK,CAC9B,MAAMC,EAAa,OAAOD,GAAO,EAAE,EAAE,KAAM,EAC3C,OAAKC,EAEE,6BADQV,EAAcL,EAAYe,CAAU,CAAC,CACV,GAFlB,EAG5B"}
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"treeSpecies.js","sources":["tongji/utils/treeSpecies.js"],"sourcesContent":["import { TREE_LEVELS, TREE_MAX_LEVEL, TREE_XP_PER_LEVEL } from './treeLevels.js'\r\n\r\n/** 可选树种:每级 emoji 与 treeLevels.js Lv.0~9 一一对应 */\r\nexport const TREE_SPECIES_LIST = [\r\n {\r\n id: 'classic',\r\n name: '稳糖灵树',\r\n tagline: '经典路线,从种子到圆满',\r\n stages: TREE_LEVELS.map((l) => l.emoji)\r\n },\r\n {\r\n id: 'bonsai',\r\n name: '雅韵盆景',\r\n tagline: '小巧精致,书桌旁的温柔陪伴',\r\n stages: ['🫘', '🌱', '🪴', '🎋', '🪴', '🌳', '🎍', '🌸', '🏵️', '🏆']\r\n },\r\n {\r\n id: 'pine',\r\n name: '苍劲青松',\r\n tagline: '挺拔向上,越记越稳',\r\n stages: ['🫘', '🌱', '🌿', '🌲', '🌲', '🌲', '⛰️', '🌲', '🏔️', '🏆']\r\n },\r\n {\r\n id: 'sakura',\r\n name: '樱花小树',\r\n tagline: '坚持打卡,枝头渐开',\r\n stages: ['🫘', '🌱', '🌿', '🌳', '🌸', '🌸', '🌸', '🌸', '🌺', '🏆']\r\n },\r\n {\r\n id: 'ginkgo',\r\n name: '金秋银杏',\r\n tagline: '叶色渐变,见证每一天',\r\n stages: ['🫘', '🌱', '🍃', '🌳', '🍂', '🍂', '🌳', '🍁', '🌟', '🏆']\r\n }\r\n]\r\n\r\nexport const TREE_SPECIES_STORAGE_KEY = 'tongji_tree_species_v1'\r\n\r\n/** 四项任务全勤时每日最多可领积分(与后端 taskDefs 一致) */\r\nexport const DAILY_MAX_TASK_POINTS = 40\r\n\r\nexport function getTreeSpecies(id) {\r\n return TREE_SPECIES_LIST.find((s) => s.id === id) || TREE_SPECIES_LIST[0]\r\n}\r\n\r\nexport function speciesEmojiAtLevel(speciesId, level) {\r\n const sp = getTreeSpecies(speciesId)\r\n const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))\r\n return sp.stages[lv] || sp.stages[0] || '🌱'\r\n}\r\n\r\n/** 当前树种成长路线图(供选择面板展示) */\r\nexport function speciesGrowthRoadmap(speciesId) {\r\n const sp = getTreeSpecies(speciesId)\r\n return TREE_LEVELS.map((meta, i) => {\r\n const xpTotal = i * TREE_XP_PER_LEVEL\r\n const daysHint = i === 0\r\n ? 0\r\n : Math.max(1, Math.ceil(xpTotal / DAILY_MAX_TASK_POINTS))\r\n return {\r\n level: i,\r\n name: meta.name,\r\n emoji: sp.stages[i] || meta.emoji,\r\n desc: meta.desc,\r\n xpTotal,\r\n daysHint\r\n }\r\n })\r\n}\r\n\r\n/** 全勤打卡大约多少天满级 */\r\nexport function estimateDaysToMaxLevel() {\r\n const totalXp = TREE_MAX_LEVEL * TREE_XP_PER_LEVEL\r\n return Math.max(1, Math.ceil(totalXp / DAILY_MAX_TASK_POINTS))\r\n}\r\n"],"names":["TREE_LEVELS","TREE_MAX_LEVEL","TREE_XP_PER_LEVEL"],"mappings":";;AAGY,MAAC,oBAAoB;AAAA,EAC7B;AAAA,IACI,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,SAAS;AAAA,IACT,QAAQA,wBAAW,YAAC,IAAI,CAAC,MAAM,EAAE,KAAK;AAAA,EACzC;AAAA,EACD;AAAA,IACI,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,SAAS;AAAA,IACT,QAAQ,CAAC,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,OAAO,IAAI;AAAA,EACvE;AAAA,EACD;AAAA,IACI,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,SAAS;AAAA,IACT,QAAQ,CAAC,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,OAAO,IAAI;AAAA,EACvE;AAAA,EACD;AAAA,IACI,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,SAAS;AAAA,IACT,QAAQ,CAAC,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,IAAI;AAAA,EACtE;AAAA,EACD;AAAA,IACI,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,SAAS;AAAA,IACT,QAAQ,CAAC,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,IAAI;AAAA,EACtE;AACL;AAKO,MAAM,wBAAwB;AAE9B,SAAS,eAAe,IAAI;AAC/B,SAAO,kBAAkB,KAAK,CAAC,MAAM,EAAE,OAAO,EAAE,KAAK,kBAAkB,CAAC;AAC5E;AAEO,SAAS,oBAAoB,WAAW,OAAO;AAClD,QAAM,KAAK,eAAe,SAAS;AACnC,QAAM,KAAK,KAAK,IAAIC,wBAAc,gBAAE,KAAK,IAAI,GAAG,OAAO,KAAK,KAAK,CAAC,CAAC;AACnE,SAAO,GAAG,OAAO,EAAE,KAAK,GAAG,OAAO,CAAC,KAAK;AAC5C;AAGO,SAAS,qBAAqB,WAAW;AAC5C,QAAM,KAAK,eAAe,SAAS;AACnC,SAAOD,oCAAY,IAAI,CAAC,MAAM,MAAM;AAChC,UAAM,UAAU,IAAIE,wBAAiB;AACrC,UAAM,WAAW,MAAM,IACjB,IACA,KAAK,IAAI,GAAG,KAAK,KAAK,UAAU,qBAAqB,CAAC;AAC5D,WAAO;AAAA,MACH,OAAO;AAAA,MACP,MAAM,KAAK;AAAA,MACX,OAAO,GAAG,OAAO,CAAC,KAAK,KAAK;AAAA,MAC5B,MAAM,KAAK;AAAA,MACX;AAAA,MACA;AAAA,IACH;AAAA,EACT,CAAK;AACL;AAGO,SAAS,yBAAyB;AACrC,QAAM,UAAUD,wBAAAA,iBAAiBC,wBAAiB;AAClD,SAAO,KAAK,IAAI,GAAG,KAAK,KAAK,UAAU,qBAAqB,CAAC;AACjE;;;;;;"}
|
||||
{"version":3,"file":"treeSpecies.js","sources":["tongji/utils/treeSpecies.js"],"sourcesContent":["import { TREE_LEVELS, TREE_MAX_LEVEL, TREE_XP_PER_LEVEL } from './treeLevels.js'\r\n\r\n/** 可选树种:每级 emoji 与 treeLevels.js Lv.0~9 一一对应 */\r\nexport const TREE_SPECIES_LIST = [\r\n {\r\n id: 'classic',\r\n name: '稳糖灵树',\r\n tagline: '经典路线,从种子到圆满',\r\n stages: TREE_LEVELS.map((l) => l.emoji)\r\n },\r\n {\r\n id: 'bonsai',\r\n name: '雅韵盆景',\r\n tagline: '小巧精致,书桌旁的温柔陪伴',\r\n stages: ['🫘', '🌱', '🪴', '🎋', '🪴', '🌳', '🎍', '🌸', '🏵️', '🏆']\r\n },\r\n {\r\n id: 'pine',\r\n name: '苍劲青松',\r\n tagline: '挺拔向上,越记越稳',\r\n stages: ['🫘', '🌱', '🌿', '🌲', '🌲', '🌲', '⛰️', '🌲', '🏔️', '🏆']\r\n },\r\n {\r\n id: 'sakura',\r\n name: '樱花小树',\r\n tagline: '坚持打卡,枝头渐开',\r\n stages: ['🫘', '🌱', '🌿', '🌳', '🌸', '🌸', '🌸', '🌸', '🌺', '🏆']\r\n },\r\n {\r\n id: 'ginkgo',\r\n name: '金秋银杏',\r\n tagline: '叶色渐变,见证每一天',\r\n stages: ['🫘', '🌱', '🍃', '🌳', '🍂', '🍂', '🌳', '🍁', '🌟', '🏆']\r\n }\r\n]\r\n\r\nexport const TREE_SPECIES_STORAGE_KEY = 'tongji_tree_species_v1'\r\n\r\n/** 四项任务全勤时每日最多可领积分(与后端 taskDefs 一致) */\r\nexport const DAILY_MAX_TASK_POINTS = 40\r\n\r\nexport function getTreeSpecies(id) {\r\n return TREE_SPECIES_LIST.find((s) => s.id === id) || TREE_SPECIES_LIST[0]\r\n}\r\n\r\nexport function speciesEmojiAtLevel(speciesId, level) {\r\n const sp = getTreeSpecies(speciesId)\r\n const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))\r\n return sp.stages[lv] || sp.stages[0] || '🌱'\r\n}\r\n\r\n/** 当前树种成长路线图(供选择面板展示) */\r\nexport function speciesGrowthRoadmap(speciesId) {\r\n const sp = getTreeSpecies(speciesId)\r\n return TREE_LEVELS.map((meta, i) => {\r\n const xpTotal = i * TREE_XP_PER_LEVEL\r\n const daysHint = i === 0\r\n ? 0\r\n : Math.max(1, Math.ceil(xpTotal / DAILY_MAX_TASK_POINTS))\r\n return {\r\n level: i,\r\n name: meta.name,\r\n emoji: sp.stages[i] || meta.emoji,\r\n desc: meta.desc,\r\n xpTotal,\r\n daysHint\r\n }\r\n })\r\n}\r\n\r\n/** 全勤打卡大约多少天满级 */\r\nexport function estimateDaysToMaxLevel() {\r\n const totalXp = TREE_MAX_LEVEL * TREE_XP_PER_LEVEL\r\n return Math.max(1, Math.ceil(totalXp / DAILY_MAX_TASK_POINTS))\r\n}\r\n"],"names":["TREE_SPECIES_LIST","TREE_LEVELS","l","DAILY_MAX_TASK_POINTS","getTreeSpecies","id","s","speciesEmojiAtLevel","speciesId","level","sp","lv","TREE_MAX_LEVEL","speciesGrowthRoadmap","meta","i","xpTotal","TREE_XP_PER_LEVEL","daysHint","estimateDaysToMaxLevel","totalXp"],"mappings":"gDAGaA,EAAoB,CAC7B,CACI,GAAI,UACJ,KAAM,OACN,QAAS,cACT,OAAQC,EAAW,YAAC,IAAKC,GAAMA,EAAE,KAAK,CACzC,EACD,CACI,GAAI,SACJ,KAAM,OACN,QAAS,gBACT,OAAQ,CAAC,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,MAAO,IAAI,CACvE,EACD,CACI,GAAI,OACJ,KAAM,OACN,QAAS,YACT,OAAQ,CAAC,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,MAAO,IAAI,CACvE,EACD,CACI,GAAI,SACJ,KAAM,OACN,QAAS,YACT,OAAQ,CAAC,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,IAAI,CACtE,EACD,CACI,GAAI,SACJ,KAAM,OACN,QAAS,aACT,OAAQ,CAAC,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,IAAI,CACtE,CACL,EAKaC,EAAwB,GAE9B,SAASC,EAAeC,EAAI,CAC/B,OAAOL,EAAkB,KAAMM,GAAMA,EAAE,KAAOD,CAAE,GAAKL,EAAkB,CAAC,CAC5E,CAEO,SAASO,EAAoBC,EAAWC,EAAO,CAClD,MAAMC,EAAKN,EAAeI,CAAS,EAC7BG,EAAK,KAAK,IAAIC,EAAc,eAAE,KAAK,IAAI,EAAG,OAAOH,CAAK,GAAK,CAAC,CAAC,EACnE,OAAOC,EAAG,OAAOC,CAAE,GAAKD,EAAG,OAAO,CAAC,GAAK,IAC5C,CAGO,SAASG,EAAqBL,EAAW,CAC5C,MAAME,EAAKN,EAAeI,CAAS,EACnC,OAAOP,cAAY,IAAI,CAACa,EAAMC,IAAM,CAChC,MAAMC,EAAUD,EAAIE,EAAiB,kBAC/BC,EAAWH,IAAM,EACjB,EACA,KAAK,IAAI,EAAG,KAAK,KAAKC,EAAUb,CAAqB,CAAC,EAC5D,MAAO,CACH,MAAOY,EACP,KAAMD,EAAK,KACX,MAAOJ,EAAG,OAAOK,CAAC,GAAKD,EAAK,MAC5B,KAAMA,EAAK,KACX,QAAAE,EACA,SAAAE,CACH,CACT,CAAK,CACL,CAGO,SAASC,GAAyB,CACrC,MAAMC,EAAUR,EAAAA,eAAiBK,EAAiB,kBAClD,OAAO,KAAK,IAAI,EAAG,KAAK,KAAKG,EAAUjB,CAAqB,CAAC,CACjE"}
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"uni-icons.js","sources":["uni_modules/uni-icons/components/uni-icons/uni-icons.vue","../../../soft/HBuilderX/plugins/uniapp-cli-vite/uniComponent:/RDovd2ViL3p5dC9Ub25namlVbmlBcHAvdW5pX21vZHVsZXMvdW5pLWljb25zL2NvbXBvbmVudHMvdW5pLWljb25zL3VuaS1pY29ucy52dWU"],"sourcesContent":["<template>\r\n\t<!-- #ifdef APP-NVUE -->\r\n\t<text :style=\"styleObj\" class=\"uni-icons\" @click=\"_onClick\">{{unicode}}</text>\r\n\t<!-- #endif -->\r\n\t<!-- #ifndef APP-NVUE -->\r\n\t<text :style=\"styleObj\" class=\"uni-icons\" :class=\"['uniui-'+type,customPrefix,customPrefix?type:'']\" @click=\"_onClick\">\r\n\t\t<slot></slot>\r\n\t</text>\r\n\t<!-- #endif -->\r\n</template>\r\n\r\n<script>\r\n\timport { fontData } from './uniicons_file_vue.js';\r\n\r\n\tconst getVal = (val) => {\r\n\t\tconst reg = /^[0-9]*$/g\r\n\t\treturn (typeof val === 'number' || reg.test(val)) ? val + 'px' : val;\r\n\t}\r\n\r\n\t// #ifdef APP-NVUE\r\n\tvar domModule = weex.requireModule('dom');\r\n\timport iconUrl from './uniicons.ttf'\r\n\tdomModule.addRule('fontFace', {\r\n\t\t'fontFamily': \"uniicons\",\r\n\t\t'src': \"url('\" + iconUrl + \"')\"\r\n\t});\r\n\t// #endif\r\n\r\n\t/**\r\n\t * Icons 图标\r\n\t * @description 用于展示 icons 图标\r\n\t * @tutorial https://ext.dcloud.net.cn/plugin?id=28\r\n\t * @property {Number} size 图标大小\r\n\t * @property {String} type 图标图案,参考示例\r\n\t * @property {String} color 图标颜色\r\n\t * @property {String} customPrefix 自定义图标\r\n\t * @event {Function} click 点击 Icon 触发事件\r\n\t */\r\n\texport default {\r\n\t\tname: 'UniIcons',\r\n\t\temits: ['click'],\r\n\t\tprops: {\r\n\t\t\ttype: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: ''\r\n\t\t\t},\r\n\t\t\tcolor: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: '#333333'\r\n\t\t\t},\r\n\t\t\tsize: {\r\n\t\t\t\ttype: [Number, String],\r\n\t\t\t\tdefault: 16\r\n\t\t\t},\r\n\t\t\tcustomPrefix: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: ''\r\n\t\t\t},\r\n\t\t\tfontFamily: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: ''\r\n\t\t\t}\r\n\t\t},\r\n\t\tdata() {\r\n\t\t\treturn {\r\n\t\t\t\ticons: fontData\r\n\t\t\t}\r\n\t\t},\r\n\t\tcomputed: {\r\n\t\t\tunicode() {\r\n\t\t\t\tlet code = this.icons.find(v => v.font_class === this.type)\r\n\t\t\t\tif (code) {\r\n\t\t\t\t\treturn code.unicode\r\n\t\t\t\t}\r\n\t\t\t\treturn ''\r\n\t\t\t},\r\n\t\t\ticonSize() {\r\n\t\t\t\treturn getVal(this.size)\r\n\t\t\t},\r\n\t\t\tstyleObj() {\r\n\t\t\t\tif (this.fontFamily !== '') {\r\n\t\t\t\t\treturn `color: ${this.color}; font-size: ${this.iconSize}; font-family: ${this.fontFamily};`\r\n\t\t\t\t}\r\n\t\t\t\treturn `color: ${this.color}; font-size: ${this.iconSize};`\r\n\t\t\t}\r\n\t\t},\r\n\t\tmethods: {\r\n\t\t\t_onClick() {\r\n\t\t\t\tthis.$emit('click')\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n</script>\r\n\r\n<style lang=\"scss\">\r\n\t/* #ifndef APP-NVUE */\r\n\t@import './uniicons.css';\r\n\r\n\t@font-face {\r\n\t\tfont-family: uniicons;\r\n\t\tsrc: url('./uniicons.ttf');\r\n\t}\r\n\r\n\t/* #endif */\r\n\t.uni-icons {\r\n\t\tfont-family: uniicons;\r\n\t\ttext-decoration: none;\r\n\t\ttext-align: center;\r\n\t}\r\n</style>\n","import Component from 'D:/web/zyt/TongjiUniApp/uni_modules/uni-icons/components/uni-icons/uni-icons.vue'\nwx.createComponent(Component)"],"names":["fontData"],"mappings":";;;AAcC,MAAM,SAAS,CAAC,QAAQ;AACvB,QAAM,MAAM;AACZ,SAAQ,OAAO,QAAQ,YAAY,IAAI,KAAK,GAAG,IAAK,MAAM,OAAO;AAClE;AAqBA,MAAK,YAAU;AAAA,EACd,MAAM;AAAA,EACN,OAAO,CAAC,OAAO;AAAA,EACf,OAAO;AAAA,IACN,MAAM;AAAA,MACL,MAAM;AAAA,MACN,SAAS;AAAA,IACT;AAAA,IACD,OAAO;AAAA,MACN,MAAM;AAAA,MACN,SAAS;AAAA,IACT;AAAA,IACD,MAAM;AAAA,MACL,MAAM,CAAC,QAAQ,MAAM;AAAA,MACrB,SAAS;AAAA,IACT;AAAA,IACD,cAAc;AAAA,MACb,MAAM;AAAA,MACN,SAAS;AAAA,IACT;AAAA,IACD,YAAY;AAAA,MACX,MAAM;AAAA,MACN,SAAS;AAAA,IACV;AAAA,EACA;AAAA,EACD,OAAO;AACN,WAAO;AAAA,MACN,OAAOA,2DAAO;AAAA,IACf;AAAA,EACA;AAAA,EACD,UAAU;AAAA,IACT,UAAU;AACT,UAAI,OAAO,KAAK,MAAM,KAAK,OAAK,EAAE,eAAe,KAAK,IAAI;AAC1D,UAAI,MAAM;AACT,eAAO,KAAK;AAAA,MACb;AACA,aAAO;AAAA,IACP;AAAA,IACD,WAAW;AACV,aAAO,OAAO,KAAK,IAAI;AAAA,IACvB;AAAA,IACD,WAAW;AACV,UAAI,KAAK,eAAe,IAAI;AAC3B,eAAO,UAAU,KAAK,KAAK,gBAAgB,KAAK,QAAQ,kBAAkB,KAAK,UAAU;AAAA,MAC1F;AACA,aAAO,UAAU,KAAK,KAAK,gBAAgB,KAAK,QAAQ;AAAA,IACzD;AAAA,EACA;AAAA,EACD,SAAS;AAAA,IACR,WAAW;AACV,WAAK,MAAM,OAAO;AAAA,IACnB;AAAA,EACD;AACD;;;;;;;;;;;AC1FD,GAAG,gBAAgB,SAAS;"}
|
||||
{"version":3,"file":"uni-icons.js","sources":["uni_modules/uni-icons/components/uni-icons/uni-icons.vue","../../../soft/HBuilderX/plugins/uniapp-cli-vite/uniComponent:/RDovd2ViL3p5dC9Ub25namlVbmlBcHAvdW5pX21vZHVsZXMvdW5pLWljb25zL2NvbXBvbmVudHMvdW5pLWljb25zL3VuaS1pY29ucy52dWU"],"sourcesContent":["<template>\r\n\t<!-- #ifdef APP-NVUE -->\r\n\t<text :style=\"styleObj\" class=\"uni-icons\" @click=\"_onClick\">{{unicode}}</text>\r\n\t<!-- #endif -->\r\n\t<!-- #ifndef APP-NVUE -->\r\n\t<text :style=\"styleObj\" class=\"uni-icons\" :class=\"['uniui-'+type,customPrefix,customPrefix?type:'']\" @click=\"_onClick\">\r\n\t\t<slot></slot>\r\n\t</text>\r\n\t<!-- #endif -->\r\n</template>\r\n\r\n<script>\r\n\timport { fontData } from './uniicons_file_vue.js';\r\n\r\n\tconst getVal = (val) => {\r\n\t\tconst reg = /^[0-9]*$/g\r\n\t\treturn (typeof val === 'number' || reg.test(val)) ? val + 'px' : val;\r\n\t}\r\n\r\n\t// #ifdef APP-NVUE\r\n\tvar domModule = weex.requireModule('dom');\r\n\timport iconUrl from './uniicons.ttf'\r\n\tdomModule.addRule('fontFace', {\r\n\t\t'fontFamily': \"uniicons\",\r\n\t\t'src': \"url('\" + iconUrl + \"')\"\r\n\t});\r\n\t// #endif\r\n\r\n\t/**\r\n\t * Icons 图标\r\n\t * @description 用于展示 icons 图标\r\n\t * @tutorial https://ext.dcloud.net.cn/plugin?id=28\r\n\t * @property {Number} size 图标大小\r\n\t * @property {String} type 图标图案,参考示例\r\n\t * @property {String} color 图标颜色\r\n\t * @property {String} customPrefix 自定义图标\r\n\t * @event {Function} click 点击 Icon 触发事件\r\n\t */\r\n\texport default {\r\n\t\tname: 'UniIcons',\r\n\t\temits: ['click'],\r\n\t\tprops: {\r\n\t\t\ttype: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: ''\r\n\t\t\t},\r\n\t\t\tcolor: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: '#333333'\r\n\t\t\t},\r\n\t\t\tsize: {\r\n\t\t\t\ttype: [Number, String],\r\n\t\t\t\tdefault: 16\r\n\t\t\t},\r\n\t\t\tcustomPrefix: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: ''\r\n\t\t\t},\r\n\t\t\tfontFamily: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: ''\r\n\t\t\t}\r\n\t\t},\r\n\t\tdata() {\r\n\t\t\treturn {\r\n\t\t\t\ticons: fontData\r\n\t\t\t}\r\n\t\t},\r\n\t\tcomputed: {\r\n\t\t\tunicode() {\r\n\t\t\t\tlet code = this.icons.find(v => v.font_class === this.type)\r\n\t\t\t\tif (code) {\r\n\t\t\t\t\treturn code.unicode\r\n\t\t\t\t}\r\n\t\t\t\treturn ''\r\n\t\t\t},\r\n\t\t\ticonSize() {\r\n\t\t\t\treturn getVal(this.size)\r\n\t\t\t},\r\n\t\t\tstyleObj() {\r\n\t\t\t\tif (this.fontFamily !== '') {\r\n\t\t\t\t\treturn `color: ${this.color}; font-size: ${this.iconSize}; font-family: ${this.fontFamily};`\r\n\t\t\t\t}\r\n\t\t\t\treturn `color: ${this.color}; font-size: ${this.iconSize};`\r\n\t\t\t}\r\n\t\t},\r\n\t\tmethods: {\r\n\t\t\t_onClick() {\r\n\t\t\t\tthis.$emit('click')\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n</script>\r\n\r\n<style lang=\"scss\">\r\n\t/* #ifndef APP-NVUE */\r\n\t@import './uniicons.css';\r\n\r\n\t@font-face {\r\n\t\tfont-family: uniicons;\r\n\t\tsrc: url('./uniicons.ttf');\r\n\t}\r\n\r\n\t/* #endif */\r\n\t.uni-icons {\r\n\t\tfont-family: uniicons;\r\n\t\ttext-decoration: none;\r\n\t\ttext-align: center;\r\n\t}\r\n</style>\n","import Component from 'D:/web/zyt/TongjiUniApp/uni_modules/uni-icons/components/uni-icons/uni-icons.vue'\nwx.createComponent(Component)"],"names":["getVal","val","_sfc_main","fontData","code","v","Component"],"mappings":"iGAcOA,EAAUC,GAEP,OAAOA,GAAQ,UADX,YAC2B,KAAKA,CAAG,EAAKA,EAAM,KAAOA,EAsB7DC,EAAU,CACd,KAAM,WACN,MAAO,CAAC,OAAO,EACf,MAAO,CACN,KAAM,CACL,KAAM,OACN,QAAS,EACT,EACD,MAAO,CACN,KAAM,OACN,QAAS,SACT,EACD,KAAM,CACL,KAAM,CAAC,OAAQ,MAAM,EACrB,QAAS,EACT,EACD,aAAc,CACb,KAAM,OACN,QAAS,EACT,EACD,WAAY,CACX,KAAM,OACN,QAAS,EACV,CACA,EACD,MAAO,CACN,MAAO,CACN,MAAOC,EAAO,QACf,CACA,EACD,SAAU,CACT,SAAU,CACT,IAAIC,EAAO,KAAK,MAAM,KAAKC,GAAKA,EAAE,aAAe,KAAK,IAAI,EAC1D,OAAID,EACIA,EAAK,QAEN,EACP,EACD,UAAW,CACV,OAAOJ,EAAO,KAAK,IAAI,CACvB,EACD,UAAW,CACV,OAAI,KAAK,aAAe,GAChB,UAAU,KAAK,KAAK,gBAAgB,KAAK,QAAQ,kBAAkB,KAAK,UAAU,IAEnF,UAAU,KAAK,KAAK,gBAAgB,KAAK,QAAQ,GACzD,CACA,EACD,QAAS,CACR,UAAW,CACV,KAAK,MAAM,OAAO,CACnB,CACD,CACD,0NC1FD,GAAG,gBAAgBM,CAAS"}
|
||||
@@ -1,2 +1,2 @@
|
||||
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const n=require("./common/vendor.js"),l={__name:"App",setup(e){const{proxy:o}=n.getCurrentInstance();n.onLaunch(()=>{try{n.index.setInnerAudioOption({obeyMuteSwitch:!1,mixWithOther:!0})}catch(r){n.index.__f__("warn","at App.vue:16","setInnerAudioOption failed:",r)}n.index.login({provider:"weixin",success:function(r){n.index.getStorageSync("token")?i(r.code):a(r.code)}})});const a=async r=>{try{const t=await o.apiUrl({url:"/api/login/mnpLogin",method:"POST",data:{code:r}},!1);t&&t.data&&t.data.token&&(n.index.setStorageSync("token",t.data.token),n.index.setStorageSync("userData",t.data))}catch{}},i=async r=>{try{const t=await o.apiUrl({url:"/api/user/info",method:"POST"},!1);if(t.code==-1){a(r);return}t&&t.data&&n.index.setStorageSync("userData",t.data)}catch{}};return()=>{}}};var d="https://admin.zhenyangtang.com.cn/";function f(e,o){const a=String(e||"").replace(/\/+$/,""),i=String(o||"").replace(/^\/+/,"");return i?`${a}/${i}`:a}function m(e,o=!0){const i={token:n.index.getStorageSync("token"),"content-type":"application/x-www-form-urlencoded",...e.header||{}},r=e.data||{},t=f(d,e.url||"");return new Promise((g,u)=>{o&&n.index.showLoading({title:"加载中...",mask:!0}),n.index.request({url:t,data:r,timeout:e.timeout!=null?e.timeout:15e3,method:e.method||"POST",header:i,sslVerify:!1,success:s=>{const c=s.data;c.code===-1&&n.index.removeStorageSync("token"),g(c)},fail:s=>{u(s)},complete:s=>{o&&n.index.hideLoading()}})})}function S(e){const o={};if(!e)return o;if(e.scene)try{decodeURIComponent(e.scene).split("&").forEach(i=>{const[r,t]=i.split("=");r&&t&&(o[r]=t)})}catch(a){n.index.__f__("error","at main.js:126","解析scene参数失败:",a)}return Object.keys(e).forEach(a=>{a!=="scene"&&e[a]&&(o[a]=e[a])}),o}function p(){const e=n.createSSRApp(l);return e.config.globalProperties.$url=d,e.config.globalProperties.apiUrl=m,e.config.globalProperties.$parsePageParams=S,{app:e}}p().app.mount("#app");exports.createApp=p;
|
||||
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const n=require("./common/vendor.js"),l={__name:"App",setup(e){const{proxy:o}=n.getCurrentInstance();n.onLaunch(()=>{try{n.index.setInnerAudioOption({obeyMuteSwitch:!1,mixWithOther:!0})}catch(r){n.index.__f__("warn","at App.vue:16","setInnerAudioOption failed:",r)}n.index.login({provider:"weixin",success:function(r){n.index.getStorageSync("token")?s(r.code):a(r.code)}})});const a=async r=>{try{const t=await o.apiUrl({url:"/api/login/mnpLogin",method:"POST",data:{code:r}},!1);t&&t.data&&t.data.token&&(n.index.setStorageSync("token",t.data.token),n.index.setStorageSync("userData",t.data))}catch{}},s=async r=>{try{const t=await o.apiUrl({url:"/api/user/info",method:"POST"},!1);if(t.code==-1){a(r);return}t&&t.data&&n.index.setStorageSync("userData",t.data)}catch{}};return()=>{}}};var d="https://xt.zhenyangtang.com.cn/";function f(e,o){const a=String(e||"").replace(/\/+$/,""),s=String(o||"").replace(/^\/+/,"");return s?`${a}/${s}`:a}function m(e,o=!0){const s={token:n.index.getStorageSync("token"),"content-type":"application/x-www-form-urlencoded",...e.header||{}},r=e.data||{},t=f(d,e.url||"");return new Promise((g,u)=>{o&&n.index.showLoading({title:"加载中...",mask:!0}),n.index.request({url:t,data:r,timeout:e.timeout!=null?e.timeout:15e3,method:e.method||"POST",header:s,sslVerify:!1,success:i=>{const c=i.data;c.code===-1&&n.index.removeStorageSync("token"),g(c)},fail:i=>{u(i)},complete:i=>{o&&n.index.hideLoading()}})})}function S(e){const o={};if(!e)return o;if(e.scene)try{decodeURIComponent(e.scene).split("&").forEach(s=>{const[r,t]=s.split("=");r&&t&&(o[r]=t)})}catch(a){n.index.__f__("error","at main.js:126","解析scene参数失败:",a)}return Object.keys(e).forEach(a=>{a!=="scene"&&e[a]&&(o[a]=e[a])}),o}function p(){const e=n.createSSRApp(l);return e.config.globalProperties.$url=d,e.config.globalProperties.apiUrl=m,e.config.globalProperties.$parsePageParams=S,{app:e}}p().app.mount("#app");exports.createApp=p;
|
||||
//# sourceMappingURL=../.sourcemap/mp-weixin/app.js.map
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"compileType": "miniprogram",
|
||||
"libVersion": "",
|
||||
"appid": "wx79b9a0bfbfe7cbcd",
|
||||
"appid": "wx0c2a9d5ebad26a3f",
|
||||
"projectname": "tongji-uniapp",
|
||||
"condition": {
|
||||
"search": {
|
||||
|
||||
@@ -81,10 +81,10 @@
|
||||
<el-option
|
||||
v-for="item in group.channels"
|
||||
:key="item.code"
|
||||
:label="item.name"
|
||||
:label="item.search_label || item.name"
|
||||
:value="item.code"
|
||||
>
|
||||
<span class="channel-option">
|
||||
<span class="channel-option" :class="{ 'is-group': item.kind === 'group' }">
|
||||
<span>{{ item.name }}</span>
|
||||
<span>{{ item.customer_count }} 人</span>
|
||||
</span>
|
||||
@@ -96,47 +96,47 @@
|
||||
</section>
|
||||
|
||||
<section class="metric-grid" aria-label="综合转化指标">
|
||||
<article v-for="metric in metricCards" :key="metric.key" class="metric-card">
|
||||
<article v-for="metric in visibleMetricCards" :key="metric.key" class="metric-card">
|
||||
<span>{{ metric.label }}</span>
|
||||
<strong>{{ formatMetric(metric.key, metric.type) }}</strong>
|
||||
<small>{{ metric.hint }}</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-if="showRankings" class="ranking-grid">
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>{{ rankingSubject }}订单量占比排名</h2>
|
||||
<p>按{{ rankingSubject }}归属统计,排除取消、拒收及退款</p>
|
||||
</div>
|
||||
<span>单位:单</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.orders.length" class="bar-list">
|
||||
<div v-for="(item, index) in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row">
|
||||
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, totalOrderValue) }" /></div>
|
||||
<strong><span>{{ formatNumber(item.value) }} 单</span><small>{{ formatShare(item.value, totalOrderValue) }}</small></strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
|
||||
<section v-if="showRankings" class="ranking-grid">
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>{{ rankingSubject }}订单量占比排名</h2>
|
||||
<p>按{{ rankingSubject }}归属统计,排除取消、拒收及退款</p>
|
||||
</div>
|
||||
<span>单位:单</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.orders.length" class="bar-list">
|
||||
<div v-for="(item, index) in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row">
|
||||
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, totalOrderValue) }" /></div>
|
||||
<strong><span>{{ formatNumber(item.value) }} 单</span><small>{{ formatShare(item.value, totalOrderValue) }}</small></strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
|
||||
</article>
|
||||
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>{{ rankingSubject }}金额占比排名</h2>
|
||||
<p>按{{ rankingSubject }}归属统计,仅含未取消、未拒收且未退款的有效金额</p>
|
||||
</div>
|
||||
<span>单位:元</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
|
||||
<div v-for="(item, index) in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row">
|
||||
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, totalAmountValue) }" /></div>
|
||||
<strong><span>{{ formatMoney(item.value) }}</span><small>{{ formatShare(item.value, totalAmountValue) }}</small></strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>{{ rankingSubject }}金额占比排名</h2>
|
||||
<p>按{{ rankingSubject }}归属统计,仅含未取消、未拒收且未退款的有效金额</p>
|
||||
</div>
|
||||
<span>单位:元</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
|
||||
<div v-for="(item, index) in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row">
|
||||
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, totalAmountValue) }" /></div>
|
||||
<strong><span>{{ formatMoney(item.value) }}</span><small>{{ formatShare(item.value, totalAmountValue) }}</small></strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="54" description="当前范围暂无金额数据" />
|
||||
</article>
|
||||
</section>
|
||||
@@ -156,7 +156,7 @@
|
||||
default-expand-all
|
||||
class="detail-table"
|
||||
>
|
||||
<el-table-column prop="name" label="部门 / 人员" min-width="220" fixed="left">
|
||||
<el-table-column prop="name" label="部门 / 人员" min-width="220" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<strong :class="{ 'is-parent': Array.isArray(row.children) && row.children.length, 'is-member': row.type === 'member' }">
|
||||
{{ row.name }}
|
||||
@@ -178,34 +178,34 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="add_fans_count" label="加粉" min-width="72" align="right" />
|
||||
<el-table-column prop="total_open_count" label="开口" min-width="72" align="right" />
|
||||
<el-table-column prop="paid_appointment_count" label="挂号" min-width="72" align="right" />
|
||||
<el-table-column prop="appointment_total_count" label="预约" min-width="72" align="right" />
|
||||
<el-table-column prop="interview_count" label="面诊" min-width="72" align="right" />
|
||||
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="88" align="right" />
|
||||
<el-table-column label="接诊金额" min-width="104" align="right">
|
||||
<el-table-column prop="add_fans_count" label="加粉" min-width="72" align="right" />
|
||||
<el-table-column prop="total_open_count" label="开口" min-width="72" align="right" />
|
||||
<el-table-column prop="paid_appointment_count" label="挂号" min-width="72" align="right" />
|
||||
<el-table-column prop="appointment_total_count" label="预约" min-width="72" align="right" />
|
||||
<el-table-column prop="interview_count" label="面诊" min-width="72" align="right" />
|
||||
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="88" align="right" />
|
||||
<el-table-column label="接诊金额" min-width="104" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开口率" min-width="82" align="right">
|
||||
<el-table-column label="开口率" min-width="82" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.total_open_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="挂号率" min-width="82" align="right">
|
||||
<el-table-column label="挂号率" min-width="82" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.paid_appointment_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="面诊率" min-width="82" align="right">
|
||||
<el-table-column label="面诊率" min-width="82" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.interview_paid_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预约率" min-width="82" align="right">
|
||||
<el-table-column label="预约率" min-width="82" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="面诊接诊率" min-width="100" align="right">
|
||||
<el-table-column label="面诊接诊率" min-width="100" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="接诊率" min-width="82" align="right">
|
||||
<el-table-column label="接诊率" min-width="82" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="ROI" min-width="72" align="right">
|
||||
<el-table-column v-if="canViewFinance" label="ROI" min-width="72" align="right">
|
||||
<template #default="{ row }">{{ formatRatio(row.roi) }}</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="当前权限范围内暂无转化数据" /></template>
|
||||
@@ -287,13 +287,18 @@ type MediaChannelOption = {
|
||||
tag_id?: string
|
||||
group_name?: string
|
||||
customer_count?: number
|
||||
kind?: 'channel' | 'group'
|
||||
search_label?: string
|
||||
}
|
||||
|
||||
const FINANCE_METRIC_KEYS = new Set(['account_cost', 'roi'])
|
||||
|
||||
const emptyDashboard = () => ({
|
||||
meta: {
|
||||
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
|
||||
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
|
||||
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: '', ranking_kind: 'hidden'
|
||||
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
|
||||
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
|
||||
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: '', ranking_kind: 'hidden',
|
||||
can_view_finance: false
|
||||
},
|
||||
filters: {
|
||||
departments: [] as any[],
|
||||
@@ -347,30 +352,58 @@ const scopeDescription = computed(() => {
|
||||
if (dashboard.meta.selected_media_channel_name) parts.push(`渠道:${dashboard.meta.selected_media_channel_name}`)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
const canViewFinance = computed(() => Boolean(dashboard.meta.can_view_finance))
|
||||
const visibleMetricCards = computed(() =>
|
||||
canViewFinance.value
|
||||
? metricCards
|
||||
: metricCards.filter((card) => !FINANCE_METRIC_KEYS.has(card.key))
|
||||
)
|
||||
const mediaChannelGroups = computed(() => {
|
||||
const groups = new Map<string, {
|
||||
const groups: Array<{
|
||||
group_name: string
|
||||
customer_count: number
|
||||
channels: MediaChannelOption[]
|
||||
}>()
|
||||
}> = []
|
||||
const indexByName = new Map<string, number>()
|
||||
|
||||
for (const channel of dashboard.filters.media_channels) {
|
||||
const groupName = channel.group_name || ''
|
||||
if (!groups.has(groupName)) {
|
||||
groups.set(groupName, { group_name: groupName, customer_count: 0, channels: [] })
|
||||
if (channel.kind === 'group') continue
|
||||
const groupName = String(channel.group_name || '').trim()
|
||||
let groupIndex = indexByName.get(groupName)
|
||||
if (groupIndex === undefined) {
|
||||
groupIndex = groups.length
|
||||
indexByName.set(groupName, groupIndex)
|
||||
groups.push({ group_name: groupName, customer_count: 0, channels: [] })
|
||||
}
|
||||
const group = groups.get(groupName)!
|
||||
group.channels.push(channel)
|
||||
const group = groups[groupIndex]
|
||||
const searchLabel = groupName !== '' && !channel.name.includes(groupName)
|
||||
? `${channel.name} ${groupName}`
|
||||
: ''
|
||||
group.channels.push(searchLabel === '' ? channel : { ...channel, search_label: searchLabel })
|
||||
group.customer_count = Math.max(group.customer_count, Number(channel.customer_count || 0))
|
||||
}
|
||||
return Array.from(groups.values())
|
||||
|
||||
for (const group of groups) {
|
||||
if (group.group_name === '') continue
|
||||
const hasSameNameLeaf = group.channels.some((item) => item.name === group.group_name)
|
||||
if (group.channels.length < 2 && hasSameNameLeaf) continue
|
||||
group.channels.unshift({
|
||||
code: `group:${group.group_name}`,
|
||||
name: `${group.group_name}(全部)`,
|
||||
group_name: group.group_name,
|
||||
customer_count: group.customer_count,
|
||||
kind: 'group'
|
||||
})
|
||||
}
|
||||
return groups
|
||||
})
|
||||
const rankingKind = computed(() => dashboard.meta.ranking_kind || (
|
||||
Number(dashboard.meta.scope_value) === 4 ? 'hidden' : Number(dashboard.meta.scope_value) === 3 ? 'member' : 'group'
|
||||
))
|
||||
const showRankings = computed(() => rankingKind.value !== 'hidden')
|
||||
const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组')
|
||||
const totalOrderValue = computed(() => dashboard.rankings.orders.reduce((total, item) => total + Number(item.value || 0), 0))
|
||||
const totalAmountValue = computed(() => dashboard.rankings.amounts.reduce((total, item) => total + Number(item.value || 0), 0))
|
||||
const rankingKind = computed(() => dashboard.meta.ranking_kind || (
|
||||
Number(dashboard.meta.scope_value) === 4 ? 'hidden' : Number(dashboard.meta.scope_value) === 3 ? 'member' : 'group'
|
||||
))
|
||||
const showRankings = computed(() => rankingKind.value !== 'hidden')
|
||||
const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组')
|
||||
const totalOrderValue = computed(() => dashboard.rankings.orders.reduce((total, item) => total + Number(item.value || 0), 0))
|
||||
const totalAmountValue = computed(() => dashboard.rankings.amounts.reduce((total, item) => total + Number(item.value || 0), 0))
|
||||
const targetChartOption = computed(() => ({
|
||||
animationDuration: 450,
|
||||
color: ['#0f9185', '#2f78df'],
|
||||
@@ -452,14 +485,14 @@ function formatMoney(value: any) {
|
||||
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function formatPercent(value: any) {
|
||||
return `${Number(value || 0).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatShare(value: any, total: number) {
|
||||
if (total <= 0) return '0.0%'
|
||||
return `${(Number(value || 0) / total * 100).toFixed(1)}%`
|
||||
}
|
||||
function formatPercent(value: any) {
|
||||
return `${Number(value || 0).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatShare(value: any, total: number) {
|
||||
if (total <= 0) return '0.0%'
|
||||
return `${(Number(value || 0) / total * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function nullablePercent(value: any) {
|
||||
return value === null || value === undefined ? '未设置' : formatPercent(value)
|
||||
@@ -474,11 +507,11 @@ function compactNumber(value: number) {
|
||||
return String(Math.round(value))
|
||||
}
|
||||
|
||||
function barWidth(value: any, total: number) {
|
||||
const numericValue = Number(value || 0)
|
||||
if (total <= 0 || numericValue <= 0) return '0%'
|
||||
return `${Math.max(4, Math.min(100, numericValue / total * 100))}%`
|
||||
}
|
||||
function barWidth(value: any, total: number) {
|
||||
const numericValue = Number(value || 0)
|
||||
if (total <= 0 || numericValue <= 0) return '0%'
|
||||
return `${Math.max(4, Math.min(100, numericValue / total * 100))}%`
|
||||
}
|
||||
|
||||
function progressValue(value: any) {
|
||||
return Math.max(0, Math.min(100, Number(value || 0)))
|
||||
@@ -488,29 +521,29 @@ onMounted(loadDashboard)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.conversion-page {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 640px;
|
||||
padding: 16px;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
color: #172033;
|
||||
background: #f4f6f8;
|
||||
}
|
||||
.conversion-page {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 640px;
|
||||
padding: 16px;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
color: #172033;
|
||||
background: #f4f6f8;
|
||||
}
|
||||
|
||||
.page-heading,
|
||||
.filter-strip,
|
||||
.panel,
|
||||
.metric-card {
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #dfe5ec;
|
||||
background: #fff;
|
||||
}
|
||||
.metric-card {
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #dfe5ec;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
@@ -553,7 +586,7 @@ onMounted(loadDashboard)
|
||||
.date-range-picker { width: 260px; }
|
||||
.employee-select { width: 190px; }
|
||||
.dept-select { width: 220px; }
|
||||
.channel-select { width: 180px; }
|
||||
.channel-select { width: 220px; }
|
||||
.channel-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -565,6 +598,11 @@ onMounted(loadDashboard)
|
||||
color: #98a2b3;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&.is-group span:first-child {
|
||||
font-weight: 650;
|
||||
color: #172033;
|
||||
}
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
@@ -584,7 +622,7 @@ onMounted(loadDashboard)
|
||||
}
|
||||
|
||||
.ranking-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.panel { min-width: 0; max-width: 100%; padding: 16px; border-radius: 10px; box-sizing: border-box; }
|
||||
.panel { min-width: 0; max-width: 100%; padding: 16px; border-radius: 10px; box-sizing: border-box; }
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
@@ -598,26 +636,26 @@ onMounted(loadDashboard)
|
||||
> span { color: #929dac; font-size: 11px; white-space: nowrap; }
|
||||
}
|
||||
|
||||
.bar-list { display: grid; gap: 13px; max-height: 340px; overflow-y: auto; padding-right: 4px; }
|
||||
.bar-row { display: grid; grid-template-columns: 130px minmax(80px, 1fr) 116px; align-items: center; gap: 10px; }
|
||||
.bar-name { overflow: hidden; color: #66748a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bar-name b { display: inline-block; width: 20px; margin-right: 7px; color: #98a2b3; font-size: 11px; font-weight: 650; text-align: center; }
|
||||
.bar-row > strong { display: flex; align-items: baseline; justify-content: flex-end; gap: 7px; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.bar-row > strong small { color: #8a95a5; font-size: 10px; font-weight: 500; }
|
||||
.bar-list { display: grid; gap: 13px; max-height: 340px; overflow-y: auto; padding-right: 4px; }
|
||||
.bar-row { display: grid; grid-template-columns: 130px minmax(80px, 1fr) 116px; align-items: center; gap: 10px; }
|
||||
.bar-name { overflow: hidden; color: #66748a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bar-name b { display: inline-block; width: 20px; margin-right: 7px; color: #98a2b3; font-size: 11px; font-weight: 650; text-align: center; }
|
||||
.bar-row > strong { display: flex; align-items: baseline; justify-content: flex-end; gap: 7px; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.bar-row > strong small { color: #8a95a5; font-size: 10px; font-weight: 500; }
|
||||
.bar-track { height: 18px; overflow: hidden; border-radius: 5px; background: #edf1f5; }
|
||||
.bar-track i { display: block; height: 100%; border-radius: 5px; transition: width .35s ease; }
|
||||
.bar-track i.is-teal { background: #15998d; }
|
||||
.bar-track i.is-blue { background: #307bdf; }
|
||||
|
||||
.detail-panel { padding-bottom: 10px; overflow: hidden; }
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
:deep(.el-table__inner-wrapper),
|
||||
:deep(.el-scrollbar) { max-width: 100%; }
|
||||
:deep(th.el-table__cell) { color: #66748a; background: #f7f9fb; font-size: 12px; }
|
||||
.detail-panel { padding-bottom: 10px; overflow: hidden; }
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
:deep(.el-table__inner-wrapper),
|
||||
:deep(.el-scrollbar) { max-width: 100%; }
|
||||
:deep(th.el-table__cell) { color: #66748a; background: #f7f9fb; font-size: 12px; }
|
||||
:deep(td.el-table__cell) { color: #273347; font-size: 12px; }
|
||||
:deep(.el-table__row--level-0 > td.el-table__cell) { background: #edf7f5; font-weight: 650; }
|
||||
strong.is-parent { color: #172033; font-weight: 700; }
|
||||
@@ -658,7 +696,7 @@ onMounted(loadDashboard)
|
||||
.metric-grid, .ranking-grid { grid-template-columns: 1fr; }
|
||||
.filter-item, .filter-item--time { width: 100%; align-items: flex-start; flex-direction: column; }
|
||||
.employee-select, .dept-select, .channel-select { width: 100%; }
|
||||
.bar-row { grid-template-columns: 100px minmax(70px, 1fr) 96px; }
|
||||
.bar-row { grid-template-columns: 100px minmax(70px, 1fr) 96px; }
|
||||
.target-summary { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 106 KiB |
@@ -0,0 +1,39 @@
|
||||
# AiConsultDialog 诊单工作区最终验收
|
||||
|
||||
日期:2026-08-18
|
||||
范围:诊单 `501`;复用 `tests/test_ai_consult_workspace_ui.py` 的 `WorkspaceRepository` 与即时异步执行方式;只读验收,未修改业务源码或测试。
|
||||
|
||||
## 结论
|
||||
|
||||
内容、控件数量、安全过滤与横向几何均通过。四个页签均有非空中文内容,没有出现 `['...']`、`{'...'}` 等 Python 集合 repr。默认窗口 `1280 × 820` 和最小窗口 `1080 × 680` 下,四页签横向滚动最大值均为 `0`,内容宽度等于视口宽度,未发现后代控件越界。
|
||||
|
||||
存在 1 类状态提示问题:测试仓储故意返回外诊单数据时,检查检验页和健康档案页已正确过滤这些数据,但把“已拒绝 1 条归属其他诊单的数据”呈现为红色 `error` 状态并显示“重新加载”。有效内容仍完整可见,安全边界也生效;建议后续改成 warning/info 提示,避免被误解为数据加载失败。
|
||||
|
||||
## 截图
|
||||
|
||||
- `01_case_records.png`:病历资料
|
||||
- `02_exam_tests.png`:检查检验
|
||||
- `03_prescriptions.png`:处方记录
|
||||
- `04_health_profile.png`:健康档案
|
||||
|
||||
截图均为 `1280 × 820`,按真实应用启动路径调用全局主题,字体为 `Microsoft YaHei UI`。
|
||||
|
||||
## 内容与控件核验
|
||||
|
||||
| 页签 | 核验结果 |
|
||||
| --- | --- |
|
||||
| 病历资料 | 文本 969 字符;`AiConsultCaseGrid` 1 个;中文集合已人类可读化;Python repr 标记 0 个。 |
|
||||
| 检查检验 | 文本 173 字符;时间线 1 个;舌苔缩略图 1 个;附件按钮 3 个,其中 PDF 报告 2 个;安全 HTTP(S) 按钮可用,本地 `file:` 附件按钮禁用。 |
|
||||
| 处方记录 | 文本 316 字符;处方卡 3 张;详情按钮 3 个;处方 ID 为 `5011 / 5012 / 5013`。 |
|
||||
| 健康档案 | 文本 443 字符;患者信息网格 1 个;本诊单健康概览 1 个;血糖/血压、饮食、运动三类跟踪记录均存在;手机号和身份证号已脱敏。 |
|
||||
|
||||
## 几何
|
||||
|
||||
- `1280 × 820`:记录面板约 `876 × 586`;有纵向滚动条时视口/内容宽度均为 `824`,检查检验页为 `834`;所有页签 `hbar maximum = 0`、后代越界量 `0`。
|
||||
- `1080 × 680`:记录面板 `676 × 446`;有纵向滚动条时视口/内容宽度均为 `624`,检查检验页为 `634`;所有页签 `hbar maximum = 0`、后代越界量 `0`。
|
||||
- 长内容通过纵向滚动呈现:病历、处方、健康档案的默认窗口纵向最大值分别为 `1064 / 215 / 455`;未发现横向裁切。
|
||||
|
||||
## Smoke
|
||||
|
||||
执行:`uv run --offline pytest -q tests/test_ai_consult_workspace_ui.py`
|
||||
结果:`11 passed`。仅有 pytest 缓存目录无写权限警告,不影响测试结果。
|
||||
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 149 KiB |
@@ -31,6 +31,8 @@ class _ScreenshotDiagnosisDialog(QWidget):
|
||||
|
||||
consultations_module.DiagnosisDialog = _ScreenshotDiagnosisDialog
|
||||
|
||||
DENSITY_SIZES = ((1366, 768), (1710, 920))
|
||||
|
||||
|
||||
def _row(identifier: int, variant: int) -> dict[str, Any]:
|
||||
common: dict[str, Any] = {
|
||||
@@ -335,7 +337,7 @@ def _save_with_payment_qr(
|
||||
return path
|
||||
|
||||
|
||||
def render() -> list[Path]:
|
||||
def _application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
# The offscreen Windows plugin does not enumerate system fonts. Register
|
||||
# the same CJK face used by the production QSS when it is available.
|
||||
@@ -345,12 +347,35 @@ def render() -> list[Path]:
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
if families:
|
||||
app.setFont(QFont(families[0], 9))
|
||||
return app
|
||||
|
||||
|
||||
def render_density() -> list[Path]:
|
||||
"""Render only the two desktop-density acceptance sizes."""
|
||||
|
||||
app = _application()
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
output = root / "artifacts" / "diagnosis_visual"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
paths: list[Path] = []
|
||||
repository = ScreenshotRepository()
|
||||
for width, height in ((1024, 640), (1440, 900)):
|
||||
for width, height in DENSITY_SIZES:
|
||||
page = _new_page(app, repository, width, height)
|
||||
path = output / f"diagnosis_{width}x{height}.png"
|
||||
paths.append(_save(page, path))
|
||||
page.close()
|
||||
app.processEvents()
|
||||
return paths
|
||||
|
||||
|
||||
def render() -> list[Path]:
|
||||
app = _application()
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
output = root / "artifacts" / "diagnosis_visual"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
paths: list[Path] = []
|
||||
repository = ScreenshotRepository()
|
||||
for width, height in ((1024, 640), *DENSITY_SIZES, (1440, 900)):
|
||||
page = _new_page(app, repository, width, height)
|
||||
path = output / f"diagnosis_{width}x{height}.png"
|
||||
paths.append(_save(page, path))
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Render patient-level AI report layout regressions with the offscreen Qt backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtGui import QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QScrollArea
|
||||
|
||||
from doctor_workstation.ui.pages.reception import _ReceptionAiAnalysisDialog
|
||||
|
||||
|
||||
def _report_payload(model_key: str) -> dict[str, object]:
|
||||
model_label = "OpenAI" if model_key == "openai" else "千问"
|
||||
return {
|
||||
"model_key": model_key,
|
||||
"model_label": model_label,
|
||||
"generated_at": "2026-08-17 10:20:00",
|
||||
"diagnosis_advice": [
|
||||
"2型糖尿病,HbA1c 7.5%,近期空腹血糖仍有波动。",
|
||||
"建议:1. 监测空腹血糖 2. 记录餐后2小时血糖 3. 复核低血糖症状",
|
||||
r"保留患者原始报告结构。\n结合复诊记录动态调整随访频率。",
|
||||
],
|
||||
"risk_assessment": [
|
||||
{"label": "低血糖", "level": "high"},
|
||||
{"label": "依从性风险", "level": "medium"},
|
||||
{"label": "并发症筛查延误风险", "level": "low"},
|
||||
{"label": "复诊中断风险", "level": "medium"},
|
||||
{
|
||||
"label": "肾功能变化可能影响二甲双胍方案,需要结合复查结果持续评估。",
|
||||
"level": "high",
|
||||
},
|
||||
{"label": "饮食波动风险", "level": "low"},
|
||||
],
|
||||
"treatment_advice": [
|
||||
r"二甲双胍 0.5g,每日2次。\n复查肾功能后再评估剂量。",
|
||||
"继续糖尿病饮食教育,并记录运动后的血糖变化。",
|
||||
"如出现心悸、出汗或意识异常,及时复测血糖并按流程处置。",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _render(
|
||||
app: QApplication,
|
||||
output: Path,
|
||||
*,
|
||||
width: int,
|
||||
height: int,
|
||||
) -> None:
|
||||
histories = {
|
||||
"qwen": [_report_payload("qwen")],
|
||||
"openai": [_report_payload("openai")],
|
||||
}
|
||||
dialog = _ReceptionAiAnalysisDialog(histories, preferred_model="qwen")
|
||||
dialog.resize(width, height)
|
||||
dialog.show()
|
||||
for _index in range(3):
|
||||
app.processEvents()
|
||||
|
||||
pixmap = dialog.grab()
|
||||
if pixmap.width() != width or pixmap.height() != height:
|
||||
raise RuntimeError(
|
||||
f"unexpected render size: {pixmap.width()}x{pixmap.height()} "
|
||||
f"(expected {width}x{height})"
|
||||
)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not pixmap.save(str(output), "PNG"):
|
||||
raise RuntimeError(f"failed to save {output}")
|
||||
|
||||
scrolls = dialog.findChildren(QScrollArea)
|
||||
risks = [
|
||||
label
|
||||
for label in dialog.findChildren(QLabel)
|
||||
if label.property("dialogAiRisk")
|
||||
]
|
||||
print(
|
||||
"PATIENT_AI_LAYOUT",
|
||||
f"{width}x{height}",
|
||||
f"scrolls={len(scrolls)}",
|
||||
f"horizontal_max={dialog.scroll_area.horizontalScrollBar().maximum()}",
|
||||
f"risk_rows={len({label.y() for label in risks})}",
|
||||
f"body_height={dialog.scroll_area.widget().height()}",
|
||||
)
|
||||
print(output)
|
||||
dialog.close()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
font_path = Path(r"C:\Windows\Fonts\msyh.ttc")
|
||||
if font_path.is_file():
|
||||
QFontDatabase.addApplicationFont(str(font_path))
|
||||
|
||||
output_dir = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "artifacts"
|
||||
/ "patient_ai_report_layout"
|
||||
)
|
||||
_render(
|
||||
app,
|
||||
output_dir / "patient_ai_report_920x760.png",
|
||||
width=920,
|
||||
height=760,
|
||||
)
|
||||
_render(
|
||||
app,
|
||||
output_dir / "patient_ai_report_720x560.png",
|
||||
width=720,
|
||||
height=560,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Render patient and appointment density gates in the real desktop shell."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import QThreadPool
|
||||
from PySide6.QtGui import QFont, QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui import ShellWindow, apply_theme
|
||||
|
||||
|
||||
def _drain(application: QApplication) -> None:
|
||||
QThreadPool.globalInstance().waitForDone(5_000)
|
||||
for _index in range(8):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _patient_rows() -> list[dict[str, Any]]:
|
||||
names = ("林晓岚", "赵明远", "吴诗雨", "周安然", "程静", "许清和")
|
||||
rows: list[dict[str, Any]] = []
|
||||
for index in range(15):
|
||||
rows.append(
|
||||
{
|
||||
"id": 101 + index,
|
||||
"diagnosis_id": 501 + index,
|
||||
"patient_id": 301 + index,
|
||||
"patient_name": names[index % len(names)],
|
||||
"gender": 2 if index % 2 == 0 else 1,
|
||||
"age": 34 + index,
|
||||
"phone_masked": f"138****{1200 + index:04d}",
|
||||
"assistant_id": 8,
|
||||
"assistant_name": "周医助",
|
||||
"appointment_id": 701 + index,
|
||||
"appointment_status": 1,
|
||||
"appointment_doctor_name": "陈医生",
|
||||
"appointment_time_text": f"2026-08-{17 + index % 3:02d} {9 + index % 7:02d}:00",
|
||||
"revisit_count": index % 4,
|
||||
"confirmation_text": "已确认" if index % 2 == 0 else "待确认",
|
||||
"diagnosis_date_text": "第 2 次复诊" if index % 3 else "初诊",
|
||||
"has_id_card": index % 4 != 0,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _appointment_rows() -> list[dict[str, Any]]:
|
||||
names = ("林晓岚", "赵明远", "吴诗雨", "周安然", "程静", "许清和")
|
||||
today = date.today().isoformat()
|
||||
rows: list[dict[str, Any]] = []
|
||||
for index in range(8):
|
||||
rows.append(
|
||||
{
|
||||
"id": 801 + index,
|
||||
"diagnosis_id": 901 + index,
|
||||
"patient_id": 401 + index,
|
||||
"patient_name": names[index % len(names)],
|
||||
"patient_phone": f"1380013{8000 + index}",
|
||||
"gender": 2 if index % 2 == 0 else 1,
|
||||
"age": 38 + index,
|
||||
"doctor_name": "陈医生",
|
||||
"assistant_id": 8,
|
||||
"assistant_name": "周医助",
|
||||
"appointment_date": today,
|
||||
"appointment_time": f"{9 + index:02d}:00",
|
||||
"channel_name": "线上复诊",
|
||||
"diagnosis_confirmed": index % 2 == 0,
|
||||
"has_prescription": index % 3 == 0,
|
||||
"status": 1,
|
||||
"status_desc": "已挂号",
|
||||
"revisit_time": "复诊" if index % 2 else "初诊",
|
||||
"unserved_days": index,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def render() -> list[Path]:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
font_path = Path("C:/Windows/Fonts/msyh.ttc")
|
||||
if font_path.is_file():
|
||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
if families:
|
||||
application.setFont(QFont(families[0], 9))
|
||||
|
||||
output = Path(__file__).resolve().parents[1] / "artifacts" / "patient_appointment_density"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
paths: list[Path] = []
|
||||
|
||||
for width, height in ((1366, 768), (1024, 640)):
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
shell = ShellWindow(
|
||||
repository,
|
||||
{"session": session, "demo_mode": True},
|
||||
permissions=session.permissions,
|
||||
)
|
||||
shell.resize(width, height)
|
||||
shell.show()
|
||||
_drain(application)
|
||||
|
||||
if not shell.navigate("patients"):
|
||||
raise RuntimeError("patients navigation is unavailable")
|
||||
_drain(application)
|
||||
patients = shell.pages["patients"]
|
||||
patient_rows = _patient_rows()
|
||||
patients.patient_workspace._apply_result(
|
||||
{
|
||||
"lists": patient_rows,
|
||||
"count": len(patient_rows),
|
||||
"extend": {
|
||||
"scope": {"label": "当前医生与部门"},
|
||||
"summary": {"today": 6, "tomorrow": 5, "day_after": 4},
|
||||
},
|
||||
},
|
||||
patients.patient_workspace._generation,
|
||||
)
|
||||
application.processEvents()
|
||||
patient_slots = patients.patient_workspace.table.viewport().height() // 40
|
||||
if width == 1366 and patient_slots < 6:
|
||||
raise RuntimeError(f"patient table only exposes {patient_slots} ordinary rows")
|
||||
patient_path = output / f"patients_{width}x{height}.png"
|
||||
if not shell.grab().save(str(patient_path), "PNG"):
|
||||
raise RuntimeError(f"failed to save {patient_path}")
|
||||
paths.append(patient_path)
|
||||
|
||||
if not shell.navigate("appointments"):
|
||||
raise RuntimeError("appointments navigation is unavailable")
|
||||
_drain(application)
|
||||
appointments = shell.pages["appointments"]
|
||||
appointments.poll_timer.stop()
|
||||
appointment_rows = _appointment_rows()
|
||||
appointments._loaded(
|
||||
{
|
||||
"lists": appointment_rows,
|
||||
"count": len(appointment_rows),
|
||||
"extend": {
|
||||
"status_count": {"1": len(appointment_rows), "3": 0},
|
||||
"unassigned_count": 0,
|
||||
},
|
||||
},
|
||||
appointments._generation,
|
||||
False,
|
||||
)
|
||||
application.processEvents()
|
||||
row_heights = [
|
||||
appointments.table.rowHeight(index)
|
||||
for index in range(appointments.table.rowCount())
|
||||
]
|
||||
appointment_slots = appointments.table.viewport().height() // max(row_heights)
|
||||
if width == 1366 and appointment_slots < 4:
|
||||
raise RuntimeError(
|
||||
f"appointment table only exposes {appointment_slots} ordinary rows"
|
||||
)
|
||||
if width == 1024 and not appointments.video_panel.isHidden():
|
||||
raise RuntimeError("narrow appointment viewport did not collapse the video panel")
|
||||
appointment_path = output / f"appointments_{width}x{height}.png"
|
||||
if not shell.grab().save(str(appointment_path), "PNG"):
|
||||
raise RuntimeError(f"failed to save {appointment_path}")
|
||||
paths.append(appointment_path)
|
||||
print(
|
||||
f"{width}x{height}: patient_slots={patient_slots}, "
|
||||
f"appointment_slots={appointment_slots}, video_hidden={appointments.video_panel.isHidden()}"
|
||||
)
|
||||
shell.close()
|
||||
application.processEvents()
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for rendered in render():
|
||||
print(rendered)
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Render deterministic prescription-list density acceptance screenshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtGui import QFont, QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.ui.pages import prescription_library as library_module
|
||||
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
|
||||
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
|
||||
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
DENSITY_SIZES = ((1366, 768), (1710, 920))
|
||||
|
||||
|
||||
def _run_immediately(
|
||||
function: Any,
|
||||
*args: Any,
|
||||
on_success: Any = None,
|
||||
on_error: Any = None,
|
||||
on_finished: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> object:
|
||||
try:
|
||||
result = function(*args, **kwargs)
|
||||
except Exception as error:
|
||||
if on_error is not None:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
return object()
|
||||
|
||||
|
||||
prescriptions_module.run_async = _run_immediately
|
||||
library_module.run_async = _run_immediately
|
||||
|
||||
|
||||
def _issued_row(index: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": 1000 + index,
|
||||
"sn": f"CF-202608-{1000 + index}",
|
||||
"prescription_type": "汤剂",
|
||||
"is_system_auto": index % 2,
|
||||
"patient_name": ("林晓岚", "周明远", "许安然")[index % 3],
|
||||
"gender": 2 if index % 2 else 1,
|
||||
"age": 29 + index,
|
||||
"audit_status": index % 3,
|
||||
"void_status": 0,
|
||||
"has_prescription_order": index % 2,
|
||||
"creator_id": 7,
|
||||
"doctor_name": "陈医生",
|
||||
"assistant_name": "赵医助",
|
||||
"create_time": f"2026-08-{(index % 9) + 10:02d} 09:30:00",
|
||||
"herbs": [{"name": "黄芪", "dosage": 15}],
|
||||
}
|
||||
|
||||
|
||||
def _library_row(index: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": 2000 + index,
|
||||
"prescription_name": ("益气养阴方", "清热祛湿方", "滋阴调和方")[index % 3],
|
||||
"formula_type": "主方" if index % 3 else "辅方",
|
||||
"herbs": [
|
||||
{"name": "黄芪", "dosage": 15},
|
||||
{"name": "党参", "dosage": 12},
|
||||
],
|
||||
"efficacy": ("益气养阴", "清热祛湿", "滋阴补肾")[index % 3],
|
||||
"is_public": index % 2,
|
||||
"disable_edit": 0,
|
||||
"creator_id": 7,
|
||||
"creator_name": "陈医生",
|
||||
"create_time": f"2026-08-{(index % 9) + 10:02d} 08:20:00",
|
||||
}
|
||||
|
||||
|
||||
class ScreenshotRepository:
|
||||
def __init__(self) -> None:
|
||||
self.issued_rows = [_issued_row(index) for index in range(15)]
|
||||
self.library_rows = [_library_row(index) for index in range(15)]
|
||||
|
||||
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
|
||||
return [{"id": 7, "name": "陈医生"}, {"id": 8, "name": "孙医生"}]
|
||||
|
||||
def list_prescriptions(self, **_filters: Any) -> dict[str, Any]:
|
||||
return {"lists": self.issued_rows, "count": 44}
|
||||
|
||||
def list_prescription_templates(self, **_filters: Any) -> dict[str, Any]:
|
||||
return {"lists": self.library_rows, "count": 41}
|
||||
|
||||
|
||||
def _application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
font_path = Path("C:/Windows/Fonts/msyh.ttc")
|
||||
if font_path.is_file():
|
||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
if families:
|
||||
app.setFont(QFont(families[0], 9))
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
def _settle(app: QApplication) -> None:
|
||||
for _ in range(6):
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def _visible_rows(page: PrescriptionsPage | PrescriptionLibraryPage) -> int:
|
||||
viewport = page.table.viewport()
|
||||
return sum(
|
||||
1
|
||||
for row in range(page.table.rowCount())
|
||||
if (
|
||||
(item := page.table.item(row, 0)) is not None
|
||||
and (rect := page.table.visualItemRect(item)).isValid()
|
||||
and rect.top() >= 0
|
||||
and rect.bottom() < viewport.height()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _new_page(
|
||||
kind: str,
|
||||
repository: ScreenshotRepository,
|
||||
) -> PrescriptionsPage | PrescriptionLibraryPage:
|
||||
current_user = SimpleNamespace(id=7, name="陈医生", root=1, role_ids=[0])
|
||||
if kind == "prescriptions":
|
||||
page: PrescriptionsPage | PrescriptionLibraryPage = PrescriptionsPage(
|
||||
repository, {"*"}, current_user
|
||||
)
|
||||
else:
|
||||
page = PrescriptionLibraryPage(repository, {"*"}, current_user)
|
||||
page.refresh()
|
||||
return page
|
||||
|
||||
|
||||
def render() -> list[Path]:
|
||||
app = _application()
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
output = root / "artifacts" / "prescription_list_density"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
repository = ScreenshotRepository()
|
||||
paths: list[Path] = []
|
||||
|
||||
for kind in ("prescriptions", "prescription_library"):
|
||||
for width, height in DENSITY_SIZES:
|
||||
page = _new_page(kind, repository)
|
||||
page.resize(width, height)
|
||||
page.show()
|
||||
_settle(app)
|
||||
minimum_rows = 6 if height == 768 else 9
|
||||
visible_rows = _visible_rows(page)
|
||||
if visible_rows < minimum_rows:
|
||||
raise RuntimeError(
|
||||
f"{kind} at {width}x{height} exposes only {visible_rows} full rows"
|
||||
)
|
||||
path = output / f"{kind}_{width}x{height}.png"
|
||||
if not page.grab().save(str(path), "PNG"):
|
||||
raise RuntimeError(f"failed to save {path}")
|
||||
paths.append(path)
|
||||
page.close()
|
||||
_settle(app)
|
||||
return paths
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for rendered in render():
|
||||
print(rendered)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Render the reception daily-record matrix for visual acceptance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import QThreadPool
|
||||
from PySide6.QtGui import QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
from doctor_workstation.ui.shell import ShellWindow
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
def main() -> int:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
font_path = Path(r"C:\Windows\Fonts\msyh.ttc")
|
||||
if font_path.is_file():
|
||||
QFontDatabase.addApplicationFont(str(font_path))
|
||||
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login("doctor", "doctor123")
|
||||
window = ShellWindow(repository, session)
|
||||
window.resize(1710, 920)
|
||||
window.show()
|
||||
if not window.navigate("reception"):
|
||||
raise RuntimeError("reception navigation is unavailable")
|
||||
|
||||
for _index in range(5):
|
||||
application.processEvents()
|
||||
QThreadPool.globalInstance().waitForDone(10_000)
|
||||
page = window.pages["reception"]
|
||||
daily_index = next(
|
||||
index
|
||||
for index in range(page.detail_tabs.count())
|
||||
if page.detail_tabs.tabText(index) == "日常记录"
|
||||
)
|
||||
page.detail_tabs.setCurrentIndex(daily_index)
|
||||
for _index in range(3):
|
||||
application.processEvents()
|
||||
QThreadPool.globalInstance().waitForDone(10_000)
|
||||
|
||||
output = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "artifacts"
|
||||
/ "reception_daily_records"
|
||||
/ "reception_daily_records_1710x920.png"
|
||||
)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not window.grab().save(str(output), "PNG"):
|
||||
raise RuntimeError(f"failed to save {output}")
|
||||
print(output)
|
||||
print(
|
||||
"DAILY_MATRIX",
|
||||
page.daily_panel.matrix.rowCount(),
|
||||
page.daily_panel.matrix.columnCount(),
|
||||
page.daily_panel.current_range(),
|
||||
)
|
||||
window.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -138,20 +138,20 @@ class DemoVideoDialog(QDialog):
|
||||
self.setWindowTitle("视频面诊 · 演示模式")
|
||||
self.setMinimumSize(760, 520)
|
||||
self.resize(980, 660)
|
||||
self.setModal(False)
|
||||
self.setStyleSheet(
|
||||
"QDialog{background:#F7F9FE;color:#111F46;}"
|
||||
"QLabel{color:#111F46;}"
|
||||
"QFrame#RemoteStage{background:#0E1421;border:1px solid #29334F;border-radius:16px;}"
|
||||
"QFrame#RemoteStage QLabel{color:#F7F9FE;}"
|
||||
"QFrame#LocalStage{background:#151D31;border:1px solid #3F4E75;border-radius:12px;}"
|
||||
"QPushButton{min-width:96px;min-height:40px;padding:0 16px;border-radius:9px;"
|
||||
"background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}"
|
||||
"QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}"
|
||||
"QPushButton:checked{color:#FFFFFF;background:#5761F4;border-color:#5761F4;}"
|
||||
"QPushButton#Hangup{color:#FFFFFF;background:#F15B67;border-color:#F15B67;}"
|
||||
"QPushButton#Hangup:hover{background:#D94857;border-color:#D94857;}"
|
||||
)
|
||||
self.setModal(False)
|
||||
self.setStyleSheet(
|
||||
"QDialog{background:#F7F9FE;color:#111F46;}"
|
||||
"QLabel{color:#111F46;}"
|
||||
"QFrame#RemoteStage{background:#0E1421;border:1px solid #29334F;border-radius:16px;}"
|
||||
"QFrame#RemoteStage QLabel{color:#F7F9FE;}"
|
||||
"QFrame#LocalStage{background:#151D31;border:1px solid #3F4E75;border-radius:12px;}"
|
||||
"QPushButton{min-width:96px;min-height:40px;padding:0 16px;border-radius:9px;"
|
||||
"background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}"
|
||||
"QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}"
|
||||
"QPushButton:checked{color:#FFFFFF;background:#5761F4;border-color:#5761F4;}"
|
||||
"QPushButton#Hangup{color:#FFFFFF;background:#F15B67;border-color:#F15B67;}"
|
||||
"QPushButton#Hangup:hover{background:#D94857;border-color:#D94857;}"
|
||||
)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(22, 18, 22, 22)
|
||||
@@ -162,7 +162,7 @@ class DemoVideoDialog(QDialog):
|
||||
header.addWidget(title)
|
||||
header.addStretch(1)
|
||||
demo = QLabel("● 演示模式 · 未连接腾讯云")
|
||||
demo.setStyleSheet("color:#7886AA;font-size:12px;")
|
||||
demo.setStyleSheet("color:#7886AA;font-size:12px;")
|
||||
header.addWidget(demo)
|
||||
self.duration_label = QLabel("00:00")
|
||||
self.duration_label.setStyleSheet("font-weight:700;")
|
||||
@@ -177,8 +177,8 @@ class DemoVideoDialog(QDialog):
|
||||
avatar = QLabel((patient_name or "患")[:1])
|
||||
avatar.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
avatar.setFixedSize(104, 104)
|
||||
avatar.setStyleSheet(
|
||||
"background:#F0F2FF;color:#5761F4;border-radius:52px;font-size:42px;font-weight:700;"
|
||||
avatar.setStyleSheet(
|
||||
"background:#F0F2FF;color:#5761F4;border-radius:52px;font-size:42px;font-weight:700;"
|
||||
)
|
||||
stage_layout.addWidget(avatar, 0, Qt.AlignmentFlag.AlignHCenter)
|
||||
waiting = QLabel("等待患者接听…")
|
||||
@@ -187,7 +187,7 @@ class DemoVideoDialog(QDialog):
|
||||
stage_layout.addWidget(waiting)
|
||||
hint = QLabel("生产模式将通过后端短时 UserSig 初始化腾讯 TUICallKit")
|
||||
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
hint.setStyleSheet("color:#A4ADC3;font-size:12px;")
|
||||
hint.setStyleSheet("color:#A4ADC3;font-size:12px;")
|
||||
stage_layout.addWidget(hint)
|
||||
stage_layout.addStretch(1)
|
||||
|
||||
@@ -197,7 +197,7 @@ class DemoVideoDialog(QDialog):
|
||||
local_layout = QVBoxLayout(local)
|
||||
local_label = QLabel("医生画面")
|
||||
local_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
local_label.setStyleSheet("color:#D9E0F2;font-weight:600;")
|
||||
local_label.setStyleSheet("color:#D9E0F2;font-weight:600;")
|
||||
local_layout.addWidget(local_label)
|
||||
root.addWidget(stage, 1)
|
||||
|
||||
@@ -788,6 +788,53 @@ class ApplicationController(QObject):
|
||||
self.remote_repository.client.close()
|
||||
|
||||
|
||||
def _prefer_native_tls_backend() -> None:
|
||||
"""Keep Qt's HTTPS stack off OpenSSL.
|
||||
|
||||
PySide6 on Windows ships no OpenSSL DLLs of its own, so Qt's OpenSSL
|
||||
TLS backend resolves to CPython's ``libcrypto-3-x64.dll`` and ends up
|
||||
sharing one OpenSSL instance with the httpx stack. Concurrent use from
|
||||
both stacks has crashed the process inside libcrypto (access violation
|
||||
at a stable offset) while pages downloaded images through
|
||||
``QNetworkAccessManager``. Route Qt network requests through the
|
||||
native Schannel backend instead, which uses the Windows certificate
|
||||
store and never touches OpenSSL.
|
||||
"""
|
||||
|
||||
with suppress(Exception):
|
||||
from PySide6.QtNetwork import QSslSocket
|
||||
|
||||
backends = QSslSocket.availableBackends()
|
||||
if "schannel" in backends and "openssl" in backends:
|
||||
QSslSocket.setActiveBackend("schannel")
|
||||
|
||||
|
||||
_CRASH_LOG_HANDLE: Any = None
|
||||
|
||||
|
||||
def _install_crash_handler(config: AppConfig) -> None:
|
||||
"""Write per-thread Python tracebacks of native crashes into the log dir.
|
||||
|
||||
Debug launchers already pass ``-X faulthandler`` and dump to stderr;
|
||||
keep that behavior and only redirect into ``crash.log`` for packaged
|
||||
or plain runs where stderr is lost.
|
||||
"""
|
||||
|
||||
global _CRASH_LOG_HANDLE
|
||||
try:
|
||||
import faulthandler
|
||||
|
||||
if faulthandler.is_enabled():
|
||||
return
|
||||
path = config.log_dir / "crash.log"
|
||||
handle = path.open("a", encoding="utf-8", buffering=1)
|
||||
handle.write(f"\n=== process started {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n")
|
||||
faulthandler.enable(file=handle, all_threads=True)
|
||||
_CRASH_LOG_HANDLE = handle
|
||||
except Exception:
|
||||
LOGGER.exception("crash handler could not be installed")
|
||||
|
||||
|
||||
def _create_application(argv: list[str]) -> QApplication:
|
||||
with suppress(AttributeError):
|
||||
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
|
||||
@@ -796,6 +843,7 @@ def _create_application(argv: list[str]) -> QApplication:
|
||||
with suppress(AttributeError):
|
||||
QApplication.setAttribute(Qt.ApplicationAttribute.AA_DontCreateNativeWidgetSiblings, True)
|
||||
application = QApplication(argv)
|
||||
_prefer_native_tls_backend()
|
||||
_install_chinese_translations(application)
|
||||
application.setApplicationName("甄养堂医生工作站")
|
||||
application.setApplicationDisplayName("甄养堂医生工作站")
|
||||
@@ -814,6 +862,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
config = AppConfig.load()
|
||||
configure_logging(config.log_dir, config.log_level)
|
||||
_install_crash_handler(config)
|
||||
LOGGER.info("doctor workstation starting", extra={"demo_mode": config.demo_mode})
|
||||
raw_argv = list(sys.argv if argv is None else argv)
|
||||
smoke_test = "--smoke-test" in raw_argv
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from threading import RLock
|
||||
from collections.abc import Callable, Iterator, Mapping
|
||||
from threading import Lock, RLock
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlsplit, urlunsplit
|
||||
|
||||
@@ -22,6 +23,15 @@ from doctor_workstation.core.errors import (
|
||||
WorkWechatBindingRequiredError,
|
||||
)
|
||||
|
||||
# One shared transport lock serializes every HTTP exchange in the process.
|
||||
# The GUI fires page loads, dialog bundles and polling timers from many
|
||||
# QThreadPool workers that all share a single ``httpx.Client``. Concurrent
|
||||
# TLS handshakes on one shared SSLContext have repeatedly crashed the
|
||||
# process inside libcrypto (access violation at a stable offset), so all
|
||||
# network traffic now goes through this single lock. Requests stay
|
||||
# off-thread, so the GUI never blocks; only the network itself is serialized.
|
||||
_HTTP_TRANSPORT_LOCK = Lock()
|
||||
|
||||
|
||||
class ApiClient:
|
||||
"""A small, testable client implementing the admin API contract.
|
||||
@@ -62,6 +72,8 @@ class ApiClient:
|
||||
self._token = token.strip()
|
||||
self._lock = RLock()
|
||||
self._owns_client = client is None
|
||||
self._stream_transport = transport
|
||||
self._verify = verify
|
||||
self._client = client or httpx.Client(transport=transport, verify=verify)
|
||||
|
||||
@staticmethod
|
||||
@@ -162,6 +174,64 @@ class ApiClient:
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
def post_event_stream(
|
||||
self,
|
||||
endpoint: str,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
cancelled: Callable[[], bool] | None = None,
|
||||
) -> Iterator[dict[str, Any]]:
|
||||
"""POST JSON and yield parsed server-sent events on an isolated client.
|
||||
|
||||
Streaming deliberately does not use ``_HTTP_TRANSPORT_LOCK`` or the
|
||||
process-wide JSON client. A diagnosis response can remain open for
|
||||
more than a minute and must not block unrelated page requests.
|
||||
"""
|
||||
|
||||
url = self._endpoint_url(endpoint)
|
||||
headers = self._headers({"Accept": "text/event-stream"})
|
||||
request_timeout = self.timeout if timeout is None else timeout
|
||||
try:
|
||||
with httpx.Client(
|
||||
transport=self._stream_transport,
|
||||
verify=self._verify,
|
||||
) as stream_client, stream_client.stream(
|
||||
"POST",
|
||||
url,
|
||||
json=dict(payload),
|
||||
headers=headers,
|
||||
timeout=request_timeout,
|
||||
) as response:
|
||||
request_id = self._request_id(response)
|
||||
if not 200 <= response.status_code < 300:
|
||||
raise ApiHttpError(
|
||||
f"API returned HTTP {response.status_code}",
|
||||
status_code=response.status_code,
|
||||
request_id=request_id,
|
||||
)
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
if "text/event-stream" not in content_type:
|
||||
response.read()
|
||||
if "json" in content_type:
|
||||
self._unwrap(response)
|
||||
raise ApiProtocolError(
|
||||
"API response is not an event stream",
|
||||
status_code=response.status_code,
|
||||
request_id=request_id,
|
||||
)
|
||||
yield from _iter_event_stream(response.iter_lines(), cancelled=cancelled)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise ApiTimeoutError(
|
||||
f"POST {endpoint} stream timed out",
|
||||
data={"method": "POST", "endpoint": endpoint},
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise ApiTransportError(
|
||||
f"POST {endpoint} stream failed: {exc}",
|
||||
data={"method": "POST", "endpoint": endpoint},
|
||||
) from exc
|
||||
|
||||
def get_bytes(self, url: str, *, max_bytes: int = 5 * 1024 * 1024) -> bytes:
|
||||
"""Download a public binary asset without applying the JSON envelope contract."""
|
||||
|
||||
@@ -175,12 +245,13 @@ class ApiClient:
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise ValueError("url must resolve to an absolute http(s) URL")
|
||||
try:
|
||||
response = self._client.get(
|
||||
target,
|
||||
headers={"Accept": "image/*,application/octet-stream;q=0.8"},
|
||||
timeout=self.timeout,
|
||||
follow_redirects=True,
|
||||
)
|
||||
with _HTTP_TRANSPORT_LOCK:
|
||||
response = self._client.get(
|
||||
target,
|
||||
headers={"Accept": "image/*,application/octet-stream;q=0.8"},
|
||||
timeout=self.timeout,
|
||||
follow_redirects=True,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise ApiTimeoutError("Image download timed out", data={"url": target}) from exc
|
||||
except httpx.RequestError as exc:
|
||||
@@ -253,34 +324,35 @@ class ApiClient:
|
||||
attempts = self.max_retries + 1 if verb == "GET" else 1
|
||||
response: httpx.Response | None = None
|
||||
request_timeout = self.timeout if timeout is None else timeout
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
response = self._client.request(
|
||||
verb,
|
||||
url,
|
||||
params=dict(params) if params is not None else None,
|
||||
json=dict(json) if verb == "POST" and json is not None else None,
|
||||
data=dict(data) if verb == "POST" and data is not None else None,
|
||||
files=dict(files) if files is not None else None,
|
||||
headers=request_headers,
|
||||
timeout=request_timeout,
|
||||
)
|
||||
break
|
||||
except httpx.TimeoutException as exc:
|
||||
if attempt + 1 < attempts:
|
||||
delay = self.retry_backoff * (2**attempt)
|
||||
if delay:
|
||||
self._sleep(delay)
|
||||
continue
|
||||
raise ApiTimeoutError(
|
||||
f"{verb} {endpoint} timed out after {attempt + 1} attempt(s)",
|
||||
data={"method": verb, "endpoint": endpoint, "attempts": attempt + 1},
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise ApiTransportError(
|
||||
f"{verb} {endpoint} failed: {exc}",
|
||||
data={"method": verb, "endpoint": endpoint},
|
||||
) from exc
|
||||
with _HTTP_TRANSPORT_LOCK:
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
response = self._client.request(
|
||||
verb,
|
||||
url,
|
||||
params=dict(params) if params is not None else None,
|
||||
json=dict(json) if verb == "POST" and json is not None else None,
|
||||
data=dict(data) if verb == "POST" and data is not None else None,
|
||||
files=dict(files) if files is not None else None,
|
||||
headers=request_headers,
|
||||
timeout=request_timeout,
|
||||
)
|
||||
break
|
||||
except httpx.TimeoutException as exc:
|
||||
if attempt + 1 < attempts:
|
||||
delay = self.retry_backoff * (2**attempt)
|
||||
if delay:
|
||||
self._sleep(delay)
|
||||
continue
|
||||
raise ApiTimeoutError(
|
||||
f"{verb} {endpoint} timed out after {attempt + 1} attempt(s)",
|
||||
data={"method": verb, "endpoint": endpoint, "attempts": attempt + 1},
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise ApiTransportError(
|
||||
f"{verb} {endpoint} failed: {exc}",
|
||||
data={"method": verb, "endpoint": endpoint},
|
||||
) from exc
|
||||
if response is None: # Defensive; the loop always returns or raises.
|
||||
raise ApiTransportError(f"{verb} {endpoint} produced no response")
|
||||
return self._unwrap(response)
|
||||
@@ -289,7 +361,8 @@ class ApiClient:
|
||||
"""Close the internally-created HTTP transport."""
|
||||
|
||||
if self._owns_client:
|
||||
self._client.close()
|
||||
with _HTTP_TRANSPORT_LOCK:
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self) -> ApiClient:
|
||||
"""Return this client for use as a context manager."""
|
||||
@@ -414,3 +487,62 @@ class ApiClient:
|
||||
|
||||
def _mapping(value: object) -> Mapping[str, Any]:
|
||||
return value if isinstance(value, Mapping) else {}
|
||||
|
||||
|
||||
def _iter_event_stream(
|
||||
lines: Iterator[str],
|
||||
*,
|
||||
cancelled: Callable[[], bool] | None = None,
|
||||
) -> Iterator[dict[str, Any]]:
|
||||
"""Parse SSE fields, including multi-line data and a final unterminated event."""
|
||||
|
||||
event_name = ""
|
||||
event_id = ""
|
||||
data_lines: list[str] = []
|
||||
|
||||
def build_event() -> dict[str, Any] | None:
|
||||
nonlocal event_name, event_id, data_lines
|
||||
if not event_name and not data_lines:
|
||||
event_id = ""
|
||||
return None
|
||||
raw_data = "\n".join(data_lines)
|
||||
try:
|
||||
data: Any = json.loads(raw_data) if raw_data else {}
|
||||
except (TypeError, ValueError):
|
||||
data = raw_data
|
||||
inferred = data.get("event") or data.get("type") if isinstance(data, Mapping) else ""
|
||||
kind = event_name or str(inferred or "message")
|
||||
if raw_data.strip() == "[DONE]":
|
||||
kind, data = "done", {}
|
||||
event = {"event": kind, "data": data}
|
||||
if event_id:
|
||||
event["id"] = event_id
|
||||
event_name = ""
|
||||
event_id = ""
|
||||
data_lines = []
|
||||
return event
|
||||
|
||||
for raw_line in lines:
|
||||
if cancelled is not None and cancelled():
|
||||
return
|
||||
line = raw_line.lstrip("\ufeff")
|
||||
if not line:
|
||||
event = build_event()
|
||||
if event is not None:
|
||||
yield event
|
||||
continue
|
||||
if line.startswith(":"):
|
||||
continue
|
||||
field, separator, value = line.partition(":")
|
||||
if separator and value.startswith(" "):
|
||||
value = value[1:]
|
||||
if field == "event":
|
||||
event_name = value
|
||||
elif field == "data":
|
||||
data_lines.append(value)
|
||||
elif field == "id":
|
||||
event_id = value
|
||||
if cancelled is None or not cancelled():
|
||||
event = build_event()
|
||||
if event is not None:
|
||||
yield event
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import mimetypes
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Callable, Iterator, Mapping, Sequence
|
||||
from contextlib import suppress
|
||||
from datetime import date
|
||||
from io import BytesIO
|
||||
@@ -13,11 +13,14 @@ from os import PathLike
|
||||
from pathlib import Path
|
||||
from typing import Any, Final, Literal, Protocol
|
||||
|
||||
from doctor_workstation.core.errors import (
|
||||
ApiProtocolError,
|
||||
ApiTransportError,
|
||||
AuthenticationExpiredError,
|
||||
)
|
||||
from doctor_workstation.core.errors import (
|
||||
ApiBusinessError,
|
||||
ApiHttpError,
|
||||
ApiProtocolError,
|
||||
ApiTimeoutError,
|
||||
ApiTransportError,
|
||||
AuthenticationExpiredError,
|
||||
)
|
||||
from doctor_workstation.core.models import (
|
||||
Appointment,
|
||||
CallTicket,
|
||||
@@ -191,6 +194,16 @@ class DoctorRepository(Protocol):
|
||||
) -> dict[str, Any]:
|
||||
"""Ask the first-party diagnosis assistant; the server selects the model."""
|
||||
|
||||
def stream_diagnosis_ai(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
prompt: str,
|
||||
*,
|
||||
task: str = "custom",
|
||||
cancelled: Callable[[], bool] | None = None,
|
||||
) -> Iterator[dict[str, Any]]:
|
||||
"""Yield normalized ``start``/``delta``/``done`` assistant events."""
|
||||
|
||||
def get_diagnosis_ai_analysis(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
@@ -1328,25 +1341,7 @@ class RemoteDoctorRepository:
|
||||
) -> dict[str, Any]:
|
||||
"""Submit a diagnosis question to the first-party server assistant."""
|
||||
|
||||
if diagnosis_id <= 0:
|
||||
raise ValueError("diagnosis_id must be positive")
|
||||
clean_prompt = prompt.strip()
|
||||
if not clean_prompt:
|
||||
raise ValueError("prompt is required")
|
||||
if len(clean_prompt) > 500:
|
||||
raise ValueError("prompt must not exceed 500 characters")
|
||||
clean_task = task.strip().lower() or "custom"
|
||||
if clean_task not in {
|
||||
"summary",
|
||||
"tcm_pattern",
|
||||
"prescription_review",
|
||||
"medication_review",
|
||||
"exam_review",
|
||||
"complication_risk",
|
||||
"guideline_review",
|
||||
"custom",
|
||||
}:
|
||||
raise ValueError("task is not supported")
|
||||
clean_prompt, clean_task = _diagnosis_ai_request(diagnosis_id, prompt, task)
|
||||
payload = _client_request(
|
||||
self.client,
|
||||
"post",
|
||||
@@ -1359,6 +1354,63 @@ class RemoteDoctorRepository:
|
||||
)
|
||||
return dict(_require_mapping(payload, "tcm.diagnosis/aiAssistant"))
|
||||
|
||||
def stream_diagnosis_ai(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
prompt: str,
|
||||
*,
|
||||
task: str = "custom",
|
||||
cancelled: Callable[[], bool] | None = None,
|
||||
) -> Iterator[dict[str, Any]]:
|
||||
"""Stream a diagnosis answer, with one legacy fallback before first content."""
|
||||
|
||||
clean_prompt, clean_task = _diagnosis_ai_request(diagnosis_id, prompt, task)
|
||||
body = {"id": diagnosis_id, "prompt": clean_prompt, "task": clean_task}
|
||||
received_delta = False
|
||||
received_done = False
|
||||
try:
|
||||
for raw_event in self.client.post_event_stream(
|
||||
"tcm.diagnosis/aiAssistantStream",
|
||||
body,
|
||||
timeout=105.0,
|
||||
cancelled=cancelled,
|
||||
):
|
||||
if cancelled is not None and cancelled():
|
||||
return
|
||||
event = _normalise_diagnosis_ai_event(raw_event)
|
||||
if event is None:
|
||||
continue
|
||||
kind = event["event"]
|
||||
if kind == "delta":
|
||||
received_delta = True
|
||||
elif kind == "done":
|
||||
received_done = True
|
||||
yield event
|
||||
if kind == "done":
|
||||
return
|
||||
if cancelled is not None and cancelled():
|
||||
return
|
||||
if not received_done:
|
||||
raise ApiProtocolError("AI assistant stream ended before done")
|
||||
except (ApiHttpError, ApiProtocolError, ApiTimeoutError, ApiTransportError):
|
||||
if received_delta or (cancelled is not None and cancelled()):
|
||||
raise
|
||||
|
||||
# Older deployments do not expose the stream route. Submit exactly one
|
||||
# request through the confirmed non-streaming endpoint in that case.
|
||||
result = self.analyze_diagnosis_ai(
|
||||
diagnosis_id,
|
||||
clean_prompt,
|
||||
task=clean_task,
|
||||
)
|
||||
if cancelled is not None and cancelled():
|
||||
return
|
||||
yield {"event": "start", "fallback": True}
|
||||
answer = str(result.get("answer") or result.get("content") or "")
|
||||
if answer:
|
||||
yield {"event": "delta", "text": answer, "fallback": True}
|
||||
yield {**result, "event": "done", "fallback": True}
|
||||
|
||||
def get_diagnosis_ai_analysis(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
@@ -2638,7 +2690,66 @@ class RemoteDoctorRepository:
|
||||
return self.update_prescription_template(template, changes, **fields)
|
||||
|
||||
|
||||
def _page_params(page_no: int, page_size: int, filters: Mapping[str, Any]) -> dict[str, Any]:
|
||||
def _diagnosis_ai_request(diagnosis_id: int, prompt: str, task: str) -> tuple[str, str]:
|
||||
if diagnosis_id <= 0:
|
||||
raise ValueError("diagnosis_id must be positive")
|
||||
clean_prompt = prompt.strip()
|
||||
if not clean_prompt:
|
||||
raise ValueError("prompt is required")
|
||||
if len(clean_prompt) > 500:
|
||||
raise ValueError("prompt must not exceed 500 characters")
|
||||
clean_task = task.strip().lower() or "custom"
|
||||
if clean_task not in {
|
||||
"summary",
|
||||
"tcm_pattern",
|
||||
"prescription_review",
|
||||
"medication_review",
|
||||
"exam_review",
|
||||
"complication_risk",
|
||||
"guideline_review",
|
||||
"custom",
|
||||
}:
|
||||
raise ValueError("task is not supported")
|
||||
return clean_prompt, clean_task
|
||||
|
||||
|
||||
def _normalise_diagnosis_ai_event(raw: Mapping[str, Any]) -> dict[str, Any] | None:
|
||||
kind = str(raw.get("event") or "message").strip().lower()
|
||||
data = raw.get("data")
|
||||
payload = dict(data) if isinstance(data, Mapping) else {}
|
||||
if kind == "message":
|
||||
kind = str(payload.get("event") or payload.get("type") or "message").lower()
|
||||
if kind == "start":
|
||||
payload.pop("event", None)
|
||||
payload.pop("type", None)
|
||||
return {**payload, "event": "start"}
|
||||
if kind == "delta":
|
||||
text = (
|
||||
data
|
||||
if isinstance(data, str)
|
||||
else payload.get("delta")
|
||||
or payload.get("content")
|
||||
or payload.get("text")
|
||||
or ""
|
||||
)
|
||||
if not isinstance(text, str):
|
||||
raise ApiProtocolError("AI assistant delta content must be text", data=data)
|
||||
return {"event": "delta", "text": text}
|
||||
if kind == "done":
|
||||
payload.pop("event", None)
|
||||
payload.pop("type", None)
|
||||
return {**payload, "event": "done"}
|
||||
if kind == "error":
|
||||
message = (
|
||||
data
|
||||
if isinstance(data, str)
|
||||
else payload.get("message") or payload.get("msg") or payload.get("error")
|
||||
)
|
||||
raise ApiBusinessError(str(message or "AI assistant stream failed"), data=data)
|
||||
return None
|
||||
|
||||
|
||||
def _page_params(page_no: int, page_size: int, filters: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if page_no < 1 or page_size < 1:
|
||||
raise ValueError("page_no and page_size must be positive")
|
||||
result = {
|
||||
|
||||
@@ -2526,14 +2526,29 @@ class _RemoteImageButton(QPushButton):
|
||||
request = QNetworkRequest(url)
|
||||
request.setTransferTimeout(10_000)
|
||||
request.setMaximumRedirectsAllowed(4)
|
||||
request.setAttribute(
|
||||
QNetworkRequest.Attribute.RedirectPolicyAttribute,
|
||||
QNetworkRequest.RedirectPolicy.NoLessSafeRedirectPolicy,
|
||||
)
|
||||
reply = self._manager.get(request)
|
||||
self._reply = reply
|
||||
reply.setProperty("diagnosisImageGeneration", generation)
|
||||
reply.finished.connect(self._reply_finished)
|
||||
request.setAttribute(
|
||||
QNetworkRequest.Attribute.RedirectPolicyAttribute,
|
||||
QNetworkRequest.RedirectPolicy.SameOriginRedirectPolicy,
|
||||
)
|
||||
reply = self._manager.get(request)
|
||||
self._reply = reply
|
||||
reply.setProperty("diagnosisImageGeneration", generation)
|
||||
reply.setProperty("diagnosisImageOversize", False)
|
||||
reply.downloadProgress.connect(self._download_progress)
|
||||
reply.finished.connect(self._reply_finished)
|
||||
|
||||
def _download_progress(self, bytes_received: int, bytes_total: int) -> None:
|
||||
"""Abort a current reply as soon as its received or declared size is unsafe."""
|
||||
|
||||
reply = self.sender()
|
||||
if reply is not self._reply:
|
||||
return
|
||||
if bytes_received <= self._MAX_IMAGE_BYTES and (
|
||||
bytes_total < 0 or bytes_total <= self._MAX_IMAGE_BYTES
|
||||
):
|
||||
return
|
||||
reply.setProperty("diagnosisImageOversize", True)
|
||||
reply.abort()
|
||||
|
||||
def _reply_finished(self) -> None:
|
||||
"""Use a QObject receiver connection so destruction auto-disconnects the callback."""
|
||||
@@ -2553,16 +2568,19 @@ class _RemoteImageButton(QPushButton):
|
||||
reply.deleteLater()
|
||||
return
|
||||
self._reply = None
|
||||
if not self._owner_is_current():
|
||||
reply.deleteLater()
|
||||
return
|
||||
error = reply.error()
|
||||
payload = bytes(reply.readAll())
|
||||
reply.deleteLater()
|
||||
if error != QNetworkReply.NetworkError.NoError:
|
||||
self._show_fallback()
|
||||
return
|
||||
self._apply_payload(payload, generation)
|
||||
if not self._owner_is_current():
|
||||
reply.deleteLater()
|
||||
return
|
||||
error = reply.error()
|
||||
if bool(reply.property("diagnosisImageOversize")) or (
|
||||
error != QNetworkReply.NetworkError.NoError
|
||||
):
|
||||
reply.deleteLater()
|
||||
self._show_fallback()
|
||||
return
|
||||
payload = bytes(reply.readAll())
|
||||
reply.deleteLater()
|
||||
self._apply_payload(payload, generation)
|
||||
|
||||
def _apply_payload(self, payload: bytes, generation: int) -> bool:
|
||||
"""Decode a current reply; kept separate so offline tests can exercise rendering."""
|
||||
|
||||
@@ -67,11 +67,33 @@ PRIMARY = QColor("#5265F6")
|
||||
TEXT = QColor("#15224A")
|
||||
SECONDARY = QColor("#7481A3")
|
||||
PLACEHOLDER = QColor("#A4ADC3")
|
||||
_INVALID_INDEX = QModelIndex()
|
||||
_TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 120, 340)
|
||||
|
||||
|
||||
def _menu_action_icon(kind: str, *, danger: bool = False) -> QIcon:
|
||||
_INVALID_INDEX = QModelIndex()
|
||||
_TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 120, 410)
|
||||
|
||||
|
||||
def _render_signature(value: Any) -> Any:
|
||||
"""Freeze repository DTOs into a stable, order-independent render key."""
|
||||
|
||||
if isinstance(value, Mapping):
|
||||
items = ((str(key), _render_signature(item)) for key, item in value.items())
|
||||
return ("mapping", tuple(sorted(items, key=lambda item: item[0])))
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return ("sequence", tuple(_render_signature(item) for item in value))
|
||||
if isinstance(value, set | frozenset):
|
||||
items = (_render_signature(item) for item in value)
|
||||
return ("set", tuple(sorted(items, key=repr)))
|
||||
try:
|
||||
hash(value)
|
||||
except TypeError:
|
||||
return ("repr", repr(value))
|
||||
return ("value", value)
|
||||
|
||||
|
||||
def _rows_render_signature(rows: Sequence[Any]) -> tuple[Any, ...]:
|
||||
return tuple(_render_signature(row) for row in rows)
|
||||
|
||||
|
||||
def _menu_action_icon(kind: str, *, danger: bool = False) -> QIcon:
|
||||
"""Draw the admin menu glyphs without relying on a symbol font."""
|
||||
|
||||
size = 18
|
||||
@@ -450,12 +472,13 @@ class DiagnosisTableModel(QAbstractTableModel):
|
||||
"操作",
|
||||
)
|
||||
|
||||
def __init__(self, rows: Iterable[Any] = (), parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.rows: list[Any] = list(rows)
|
||||
self._checked_ids: set[int] = set()
|
||||
self._hover_row = -1
|
||||
self._sort_direction = ""
|
||||
def __init__(self, rows: Iterable[Any] = (), parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.rows: list[Any] = list(rows)
|
||||
self._render_signature = _rows_render_signature(self.rows)
|
||||
self._checked_ids: set[int] = set()
|
||||
self._hover_row = -1
|
||||
self._sort_direction = ""
|
||||
|
||||
def rowCount(self, parent: QModelIndex = _INVALID_INDEX) -> int: # noqa: N802
|
||||
return 0 if parent.isValid() else len(self.rows)
|
||||
@@ -526,15 +549,24 @@ class DiagnosisTableModel(QAbstractTableModel):
|
||||
def record(self, row: int) -> Any:
|
||||
return self.rows[row] if 0 <= row < len(self.rows) else None
|
||||
|
||||
def set_rows(self, rows: Iterable[Any]) -> None:
|
||||
materialized = list(rows)
|
||||
valid_ids = {self.record_id(row) for row in materialized}
|
||||
self.beginResetModel()
|
||||
self.rows = materialized
|
||||
self._checked_ids.intersection_update(valid_ids)
|
||||
self._hover_row = -1
|
||||
self.endResetModel()
|
||||
self.selection_changed.emit(len(self._checked_ids))
|
||||
@property
|
||||
def render_signature(self) -> tuple[Any, ...]:
|
||||
return self._render_signature
|
||||
|
||||
def set_rows(self, rows: Iterable[Any]) -> bool:
|
||||
materialized = list(rows)
|
||||
render_signature = _rows_render_signature(materialized)
|
||||
if render_signature == self._render_signature:
|
||||
return False
|
||||
valid_ids = {self.record_id(row) for row in materialized}
|
||||
self.beginResetModel()
|
||||
self.rows = materialized
|
||||
self._render_signature = render_signature
|
||||
self._checked_ids.intersection_update(valid_ids)
|
||||
self._hover_row = -1
|
||||
self.endResetModel()
|
||||
self.selection_changed.emit(len(self._checked_ids))
|
||||
return True
|
||||
|
||||
def checked_records(self) -> list[Any]:
|
||||
return [row for row in self.rows if self.record_id(row) in self._checked_ids]
|
||||
@@ -1157,11 +1189,12 @@ class DiagnosisTableView(QTableView):
|
||||
def _current_row_changed(self, _current: QModelIndex, _previous: QModelIndex) -> None:
|
||||
self.itemSelectionChanged.emit()
|
||||
|
||||
def set_rows(self, rows: Iterable[Any]) -> None:
|
||||
self._diagnosis_model().set_rows(rows)
|
||||
self.clearSelection()
|
||||
self.setCurrentIndex(QModelIndex())
|
||||
self.rows_replaced.emit()
|
||||
def set_rows(self, rows: Iterable[Any]) -> None:
|
||||
if not self._diagnosis_model().set_rows(rows):
|
||||
return
|
||||
self.clearSelection()
|
||||
self.setCurrentIndex(QModelIndex())
|
||||
self.rows_replaced.emit()
|
||||
|
||||
def rowCount(self) -> int: # noqa: N802 - compatibility
|
||||
return self._diagnosis_model().rowCount()
|
||||
@@ -1303,6 +1336,8 @@ class DiagnosisTableHost(QFrame):
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("DiagnosisTableHost")
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.setMinimumHeight(0)
|
||||
self.action_policy = dict(action_policy or {})
|
||||
self.force_open_prescription = False
|
||||
self.model = DiagnosisTableModel(parent=self)
|
||||
@@ -1316,10 +1351,13 @@ class DiagnosisTableHost(QFrame):
|
||||
view.setModel(self.model)
|
||||
view.setSelectionModel(self.selection)
|
||||
view.setItemDelegate(self.delegate)
|
||||
view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
view.setMinimumHeight(0)
|
||||
view.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
view.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
|
||||
view.horizontalHeader().setStretchLastSection(False)
|
||||
self.main.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.fixed.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.main.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.fixed.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.main.setMinimumWidth(0)
|
||||
@@ -1335,6 +1373,7 @@ class DiagnosisTableHost(QFrame):
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self.main, 1)
|
||||
layout.addWidget(self.fixed)
|
||||
layout.setAlignment(self.fixed, Qt.AlignmentFlag.AlignTop)
|
||||
self.fixed_shadow = _FixedColumnShadow(self)
|
||||
self.fixed_shadow.show()
|
||||
self.empty_label = QLabel("暂无数据", self)
|
||||
@@ -1348,22 +1387,27 @@ class DiagnosisTableHost(QFrame):
|
||||
self.main.hovered_row.connect(self._set_hover_row)
|
||||
self.fixed.hovered_row.connect(self._set_hover_row)
|
||||
self.model.selection_changed.connect(self._checked_changed)
|
||||
self.main.horizontalHeader().sectionClicked.connect(self._header_clicked)
|
||||
self._update_height()
|
||||
|
||||
def set_rows(self, rows: Iterable[Any]) -> None:
|
||||
previous_id = DiagnosisTableModel.record_id(self.main.current_data())
|
||||
self.model.set_rows(rows)
|
||||
self.empty_label.setText("暂无数据")
|
||||
self.empty_label.setProperty("stateKind", "empty")
|
||||
self.empty_label.style().unpolish(self.empty_label)
|
||||
self.empty_label.style().polish(self.empty_label)
|
||||
self._rows_changed()
|
||||
if previous_id > 0:
|
||||
for index, record in enumerate(self.model.rows):
|
||||
if DiagnosisTableModel.record_id(record) == previous_id:
|
||||
self.main.selectRow(index)
|
||||
break
|
||||
self.main.horizontalHeader().sectionClicked.connect(self._header_clicked)
|
||||
self.main.verticalScrollBar().valueChanged.connect(self.fixed.verticalScrollBar().setValue)
|
||||
self.fixed.verticalScrollBar().valueChanged.connect(self.main.verticalScrollBar().setValue)
|
||||
self.main.horizontalScrollBar().rangeChanged.connect(self._schedule_fixed_height_sync)
|
||||
self._fixed_render_signature: tuple[Any, ...] | None = None
|
||||
|
||||
def set_rows(self, rows: Iterable[Any]) -> None:
|
||||
previous_id = DiagnosisTableModel.record_id(self.main.current_data())
|
||||
rows_changed = self.model.set_rows(rows)
|
||||
self.empty_label.setText("暂无数据")
|
||||
self.empty_label.setProperty("stateKind", "empty")
|
||||
self.empty_label.style().unpolish(self.empty_label)
|
||||
self.empty_label.style().polish(self.empty_label)
|
||||
fixed_widgets_changed = self._fixed_widgets_signature() != self._fixed_render_signature
|
||||
if rows_changed or fixed_widgets_changed:
|
||||
self._rows_changed()
|
||||
if rows_changed and previous_id > 0:
|
||||
for index, record in enumerate(self.model.rows):
|
||||
if DiagnosisTableModel.record_id(record) == previous_id:
|
||||
self.main.selectRow(index)
|
||||
break
|
||||
|
||||
def selected_records(self) -> list[Any]:
|
||||
return self.model.checked_records()
|
||||
@@ -1389,13 +1433,21 @@ class DiagnosisTableHost(QFrame):
|
||||
def set_sort_direction(self, direction: str) -> None:
|
||||
self.model.set_sort_direction(direction)
|
||||
|
||||
def _rows_changed(self) -> None:
|
||||
self._sync_row_heights()
|
||||
self._install_fixed_widgets()
|
||||
self.empty_label.setVisible(self.model.rowCount() == 0)
|
||||
self._update_height()
|
||||
self._position_empty()
|
||||
self._position_fixed_shadow()
|
||||
def _rows_changed(self) -> None:
|
||||
self._sync_row_heights()
|
||||
self._install_fixed_widgets()
|
||||
self._fixed_render_signature = self._fixed_widgets_signature()
|
||||
self.empty_label.setVisible(self.model.rowCount() == 0)
|
||||
self._position_empty()
|
||||
self._position_fixed_shadow()
|
||||
self._schedule_fixed_height_sync()
|
||||
|
||||
def _fixed_widgets_signature(self) -> tuple[Any, ...]:
|
||||
return (
|
||||
self.model.render_signature,
|
||||
self.force_open_prescription,
|
||||
tuple(sorted(self.action_policy.items())),
|
||||
)
|
||||
|
||||
def _sync_row_heights(self) -> None:
|
||||
for row, record in enumerate(self.model.rows):
|
||||
@@ -1479,6 +1531,8 @@ class DiagnosisTableHost(QFrame):
|
||||
"primary",
|
||||
)
|
||||
)
|
||||
if self.action_policy.get("ai_consult", False):
|
||||
layout.addWidget(self._action_button("AI 分析", "ai_consult", record, "primary"))
|
||||
if self.action_policy.get("appointment", False):
|
||||
layout.addWidget(self._action_button("预约", "appointment", record, "success"))
|
||||
if self.action_policy.get("edit", False):
|
||||
@@ -1604,14 +1658,23 @@ class DiagnosisTableHost(QFrame):
|
||||
self.set_sort_direction(direction)
|
||||
self.sort_unserved_requested.emit(direction)
|
||||
|
||||
def _update_height(self) -> None:
|
||||
rows_height = sum(self.main.rowHeight(row) for row in range(self.model.rowCount()))
|
||||
body_height = rows_height if rows_height else 60
|
||||
horizontal = self.main.horizontalScrollBar().sizeHint().height()
|
||||
self.setFixedHeight(39 + body_height + horizontal + 2)
|
||||
def _schedule_fixed_height_sync(self, *_range: int) -> None:
|
||||
"""Keep both table viewports equally tall when the main x-scrollbar appears."""
|
||||
|
||||
QTimer.singleShot(0, self._sync_fixed_height)
|
||||
|
||||
def _sync_fixed_height(self) -> None:
|
||||
horizontal = self.main.horizontalScrollBar()
|
||||
reserved = horizontal.sizeHint().height() if horizontal.maximum() > 0 else 0
|
||||
target = max(0, self.height() - reserved)
|
||||
if self.fixed.height() != target:
|
||||
self.fixed.setFixedHeight(target)
|
||||
|
||||
def _position_empty(self) -> None:
|
||||
self.empty_label.setGeometry(0, 39, self.width(), 60)
|
||||
horizontal = self.main.horizontalScrollBar()
|
||||
reserved = horizontal.sizeHint().height() if horizontal.maximum() > 0 else 0
|
||||
body_height = max(0, self.height() - 39 - reserved)
|
||||
self.empty_label.setGeometry(0, 39, self.width(), body_height)
|
||||
self.empty_label.raise_()
|
||||
|
||||
def _position_fixed_shadow(self) -> None:
|
||||
@@ -1626,6 +1689,7 @@ class DiagnosisTableHost(QFrame):
|
||||
|
||||
def resizeEvent(self, event: QResizeEvent) -> None:
|
||||
super().resizeEvent(event)
|
||||
self._sync_fixed_height()
|
||||
self._position_empty()
|
||||
self._position_fixed_shadow()
|
||||
|
||||
@@ -1639,14 +1703,16 @@ class DiagnosisPager(QWidget):
|
||||
def __init__(self, page_size: int = 15, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("DiagnosisPager")
|
||||
self.setFixedHeight(42)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.page = 1
|
||||
self.page_size = page_size
|
||||
self.total = 0
|
||||
self._setting = False
|
||||
self._page_buttons: list[QToolButton] = []
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(16, 10, 16, 16)
|
||||
layout.setSpacing(8)
|
||||
layout.setContentsMargins(12, 4, 12, 4)
|
||||
layout.setSpacing(6)
|
||||
layout.addStretch(1)
|
||||
self.summary = QLabel("共 0 条")
|
||||
self.summary.setProperty("pagerMuted", True)
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
"""Reusable doctor-workstation dialogs."""
|
||||
|
||||
from .ai_consult import AiConsultDialog, can_open_ai_consult, present_ai_consult
|
||||
from .diagnosis import DiagnosisDialog, OrderDetailDrawer, present_order_detail
|
||||
|
||||
__all__ = ["DiagnosisDialog", "OrderDetailDrawer", "present_order_detail"]
|
||||
__all__ = [
|
||||
"AiConsultDialog",
|
||||
"DiagnosisDialog",
|
||||
"OrderDetailDrawer",
|
||||
"can_open_ai_consult",
|
||||
"present_ai_consult",
|
||||
"present_order_detail",
|
||||
]
|
||||
|
||||
@@ -5117,6 +5117,10 @@ class DiagnosisDetailDialog(QDialog):
|
||||
source = _mapping(diagnosis)
|
||||
self.repository = repository
|
||||
self.permissions = permissions
|
||||
self._order_detail_generation = 0
|
||||
self._order_detail_order_id = 0
|
||||
self._order_detail_table: QTableWidget | None = None
|
||||
self._order_detail_button: QPushButton | None = None
|
||||
self.setWindowTitle("诊单详情(只读)")
|
||||
self.resize(880, 700)
|
||||
root = QVBoxLayout(self)
|
||||
@@ -5195,6 +5199,7 @@ class DiagnosisDetailDialog(QDialog):
|
||||
table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
table.verticalHeader().hide()
|
||||
table.horizontalHeader().setStretchLastSection(True)
|
||||
self._order_detail_table = table
|
||||
for row_index, row in enumerate(rows):
|
||||
values = (
|
||||
first_value(row, "order_no", "sn", "id"),
|
||||
@@ -5212,36 +5217,78 @@ class DiagnosisDetailDialog(QDialog):
|
||||
actions = QHBoxLayout()
|
||||
view = QPushButton("查看订单详情")
|
||||
view.setProperty("variant", "primary")
|
||||
|
||||
def open_selected() -> None:
|
||||
row = table.currentRow()
|
||||
item = table.item(row, 0) if row >= 0 else None
|
||||
order = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
|
||||
if order is None:
|
||||
return
|
||||
order_id = _int(first_value(order, "id", "order_id"), 0)
|
||||
if order_id > 0 and callable(getattr(self.repository, "get_prescription_order", None)):
|
||||
try: # noqa: SIM105 - retain the embedded row if detail lookup fails
|
||||
order = self.repository.get_prescription_order(order_id)
|
||||
except Exception: # noqa: BLE001 - fall back to embedded row
|
||||
pass
|
||||
from .diagnosis import present_order_detail
|
||||
|
||||
present_order_detail(
|
||||
self.window() if self.window() is not None else self,
|
||||
order,
|
||||
order_id=order_id,
|
||||
permissions=self.permissions,
|
||||
exec_=True,
|
||||
)
|
||||
|
||||
view.clicked.connect(open_selected)
|
||||
table.itemDoubleClicked.connect(lambda _item: open_selected())
|
||||
self._order_detail_button = view
|
||||
view.clicked.connect(self._open_selected_order)
|
||||
table.itemDoubleClicked.connect(lambda _item: self._open_selected_order())
|
||||
actions.addWidget(view)
|
||||
actions.addStretch(1)
|
||||
layout.addLayout(actions)
|
||||
return host
|
||||
|
||||
def _set_order_detail_loading(self, loading: bool) -> None:
|
||||
if self._order_detail_table is not None:
|
||||
self._order_detail_table.setEnabled(not loading)
|
||||
if self._order_detail_button is not None:
|
||||
self._order_detail_button.setEnabled(not loading)
|
||||
|
||||
def _open_selected_order(self) -> None:
|
||||
table = self._order_detail_table
|
||||
if table is None:
|
||||
return
|
||||
row = table.currentRow()
|
||||
item = table.item(row, 0) if row >= 0 else None
|
||||
order = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
|
||||
if order is None:
|
||||
return
|
||||
order_id = _int(first_value(order, "id", "order_id"), 0)
|
||||
self._order_detail_generation += 1
|
||||
generation = self._order_detail_generation
|
||||
self._order_detail_order_id = order_id
|
||||
getter = getattr(self.repository, "get_prescription_order", None)
|
||||
if order_id <= 0 or not callable(getter):
|
||||
self._set_order_detail_loading(False)
|
||||
self._present_order_detail(order, order_id)
|
||||
return
|
||||
|
||||
self._set_order_detail_loading(True)
|
||||
run_async(
|
||||
lambda: getter(order_id),
|
||||
on_success=lambda result: self._order_detail_success(result, order_id, generation),
|
||||
on_error=lambda error: self._order_detail_error(error, order, order_id, generation),
|
||||
on_finished=lambda: self._order_detail_finished(order_id, generation),
|
||||
)
|
||||
|
||||
def _order_detail_success(self, order: Any, order_id: int, generation: int) -> None:
|
||||
if generation != self._order_detail_generation or order_id != self._order_detail_order_id:
|
||||
return
|
||||
self._present_order_detail(order, order_id)
|
||||
|
||||
def _order_detail_error(
|
||||
self,
|
||||
_error: Exception,
|
||||
fallback_order: Any,
|
||||
order_id: int,
|
||||
generation: int,
|
||||
) -> None:
|
||||
if generation != self._order_detail_generation or order_id != self._order_detail_order_id:
|
||||
return
|
||||
self._present_order_detail(fallback_order, order_id)
|
||||
|
||||
def _order_detail_finished(self, order_id: int, generation: int) -> None:
|
||||
if generation == self._order_detail_generation and order_id == self._order_detail_order_id:
|
||||
self._set_order_detail_loading(False)
|
||||
|
||||
def _present_order_detail(self, order: Any, order_id: int) -> None:
|
||||
from .diagnosis import present_order_detail
|
||||
|
||||
present_order_detail(
|
||||
self.window() if self.window() is not None else self,
|
||||
order,
|
||||
order_id=order_id,
|
||||
permissions=self.permissions,
|
||||
exec_=True,
|
||||
)
|
||||
|
||||
|
||||
class PrescriptionOrderDialog(QDialog):
|
||||
"""Create a fulfilment order from one issued prescription."""
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
from PySide6.QtCore import QDate, QSize, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QAction, QBrush, QColor, QDesktopServices, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
@@ -34,6 +35,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from ..dialogs import DiagnosisDialog
|
||||
from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult
|
||||
from ..dialogs.prescription import (
|
||||
PrescriptionDetailDialog,
|
||||
PrescriptionEditorDialog,
|
||||
@@ -92,12 +94,12 @@ _SEMANTIC_COLORS = {
|
||||
|
||||
APPOINTMENTS_LIGHT_QSS = """
|
||||
#AppointmentsPage QWidget#PageHeader {
|
||||
min-height: 42px;
|
||||
max-height: 42px;
|
||||
min-height: 26px;
|
||||
max-height: 26px;
|
||||
}
|
||||
#AppointmentsPage QFrame#AppointmentFilterPanel {
|
||||
min-height: 92px;
|
||||
max-height: 92px;
|
||||
min-height: 80px;
|
||||
max-height: 80px;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E2E7F4;
|
||||
border-radius: 10px;
|
||||
@@ -109,9 +111,9 @@ APPOINTMENTS_LIGHT_QSS = """
|
||||
border-radius: 10px;
|
||||
}
|
||||
#AppointmentsPage QPushButton[appointmentStat="true"] {
|
||||
min-height: 34px;
|
||||
max-height: 34px;
|
||||
padding: 0 10px;
|
||||
min-height: 30px;
|
||||
max-height: 30px;
|
||||
padding: 0 7px;
|
||||
color: #59698E;
|
||||
background-color: #F8F9FD;
|
||||
border: 0;
|
||||
@@ -141,8 +143,8 @@ APPOINTMENTS_LIGHT_QSS = """
|
||||
background-color: #FFF8ED;
|
||||
}
|
||||
#AppointmentsPage QLineEdit#AppointmentPatientSearch {
|
||||
min-height: 34px;
|
||||
max-height: 34px;
|
||||
min-height: 30px;
|
||||
max-height: 30px;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #DDE3F0;
|
||||
border-radius: 7px;
|
||||
@@ -157,10 +159,10 @@ APPOINTMENTS_LIGHT_QSS = """
|
||||
padding: 0 2px;
|
||||
}
|
||||
#AppointmentsPage QTableWidget#AppointmentTable::item {
|
||||
padding: 6px 8px;
|
||||
padding: 5px 7px;
|
||||
}
|
||||
#AppointmentsPage QTableWidget#AppointmentTable QHeaderView::section {
|
||||
min-height: 35px;
|
||||
min-height: 34px;
|
||||
background-color: #F8FAFF;
|
||||
}
|
||||
#AppointmentsPage QTableWidget#AppointmentTable {
|
||||
@@ -211,8 +213,9 @@ APPOINTMENTS_LIGHT_QSS = """
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
#AppointmentsPage QPushButton[compactAction="true"] {
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
min-height: 28px;
|
||||
max-height: 28px;
|
||||
padding: 0 8px;
|
||||
border-radius: 7px;
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -238,7 +241,7 @@ APPOINTMENTS_LIGHT_QSS = """
|
||||
#AppointmentsPage QPushButton[variant="chip"]:focus { border-color: #8D9BFF; }
|
||||
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab {
|
||||
min-width: 62px;
|
||||
min-height: 32px;
|
||||
min-height: 28px;
|
||||
padding: 0 7px;
|
||||
color: #7481A3;
|
||||
background-color: transparent;
|
||||
@@ -326,9 +329,9 @@ APPOINTMENTS_LIGHT_QSS = """
|
||||
#AppointmentsPage QPushButton[videoActionKind="success"] { color: #159C79; }
|
||||
#AppointmentsPage QPushButton[videoActionKind="warning"] { color: #C17A16; }
|
||||
#AppointmentsPage QPushButton[filterChoice="true"] {
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
padding: 0 13px;
|
||||
min-height: 28px;
|
||||
max-height: 28px;
|
||||
padding: 0 10px;
|
||||
color: #405074;
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
@@ -436,14 +439,9 @@ def _diagnosis_id(row: Any) -> int:
|
||||
|
||||
|
||||
def _video_patient_id(row: Any) -> int:
|
||||
source = _as_int(first_value(row, "source_patient_id", default=0))
|
||||
if source > 0:
|
||||
return source
|
||||
diagnosis = _as_int(first_value(row, "diagnosis_id", default=0))
|
||||
patient = _as_int(first_value(row, "patient_id", default=0))
|
||||
if diagnosis > 0 and patient > 0 and diagnosis != patient:
|
||||
return patient
|
||||
return patient
|
||||
"""Return only the appointment row's explicitly separated patient owner."""
|
||||
|
||||
return _as_int(first_value(row, "source_patient_id", default=0))
|
||||
|
||||
|
||||
def _appointment_id(row: Any) -> int:
|
||||
@@ -604,6 +602,56 @@ def _flatten_departments(
|
||||
return result
|
||||
|
||||
|
||||
def _stable_signature_value(value: Any) -> Any:
|
||||
"""Return a deterministic, order-independent representation of API data."""
|
||||
|
||||
if value is None or isinstance(value, (bool, int, str)):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return repr(value)
|
||||
if isinstance(value, Mapping):
|
||||
items = (
|
||||
(str(key), _stable_signature_value(item))
|
||||
for key, item in value.items()
|
||||
)
|
||||
return tuple(sorted(items, key=lambda pair: pair[0]))
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return tuple(_stable_signature_value(item) for item in value)
|
||||
if isinstance(value, (set, frozenset)):
|
||||
frozen = (_stable_signature_value(item) for item in value)
|
||||
return tuple(sorted(frozen, key=repr))
|
||||
dataclass_fields = getattr(type(value), "__dataclass_fields__", None)
|
||||
if isinstance(dataclass_fields, Mapping):
|
||||
return (
|
||||
type(value).__qualname__,
|
||||
tuple(
|
||||
(name, _stable_signature_value(getattr(value, name, None)))
|
||||
for name in dataclass_fields
|
||||
),
|
||||
)
|
||||
state = getattr(value, "__dict__", None)
|
||||
if isinstance(state, Mapping):
|
||||
return (type(value).__qualname__, _stable_signature_value(state))
|
||||
isoformat = getattr(value, "isoformat", None)
|
||||
if callable(isoformat):
|
||||
try:
|
||||
return (type(value).__qualname__, isoformat())
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return (type(value).__qualname__, str(value))
|
||||
|
||||
|
||||
def _appointment_result_signature(rows: Sequence[Any], total: int, extend: Any) -> tuple[Any, ...]:
|
||||
"""Build the stable render signature used to short-circuit unchanged polling."""
|
||||
|
||||
return (
|
||||
"appointments-render-v1",
|
||||
total,
|
||||
_stable_signature_value(rows),
|
||||
_stable_signature_value(extend),
|
||||
)
|
||||
|
||||
|
||||
class AppointmentsPage(QWidget):
|
||||
"""Desktop appointment list with status tabs, filters, call and prescription."""
|
||||
|
||||
@@ -639,6 +687,9 @@ class AppointmentsPage(QWidget):
|
||||
self._end_date = self._start_date
|
||||
self._status_counts: dict[int, int] = {1: 0, 2: 0, 3: 0, 4: 0}
|
||||
self._dict_labels: dict[str, dict[str, str]] = {}
|
||||
self._last_render_signature: tuple[Any, ...] | None = None
|
||||
self._responsive_narrow: bool | None = None
|
||||
self._video_panel_user_open = False
|
||||
self._can_plain_phone = _canonical_allowed(
|
||||
permissions, "tcm.diagnosis/phonePlain", default=False
|
||||
)
|
||||
@@ -646,20 +697,23 @@ class AppointmentsPage(QWidget):
|
||||
self._is_admin = _is_admin_user(current_user)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(22, 7, 6, 8)
|
||||
root.setContentsMargins(18, 3, 6, 8)
|
||||
root.setSpacing(4)
|
||||
header = PageHeader("问诊列表")
|
||||
header.title_label.hide()
|
||||
header.subtitle_label.hide()
|
||||
root.addWidget(header)
|
||||
root.addWidget(self._build_filter_panel())
|
||||
self.header = PageHeader("问诊列表")
|
||||
self.header.title_label.hide()
|
||||
self.header.subtitle_label.hide()
|
||||
root.addWidget(self.header)
|
||||
self.filter_panel = self._build_filter_panel()
|
||||
root.addWidget(self.filter_panel)
|
||||
self.banner = MessageBanner()
|
||||
root.addWidget(self.banner)
|
||||
root.addWidget(self._build_content(), 1)
|
||||
self.content_host = self._build_content()
|
||||
root.addWidget(self.content_host, 1)
|
||||
|
||||
self.poll_timer = QTimer(self)
|
||||
self.poll_timer.setInterval(LIST_POLL_MS)
|
||||
self.poll_timer.timeout.connect(lambda: self.refresh(silent=True))
|
||||
self._apply_responsive_layout()
|
||||
|
||||
def _diagnosis_dialog(self) -> DiagnosisDialog:
|
||||
dialog = getattr(self, "_diagnosis_dialog_impl", None)
|
||||
@@ -673,76 +727,77 @@ class AppointmentsPage(QWidget):
|
||||
frame = QFrame()
|
||||
frame.setObjectName("AppointmentFilterPanel")
|
||||
layout = QVBoxLayout(frame)
|
||||
layout.setContentsMargins(4, 2, 12, 12)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(6, 4, 8, 6)
|
||||
layout.setSpacing(4)
|
||||
|
||||
date_row = QHBoxLayout()
|
||||
date_row.setSpacing(8)
|
||||
date_row.setSpacing(4)
|
||||
self.date_buttons: dict[str, QPushButton] = {}
|
||||
self._date_stat_labels: dict[str, str] = {}
|
||||
stat_widths = {
|
||||
"yesterday": 106,
|
||||
"day_before": 98,
|
||||
"today": 98,
|
||||
"tomorrow": 94,
|
||||
"day_after": 94,
|
||||
"": 110,
|
||||
}
|
||||
for source_label, preset in DATE_PRESETS:
|
||||
label = "全部" if preset == "" else source_label
|
||||
button = QPushButton(f"{label} 0")
|
||||
button.setCheckable(True)
|
||||
button.setProperty("appointmentStat", True)
|
||||
button.setFixedWidth(stat_widths[preset])
|
||||
button.setMinimumWidth(0)
|
||||
button.clicked.connect(
|
||||
lambda _checked=False, value=preset: self._set_date_preset(value)
|
||||
)
|
||||
self.date_buttons[preset] = button
|
||||
self._date_stat_labels[preset] = label
|
||||
date_row.addWidget(button)
|
||||
date_row.addWidget(button, 1)
|
||||
self.date_buttons["today"].setChecked(True)
|
||||
self.date_overflow_button = QPushButton("日期")
|
||||
self.date_overflow_button.setProperty("appointmentStat", True)
|
||||
date_menu = QMenu(self.date_overflow_button)
|
||||
for source_label, preset in DATE_PRESETS:
|
||||
action = date_menu.addAction(source_label if preset else "不限日期")
|
||||
action.triggered.connect(
|
||||
lambda _checked=False, value=preset: self._set_date_preset(value)
|
||||
)
|
||||
date_menu.addSeparator()
|
||||
date_menu.addAction("自定义日期").triggered.connect(self._open_custom_date)
|
||||
self.date_overflow_button.setMenu(date_menu)
|
||||
date_row.addWidget(self.date_overflow_button)
|
||||
|
||||
self.pending_stat_button = QPushButton("待预约 0")
|
||||
self.pending_stat_button.setProperty("appointmentStat", True)
|
||||
self.pending_stat_button.setProperty("appointmentStatKind", "pending")
|
||||
self.pending_stat_button.setFixedWidth(104)
|
||||
self.pending_stat_button.setMinimumWidth(0)
|
||||
self.pending_stat_button.clicked.connect(lambda: self._set_status_from_stat(1))
|
||||
date_row.addWidget(self.pending_stat_button)
|
||||
date_row.addWidget(self.pending_stat_button, 1)
|
||||
self.completed_stat_button = QPushButton("已完成 0")
|
||||
self.completed_stat_button.setProperty("appointmentStat", True)
|
||||
self.completed_stat_button.setProperty("appointmentStatKind", "success")
|
||||
self.completed_stat_button.setFixedWidth(104)
|
||||
self.completed_stat_button.setMinimumWidth(0)
|
||||
self.completed_stat_button.clicked.connect(lambda: self._set_status_from_stat(3))
|
||||
date_row.addWidget(self.completed_stat_button)
|
||||
date_row.addWidget(self.completed_stat_button, 1)
|
||||
self.unassigned_stat_button = QPushButton("待分配医助 0")
|
||||
self.unassigned_stat_button.setCheckable(True)
|
||||
self.unassigned_stat_button.setProperty("appointmentStat", True)
|
||||
self.unassigned_stat_button.setProperty("appointmentStatKind", "warning")
|
||||
self.unassigned_stat_button.setFixedWidth(130)
|
||||
self.unassigned_stat_button.setMinimumWidth(0)
|
||||
self.unassigned_stat_button.clicked.connect(self._toggle_unassigned_filter)
|
||||
date_row.addWidget(self.unassigned_stat_button)
|
||||
date_row.addStretch(1)
|
||||
date_row.addWidget(self.unassigned_stat_button, 1)
|
||||
|
||||
self.patient_input = QLineEdit()
|
||||
self.patient_input.setObjectName("AppointmentPatientSearch")
|
||||
self.patient_input.setPlaceholderText("患者姓名 / 手机号")
|
||||
self.patient_input.setClearButtonEnabled(True)
|
||||
self.patient_input.setFixedWidth(204)
|
||||
self.patient_input.returnPressed.connect(self._search)
|
||||
date_row.addWidget(self.patient_input)
|
||||
date_row.addWidget(self.patient_input, 2)
|
||||
|
||||
search = QPushButton("查询")
|
||||
search.setProperty("variant", "primary")
|
||||
search.setFixedWidth(62)
|
||||
search.clicked.connect(self._search)
|
||||
date_row.addWidget(search)
|
||||
layout.addLayout(date_row)
|
||||
|
||||
filter_row = QHBoxLayout()
|
||||
filter_row.setSpacing(7)
|
||||
status_label = QLabel("挂号状态:")
|
||||
status_label.setObjectName("FilterRowLabel")
|
||||
filter_row.addWidget(status_label)
|
||||
filter_row.setSpacing(5)
|
||||
self.status_filter_label = QLabel("挂号状态:")
|
||||
self.status_filter_label.setObjectName("FilterRowLabel")
|
||||
filter_row.addWidget(self.status_filter_label)
|
||||
self.tab_bar = QTabBar()
|
||||
self.tab_bar.setObjectName("AppointmentStatusTabs")
|
||||
self._tab_indexes: dict[str | int, int] = {}
|
||||
@@ -753,13 +808,15 @@ class AppointmentsPage(QWidget):
|
||||
self.tab_bar.currentChanged.connect(self._tab_changed)
|
||||
filter_row.addWidget(self.tab_bar)
|
||||
|
||||
self.filter_dividers: list[QLabel] = []
|
||||
divider = QLabel("│")
|
||||
divider.setObjectName("FilterDivider")
|
||||
self.filter_dividers.append(divider)
|
||||
filter_row.addWidget(divider)
|
||||
|
||||
confirm_label = QLabel("确认状态:")
|
||||
confirm_label.setObjectName("FilterRowLabel")
|
||||
filter_row.addWidget(confirm_label)
|
||||
self.confirm_filter_label = QLabel("确认状态:")
|
||||
self.confirm_filter_label.setObjectName("FilterRowLabel")
|
||||
filter_row.addWidget(self.confirm_filter_label)
|
||||
self.confirmed_filter = QComboBox(frame)
|
||||
self.confirmed_filter.addItem("全部", "")
|
||||
self.confirmed_filter.addItem("已确认", "1")
|
||||
@@ -783,6 +840,7 @@ class AppointmentsPage(QWidget):
|
||||
|
||||
divider = QLabel("│")
|
||||
divider.setObjectName("FilterDivider")
|
||||
self.filter_dividers.append(divider)
|
||||
filter_row.addWidget(divider)
|
||||
|
||||
self.more_filters_button = QPushButton("更多筛选")
|
||||
@@ -825,26 +883,32 @@ class AppointmentsPage(QWidget):
|
||||
|
||||
def _build_content(self) -> QWidget:
|
||||
host = QWidget()
|
||||
host.setMinimumHeight(0)
|
||||
layout = QHBoxLayout(host)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(4)
|
||||
layout.addWidget(self._build_table_card(), 3)
|
||||
layout.addWidget(self._build_video_panel(), 1)
|
||||
self.content_layout = layout
|
||||
self.table_card = self._build_table_card()
|
||||
self.video_panel = self._build_video_panel()
|
||||
layout.addWidget(self.table_card, 3)
|
||||
layout.addWidget(self.video_panel, 1)
|
||||
return host
|
||||
|
||||
def _build_video_panel(self) -> QWidget:
|
||||
panel = QFrame()
|
||||
panel.setObjectName("VideoConsultPanel")
|
||||
panel.setFixedWidth(420)
|
||||
panel.setMinimumWidth(0)
|
||||
panel.setMaximumWidth(420)
|
||||
layout = QVBoxLayout(panel)
|
||||
layout.setContentsMargins(18, 17, 18, 10)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(14, 12, 14, 8)
|
||||
layout.setSpacing(8)
|
||||
title = QLabel("视频问诊")
|
||||
title.setObjectName("VideoConsultTitle")
|
||||
layout.addWidget(title)
|
||||
self.video_list = QListWidget()
|
||||
self.video_list.setObjectName("VideoConsultList")
|
||||
self.video_list.setSpacing(0)
|
||||
self.video_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
self.video_list.currentRowChanged.connect(self._video_row_selected)
|
||||
self.video_list.itemDoubleClicked.connect(lambda _item: self._open_detail())
|
||||
layout.addWidget(self.video_list, 1)
|
||||
@@ -857,9 +921,10 @@ class AppointmentsPage(QWidget):
|
||||
def _build_table_card(self) -> QWidget:
|
||||
card = QFrame()
|
||||
card.setObjectName("AppointmentMainCard")
|
||||
card.setMinimumHeight(0)
|
||||
layout = QVBoxLayout(card)
|
||||
layout.setContentsMargins(4, 18, 10, 10)
|
||||
layout.setSpacing(14)
|
||||
layout.setContentsMargins(4, 8, 8, 8)
|
||||
layout.setSpacing(8)
|
||||
|
||||
compatibility_host = QWidget(card)
|
||||
compatibility_host.hide()
|
||||
@@ -904,7 +969,7 @@ class AppointmentsPage(QWidget):
|
||||
widget.setProperty("compactAction", True)
|
||||
|
||||
actions = QHBoxLayout()
|
||||
actions.setSpacing(9)
|
||||
actions.setSpacing(6)
|
||||
self.toolbar_edit_button = QPushButton("编辑患者", card)
|
||||
self.toolbar_edit_button.setProperty("variant", "primary")
|
||||
self.toolbar_edit_button.setProperty("compactAction", True)
|
||||
@@ -923,6 +988,13 @@ class AppointmentsPage(QWidget):
|
||||
self.toolbar_qr_button.setEnabled(False)
|
||||
self.toolbar_qr_button.clicked.connect(self._request_video_qr)
|
||||
actions.addWidget(self.toolbar_qr_button)
|
||||
self.toolbar_ai_consult_button = QPushButton("AI 分析", card)
|
||||
self.toolbar_ai_consult_button.setProperty("variant", "secondary")
|
||||
self.toolbar_ai_consult_button.setProperty("compactAction", True)
|
||||
self.toolbar_ai_consult_button.setVisible(can_open_ai_consult(self.permissions))
|
||||
self.toolbar_ai_consult_button.setEnabled(False)
|
||||
self.toolbar_ai_consult_button.clicked.connect(self._open_ai_consult)
|
||||
actions.addWidget(self.toolbar_ai_consult_button)
|
||||
self.toolbar_cancel_button = QPushButton("取消挂号", card)
|
||||
self.toolbar_cancel_button.setProperty("variant", "danger")
|
||||
self.toolbar_cancel_button.setProperty("compactAction", True)
|
||||
@@ -932,9 +1004,16 @@ class AppointmentsPage(QWidget):
|
||||
self.toolbar_cancel_button.setEnabled(False)
|
||||
self.toolbar_cancel_button.clicked.connect(self._cancel_selected)
|
||||
actions.addWidget(self.toolbar_cancel_button)
|
||||
self.video_panel_button = QPushButton("视频问诊", card)
|
||||
self.video_panel_button.setCheckable(True)
|
||||
self.video_panel_button.setProperty("variant", "ghost")
|
||||
self.video_panel_button.setProperty("compactAction", True)
|
||||
self.video_panel_button.clicked.connect(self._toggle_video_panel)
|
||||
actions.addWidget(self.video_panel_button)
|
||||
actions.addStretch(1)
|
||||
refresh = QPushButton("刷新", card)
|
||||
refresh.setProperty("variant", "ghost")
|
||||
refresh.setProperty("compactAction", True)
|
||||
refresh.clicked.connect(lambda: self.refresh())
|
||||
actions.addWidget(refresh)
|
||||
layout.addLayout(actions)
|
||||
@@ -958,17 +1037,56 @@ class AppointmentsPage(QWidget):
|
||||
]
|
||||
)
|
||||
self.table.setObjectName("AppointmentTable")
|
||||
self.table.setMinimumHeight(0)
|
||||
self.table.setWordWrap(True)
|
||||
self.table.horizontalHeader().setFixedHeight(38)
|
||||
self.table.verticalHeader().setDefaultSectionSize(66)
|
||||
self.table.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
self.table.horizontalHeader().setFixedHeight(34)
|
||||
self.table.verticalHeader().setDefaultSectionSize(60)
|
||||
self.table.itemSelectionChanged.connect(self._selection_changed)
|
||||
self.table.itemDoubleClicked.connect(lambda _item: self._open_detail())
|
||||
layout.addWidget(self.table, 1)
|
||||
self.pager = Pager(self._page_size)
|
||||
self.pager.setMaximumHeight(38)
|
||||
self.pager.page_changed.connect(self._page_changed)
|
||||
layout.addWidget(self.pager)
|
||||
return card
|
||||
|
||||
def _toggle_video_panel(self, checked: bool) -> None:
|
||||
self._video_panel_user_open = checked
|
||||
self._apply_responsive_layout()
|
||||
|
||||
def _apply_responsive_layout(self) -> None:
|
||||
"""Keep the table usable while retaining a video-list entry at low widths."""
|
||||
|
||||
panel = getattr(self, "video_panel", None)
|
||||
button = getattr(self, "video_panel_button", None)
|
||||
if not isinstance(panel, QWidget) or not isinstance(button, QPushButton):
|
||||
return
|
||||
narrow = self.width() < 1120
|
||||
for preset in ("yesterday", "day_before", "tomorrow", "day_after"):
|
||||
self.date_buttons[preset].setVisible(not narrow)
|
||||
self.date_overflow_button.setVisible(narrow)
|
||||
self.status_filter_label.setVisible(not narrow)
|
||||
self.confirm_filter_label.setVisible(not narrow)
|
||||
if narrow:
|
||||
if self._responsive_narrow is not True:
|
||||
self._video_panel_user_open = False
|
||||
button.show()
|
||||
button.setChecked(self._video_panel_user_open)
|
||||
panel.setVisible(self._video_panel_user_open)
|
||||
if self._video_panel_user_open:
|
||||
panel.setFixedWidth(max(250, min(300, round(self.width() * 0.32))))
|
||||
else:
|
||||
button.hide()
|
||||
button.setChecked(False)
|
||||
panel.show()
|
||||
panel.setFixedWidth(max(300, min(420, round(self.width() * 0.30))))
|
||||
self._responsive_narrow = narrow
|
||||
|
||||
def resizeEvent(self, event: Any) -> None:
|
||||
super().resizeEvent(event)
|
||||
self._apply_responsive_layout()
|
||||
|
||||
def _sync_video_list(self, rows: Sequence[Any]) -> None:
|
||||
selected_id = (
|
||||
_appointment_id(self.video_list.currentItem().data(Qt.ItemDataRole.UserRole))
|
||||
@@ -1002,6 +1120,7 @@ class AppointmentsPage(QWidget):
|
||||
self.video_list.setCurrentRow(row_to_select)
|
||||
else:
|
||||
self.video_list.setCurrentRow(-1)
|
||||
self.video_panel_button.setText(f"视频问诊 {len(rows)}")
|
||||
|
||||
def _build_video_patient_card(self, row: Any) -> QWidget:
|
||||
card = QFrame()
|
||||
@@ -1066,6 +1185,8 @@ class AppointmentsPage(QWidget):
|
||||
)
|
||||
add_action("查看", self._open_detail)
|
||||
add_action("诊单", self._edit_patient, "tcm.diagnosis/edit")
|
||||
if can_open_ai_consult(self.permissions):
|
||||
add_action("AI 分析", self._open_ai_consult)
|
||||
add_action("开方", self._open_prescription, "tcm.diagnosis/kaifang")
|
||||
add_action("二维码", self._request_video_qr, "tcm.diagnosis/videoQr", "success")
|
||||
more = QPushButton("更多", card)
|
||||
@@ -1109,6 +1230,8 @@ class AppointmentsPage(QWidget):
|
||||
|
||||
add_action("完成问诊", self._complete_selected, "doctor.appointment/complete")
|
||||
add_action("查看病历", self._view_case, "tcm.diagnosis/kaifang")
|
||||
if can_open_ai_consult(self.permissions):
|
||||
add_action("AI 分析", self._open_ai_consult)
|
||||
if can_open_diagnosis_ai_report(self.permissions):
|
||||
add_action("AI 报告", self._open_ai_report)
|
||||
add_action("取消挂号", self._cancel_selected, "doctor.appointment/cancel")
|
||||
@@ -1193,6 +1316,13 @@ class AppointmentsPage(QWidget):
|
||||
self._date_preset = preset
|
||||
for key, button in self.date_buttons.items():
|
||||
button.setChecked(key == preset)
|
||||
overflow_labels = {
|
||||
"yesterday": "昨天",
|
||||
"day_before": "前天",
|
||||
"tomorrow": "明天",
|
||||
"day_after": "后天",
|
||||
}
|
||||
self.date_overflow_button.setText(overflow_labels.get(preset, "日期"))
|
||||
target = _offset_date(preset)
|
||||
if target is None:
|
||||
self._start_date = ""
|
||||
@@ -1248,6 +1378,7 @@ class AppointmentsPage(QWidget):
|
||||
for button in self.date_buttons.values():
|
||||
button.setChecked(False)
|
||||
self.date_buttons[""].setChecked(True)
|
||||
self.date_overflow_button.setText("自定义")
|
||||
self._start_date = start_date
|
||||
self._end_date = end_date
|
||||
self._page = 1
|
||||
@@ -1332,6 +1463,16 @@ class AppointmentsPage(QWidget):
|
||||
rows = page_items(result)
|
||||
total = page_total(result)
|
||||
extend = get_value(result, "extend", {}) or {}
|
||||
signature = (
|
||||
self._page,
|
||||
_stable_signature_value(self._query_filters()),
|
||||
*_appointment_result_signature(rows, total, extend),
|
||||
)
|
||||
if signature == self._last_render_signature:
|
||||
if not silent:
|
||||
self.banner.clear()
|
||||
return
|
||||
self._last_render_signature = signature
|
||||
status_count = get_value(extend, "status_count", {}) or {}
|
||||
if isinstance(status_count, Mapping):
|
||||
for key in (1, 2, 3, 4):
|
||||
@@ -1507,7 +1648,7 @@ class AppointmentsPage(QWidget):
|
||||
default=1,
|
||||
)
|
||||
height = line_count * line_height + TABLE_CELL_VERTICAL_PADDING
|
||||
self.table.setRowHeight(row_index, max(66, height))
|
||||
self.table.setRowHeight(row_index, max(60, min(66, height)))
|
||||
|
||||
def _load_error(self, error: Exception, generation: int, silent: bool) -> None:
|
||||
if generation != self._generation:
|
||||
@@ -1551,6 +1692,9 @@ class AppointmentsPage(QWidget):
|
||||
self.prescription_button.setText(prescription_action_label(row) if has_row else "开方")
|
||||
self.case_button.setEnabled(has_row and _appointment_id(row) > 0)
|
||||
self.ai_button.setEnabled(has_row and _diagnosis_id(row) > 0)
|
||||
self.toolbar_ai_consult_button.setEnabled(
|
||||
has_row and can_open_ai_consult(self.permissions) and _diagnosis_id(row) > 0
|
||||
)
|
||||
self.cancel_button.setEnabled(has_row and status == 1)
|
||||
self.toolbar_edit_button.setEnabled(has_row and _diagnosis_id(row) > 0)
|
||||
self.toolbar_qr_button.setEnabled(
|
||||
@@ -1592,6 +1736,19 @@ class AppointmentsPage(QWidget):
|
||||
)
|
||||
present_diagnosis_ai_report(self.repository, self.permissions, self, payload)
|
||||
|
||||
def _open_ai_consult(self) -> None:
|
||||
row = self._current_row()
|
||||
diagnosis_id = _diagnosis_id(row)
|
||||
present_ai_consult(
|
||||
self.repository,
|
||||
self.permissions,
|
||||
self,
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=_video_patient_id(row),
|
||||
seed=row,
|
||||
source_title="问诊列表",
|
||||
)
|
||||
|
||||
def _request_video(self) -> None:
|
||||
if not _canonical_allowed(
|
||||
self.permissions, "doctor.appointment/prescription", default=False
|
||||
@@ -1640,7 +1797,7 @@ class AppointmentsPage(QWidget):
|
||||
if row is None or _status_value(row) == 3:
|
||||
return
|
||||
diagnosis_id = _diagnosis_id(row)
|
||||
patient_id = _video_patient_id(row) or _as_int(first_value(row, "patient_id", default=0))
|
||||
patient_id = _video_patient_id(row)
|
||||
doctor_id = _as_int(first_value(row, "doctor_id", default=0))
|
||||
share_user_id = _as_int(first_value(self.current_user, "id", default=0))
|
||||
if diagnosis_id <= 0 or patient_id <= 0 or doctor_id <= 0 or share_user_id <= 0:
|
||||
|
||||
@@ -29,6 +29,7 @@ from PySide6.QtWidgets import (
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
@@ -46,6 +47,7 @@ from ..diagnosis_index_widgets import (
|
||||
FlowWidget,
|
||||
)
|
||||
from ..dialogs import DiagnosisDialog
|
||||
from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult
|
||||
from ..dialogs.prescription import (
|
||||
PrescriptionDetailDialog,
|
||||
PrescriptionEditorDialog,
|
||||
@@ -68,8 +70,12 @@ from ..widgets import (
|
||||
show_toast,
|
||||
)
|
||||
|
||||
_PAGE_HEADER_HEIGHT = 62
|
||||
_STATUS_CARD_HEIGHT = 50
|
||||
_FILTERS_COLLAPSED_HEIGHT = 90
|
||||
|
||||
CONSULTATIONS_REFERENCE_QSS = """
|
||||
#DiagnosisIndex QWidget#PageHeader { min-height: 72px; }
|
||||
#DiagnosisIndex QWidget#PageHeader { min-height: 62px; max-height: 62px; }
|
||||
#DiagnosisIndex QLabel[role="pageTitle"] {
|
||||
color: #15224A; font-size: 22px; font-weight: 700;
|
||||
}
|
||||
@@ -82,7 +88,7 @@ CONSULTATIONS_REFERENCE_QSS = """
|
||||
background: #FFFFFF; border: 1px solid #E2E7F4; border-radius: 13px;
|
||||
}
|
||||
#DiagnosisIndex QFrame#DiagnosisStatusCard {
|
||||
min-height: 60px; max-height: 60px;
|
||||
min-height: 50px; max-height: 50px;
|
||||
}
|
||||
#DiagnosisIndex QFrame#DiagnosisStatusCard QToolButton[diagnosisChip="true"] {
|
||||
min-height: 34px; max-height: 34px; min-width: 56px;
|
||||
@@ -103,7 +109,7 @@ CONSULTATIONS_REFERENCE_QSS = """
|
||||
min-width: 116px; max-width: 116px;
|
||||
}
|
||||
#DiagnosisIndex QFrame#DiagnosisFilterCard {
|
||||
min-height: 106px;
|
||||
min-height: 88px;
|
||||
}
|
||||
#DiagnosisIndex QWidget#DiagnosisDateFilters,
|
||||
#DiagnosisIndex QWidget#DiagnosisSecondaryFilters {
|
||||
@@ -147,7 +153,7 @@ CONSULTATIONS_REFERENCE_QSS = """
|
||||
border-radius: 8px; font-size: 12px;
|
||||
}
|
||||
#DiagnosisIndex QFrame#DiagnosisListToolbar {
|
||||
min-height: 54px; max-height: 54px; background: #FFFFFF;
|
||||
min-height: 44px; max-height: 44px; background: #FFFFFF;
|
||||
border-bottom: 1px solid #E7EBF5;
|
||||
}
|
||||
#DiagnosisIndex QFrame#DiagnosisListToolbar QPushButton {
|
||||
@@ -167,7 +173,7 @@ CONSULTATIONS_REFERENCE_QSS = """
|
||||
}
|
||||
#DiagnosisIndex QTableView { background: #FFFFFF; alternate-background-color: #FBFCFF; }
|
||||
#DiagnosisIndex QToolButton[rowLink] { font-size: 11px; padding: 2px; }
|
||||
#DiagnosisIndex QWidget#DiagnosisPager { min-height: 48px; max-height: 48px; }
|
||||
#DiagnosisIndex QWidget#DiagnosisPager { min-height: 42px; max-height: 42px; }
|
||||
#DiagnosisIndex QToolButton[pagerButton="true"] {
|
||||
min-width: 32px; min-height: 32px; max-height: 32px;
|
||||
border: 1px solid #E2E7F4; border-radius: 7px; background: #FFFFFF;
|
||||
@@ -1068,30 +1074,31 @@ class ConsultationsPage(QWidget):
|
||||
self.page_scroll.setWidgetResizable(True)
|
||||
self.page_scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.page_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.page_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
root.addWidget(self.page_scroll)
|
||||
content = QWidget()
|
||||
content.setObjectName("DiagnosisIndexContent")
|
||||
content.setAutoFillBackground(False)
|
||||
self.page_scroll.setWidget(content)
|
||||
page_layout = QVBoxLayout(content)
|
||||
page_layout.setContentsMargins(20, 18, 29, 16)
|
||||
page_layout.setSpacing(12)
|
||||
page_layout.setContentsMargins(18, 10, 18, 10)
|
||||
page_layout.setSpacing(8)
|
||||
|
||||
page_layout.addWidget(
|
||||
PageHeader(
|
||||
"问诊列表",
|
||||
"按状态与日期管理患者队列,完成通话、开方与接诊闭环。",
|
||||
content,
|
||||
)
|
||||
self.page_header = PageHeader(
|
||||
"问诊列表",
|
||||
"按状态与日期管理患者队列,完成通话、开方与接诊闭环。",
|
||||
content,
|
||||
)
|
||||
self.page_header.setFixedHeight(_PAGE_HEADER_HEIGHT)
|
||||
page_layout.addWidget(self.page_header)
|
||||
|
||||
status_card = QFrame()
|
||||
status_card.setObjectName("DiagnosisStatusCard")
|
||||
status_card.setFixedHeight(62)
|
||||
status_card.setFixedHeight(_STATUS_CARD_HEIGHT)
|
||||
self.status_card = status_card
|
||||
status_card_layout = QHBoxLayout(status_card)
|
||||
status_card_layout.setContentsMargins(12, 7, 12, 7)
|
||||
status_card_layout.setSpacing(12)
|
||||
status_card_layout.setContentsMargins(12, 5, 12, 5)
|
||||
status_card_layout.setSpacing(10)
|
||||
|
||||
status_tabs = QWidget(status_card)
|
||||
status_tabs.setObjectName("DiagnosisStatusTabs")
|
||||
@@ -1160,8 +1167,8 @@ class ConsultationsPage(QWidget):
|
||||
filters.setObjectName("DiagnosisFilterCard")
|
||||
self.filters_card = filters
|
||||
filter_layout = QVBoxLayout(filters)
|
||||
filter_layout.setContentsMargins(14, 10, 14, 10)
|
||||
filter_layout.setSpacing(7)
|
||||
filter_layout.setContentsMargins(12, 6, 12, 6)
|
||||
filter_layout.setSpacing(4)
|
||||
|
||||
date_filters = QWidget(filters)
|
||||
date_filters.setObjectName("DiagnosisDateFilters")
|
||||
@@ -1365,11 +1372,12 @@ class ConsultationsPage(QWidget):
|
||||
advanced_layout.addWidget(self.advanced_filter_flow)
|
||||
self.advanced_filters.hide()
|
||||
filter_layout.addWidget(self.advanced_filters)
|
||||
filters.setFixedHeight(108)
|
||||
filters.setFixedHeight(_FILTERS_COLLAPSED_HEIGHT)
|
||||
page_layout.addWidget(filters)
|
||||
|
||||
card = QFrame()
|
||||
card.setObjectName("DiagnosisListCard")
|
||||
card.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.list_card = card
|
||||
card_layout = QVBoxLayout(card)
|
||||
card_layout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -1377,8 +1385,8 @@ class ConsultationsPage(QWidget):
|
||||
toolbar = QFrame()
|
||||
toolbar.setObjectName("DiagnosisListToolbar")
|
||||
toolbar_layout = QHBoxLayout(toolbar)
|
||||
toolbar_layout.setContentsMargins(14, 6, 14, 6)
|
||||
toolbar_layout.setSpacing(8)
|
||||
toolbar_layout.setContentsMargins(12, 4, 12, 4)
|
||||
toolbar_layout.setSpacing(6)
|
||||
self.add_button = QPushButton("+ 新增患者", toolbar)
|
||||
self.add_button.setProperty("variant", "primary")
|
||||
self.add_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/add"))
|
||||
@@ -1479,14 +1487,16 @@ class ConsultationsPage(QWidget):
|
||||
toolbar_layout.addWidget(self.refresh_button)
|
||||
|
||||
table_wrap = QWidget()
|
||||
table_wrap.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
table_wrap_layout = QVBoxLayout(table_wrap)
|
||||
table_wrap_layout.setContentsMargins(12, 8, 12, 0)
|
||||
table_wrap_layout.setContentsMargins(12, 4, 12, 0)
|
||||
table_wrap_layout.setSpacing(0)
|
||||
self.table_host = DiagnosisTableHost(
|
||||
action_policy={
|
||||
"view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"),
|
||||
"edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"),
|
||||
"prescription": _canonical_allowed(permissions, "tcm.diagnosis/kaifang"),
|
||||
"ai_consult": can_open_ai_consult(permissions),
|
||||
"appointment": _canonical_allowed(permissions, "tcm.diagnosis/guahao"),
|
||||
"assign": _canonical_allowed(permissions, "tcm.diagnosis/assign"),
|
||||
"delete": _canonical_allowed(permissions, "tcm.diagnosis/delete"),
|
||||
@@ -1526,15 +1536,14 @@ class ConsultationsPage(QWidget):
|
||||
self.table_host.video_requested.connect(self._row_video)
|
||||
self.table_host.checked_changed.connect(self._checked_changed)
|
||||
self.table_host.sort_unserved_requested.connect(self._sort_unserved)
|
||||
table_wrap_layout.addWidget(self.table_host)
|
||||
table_wrap_layout.addWidget(self.table_host, 1)
|
||||
self.loading_overlay = DiagnosisLoadingOverlay(self.table_host)
|
||||
card_layout.addWidget(table_wrap)
|
||||
card_layout.addWidget(table_wrap, 1)
|
||||
self.pager = DiagnosisPager(self._page_size)
|
||||
self.pager.page_changed.connect(self._change_page)
|
||||
self.pager.page_size_changed.connect(self._change_page_size)
|
||||
card_layout.addWidget(self.pager)
|
||||
page_layout.addWidget(card)
|
||||
page_layout.addStretch(1)
|
||||
page_layout.addWidget(card, 1)
|
||||
|
||||
self.poll_timer = QTimer(self)
|
||||
self.poll_timer.setInterval(20_000)
|
||||
@@ -1703,7 +1712,9 @@ class ConsultationsPage(QWidget):
|
||||
def _toggle_advanced_filters(self, checked: bool) -> None:
|
||||
self.advanced_filters.setVisible(checked)
|
||||
self.filters_card.setFixedHeight(
|
||||
108 + self.advanced_filters.sizeHint().height() + 8 if checked else 108
|
||||
_FILTERS_COLLAPSED_HEIGHT + self.advanced_filters.sizeHint().height() + 8
|
||||
if checked
|
||||
else _FILTERS_COLLAPSED_HEIGHT
|
||||
)
|
||||
self.more_filter_button.setText("收起" if checked else "更多筛选")
|
||||
self.more_filter_button.setArrowType(
|
||||
@@ -2244,6 +2255,7 @@ class ConsultationsPage(QWidget):
|
||||
"view": self._open_readonly,
|
||||
"edit": self._open_edit,
|
||||
"prescription": self._open_prescription,
|
||||
"ai_consult": self._open_ai_consult,
|
||||
"appointment": self._book_selected_appointment,
|
||||
"fill_id_card": self._fill_selected_id_card,
|
||||
"assign": self._assign_selected,
|
||||
@@ -2954,6 +2966,21 @@ class ConsultationsPage(QWidget):
|
||||
return
|
||||
self._diagnosis_dialog.open_view_only(diagnosis_id, seed=record)
|
||||
|
||||
def _open_ai_consult(self) -> None:
|
||||
record = self.table.current_data()
|
||||
diagnosis_id = _as_int(first_value(record, "diagnosis_id", "id", default=0))
|
||||
present_ai_consult(
|
||||
self.repository,
|
||||
self.permissions,
|
||||
self,
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=_as_int(
|
||||
first_value(record, "source_patient_id", "patient_id", default=0)
|
||||
),
|
||||
seed=record,
|
||||
source_title="患者列表",
|
||||
)
|
||||
|
||||
def _open_edit(self) -> None:
|
||||
if not _canonical_allowed(self.permissions, "tcm.diagnosis/edit"):
|
||||
return
|
||||
@@ -3324,7 +3351,11 @@ class ConsultationsPage(QWidget):
|
||||
def _poll_refresh(self) -> None:
|
||||
"""Refresh rows and chip counts without showing the table mask."""
|
||||
|
||||
if not self.isVisible() or self._order_flow_generation is not None:
|
||||
if (
|
||||
not self.isVisible()
|
||||
or self._loading
|
||||
or self._order_flow_generation is not None
|
||||
):
|
||||
return
|
||||
self.refresh(silent=True)
|
||||
self._refresh_counts()
|
||||
|
||||
@@ -37,7 +37,8 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from ..appointment_drawer import AppointmentDrawer
|
||||
from ..dialogs import DiagnosisDialog, present_order_detail
|
||||
from ..dialogs import DiagnosisDialog, present_ai_consult, present_order_detail
|
||||
from ..dialogs.ai_consult import can_open_ai_consult
|
||||
from ..dialogs.prescription import PrescriptionOrderListDialog
|
||||
from ..theme import mark_business_dialog
|
||||
from ..widgets import (
|
||||
@@ -98,7 +99,7 @@ _SEMANTIC_COLORS = {
|
||||
}
|
||||
|
||||
PATIENTS_LIGHT_QSS = """
|
||||
#PatientsPage QWidget#PageHeader { min-height: 88px; max-height: 88px; }
|
||||
#PatientsPage QWidget#PageHeader { min-height: 62px; max-height: 62px; }
|
||||
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
|
||||
color: #10204A;
|
||||
font-size: 20px;
|
||||
@@ -109,8 +110,8 @@ PATIENTS_LIGHT_QSS = """
|
||||
font-size: 12px;
|
||||
}
|
||||
#PatientsPage QPushButton[patientSearchAction="true"] {
|
||||
min-height: 34px;
|
||||
max-height: 34px;
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
color: #FFFFFF;
|
||||
background-color: #5265F6;
|
||||
border: 1px solid #5265F6;
|
||||
@@ -122,24 +123,24 @@ PATIENTS_LIGHT_QSS = """
|
||||
border-color: #4557E7;
|
||||
}
|
||||
#PatientsPage QFrame[patientListFilter="true"] {
|
||||
min-height: 112px;
|
||||
max-height: 112px;
|
||||
min-height: 88px;
|
||||
max-height: 88px;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E6EAF5;
|
||||
border-radius: 11px;
|
||||
}
|
||||
#PatientsPage QFrame[patientListFilter="true"] QLineEdit,
|
||||
#PatientsPage QFrame[patientListFilter="true"] QDateEdit {
|
||||
min-height: 34px;
|
||||
max-height: 34px;
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E2E7F4;
|
||||
border-radius: 7px;
|
||||
}
|
||||
#PatientsPage QPushButton[patientStatusChip="true"] {
|
||||
min-height: 34px;
|
||||
max-height: 34px;
|
||||
padding: 0 13px;
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
padding: 0 10px;
|
||||
color: #405074;
|
||||
background-color: #F8F9FD;
|
||||
border: 0;
|
||||
@@ -155,9 +156,9 @@ PATIENTS_LIGHT_QSS = """
|
||||
border: 1px solid #9EA8FF;
|
||||
}
|
||||
#PatientsPage QPushButton[patientQuickDate="true"] {
|
||||
min-height: 34px;
|
||||
max-height: 34px;
|
||||
padding: 0 12px;
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
padding: 0 9px;
|
||||
color: #29365C;
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
@@ -179,7 +180,7 @@ PATIENTS_LIGHT_QSS = """
|
||||
}
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab {
|
||||
min-width: 86px;
|
||||
min-height: 40px;
|
||||
min-height: 34px;
|
||||
padding: 0 6px;
|
||||
margin-right: 8px;
|
||||
color: #59698E;
|
||||
@@ -193,9 +194,9 @@ PATIENTS_LIGHT_QSS = """
|
||||
border-bottom-color: #5265F6;
|
||||
}
|
||||
#PatientsPage QPushButton[summaryCard="true"] {
|
||||
min-height: 54px;
|
||||
max-height: 54px;
|
||||
padding: 0 13px;
|
||||
min-height: 42px;
|
||||
max-height: 42px;
|
||||
padding: 0 11px;
|
||||
color: #5265F6;
|
||||
background-color: #F7F8FF;
|
||||
border: 1px solid #E1E5FF;
|
||||
@@ -1277,6 +1278,7 @@ class PatientListWorkspace(QWidget):
|
||||
fill_id_requested = Signal(object)
|
||||
cancel_requested = Signal(object)
|
||||
orders_requested = Signal(object)
|
||||
ai_consult_requested = Signal(object)
|
||||
scope_changed = Signal(str)
|
||||
|
||||
def __init__(self, repository: Any, permissions: Any, parent: QWidget | None = None) -> None:
|
||||
@@ -1289,15 +1291,18 @@ class PatientListWorkspace(QWidget):
|
||||
self._date_mode = "all"
|
||||
self._scope = "按权限加载"
|
||||
self._setting_dates = False
|
||||
self.setMinimumHeight(0)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 2, 0, 0)
|
||||
root.setSpacing(10)
|
||||
root.addWidget(self._build_filters())
|
||||
root.setSpacing(6)
|
||||
self.filter_card = self._build_filters()
|
||||
root.addWidget(self.filter_card)
|
||||
root.addLayout(self._build_summary())
|
||||
self.banner = MessageBanner()
|
||||
root.addWidget(self.banner)
|
||||
root.addWidget(self._build_table(), 1)
|
||||
self.table_card = self._build_table()
|
||||
root.addWidget(self.table_card, 1)
|
||||
|
||||
@property
|
||||
def scope(self) -> str:
|
||||
@@ -1308,20 +1313,17 @@ class PatientListWorkspace(QWidget):
|
||||
card.setObjectName("FilterBar")
|
||||
card.setProperty("patientListFilter", True)
|
||||
panel = QVBoxLayout(card)
|
||||
panel.setContentsMargins(18, 18, 18, 12)
|
||||
panel.setSpacing(10)
|
||||
panel.setContentsMargins(12, 8, 12, 8)
|
||||
panel.setSpacing(6)
|
||||
|
||||
top_row = QHBoxLayout()
|
||||
top_row.setContentsMargins(0, 0, 0, 0)
|
||||
top_row.setSpacing(0)
|
||||
top_row.setSpacing(6)
|
||||
self.keyword_edit = QLineEdit()
|
||||
self.keyword_edit.setPlaceholderText("患者姓名 / 手机号 / 助理 / 医生")
|
||||
self.keyword_edit.setClearButtonEnabled(True)
|
||||
self.keyword_edit.setMinimumWidth(300)
|
||||
self.keyword_edit.setMaximumWidth(605)
|
||||
self.keyword_edit.returnPressed.connect(self.search)
|
||||
top_row.addWidget(self.keyword_edit, 1)
|
||||
top_row.addSpacing(21)
|
||||
top_row.addWidget(self.keyword_edit, 3)
|
||||
# Compatibility-only control: it is intentionally hidden and never
|
||||
# inserted into a layout, so it needs an explicit parent to avoid
|
||||
# becoming a transient top-level Windows HWND during page creation.
|
||||
@@ -1332,11 +1334,10 @@ class PatientListWorkspace(QWidget):
|
||||
self.status_combo.addItem("已完成", "completed")
|
||||
self.status_combo.addItem("已过号", "missed")
|
||||
self.status_combo.hide()
|
||||
status_host = QWidget()
|
||||
status_host.setFixedWidth(396)
|
||||
status_row = QHBoxLayout(status_host)
|
||||
self.status_host = QWidget()
|
||||
status_row = QHBoxLayout(self.status_host)
|
||||
status_row.setContentsMargins(0, 0, 0, 0)
|
||||
status_row.setSpacing(0)
|
||||
status_row.setSpacing(2)
|
||||
self.status_group = QButtonGroup(self)
|
||||
self.status_group.setExclusive(True)
|
||||
self.status_buttons: dict[str, QPushButton] = {}
|
||||
@@ -1368,30 +1369,25 @@ class PatientListWorkspace(QWidget):
|
||||
self.status_buttons[value] = button
|
||||
status_row.addWidget(button, 1)
|
||||
self.status_buttons[""].setChecked(True)
|
||||
top_row.addWidget(status_host)
|
||||
top_row.addStretch(1)
|
||||
top_row.addWidget(self.status_host, 2)
|
||||
search = QPushButton("查询")
|
||||
search.setProperty("variant", "primary")
|
||||
search.setProperty("patientSearchAction", True)
|
||||
search.setFixedWidth(72)
|
||||
search.clicked.connect(self.search)
|
||||
top_row.addWidget(search)
|
||||
top_row.addSpacing(12)
|
||||
reset = QPushButton("重置")
|
||||
reset.setProperty("variant", "ghost")
|
||||
reset.setFixedWidth(72)
|
||||
reset.clicked.connect(self.reset_filters)
|
||||
top_row.addWidget(reset)
|
||||
panel.addLayout(top_row)
|
||||
|
||||
bottom_row = QHBoxLayout()
|
||||
bottom_row.setContentsMargins(0, 0, 0, 0)
|
||||
bottom_row.setSpacing(0)
|
||||
quick_host = QWidget()
|
||||
quick_host.setFixedWidth(730)
|
||||
quick = QHBoxLayout(quick_host)
|
||||
bottom_row.setSpacing(6)
|
||||
self.quick_host = QWidget()
|
||||
quick = QHBoxLayout(self.quick_host)
|
||||
quick.setContentsMargins(0, 0, 0, 0)
|
||||
quick.setSpacing(0)
|
||||
quick.setSpacing(2)
|
||||
self.quick_group = QButtonGroup(self)
|
||||
self.quick_group.setExclusive(True)
|
||||
self.quick_buttons: dict[str, QPushButton] = {}
|
||||
@@ -1412,15 +1408,12 @@ class PatientListWorkspace(QWidget):
|
||||
self.quick_buttons[mode] = button
|
||||
quick.addWidget(button, 1)
|
||||
self.quick_buttons["all"].setChecked(True)
|
||||
bottom_row.addWidget(quick_host)
|
||||
bottom_row.addSpacing(20)
|
||||
bottom_row.addWidget(self.quick_host, 3)
|
||||
|
||||
date_host = QWidget()
|
||||
date_host.setMinimumWidth(390)
|
||||
date_host.setMaximumWidth(476)
|
||||
dates = QHBoxLayout(date_host)
|
||||
self.date_host = QWidget()
|
||||
dates = QHBoxLayout(self.date_host)
|
||||
dates.setContentsMargins(0, 0, 0, 0)
|
||||
dates.setSpacing(10)
|
||||
dates.setSpacing(6)
|
||||
self.start_date = QDateEdit(QDate.currentDate())
|
||||
self.start_date.setCalendarPopup(True)
|
||||
self.start_date.setDisplayFormat("yyyy-MM-dd")
|
||||
@@ -1430,7 +1423,6 @@ class PatientListWorkspace(QWidget):
|
||||
separator = QLabel("~")
|
||||
separator.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
separator.setProperty("role", "muted")
|
||||
separator.setFixedWidth(24)
|
||||
dates.addWidget(separator)
|
||||
self.end_date = QDateEdit(QDate.currentDate())
|
||||
self.end_date.setCalendarPopup(True)
|
||||
@@ -1438,14 +1430,11 @@ class PatientListWorkspace(QWidget):
|
||||
self.end_date.setEnabled(False)
|
||||
self.end_date.editingFinished.connect(self._custom_date_changed)
|
||||
dates.addWidget(self.end_date, 1)
|
||||
bottom_row.addWidget(date_host, 1)
|
||||
bottom_row.addSpacing(20)
|
||||
bottom_row.addWidget(self.date_host, 2)
|
||||
custom = QPushButton("自定义")
|
||||
custom.setProperty("variant", "ghost")
|
||||
custom.setFixedWidth(100)
|
||||
custom.clicked.connect(lambda: self.set_date_mode("custom"))
|
||||
bottom_row.addWidget(custom)
|
||||
bottom_row.addStretch(1)
|
||||
panel.addLayout(bottom_row)
|
||||
return card
|
||||
|
||||
@@ -1460,7 +1449,7 @@ class PatientListWorkspace(QWidget):
|
||||
):
|
||||
button = QPushButton(f"{label}\n0 人")
|
||||
button.setProperty("summaryCard", True)
|
||||
button.setFixedHeight(56)
|
||||
button.setFixedHeight(42)
|
||||
button.setIcon(_summary_calendar_icon())
|
||||
button.setIconSize(QSize(34, 34))
|
||||
button.clicked.connect(lambda _checked=False, value=mode: self.set_date_mode(value))
|
||||
@@ -1471,9 +1460,10 @@ class PatientListWorkspace(QWidget):
|
||||
def _build_table(self) -> QWidget:
|
||||
card = QFrame()
|
||||
card.setObjectName("Card")
|
||||
card.setMinimumHeight(0)
|
||||
layout = QVBoxLayout(card)
|
||||
layout.setContentsMargins(14, 12, 14, 12)
|
||||
layout.setSpacing(8)
|
||||
layout.setContentsMargins(10, 8, 10, 8)
|
||||
layout.setSpacing(6)
|
||||
heading = QHBoxLayout()
|
||||
title = QLabel("患者列表")
|
||||
title.setProperty("role", "sectionTitle")
|
||||
@@ -1485,10 +1475,12 @@ class PatientListWorkspace(QWidget):
|
||||
heading.addWidget(self.scope_label)
|
||||
layout.addLayout(heading)
|
||||
self.content_stack = QStackedWidget()
|
||||
self.content_stack.setMinimumHeight(0)
|
||||
host = QWidget()
|
||||
host.setMinimumHeight(0)
|
||||
host_layout = QVBoxLayout(host)
|
||||
host_layout.setContentsMargins(0, 0, 0, 0)
|
||||
host_layout.setSpacing(8)
|
||||
host_layout.setSpacing(6)
|
||||
self.table = SortableTable(
|
||||
[
|
||||
TableColumn("_selected", "", 40, alignment=Qt.AlignmentFlag.AlignCenter),
|
||||
@@ -1524,17 +1516,23 @@ class PatientListWorkspace(QWidget):
|
||||
TableColumn("confirmation_text", "确认信息", 88),
|
||||
TableColumn("diagnosis_date_text", "诊单日期", 96),
|
||||
TableColumn("phone_masked", "手机", 116),
|
||||
TableColumn("_actions", "操作", 430),
|
||||
TableColumn("_actions", "操作", 500),
|
||||
]
|
||||
)
|
||||
self.table.setObjectName("PatientTable")
|
||||
self.table.horizontalHeader().setFixedHeight(38)
|
||||
self.table.setMinimumHeight(0)
|
||||
self.table.horizontalHeader().setFixedHeight(34)
|
||||
self.table.verticalHeader().setDefaultSectionSize(40)
|
||||
self.table.itemSelectionChanged.connect(self._update_actions)
|
||||
self.table.itemDoubleClicked.connect(lambda _item: self._open_selected_diagnosis())
|
||||
host_layout.addWidget(self.table, 1)
|
||||
host_layout.addLayout(self._build_actions())
|
||||
self.bottom_actions = QWidget(host)
|
||||
self.bottom_actions.setObjectName("PatientBottomActions")
|
||||
self.bottom_actions.setLayout(self._build_actions())
|
||||
self.bottom_actions.hide()
|
||||
host_layout.addWidget(self.bottom_actions)
|
||||
self.pager = Pager(self._page_size)
|
||||
self.pager.setMaximumHeight(38)
|
||||
self.pager.page_changed.connect(self._change_page)
|
||||
host_layout.addWidget(self.pager)
|
||||
self.content_stack.addWidget(host)
|
||||
@@ -1594,6 +1592,11 @@ class PatientListWorkspace(QWidget):
|
||||
value, can_edit
|
||||
),
|
||||
)
|
||||
if can_open_ai_consult(self.permissions):
|
||||
add_action(
|
||||
"AI 分析",
|
||||
lambda _checked=False, value=row: self.ai_consult_requested.emit(value),
|
||||
)
|
||||
if can_book:
|
||||
add_action(
|
||||
"预约",
|
||||
@@ -1636,6 +1639,12 @@ class PatientListWorkspace(QWidget):
|
||||
self.diagnosis_button.setProperty("variant", "primary")
|
||||
self.diagnosis_button.clicked.connect(self._open_selected_diagnosis)
|
||||
layout.addWidget(self.diagnosis_button)
|
||||
self.ai_consult_button = QPushButton("AI 分析", self)
|
||||
self.ai_consult_button.clicked.connect(
|
||||
lambda: self._emit_selected(self.ai_consult_requested)
|
||||
)
|
||||
self.ai_consult_button.setVisible(can_open_ai_consult(self.permissions))
|
||||
layout.addWidget(self.ai_consult_button)
|
||||
self.appointment_button = QPushButton("预约", self)
|
||||
self.appointment_button.clicked.connect(
|
||||
lambda: self._emit_selected(self.appointment_requested)
|
||||
@@ -1682,6 +1691,8 @@ class PatientListWorkspace(QWidget):
|
||||
self.diagnosis_button.setVisible(editable or readable)
|
||||
self.diagnosis_button.setText("诊单" if editable else "查看")
|
||||
self.diagnosis_button.setEnabled(selected)
|
||||
self.ai_consult_button.setVisible(can_open_ai_consult(self.permissions))
|
||||
self.ai_consult_button.setEnabled(selected)
|
||||
self.appointment_button.setVisible(can_book)
|
||||
self.appointment_button.setEnabled(selected)
|
||||
self.assign_button.setVisible(can_assign)
|
||||
@@ -2606,19 +2617,20 @@ class PatientsPage(QWidget):
|
||||
self._assistant_generation = 0
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(25, 4, 30, 14)
|
||||
root.setContentsMargins(20, 2, 24, 10)
|
||||
root.setSpacing(0)
|
||||
header = PageHeader("我的患者", "患者、挂号与诊单按当前角色和部门数据范围展示。")
|
||||
self.header = PageHeader("我的患者", "患者、挂号与诊单按当前角色和部门数据范围展示。")
|
||||
self.scope_badge = StatusBadge("按权限加载", "neutral")
|
||||
header.add_action(self.scope_badge)
|
||||
self.header.add_action(self.scope_badge)
|
||||
refresh = QPushButton("刷新")
|
||||
refresh.setProperty("variant", "primary")
|
||||
refresh.clicked.connect(self.refresh)
|
||||
header.add_action(refresh)
|
||||
root.addWidget(header)
|
||||
self.header.add_action(refresh)
|
||||
root.addWidget(self.header)
|
||||
|
||||
self.tabs = QTabWidget()
|
||||
self.tabs.setObjectName("PatientWorkspaceTabs")
|
||||
self.tabs.setMinimumHeight(0)
|
||||
self.patient_workspace = PatientListWorkspace(repository, permissions)
|
||||
self.order_workspace = PatientOrdersWorkspace(repository, permissions)
|
||||
self.progress_workspace = PatientProgressWorkspace(repository)
|
||||
@@ -2628,6 +2640,7 @@ class PatientsPage(QWidget):
|
||||
root.addWidget(self.tabs, 1)
|
||||
|
||||
self.patient_workspace.diagnosis_requested.connect(self._open_diagnosis)
|
||||
self.patient_workspace.ai_consult_requested.connect(self._open_ai_consult)
|
||||
self.patient_workspace.appointment_requested.connect(self._book_appointment)
|
||||
self.patient_workspace.assign_requested.connect(self._load_assistants)
|
||||
self.patient_workspace.fill_id_requested.connect(self._fill_id_card)
|
||||
@@ -2689,6 +2702,17 @@ class PatientsPage(QWidget):
|
||||
else:
|
||||
self._ensure_diagnosis_dialog().open_view_only(diagnosis_id, seed=row)
|
||||
|
||||
def _open_ai_consult(self, row: Any) -> None:
|
||||
present_ai_consult(
|
||||
self.repository,
|
||||
self.permissions,
|
||||
self,
|
||||
diagnosis_id=self._diagnosis_id(row),
|
||||
patient_id=_as_int(first_value(row, "patient_id", "source_patient_id", default=0)),
|
||||
seed=row,
|
||||
source_title="我的患者",
|
||||
)
|
||||
|
||||
def _open_order_diagnosis(self, row: Any) -> None:
|
||||
editable = _canonical_allowed(self.permissions, "tcm.diagnosis/edit")
|
||||
readable = _canonical_allowed(self.permissions, "tcm.diagnosis/readonlyDetail")
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import Any
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtGui import QColor, QFont
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QFrame,
|
||||
@@ -26,6 +25,7 @@ from PySide6.QtWidgets import (
|
||||
from ..dialogs.prescription import PrescriptionTemplateDialog
|
||||
from ..dialogs.prescription_ai import PrescriptionAiReportDialog, can_open_ai_explain
|
||||
from ..widgets import (
|
||||
BusinessPager,
|
||||
EmptyState,
|
||||
MessageBanner,
|
||||
MetricCard,
|
||||
@@ -44,7 +44,6 @@ from ..widgets import (
|
||||
show_toast,
|
||||
)
|
||||
from .prescriptions import (
|
||||
BusinessPager,
|
||||
_cell_host,
|
||||
_painted_icon,
|
||||
_row_action_button,
|
||||
@@ -54,27 +53,27 @@ from .prescriptions import (
|
||||
|
||||
PRESCRIPTION_LIBRARY_PAGE_QSS = """
|
||||
#PrescriptionLibraryPage { background: #F8FAFF; }
|
||||
#PrescriptionLibraryPage QWidget#PageHeader { min-height: 84px; max-height: 84px; }
|
||||
#PrescriptionLibraryPage QWidget#PageHeader { min-height: 62px; max-height: 62px; }
|
||||
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumb"],
|
||||
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
|
||||
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
|
||||
min-height: 16px; max-height: 16px;
|
||||
min-height: 14px; max-height: 14px;
|
||||
}
|
||||
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
|
||||
color: #15224A; font-size: 20px; font-weight: 700;
|
||||
}
|
||||
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="muted"] {
|
||||
color: #7481A3; font-size: 12px; padding-top: 5px;
|
||||
color: #7481A3; font-size: 12px;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#MetricCard {
|
||||
min-height: 80px; max-height: 80px;
|
||||
min-height: 64px; max-height: 64px;
|
||||
border: 1px solid #E2E7F4; border-radius: 12px; background: #FFFFFF;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
|
||||
color: #405074; font-size: 12px; font-weight: 600;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
|
||||
color: #5265F6; font-size: 22px; font-weight: 700;
|
||||
color: #5265F6; font-size: 20px; font-weight: 700;
|
||||
}
|
||||
#PrescriptionLibraryPage QLabel[metricIcon="true"] {
|
||||
border: 1px solid #DCE3FF; border-radius: 11px; background: #EEF1FF;
|
||||
@@ -97,7 +96,7 @@ PRESCRIPTION_LIBRARY_PAGE_QSS = """
|
||||
border-top-left-radius: 13px; border-top-right-radius: 13px;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
|
||||
min-width: 82px; min-height: 36px; max-height: 36px;
|
||||
min-width: 82px; min-height: 32px; max-height: 32px;
|
||||
padding: 0 8px; margin: 0 4px 0 0;
|
||||
color: #59698E; background: transparent; border: 0;
|
||||
border-bottom: 2px solid transparent; border-radius: 0;
|
||||
@@ -107,7 +106,7 @@ PRESCRIPTION_LIBRARY_PAGE_QSS = """
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar QLineEdit,
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar QComboBox {
|
||||
min-height: 36px; max-height: 36px; padding: 0 11px;
|
||||
min-height: 32px; max-height: 32px; padding: 0 11px;
|
||||
border-radius: 8px; font-size: 12px;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton {
|
||||
@@ -159,11 +158,14 @@ PRESCRIPTION_LIBRARY_PAGE_QSS = """
|
||||
min-width: 34px; max-width: 34px; min-height: 32px; max-height: 32px;
|
||||
padding: 0; background: #FFFFFF; color: #405074; border-color: #E2E7F4;
|
||||
}
|
||||
#PrescriptionLibraryPage QWidget#BusinessPager QPushButton {
|
||||
min-height: 32px; max-height: 32px;
|
||||
}
|
||||
#PrescriptionLibraryPage QWidget#BusinessPager QPushButton[pagerPage="true"][active="true"] {
|
||||
background: #5265F6; color: #FFFFFF; border-color: #5265F6;
|
||||
}
|
||||
#PrescriptionLibraryPage QWidget#BusinessPager QComboBox {
|
||||
min-height: 32px; max-height: 32px; min-width: 92px; padding: 0 9px;
|
||||
#PrescriptionLibraryPage QWidget#BusinessPager QLabel[pagerSize="true"] {
|
||||
min-width: 64px; color: #7481A3; font-size: 12px;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -214,13 +216,13 @@ def _efficacy_text(_value: Any, row: Any) -> str:
|
||||
|
||||
def _metric_card(title: str, kind: str = "accent") -> MetricCard:
|
||||
card = MetricCard(title, "0", kind=kind, glyph="")
|
||||
card.setFixedHeight(80)
|
||||
card.layout().setContentsMargins(18, 12, 16, 12)
|
||||
card.setFixedHeight(64)
|
||||
card.layout().setContentsMargins(16, 8, 14, 8)
|
||||
icon = QLabel(card)
|
||||
icon.setProperty("metricIcon", True)
|
||||
icon.setProperty("kind", kind)
|
||||
icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
icon.setFixedSize(42, 42)
|
||||
icon.setFixedSize(36, 36)
|
||||
colors = {
|
||||
"accent": "#5365F5",
|
||||
"info": "#8268E8",
|
||||
@@ -261,6 +263,7 @@ class PrescriptionLibraryPage(QWidget):
|
||||
"处方库",
|
||||
"管理常用处方模板,支持 AI 解析辅助开方。",
|
||||
)
|
||||
header.layout().setSpacing(4)
|
||||
header.actions.setSpacing(16)
|
||||
self.new_button = QPushButton("新增处方", header)
|
||||
self.new_button.setMinimumWidth(124)
|
||||
@@ -274,8 +277,8 @@ class PrescriptionLibraryPage(QWidget):
|
||||
root.addWidget(header)
|
||||
|
||||
metrics = QHBoxLayout()
|
||||
metrics.setContentsMargins(0, 0, 0, 5)
|
||||
metrics.setSpacing(24)
|
||||
metrics.setContentsMargins(0, 0, 0, 0)
|
||||
metrics.setSpacing(16)
|
||||
self.metric_cards = {
|
||||
"total": _metric_card("全部处方"),
|
||||
"private": _metric_card("仅自己", "info"),
|
||||
@@ -295,9 +298,10 @@ class PrescriptionLibraryPage(QWidget):
|
||||
|
||||
filters = QFrame()
|
||||
filters.setObjectName("PrescriptionLibraryFilterBar")
|
||||
filters.setFixedHeight(52)
|
||||
grid = QGridLayout(filters)
|
||||
grid.setContentsMargins(16, 11, 16, 11)
|
||||
grid.setHorizontalSpacing(20)
|
||||
grid.setContentsMargins(16, 9, 16, 9)
|
||||
grid.setHorizontalSpacing(12)
|
||||
self.name_filter = QLineEdit()
|
||||
self.name_filter.setPlaceholderText("搜索处方名称、药材、功效等关键词")
|
||||
self.name_filter.setClearButtonEnabled(True)
|
||||
@@ -319,10 +323,10 @@ class PrescriptionLibraryPage(QWidget):
|
||||
self.effect_filter.addItem("清热祛湿", "清热祛湿")
|
||||
self.effect_filter.addItem("滋阴补肾", "滋阴补肾")
|
||||
grid.addWidget(self.effect_filter, 0, 3)
|
||||
self.name_filter.setMinimumWidth(500)
|
||||
self.formula_filter.setFixedWidth(190)
|
||||
self.visibility_filter.setFixedWidth(174)
|
||||
self.effect_filter.setFixedWidth(190)
|
||||
self.name_filter.setMinimumWidth(220)
|
||||
self.formula_filter.setMinimumWidth(132)
|
||||
self.visibility_filter.setMinimumWidth(148)
|
||||
self.effect_filter.setMinimumWidth(144)
|
||||
self.query_button = QPushButton("查询")
|
||||
self.query_button.setFixedWidth(66)
|
||||
self.query_button.setProperty("variant", "secondary")
|
||||
@@ -335,7 +339,10 @@ class PrescriptionLibraryPage(QWidget):
|
||||
self.reset_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.reset_button.clicked.connect(self._reset_filters)
|
||||
grid.addWidget(self.reset_button, 0, 5)
|
||||
grid.setColumnStretch(0, 1)
|
||||
grid.setColumnStretch(0, 4)
|
||||
grid.setColumnStretch(1, 1)
|
||||
grid.setColumnStretch(2, 1)
|
||||
grid.setColumnStretch(3, 1)
|
||||
root.addWidget(filters)
|
||||
|
||||
self.banner = MessageBanner()
|
||||
@@ -347,8 +354,9 @@ class PrescriptionLibraryPage(QWidget):
|
||||
card_layout.setSpacing(0)
|
||||
toolbar_host = QFrame(card)
|
||||
toolbar_host.setObjectName("PrescriptionLibraryToolbar")
|
||||
toolbar_host.setFixedHeight(46)
|
||||
toolbar = QHBoxLayout(toolbar_host)
|
||||
toolbar.setContentsMargins(16, 10, 16, 9)
|
||||
toolbar.setContentsMargins(16, 7, 16, 7)
|
||||
toolbar.setSpacing(8)
|
||||
self.all_tab = QPushButton("处方列表", toolbar_host)
|
||||
self.all_tab.setProperty("toolbarTab", True)
|
||||
@@ -423,10 +431,9 @@ class PrescriptionLibraryPage(QWidget):
|
||||
TableColumn("__actions__", "操作", 160, lambda _value, _row: ""),
|
||||
]
|
||||
)
|
||||
self.table.verticalHeader().setDefaultSectionSize(37)
|
||||
self.table.horizontalHeader().setFixedHeight(41)
|
||||
self.table.verticalHeader().setDefaultSectionSize(36)
|
||||
self.table.horizontalHeader().setFixedHeight(38)
|
||||
self.table.setWordWrap(False)
|
||||
self.table.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
self.table.itemSelectionChanged.connect(self._selection_changed)
|
||||
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
|
||||
table_layout.addWidget(self.table, 1)
|
||||
|
||||
@@ -6,10 +6,9 @@ from collections.abc import Callable, Mapping
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QDateTime, QRectF, QSize, Qt, Signal
|
||||
from PySide6.QtCore import QDateTime, QRectF, QSize, Qt
|
||||
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QComboBox,
|
||||
QDateTimeEdit,
|
||||
QDialog,
|
||||
@@ -38,6 +37,7 @@ from ..dialogs.prescription import (
|
||||
PrescriptionOrderListDialog,
|
||||
)
|
||||
from ..widgets import (
|
||||
BusinessPager,
|
||||
EmptyState,
|
||||
MessageBanner,
|
||||
PageHeader,
|
||||
@@ -57,17 +57,17 @@ from ..widgets import (
|
||||
|
||||
PRESCRIPTIONS_PAGE_QSS = """
|
||||
#PrescriptionsPage { background: #F8FAFF; }
|
||||
#PrescriptionsPage QWidget#PageHeader { min-height: 84px; max-height: 84px; }
|
||||
#PrescriptionsPage QWidget#PageHeader { min-height: 62px; max-height: 62px; }
|
||||
#PrescriptionsPage QWidget#PageHeader QLabel[role="breadcrumb"],
|
||||
#PrescriptionsPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
|
||||
#PrescriptionsPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
|
||||
min-height: 16px; max-height: 16px;
|
||||
min-height: 14px; max-height: 14px;
|
||||
}
|
||||
#PrescriptionsPage QLabel[role="pageTitle"] {
|
||||
color: #15224A; font-size: 20px; font-weight: 700;
|
||||
}
|
||||
#PrescriptionsPage QWidget#PageHeader QLabel[role="muted"] {
|
||||
color: #7481A3; font-size: 12px; padding-top: 5px;
|
||||
color: #7481A3; font-size: 12px;
|
||||
}
|
||||
#PrescriptionsPage QFrame#PrescriptionFilterBar,
|
||||
#PrescriptionsPage QFrame#PrescriptionTableCard {
|
||||
@@ -85,7 +85,7 @@ PRESCRIPTIONS_PAGE_QSS = """
|
||||
#PrescriptionsPage QFrame#PrescriptionFilterBar QLineEdit,
|
||||
#PrescriptionsPage QFrame#PrescriptionFilterBar QComboBox,
|
||||
#PrescriptionsPage QFrame#PrescriptionFilterBar QDateTimeEdit {
|
||||
min-height: 34px; max-height: 34px; padding: 0 11px;
|
||||
min-height: 32px; max-height: 32px; padding: 0 11px;
|
||||
border-radius: 8px; font-size: 12px;
|
||||
}
|
||||
#PrescriptionsPage QPushButton {
|
||||
@@ -142,11 +142,14 @@ PRESCRIPTIONS_PAGE_QSS = """
|
||||
min-width: 34px; max-width: 34px; min-height: 32px; max-height: 32px;
|
||||
padding: 0; background: #FFFFFF; color: #405074; border-color: #E2E7F4;
|
||||
}
|
||||
#PrescriptionsPage QWidget#BusinessPager QPushButton {
|
||||
min-height: 32px; max-height: 32px;
|
||||
}
|
||||
#PrescriptionsPage QWidget#BusinessPager QPushButton[pagerPage="true"][active="true"] {
|
||||
background: #5265F6; color: #FFFFFF; border-color: #5265F6;
|
||||
}
|
||||
#PrescriptionsPage QWidget#BusinessPager QComboBox {
|
||||
min-height: 32px; max-height: 32px; min-width: 92px; padding: 0 9px;
|
||||
#PrescriptionsPage QWidget#BusinessPager QLabel[pagerSize="true"] {
|
||||
min-width: 64px; color: #7481A3; font-size: 12px;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -276,88 +279,6 @@ def _row_action_button(
|
||||
return button
|
||||
|
||||
|
||||
class BusinessPager(QWidget):
|
||||
"""Reference-style numbered pager while retaining the page's fixed size contract."""
|
||||
|
||||
page_changed = Signal(int)
|
||||
|
||||
def __init__(self, page_size: int = 15, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("BusinessPager")
|
||||
self.page = 1
|
||||
self.page_size = page_size
|
||||
self.total = 0
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 6, 0, 0)
|
||||
layout.setSpacing(7)
|
||||
self.summary = QLabel("共 0 条", self)
|
||||
self.summary.setProperty("role", "muted")
|
||||
layout.addWidget(self.summary)
|
||||
layout.addStretch(1)
|
||||
self.previous = QPushButton("‹", self)
|
||||
self.previous.setProperty("variant", "ghost")
|
||||
self.previous.clicked.connect(lambda: self._request(self.page - 1))
|
||||
layout.addWidget(self.previous)
|
||||
self.pages_host = QWidget(self)
|
||||
self.pages_layout = QHBoxLayout(self.pages_host)
|
||||
self.pages_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.pages_layout.setSpacing(5)
|
||||
layout.addWidget(self.pages_host)
|
||||
self.next = QPushButton("›", self)
|
||||
self.next.setProperty("variant", "ghost")
|
||||
self.next.clicked.connect(lambda: self._request(self.page + 1))
|
||||
layout.addWidget(self.next)
|
||||
self.size_combo = QComboBox(self)
|
||||
self.size_combo.addItem(f"{page_size} 条/页", page_size)
|
||||
layout.addWidget(self.size_combo)
|
||||
self.page_label: QPushButton | None = None
|
||||
self.update_state(1, 0)
|
||||
|
||||
@property
|
||||
def page_count(self) -> int:
|
||||
return max(1, (self.total + self.page_size - 1) // self.page_size)
|
||||
|
||||
def update_state(self, page: int, total: int) -> None:
|
||||
self.page = max(1, page)
|
||||
self.total = max(0, total)
|
||||
self.summary.setText(f"共 {self.total} 条")
|
||||
while self.pages_layout.count():
|
||||
item = self.pages_layout.takeAt(0)
|
||||
if item.widget() is not None:
|
||||
item.widget().deleteLater()
|
||||
count = self.page_count
|
||||
if count <= 4:
|
||||
pages: list[int | None] = list(range(1, count + 1))
|
||||
elif self.page <= 3:
|
||||
pages = [1, 2, 3, None, count]
|
||||
elif self.page >= count - 2:
|
||||
pages = [1, None, count - 2, count - 1, count]
|
||||
else:
|
||||
pages = [1, None, self.page, None, count]
|
||||
self.page_label = None
|
||||
for number in pages:
|
||||
if number is None:
|
||||
ellipsis = QLabel("…", self.pages_host)
|
||||
ellipsis.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
ellipsis.setFixedWidth(24)
|
||||
self.pages_layout.addWidget(ellipsis)
|
||||
continue
|
||||
button = QPushButton(str(number), self.pages_host)
|
||||
button.setProperty("pagerPage", True)
|
||||
button.setProperty("active", number == self.page)
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button.clicked.connect(lambda _checked=False, value=number: self._request(value))
|
||||
self.pages_layout.addWidget(button)
|
||||
if number == self.page:
|
||||
self.page_label = button
|
||||
self.previous.setEnabled(self.page > 1)
|
||||
self.next.setEnabled(self.page < count)
|
||||
|
||||
def _request(self, page: int) -> None:
|
||||
if 1 <= page <= self.page_count and page != self.page:
|
||||
self.page_changed.emit(page)
|
||||
|
||||
|
||||
def _int(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
@@ -609,6 +530,7 @@ class PrescriptionsPage(QWidget):
|
||||
"已开处方",
|
||||
"管理处方审核、患者修正与履约订单;已通过且未作废的处方只允许查看。",
|
||||
)
|
||||
header.layout().setSpacing(4)
|
||||
header.actions.setSpacing(16)
|
||||
self.orders_button = QPushButton("业务订单", header)
|
||||
self.orders_button.setMinimumWidth(86)
|
||||
@@ -635,10 +557,11 @@ class PrescriptionsPage(QWidget):
|
||||
def _build_filters(self) -> QWidget:
|
||||
frame = QFrame()
|
||||
frame.setObjectName("PrescriptionFilterBar")
|
||||
frame.setFixedHeight(88)
|
||||
grid = QGridLayout(frame)
|
||||
grid.setContentsMargins(14, 17, 14, 17)
|
||||
grid.setContentsMargins(16, 8, 16, 8)
|
||||
grid.setHorizontalSpacing(18)
|
||||
grid.setVerticalSpacing(15)
|
||||
grid.setVerticalSpacing(8)
|
||||
self.quick_date = QComboBox()
|
||||
self.quick_date.addItem("全部时间", "all")
|
||||
self.quick_date.addItem("今日", "today")
|
||||
@@ -712,8 +635,9 @@ class PrescriptionsPage(QWidget):
|
||||
layout.setSpacing(0)
|
||||
toolbar_host = QFrame(card)
|
||||
toolbar_host.setObjectName("PrescriptionToolbar")
|
||||
toolbar_host.setFixedHeight(46)
|
||||
toolbar = QHBoxLayout(toolbar_host)
|
||||
toolbar.setContentsMargins(16, 11, 16, 11)
|
||||
toolbar.setContentsMargins(16, 7, 16, 7)
|
||||
toolbar.setSpacing(8)
|
||||
title = QLabel("处方列表")
|
||||
title.setProperty("role", "sectionTitle")
|
||||
@@ -772,7 +696,6 @@ class PrescriptionsPage(QWidget):
|
||||
self.table.verticalHeader().setDefaultSectionSize(36)
|
||||
self.table.horizontalHeader().setFixedHeight(38)
|
||||
self.table.setWordWrap(False)
|
||||
self.table.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
self.table.horizontalHeaderItem(0).setIcon(_painted_icon("checkbox", "#AEB9D4", 14))
|
||||
self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.table.itemSelectionChanged.connect(self._selection_changed)
|
||||
@@ -1434,6 +1357,7 @@ class PrescriptionsPage(QWidget):
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BusinessPager",
|
||||
"PrescriptionsPage",
|
||||
"can_audit",
|
||||
"can_create_order",
|
||||
|
||||
@@ -4,9 +4,10 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
|
||||
from PySide6.QtCore import QPointF, QRectF, QSize, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QFont,
|
||||
@@ -863,6 +864,10 @@ class ShellWindow(QMainWindow):
|
||||
self.page_titles: dict[int, str] = {}
|
||||
self._fixed_tab_key: str | None = None
|
||||
self._sidebar_collapsed = False
|
||||
self._active_page_key: str | None = None
|
||||
self._activation_page: QWidget | None = None
|
||||
self._activation_generation = 0
|
||||
self._activation_refreshed = False
|
||||
|
||||
self.setMinimumSize(1024, 640)
|
||||
self.resize(1710, 920)
|
||||
@@ -1614,6 +1619,7 @@ class ShellWindow(QMainWindow):
|
||||
current_user=self.current_user,
|
||||
parent=self.stack,
|
||||
)
|
||||
self._install_activation_refresh_gate(page)
|
||||
if hasattr(page, "video_requested"):
|
||||
page.video_requested.connect(
|
||||
lambda payload: self.video_requested.emit(payload)
|
||||
@@ -1662,9 +1668,49 @@ class ShellWindow(QMainWindow):
|
||||
),
|
||||
)
|
||||
|
||||
def _install_activation_refresh_gate(self, page: QWidget) -> None:
|
||||
"""Coalesce lifecycle and shell refreshes during one page activation."""
|
||||
|
||||
refresh = getattr(page, "refresh", None)
|
||||
if not callable(refresh):
|
||||
return
|
||||
|
||||
@wraps(refresh)
|
||||
def activation_refresh(*args: Any, **kwargs: Any) -> Any:
|
||||
if page is self._activation_page:
|
||||
if self._activation_refreshed:
|
||||
return None
|
||||
self._activation_refreshed = True
|
||||
return refresh(*args, **kwargs)
|
||||
|
||||
page.refresh = activation_refresh # type: ignore[attr-defined,method-assign]
|
||||
|
||||
def _ensure_activation_refresh(self, page: QWidget, generation: int) -> None:
|
||||
if generation != self._activation_generation or page is not self._activation_page:
|
||||
return
|
||||
if not self._activation_refreshed:
|
||||
refresh = getattr(page, "refresh", None)
|
||||
if callable(refresh):
|
||||
refresh()
|
||||
|
||||
def _finish_activation(self, page: QWidget, generation: int) -> None:
|
||||
if generation != self._activation_generation or page is not self._activation_page:
|
||||
return
|
||||
self._ensure_activation_refresh(page, generation)
|
||||
self._activation_page = None
|
||||
|
||||
def _navigate(self, index: int, key: str) -> None:
|
||||
if index < 0 or index >= self.stack.count():
|
||||
return
|
||||
page = self.stack.widget(index)
|
||||
if page is None:
|
||||
return
|
||||
if self._active_page_key == key and self.stack.currentWidget() is page:
|
||||
return
|
||||
self._activation_generation += 1
|
||||
activation_generation = self._activation_generation
|
||||
self._activation_page = page
|
||||
self._activation_refreshed = False
|
||||
self.stack.setCurrentIndex(index)
|
||||
if not self._sidebar_collapsed:
|
||||
self.sidebar.setFixedWidth(self._expanded_sidebar_width(key))
|
||||
@@ -1675,10 +1721,15 @@ class ShellWindow(QMainWindow):
|
||||
button = self.nav_buttons.get(key)
|
||||
if button is not None:
|
||||
button.setChecked(True)
|
||||
page = self.stack.widget(index)
|
||||
refresh = getattr(page, "refresh", None)
|
||||
if callable(refresh):
|
||||
refresh()
|
||||
self._active_page_key = key
|
||||
self._ensure_activation_refresh(page, activation_generation)
|
||||
if self.isVisible():
|
||||
QTimer.singleShot(
|
||||
0,
|
||||
lambda page=page, generation=activation_generation: self._finish_activation(
|
||||
page, generation
|
||||
),
|
||||
)
|
||||
self.page_changed.emit(key)
|
||||
|
||||
def navigate(self, key: str) -> bool:
|
||||
@@ -1697,6 +1748,17 @@ class ShellWindow(QMainWindow):
|
||||
if callable(refresh):
|
||||
refresh()
|
||||
|
||||
def showEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||
super().showEvent(event)
|
||||
page = self.stack.currentWidget()
|
||||
if page is None or page is not self._activation_page:
|
||||
return
|
||||
generation = self._activation_generation
|
||||
QTimer.singleShot(
|
||||
0,
|
||||
lambda page=page, generation=generation: self._finish_activation(page, generation),
|
||||
)
|
||||
|
||||
def setVisible(self, visible: bool) -> None: # noqa: N802 - Qt API
|
||||
if visible:
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, False)
|
||||
|
||||
@@ -716,6 +716,9 @@ class SortableTable(QTableWidget):
|
||||
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
self.setMinimumHeight(0)
|
||||
self.setSortingEnabled(True)
|
||||
self.verticalHeader().setVisible(False)
|
||||
self.horizontalHeader().setStretchLastSection(True)
|
||||
@@ -754,6 +757,105 @@ class SortableTable(QTableWidget):
|
||||
return item.data(Qt.ItemDataRole.UserRole) if item is not None else None
|
||||
|
||||
|
||||
class BusinessPager(QWidget):
|
||||
"""Compact numbered pager shared by dense business-list pages."""
|
||||
|
||||
page_changed = Signal(int)
|
||||
|
||||
def __init__(self, page_size: int = 15, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("BusinessPager")
|
||||
self.setFixedHeight(42)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.page = 1
|
||||
self.page_size = page_size
|
||||
self.total = 0
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(16, 4, 16, 4)
|
||||
layout.setSpacing(7)
|
||||
self.summary = QLabel("共 0 条", self)
|
||||
self.summary.setProperty("role", "muted")
|
||||
layout.addWidget(self.summary)
|
||||
layout.addStretch(1)
|
||||
|
||||
self.previous = QPushButton("‹", self)
|
||||
self.previous.setProperty("variant", "ghost")
|
||||
self.previous.setAccessibleName("上一页")
|
||||
self.previous.clicked.connect(lambda: self._request(self.page - 1))
|
||||
layout.addWidget(self.previous)
|
||||
|
||||
self.pages_host = QWidget(self)
|
||||
self.pages_layout = QHBoxLayout(self.pages_host)
|
||||
self.pages_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.pages_layout.setSpacing(5)
|
||||
layout.addWidget(self.pages_host)
|
||||
|
||||
self.next = QPushButton("›", self)
|
||||
self.next.setProperty("variant", "ghost")
|
||||
self.next.setAccessibleName("下一页")
|
||||
self.next.clicked.connect(lambda: self._request(self.page + 1))
|
||||
layout.addWidget(self.next)
|
||||
|
||||
# The business contract fixes this list to one page size. A label is
|
||||
# intentionally used instead of a one-option combo box so the control
|
||||
# does not advertise an interaction that cannot change anything.
|
||||
self.page_size_label = QLabel(f"{page_size} 条/页", self)
|
||||
self.page_size_label.setProperty("pagerSize", True)
|
||||
self.page_size_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(self.page_size_label)
|
||||
|
||||
self.page_label: QPushButton | None = None
|
||||
self.update_state(1, 0)
|
||||
|
||||
@property
|
||||
def page_count(self) -> int:
|
||||
return max(1, (self.total + self.page_size - 1) // self.page_size)
|
||||
|
||||
def update_state(self, page: int, total: int) -> None:
|
||||
self.total = max(0, total)
|
||||
self.page = min(max(1, page), self.page_count)
|
||||
self.summary.setText(f"共 {self.total} 条")
|
||||
while self.pages_layout.count():
|
||||
item = self.pages_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.deleteLater()
|
||||
|
||||
count = self.page_count
|
||||
if count <= 4:
|
||||
pages: list[int | None] = list(range(1, count + 1))
|
||||
elif self.page <= 3:
|
||||
pages = [1, 2, 3, None, count]
|
||||
elif self.page >= count - 2:
|
||||
pages = [1, None, count - 2, count - 1, count]
|
||||
else:
|
||||
pages = [1, None, self.page, None, count]
|
||||
|
||||
self.page_label = None
|
||||
for number in pages:
|
||||
if number is None:
|
||||
ellipsis = QLabel("…", self.pages_host)
|
||||
ellipsis.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
ellipsis.setFixedWidth(24)
|
||||
self.pages_layout.addWidget(ellipsis)
|
||||
continue
|
||||
button = QPushButton(str(number), self.pages_host)
|
||||
button.setProperty("pagerPage", True)
|
||||
button.setProperty("active", number == self.page)
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button.clicked.connect(lambda _checked=False, value=number: self._request(value))
|
||||
self.pages_layout.addWidget(button)
|
||||
if number == self.page:
|
||||
self.page_label = button
|
||||
self.previous.setEnabled(self.page > 1)
|
||||
self.next.setEnabled(self.page < count)
|
||||
|
||||
def _request(self, page: int) -> None:
|
||||
if 1 <= page <= self.page_count and page != self.page:
|
||||
self.page_changed.emit(page)
|
||||
|
||||
|
||||
class Pager(QWidget):
|
||||
page_changed = Signal(int)
|
||||
|
||||
@@ -833,6 +935,7 @@ def clear_layout(layout: QVBoxLayout | QHBoxLayout) -> None:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BusinessPager",
|
||||
"BusyOverlay",
|
||||
"EmptyState",
|
||||
"MessageBanner",
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QTextBrowser
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module
|
||||
from doctor_workstation.ui.dialogs.ai_consult import (
|
||||
AiConsultDialog,
|
||||
can_open_ai_consult,
|
||||
present_ai_consult,
|
||||
render_chat_payload,
|
||||
)
|
||||
from doctor_workstation.ui.pages.appointments import AppointmentsPage
|
||||
from doctor_workstation.ui.pages.consultations import ConsultationsPage
|
||||
from doctor_workstation.ui.pages.patients import PatientListWorkspace
|
||||
from doctor_workstation.ui.pages.reception import ReceptionPage
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def run_immediately(
|
||||
function: Any,
|
||||
*args: Any,
|
||||
on_success: Any = None,
|
||||
on_error: Any = None,
|
||||
on_finished: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> object:
|
||||
try:
|
||||
result = function(*args, **kwargs)
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(ai_consult_module, "run_async", run_immediately)
|
||||
|
||||
|
||||
def test_ai_consult_dialog_matches_workspace_chrome(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
repository = DemoDoctorRepository()
|
||||
dialog = AiConsultDialog(repository, PermissionSet(["tcm.diagnosis/aiAssistant"]))
|
||||
dialog.open_for(
|
||||
diagnosis_id=501,
|
||||
patient_id=301,
|
||||
seed={"patient_name": "杨永", "age": 52, "clinical_diagnosis": "2型糖尿病"},
|
||||
source_title="问诊列表",
|
||||
)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
|
||||
labels = [widget.text() for widget in dialog.findChildren(QLabel) if widget.text()]
|
||||
assert "问诊详情" in labels
|
||||
assert "AI 助手" in labels
|
||||
assert "智能分析" in labels
|
||||
assert "快捷工具" in labels
|
||||
assert "对话建议" in labels
|
||||
assert dialog.tabs.tabText(0) == "问诊对话"
|
||||
assert dialog.send_button.objectName() == "AiConsultSend"
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_present_ai_consult_requires_diagnosis_id(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
opened: list[int] = []
|
||||
monkeypatch.setattr(ai_consult_module.AiConsultDialog, "exec", lambda self: opened.append(self.diagnosis_id))
|
||||
present_ai_consult(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
None,
|
||||
diagnosis_id=0,
|
||||
)
|
||||
assert opened == []
|
||||
present_ai_consult(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
None,
|
||||
diagnosis_id=501,
|
||||
seed={"patient_name": "杨永"},
|
||||
)
|
||||
assert opened == [501]
|
||||
|
||||
|
||||
def test_four_entry_points_expose_ai_consult_action(application: QApplication) -> None:
|
||||
repository = DemoDoctorRepository()
|
||||
allowed = PermissionSet(["*", "tcm.diagnosis/aiAssistant"])
|
||||
assert can_open_ai_consult(allowed)
|
||||
|
||||
patients = PatientListWorkspace(repository, allowed)
|
||||
patients.show()
|
||||
application.processEvents()
|
||||
assert patients.ai_consult_button.text() == "AI 分析"
|
||||
assert not patients.ai_consult_button.isHidden()
|
||||
|
||||
reception = ReceptionPage(repository, allowed)
|
||||
reception.show()
|
||||
application.processEvents()
|
||||
menu_titles = [action.text() for action in reception.more_button.menu().actions()]
|
||||
assert "AI 分析" in menu_titles
|
||||
assert reception.ai_consult_button.text() == "AI 分析"
|
||||
|
||||
appointments = AppointmentsPage(repository, permissions=allowed)
|
||||
appointments.show()
|
||||
application.processEvents()
|
||||
assert appointments.toolbar_ai_consult_button.text() == "AI 分析"
|
||||
assert not appointments.toolbar_ai_consult_button.isHidden()
|
||||
|
||||
consultations = ConsultationsPage(repository, permissions=allowed)
|
||||
assert consultations.table_host.action_policy.get("ai_consult") is True
|
||||
patients.close()
|
||||
reception.close()
|
||||
appointments.close()
|
||||
consultations.close()
|
||||
|
||||
|
||||
def test_ai_consult_sidebar_loads_patient_facts_and_reports(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog.open_for(diagnosis_id=501, patient_id=301, seed={"patient_name": "林晓岚"})
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
|
||||
values = {
|
||||
widget.text()
|
||||
for widget in dialog.findChildren(QLabel)
|
||||
if widget.objectName() == "AiConsultKeyValue"
|
||||
}
|
||||
assert "22.1" in values
|
||||
assert any("病程" in text or "3" in text for text in values)
|
||||
titles = {
|
||||
widget.text()
|
||||
for widget in dialog.findChildren(QLabel)
|
||||
if widget.objectName() == "AiConsultRecordTitle"
|
||||
}
|
||||
assert "血糖控制评估" in titles
|
||||
assert "并发症风险评估" in titles
|
||||
bodies = [
|
||||
widget.toPlainText()
|
||||
for widget in dialog.findChildren(QTextBrowser)
|
||||
if widget.objectName() == "AiConsultBubbleText"
|
||||
]
|
||||
assert any("病情与证候分析" in text for text in bodies)
|
||||
assert any("###" not in text for text in bodies if "病情与证候分析" in text)
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_ai_consult_sidebar_survives_chat_archive_errors(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
class BrokenChatRepository(DemoDoctorRepository):
|
||||
def list_im_chat_messages(self, diagnosis_id: int, *, only_archived: bool = True):
|
||||
raise RuntimeError("archive unavailable")
|
||||
|
||||
dialog = AiConsultDialog(
|
||||
BrokenChatRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog.open_for(diagnosis_id=501, patient_id=301)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
values = {
|
||||
widget.text()
|
||||
for widget in dialog.findChildren(QLabel)
|
||||
if widget.objectName() == "AiConsultKeyValue"
|
||||
}
|
||||
titles = {
|
||||
widget.text()
|
||||
for widget in dialog.findChildren(QLabel)
|
||||
if widget.objectName() == "AiConsultRecordTitle"
|
||||
}
|
||||
assert "22.1" in values
|
||||
assert "血糖控制评估" in titles
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_chat_payload_parses_markdown_html_and_json(application: QApplication) -> None:
|
||||
browser = QTextBrowser()
|
||||
render_chat_payload(browser, "### 病情摘要\n\n**核心病机**\n\n- 口干")
|
||||
assert "病情摘要" in browser.toPlainText()
|
||||
assert "核心病机" in browser.toPlainText()
|
||||
assert "###" not in browser.toPlainText()
|
||||
assert "<h3" in browser.toHtml().lower()
|
||||
|
||||
render_chat_payload(browser, "<p>空腹血糖 <strong>6.8</strong></p>")
|
||||
assert "空腹血糖" in browser.toPlainText()
|
||||
assert "6.8" in browser.toPlainText()
|
||||
|
||||
render_chat_payload(browser, '{"diagnosis":"肝郁脾虚证","risk":["血糖波动"]}')
|
||||
assert "肝郁脾虚证" in browser.toPlainText()
|
||||
browser.deleteLater()
|
||||
|
||||
|
||||
def test_stream_chunks_update_one_ai_bubble_before_done_and_preserve_order(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog.show()
|
||||
dialog._stream_bubble = dialog._append_bubble("ai", "")
|
||||
bubble = dialog._stream_bubble
|
||||
generation = dialog._generation
|
||||
stream_generation = dialog._stream_generation
|
||||
|
||||
dialog._stream_event(
|
||||
generation,
|
||||
stream_generation,
|
||||
{"event": "delta", "text": "第一段"},
|
||||
)
|
||||
dialog._flush_timer.stop()
|
||||
dialog._flush_stream_chunks()
|
||||
application.processEvents()
|
||||
assert bubble is not None and bubble.body is not None
|
||||
assert bubble.body.toPlainText() == "第一段"
|
||||
ai_bubble_count = len(
|
||||
[frame for frame in dialog.findChildren(ai_consult_module.QFrame) if frame.objectName() == "AiConsultBubbleAi"]
|
||||
)
|
||||
|
||||
dialog._stream_event(
|
||||
generation,
|
||||
stream_generation,
|
||||
{"event": "delta", "text": "第二段"},
|
||||
)
|
||||
dialog._stream_event(
|
||||
generation,
|
||||
stream_generation,
|
||||
{"event": "done", "model_label": "千问"},
|
||||
)
|
||||
application.processEvents()
|
||||
assert bubble.body.toPlainText() == "第一段第二段"
|
||||
assert len(
|
||||
[frame for frame in dialog.findChildren(ai_consult_module.QFrame) if frame.objectName() == "AiConsultBubbleAi"]
|
||||
) == ai_bubble_count
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_stream_error_and_cancelled_late_chunk_reuse_or_leave_current_bubble(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog.show()
|
||||
dialog._stream_bubble = dialog._append_bubble("ai", "")
|
||||
bubble = dialog._stream_bubble
|
||||
generation = dialog._generation
|
||||
stream_generation = dialog._stream_generation
|
||||
dialog._stream_event(
|
||||
generation,
|
||||
stream_generation,
|
||||
{"event": "delta", "text": "已生成"},
|
||||
)
|
||||
dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙"))
|
||||
application.processEvents()
|
||||
assert bubble is not None and bubble.body is not None
|
||||
assert "已生成" in bubble.body.toPlainText()
|
||||
assert "模型繁忙" in bubble.body.toPlainText()
|
||||
|
||||
before_cancel = bubble.body.toPlainText()
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
dialog._stream_event(
|
||||
generation,
|
||||
stream_generation,
|
||||
{"event": "delta", "text": "迟到内容"},
|
||||
)
|
||||
application.processEvents()
|
||||
assert bubble.body.toPlainText() == before_cancel
|
||||
|
||||
|
||||
def test_chat_scroll_follows_bottom_but_respects_user_scroll_and_send_restores_it(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog.diagnosis_id = 501
|
||||
dialog.show()
|
||||
for index in range(28):
|
||||
dialog._append_bubble("ai", f"历史消息 {index}:" + "辨证内容" * 16)
|
||||
application.processEvents()
|
||||
bar = dialog.chat_scroll.verticalScrollBar()
|
||||
bar.setValue(bar.maximum())
|
||||
application.processEvents()
|
||||
assert dialog._follow_chat
|
||||
|
||||
bar.setValue(max(0, bar.maximum() // 3))
|
||||
application.processEvents()
|
||||
reading_position = bar.value()
|
||||
assert not dialog._follow_chat
|
||||
dialog._append_bubble("ai", "新的流式内容" * 20)
|
||||
application.processEvents()
|
||||
assert bar.value() == reading_position
|
||||
|
||||
dialog._ask("请继续分析")
|
||||
application.processEvents()
|
||||
assert dialog._follow_chat
|
||||
assert bar.value() == bar.maximum()
|
||||
dialog.close()
|
||||
@@ -0,0 +1,866 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QTextBrowser,
|
||||
QTextEdit,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module
|
||||
from doctor_workstation.ui.dialogs import prescription as prescription_module
|
||||
from doctor_workstation.ui.dialogs.ai_consult import AiConsultDialog
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def run_immediately(
|
||||
function: Callable[..., Any],
|
||||
*args: Any,
|
||||
on_success: Callable[[Any], Any] | None = None,
|
||||
on_error: Callable[[Exception], Any] | None = None,
|
||||
on_finished: Callable[[], Any] | None = None,
|
||||
**_kwargs: Any,
|
||||
) -> object:
|
||||
try:
|
||||
result = function(*args)
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(ai_consult_module, "run_async", run_immediately)
|
||||
|
||||
|
||||
class DeferredAsync:
|
||||
def __init__(self) -> None:
|
||||
self.pending: list[dict[str, Any]] = []
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
function: Callable[..., Any],
|
||||
*args: Any,
|
||||
on_success: Callable[[Any], Any] | None = None,
|
||||
on_error: Callable[[Exception], Any] | None = None,
|
||||
on_finished: Callable[[], Any] | None = None,
|
||||
**_kwargs: Any,
|
||||
) -> object:
|
||||
self.pending.append(
|
||||
{
|
||||
"function": function,
|
||||
"args": args,
|
||||
"on_success": on_success,
|
||||
"on_error": on_error,
|
||||
"on_finished": on_finished,
|
||||
}
|
||||
)
|
||||
return object()
|
||||
|
||||
def complete(self, index: int) -> None:
|
||||
pending = self.pending[index]
|
||||
try:
|
||||
result = pending["function"](*pending["args"])
|
||||
except Exception as error:
|
||||
if pending["on_error"]:
|
||||
pending["on_error"](error)
|
||||
else:
|
||||
if pending["on_success"]:
|
||||
pending["on_success"](result)
|
||||
finally:
|
||||
if pending["on_finished"]:
|
||||
pending["on_finished"]()
|
||||
|
||||
|
||||
def _detail(diagnosis_id: int, marker: str) -> dict[str, Any]:
|
||||
diagnosis = {
|
||||
"id": diagnosis_id,
|
||||
"patient_id": diagnosis_id + 1000,
|
||||
"patient_name": f"{marker}患者",
|
||||
"phone": "13800138000",
|
||||
"id_card": "110105199203071234",
|
||||
"gender": 0,
|
||||
"age": 34,
|
||||
"region": f"{marker}杭州",
|
||||
"address": f"{marker}健康路 8 号",
|
||||
"height": 162,
|
||||
"weight": 54.5,
|
||||
"bmi": 20.8,
|
||||
"systolic_pressure": 146,
|
||||
"diastolic_pressure": 92,
|
||||
"fasting_blood_sugar": 8.2,
|
||||
"chief_complaint": f"{marker}主诉口渴乏力",
|
||||
"present_illness": f"{marker}现病史半年血糖波动",
|
||||
"past_history": f"{marker}既往高血压五年",
|
||||
"allergy_history": f"{marker}青霉素过敏",
|
||||
"family_history": f"{marker}父亲糖尿病",
|
||||
"clinical_diagnosis": f"{marker}气阴两虚",
|
||||
"diabetes_discovery_year": 6,
|
||||
"current_medications": [f"{marker}二甲双胍", "阿卡波糖"],
|
||||
"smoking": "不吸烟",
|
||||
"sleep_condition": [f"{marker}易醒", "多梦"],
|
||||
"local_hospital_diagnosis": [f"{marker}2 型糖尿病", "高血压"],
|
||||
"diet_condition": [f"{marker}偏甜", "夜宵"],
|
||||
"body_feeling": [f"{marker}乏力", "四肢沉重"],
|
||||
"tongue": f"{marker}舌淡红",
|
||||
"tongue_coating": f"{marker}苔薄白",
|
||||
"pulse": f"{marker}脉细",
|
||||
"remark": f"{marker}继续监测",
|
||||
"latest_prescription_order": {
|
||||
"id": f"{marker}-RX-09",
|
||||
"status_text": "待配药",
|
||||
},
|
||||
}
|
||||
for index in range(12):
|
||||
diagnosis[f"custom_field_{index}"] = f"{marker}扩展病历字段 {index}"
|
||||
return {
|
||||
"diagnosis": diagnosis,
|
||||
"patient": {
|
||||
"id": diagnosis_id + 1000,
|
||||
"patient_name": f"{marker}患者",
|
||||
"phone": "13800138000",
|
||||
"id_card": "110105199203071234",
|
||||
"gender": 0,
|
||||
"age": 34,
|
||||
"region": f"{marker}杭州",
|
||||
"address": f"{marker}健康路 8 号",
|
||||
},
|
||||
"appointment": {"doctor_name": f"{marker}陈医生"},
|
||||
}
|
||||
|
||||
|
||||
class WorkspaceRepository:
|
||||
def __init__(self, *, include_foreign_rows: bool = True) -> None:
|
||||
self.details = {501: _detail(501, "甲"), 502: _detail(502, "乙")}
|
||||
self.include_foreign_rows = include_foreign_rows
|
||||
self.failures: set[tuple[str, int]] = set()
|
||||
self.calls: list[tuple[str, int]] = []
|
||||
self.prescription_detail_calls: list[int] = []
|
||||
self.prescription_overrides: dict[int, dict[str, Any]] = {}
|
||||
self.report_payload: Any = []
|
||||
|
||||
def _check(self, name: str, diagnosis_id: int) -> None:
|
||||
self.calls.append((name, diagnosis_id))
|
||||
if (name, diagnosis_id) in self.failures:
|
||||
raise RuntimeError(f"{name} 暂时不可用")
|
||||
|
||||
def get_diagnosis_detail(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
*,
|
||||
readonly: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
del readonly
|
||||
self._check("get_diagnosis_detail", diagnosis_id)
|
||||
return self.details[diagnosis_id]
|
||||
|
||||
def list_im_chat_messages(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
*,
|
||||
only_archived: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
del only_archived
|
||||
self._check("list_im_chat_messages", diagnosis_id)
|
||||
return []
|
||||
|
||||
def list_patient_ai_reports(self, patient_id: int) -> Any:
|
||||
self.calls.append(("list_patient_ai_reports", patient_id))
|
||||
return self.report_payload
|
||||
|
||||
def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
|
||||
self._check("get_doctor_notes", diagnosis_id)
|
||||
marker = "甲" if diagnosis_id == 501 else "乙"
|
||||
rows = [
|
||||
{
|
||||
"id": diagnosis_id * 10 + 1,
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"create_time": "2026-08-18 09:20",
|
||||
"content": f"{marker}医生检查记录",
|
||||
"tongue_images": [
|
||||
{
|
||||
"name": f"{marker}舌苔照片.jpg",
|
||||
"url": f"https://media.example.invalid/{marker}/tongue.jpg",
|
||||
}
|
||||
],
|
||||
"report_files": [
|
||||
{
|
||||
"name": f"{marker}血糖报告.pdf",
|
||||
"url": f"https://media.example.invalid/{marker}/report.pdf",
|
||||
},
|
||||
{
|
||||
"name": f"{marker}本地危险附件.pdf",
|
||||
"url": "file:///C:/private/unsafe.pdf",
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
if self.include_foreign_rows:
|
||||
rows.append(
|
||||
{
|
||||
"id": 99901,
|
||||
"diagnosis_id": 999,
|
||||
"content": "错误诊单附件哨兵",
|
||||
"tongue_images": [
|
||||
{
|
||||
"name": "错误诊单舌苔.jpg",
|
||||
"url": "https://media.example.invalid/wrong.jpg",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
def get_tracking_window(self, diagnosis_id: int) -> dict[str, Any]:
|
||||
self._check("get_tracking_window", diagnosis_id)
|
||||
marker = "甲" if diagnosis_id == 501 else "乙"
|
||||
blood_records = [
|
||||
{
|
||||
"id": diagnosis_id * 10 + 2,
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"record_date": "2026-08-18",
|
||||
"fasting_blood_sugar": f"{marker}8.2",
|
||||
"postprandial_blood_sugar": f"{marker}12.4",
|
||||
"systolic_pressure": f"{marker}146",
|
||||
"diastolic_pressure": f"{marker}92",
|
||||
}
|
||||
]
|
||||
if self.include_foreign_rows:
|
||||
blood_records.append(
|
||||
{
|
||||
"id": 99902,
|
||||
"diagnosis_id": 999,
|
||||
"record_date": "2026-08-18",
|
||||
"fasting_blood_sugar": "错误诊单血糖 19.9",
|
||||
}
|
||||
)
|
||||
return {
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"blood_records": blood_records,
|
||||
"diet_records": [
|
||||
{
|
||||
"id": diagnosis_id * 10 + 3,
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"record_date": "2026-08-18",
|
||||
"breakfast_foods": f"{marker}燕麦鸡蛋",
|
||||
"lunch_foods": f"{marker}杂粮饭",
|
||||
}
|
||||
],
|
||||
"exercise_records": [
|
||||
{
|
||||
"id": diagnosis_id * 10 + 4,
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"record_date": "2026-08-17",
|
||||
"exercise_type": f"{marker}散步",
|
||||
"duration": 35,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def list_prescriptions_by_diagnosis(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
self._check("list_prescriptions_by_diagnosis", diagnosis_id)
|
||||
marker = "甲" if diagnosis_id == 501 else "乙"
|
||||
return [
|
||||
{
|
||||
"id": diagnosis_id * 10 + index,
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"sn": f"{marker}-RX-{index}",
|
||||
"prescription_date": f"2026-08-{10 + index}",
|
||||
"prescription_summary": f"{marker}方剂 {index}",
|
||||
"doctor_name": f"{marker}陈医生",
|
||||
"status_text": "已审核",
|
||||
"herbs": [{"name": f"{marker}黄芪", "dosage": index * 5, "unit": "g"}],
|
||||
}
|
||||
for index in range(1, 4)
|
||||
]
|
||||
|
||||
def get_prescription(self, prescription_id: int) -> dict[str, Any]:
|
||||
self.prescription_detail_calls.append(prescription_id)
|
||||
if prescription_id in self.prescription_overrides:
|
||||
return self.prescription_overrides[prescription_id]
|
||||
diagnosis_id = prescription_id // 10
|
||||
return {
|
||||
"id": prescription_id,
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"sn": f"FULL-{prescription_id}",
|
||||
"clinical_diagnosis": "气阴两虚",
|
||||
"herbs": [{"name": "黄芪", "dosage": 15, "unit": "g"}],
|
||||
}
|
||||
|
||||
|
||||
def _permissions() -> PermissionSet:
|
||||
return PermissionSet(["tcm.diagnosis/aiAssistant", "cf.prescription/read"])
|
||||
|
||||
|
||||
def _pane_text(widget: QWidget) -> str:
|
||||
parts = [child.text() for child in widget.findChildren(QLabel)]
|
||||
parts.extend(child.text() for child in widget.findChildren(QPushButton))
|
||||
parts.extend(child.text() for child in widget.findChildren(QLineEdit))
|
||||
parts.extend(child.toPlainText() for child in widget.findChildren(QTextBrowser))
|
||||
parts.extend(child.toPlainText() for child in widget.findChildren(QTextEdit))
|
||||
return "\n".join(part for part in parts if part)
|
||||
|
||||
|
||||
def _open_dialog(
|
||||
application: QApplication,
|
||||
repository: WorkspaceRepository,
|
||||
diagnosis_id: int = 501,
|
||||
) -> AiConsultDialog:
|
||||
dialog = AiConsultDialog(repository, _permissions())
|
||||
dialog.open_for(diagnosis_id=diagnosis_id, patient_id=diagnosis_id + 1000)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
return dialog
|
||||
|
||||
|
||||
def test_case_tab_renders_complete_owned_detail_as_readable_chinese(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
dialog = _open_dialog(application, WorkspaceRepository())
|
||||
pane = dialog.records["病历资料"]
|
||||
dialog.tabs.setCurrentIndex(1)
|
||||
application.processEvents()
|
||||
|
||||
assert pane.findChild(QWidget, "AiConsultCaseGrid") is not None
|
||||
text = _pane_text(pane)
|
||||
for sentinel in (
|
||||
"甲主诉口渴乏力",
|
||||
"甲现病史半年血糖波动",
|
||||
"甲既往高血压五年",
|
||||
"甲青霉素过敏",
|
||||
"甲父亲糖尿病",
|
||||
"甲气阴两虚",
|
||||
"甲2 型糖尿病",
|
||||
"高血压",
|
||||
"甲偏甜",
|
||||
"夜宵",
|
||||
"甲-RX-09",
|
||||
"待配药",
|
||||
):
|
||||
assert sentinel in text
|
||||
assert "['" not in text
|
||||
assert "{'" not in text
|
||||
|
||||
scroll = pane.findChild(QScrollArea)
|
||||
assert scroll is not None and scroll.widgetResizable()
|
||||
assert pane.geometry().isValid() and scroll.viewport().geometry().isValid()
|
||||
assert scroll.verticalScrollBar().maximum() > 0
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_all_four_record_tabs_use_the_selected_diagnosis_id(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
repository = WorkspaceRepository(include_foreign_rows=False)
|
||||
dialog = _open_dialog(application, repository, diagnosis_id=501)
|
||||
|
||||
for method in (
|
||||
"get_diagnosis_detail",
|
||||
"get_doctor_notes",
|
||||
"get_tracking_window",
|
||||
"list_prescriptions_by_diagnosis",
|
||||
):
|
||||
assert (method, 501) in repository.calls
|
||||
assert all(
|
||||
called_id == 501
|
||||
for called_method, called_id in repository.calls
|
||||
if called_method == method
|
||||
)
|
||||
assert ("list_patient_ai_reports", 1501) in repository.calls
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_seed_cannot_replace_the_authoritative_patient_id(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
deferred = DeferredAsync()
|
||||
monkeypatch.setattr(ai_consult_module, "run_async", deferred)
|
||||
repository = WorkspaceRepository(include_foreign_rows=False)
|
||||
dialog = AiConsultDialog(repository, _permissions())
|
||||
|
||||
dialog.open_for(
|
||||
diagnosis_id=501,
|
||||
patient_id=1501,
|
||||
seed={"patient_id": 501, "patient_name": "错误种子"},
|
||||
)
|
||||
|
||||
assert dialog.patient_id == 1501
|
||||
deferred.complete(0)
|
||||
application.processEvents()
|
||||
assert dialog.patient_id == 1501
|
||||
assert ("list_patient_ai_reports", 1501) in repository.calls
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_detail_failure_or_wrong_owner_never_requests_patient_reports(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
failed_repository = WorkspaceRepository(include_foreign_rows=False)
|
||||
failed_repository.failures.add(("get_diagnosis_detail", 501))
|
||||
failed = _open_dialog(application, failed_repository)
|
||||
assert all(
|
||||
method != "list_patient_ai_reports"
|
||||
for method, _owner in failed_repository.calls
|
||||
)
|
||||
failed.close()
|
||||
|
||||
wrong_repository = WorkspaceRepository(include_foreign_rows=False)
|
||||
wrong_repository.details[501] = _detail(999, "越权")
|
||||
wrong = _open_dialog(application, wrong_repository)
|
||||
assert all(
|
||||
method != "list_patient_ai_reports"
|
||||
for method, _owner in wrong_repository.calls
|
||||
)
|
||||
wrong.close()
|
||||
|
||||
|
||||
def test_patient_report_response_owner_must_match_exactly(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
repository = WorkspaceRepository(include_foreign_rows=False)
|
||||
repository.report_payload = {
|
||||
"patient_id": "1501",
|
||||
"reports": [
|
||||
{
|
||||
"patient_id": 1501,
|
||||
"report": {"diagnosis": "不应显示的越权报告"},
|
||||
}
|
||||
],
|
||||
}
|
||||
dialog = _open_dialog(application, repository)
|
||||
|
||||
assert ("list_patient_ai_reports", 1501) in repository.calls
|
||||
assert "不应显示的越权报告" not in _pane_text(dialog)
|
||||
assert not ai_consult_module._report_response_matches_patient(
|
||||
repository.report_payload,
|
||||
1501,
|
||||
)
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_exam_tab_filters_foreign_attachments_and_blocks_file_urls(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
opened: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
ai_consult_module,
|
||||
"open_safe_http_url",
|
||||
lambda target: opened.append(target) or True,
|
||||
)
|
||||
dialog = _open_dialog(application, WorkspaceRepository())
|
||||
pane = dialog.records["检查检验"]
|
||||
dialog.tabs.setCurrentIndex(2)
|
||||
application.processEvents()
|
||||
|
||||
assert pane.findChild(QWidget, "AiConsultExamTimeline") is not None
|
||||
text = _pane_text(pane)
|
||||
assert "甲舌苔照片.jpg" in text
|
||||
assert "甲血糖报告.pdf" in text
|
||||
assert "甲本地危险附件.pdf" in text
|
||||
assert "错误诊单附件哨兵" not in text
|
||||
assert "错误诊单舌苔.jpg" not in text
|
||||
|
||||
buttons = pane.findChildren(QPushButton, "AiConsultMediaOpen")
|
||||
assert len(buttons) == 3
|
||||
thumbnails = pane.findChildren(QPushButton, "AiConsultTongueThumb")
|
||||
assert len(thumbnails) == 1
|
||||
assert thumbnails[0].isEnabled()
|
||||
assert thumbnails[0].accessibleName() == "舌苔图片点击查看"
|
||||
assert thumbnails[0].property("loadState") == "blocked"
|
||||
assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined]
|
||||
assert pane.retry_button.isHidden() # type: ignore[attr-defined]
|
||||
unsafe = next(button for button in buttons if "本地危险附件" in button.text())
|
||||
assert not unsafe.isEnabled()
|
||||
for button in buttons:
|
||||
button.click()
|
||||
assert len(opened) == 2
|
||||
assert all(target.startswith(("http://", "https://")) for target in opened)
|
||||
assert all(not target.startswith("file:") for target in opened)
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_tongue_thumbnail_auto_get_requires_configured_https_origin(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
requested: list[str] = []
|
||||
|
||||
class RecordingRemoteImageButton(QPushButton):
|
||||
def __init__(self, source: str, **kwargs: Any) -> None:
|
||||
super().__init__(kwargs.get("parent"))
|
||||
requested.append(source)
|
||||
self.setObjectName(str(kwargs.get("object_name") or ""))
|
||||
self.setAccessibleName(
|
||||
str(kwargs.get("fallback_text") or "").replace("\n", "")
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
ai_consult_module,
|
||||
"_RemoteImageButton",
|
||||
RecordingRemoteImageButton,
|
||||
)
|
||||
|
||||
untrusted = _open_dialog(application, WorkspaceRepository())
|
||||
assert requested == []
|
||||
untrusted.close()
|
||||
|
||||
trusted_repository = WorkspaceRepository()
|
||||
trusted_repository.trusted_media_domains = ["media.example.invalid"]
|
||||
assert not ai_consult_module._trusted_thumbnail_url(
|
||||
trusted_repository,
|
||||
"http://media.example.invalid/甲/tongue.jpg",
|
||||
)
|
||||
assert not ai_consult_module._trusted_thumbnail_url(
|
||||
trusted_repository,
|
||||
"https://sub.media.example.invalid/甲/tongue.jpg",
|
||||
)
|
||||
trusted = _open_dialog(application, trusted_repository)
|
||||
assert requested == ["https://media.example.invalid/甲/tongue.jpg"]
|
||||
trusted.close()
|
||||
|
||||
|
||||
def test_three_prescription_cards_open_exact_details_and_reject_wrong_or_late_ids(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = WorkspaceRepository(include_foreign_rows=False)
|
||||
opened: list[int] = []
|
||||
|
||||
class FakePrescriptionDetailDialog:
|
||||
def __init__(self, prescription: Any, **_kwargs: Any) -> None:
|
||||
self.prescription = prescription
|
||||
|
||||
def exec(self) -> None:
|
||||
opened.append(int(self.prescription["id"]))
|
||||
|
||||
monkeypatch.setattr(
|
||||
prescription_module,
|
||||
"PrescriptionDetailDialog",
|
||||
FakePrescriptionDetailDialog,
|
||||
)
|
||||
dialog = _open_dialog(application, repository)
|
||||
pane = dialog.records["处方记录"]
|
||||
dialog.tabs.setCurrentIndex(3)
|
||||
application.processEvents()
|
||||
|
||||
cards = pane.findChildren(QWidget, "AiConsultPrescriptionCard")
|
||||
buttons = sorted(
|
||||
pane.findChildren(QPushButton, "AiConsultPrescriptionOpen"),
|
||||
key=lambda button: int(button.property("prescriptionId")),
|
||||
)
|
||||
expected_ids = [5011, 5012, 5013]
|
||||
assert len(cards) == len(buttons) == 3
|
||||
assert [int(button.property("prescriptionId")) for button in buttons] == expected_ids
|
||||
assert all(button.text() == "查看详情" for button in buttons)
|
||||
for button in buttons:
|
||||
button.click()
|
||||
assert repository.prescription_detail_calls == expected_ids
|
||||
assert opened == expected_ids
|
||||
|
||||
repository.prescription_overrides[5011] = {
|
||||
"id": 9999,
|
||||
"diagnosis_id": 501,
|
||||
}
|
||||
buttons[0].click()
|
||||
assert repository.prescription_detail_calls[-1] == 5011
|
||||
assert opened == expected_ids
|
||||
repository.prescription_overrides.pop(5011)
|
||||
|
||||
repository.prescription_overrides[5011] = {"id": 5011}
|
||||
buttons[0].click()
|
||||
assert repository.prescription_detail_calls[-1] == 5011
|
||||
assert opened == expected_ids
|
||||
repository.prescription_overrides.pop(5011)
|
||||
|
||||
calls_before_unowned_source = list(repository.prescription_detail_calls)
|
||||
dialog._open_prescription_detail({"id": 5011})
|
||||
assert repository.prescription_detail_calls == calls_before_unowned_source
|
||||
|
||||
deferred = DeferredAsync()
|
||||
monkeypatch.setattr(ai_consult_module, "run_async", deferred)
|
||||
buttons[0].click()
|
||||
buttons[1].click()
|
||||
assert len(deferred.pending) == 2
|
||||
deferred.complete(1)
|
||||
application.processEvents()
|
||||
deferred.complete(0)
|
||||
application.processEvents()
|
||||
assert repository.prescription_detail_calls[-2:] == [5012, 5011]
|
||||
assert opened == [*expected_ids, 5012]
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_health_tab_masks_sensitive_patient_data_and_renders_tracking_window(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
dialog = _open_dialog(application, WorkspaceRepository())
|
||||
pane = dialog.records["健康档案"]
|
||||
dialog.tabs.setCurrentIndex(4)
|
||||
application.processEvents()
|
||||
|
||||
assert pane.findChild(QWidget, "AiConsultHealthGrid") is not None
|
||||
text = _pane_text(pane)
|
||||
for sentinel in (
|
||||
"甲患者",
|
||||
"甲杭州",
|
||||
"甲健康路 8 号",
|
||||
"138****8000",
|
||||
"110***********1234",
|
||||
"甲8.2",
|
||||
"甲12.4",
|
||||
"甲146",
|
||||
"甲92",
|
||||
"甲燕麦鸡蛋",
|
||||
"甲杂粮饭",
|
||||
"甲散步",
|
||||
"35",
|
||||
"甲气阴两虚",
|
||||
"甲二甲双胍",
|
||||
"阿卡波糖",
|
||||
"不吸烟",
|
||||
"甲易醒",
|
||||
"多梦",
|
||||
):
|
||||
assert sentinel in text
|
||||
assert pane.findChild(QWidget, "AiConsultDiagnosisHealthSummary") is not None
|
||||
assert "13800138000" not in text
|
||||
assert "110105199203071234" not in text
|
||||
assert "错误诊单血糖 19.9" not in text
|
||||
assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined]
|
||||
assert pane.retry_button.isHidden() # type: ignore[attr-defined]
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_ownerless_notes_prescriptions_and_tracking_rows_fail_closed_as_warning(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
class OwnerlessRepository(WorkspaceRepository):
|
||||
def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
|
||||
rows = super().get_doctor_notes(diagnosis_id)
|
||||
for row in rows:
|
||||
row.pop("diagnosis_id", None)
|
||||
return rows
|
||||
|
||||
def list_prescriptions_by_diagnosis(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = super().list_prescriptions_by_diagnosis(diagnosis_id)
|
||||
for row in rows:
|
||||
row.pop("diagnosis_id", None)
|
||||
return rows
|
||||
|
||||
def get_tracking_window(self, diagnosis_id: int) -> dict[str, Any]:
|
||||
result = super().get_tracking_window(diagnosis_id)
|
||||
for key in ("blood_records", "diet_records", "exercise_records"):
|
||||
for row in result[key]:
|
||||
row.pop("diagnosis_id", None)
|
||||
return result
|
||||
|
||||
dialog = _open_dialog(
|
||||
application,
|
||||
OwnerlessRepository(include_foreign_rows=False),
|
||||
)
|
||||
exam = dialog.records["检查检验"]
|
||||
prescriptions = dialog.records["处方记录"]
|
||||
health = dialog.records["健康档案"]
|
||||
|
||||
assert "甲医生检查记录" not in _pane_text(exam)
|
||||
assert not prescriptions.findChildren(QWidget, "AiConsultPrescriptionCard")
|
||||
assert "甲燕麦鸡蛋" not in _pane_text(health)
|
||||
for pane in (exam, prescriptions, health):
|
||||
assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined]
|
||||
assert pane.retry_button.isHidden() # type: ignore[attr-defined]
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_tracking_response_without_diagnosis_owner_is_filtered_without_retry(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
class OwnerlessTrackingRepository(WorkspaceRepository):
|
||||
def get_tracking_window(self, diagnosis_id: int) -> dict[str, Any]:
|
||||
result = super().get_tracking_window(diagnosis_id)
|
||||
result.pop("diagnosis_id", None)
|
||||
return result
|
||||
|
||||
dialog = _open_dialog(
|
||||
application,
|
||||
OwnerlessTrackingRepository(include_foreign_rows=False),
|
||||
)
|
||||
pane = dialog.records["健康档案"]
|
||||
|
||||
assert "甲燕麦鸡蛋" not in _pane_text(pane)
|
||||
assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined]
|
||||
assert pane.retry_button.isHidden() # type: ignore[attr-defined]
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_late_workspace_a_response_cannot_pollute_selected_workspace_b(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = WorkspaceRepository(include_foreign_rows=False)
|
||||
deferred = DeferredAsync()
|
||||
monkeypatch.setattr(ai_consult_module, "run_async", deferred)
|
||||
dialog = AiConsultDialog(repository, _permissions())
|
||||
dialog.open_for(diagnosis_id=501, patient_id=1501)
|
||||
dialog.open_for(diagnosis_id=502, patient_id=1502)
|
||||
dialog.show()
|
||||
assert len(deferred.pending) == 2
|
||||
|
||||
deferred.complete(1)
|
||||
application.processEvents()
|
||||
deferred.complete(0)
|
||||
application.processEvents()
|
||||
for title in ("病历资料", "检查检验", "处方记录", "健康档案"):
|
||||
text = _pane_text(dialog.records[title])
|
||||
assert "乙" in text
|
||||
assert "甲主诉口渴乏力" not in text
|
||||
assert "甲医生检查记录" not in text
|
||||
assert "甲-RX-1" not in text
|
||||
assert "甲燕麦鸡蛋" not in text
|
||||
assert dialog.diagnosis_id == 502
|
||||
assert dialog._detail["diagnosis"]["id"] == 502
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_mismatched_detail_owner_fails_closed_across_all_record_tabs(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
repository = WorkspaceRepository()
|
||||
repository.details[501] = _detail(999, "越权")
|
||||
dialog = _open_dialog(application, repository)
|
||||
|
||||
forbidden = (
|
||||
"越权主诉口渴乏力",
|
||||
"甲医生检查记录",
|
||||
"甲舌苔照片.jpg",
|
||||
"甲-RX-1",
|
||||
"甲燕麦鸡蛋",
|
||||
)
|
||||
for title in ("病历资料", "检查检验", "处方记录", "健康档案"):
|
||||
pane = dialog.records[title]
|
||||
text = _pane_text(pane)
|
||||
assert all(sentinel not in text for sentinel in forbidden)
|
||||
state = pane.state_label # type: ignore[attr-defined]
|
||||
retry = pane.retry_button # type: ignore[attr-defined]
|
||||
assert state.objectName() == "AiConsultRecordState"
|
||||
assert retry.objectName() == "AiConsultRecordRetry"
|
||||
assert state is not None and state.property("state") == "warning"
|
||||
assert retry is not None and retry.isHidden()
|
||||
assert all(
|
||||
method != "list_patient_ai_reports" for method, _owner in repository.calls
|
||||
)
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "error_tabs", "success_sentinels"),
|
||||
[
|
||||
(
|
||||
"get_diagnosis_detail",
|
||||
{"病历资料", "健康档案"},
|
||||
{"检查检验": "甲医生检查记录", "处方记录": "甲-RX-1"},
|
||||
),
|
||||
(
|
||||
"get_doctor_notes",
|
||||
{"检查检验"},
|
||||
{
|
||||
"病历资料": "甲主诉口渴乏力",
|
||||
"处方记录": "甲-RX-1",
|
||||
"健康档案": "甲燕麦鸡蛋",
|
||||
},
|
||||
),
|
||||
(
|
||||
"get_tracking_window",
|
||||
{"健康档案"},
|
||||
{
|
||||
"病历资料": "甲主诉口渴乏力",
|
||||
"检查检验": "甲医生检查记录",
|
||||
"处方记录": "甲-RX-1",
|
||||
},
|
||||
),
|
||||
(
|
||||
"list_prescriptions_by_diagnosis",
|
||||
{"处方记录"},
|
||||
{
|
||||
"病历资料": "甲主诉口渴乏力",
|
||||
"检查检验": "甲医生检查记录",
|
||||
"健康档案": "甲燕麦鸡蛋",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_one_failed_source_has_local_error_retry_and_preserves_other_sections(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
method: str,
|
||||
error_tabs: set[str],
|
||||
success_sentinels: dict[str, str],
|
||||
) -> None:
|
||||
repository = WorkspaceRepository(include_foreign_rows=False)
|
||||
repository.failures.add((method, 501))
|
||||
dialog = _open_dialog(application, repository)
|
||||
|
||||
for title in error_tabs:
|
||||
pane = dialog.records[title]
|
||||
state = pane.state_label # type: ignore[attr-defined]
|
||||
retry = pane.retry_button # type: ignore[attr-defined]
|
||||
assert state.objectName() == "AiConsultRecordState"
|
||||
assert retry.objectName() == "AiConsultRecordRetry"
|
||||
assert state is not None and state.property("state") == "error"
|
||||
assert retry is not None and not retry.isHidden() and retry.isEnabled()
|
||||
for title, sentinel in success_sentinels.items():
|
||||
assert sentinel in _pane_text(dialog.records[title])
|
||||
state = dialog.records[title].state_label # type: ignore[attr-defined]
|
||||
assert state is not None and state.property("state") != "error", (
|
||||
title,
|
||||
state.text(),
|
||||
state.property("state"),
|
||||
)
|
||||
|
||||
repository.failures.clear()
|
||||
dialog.records[next(iter(error_tabs))].retry_button.click() # type: ignore[attr-defined]
|
||||
application.processEvents()
|
||||
for pane in dialog.records.values():
|
||||
state = pane.state_label # type: ignore[attr-defined]
|
||||
assert state is not None and state.property("state") != "error"
|
||||
dialog.close()
|
||||
@@ -74,6 +74,52 @@ def test_post_uses_json_and_never_retries_timeout() -> None:
|
||||
assert caught.value.data["attempts"] == 1
|
||||
|
||||
|
||||
def test_post_event_stream_sends_exact_contract_and_preserves_event_order() -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
content = (
|
||||
'event: start\ndata: {"model_key":"qwen"}\n\n'
|
||||
'event: delta\ndata: {"content":"辨"}\n\n'
|
||||
'event: delta\ndata: {"content":"证"}\n\n'
|
||||
'event: done\ndata: {"model_label":"千问"}\n\n'
|
||||
)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream; charset=utf-8"},
|
||||
text=content,
|
||||
)
|
||||
|
||||
client = ApiClient(
|
||||
"https://example.test",
|
||||
token="stream-token",
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
events = list(
|
||||
client.post_event_stream(
|
||||
"tcm.diagnosis/aiAssistantStream",
|
||||
{"id": 501, "task": "custom", "prompt": "如何辨证?"},
|
||||
)
|
||||
)
|
||||
client.close()
|
||||
|
||||
assert [event["event"] for event in events] == ["start", "delta", "delta", "done"]
|
||||
assert [event["data"] for event in events[1:3]] == [
|
||||
{"content": "辨"},
|
||||
{"content": "证"},
|
||||
]
|
||||
request = requests[0]
|
||||
assert request.headers["accept"] == "text/event-stream"
|
||||
assert request.headers["token"] == "stream-token"
|
||||
assert str(request.url).endswith("/adminapi/tcm.diagnosis/aiAssistantStream")
|
||||
assert json.loads(request.content) == {
|
||||
"id": 501,
|
||||
"task": "custom",
|
||||
"prompt": "如何辨证?",
|
||||
}
|
||||
|
||||
|
||||
def test_multipart_post_lets_httpx_set_boundary_and_sends_form_fields(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox, QLabel
|
||||
from PySide6.QtWidgets import QAbstractItemView, QApplication, QDialog, QDialogButtonBox, QLabel
|
||||
|
||||
from doctor_workstation.core.errors import ApiProtocolError
|
||||
from doctor_workstation.core.models import Appointment, PageResult
|
||||
@@ -163,6 +164,8 @@ def test_diagnosis_and_patient_ids_stay_distinct_for_video() -> None:
|
||||
)
|
||||
assert _diagnosis_id(row) == 501
|
||||
assert _video_patient_id(row) == 301
|
||||
assert _video_patient_id({"diagnosis_id": 501, "patient_id": 301}) == 0
|
||||
assert _video_patient_id({"diagnosis_id": 501, "patient_id": 501}) == 0
|
||||
assert prescription_action_label(row) == "开方"
|
||||
|
||||
approved = Appointment.from_dict(
|
||||
@@ -354,7 +357,8 @@ def test_appointment_multiline_cells_receive_enough_row_height(
|
||||
assert appointment_text.count("\n") == 2
|
||||
assert "2026-08-11 14:30" in appointment_text
|
||||
required = 3 * max(16, page.table.fontMetrics().lineSpacing()) + 10
|
||||
assert page.table.rowHeight(0) >= required
|
||||
assert 60 <= page.table.rowHeight(0) <= 66
|
||||
assert page.table.rowHeight(0) >= min(required, 66)
|
||||
assert page.table.item(0, 4).toolTip() == appointment_text
|
||||
page.close()
|
||||
|
||||
@@ -513,6 +517,7 @@ def test_appointments_reference_split_layout_and_video_list(
|
||||
current_user={"id": 1001, "role_id": 1},
|
||||
)
|
||||
page.resize(1460, 820)
|
||||
page._apply_responsive_layout()
|
||||
page._loaded(
|
||||
{
|
||||
"lists": [
|
||||
@@ -538,8 +543,133 @@ def test_appointments_reference_split_layout_and_video_list(
|
||||
|
||||
assert page.video_list.count() == 1
|
||||
assert "赵俊霞" in page.video_list.item(0).text()
|
||||
assert page.video_list.parentWidget().width() == 420
|
||||
assert 300 <= page.video_panel.width() <= 420
|
||||
assert page.table.objectName() == "AppointmentTable"
|
||||
assert (
|
||||
page.table.verticalScrollMode()
|
||||
== QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
)
|
||||
assert (
|
||||
page.video_list.verticalScrollMode()
|
||||
== QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
)
|
||||
assert page.date_buttons["today"].isChecked()
|
||||
|
||||
page.resize(1024, 640)
|
||||
page._apply_responsive_layout()
|
||||
assert page.video_panel.isHidden()
|
||||
assert not page.video_panel_button.isHidden()
|
||||
assert not page.date_overflow_button.isHidden()
|
||||
assert page.date_buttons["yesterday"].isHidden()
|
||||
assert not page.date_buttons["today"].isHidden()
|
||||
page.video_panel_button.click()
|
||||
assert not page.video_panel.isHidden()
|
||||
assert 250 <= page.video_panel.width() <= 300
|
||||
page.video_panel_button.click()
|
||||
assert page.video_panel.isHidden()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_identical_appointment_poll_keeps_existing_cell_widgets(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
page = AppointmentsPage(
|
||||
DemoDoctorRepository(),
|
||||
permissions=PermissionSet(["doctor.appointment/lists"]),
|
||||
current_user={"id": 1001, "role_id": 1},
|
||||
)
|
||||
result = {
|
||||
"lists": [
|
||||
{
|
||||
"id": 101,
|
||||
"diagnosis_id": 501,
|
||||
"patient_id": 301,
|
||||
"patient_name": "赵俊霞",
|
||||
"gender": 2,
|
||||
"age": 53,
|
||||
"assistant_name": "周医助",
|
||||
"appointment_date": "2026-08-17",
|
||||
"appointment_time": "09:50",
|
||||
"status": 1,
|
||||
"status_desc": "已挂号",
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
"extend": {"status_count": {"1": 1}},
|
||||
}
|
||||
page._loaded(result, page._generation, True)
|
||||
selector = page.table.cellWidget(0, 0)
|
||||
appointment_info = page.table.cellWidget(0, 4)
|
||||
video_card = page.video_list.itemWidget(page.video_list.item(0))
|
||||
|
||||
page._loaded(deepcopy(result), page._generation, True)
|
||||
|
||||
assert page.table.cellWidget(0, 0) is selector
|
||||
assert page.table.cellWidget(0, 4) is appointment_info
|
||||
assert page.video_list.itemWidget(page.video_list.item(0)) is video_card
|
||||
|
||||
changed = deepcopy(result)
|
||||
changed["lists"][0]["assistant_name"] = "新医助"
|
||||
page._loaded(changed, page._generation, True)
|
||||
assert page.table.cellWidget(0, 0) is not selector
|
||||
assert page.table.cellWidget(0, 4) is not appointment_info
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_appointments_density_fits_four_rows_in_1366_shell_viewport(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(appointments_module, "run_async", lambda *_args, **_kwargs: object())
|
||||
page = AppointmentsPage(
|
||||
DemoDoctorRepository(),
|
||||
permissions=PermissionSet(["*"]),
|
||||
current_user={"id": 1001, "role_id": 1},
|
||||
)
|
||||
# 1366x768 shell minus its 179 px appointment rail, 26 px outer gutter,
|
||||
# and 62 px top bar leaves a 1161x680 page viewport.
|
||||
page.resize(1161, 680)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
rows = [
|
||||
{
|
||||
"id": 100 + index,
|
||||
"diagnosis_id": 500 + index,
|
||||
"patient_id": 300 + index,
|
||||
"patient_name": f"患者{index}",
|
||||
"gender": 2,
|
||||
"age": 40 + index,
|
||||
"doctor_name": "陈医生",
|
||||
"assistant_name": "周医助",
|
||||
"appointment_date": "2026-08-17",
|
||||
"appointment_time": f"{8 + index:02d}:00",
|
||||
"status": 1,
|
||||
"status_desc": "已挂号",
|
||||
"diagnosis_confirmed": 0,
|
||||
"has_prescription": 0,
|
||||
}
|
||||
for index in range(8)
|
||||
]
|
||||
page._loaded(
|
||||
{"lists": rows, "count": len(rows), "extend": {"status_count": {"1": 8}}},
|
||||
page._generation,
|
||||
False,
|
||||
)
|
||||
application.processEvents()
|
||||
|
||||
heights = [page.table.rowHeight(index) for index in range(page.table.rowCount())]
|
||||
assert page.header.height() == 26
|
||||
assert page.filter_panel.height() <= 84
|
||||
assert all(60 <= height <= 66 for height in heights)
|
||||
assert page.table.viewport().height() // max(heights) >= 4
|
||||
assert page.pager.isVisibleTo(page)
|
||||
assert 300 <= page.video_panel.width() < 420
|
||||
|
||||
page.resize(1024, 640)
|
||||
application.processEvents()
|
||||
assert page.video_panel.isHidden()
|
||||
assert not page.video_panel_button.isHidden()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
@@ -298,7 +298,7 @@ def test_action_visibility_requires_exact_canonical_permissions(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_refresh_generation_ignores_late_results(
|
||||
def test_refresh_generation_ignores_late_results(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -318,11 +318,81 @@ def test_refresh_generation_ignores_late_results(
|
||||
application.processEvents()
|
||||
|
||||
assert page.table.rowCount() == 1
|
||||
assert page.table.item(0, 0).text().startswith("902")
|
||||
page.close()
|
||||
|
||||
|
||||
def test_current_appointment_is_the_only_prescription_authority(
|
||||
assert page.table.item(0, 0).text().startswith("902")
|
||||
page.close()
|
||||
|
||||
|
||||
def test_identical_silent_refresh_has_zero_model_reset_and_fixed_widget_budget(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class Repository:
|
||||
calls = 0
|
||||
|
||||
def list_consultations(self, **_kwargs: Any) -> dict[str, Any]:
|
||||
self.calls += 1
|
||||
return {"lists": [_row()], "count": 1}
|
||||
|
||||
repository = Repository()
|
||||
page = ConsultationsPage(repository, permissions=PermissionSet(["*"]))
|
||||
page.refresh(silent=True)
|
||||
page.table.selectRow(0)
|
||||
model = page.table_host.model
|
||||
action_widget = page.table_host.fixed.indexWidget(model.index(0, 11))
|
||||
video_widget = page.table_host.fixed.indexWidget(model.index(0, 10))
|
||||
resets: list[str] = []
|
||||
model.modelAboutToBeReset.connect(lambda: resets.append("begin"))
|
||||
model.modelReset.connect(lambda: resets.append("end"))
|
||||
install_calls: list[None] = []
|
||||
original_install = page.table_host._install_fixed_widgets
|
||||
|
||||
def count_install() -> None:
|
||||
install_calls.append(None)
|
||||
original_install()
|
||||
|
||||
monkeypatch.setattr(page.table_host, "_install_fixed_widgets", count_install)
|
||||
page.refresh(silent=True)
|
||||
|
||||
assert repository.calls == 2
|
||||
assert resets == []
|
||||
assert install_calls == []
|
||||
assert page.table_host.fixed.indexWidget(model.index(0, 11)) is action_widget
|
||||
assert page.table_host.fixed.indexWidget(model.index(0, 10)) is video_widget
|
||||
assert page.table.currentIndex().row() == 0
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_timer_poll_has_one_request_budget_while_refresh_is_in_flight(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
jobs: list[dict[str, Any]] = []
|
||||
|
||||
def queue_async(_function: Any, **options: Any) -> object:
|
||||
jobs.append(options)
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(consultations_module, "run_async", queue_async)
|
||||
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
|
||||
monkeypatch.setattr(page, "isVisible", lambda: True)
|
||||
monkeypatch.setattr(page, "_refresh_counts", lambda: None)
|
||||
|
||||
page._poll_refresh()
|
||||
page._poll_refresh()
|
||||
page._poll_refresh()
|
||||
|
||||
assert len(jobs) == 1
|
||||
assert page._loading
|
||||
jobs[0]["on_success"]({"lists": [_row()], "count": 1})
|
||||
jobs[0]["on_finished"]()
|
||||
assert not page._loading
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_current_appointment_is_the_only_prescription_authority(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
calls: list[tuple[str, int]] = []
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -276,13 +277,15 @@ class VisualRepository:
|
||||
) -> dict[str, Any]:
|
||||
assert diagnosis_id == 501
|
||||
self.tracking_calls.append((start_date, end_date))
|
||||
newest_date = end_date or date.today().isoformat()
|
||||
previous_date = (date.fromisoformat(newest_date) - timedelta(days=1)).isoformat()
|
||||
return {
|
||||
"blood_records": [
|
||||
{
|
||||
"id": 6201,
|
||||
"diagnosis_id": 501,
|
||||
"patient_id": 1501,
|
||||
"record_date": "2026-08-10",
|
||||
"record_date": newest_date,
|
||||
"fasting_blood_sugar": 8.2,
|
||||
"systolic_pressure": 146,
|
||||
"source": 1,
|
||||
@@ -291,7 +294,7 @@ class VisualRepository:
|
||||
"id": 6202,
|
||||
"diagnosis_id": 501,
|
||||
"patient_id": 1501,
|
||||
"record_date": "2026-08-10",
|
||||
"record_date": newest_date,
|
||||
"postprandial_blood_sugar": 12.4,
|
||||
"diastolic_pressure": 92,
|
||||
"western_medicine": "二甲双胍",
|
||||
@@ -300,7 +303,7 @@ class VisualRepository:
|
||||
"id": 6203,
|
||||
"diagnosis_id": 501,
|
||||
"patient_id": 1501,
|
||||
"record_date": "2026-08-09",
|
||||
"record_date": previous_date,
|
||||
"fasting_blood_sugar": 7.6,
|
||||
"postprandial_blood_sugar": 10.8,
|
||||
},
|
||||
@@ -310,7 +313,7 @@ class VisualRepository:
|
||||
"id": 6301,
|
||||
"diagnosis_id": 501,
|
||||
"patient_id": 1501,
|
||||
"record_date": "2026-08-10",
|
||||
"record_date": newest_date,
|
||||
"breakfast_foods": "燕麦、鸡蛋",
|
||||
"lunch_foods": "杂粮饭",
|
||||
}
|
||||
@@ -320,7 +323,7 @@ class VisualRepository:
|
||||
"id": 6401,
|
||||
"diagnosis_id": 501,
|
||||
"patient_id": 1501,
|
||||
"record_date": "2026-08-09",
|
||||
"record_date": previous_date,
|
||||
"exercise_type": "散步",
|
||||
"duration": 35,
|
||||
"intensity": 2,
|
||||
@@ -331,7 +334,7 @@ class VisualRepository:
|
||||
def list_tracking_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
|
||||
assert diagnosis_id == 501
|
||||
self.tracking_note_calls += 1
|
||||
return [{"note_date": "2026-08-10", "content": "饭后散步,继续观察。"}]
|
||||
return [{"note_date": date.today().isoformat(), "content": "饭后散步,继续观察。"}]
|
||||
|
||||
def list_diagnosis_todos(
|
||||
self,
|
||||
@@ -871,10 +874,11 @@ def test_tabs_lazy_load_real_repository_data_and_daily_matrix_structure(
|
||||
panel = dialog._daily_panels[1]
|
||||
assert panel.matrix.rowCount() == 11
|
||||
assert panel.matrix.columnCount() == 8
|
||||
newest_header = repository.tracking_calls[-1][1][5:]
|
||||
blood_column = next(
|
||||
column
|
||||
for column in range(1, panel.matrix.columnCount())
|
||||
if panel.matrix.horizontalHeaderItem(column).text() == "08-10"
|
||||
if panel.matrix.horizontalHeaderItem(column).text() == newest_header
|
||||
)
|
||||
assert panel.matrix.item(0, blood_column).text() == "↑ 8.2 · 自录"
|
||||
assert panel.matrix.item(3, blood_column).text() == "↑ 146/92 · 自录"
|
||||
@@ -1270,15 +1274,18 @@ def test_existing_daily_cells_edit_real_records_and_reject_wrong_owner(
|
||||
return payload
|
||||
|
||||
monkeypatch.setattr(diagnosis_module, "DailyRecordEditorDialog", AcceptedEditor)
|
||||
newest_date = date.fromisoformat(repository.tracking_calls[-1][1])
|
||||
newest_header = newest_date.strftime("%m-%d")
|
||||
previous_header = (newest_date - timedelta(days=1)).strftime("%m-%d")
|
||||
blood_column = next(
|
||||
column
|
||||
for column in range(1, panel.matrix.columnCount())
|
||||
if panel.matrix.horizontalHeaderItem(column).text() == "08-10"
|
||||
if panel.matrix.horizontalHeaderItem(column).text() == newest_header
|
||||
)
|
||||
exercise_column = next(
|
||||
column
|
||||
for column in range(1, panel.matrix.columnCount())
|
||||
if panel.matrix.horizontalHeaderItem(column).text() == "08-09"
|
||||
if panel.matrix.horizontalHeaderItem(column).text() == previous_header
|
||||
)
|
||||
blood_role = panel.matrix.item(0, blood_column).data(Qt.ItemDataRole.UserRole)
|
||||
diet_role = panel.matrix.item(6, blood_column).data(Qt.ItemDataRole.UserRole)
|
||||
|
||||
@@ -8,9 +8,16 @@ from typing import Any
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QAbstractTableModel, QRect, Qt, Signal
|
||||
from PySide6.QtCore import QAbstractTableModel, QPoint, QRect, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QImage, QPainter
|
||||
from PySide6.QtWidgets import QApplication, QFrame, QToolButton, QWidget
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QFrame,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.diagnosis_index_widgets import (
|
||||
@@ -158,14 +165,15 @@ def test_visual_hierarchy_and_filter_contract(
|
||||
page = _page()
|
||||
content_layout = page.page_scroll.widget().layout()
|
||||
margins = content_layout.contentsMargins()
|
||||
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (20, 18, 29, 16)
|
||||
assert content_layout.spacing() == 12
|
||||
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (18, 10, 18, 10)
|
||||
assert content_layout.spacing() == 8
|
||||
status_card = page.findChild(QFrame, "DiagnosisStatusCard")
|
||||
assert status_card is not None
|
||||
assert status_card.height() == 62
|
||||
assert page.page_header.height() == 62
|
||||
assert status_card.height() == 50
|
||||
assert page.findChild(QFrame, "DiagnosisFilterCard") is not None
|
||||
assert page.findChild(QFrame, "DiagnosisListCard") is not None
|
||||
assert page.filters_card.height() == 108
|
||||
assert page.filters_card.height() == 90
|
||||
assert page.keyword_edit.maximumWidth() == 380
|
||||
assert list(page.status_buttons) == ["1", "", "4", "2", "3"]
|
||||
assert page.status_buttons["1"].isChecked()
|
||||
@@ -234,12 +242,17 @@ def test_dedicated_model_fixed_columns_selection_and_sort(
|
||||
assert isinstance(page.table.model(), QAbstractTableModel)
|
||||
assert isinstance(page.table.model(), DiagnosisTableModel)
|
||||
assert page.table_host.LEFT_WIDTHS == (48, 70, 60, 100, 175, 88, 120, 100, 72, 110)
|
||||
assert page.table_host.FIXED_WIDTHS == (120, 340)
|
||||
assert page.table_host.fixed.width() == 462
|
||||
assert page.table_host.FIXED_WIDTHS == (120, 410)
|
||||
assert page.table_host.fixed.width() == 532
|
||||
assert page.table.isColumnHidden(10)
|
||||
assert page.table_host.fixed.isColumnHidden(9)
|
||||
assert not page.table_host.fixed.isColumnHidden(10)
|
||||
assert page.table.verticalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
assert page.table.verticalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
assert page.table.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
assert page.table_host.fixed.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
assert page.table_host.fixed.verticalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
assert page.table_host.minimumHeight() == 0
|
||||
assert page.table_host.sizePolicy().verticalPolicy() == QSizePolicy.Policy.Expanding
|
||||
|
||||
rows = [_row(501), _row(502, has_appointment=0, appointments=[])]
|
||||
page.table_host.set_rows(rows)
|
||||
@@ -303,6 +316,10 @@ def test_empty_loading_and_full_pager_keep_the_table_shell(
|
||||
page.loading_overlay.stop()
|
||||
|
||||
page.pager.update_state(3, 97)
|
||||
assert 40 <= page.pager.height() <= 44
|
||||
pager_margins = page.pager.layout().contentsMargins()
|
||||
assert pager_margins.top() >= 4
|
||||
assert pager_margins.bottom() >= 4
|
||||
assert [page.pager.size_combo.itemData(index) for index in range(4)] == [15, 20, 30, 40]
|
||||
assert len([button for button in page.pager._page_buttons if not button.isHidden()]) == 5
|
||||
assert page.pager.jumper.maximum() == 7
|
||||
@@ -472,6 +489,7 @@ def test_full_more_menu_requires_each_real_repository_capability(
|
||||
"view": True,
|
||||
"edit": True,
|
||||
"prescription": True,
|
||||
"ai_consult": True,
|
||||
"appointment": True,
|
||||
"assign": True,
|
||||
"delete": True,
|
||||
@@ -612,30 +630,106 @@ def test_error_state_is_persistent_until_rows_replace_it(application: QApplicati
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", [(1024, 640), (1440, 900)])
|
||||
def test_two_desktop_sizes_scroll_vertically_without_horizontal_page_clipping(
|
||||
@pytest.mark.parametrize(
|
||||
("size", "minimum_visible_rows"),
|
||||
[((1366, 768), 4), ((1710, 920), 7)],
|
||||
)
|
||||
def test_two_desktop_sizes_keep_pager_visible_and_scroll_rows_inside_table(
|
||||
application: QApplication,
|
||||
size: tuple[int, int],
|
||||
minimum_visible_rows: int,
|
||||
) -> None:
|
||||
page = _page()
|
||||
rows = [_row(600 + index, patient_name=f"患者{index:02d}") for index in range(15)]
|
||||
rows = [
|
||||
_row(
|
||||
600 + index,
|
||||
patient_name=f"患者{index:02d}",
|
||||
latest_appointment_channel_text="健康顾问转介",
|
||||
)
|
||||
for index in range(40)
|
||||
]
|
||||
page.table_host.set_rows(rows)
|
||||
page.resize(*size)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
viewport = page.table.viewport()
|
||||
visible_rows = sum(
|
||||
1
|
||||
for row in range(page.table_host.model.rowCount())
|
||||
if (
|
||||
(rect := page.table.visualRect(page.table_host.model.index(row, 0))).isValid()
|
||||
and rect.top() >= 0
|
||||
and rect.bottom() < viewport.height()
|
||||
)
|
||||
)
|
||||
pager_top = page.pager.mapTo(page.page_scroll.viewport(), QPoint()).y()
|
||||
assert page.page_scroll.horizontalScrollBar().maximum() == 0
|
||||
assert page.page_scroll.verticalScrollBar().maximum() > 0
|
||||
assert page.page_scroll.verticalScrollBar().maximum() == 0
|
||||
assert pager_top >= 0
|
||||
assert pager_top + page.pager.height() <= page.page_scroll.viewport().height()
|
||||
assert visible_rows >= minimum_visible_rows
|
||||
assert page.table.verticalScrollBar().maximum() > 0
|
||||
assert page.table_host.fixed.geometry().right() <= page.table_host.rect().right()
|
||||
assert page.search_button.geometry().right() <= page.search_button.parentWidget().rect().right()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_frozen_rows_track_main_pixel_scroll_and_host_height_is_page_size_stable(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
page = _page()
|
||||
rows = [
|
||||
_row(
|
||||
800 + index,
|
||||
latest_appointment_channel_text="健康顾问转介",
|
||||
)
|
||||
for index in range(40)
|
||||
]
|
||||
page.table_host.set_rows(rows[:15])
|
||||
page.resize(1366, 768)
|
||||
page.show()
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
host_height = page.table_host.height()
|
||||
|
||||
monkeypatch.setattr(page, "refresh", lambda silent=False: None)
|
||||
page._change_page_size(40)
|
||||
page.table_host.set_rows(rows)
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
|
||||
main_scroll = page.table.verticalScrollBar()
|
||||
fixed_scroll = page.table_host.fixed.verticalScrollBar()
|
||||
assert page.table_host.height() == host_height
|
||||
assert main_scroll.maximum() == fixed_scroll.maximum()
|
||||
main_scroll.setValue(main_scroll.maximum() // 2)
|
||||
application.processEvents()
|
||||
assert fixed_scroll.value() == main_scroll.value()
|
||||
fixed_scroll.setValue(fixed_scroll.maximum() // 3)
|
||||
application.processEvents()
|
||||
assert main_scroll.value() == fixed_scroll.value()
|
||||
|
||||
center_index = page.table.indexAt(page.table.viewport().rect().center())
|
||||
assert center_index.isValid()
|
||||
main_top = page.table.visualRect(page.table_host.model.index(center_index.row(), 0)).top()
|
||||
fixed_top = page.table_host.fixed.visualRect(
|
||||
page.table_host.model.index(center_index.row(), 10)
|
||||
).top()
|
||||
assert main_top == fixed_top
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_required_reference_artifacts_exist() -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
expected = {
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_1024x640.png": (1024, 640),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_1440x900.png": (1440, 900),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_1366x768.png": (1366, 768),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_1710x920.png": (1710, 920),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_loading_1280x800.png": (
|
||||
1280,
|
||||
800,
|
||||
|
||||
@@ -7,7 +7,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
import pytest
|
||||
from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QObject, QSize, Signal
|
||||
from PySide6.QtGui import QColor, QImage
|
||||
from PySide6.QtNetwork import QNetworkReply
|
||||
from PySide6.QtNetwork import QNetworkReply, QNetworkRequest
|
||||
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
|
||||
|
||||
from doctor_workstation.ui.diagnosis_drawer import (
|
||||
@@ -34,6 +34,7 @@ def _png_bytes(width: int, height: int, color: str = "#0F766E") -> bytes:
|
||||
|
||||
class _FakeReply(QObject):
|
||||
finished = Signal()
|
||||
downloadProgress = Signal(int, int)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -45,6 +46,7 @@ class _FakeReply(QObject):
|
||||
self.payload = payload
|
||||
self.network_error = error
|
||||
self.aborted = False
|
||||
self.read_all_calls = 0
|
||||
|
||||
def abort(self) -> None:
|
||||
self.aborted = True
|
||||
@@ -53,6 +55,7 @@ class _FakeReply(QObject):
|
||||
return self.network_error
|
||||
|
||||
def readAll(self) -> QByteArray: # noqa: N802 - mirrors QNetworkReply
|
||||
self.read_all_calls += 1
|
||||
return QByteArray(self.payload)
|
||||
|
||||
|
||||
@@ -61,6 +64,7 @@ class _FakeManager(QObject):
|
||||
super().__init__(parent)
|
||||
self.responses: list[tuple[bytes, QNetworkReply.NetworkError]] = []
|
||||
self.requests: list[str] = []
|
||||
self.request_objects: list[object] = []
|
||||
self.replies: list[_FakeReply] = []
|
||||
|
||||
def queue(
|
||||
@@ -73,6 +77,7 @@ class _FakeManager(QObject):
|
||||
def get(self, request: object) -> _FakeReply:
|
||||
payload, error = self.responses.pop(0)
|
||||
self.requests.append(request.url().toString())
|
||||
self.request_objects.append(request)
|
||||
reply = _FakeReply(payload, error, self)
|
||||
self.replies.append(reply)
|
||||
return reply
|
||||
@@ -174,6 +179,45 @@ def test_remote_image_uses_text_only_after_request_or_decode_failure(
|
||||
assert application.thread() == button.thread()
|
||||
|
||||
|
||||
def test_remote_image_enforces_same_origin_redirects_and_aborts_oversize_download(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
owner = _RenderOwner(5)
|
||||
button = _RemoteImageButton(
|
||||
"",
|
||||
render_owner=owner,
|
||||
owner_generation=5,
|
||||
maximum_size=QSize(64, 64),
|
||||
fallback_text="image unavailable",
|
||||
cover=True,
|
||||
object_name="DiagnosisTongueThumb",
|
||||
parent=owner,
|
||||
)
|
||||
button._manager.deleteLater()
|
||||
manager = _FakeManager(button)
|
||||
button._manager = manager
|
||||
manager.queue(b"must-not-be-read")
|
||||
|
||||
button.load_url("https://media.example.invalid/oversize.png")
|
||||
request = manager.request_objects[-1]
|
||||
assert request.attribute(QNetworkRequest.Attribute.RedirectPolicyAttribute) == (
|
||||
QNetworkRequest.RedirectPolicy.SameOriginRedirectPolicy
|
||||
)
|
||||
|
||||
reply = manager.replies[-1]
|
||||
reply.downloadProgress.emit(button._MAX_IMAGE_BYTES, -1)
|
||||
assert reply.aborted is False
|
||||
reply.downloadProgress.emit(button._MAX_IMAGE_BYTES + 1, -1)
|
||||
assert reply.aborted is True
|
||||
assert reply.property("diagnosisImageOversize") is True
|
||||
|
||||
reply.finished.emit()
|
||||
assert reply.read_all_calls == 0
|
||||
assert button.property("loadState") == "failed"
|
||||
assert button.text() == "image unavailable"
|
||||
assert application.thread() == button.thread()
|
||||
|
||||
|
||||
def test_notes_render_cover_thumbnail_and_keep_safe_open_and_single_delete(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QScrollArea
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
@@ -18,7 +19,9 @@ from doctor_workstation.ui.pages import reception as reception_module
|
||||
from doctor_workstation.ui.pages.reception import (
|
||||
AI_MEDICAL_DISCLAIMER,
|
||||
ReceptionPage,
|
||||
_ai_narrative_text,
|
||||
_generated_patient_report,
|
||||
_normalize_patient_report,
|
||||
_patient_report_rows,
|
||||
_ReceptionAiAnalysisDialog,
|
||||
)
|
||||
@@ -596,3 +599,187 @@ def test_patient_ai_disclaimer_remains_the_unified_text() -> None:
|
||||
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
|
||||
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
|
||||
)
|
||||
|
||||
|
||||
def test_ai_narrative_formatter_preserves_lists_arrays_and_medical_numbers() -> None:
|
||||
diagnosis: list[Any] = [
|
||||
"2型糖尿病,HbA1c 7.5%,当前控制未达标。",
|
||||
{"text": r"二甲双胍 0.5g,每日2次。\n复查肾功能。"},
|
||||
"建议:1. 监测空腹血糖 2. 记录餐后2小时血糖",
|
||||
]
|
||||
original = deepcopy(diagnosis)
|
||||
|
||||
rendered = _ai_narrative_text(diagnosis)
|
||||
|
||||
assert diagnosis == original
|
||||
assert rendered == _ai_narrative_text(rendered)
|
||||
assert rendered.splitlines() == [
|
||||
"• 2型糖尿病,HbA1c 7.5%,当前控制未达标。",
|
||||
"• 二甲双胍 0.5g,每日2次。",
|
||||
"复查肾功能。",
|
||||
"• 建议:",
|
||||
"1. 监测空腹血糖",
|
||||
"2. 记录餐后2小时血糖",
|
||||
]
|
||||
assert "7.5%" in rendered
|
||||
assert "0.5g" in rendered
|
||||
assert "2型糖尿病" in rendered
|
||||
assert "7.\n5" not in rendered
|
||||
assert "0.\n5" not in rendered
|
||||
assert "2\n型糖尿病" not in rendered
|
||||
|
||||
payload = {
|
||||
"model_key": "qwen",
|
||||
"diagnosis_advice": diagnosis,
|
||||
"treatment_advice": ["控制总热量", "规律复诊"],
|
||||
"risk_assessment": ["低血糖风险", {"label": "依从性风险", "level": "medium"}],
|
||||
}
|
||||
payload_before = deepcopy(payload)
|
||||
normalized = _normalize_patient_report(payload)
|
||||
|
||||
assert payload == payload_before
|
||||
assert normalized is not None
|
||||
assert normalized["diagnosis_advice"] == rendered
|
||||
assert normalized["treatment_advice"] == "• 控制总热量\n• 规律复诊"
|
||||
assert normalized["risk_assessment"] == [
|
||||
{"label": "低血糖风险", "level": "low"},
|
||||
{"label": "依从性风险", "level": "medium"},
|
||||
]
|
||||
|
||||
|
||||
def test_patient_report_dialog_uses_one_scroll_owner_and_wrapped_risk_flow(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
long_risk = (
|
||||
"这是一个需要换行展示的较长风险项目,用于验证标签不会超出正文区域,"
|
||||
"并且能够在流式布局中可靠折行。"
|
||||
)
|
||||
payload = {
|
||||
"model_key": "qwen",
|
||||
"model_label": "千问",
|
||||
"generated_at": "2026-08-17 10:20:00",
|
||||
"diagnosis_advice": [
|
||||
"2型糖尿病,HbA1c 7.5%,建议继续分层监测。",
|
||||
"1. 监测空腹血糖 2. 记录餐后2小时血糖",
|
||||
]
|
||||
* 10
|
||||
+ ["[诊断末尾]"],
|
||||
"risk_assessment": [
|
||||
{"label": "低血糖", "level": "high"},
|
||||
{"label": "依从性风险", "level": "medium"},
|
||||
{"label": "并发症筛查延误风险", "level": "low"},
|
||||
{"label": "复诊中断风险", "level": "medium"},
|
||||
{"label": long_risk, "level": "high"},
|
||||
{"label": "饮食波动风险", "level": "low"},
|
||||
],
|
||||
"treatment_advice": [r"二甲双胍 0.5g,每日2次。\n复查肾功能。"] * 12
|
||||
+ ["[治疗末尾]"],
|
||||
}
|
||||
dialog = _ReceptionAiAnalysisDialog({"qwen": [payload]})
|
||||
dialog.resize(720, 560)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
|
||||
assert dialog.minimumWidth() == 720
|
||||
assert dialog.minimumHeight() == 560
|
||||
scrolls = dialog.findChildren(QScrollArea)
|
||||
assert scrolls == [dialog.scroll_area]
|
||||
assert dialog.scroll_area.horizontalScrollBar().maximum() == 0
|
||||
assert dialog.scroll_area.verticalScrollBar().maximum() > 0
|
||||
body = dialog.scroll_area.widget()
|
||||
assert body is not None and body.layout() is not None
|
||||
assert body.height() <= max(
|
||||
dialog.scroll_area.viewport().height(),
|
||||
body.layout().sizeHint().height(),
|
||||
) + 40
|
||||
assert dialog.diagnosis_label.text().endswith("[诊断末尾]")
|
||||
assert dialog.treatment_label.text().endswith("[治疗末尾]")
|
||||
assert "7.5%" in dialog.diagnosis_label.text()
|
||||
assert "0.5g" in dialog.treatment_label.text()
|
||||
|
||||
risk_labels = [
|
||||
label
|
||||
for label in dialog.findChildren(QLabel)
|
||||
if label.property("dialogAiRisk")
|
||||
]
|
||||
assert len(risk_labels) == 6
|
||||
assert len({label.y() for label in risk_labels}) >= 2
|
||||
short_risk = risk_labels[0]
|
||||
wrapped_risk = next(label for label in risk_labels if label.text() == long_risk)
|
||||
assert short_risk.width() < dialog.risk_items.width() // 2
|
||||
assert wrapped_risk.width() <= 340
|
||||
assert wrapped_risk.height() > short_risk.height()
|
||||
assert max(label.y() + label.height() for label in risk_labels) <= dialog.risk_items.height()
|
||||
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_reception_ai_card_is_compact_preview_without_nested_scroll(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
payload = {
|
||||
"diagnosis_advice": ["2型糖尿病,HbA1c 7.5%,需要继续监测。"] * 12,
|
||||
"risk_assessment": [
|
||||
{"label": "低血糖", "level": "high"},
|
||||
{"label": "依从性风险", "level": "medium"},
|
||||
{
|
||||
"label": "这是一个需要在紧凑卡片内自行换行而不能向右溢出的长风险项目。",
|
||||
"level": "low",
|
||||
},
|
||||
{"label": "复诊中断", "level": "medium"},
|
||||
{"label": "饮食波动", "level": "low"},
|
||||
{"label": "并发症筛查延误", "level": "high"},
|
||||
],
|
||||
"treatment_advice": ["二甲双胍 0.5g,每日2次。"] * 10,
|
||||
"model_key": "qwen",
|
||||
"model_label": "千问",
|
||||
}
|
||||
payload_before = deepcopy(payload)
|
||||
page = ReceptionPage(object(), PermissionSet([]))
|
||||
page._render_ai_analysis_payload(payload, "qwen")
|
||||
page.ai_analysis_stack.setCurrentWidget(page.ai_analysis_content_page)
|
||||
page.detail_stack.setCurrentIndex(1)
|
||||
page.resize(1494, 832)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
|
||||
assert payload == payload_before
|
||||
assert not isinstance(page.ai_analysis_content_page, QScrollArea)
|
||||
assert page.ai_analysis_card.findChildren(QScrollArea) == []
|
||||
assert page.ai_analysis_card.minimumHeight() < 470
|
||||
assert page.ai_analysis_card.maximumHeight() > 520
|
||||
assert page.ai_analysis_card.sizeHint().height() < 470
|
||||
assert page.ai_summary_label.fullText() == _ai_narrative_text(
|
||||
payload["diagnosis_advice"]
|
||||
)
|
||||
assert page.ai_treatment_label.fullText() == _ai_narrative_text(
|
||||
payload["treatment_advice"]
|
||||
)
|
||||
assert page.ai_summary_label.text().count("\n") + 1 == 3
|
||||
assert page.ai_treatment_label.text().count("\n") + 1 == 2
|
||||
assert page.ai_summary_label.text().endswith("…")
|
||||
assert page.ai_treatment_label.text().endswith("…")
|
||||
|
||||
chips = [
|
||||
label
|
||||
for label in page.ai_risk_chip_host.findChildren(QLabel)
|
||||
if label.property("receptionRiskChip")
|
||||
]
|
||||
overflow = [
|
||||
label
|
||||
for label in page.ai_risk_chip_host.findChildren(QLabel)
|
||||
if label.property("receptionRiskOverflow")
|
||||
]
|
||||
assert len(chips) == 3
|
||||
assert [label.text() for label in overflow] == ["+3 项"]
|
||||
assert max(label.x() + label.width() for label in [*chips, *overflow]) <= (
|
||||
page.ai_risk_chip_host.width()
|
||||
)
|
||||
assert max(label.y() + label.height() for label in [*chips, *overflow]) <= (
|
||||
page.ai_risk_chip_host.height()
|
||||
)
|
||||
assert page.ai_risk_label.text().count("、") == 5
|
||||
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
@@ -621,17 +621,36 @@ def test_patient_list_reference_geometry_and_row_actions(
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
page = PatientsPage(repository, permissions=session.permissions, current_user=session.user)
|
||||
page.resize(1460, 820)
|
||||
# 1366x768 shell minus its 170 px patient rail, 26 px outer gutter,
|
||||
# and 62 px top bar leaves a 1170x680 page viewport.
|
||||
page.resize(1170, 680)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
page.patient_workspace.refresh()
|
||||
application.processEvents()
|
||||
|
||||
workspace = page.patient_workspace
|
||||
assert page.header.height() == 62
|
||||
assert workspace.filter_card.height() <= 92
|
||||
assert all(
|
||||
button.minimumHeight() == 56 and button.maximumHeight() == 56
|
||||
button.minimumHeight() == 44 and button.maximumHeight() == 44
|
||||
for button in workspace.summary_buttons.values()
|
||||
)
|
||||
assert all(
|
||||
widget.minimumWidth() == 0 and widget.maximumWidth() > 1000
|
||||
for widget in (
|
||||
workspace.keyword_edit,
|
||||
workspace.status_host,
|
||||
workspace.quick_host,
|
||||
workspace.date_host,
|
||||
)
|
||||
)
|
||||
assert page.tabs.minimumHeight() == 0
|
||||
assert workspace.content_stack.minimumHeight() == 0
|
||||
assert workspace.table.minimumHeight() == 0
|
||||
assert workspace.bottom_actions.isHidden()
|
||||
assert workspace.table.viewport().height() // 40 >= 6
|
||||
assert workspace.pager.isVisibleTo(page)
|
||||
assert workspace.table.objectName() == "PatientTable"
|
||||
assert workspace.table.columnCount() == 10
|
||||
assert workspace.table.horizontalHeaderItem(9).text() == "操作"
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint
|
||||
from PySide6.QtGui import QImage
|
||||
from PySide6.QtWidgets import QAbstractItemView, QApplication, QComboBox, QFrame, QWidget
|
||||
|
||||
from doctor_workstation.ui.pages import prescription_library as library_module
|
||||
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
|
||||
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
|
||||
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
|
||||
from doctor_workstation.ui.widgets import BusinessPager
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def run_immediately(
|
||||
function: Any,
|
||||
*args: Any,
|
||||
on_success: Any = None,
|
||||
on_error: Any = None,
|
||||
on_finished: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> object:
|
||||
try:
|
||||
result = function(*args, **kwargs)
|
||||
except Exception as error:
|
||||
if on_error is not None:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(prescriptions_module, "run_async", run_immediately)
|
||||
monkeypatch.setattr(library_module, "run_async", run_immediately)
|
||||
|
||||
|
||||
def _issued_row(index: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": 1000 + index,
|
||||
"sn": f"CF-202608-{1000 + index}",
|
||||
"prescription_type": "汤剂",
|
||||
"is_system_auto": index % 2,
|
||||
"patient_name": ("林晓岚", "周明远", "许安然")[index % 3],
|
||||
"gender": 2 if index % 2 else 1,
|
||||
"age": 29 + index,
|
||||
"audit_status": index % 3,
|
||||
"void_status": 0,
|
||||
"has_prescription_order": index % 2,
|
||||
"creator_id": 7,
|
||||
"doctor_name": "陈医生",
|
||||
"assistant_name": "赵医助",
|
||||
"create_time": f"2026-08-{(index % 9) + 10:02d} 09:30:00",
|
||||
"herbs": [{"name": "黄芪", "dosage": 15}],
|
||||
}
|
||||
|
||||
|
||||
def _library_row(index: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": 2000 + index,
|
||||
"prescription_name": ("益气养阴方", "清热祛湿方", "滋阴调和方")[index % 3],
|
||||
"formula_type": "主方" if index % 3 else "辅方",
|
||||
"herbs": [
|
||||
{"name": "黄芪", "dosage": 15},
|
||||
{"name": "党参", "dosage": 12},
|
||||
],
|
||||
"efficacy": ("益气养阴", "清热祛湿", "滋阴补肾")[index % 3],
|
||||
"is_public": index % 2,
|
||||
"disable_edit": 0,
|
||||
"creator_id": 7,
|
||||
"creator_name": "陈医生",
|
||||
"create_time": f"2026-08-{(index % 9) + 10:02d} 08:20:00",
|
||||
}
|
||||
|
||||
|
||||
class DensityRepository:
|
||||
def __init__(self) -> None:
|
||||
self.issued_rows = [_issued_row(index) for index in range(15)]
|
||||
self.library_rows = [_library_row(index) for index in range(15)]
|
||||
|
||||
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
|
||||
return [{"id": 7, "name": "陈医生"}, {"id": 8, "name": "孙医生"}]
|
||||
|
||||
def list_prescriptions(self, **_filters: Any) -> dict[str, Any]:
|
||||
return {"lists": self.issued_rows, "count": 44}
|
||||
|
||||
def list_prescription_templates(self, **_filters: Any) -> dict[str, Any]:
|
||||
return {"lists": self.library_rows, "count": 41}
|
||||
|
||||
|
||||
def _new_page(kind: str) -> PrescriptionsPage | PrescriptionLibraryPage:
|
||||
repository = DensityRepository()
|
||||
user = SimpleNamespace(id=7, name="陈医生", root=1, role_ids=[0])
|
||||
permissions = {"*"}
|
||||
if kind == "issued":
|
||||
page: PrescriptionsPage | PrescriptionLibraryPage = PrescriptionsPage(
|
||||
repository, permissions, user
|
||||
)
|
||||
else:
|
||||
page = PrescriptionLibraryPage(repository, permissions, user)
|
||||
page.refresh()
|
||||
return page
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(5):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _fully_visible_rows(page: PrescriptionsPage | PrescriptionLibraryPage) -> int:
|
||||
viewport = page.table.viewport()
|
||||
return sum(
|
||||
1
|
||||
for row in range(page.table.rowCount())
|
||||
if (
|
||||
(item := page.table.item(row, 0)) is not None
|
||||
and (rect := page.table.visualItemRect(item)).isValid()
|
||||
and rect.top() >= 0
|
||||
and rect.bottom() < viewport.height()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_business_pager_is_shared_fixed_and_not_a_fake_dropdown(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
pager = BusinessPager(15)
|
||||
pager.update_state(2, 44)
|
||||
pager.show()
|
||||
_settle(application)
|
||||
|
||||
assert prescriptions_module.BusinessPager is BusinessPager
|
||||
assert 40 <= pager.height() <= 44
|
||||
assert pager.minimumHeight() == pager.maximumHeight() == 42
|
||||
assert pager.findChildren(QComboBox) == []
|
||||
assert pager.page_size_label.text() == "15 条/页"
|
||||
margins = pager.layout().contentsMargins()
|
||||
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (16, 4, 16, 4)
|
||||
assert pager.page_label is not None and pager.page_label.text() == "2"
|
||||
pager.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["issued", "library"])
|
||||
@pytest.mark.parametrize(
|
||||
("size", "minimum_visible_rows"),
|
||||
[((1366, 768), 6), ((1710, 920), 9)],
|
||||
)
|
||||
def test_desktop_sizes_keep_rows_and_pager_visible_and_aligned(
|
||||
application: QApplication,
|
||||
kind: str,
|
||||
size: tuple[int, int],
|
||||
minimum_visible_rows: int,
|
||||
) -> None:
|
||||
page = _new_page(kind)
|
||||
page.resize(*size)
|
||||
page.show()
|
||||
_settle(application)
|
||||
|
||||
header = page.findChild(QWidget, "PageHeader")
|
||||
toolbar_name = "PrescriptionToolbar" if kind == "issued" else "PrescriptionLibraryToolbar"
|
||||
toolbar = page.findChild(QFrame, toolbar_name)
|
||||
assert header is not None and 60 <= header.height() <= 64
|
||||
assert toolbar is not None and 44 <= toolbar.height() <= 48
|
||||
assert 40 <= page.pager.height() <= 44
|
||||
assert page.pager.minimumHeight() == page.pager.maximumHeight()
|
||||
assert page.table.minimumHeight() == 0
|
||||
assert page.table.horizontalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
assert page.table.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
assert _fully_visible_rows(page) >= minimum_visible_rows
|
||||
|
||||
pager_position = page.pager.mapTo(page, QPoint())
|
||||
assert pager_position.x() >= 0
|
||||
assert pager_position.x() + page.pager.width() <= page.width()
|
||||
assert pager_position.y() >= 0
|
||||
assert pager_position.y() + page.pager.height() <= page.height()
|
||||
page_size_right = page.pager.page_size_label.mapTo(page, QPoint()).x() + (
|
||||
page.pager.page_size_label.width()
|
||||
)
|
||||
assert page_size_right <= page.width()
|
||||
pager_margins = page.pager.layout().contentsMargins()
|
||||
toolbar_margins = toolbar.layout().contentsMargins()
|
||||
assert pager_margins.left() == toolbar_margins.left() == 16
|
||||
assert pager_margins.right() == toolbar_margins.right() == 16
|
||||
|
||||
if kind == "issued":
|
||||
filters = page.findChild(QFrame, "PrescriptionFilterBar")
|
||||
assert filters is not None and 84 <= filters.height() <= 92
|
||||
else:
|
||||
filters = page.findChild(QFrame, "PrescriptionLibraryFilterBar")
|
||||
assert filters is not None
|
||||
assert page.name_filter.minimumWidth() < 500
|
||||
filter_right = filters.contentsRect().right()
|
||||
for control in (
|
||||
page.name_filter,
|
||||
page.formula_filter,
|
||||
page.visibility_filter,
|
||||
page.effect_filter,
|
||||
page.query_button,
|
||||
page.reset_button,
|
||||
):
|
||||
right = control.mapTo(filters, QPoint()).x() + control.width()
|
||||
assert right <= filter_right
|
||||
|
||||
page.close()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def test_density_reference_artifacts_exist() -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
expected = {
|
||||
root / "artifacts" / "prescription_list_density" / "prescriptions_1366x768.png": (
|
||||
1366,
|
||||
768,
|
||||
),
|
||||
root / "artifacts" / "prescription_list_density" / "prescriptions_1710x920.png": (
|
||||
1710,
|
||||
920,
|
||||
),
|
||||
root
|
||||
/ "artifacts"
|
||||
/ "prescription_list_density"
|
||||
/ "prescription_library_1366x768.png": (1366, 768),
|
||||
root
|
||||
/ "artifacts"
|
||||
/ "prescription_list_density"
|
||||
/ "prescription_library_1710x920.png": (1710, 920),
|
||||
}
|
||||
for path, dimensions in expected.items():
|
||||
image = QImage(str(path))
|
||||
assert not image.isNull(), path
|
||||
assert (image.width(), image.height()) == dimensions
|
||||
@@ -16,6 +16,7 @@ from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
||||
from doctor_workstation.ui.dialogs import prescription as dialog_module
|
||||
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog
|
||||
from doctor_workstation.ui.dialogs.prescription import (
|
||||
DiagnosisDetailDialog,
|
||||
PrescriptionEditorDialog,
|
||||
PrescriptionOrderDialog,
|
||||
PrescriptionTemplateDialog,
|
||||
@@ -287,6 +288,125 @@ def test_paid_order_response_is_bound_to_active_diagnosis_and_blocks_save(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_order_detail_lookup_is_queued_before_repository_call(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
queued: list[tuple[Any, dict[str, Any]]] = []
|
||||
requested: list[int] = []
|
||||
shown: list[tuple[int, str]] = []
|
||||
|
||||
class Repository:
|
||||
def get_prescription_order(self, order_id: int) -> dict[str, Any]:
|
||||
requested.append(order_id)
|
||||
return {"id": order_id, "order_no": f"DETAIL-{order_id}"}
|
||||
|
||||
def queue_async(function: Any, **options: Any) -> object:
|
||||
queued.append((function, options))
|
||||
return object()
|
||||
|
||||
def present_order_detail(
|
||||
_host: Any,
|
||||
order: dict[str, Any],
|
||||
*,
|
||||
order_id: int,
|
||||
permissions: Any,
|
||||
exec_: bool,
|
||||
) -> None:
|
||||
del permissions, exec_
|
||||
shown.append((order_id, order["order_no"]))
|
||||
|
||||
monkeypatch.setattr(dialog_module, "run_async", queue_async)
|
||||
monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail)
|
||||
dialog = DiagnosisDetailDialog(
|
||||
{"orders": [{"id": 17, "order_no": "ROW-17"}]},
|
||||
repository=Repository(),
|
||||
)
|
||||
table = dialog._order_detail_table
|
||||
button = dialog._order_detail_button
|
||||
assert table is not None
|
||||
assert button is not None
|
||||
table.setCurrentCell(0, 0)
|
||||
|
||||
button.click()
|
||||
|
||||
assert len(queued) == 1
|
||||
assert requested == []
|
||||
assert shown == []
|
||||
assert not table.isEnabled()
|
||||
assert not button.isEnabled()
|
||||
|
||||
function, options = queued[0]
|
||||
options["on_success"](function())
|
||||
options["on_finished"]()
|
||||
assert requested == [17]
|
||||
assert shown == [(17, "DETAIL-17")]
|
||||
assert table.isEnabled()
|
||||
assert button.isEnabled()
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_order_detail_ignores_stale_result_and_keeps_row_fallback(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
queued: list[dict[str, Any]] = []
|
||||
shown: list[tuple[int, str]] = []
|
||||
|
||||
def queue_async(_function: Any, **options: Any) -> object:
|
||||
queued.append(options)
|
||||
return object()
|
||||
|
||||
def present_order_detail(
|
||||
_host: Any,
|
||||
order: dict[str, Any],
|
||||
*,
|
||||
order_id: int,
|
||||
permissions: Any,
|
||||
exec_: bool,
|
||||
) -> None:
|
||||
del permissions, exec_
|
||||
shown.append((order_id, order["order_no"]))
|
||||
|
||||
repository = SimpleNamespace(get_prescription_order=lambda order_id: {"id": order_id})
|
||||
monkeypatch.setattr(dialog_module, "run_async", queue_async)
|
||||
monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail)
|
||||
dialog = DiagnosisDetailDialog(
|
||||
{
|
||||
"orders": [
|
||||
{"id": 21, "order_no": "ROW-21"},
|
||||
{"id": 22, "order_no": "ROW-22"},
|
||||
]
|
||||
},
|
||||
repository=repository,
|
||||
)
|
||||
table = dialog._order_detail_table
|
||||
button = dialog._order_detail_button
|
||||
assert table is not None
|
||||
assert button is not None
|
||||
|
||||
table.setCurrentCell(0, 0)
|
||||
dialog._open_selected_order()
|
||||
table.setCurrentCell(1, 0)
|
||||
dialog._open_selected_order()
|
||||
assert len(queued) == 2
|
||||
|
||||
queued[0]["on_success"]({"id": 21, "order_no": "STALE-21"})
|
||||
queued[0]["on_finished"]()
|
||||
assert shown == []
|
||||
assert not table.isEnabled()
|
||||
assert not button.isEnabled()
|
||||
|
||||
queued[1]["on_error"](RuntimeError("detail unavailable"))
|
||||
queued[1]["on_finished"]()
|
||||
assert shown == [(22, "ROW-22")]
|
||||
assert table.isEnabled()
|
||||
assert button.isEnabled()
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _finish_queued(callback: dict[str, Any], result: Any) -> None:
|
||||
callback["on_success"](result)
|
||||
if callback.get("on_finished"):
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -12,7 +12,7 @@ import httpx
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, QPoint, Qt
|
||||
from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QScrollArea
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QScrollArea, QWidget
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services.api_client import ApiClient
|
||||
@@ -283,9 +283,10 @@ def test_queue_uses_admin_same_day_contract(
|
||||
"status": 1,
|
||||
"start_date": date.today().isoformat(),
|
||||
"end_date": date.today().isoformat(),
|
||||
"page_no": 1,
|
||||
"page_size": 15,
|
||||
"patient_name": "王小明",
|
||||
"page_no": 1,
|
||||
"page_size": 15,
|
||||
"patient_name": "王小明",
|
||||
"include_status_counts": 1,
|
||||
}
|
||||
]
|
||||
|
||||
@@ -296,6 +297,167 @@ def test_queue_uses_admin_same_day_contract(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_reception_daily_records_use_backend_matrix_contract(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
today = date.today().isoformat()
|
||||
tracking_calls: list[tuple[int, str, str]] = []
|
||||
note_calls: list[int] = []
|
||||
|
||||
class Repository:
|
||||
fail_tracking = False
|
||||
|
||||
def list_appointments(self, **_kwargs: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"lists": [
|
||||
{
|
||||
"id": 71,
|
||||
"diagnosis_id": 271,
|
||||
# Production appointment.patient_id is the diagnosis id.
|
||||
"patient_id": 271,
|
||||
"patient_name": "日常记录患者",
|
||||
"status": 1,
|
||||
"appointment_time": "09:30:00",
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
}
|
||||
|
||||
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||
assert appointment_id == 71
|
||||
return {
|
||||
"appointment": {
|
||||
"id": 71,
|
||||
"patient_id": 271,
|
||||
"patient_name": "日常记录患者",
|
||||
"status": 1,
|
||||
},
|
||||
"diagnosis": {
|
||||
"id": 271,
|
||||
"patient_id": 971,
|
||||
"patient_name": "日常记录患者",
|
||||
"age": 56,
|
||||
},
|
||||
"tracking_notes": [
|
||||
{"note_date": today, "content": "内嵌备注降级数据"}
|
||||
],
|
||||
}
|
||||
|
||||
def get_tracking_window(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
*,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
) -> dict[str, Any]:
|
||||
tracking_calls.append((diagnosis_id, start_date, end_date))
|
||||
if self.fail_tracking:
|
||||
raise RuntimeError("tracking unavailable")
|
||||
return {
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"blood_records": [
|
||||
{
|
||||
"record_date": today,
|
||||
"record_time": "08:30:00",
|
||||
"fasting_blood_sugar": 9.6,
|
||||
"postprandial_blood_sugar": 11.4,
|
||||
"other_blood_sugar": 8.5,
|
||||
"systolic_pressure": 141,
|
||||
"diastolic_pressure": 90,
|
||||
"western_medicine": "二甲双胍",
|
||||
"insulin": "睡前 8U",
|
||||
"source": 1,
|
||||
}
|
||||
],
|
||||
"diet_records": [
|
||||
{
|
||||
"record_date": today,
|
||||
"breakfast_foods": ["小米粥"],
|
||||
"lunch_foods": ["杂粮饭"],
|
||||
"dinner_foods": ["青菜"],
|
||||
}
|
||||
],
|
||||
"exercise_records": [
|
||||
{
|
||||
"record_date": today,
|
||||
"exercise_type": "快走",
|
||||
"duration": 45,
|
||||
"intensity_text": "中等",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def list_tracking_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
|
||||
note_calls.append(diagnosis_id)
|
||||
return [{"note_date": today, "content": "睡眠改善,继续随访"}]
|
||||
|
||||
repository = Repository()
|
||||
page = ReceptionPage(repository, PermissionSet([]))
|
||||
page.refresh()
|
||||
application.processEvents()
|
||||
|
||||
assert [
|
||||
page.detail_tabs.tabText(index) for index in range(page.detail_tabs.count())
|
||||
] == ["问诊信息", "检查报告", "用药记录", "日常记录", "随访记录", "健康数据"]
|
||||
assert tracking_calls == [
|
||||
(271, (date.today() - timedelta(days=6)).isoformat(), today)
|
||||
]
|
||||
assert note_calls == [271]
|
||||
assert page._selection_context()[-1] == 971
|
||||
|
||||
matrix = page.daily_panel.matrix
|
||||
assert matrix.objectName() == "ReceptionDailyRecordsTable"
|
||||
assert matrix.rowCount() == 11
|
||||
assert matrix.columnCount() == 8
|
||||
assert matrix.horizontalHeaderItem(0).text() == "指标"
|
||||
assert matrix.horizontalHeaderItem(1).text() == today[5:]
|
||||
assert [matrix.item(row, 0).text() for row in range(11)] == [
|
||||
"空腹血糖",
|
||||
"餐后2h血糖",
|
||||
"其他血糖",
|
||||
"血压",
|
||||
"西药",
|
||||
"胰岛素",
|
||||
"早餐",
|
||||
"午餐",
|
||||
"晚餐",
|
||||
"运动",
|
||||
"跟踪备注",
|
||||
]
|
||||
assert matrix.item(0, 1).text() == "9.6 · 自录 ↑"
|
||||
assert matrix.item(0, 1).data(Qt.ItemDataRole.UserRole)["high"] is True
|
||||
assert matrix.item(1, 1).text() == "11.4 · 自录 ↑"
|
||||
assert matrix.item(2, 1).text() == "8.5 · 自录"
|
||||
assert matrix.item(2, 1).data(Qt.ItemDataRole.UserRole)["high"] is False
|
||||
assert matrix.item(3, 1).text() == "141/90 · 自录 ↑"
|
||||
assert matrix.item(4, 1).text() == "二甲双胍"
|
||||
assert matrix.item(5, 1).text() == "睡前 8U"
|
||||
assert matrix.item(6, 1).text() == "已记录"
|
||||
assert matrix.item(9, 1).text() == "45min"
|
||||
assert matrix.item(10, 1).text() == "睡眠改善,继续随访"
|
||||
assert "睡眠改善" in page.followup_text.text()
|
||||
|
||||
page.daily_panel.range_buttons["30"].click()
|
||||
assert tracking_calls[-1] == (
|
||||
271,
|
||||
(date.today() - timedelta(days=29)).isoformat(),
|
||||
today,
|
||||
)
|
||||
assert page.daily_panel.matrix.columnCount() == 31
|
||||
|
||||
preserved = page.daily_panel.matrix.item(0, 1).text()
|
||||
repository.fail_tracking = True
|
||||
page.daily_panel.refresh_button.click()
|
||||
assert page.daily_panel.matrix.item(0, 1).text() == preserved
|
||||
assert "已保留上次数据" in page.daily_panel.state.label.text()
|
||||
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_queue_date_picker_filters_the_selected_day(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
@@ -394,6 +556,69 @@ def test_silent_queue_polls_reuse_rows_and_do_not_restart_detail_or_ai(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_silent_poll_replaces_only_the_queue_row_with_visible_changes(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
original_queue_row = reception_module.QueueRow
|
||||
constructed: list[int] = []
|
||||
|
||||
class CountingQueueRow(original_queue_row):
|
||||
def __init__(self, record: Any, parent: QWidget | None = None) -> None:
|
||||
constructed.append(int(record["id"]))
|
||||
super().__init__(record, parent)
|
||||
|
||||
monkeypatch.setattr(reception_module, "QueueRow", CountingQueueRow)
|
||||
|
||||
class Repository:
|
||||
calls = 0
|
||||
|
||||
def list_appointments(self, **_kwargs: Any) -> dict[str, Any]:
|
||||
self.calls += 1
|
||||
rows = [
|
||||
{
|
||||
"id": index,
|
||||
"diagnosis_id": 200 + index,
|
||||
"patient_id": 100 + index,
|
||||
"patient_name": f"患者{index}",
|
||||
"clinical_diagnosis": "消渴",
|
||||
"status": 1,
|
||||
}
|
||||
for index in range(1, 4)
|
||||
]
|
||||
if self.calls > 1:
|
||||
rows[1]["clinical_diagnosis"] = "消渴 · 气阴两虚"
|
||||
return {"lists": rows, "count": 3}
|
||||
|
||||
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||
return {
|
||||
"appointment": {"id": appointment_id, "patient_id": 100 + appointment_id},
|
||||
"diagnosis": {
|
||||
"id": 200 + appointment_id,
|
||||
"patient_id": 100 + appointment_id,
|
||||
},
|
||||
}
|
||||
|
||||
page = ReceptionPage(Repository(), PermissionSet([]))
|
||||
page.refresh()
|
||||
items = [page.queue_list.item(index) for index in range(3)]
|
||||
widgets = [page.queue_list.itemWidget(item) for item in items]
|
||||
assert constructed == [1, 2, 3]
|
||||
|
||||
page.refresh(silent=True)
|
||||
|
||||
assert all(page.queue_list.item(index) is items[index] for index in range(3))
|
||||
assert page.queue_list.itemWidget(items[0]) is widgets[0]
|
||||
assert page.queue_list.itemWidget(items[1]) is not widgets[1]
|
||||
assert page.queue_list.itemWidget(items[2]) is widgets[2]
|
||||
assert constructed == [1, 2, 3, 2]
|
||||
changed_row = page.queue_list.itemWidget(items[1])
|
||||
assert changed_row.findChild(QLabel, "ReceptionQueueSubline").text() == "消渴 · 气阴两虚"
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_timer_poll_does_not_supersede_an_in_flight_queue_request(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -905,9 +1130,10 @@ def test_queue_worker_uses_frozen_widget_snapshot(
|
||||
"status": 1,
|
||||
"start_date": date.today().isoformat(),
|
||||
"end_date": date.today().isoformat(),
|
||||
"page_no": 1,
|
||||
"page_size": 15,
|
||||
"patient_name": "甲患者",
|
||||
"page_no": 1,
|
||||
"page_size": 15,
|
||||
"patient_name": "甲患者",
|
||||
"include_status_counts": 1,
|
||||
}
|
||||
]
|
||||
page.close()
|
||||
@@ -1094,7 +1320,10 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
|
||||
|
||||
left = page.ai_analysis_card.geometry()
|
||||
right = page.ai_assistant_card.geometry()
|
||||
assert 470 <= left.height() <= 520
|
||||
assert left.height() < 470
|
||||
assert page.ai_analysis_card.minimumHeight() == 0
|
||||
assert page.ai_analysis_card.maximumHeight() > 520
|
||||
assert page.ai_analysis_card.findChildren(QScrollArea) == []
|
||||
assert left.height() == right.height()
|
||||
assert 0 <= right.left() - left.right() - 1 <= 2
|
||||
assert abs(left.width() * 5 - right.width() * 4) <= 10
|
||||
@@ -1464,7 +1693,6 @@ def test_ai_analysis_dialog_switches_complete_cached_payloads_without_requests(
|
||||
"qwen 风险项目 1",
|
||||
"qwen 风险项目 2",
|
||||
"qwen 风险项目 3",
|
||||
"qwen 风险项目 4",
|
||||
]
|
||||
calls_before_dialog = list(repository.analysis_calls)
|
||||
page.ai_analysis_expand_button.click()
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.core.errors import ApiProtocolError
|
||||
from doctor_workstation.core.errors import ApiBusinessError, ApiHttpError, ApiProtocolError
|
||||
from doctor_workstation.core.models import Appointment, Consultation, PageResult, Prescription
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
from doctor_workstation.services.repository import (
|
||||
@@ -271,6 +271,51 @@ def test_remote_reception_is_forcibly_scoped_to_today() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_remote_reception_daily_records_use_admin_endpoints_exactly() -> None:
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
repository.list_appointments(
|
||||
status=1,
|
||||
start_date="2026-08-11",
|
||||
end_date="2026-08-17",
|
||||
include_status_counts=1,
|
||||
page_no=1,
|
||||
page_size=15,
|
||||
)
|
||||
repository.get_reception(71)
|
||||
repository.get_tracking_window(
|
||||
271,
|
||||
start_date="2026-08-11",
|
||||
end_date="2026-08-17",
|
||||
)
|
||||
repository.list_tracking_notes(271)
|
||||
|
||||
assert client.get_calls[-4:] == [
|
||||
(
|
||||
"doctor.appointment/lists",
|
||||
{
|
||||
"status": 1,
|
||||
"start_date": "2026-08-11",
|
||||
"end_date": "2026-08-17",
|
||||
"include_status_counts": 1,
|
||||
"page_no": 1,
|
||||
"page_size": 15,
|
||||
},
|
||||
),
|
||||
("doctor.appointment/reception", {"id": 71}),
|
||||
(
|
||||
"tcm.diagnosis/trackingWindow",
|
||||
{
|
||||
"id": 271,
|
||||
"start_date": "2026-08-11",
|
||||
"end_date": "2026-08-17",
|
||||
},
|
||||
),
|
||||
("tcm.diagnosis/trackingNotes", {"diagnosis_id": 271}),
|
||||
]
|
||||
|
||||
|
||||
def test_remote_new_contracts_use_exact_admin_endpoints_and_dtos() -> None:
|
||||
"""Prescription, patient and diagnosis methods remain thin endpoint adapters."""
|
||||
|
||||
@@ -600,6 +645,56 @@ def test_remote_diagnosis_ai_assistant_uses_first_party_endpoint_only() -> None:
|
||||
assert client.timeouts == [105.0]
|
||||
|
||||
|
||||
def test_remote_diagnosis_ai_stream_normalises_chunks_in_order() -> None:
|
||||
class StreamingClient(RecordingClient):
|
||||
def post_event_stream(self, endpoint: str, payload: dict[str, Any], **kwargs: Any):
|
||||
assert endpoint == "tcm.diagnosis/aiAssistantStream"
|
||||
assert payload == {"id": 501, "prompt": "请辨证", "task": "tcm_pattern"}
|
||||
assert kwargs["timeout"] == 105.0
|
||||
yield {"event": "start", "data": {"model_key": "qwen"}}
|
||||
yield {"event": "delta", "data": {"content": "肝郁"}}
|
||||
yield {"event": "delta", "data": {"delta": "脾虚"}}
|
||||
yield {"event": "done", "data": {"model_label": "千问"}}
|
||||
|
||||
client = StreamingClient()
|
||||
events = list(
|
||||
RemoteDoctorRepository(client).stream_diagnosis_ai(
|
||||
501,
|
||||
"请辨证",
|
||||
task="tcm_pattern",
|
||||
)
|
||||
)
|
||||
|
||||
assert [event["event"] for event in events] == ["start", "delta", "delta", "done"]
|
||||
assert "".join(event.get("text", "") for event in events) == "肝郁脾虚"
|
||||
assert client.post_calls == []
|
||||
|
||||
|
||||
def test_remote_diagnosis_ai_stream_falls_back_once_but_not_for_error_event() -> None:
|
||||
class MissingStreamClient(RecordingClient):
|
||||
def post_event_stream(self, *args: Any, **kwargs: Any):
|
||||
raise ApiHttpError("missing", status_code=404)
|
||||
|
||||
missing_client = MissingStreamClient()
|
||||
events = list(
|
||||
RemoteDoctorRepository(missing_client).stream_diagnosis_ai(501, "请分析")
|
||||
)
|
||||
assert [event["event"] for event in events] == ["start", "delta", "done"]
|
||||
assert events[1]["text"] == "服务端分析结果"
|
||||
assert [call[0] for call in missing_client.post_calls] == [
|
||||
"tcm.diagnosis/aiAssistant"
|
||||
]
|
||||
|
||||
class ErrorStreamClient(RecordingClient):
|
||||
def post_event_stream(self, *args: Any, **kwargs: Any):
|
||||
yield {"event": "error", "data": {"message": "模型繁忙"}}
|
||||
|
||||
error_client = ErrorStreamClient()
|
||||
with pytest.raises(ApiBusinessError, match="模型繁忙"):
|
||||
list(RemoteDoctorRepository(error_client).stream_diagnosis_ai(501, "请分析"))
|
||||
assert error_client.post_calls == []
|
||||
|
||||
|
||||
def test_remote_diagnosis_ai_analysis_uses_exact_post_contract() -> None:
|
||||
"""The legacy default is qwen, followed by an explicit OpenAI request."""
|
||||
|
||||
|
||||
@@ -26,10 +26,16 @@ class _ShellPageDouble(QWidget):
|
||||
self.permissions = permissions
|
||||
self.current_user = current_user
|
||||
self.refresh_count = 0
|
||||
self.show_count = 0
|
||||
|
||||
def refresh(self) -> None:
|
||||
self.refresh_count += 1
|
||||
|
||||
def showEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||
super().showEvent(event)
|
||||
self.show_count += 1
|
||||
self.refresh()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
@@ -211,6 +217,34 @@ def test_every_visible_page_navigates_and_visited_tabs_track_active_page(
|
||||
]
|
||||
|
||||
|
||||
def test_real_navigation_refreshes_once_and_current_page_click_is_a_noop(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
appointments = shell_window.pages["appointments"]
|
||||
reception = shell_window.pages["reception"]
|
||||
assert isinstance(appointments, _ShellPageDouble)
|
||||
assert isinstance(reception, _ShellPageDouble)
|
||||
assert appointments.refresh_count == 1
|
||||
assert appointments.show_count == 1
|
||||
assert reception.refresh_count == 0
|
||||
|
||||
shell_window.nav_buttons["reception"].click()
|
||||
application.processEvents()
|
||||
assert reception.refresh_count == 1
|
||||
assert reception.show_count == 1
|
||||
|
||||
shell_window.nav_buttons["reception"].click()
|
||||
application.processEvents()
|
||||
assert reception.refresh_count == 1
|
||||
assert reception.show_count == 1
|
||||
|
||||
shell_window.nav_buttons["appointments"].click()
|
||||
application.processEvents()
|
||||
assert appointments.refresh_count == 2
|
||||
assert appointments.show_count == 2
|
||||
|
||||
|
||||
def test_non_fixed_tabs_close_and_active_close_renavigates(
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
|
||||
@@ -4,9 +4,10 @@ namespace app\adminapi\controller\doctor;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\doctor\AppointmentLists;
|
||||
use app\adminapi\logic\doctor\AppointmentLogic;
|
||||
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
||||
use app\adminapi\validate\doctor\AppointmentValidate;
|
||||
use app\adminapi\logic\doctor\AppointmentLogic;
|
||||
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||
use app\adminapi\validate\doctor\AppointmentValidate;
|
||||
|
||||
/**
|
||||
* 医生预约控制器
|
||||
@@ -147,8 +148,11 @@ class AppointmentController extends BaseAdminController
|
||||
public function reception()
|
||||
{
|
||||
$params = (new AppointmentValidate())->goCheck('reception');
|
||||
$result = AppointmentLogic::reception($params);
|
||||
return $this->data($result);
|
||||
$result = AppointmentLogic::reception($params, $this->adminId, $this->adminInfo);
|
||||
if (empty($result)) {
|
||||
return $this->fail('预约记录不存在或无权访问');
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,10 +169,17 @@ class AppointmentController extends BaseAdminController
|
||||
return $this->success('通知已发送');
|
||||
}
|
||||
|
||||
public function addDoctorNote()
|
||||
{
|
||||
$params = (new AppointmentValidate())->post()->goCheck('addDoctorNote');
|
||||
$params['doctor_id'] = $this->adminId;
|
||||
public function addDoctorNote()
|
||||
{
|
||||
$params = (new AppointmentValidate())->post()->goCheck('addDoctorNote');
|
||||
if (!DiagnosisLogic::canViewReadonlyDiagnosis(
|
||||
(int) $params['diagnosis_id'],
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)) {
|
||||
return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
|
||||
}
|
||||
$params['doctor_id'] = $this->adminId;
|
||||
$result = DoctorNoteLogic::addOrAppend($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(DoctorNoteLogic::getError());
|
||||
@@ -176,10 +187,17 @@ class AppointmentController extends BaseAdminController
|
||||
return $this->success('保存成功');
|
||||
}
|
||||
|
||||
public function doctorNotes()
|
||||
{
|
||||
$params = (new AppointmentValidate())->goCheck('doctorNotes');
|
||||
return $this->data(DoctorNoteLogic::getByDiagnosis((int) $params['diagnosis_id']));
|
||||
public function doctorNotes()
|
||||
{
|
||||
$params = (new AppointmentValidate())->goCheck('doctorNotes');
|
||||
if (!DiagnosisLogic::canViewReadonlyDiagnosis(
|
||||
(int) $params['diagnosis_id'],
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)) {
|
||||
return $this->fail('诊单不存在或无权访问');
|
||||
}
|
||||
return $this->data(DoctorNoteLogic::getByDiagnosis((int) $params['diagnosis_id']));
|
||||
}
|
||||
|
||||
public function deleteDoctorNoteImage()
|
||||
|
||||
@@ -21,7 +21,8 @@ use app\adminapi\logic\tcm\DiagnosisAiLogic;
|
||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||
use app\adminapi\logic\tcm\PatientAiReportLogic;
|
||||
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||
use app\adminapi\validate\tcm\DiagnosisValidate;
|
||||
use app\adminapi\service\AssistantSseProtocol;
|
||||
use app\adminapi\validate\tcm\DiagnosisValidate;
|
||||
use app\common\model\Order;
|
||||
use app\common\model\WechatChatRecord;
|
||||
|
||||
@@ -168,10 +169,13 @@ class DiagnosisController extends BaseAdminController
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function trackingWindow()
|
||||
{
|
||||
$params = (new DiagnosisValidate())->goCheck('trackingWindow');
|
||||
$result = DiagnosisLogic::fetchTrackingWindow(
|
||||
public function trackingWindow()
|
||||
{
|
||||
$params = (new DiagnosisValidate())->goCheck('trackingWindow');
|
||||
if (!DiagnosisLogic::canViewReadonlyDiagnosis((int) $params['id'], $this->adminId, $this->adminInfo)) {
|
||||
return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
|
||||
}
|
||||
$result = DiagnosisLogic::fetchTrackingWindow(
|
||||
(int) $params['id'],
|
||||
(string) ($params['start_date'] ?? ''),
|
||||
(string) ($params['end_date'] ?? '')
|
||||
@@ -865,9 +869,9 @@ class DiagnosisController extends BaseAdminController
|
||||
/**
|
||||
* @notes 基于当前授权诊单向 AI 助手提问,不接收客户端上游配置
|
||||
*/
|
||||
public function aiAssistant()
|
||||
{
|
||||
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistant');
|
||||
public function aiAssistant()
|
||||
{
|
||||
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistant');
|
||||
$result = DiagnosisAiLogic::assistant(
|
||||
(int) $params['id'],
|
||||
(string) $params['task'],
|
||||
@@ -877,11 +881,113 @@ class DiagnosisController extends BaseAdminController
|
||||
);
|
||||
if ($result === null) {
|
||||
return $this->fail(DiagnosisAiLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 基于当前授权诊单向 AI 助手提问(SSE 真流式)
|
||||
*
|
||||
* 路由:POST tcm.diagnosis/aiAssistantStream body: id, task, prompt
|
||||
*/
|
||||
public function aiAssistantStream()
|
||||
{
|
||||
// 登录由全局中间件完成;请求校验、旧助手权限与 DataScope 必须全部
|
||||
// 在任何 SSE header / start 事件之前完成,失败时仍返回标准 JSON。
|
||||
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistant');
|
||||
$prepared = DiagnosisAiLogic::prepareAssistant(
|
||||
(int) $params['id'],
|
||||
(string) $params['task'],
|
||||
(string) ($params['prompt'] ?? ''),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($prepared === null) {
|
||||
return $this->fail(DiagnosisAiLogic::getError());
|
||||
}
|
||||
|
||||
$this->runAssistantSse($prepared);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $prepared */
|
||||
private function runAssistantSse(array $prepared): void
|
||||
{
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
@ini_set('output_buffering', 'off');
|
||||
@ini_set('zlib.output_compression', '0');
|
||||
ignore_user_abort(true);
|
||||
if (function_exists('apache_setenv')) {
|
||||
@apache_setenv('no-gzip', '1');
|
||||
}
|
||||
|
||||
header('Content-Type: text/event-stream; charset=utf-8');
|
||||
header('Cache-Control: no-cache, no-transform');
|
||||
header('Connection: keep-alive');
|
||||
header('X-Accel-Buffering: no');
|
||||
header('Content-Encoding: none');
|
||||
|
||||
echo ':' . str_repeat(' ', 2048) . "\n\n";
|
||||
$this->flushSseOutput();
|
||||
|
||||
$protocol = new AssistantSseProtocol();
|
||||
$emit = function (string $event, array $payload) use ($protocol): bool {
|
||||
if ($protocol->isTerminal() || connection_aborted()) {
|
||||
return false;
|
||||
}
|
||||
$encoded = $protocol->encode($event, $payload);
|
||||
if ($encoded === null) {
|
||||
return false;
|
||||
}
|
||||
echo $encoded;
|
||||
$this->flushSseOutput();
|
||||
return !connection_aborted();
|
||||
};
|
||||
|
||||
$emit('start', [
|
||||
'task' => (string) ($prepared['task'] ?? ''),
|
||||
'model_key' => (string) ($prepared['profile'] ?? ''),
|
||||
'message' => '已连接,正在生成…',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = DiagnosisAiLogic::streamPreparedAssistant(
|
||||
$prepared,
|
||||
static fn (string $delta): bool => $emit('delta', ['text' => $delta]),
|
||||
static fn (): bool => connection_aborted() === 1
|
||||
);
|
||||
if (connection_aborted()) {
|
||||
exit;
|
||||
}
|
||||
if ($result === null) {
|
||||
$emit('error', [
|
||||
'code' => 'AI_ASSISTANT_FAILED',
|
||||
'message' => 'AI 助手暂时不可用,请稍后重试',
|
||||
]);
|
||||
} else {
|
||||
$emit('done', $result);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$emit('error', [
|
||||
'code' => 'AI_ASSISTANT_FAILED',
|
||||
'message' => 'AI 助手暂时不可用,请稍后重试',
|
||||
]);
|
||||
}
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
private function flushSseOutput(): void
|
||||
{
|
||||
if (function_exists('ob_flush')) {
|
||||
@ob_flush();
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 对当前授权诊单生成一次结构化 AI 智能分析,仅接受 qwen/openai 模型键
|
||||
*/
|
||||
public function aiAnalysis()
|
||||
|
||||
@@ -137,9 +137,12 @@ class PrescriptionController extends BaseAdminController
|
||||
$diagnosisId = (int)($this->request->get('diagnosis_id') ?? 0);
|
||||
if (!$diagnosisId) {
|
||||
return $this->fail('诊单ID不能为空');
|
||||
}
|
||||
$list = PrescriptionLogic::listByDiagnosis($diagnosisId);
|
||||
return $this->data($list);
|
||||
}
|
||||
$list = PrescriptionLogic::listByDiagnosis($diagnosisId, (int) $this->adminId, $this->adminInfo);
|
||||
if (PrescriptionLogic::getError() !== '') {
|
||||
return $this->fail(PrescriptionLogic::getError());
|
||||
}
|
||||
return $this->data($list);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||