Compare commits

..
Author SHA1 Message Date
long 9a6146e25a docs: record restored EJ medicine count 2026-09-10 12:19:53 +08:00
long 068f3f8b06 docs: record EJ medicine sync execution 2026-09-10 12:17:06 +08:00
long 6862547a9d docs: record EJ sync production deployment 2026-09-10 12:12:06 +08:00
long 192c31fb8c docs: verify EJ medicine restore behavior 2026-09-10 11:48:56 +08:00
long eec3204408 docs: define EJ medicine restore semantics 2026-09-10 11:43:22 +08:00
long d3d66970b2 docs: finalize EJ sync branch evidence 2026-09-10 11:11:26 +08:00
long 2a0e49dbc9 docs: record EJ incremental sync verification 2026-09-10 11:11:06 +08:00
long 17e9e7b6b6 feat: add non-destructive EJ medicine sync 2026-09-10 11:09:26 +08:00
Your Name 27fbef9321 gengx 2026-09-09 15:52:46 +08:00
Your Name cb10e75ead 更新 2026-09-09 15:47:48 +08:00
Your Name bd5d5c5f08 更新 2026-09-09 14:08:11 +08:00
Your Name 083fd857e8 更新 2026-09-09 14:04:09 +08:00
Your Name 9be2e18ea6 更新 2026-09-09 12:25:20 +08:00
Your Name 691c749f34 Merge branch 'master' into UI-09-03 2026-09-09 12:19:39 +08:00
Your Name 40319eea41 更新 2026-09-09 12:18:17 +08:00
gr c1f7287331 igengx 2026-09-09 10:48:05 +08:00
gr 9d91ff90ae ;rgb:0000/0000/0000 2026-09-09 10:02:43 +08:00
gr 1b8e166b33 i更新 2026-09-09 09:58:12 +08:00
Your Name 0cea43b027 Merge branch 'master' into UI-09-03 2026-09-08 16:16:25 +08:00
long d0e2422155 revert(iam): 统一登录 - 撤销 IAM Hub 接入 2026-09-08 16:14:59 +08:00
long 9206d5ea62 docs: record ZYT IAM integration evidence 2026-09-08 13:42:22 +08:00
long bd0a3d3b96 fix: complete IAM login after OIDC callback 2026-09-08 13:34:18 +08:00
long a5f4c4472d feat: integrate ZYT admin with IAM Hub 2026-09-08 13:26:41 +08:00
Your Name 9ef6eb8d67 更新 2026-09-07 12:30:42 +08:00
Your Name d5164b7369 更新 2026-09-07 10:07:47 +08:00
Your Name cf3fbdc5ef 更新 2026-09-03 18:05:32 +08:00
Your Name 928f72ec3d 更新 2026-09-03 16:03:41 +08:00
Your Name b4c11881b4 更新 2026-09-01 18:12:39 +08:00
Your Name 58ffde808f 更新 2026-09-01 17:49:29 +08:00
Your Name 486acc465d 更新 2026-09-01 17:18:42 +08:00
Your Name 398f9f3726 更新 2026-09-01 11:26:47 +08:00
Your Name 5bd5eae62d 更新 2026-09-01 10:42:16 +08:00
Your Name 456dd667df 更新 2026-08-31 15:17:34 +08:00
Your Name ed48f8be31 更新 2026-08-28 18:24:37 +08:00
gr 5e22d423d4 更新 2026-08-28 14:07:30 +08:00
gr 7f1ed49cc8 更新 2026-08-28 14:06:56 +08:00
Your Name 43ad07208f 更新 2026-08-27 16:26:14 +08:00
Your Name 4b8b4eb649 更新 2026-08-27 16:02:38 +08:00
Your Name 75e214dc08 更新 2026-08-27 15:41:09 +08:00
Your Name 74ff568ba4 更新 2026-08-27 14:54:18 +08:00
Your Name 2fa8492c56 更新 2026-08-27 14:23:23 +08:00
Your Name b5b14516a1 更新 2026-08-25 15:05:49 +08:00
Your Name 381fe65367 更新 2026-08-25 14:53:17 +08:00
Your Name af603a4e9a 更新 2026-08-25 14:16:31 +08:00
Your Name f24afa116f 更新 2026-08-25 11:40:58 +08:00
Your Name b8ccbaf567 更新 2026-08-25 10:45:24 +08:00
Your Name af1db59c07 更新 2026-08-25 09:41:26 +08:00
Your Name 47094cc617 更新 2026-08-25 09:36:25 +08:00
Your Name 01c38d8c5b 更新 2026-08-25 09:35:47 +08:00
Your Name 1f3e580cf8 更新 2026-08-25 09:34:39 +08:00
Your Name 562fe0ea0e 更新 2026-08-24 16:56:37 +08:00
Your Name d9bb94cd3f 更新 2026-08-22 10:50:20 +08:00
Your Name 9646ccd3f6 gengxin 2026-08-22 10:49:34 +08:00
Your Name 43e5411b6a 更新 2026-08-22 10:46:13 +08:00
Your Name c06d293424 更新 2026-08-22 08:51:35 +08:00
Your Name 6c444a4a04 更新 2026-08-20 18:01:36 +08:00
Your Name cc6173e5b0 更新 2026-08-20 17:56:08 +08:00
Your Name 5794f60c5d 更新bug 2026-08-20 17:47:14 +08:00
Your Name 35f91ee37a 更新 2026-08-18 14:14:43 +08:00
Your Name bc1228a310 geng 2026-08-18 14:08:38 +08:00
Your Name 8b9df1154c 更新 2026-08-17 16:52:38 +08:00
Your Name 5f53aa0bbd 更新 2026-08-17 14:06:41 +08:00
981 changed files with 103105 additions and 21672 deletions
+35 -33
View File
@@ -1,33 +1,35 @@
# Build and Release Folders
bin-debug/
bin-release/
[Oo]bj/
[Bb]in/
# Other files and folders
.settings/
# Executables
*.swf
*.air
*.ipa
*.apk
# Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties`
# should NOT be excluded as they contain compiler settings and other important
# information for Eclipse / Flash Builder.
/.idea
/.codex-tasks
/.trellis
/.claude
/.agent
/.shared
/.cursor
/.codex
/.agents
/server/.spool
/server/.claude
/.spool
TUICallKit-Vue3/.env
/.codegraph
# Build and Release Folders
bin-debug/
bin-release/
[Oo]bj/
[Bb]in/
# Other files and folders
.settings/
# Executables
*.swf
*.air
*.ipa
*.apk
# Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties`
# should NOT be excluded as they contain compiler settings and other important
# information for Eclipse / Flash Builder.
# 测试临时目录
app/.test-tmp-stream/
/.idea
/.codex-tasks
/.trellis
/.claude
/.agent
/.shared
/.cursor
/.codex
/.agents
/server/.spool
/server/.claude
/.spool
TUICallKit-Vue3/.env
/.codegraph
app/artifacts/
+90
View File
@@ -0,0 +1,90 @@
# 2026-08-20 — 医生工作站 AI 问诊对话框布局优化
## 改动
- 文件: `app/src/doctor_workstation/ui/dialogs/ai_consult.py`
- 模块: `AiConsultDialog` 右侧「智能分析」卡片 + 问诊对话卡片密度优化
- 新增 helpers: `_InsightItem`, `_split_insight_segments`, `_split_numbered_list`, `_split_plain_paragraph`, `_split_label_or_sentence`, `_escape_html`
- `_set_insights` 改成将稠密段落拆成 lead / list / plain 三类, 每类单独 QLabel 显示
- CSS 新增 `AiConsultInsightMarker` / `AiConsultInsightText` / `AiConsultInsightPlaceholder`
- 卡片 padding `10,8,10,8``14,12,14,14`; 卡间距 `8``10`
- 聊天气泡垂直 margin `4``6`, chat_layout spacing `10``12`
## 拆分策略
- 文本以 `1./2、/3)/4` 起头 → 视为完整编号列表
- 文本中嵌入 `…:1.…2.…` → 拆出序言 + 编号列表
- 短中文标签 `XXYY` 开头 → 视为 lead, 标签部分加粗
- 其余按 `。//;/` 分句 → 单独成行
## 测试
- 现有 `tests/test_ai_consult_ui.py` 通过 `widget.objectName() == "AiConsultRecordTitle"` 校验标题仍在, 未受影响
- 暂未运行 UI 测试 (开发环境未装 PySide6); 解析逻辑单独脚本验证通过
## 注意
- PySide6 QSS 对 `QLabel``line-height` 支持有限; 主要靠 item 间距 `8px``padding:1px 0` 提供节奏
- 如果后续接入 `AiConsultInsightText` 还嫌密集, 可再调 `body_host.setSpacing`
---
# 接诊台 AI 智能分析详情对话框 (`ReceptionAiAnalysisDialog`) 优化
用户截图(`clipboard-2026-08-20T08-42-40`...)反馈: 「问诊详情 → AI 智能分析」卡片里的诊断建议 / 风险评估 / 治疗建议三段文字太稠密, 不利于阅读。
## 关键差异
这次的目标文件和上次不同 — 用户截图里出现 "诊断建议 / 风险评估 / 治疗建议" 标题, 而 `ai_consult.py` 用的是 "血糖控制评估 / 并发症风险评估 / 用药合理性评估"。实际是 `app/src/doctor_workstation/ui/pages/reception.py` 中的 `_ReceptionAiAnalysisDialog` 类。
## 改动
- 文件: `app/src/doctor_workstation/ui/pages/reception.py`
- 新增 helpers (与 ai_consult.py 类似但导出名加 `_ai_` 前缀):
- `_ai_split_into_segments` / `_ai_split_numbered_list` / `_ai_split_plain_paragraph`
- `_ai_segments_to_html` / `_ai_narrative_structured_html`
- `_ai_split_label_or_sentence` / `_ai_escape_html`
- 正则: `_AI_INSIGHT_LIST_RE`, `_AI_INSIGHT_LIST_LOOKAHEAD_RE`, `_AI_INSIGHT_LABEL_RE`
- `add_text_section` 闭包内多挂一个 RichText `_structured_labels[name]` 标签 (`.setObjectName(name + "Structured")`, `property="dialogAiStructured"`)
- 原 PlainText `value_label` 仍存在 (`hide()` + `setMaximumHeight(0)`) 用于兼容既有 findChild 测试
- `_render_model` 同步刷新两个结构化标签; 空 HTML 时回退显示原 label 文本
- 风险评估 FlowLayout spacing `7×7``9×9`
- `body_layout.setSpacing` `12``14`
- 卡片 contentsMargins `14,13,16,15``16,14,18,16`
- QSS: `dialogAiBody``line-height: 1.85`, 新增 `dialogAiStructured` 选择器 (line-height 175%); 风险 pill `min-height` `28``30`, padding `4 10``5 12`, font 12 → 12.5, border-radius `7``8`
## 兼容约束
- `tests/test_reception_parity_ui.py` 严格校验:
- `findChild(QLabel, "ReceptionAiAnalysisDialogDiagnosisText")` 必须能找到
- `.text()` 必须等于 `_ai_narrative_text` 归一化结果
- `.textFormat() == Qt.TextFormat.PlainText`
- 因此 PlainText 标签必须原样保留, 仅做 `.hide()` + `setMaximumHeight(0)` (避免 QSS `>` 子选择器不被 Qt 支持)
## 验证
- `py_compile` 通过, 无 `SyntaxWarning`
- 单独脚本跑过 4 组样本文本 (稠密段落 / 嵌入式编号列表 / 短句 / 纯编号列表), 拆分结果符合预期
- 未在本机跑 UI 测试 (缺 PySide6)
---
# 患者列表 AI 分析: 发送完整患者资料给 AI
用户需求: 患者列表 AI 分析时, 每日血糖报告、舌苔、AI 报告、视频录制回放的文字对话都要发给 AI。
## 改动
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py`
- `_load_workspace` 新增 `list_call_records` section (视频问诊记录, 含 `transcript_text`)
- 模块级纯函数 (无 Qt 依赖, 便于测试):
- `AI_CONTEXT_MAX_CHARS=320`, `AI_PROMPT_LIMIT=500`, `AI_CONTEXT_SEPARATOR="\n\n— 医生提问 —\n"`
- `_truncate_for_context` / `_patient_context_blood_sugar` / `_patient_context_tongue` / `_patient_context_reports` / `_patient_context_prescriptions` / `_patient_context_videos` / `build_patient_ai_context` / `_compose_ai_prompt`
- `build_patient_ai_context` 返回 `(text, labels)`, 按 每日血糖→舌苔/脉象→视频问诊文字→历史AI报告→处方记录 顺序装入 320 字预算的信封
- `_patient_context_videos``diagnosis_id` 过滤归属 (`_exact_positive_id`), 只取最近一条有文字的
- `_compose_ai_prompt` 本地强制 500 字上限: 上下文 + "— 医生提问 —" + 问题, 超长时截断问题加 `…` (服务端 `tcm.diagnosis/aiAssistant` prompt 上限 500)
- `_ChatBubble.attach_extra()` 支持气泡下方挂载控件
- `_render_patient_context`: 聊天区系统气泡 "AI 已加载本诊单上下文(N 项…)", `_attach_context_detail` 挂 "查看本次发送给 AI 的资料全文" 展开按钮 (QSS: `AiConsultContextToggle`/`AiConsultContextReveal`)
- `_ask``_compose_ai_prompt(text, self._patient_ai_context)` 组装后传给 `_AiStreamWorker`; 医生气泡仍显示原始问题
- `open_for` / `_clear_chat` 重置 `_patient_ai_context*` 三个状态, 防止跨患者残留
## 测试
- `app/tests/test_ai_consult_ui.py` 新增 5 个测试 (总 15 个全通过):
纯函数: 全 section 信封+视频按诊单过滤 / 仅基本病历返回空 / 长问题截断≤500
UI: 上下文气泡+展开按钮+视频记录计数 / `_ask` 注入上下文 (FakeWorker 捕获 prompt, monkeypatch `QThreadPool`)
- `app/scripts/check_ai_context.py`: AST 提取纯函数的冒烟脚本 (无需 Qt)
- `tests/test_ai_consult_workspace_ui.py` 18 个测试无回归
## 环境要点 (重要)
- **PySide6 + pytest 在 `app/.venv` 里可用**: 用 `D:/web/zyt/app/.venv/Scripts/python.exe -m pytest` 跑 UI 测试 (QT_QPA_PLATFORM=offscreen)。之前"本机缺 PySide6"的结论只对系统 Python 成立。
- 测试 FakeWorker 需实现 `signals.event/error/finished.connect()``cancel()` (closeEvent 会调 cancel)
+1 -1
View File
@@ -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(/\/+$/, '')
+68 -68
View File
@@ -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"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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"}
File diff suppressed because one or more lines are too long
@@ -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.09 一一对应 */\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.09 一一对应 */\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"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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"}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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": {
+475 -423
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
const { parse, compileScript, compileTemplate, compileStyleAsync } = require('@vue/compiler-sfc')
const root = path.resolve(__dirname, '..')
const read = (relative) => fs.readFileSync(path.join(root, 'src', relative), 'utf8')
const utils = ts.transpileModule(read('utils/appointment-type.ts'), {
compilerOptions: { module: ts.ModuleKind.CommonJS }
}).outputText
const exportsObject = {}
vm.runInNewContext(utils, { exports: exportsObject })
for (const [value, expected] of [
['video', '视频问诊'], ['text', '图文问诊'], [undefined, '视频问诊'],
[null, '视频问诊'], ['', '视频问诊'], [' ', '视频问诊'],
['phone', '电话问诊'], ['other', '未知']
]) {
assert.equal(exportsObject.appointmentTypeDescription(value), expected)
}
const callers = [
'views/tcm/appointment/list.vue',
'views/tcm/appointment/list_h5.vue',
'views/patient/reception/index.vue'
]
for (const caller of callers) {
assert.match(read(caller), /chatDialogRef\.value\?\.open\(\{[\s\S]*?appointmentType: row\.appointment_type,/)
assert.match(read(caller), /appointmentId: Number\(row\.id\)/)
}
const chat = read('components/chat-dialog/index.vue')
assert.match(chat, /appointmentType\.value = data\.appointmentType/)
assert.match(chat, /class="chat-appointment-type">\{\{ appointmentTypeLabel \}\}/)
const form = read('views/tcm/diagnosis/appointment.vue')
assert.match(form, /<el-radio value="text">图文问诊<\/el-radio>/)
assert.match(form, /appointmentType: 'video'/)
assert.match(form, /form\.appointmentType = 'video'/)
assert.match(form, /appointment_type: form\.appointmentType/)
async function main() {
const components = [
...callers, 'components/chat-dialog/index.vue',
'views/tcm/diagnosis/appointment.vue', 'views/consumer/prescription/guahao.vue',
'views/first_visit/my_patients/index.vue', 'views/tcm/diagnosis/index.vue', 'views/tcm/diagnosis/index_h5.vue'
]
for (const filename of components) {
const { descriptor, errors } = parse(read(filename), { filename })
assert.deepEqual(errors, [], `${filename} parses`)
const script = compileScript(descriptor, { id: filename })
const template = compileTemplate({
source: descriptor.template.content,
filename, id: filename,
compilerOptions: { bindingMetadata: script.bindings }
})
assert.deepEqual(template.errors, [], `${filename} template compiles`)
for (const style of descriptor.styles) {
const result = await compileStyleAsync({
source: style.content, filename: path.join(root, 'src', filename),
id: filename, scoped: style.scoped, preprocessLang: style.lang
})
assert.deepEqual(result.errors, [], `${filename} styles compile`)
}
}
console.log(`Appointment type defaults/labels, ${callers.length} chat entry contracts, ${components.length} Vue components: OK`)
}
main().catch((error) => { console.error(error); process.exitCode = 1 })
+5 -2
View File
@@ -100,6 +100,7 @@ export interface OssCredentialsResponse {
host?: string
cdn_domain?: string
key_prefix?: string
object_key?: string
max_size?: number
duration?: number
expired_time?: number
@@ -111,8 +112,10 @@ export interface OssCredentialsResponse {
}
}
export type OssDirectUploadType = 'video' | 'voice' | 'desktop_package'
/** 申请 STS 临时凭证 */
export function getOssCredentials(params: { type: 'video' }) {
export function getOssCredentials(params: { type: OssDirectUploadType; name?: string }) {
return request.post({
url: '/upload/ossCredentials',
params
@@ -121,7 +124,7 @@ export function getOssCredentials(params: { type: 'video' }) {
/** 直传完成回执:写 file 表 + HEAD 校验 */
export function confirmOssUpload(params: {
type: 'video'
type: OssDirectUploadType
key: string
name: string
size: number
+210 -43
View File
@@ -154,6 +154,51 @@ export function firstVisitConversionOverview(params: FirstVisitConversionParams)
)
}
export interface FirstVisitConversionFansDetailParams extends FirstVisitConversionParams {
entity_type: 'dept' | 'member'
entity_id: string | number
admin_id?: number
page_no: number
page_size: number
}
export interface FirstVisitConversionFanDetailRow {
external_userid: string
customer_name: string
wecom_userid: string
wecom_staff_name: string
add_time: string | null
is_deleted?: boolean
delete_time?: string | null
}
export interface FirstVisitConversionFansDetailResult {
can_view_deleted_fans?: boolean
lists?: FirstVisitConversionFanDetailRow[]
rows?: FirstVisitConversionFanDetailRow[]
total?: number
count?: number
page_no?: number
page_size?: number
entity?: {
type?: 'dept' | 'member'
id?: string | number
admin_id?: number
name?: string
add_fans_count?: number
deleted_fans_count?: number
}
date_range?: [string, string]
}
/** 一诊加粉明细:时间、渠道及行实体均由调用方显式透传,服务端仍需按 DataScope 收窄。 */
export function firstVisitConversionFansDetail(params: FirstVisitConversionFansDetailParams) {
return request.get(
{ url: '/firstvisit.conversion/fansDetail', params, timeout: 120000 },
{ ignoreCancelToken: true }
)
}
export interface FirstVisitRegistrationStatsParams {
time_type: 'today' | 'yesterday' | 'week' | 'month'
dept_id?: number
@@ -190,29 +235,151 @@ export function wecomPromotionOverview() {
return request.get({ url: '/firstvisit.wecomPromotion/overview' })
}
export function wecomPromotionSavePool(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params })
}
export function wecomPromotionSavePool(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params, timeout: 120000 })
}
export interface WecomPromotionTagGroup {
group_id: string
group_name: string
tag: Array<{ id: string; name: string }>
}
export function wecomPromotionTagOptions() {
return request.get<{ tag_groups: WecomPromotionTagGroup[] }>({
url: '/firstvisit.wecomPromotion/tagOptions'
})
}
export function wecomPromotionCreateTag(params: { name: string }) {
return request.post<{ tag: { id: string; name: string }; group_id: string; group_name: string; reused: boolean }>({
url: '/firstvisit.wecomPromotion/createTag', params, timeout: 30000
}, { ignoreCancelToken: true })
}
export function wecomPromotionUploadWelcomeMedia(file: File, type: 'image' | 'video' | 'file') {
const data = new FormData()
data.append('file', file)
data.append('type', type)
return request.post<{ asset_id: string; name: string; type: string }>({
url: '/firstvisit.wecomPromotion/uploadWelcomeMedia',
data,
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120000
}, { ignoreCancelToken: true })
}
export function wecomPromotionSaveWidget(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveWidget', params })
}
export function wecomPromotionSaveWidget(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveWidget', params })
}
export interface WecomPromotionBatchSetOperatorsParams {
pool_ids: number[]
operator_admin_ids: number[]
action: 'grant' | 'revoke'
}
export interface WecomPromotionBatchSetOperatorsResult {
action: 'grant' | 'revoke'
pool_ids: number[]
operator_admin_ids: number[]
affected: number
}
export function wecomPromotionBatchSetOperators(params: WecomPromotionBatchSetOperatorsParams) {
return request.post<WecomPromotionBatchSetOperatorsResult>({
url: '/firstvisit.wecomPromotion/batchSetOperators',
params
})
}
export interface WecomPromotionBatchUpdatePoolsParams {
pool_ids: number[]
changes: {
skip_verify?: 0 | 1
fallback_url?: string
status?: 0 | 1
automation_config?: Record<string, unknown>
member_status?: {
member_admin_ids: number[]
status: 0 | 1
}
}
}
export interface WecomPromotionBatchUpdatePoolResult {
id: number
name: string
success: boolean
sync_error?: string
sync_queued?: boolean
sync_status?: WecomPromotionMemberSyncStatus
member_matched?: number
member_updated?: number
error?: string
}
export interface WecomPromotionBatchUpdatePoolsResult {
pool_ids: number[]
updated: number
failed: number
sync_error_count: number
sync_queued_count: number
member_matched: number
member_updated: number
results: WecomPromotionBatchUpdatePoolResult[]
}
export function wecomPromotionBatchUpdatePools(params: WecomPromotionBatchUpdatePoolsParams) {
return request.post<WecomPromotionBatchUpdatePoolsResult>({
url: '/firstvisit.wecomPromotion/batchUpdatePools',
params,
timeout: 120000
}, { ignoreCancelToken: true })
}
export function wecomPromotionDeletePool(params: { id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params, timeout: 120000 })
}
export function wecomPromotionDeletePool(params: { id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params })
}
export function wecomPromotionSaveLink(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
}
export function wecomPromotionSaveLink(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
}
export function wecomPromotionSaveMember(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveMember', params, timeout: 120000 })
}
export function wecomPromotionToggleMember(params: { id: number; status: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/toggleMember', params, timeout: 120000 })
}
export function wecomPromotionCheckApiPermission() {
return request.post({ url: '/firstvisit.wecomPromotion/checkApiPermission' })
}
export function wecomPromotionSyncRemoteLinks(params: { pool_id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 })
}
export function wecomPromotionSyncRemoteLinks(params: { pool_id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 })
}
export type WecomPromotionMemberSyncStatus = 'synced' | 'pending' | 'failed' | 'blocked'
export interface WecomPromotionMemberSyncResult {
pool_id: number
sync_status: WecomPromotionMemberSyncStatus
sync_error: string
sync_queued: boolean
range_userids: string[]
range_department_ids: string[]
}
export function wecomPromotionSyncMemberRange(params: { pool_id: number }) {
return request.post<WecomPromotionMemberSyncResult>({
url: '/firstvisit.wecomPromotion/syncMemberRange',
params,
timeout: 120000
}, { ignoreCancelToken: true, isOpenRetry: false })
}
export function wecomPromotionRemoteLinkDetail(params: { id: number }) {
return request.get({ url: '/firstvisit.wecomPromotion/remoteLinkDetail', params })
@@ -230,27 +397,27 @@ export function wecomPromotionDeleteLink(params: { id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/deleteLink', params })
}
export type WecomPromotionCustomerChatStatus = '' | 'messaged' | 'silent' | 'unknown'
export interface WecomPromotionCustomerStatsParams {
page_no: number
page_size: number
promotion_link_id?: number
userid?: string
chat_status?: 0 | 1 | 2
}
export interface WecomPromotionCustomerStatsSummary {
export type WecomPromotionCustomerChatStatus = '' | 'messaged' | 'silent' | 'unknown'
export interface WecomPromotionCustomerStatsParams {
page_no: number
page_size: number
promotion_link_id?: number
userid?: string
chat_status?: 0 | 1 | 2
}
export interface WecomPromotionCustomerStatsSummary {
customer_count: number
messaged_customer_count: number
message_customer_rate: number
received_message_count: number
message_count_known_count: number
}
export interface WecomPromotionCustomerStatRow {
id?: number
external_userid_masked?: string
received_message_count: number
message_count_known_count: number
}
export interface WecomPromotionCustomerStatRow {
id?: number
userid?: string
external_userid_masked?: string
customer_id_masked?: string
customer_name_masked?: string
link_id?: number | string
@@ -260,21 +427,21 @@ export interface WecomPromotionCustomerStatRow {
department_name?: string
dept_name?: string
has_messaged?: boolean | number
chat_status?: 0 | 1 | 2
message_count_known?: boolean | number
received_message_count?: number
last_synced_at?: string
last_message_at?: string
}
chat_status?: 0 | 1 | 2
message_count_known?: boolean | number
received_message_count?: number
last_synced_at?: string
last_message_at?: string
}
export interface WecomPromotionCustomerStatsResult {
summary?: Partial<WecomPromotionCustomerStatsSummary>
lists?: WecomPromotionCustomerStatRow[]
total?: number
link_options?: Array<{ id: number | string; name: string }>
member_options?: Array<{ id: number; name: string; department_name?: string; dept_name?: string }>
meta?: { last_synced_at?: string }
}
member_options?: Array<{ id: number; userid?: string; name: string; department_name?: string; dept_name?: string }>
meta?: { last_synced_at?: string }
}
/** 获客客户消息统计:服务端继续按当前角色和部门数据范围收窄。 */
export function wecomPromotionCustomerStats(params: WecomPromotionCustomerStatsParams) {
+5
View File
@@ -5,6 +5,11 @@ export function qywxCustomerLists(params: any) {
return request.get({ url: '/qywx.customer/lists', params })
}
// 删除一条本地企业微信客户同步记录
export function qywxCustomerDelete(params: { id: number }) {
return request.post({ url: '/qywx.customer/delete', params })
}
// 同步企业微信客户
export function qywxCustomerSync() {
return request.post({ url: '/qywx.customer/sync' })
@@ -0,0 +1,35 @@
import request from '@/utils/request'
export type DesktopPackageType = 'archive' | 'inno_setup'
export type DesktopPackage = {
url: string
sha256: string
size: number
filename: string
type: DesktopPackageType
}
export type DesktopWorkstationConfig = {
enabled: number
latest_version: string
min_version: string
force_update: number
title: string
notes: string
packages: {
windows_x64: DesktopPackage
macos_arm64: DesktopPackage
macos_x64: DesktopPackage
}
}
export function getDesktopWorkstationConfig() {
return request.get({
url: '/setting.desktop_workstation/getConfig'
}) as Promise<DesktopWorkstationConfig>
}
export function setDesktopWorkstationConfig(params: DesktopWorkstationConfig) {
return request.post({ url: '/setting.desktop_workstation/setConfig', params })
}
+54 -6
View File
@@ -275,14 +275,52 @@ export function generateOrderQrcode(params: any) {
// ========== IM / 企业微信聊天记录 ==========
/** 腾讯云 IM 单聊漫游消息(诊单维度:患者 patient_* 与医生 doctor_* */
export function getImChatMessages(params: { diagnosis_id: number; only_archived?: 0 | 1 }) {
return request.get({ url: '/tcm.diagnosis/getImChatMessages', params })
export interface ImChatMessagesResponse {
lists: any[]
patient_im_id?: string
patient_name?: string
sync_error?: string
}
/** 触发后台异步同步:从腾讯云 IM 拉取诊单聊天记录入归档表,请求即返回 */
export function triggerImChatSync(data: { diagnosis_id: number }) {
return request.post({ url: '/tcm.diagnosis/triggerImChatSync', data })
export interface ImChatSyncProgress {
sync_token?: string
completed: boolean
phase?: 'checking_accounts' | 'syncing' | 'completed'
checked_accounts?: number
candidate_accounts?: number
skipped_accounts?: number
inserted: number
processed_peers: number
total_peers: number
error?: string
errors?: string[]
}
/** 保留接口错误原因,并由面板决定何时提示同步完成。 */
function unwrapImChatResponse<T>(response: { code: number; data: T; msg?: string }): T {
if (Number(response?.code) !== 1) {
const detail = response?.data as any
throw new Error(response?.msg || detail?.error || detail?.message || '聊天记录请求失败')
}
return response.data
}
/** 当前患者所有诊单的 IM 归档消息。 */
export async function getImChatMessages(params: { diagnosis_id: number; only_archived?: 0 | 1 }): Promise<ImChatMessagesResponse> {
const response = await request.get(
{ url: '/tcm.diagnosis/getImChatMessages', params, timeout: 30000 },
{ isTransformResponse: false, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 }
)
return unwrapImChatResponse<ImChatMessagesResponse>(response)
}
/** 同步一页云端消息,继续传入返回的 token,直到 completed 为 true。 */
export async function triggerImChatSync(data: { diagnosis_id: number; sync_token?: string; scope?: 'current' }): Promise<ImChatSyncProgress> {
const response = await request.post(
{ url: '/tcm.diagnosis/triggerImChatSync', data, timeout: 30000 },
{ isTransformResponse: false, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 }
)
return unwrapImChatResponse<ImChatSyncProgress>(response)
}
// 获取企业微信聊天记录
@@ -424,6 +462,11 @@ export function prescriptionOrderEdit(params: Record<string, unknown>) {
return request.post({ url: '/tcm.prescriptionOrder/edit', params })
}
/** 仅修改处方业务订单创建时间,需独立的 editTime 权限 */
export function prescriptionOrderEditTime(params: { id: number; create_time: string }) {
return request.post({ url: '/tcm.prescriptionOrder/editTime', params })
}
/** 仅修改业务订单的承运商与快递单号;所有履约状态均可使用 */
export function prescriptionOrderDdcode(params: {
id: number
@@ -578,6 +621,11 @@ export function prescriptionOrderAddLog(params: {
return request.post({ url: '/tcm.prescriptionOrder/addLog', params })
}
/** 移除单笔收款关联,总金额不变,同步更新已付金额和需代收 */
export function prescriptionOrderUnlinkPayOrder(params: { id: number; pay_order_id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/unlinkPayOrder', params })
}
/** 修改订单金额 */
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
+138 -9
View File
@@ -11,7 +11,10 @@
class="chat-window-header"
@mousedown="onHeaderMouseDown"
>
<span class="chat-window-title" :title="patientName"> {{ patientName }} 通讯</span>
<div class="chat-window-heading">
<span class="chat-window-title" :title="patientName"> {{ patientName }} 通讯</span>
<el-tag size="small" type="info" class="chat-appointment-type">{{ appointmentTypeLabel }}</el-tag>
</div>
<div class="chat-header-actions" @mousedown.stop>
<el-button type="danger" link class="chat-close-btn" @click="handleClose">
<el-icon><Close /></el-icon>
@@ -52,18 +55,19 @@
<ImagePicker />
<FilePicker />
<!-- 仅在 TUICallKit 初始化成功后展示音视频入口避免初始化登录未完成错误 -->
<AudioCallPicker v-if="isCallReady" />
<AudioCallPicker v-if="isCallReady && canAudioCall" />
<!-- 视频接通后 doBindCallRoom 后端 bindCallRoom 会调 CreateCloudRecordingRecordParams.RecordMode=2混流/合流 TrtcCloudRecordingService -->
<VideoCallPicker v-if="isCallReady" />
<VideoCallPicker v-if="isCallReady && canVideoCall" />
<!-- 群组视频通话基于 TUICallKitServer.calls多人通话入口 -->
<el-button
v-if="isCallReady"
v-if="isCallReady && canVideoCall"
size="small"
type="primary"
@click="startGroupVideoCall"
>
群视频
</el-button>
<span v-if="appointmentType === 'text'" class="text-consultation-hint">图文问诊不支持音视频通话</span>
</div>
</template>
</MessageInput>
@@ -135,11 +139,15 @@ import {
bindCallRoom,
endCall,
getCallSignature,
triggerImChatSync,
startCall
} from '@/api/tcm'
import { CallLocalRecorder } from '@/utils/call-local-recorder'
import { createImChatArchiveTrigger } from '@/utils/im-chat-archive-trigger'
import { captureVideoFrameFromElement } from '@/utils/call-video-screenshot'
import feedback from '@/utils/feedback'
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
import { checkAppointmentOutgoingCall, registerAppointmentCallGuard } from '@/utils/appointment-call-guard'
import {
formatTUICallUserError,
getTUICallPackageArrearsMessage,
@@ -180,6 +188,18 @@ const visible = ref(false)
const isReady = ref(false)
const error = ref('')
const patientName = ref('')
const appointmentType = ref<string | null | undefined>('video')
const appointmentId = ref(0)
const confirmedTypeLabel = ref('')
const appointmentTypeLabel = computed(() => confirmedTypeLabel.value || appointmentTypeDescription(appointmentType.value))
const serverAllowsVideo = ref(false)
const serverAllowsAudio = ref(false)
const callDisabledReason = ref('正在确认挂号类型')
const canVideoCall = computed(() => serverAllowsVideo.value && canAppointmentVideoCall(appointmentType.value))
const canAudioCall = computed(() => serverAllowsAudio.value && (canAppointmentVideoCall(appointmentType.value) || appointmentType.value === 'phone'))
let releaseAppointmentCallGuard: (() => void) | undefined
let chatContextVersion = 0
let outgoingCallType = 2
const patientId = ref<number | null>(null)
const diagnosisId = ref<number | null>(null)
const loadingText = ref('正在初始化...')
@@ -191,6 +211,68 @@ const { login, logout } = useLoginState()
const { setActiveConversation, createC2CConversation, activeConversation } = useConversationListState()
const userStore = useUserStore()
const syncChatArchive = createImChatArchiveTrigger(triggerImChatSync, () => {
console.warn('[chat-dialog] 聊天记录归档未完成,可在诊单聊天记录中重试同步')
})
let chatArchiveTimer: ReturnType<typeof setInterval> | undefined
let chatArchiveDebounce: ReturnType<typeof setTimeout> | undefined
function queueChatArchive() {
const id = diagnosisId.value
if (!visible.value || !isReady.value || !id) return
if (chatArchiveDebounce) clearTimeout(chatArchiveDebounce)
chatArchiveDebounce = setTimeout(() => {
chatArchiveDebounce = undefined
void syncChatArchive(id)
}, 500)
}
function stopChatArchiveWatch(flush = false) {
if (chatArchiveTimer) clearInterval(chatArchiveTimer)
if (chatArchiveDebounce) clearTimeout(chatArchiveDebounce)
chatArchiveTimer = undefined
chatArchiveDebounce = undefined
if (flush && diagnosisId.value && isReady.value) void syncChatArchive(diagnosisId.value)
TUIChatEngine?.chat?.off?.(TencentCloudChat.EVENT.CONVERSATION_LIST_UPDATED, queueChatArchive)
}
function startChatArchiveWatch() {
stopChatArchiveWatch()
TUIChatEngine?.chat?.on?.(TencentCloudChat.EVENT.CONVERSATION_LIST_UPDATED, queueChatArchive)
chatArchiveTimer = setInterval(queueChatArchive, 15000)
queueChatArchive()
}
function syncAppointmentCallPolicy(res: {
appointment_type?: string | null; appointment_type_desc?: string;
appointment_id?: number; can_video_call?: boolean; can_audio_call?: boolean; call_disabled_reason?: string
}) {
if ('appointment_type' in res) appointmentType.value = res.appointment_type
confirmedTypeLabel.value = res.appointment_type_desc || ''
serverAllowsVideo.value = res.can_video_call === true
serverAllowsAudio.value = res.can_audio_call === true
callDisabledReason.value = res.call_disabled_reason || '当前挂号不支持该通话方式'
if (Number(res.appointment_id) > 0) appointmentId.value = Number(res.appointment_id)
}
async function prepareAppointmentCall(callType: number) {
const contextVersion = chatContextVersion
const allowed = () => callType === 1 ? canAudioCall.value : canVideoCall.value
if (!visible.value || !allowed()) {
const message = appointmentType.value === 'text' ? '图文问诊不支持音视频通话' : callDisabledReason.value
feedback.msgWarning(message)
throw new Error(message)
}
// 发起前重新读取服务端挂号,防止打开聊天后问诊类型已被修改。
const res = await getCallSignature({
patient_id: patientId.value, diagnosis_id: diagnosisId.value, appointment_id: appointmentId.value
})
if (!visible.value || chatContextVersion !== contextVersion) throw new Error('当前问诊已切换,请重新发起通话')
syncAppointmentCallPolicy(res)
if (!allowed()) {
feedback.msgWarning(callDisabledReason.value)
throw new Error(callDisabledReason.value)
}
outgoingCallType = callType
}
/** TUICallKit CallStatus(与 @tencentcloud/call-uikit-vue 一致) */
const CALL_STATUS_IDLE = 'idle'
const CALL_STATUS_CALLING = 'calling'
@@ -622,6 +704,7 @@ function installTUICallKitRoomHooks() {
const original = server[methodName]
if (typeof original !== 'function') return
server[methodName] = async function patched(...args: unknown[]) {
await checkAppointmentOutgoingCall(args[0])
const result = await original.apply(server, args)
await flushAndBindRoomAfterTUICallApi(methodName)
return result
@@ -689,7 +772,8 @@ async function ensureCallRecordStarted() {
await startCall({
diagnosis_id: diagnosisId.value,
patient_id: patientId.value,
call_type: 2
appointment_id: appointmentId.value,
call_type: outgoingCallType
})
} catch (e) {
console.warn('[chat-dialog] startCall 记录失败', e)
@@ -933,6 +1017,9 @@ onMounted(() => {
})
onUnmounted(() => {
stopChatArchiveWatch(true)
chatContextVersion++
releaseAppointmentCallGuard?.()
clearLocalRecordingStartTimer()
localCallRecorder.reset()
tearDownCallRoomBinding?.()
@@ -995,7 +1082,8 @@ watch(() => activeConversation.value, async (newConversation) => {
try {
const res = await getCallSignature({
patient_id: newConversation.userProfile.userID.replace("patient_", ""),
diagnosis_id: diagnosisId.value
diagnosis_id: diagnosisId.value,
appointment_id: appointmentId.value
})
syncLochostVodFromSignature(res)
patientUserId.value = res.patientUserId
@@ -1010,13 +1098,23 @@ watch(() => activeConversation.value, async (newConversation) => {
}
})
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number }) => {
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number; appointmentId?: number; appointmentType?: string | null }) => {
stopChatArchiveWatch(true)
const contextVersion = ++chatContextVersion
releaseAppointmentCallGuard?.()
releaseAppointmentCallGuard = registerAppointmentCallGuard(prepareAppointmentCall)
visible.value = true
isMinimized.value = false
posX.value = 100
posY.value = 80
resetCallKitPositionToLeft()
patientName.value = data.patientName
appointmentType.value = data.appointmentType
appointmentId.value = Number(data.appointmentId || 0)
confirmedTypeLabel.value = ''
serverAllowsVideo.value = false
serverAllowsAudio.value = false
callDisabledReason.value = '正在确认挂号类型'
patientId.value = data.patientId
diagnosisId.value = data.diagnosisId || null
error.value = ''
@@ -1029,8 +1127,11 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
// 获取医生的签名信息
const res = await getCallSignature({
patient_id: data.patientId,
diagnosis_id: data.diagnosisId || 0
diagnosis_id: data.diagnosisId || 0,
appointment_id: appointmentId.value
})
if (contextVersion !== chatContextVersion || !visible.value) return
syncAppointmentCallPolicy(res)
syncLochostVodFromSignature(res)
assistant_ids.value = res.assistant_id
console.log('后端返回的签名数据:', res)
@@ -1116,7 +1217,9 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
// 等待 UIKit 初始化完成后创建并激活会话
setTimeout(async () => {
if (contextVersion !== chatContextVersion || !visible.value) return
isReady.value = true
startChatArchiveWatch()
// 使用 nextTick 确保组件已渲染
await nextTick()
@@ -1157,6 +1260,10 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
// 群组视频通话(多人通话):当前医生作为发起方,默认先邀请当前会话患者
const startGroupVideoCall = async () => {
if (!canVideoCall.value) {
feedback.msgWarning(appointmentType.value === 'text' ? '图文问诊不支持视频通话' : callDisabledReason.value)
return
}
if (!isCallReady.value) {
feedback.msgWarning('通话服务初始化中,请稍后再试')
return
@@ -1173,7 +1280,8 @@ const startGroupVideoCall = async () => {
console.warn('assistant_id 为空,尝试重新获取')
const res = await getCallSignature({
patient_id: patientId.value!,
diagnosis_id: diagnosisId.value
diagnosis_id: diagnosisId.value,
appointment_id: appointmentId.value
})
syncLochostVodFromSignature(res)
assistant_ids.value = res.assistant_id
@@ -1353,6 +1461,9 @@ const onHeaderMouseUp = () => {
}
const handleClose = async () => {
stopChatArchiveWatch(true)
chatContextVersion++
releaseAppointmentCallGuard?.()
unbindImHangupMessageListener()
// 关闭时如有通话中/拨通中,先结束本地录制再挂断(不依赖悬浮窗是否显示)
if (isCallReady.value) {
@@ -1426,6 +1537,18 @@ defineExpose({ open })
justify-content: space-between;
flex-shrink: 0;
.chat-window-heading {
display: flex;
align-items: center;
min-width: 0;
gap: 4px;
}
.chat-appointment-type {
flex-shrink: 0;
margin-right: 8px;
}
.chat-window-title {
font-size: 16px;
font-weight: 500;
@@ -1510,6 +1633,12 @@ defineExpose({ open })
}
/* 消息工具栏样式 */
.text-consultation-hint {
color: #909399;
font-size: 12px;
line-height: 20px;
}
.message-toolbar {
display: flex;
gap: 8px;
+23 -8
View File
@@ -51,8 +51,10 @@ import useAppStore from '@/stores/modules/app'
import useUserStore from '@/stores/modules/user'
import feedback from '@/utils/feedback'
import {
DirectUploadApiError,
DirectUploadFallbackError,
uploadVideoDirectToCos
uploadDirectToCos,
type DirectUploadType
} from '@/utils/oss-direct-upload'
export default defineComponent({
@@ -83,7 +85,7 @@ export default defineComponent({
type: Boolean,
default: false
},
// 视频直传到 OSS绕开服务器中转,仅 type=video 生效)
// 直传到对象存储,绕开服务器中转
direct: {
type: Boolean,
default: false
@@ -102,8 +104,10 @@ export default defineComponent({
const visible = ref(false)
const fileList = ref<any[]>([])
// 仅 video/voice + direct 时才接管 http-request
const useDirect = computed(() => props.direct && ['video', 'voice'].includes(props.type))
const directTypes: DirectUploadType[] = ['video', 'voice', 'desktop_package']
const useDirect = computed(
() => props.direct && directTypes.includes(props.type as DirectUploadType)
)
const handleProgress = () => {
visible.value = true
@@ -131,7 +135,10 @@ export default defineComponent({
fileList.value = []
emit('allSuccess')
}
feedback.msgError(`${file.name}文件上传失败`)
if (!(event instanceof DirectUploadApiError)) {
const message = event instanceof Error ? event.message : ''
feedback.msgError(message || `${file.name}文件上传失败`)
}
uploadRefs.value?.abort(file)
visible.value = false
emit('change', file)
@@ -153,18 +160,20 @@ export default defineComponent({
return '.wmv,.avi,.mpg,.mpeg,.3gp,.mov,.mp4,.flv,.rmvb,.mkv'
case 'voice':
return '.mp3,.wav,.wma,.m4a,.aac,.amr'
case 'desktop_package':
return '.exe,.zip'
default:
return '*'
}
})
// 走 COS 直传:成功时模拟老接口的响应 envelope,失败/降级时回到默认 XHR
// 走 COS 直传:成功时模拟老接口的响应 envelope
const httpRequest = async (options: UploadRequestOptions) => {
visible.value = true
try {
const data = await uploadVideoDirectToCos({
const data = await uploadDirectToCos({
file: options.file,
type: props.type as any,
type: props.type as DirectUploadType,
cid: Number((options.data as any)?.cid ?? 0),
onProgress(info) {
// 触发 ElUpload 内部进度(保持与默认上传一致的体验)
@@ -178,6 +187,12 @@ export default defineComponent({
;(options as any).onSuccess?.({ code: RequestCodeEnum.SUCCESS, msg: 'ok', data })
} catch (err: any) {
if (err instanceof DirectUploadFallbackError) {
if (props.type === 'desktop_package') {
;(options as any).onError?.(
new Error('当前未启用腾讯云 COS,安装包无法直传,请配置 COS 后重试')
)
return
}
feedback.msgWarning('当前存储不支持直传,已切换为普通上传')
await defaultXhrUpload(options)
return
+19
View File
@@ -0,0 +1,19 @@
type OutgoingCallGuard = (callType: number) => Promise<void>
// 通话 SDK 为全局单例;使用当前窗口的校验器,避免首个组件闭包保留旧患者类型。
let activeGuard: OutgoingCallGuard | undefined
export function registerAppointmentCallGuard(guard: OutgoingCallGuard): () => void {
activeGuard = guard
return () => {
if (activeGuard === guard) activeGuard = undefined
}
}
export async function checkAppointmentOutgoingCall(params: unknown): Promise<void> {
const guard = activeGuard
if (!guard) throw new Error('请先打开本次挂号的聊天窗口')
const type = params && typeof params === 'object' && 'type' in params ? Number(params.type) : 2
await guard(type === 1 ? 1 : 2)
if (activeGuard !== guard) throw new Error('当前问诊已切换,请重新发起通话')
}
+13
View File
@@ -0,0 +1,13 @@
/** 展示当前挂号的问诊方式;旧记录的空值默认视频。 */
export function appointmentTypeDescription(value?: string | null): string {
if (value == null || value.trim() === '') return '视频问诊'
if (value === 'text') return '图文问诊'
if (value === 'video') return '视频问诊'
if (value === 'phone') return '电话问诊'
return '未知'
}
/** 图文及未知问诊方式不提供视频;只有已存在挂号的历史空值沿用视频。 */
export function canAppointmentVideoCall(value?: string | null): boolean {
return value == null || value.trim() === '' || value === 'video'
}
@@ -0,0 +1,42 @@
/** 合并聊天窗口的收发事件;每次同步只接受后端读取的云端消息,不上报客户端正文。 */
export function createImChatArchiveTrigger(
syncPage: (params: { diagnosis_id: number; scope: 'current'; sync_token?: string }) => Promise<{
sync_token?: string; completed: boolean; errors?: string[]
}>,
onError: (error: unknown) => void = () => {}
) {
const jobs = new Map<number, { again: boolean; promise: Promise<void> }>()
return (diagnosisId: number): Promise<void> => {
if (!Number.isInteger(diagnosisId) || diagnosisId <= 0) return Promise.resolve()
const pending = jobs.get(diagnosisId)
if (pending) {
pending.again = true
return pending.promise
}
const job = { again: false, promise: Promise.resolve() }
jobs.set(diagnosisId, job)
job.promise = (async () => {
try {
do {
job.again = false
let token: string | undefined
for (;;) {
const result = await syncPage({ diagnosis_id: diagnosisId, scope: 'current', sync_token: token })
if (result.completed) {
if (result.errors?.length) throw new Error(result.errors.join(''))
break
}
if (!result.sync_token) throw new Error('聊天记录同步未返回进度')
token = result.sync_token
}
// 同步期间收到新消息时再追一次,覆盖新消息晚于本轮首页的情况。
} while (job.again)
} catch (error) {
onError(error)
} finally {
jobs.delete(diagnosisId)
}
})()
return job.promise
}
}
+245
View File
@@ -0,0 +1,245 @@
import { reactive } from 'vue'
import type { ImChatMessagesResponse, ImChatSyncProgress } from '@/api/tcm'
type NoticeKind = 'success' | 'warning' | 'error'
type TimerHandle = ReturnType<typeof setTimeout>
interface HistoryDependencies {
load: (diagnosisId: number) => Promise<ImChatMessagesResponse>
sync: (diagnosisId: number, token?: string) => Promise<ImChatSyncProgress>
notify?: (kind: NoticeKind, message: string) => void
now?: () => number
setTimer?: (callback: () => void, delay: number) => TimerHandle
clearTimer?: (timer: TimerHandle) => void
visible?: boolean
}
interface Session {
diagnosisId: number
generation: number
archiveRequest?: Promise<boolean>
syncRequest?: Promise<void>
}
/** Each visible diagnosis owns its requests and one timer; late responses cannot update another patient. */
export function createImChatHistory(deps: HistoryDependencies) {
const state = reactive({
rows: [] as any[],
patientImId: '',
patientName: '',
loading: false,
syncing: false,
hasLoaded: false,
readError: '',
syncError: '',
partialErrors: [] as string[],
inserted: 0,
processedPeers: 0,
totalPeers: 0,
phase: '',
checkedAccounts: 0,
candidateAccounts: 0,
skippedAccounts: 0,
lastSyncAt: null as number | null
})
const now = deps.now ?? Date.now
const setTimer = deps.setTimer ?? setTimeout
const clearTimer = deps.clearTimer ?? clearTimeout
let diagnosisId = 0
let generation = 0
let visible = deps.visible ?? true
let disposed = false
let session: Session | undefined
let timer: TimerHandle | undefined
function current(target: Session) {
return !disposed && visible && target === session && target.generation === generation && target.diagnosisId === diagnosisId
}
function clearScheduledSync() {
if (timer !== undefined) clearTimer(timer)
timer = undefined
}
function invalidate() {
generation++
clearScheduledSync()
session = undefined
state.loading = false
state.syncing = false
}
async function loadArchived(target: Session, freshAfterPending = false): Promise<boolean> {
if (!current(target)) return false
if (target.archiveRequest) {
const result = await target.archiveRequest
if (!freshAfterPending || !current(target)) return result
return loadArchived(target)
}
state.loading = true
const request = Promise.resolve().then(async () => {
if (!current(target)) return false
try {
const response = await deps.load(target.diagnosisId)
if (!current(target)) return false
if (!Array.isArray(response?.lists)) throw new Error('聊天记录接口返回的数据不完整')
state.rows = response.lists
state.patientImId = response.patient_im_id || ''
state.patientName = response.patient_name || ''
state.hasLoaded = true
state.readError = ''
return true
} catch (error) {
if (current(target)) state.readError = imChatErrorMessage(error, '读取聊天记录失败')
return false
}
})
target.archiveRequest = request
try {
return await request
} finally {
if (target.archiveRequest === request) target.archiveRequest = undefined
if (current(target)) state.loading = false
}
}
function scheduleSync(target: Session) {
clearScheduledSync()
if (!current(target)) return
timer = setTimer(() => {
timer = undefined
if (current(target)) void sync(target)
}, 30000)
}
async function sync(target: Session, manual = false): Promise<void> {
if (!current(target)) return
if (target.syncRequest) return target.syncRequest
clearScheduledSync()
state.syncing = true
state.syncError = ''
state.partialErrors = []
state.inserted = 0
state.processedPeers = 0
state.totalPeers = 0
state.phase = 'checking_accounts'
state.checkedAccounts = 0
state.candidateAccounts = 0
state.skippedAccounts = 0
const request = Promise.resolve().then(async () => {
let token: string | undefined
let pagesSinceRefresh = 0
let lastRefreshAt = now()
const errors = new Set<string>()
try {
while (current(target)) {
const progress = await deps.sync(target.diagnosisId, token)
if (!current(target)) return
if (typeof progress?.completed !== 'boolean') throw new Error('同步接口未返回有效的完成状态')
state.inserted = Number(progress.inserted) || 0
state.processedPeers = Number(progress.processed_peers) || 0
state.totalPeers = Number(progress.total_peers) || 0
state.phase = progress.phase || 'syncing'
state.checkedAccounts = Number(progress.checked_accounts) || 0
state.candidateAccounts = Number(progress.candidate_accounts) || 0
state.skippedAccounts = Number(progress.skipped_accounts) || 0
for (const error of [progress.error, ...(progress.errors || [])]) {
if (typeof error === 'string' && error.trim()) errors.add(error)
}
state.partialErrors = [...errors]
if (progress.completed) {
const refreshed = await loadArchived(target, true)
if (!current(target)) return
state.lastSyncAt = now()
if (manual) {
if (errors.size) deps.notify?.('warning', `同步完成,部分会话失败:${[...errors].join('')}`)
else if (!refreshed) deps.notify?.('warning', `同步已完成,但读取聊天记录失败:${state.readError}`)
else deps.notify?.('success', `聊天记录已更新,本次新增 ${state.inserted}`)
}
return
}
if (!progress.sync_token) throw new Error('同步接口未返回继续同步所需的进度标识')
token = progress.sync_token
pagesSinceRefresh++
if (pagesSinceRefresh >= 3 || now() - lastRefreshAt >= 2000) {
await loadArchived(target, true)
pagesSinceRefresh = 0
lastRefreshAt = now()
}
}
} catch (error) {
if (!current(target)) return
state.syncError = imChatErrorMessage(error, '同步聊天记录失败')
if (manual) deps.notify?.('error', state.syncError)
}
})
target.syncRequest = request
try {
await request
} finally {
if (target.syncRequest === request) target.syncRequest = undefined
if (current(target)) {
state.syncing = false
scheduleSync(target)
}
}
}
function start() {
if (disposed || !visible || !diagnosisId) return
const target: Session = { diagnosisId, generation }
session = target
void loadArchived(target)
void sync(target)
}
function setDiagnosis(value: number) {
const nextId = Number(value) > 0 ? Number(value) : 0
if (disposed || (nextId === diagnosisId && session)) return
invalidate()
diagnosisId = nextId
state.rows = []
state.patientImId = ''
state.patientName = ''
state.hasLoaded = false
state.readError = ''
state.syncError = ''
state.partialErrors = []
state.inserted = 0
state.processedPeers = 0
state.totalPeers = 0
state.phase = ''
state.checkedAccounts = 0
state.candidateAccounts = 0
state.skippedAccounts = 0
state.lastSyncAt = null
start()
}
function setVisible(value: boolean) {
if (disposed || value === visible) return
visible = value
invalidate()
if (visible) start()
}
function dispose() {
disposed = true
invalidate()
}
return {
state,
setDiagnosis,
setVisible,
dispose,
reload: () => session ? loadArchived(session) : Promise.resolve(false),
sync: () => session ? sync(session, true) : Promise.resolve()
}
}
export function imChatErrorMessage(error: unknown, fallback: string): string {
if (typeof error === 'string' && error.trim()) return error
const detail = error as any
return detail?.response?.data?.msg || detail?.msg || detail?.error || detail?.message || fallback
}
+43 -15
View File
@@ -3,10 +3,11 @@ import COS from 'cos-js-sdk-v5'
import {
confirmOssUpload,
getOssCredentials,
type OssCredentialsResponse
type OssCredentialsResponse,
type OssDirectUploadType
} from '@/api/file'
export type DirectUploadType = 'video'
export type DirectUploadType = OssDirectUploadType
export interface DirectUploadProgress {
/** 0-100 */
@@ -37,8 +38,17 @@ export interface DirectUploadOptions {
const SLICE_SIZE = 5 * 1024 * 1024 // 5MB
const ASYNC_LIMIT = 3
async function callDirectUploadApi<T>(request: () => Promise<T>): Promise<T> {
try {
return await request()
} catch (error) {
// request 拦截器已经展示过接口/网络错误,上传组件只负责收口失败状态
throw new DirectUploadApiError(error)
}
}
function buildKey(prefix: string, file: File): string {
const ext = (file.name.split('.').pop() || 'mp4').toLowerCase()
const ext = (file.name.split('.').pop() || 'bin').toLowerCase()
const ts = Date.now()
const rand = Math.random().toString(36).slice(2, 10)
return `${prefix}${ts}-${rand}.${ext}`
@@ -48,8 +58,13 @@ function buildKey(prefix: string, file: File): string {
* 直传到腾讯云 COS(含 STS 凭证申请、分片上传、回执)
* 不支持降级 / fallback=true 时抛错,由调用方决定走老链路。
*/
export async function uploadVideoDirectToCos(options: DirectUploadOptions): Promise<DirectUploadResult> {
const credentials: OssCredentialsResponse = await getOssCredentials({ type: options.type })
export async function uploadDirectToCos(options: DirectUploadOptions): Promise<DirectUploadResult> {
const credentials: OssCredentialsResponse = await callDirectUploadApi(() =>
getOssCredentials({
type: options.type,
name: options.file.name
})
)
if (credentials.fallback) {
const handled = options.onFallback?.(credentials.provider) ?? false
@@ -66,7 +81,7 @@ export async function uploadVideoDirectToCos(options: DirectUploadOptions): Prom
if (credentials.max_size && options.file.size > credentials.max_size) {
const mb = Math.round(credentials.max_size / 1024 / 1024)
throw new Error(`视频体积超出上限(${mb}MB`)
throw new Error(`文件体积超出上限(${mb}MB`)
}
const cred = credentials.credentials
@@ -85,7 +100,7 @@ export async function uploadVideoDirectToCos(options: DirectUploadOptions): Prom
}
})
const key = buildKey(credentials.key_prefix, options.file)
const key = credentials.object_key || buildKey(credentials.key_prefix, options.file)
await new Promise<void>((resolve, reject) => {
cos.uploadFile(
@@ -117,14 +132,16 @@ export async function uploadVideoDirectToCos(options: DirectUploadOptions): Prom
)
})
const confirmed = await confirmOssUpload({
type: options.type,
key,
name: options.file.name,
size: options.file.size,
content_type: options.file.type || '',
cid: options.cid ?? 0
})
const confirmed = await callDirectUploadApi(() =>
confirmOssUpload({
type: options.type,
key,
name: options.file.name,
size: options.file.size,
content_type: options.file.type || '',
cid: options.cid ?? 0
})
)
options.onProgress?.({ percent: 100, loaded: options.file.size, total: options.file.size, speed: 0 })
@@ -140,3 +157,14 @@ export class DirectUploadFallbackError extends Error {
this.provider = provider
}
}
/** 请求层已经展示过错误,避免 ElUpload 再弹一条通用失败提示。 */
export class DirectUploadApiError extends Error {
readonly originalError: unknown
constructor(error: unknown) {
super('')
this.name = 'DirectUploadApiError'
this.originalError = error
}
}
@@ -541,6 +541,19 @@
{{ formatOrderTime(row.create_time) }}
</template>
</el-table-column>
<el-table-column v-if="canUnlinkPayOrder" label="操作" width="80" fixed="right" align="center">
<template #default="{ row }">
<el-button
type="danger"
size="small"
link
:loading="unlinkPayOrderId === Number(row.id)"
:disabled="unlinkPayOrderId !== null || ![2, 5].includes(Number(row.status))"
:title="Number(row.status) === 4 ? '已退款收款记录不可移除' : '解除关联并更新已付金额,总金额不变'"
@click="confirmUnlinkPayOrder(row)"
>移除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="未关联收款单" :image-size="60" />
</el-card>
@@ -1014,6 +1027,7 @@ import {
prescriptionOrderLogisticsTrace,
prescriptionOrderLogisticsJdUpdate,
prescriptionOrderPaidPayOrders,
prescriptionOrderUnlinkPayOrder,
prescriptionOrderPatchPrescriptionUsage
} from '@/api/tcm'
import { getDictData } from '@/api/app'
@@ -1107,6 +1121,38 @@ const detailLoading = ref(false)
const detailData = ref<Record<string, any> | null>(null)
const detailLogs = ref<any[]>([])
const detailUnlinkedPayOrders = ref<any[]>([])
const unlinkPayOrderId = ref<number | null>(null)
const canUnlinkPayOrder = computed(() =>
!props.readonly && canUpdateAmount(detailData.value) && hasPerm('tcm.prescriptionOrder/unlinkPayOrder')
)
async function confirmUnlinkPayOrder(row: { id: number; order_no?: string; amount: number | string; status: number | string }) {
const orderId = Number(detailData.value?.id)
const payOrderId = Number(row.id)
if (!canUnlinkPayOrder.value || !orderId || !payOrderId || unlinkPayOrderId.value !== null) return
if (![2, 5].includes(Number(row.status))) return
unlinkPayOrderId.value = payOrderId
try {
await feedback.confirm(
`确定移除收款记录「${row.order_no || '#' + payOrderId}」(¥${formatMoney(row.amount)})?` +
'仅解除关联,原收款记录保留;订单总金额不变,已付金额与需代收金额按剩余关联收款重新计算。'
)
if (!detailVisible.value || Number(detailData.value?.id) !== orderId) return
const res: any = await prescriptionOrderUnlinkPayOrder({ id: orderId, pay_order_id: payOrderId })
const updated = res?.data ?? res
if (Number(detailData.value?.id) === orderId && Number(updated?.id) === orderId) {
Object.assign(detailData.value!, updated)
}
feedback.msgSuccess('收款关联已移除,金额已同步更新')
emit('detail-changed')
await refreshIfCurrent(orderId)
} catch {
/* 取消不提交;接口错误由拦截器提示,保留当前收款记录 */
} finally {
unlinkPayOrderId.value = null
}
}
const detailPrescription = computed(() => {
const p = detailData.value?.prescription
@@ -0,0 +1,93 @@
<template>
<el-dialog
v-model="visible"
title="修改订单创建时间"
width="min(440px, 94vw)"
append-to-body
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="!submitting"
:show-close="!submitting"
>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="业务订单">
<span class="break-all">{{ orderNo || `#${form.id}` }}</span>
</el-form-item>
<el-form-item label="创建时间" prop="create_time">
<el-date-picker
v-model="form.create_time"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择创建时间"
:clearable="false"
:disabled="submitting"
class="!w-full"
/>
</el-form-item>
</el-form>
<p class="text-xs text-gray-500">保存后列表和业绩统计将按新的创建时间归属日期修改记录可在订单日志中查看</p>
<template #footer>
<el-button :disabled="submitting" @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { nextTick, reactive, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { prescriptionOrderEditTime } from '@/api/tcm'
import feedback from '@/utils/feedback'
const emit = defineEmits<{ (event: 'saved', id: number): void }>()
const visible = ref(false)
const submitting = ref(false)
const orderNo = ref('')
const formRef = ref<FormInstance>()
const form = reactive({ id: 0, create_time: '' })
const rules: FormRules = {
create_time: [{ required: true, message: '请选择创建时间', trigger: 'change' }]
}
// 列表可能返回日期字符串或历史 Unix 时间戳;保留秒,避免只打开弹窗就丢失精度。
function editableTime(value: unknown): string {
const raw = String(value ?? '').trim()
if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(raw)) {
return raw.replace('T', ' ').padEnd(19, ':00')
}
if (!/^\d+$/.test(raw) || Number(raw) <= 0) return ''
const timestamp = Number(raw)
const date = new Date(timestamp < 1e11 ? timestamp * 1000 : timestamp)
if (Number.isNaN(date.getTime())) return ''
const pad = (part: number) => String(part).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
function open(row: { id?: unknown; order_no?: unknown; create_time?: unknown }) {
if (submitting.value || !Number(row.id)) return
form.id = Number(row.id)
form.create_time = editableTime(row.create_time)
orderNo.value = String(row.order_no || '')
visible.value = true
nextTick(() => formRef.value?.clearValidate())
}
async function submit() {
if (submitting.value || !formRef.value) return
submitting.value = true
try {
if (!(await formRef.value.validate().catch(() => false))) return
await prescriptionOrderEditTime({ id: form.id, create_time: form.create_time })
feedback.msgSuccess('订单创建时间已修改')
visible.value = false
emit('saved', form.id)
} catch {
// 请求拦截器展示错误,保留已填写的时间便于重试。
} finally {
submitting.value = false
}
}
defineExpose({ open })
</script>
@@ -168,6 +168,7 @@ export function logActionText(act: string) {
ship: '确认发货',
withdraw: '撤销',
link_pay_order: '关联支付单',
unlink_pay_order: '移除收款关联',
completion_request: '完单申请',
auto_complete: '自动完成',
revoke_rx_audit: '撤回处方审核',
@@ -177,7 +178,8 @@ export function logActionText(act: string) {
ej_pharmacy_callback: '洛阳药房状态',
patch_rx_patient: '处方患者信息',
patch_rx_usage: '服用参数',
update_amount: '修改订单金额',
update_amount: '修改订单金额',
edit_time: '修改创建时间',
complete: '完成订单',
refund: '退款',
manual_log: '手工备注',
@@ -42,6 +42,13 @@
/>
</el-select>
</el-form-item>
<el-form-item label="挂号类型">
<el-select v-model="queryParams.appointment_type" placeholder="全部" clearable class="!w-[140px]">
<el-option label="全部" value="" />
<el-option label="视频问诊" value="video" />
<el-option label="图文问诊" value="text" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="全部" clearable class="!w-[130px]">
<el-option label="已预约" :value="1" />
@@ -115,7 +122,9 @@
<div class="text-gray-500">{{ formatHm(row.appointment_time) }} · {{ row.period_desc }}</div>
</template>
</el-table-column>
<el-table-column label="类型" prop="appointment_type_desc" width="100" />
<el-table-column label="挂号类型" width="110">
<template #default="{ row }">{{ appointmentTypeDescription(row.appointment_type) }}</template>
</el-table-column>
<el-table-column label="渠道" min-width="130" show-overflow-tooltip>
<template #default="{ row }">
<div class="text-sm">{{ row.channel_source_desc || '—' }}</div>
@@ -178,11 +187,10 @@
<el-radio-button label="all">全天</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="问诊类型" required>
<el-form-item label="挂号类型" required>
<el-select v-model="editForm.appointment_type" class="!w-full">
<el-option label="视频问诊" value="video" />
<el-option label="图文问诊" value="text" />
<el-option label="电话问诊" value="phone" />
</el-select>
</el-form-item>
<el-form-item label="渠道来源" required>
@@ -293,6 +301,7 @@
import DaterangePicker from '@/components/daterange-picker/index.vue'
import { getDictData } from '@/api/app'
import { appointmentAdminEdit, appointmentBatchEditChannel, appointmentLists } from '@/api/doctor'
import { appointmentTypeDescription } from '@/utils/appointment-type'
import { getAssistants } from '@/api/tcm'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
@@ -303,6 +312,7 @@ const queryParams = reactive({
patient_name: '',
doctor_name: '',
assistant_id: undefined as number | undefined,
appointment_type: '',
status: '' as number | '',
channel_source: '' as string
})
@@ -313,6 +323,7 @@ const queryInit = {
patient_name: '',
doctor_name: '',
assistant_id: undefined as number | undefined,
appointment_type: '',
status: '' as number | '',
channel_source: ''
}
@@ -331,9 +342,8 @@ function onSelectionChange(rows: Record<string, unknown>[]) {
selectedRows.value = rows
}
/** 与诊单预约弹窗一致:这些字典 name 需填「自媒体补充」 */
/** 与诊单预约弹窗一致:自媒体4H/4Q 无需补充,仅以下字典 name 需填「自媒体补充」 */
const CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL = new Set([
'自媒体4H',
'自媒体3Q',
'自媒体3H',
'自媒体2H',
@@ -1585,7 +1585,7 @@ import DaterangePicker from '@/components/daterange-picker/index.vue'
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
import PrescriptionSlip from '@/components/prescription-slip/index.vue'
import { Search, Download, Printer, EditPen } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue'
import { computed, onMounted, onActivated, onDeactivated, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue'
import { useRouter } from 'vue-router'
import html2canvas from 'html2canvas'
import jsPDF from 'jspdf'
@@ -3882,7 +3882,18 @@ const handleDelete = async (id: number) => {
getLists()
}
onMounted(async () => {
// 从订单页返回缓存的处方列表时,重新读取退款后的占用状态。
let refreshOnReturn = false
onDeactivated(() => {
refreshOnReturn = true
})
onActivated(() => {
if (!refreshOnReturn) return
refreshOnReturn = false
getLists()
})
onMounted(async () => {
await loadRoleOptions()
await loadRegionData()
await loadServicePackageOptions()
@@ -550,7 +550,13 @@
type="primary"
link
@click="openEdit(row)"
>编辑</el-button>
>编辑</el-button>
<el-button
v-perms="['tcm.prescriptionOrder/editTime']"
type="primary"
link
@click="orderTimeDialogRef?.open(row)"
>修改创建时间</el-button>
<el-button
v-if="canQuickTrackRow(row)"
v-perms="['tcm.prescriptionOrder/ddcode']"
@@ -649,7 +655,12 @@
>{{ shipModeLabel(detail.ship_mode) }}</el-tag>
</div>
</template>
<template #header-actions="{ detail }">
<template #header-actions="{ detail }">
<el-button
v-perms="['tcm.prescriptionOrder/editTime']"
size="small"
@click="orderTimeDialogRef?.open(detail)"
>修改创建时间</el-button>
<gancao-submission-reconcile-button
:order="detail"
@resolved="handleGancaoSubmissionResolved"
@@ -1511,7 +1522,9 @@
</el-button>
</template>
</el-dialog>
<!-- 订单退款须填写原因 -->
<prescription-order-time-dialog ref="orderTimeDialogRef" @saved="onOrderTimeSaved" />
<!-- 订单退款须填写原因 -->
<el-dialog
v-model="refundOrderDialogVisible"
title="订单退款"
@@ -2031,7 +2044,8 @@ import { computed, onMounted, reactive, ref, nextTick, watch, defineAsyncCompone
import { useRoute } from 'vue-router'
import { ArrowDown, InfoFilled, QuestionFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue'
import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue'
import PrescriptionOrderTimeDialog from './components/PrescriptionOrderTimeDialog.vue'
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
import {
TCM_ASSISTANT_ROLE_ID,
@@ -3087,7 +3101,13 @@ function canUploadPharmacyRow(row: {
}
// ─── 详情抽屉(共享组件 PrescriptionOrderDetailDrawer):状态桥接 ───
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
const orderTimeDialogRef = ref<InstanceType<typeof PrescriptionOrderTimeDialog>>()
async function onOrderTimeSaved(id: number) {
getLists()
await detailDrawerRef.value?.refreshIfCurrent(id)
}
/** 当前详情数据(组件内部 ref 的桥接;可原地修改属性,整体刷新请用 detailDrawerRef.refresh() */
const detailData = computed(() => (detailDrawerRef.value?.detail ?? null) as Record<string, any> | null)
@@ -474,7 +474,12 @@
v-perms="['tcm.prescriptionOrder/edit']"
size="small"
@click="openEdit(row)"
>编辑</el-button>
>编辑</el-button>
<el-button
v-perms="['tcm.prescriptionOrder/editTime']"
size="small"
@click="orderTimeDialogRef?.open(row)"
>修改创建时间</el-button>
<el-button
v-if="canQuickTrackRow(row)"
v-perms="['tcm.prescriptionOrder/ddcode']"
@@ -592,8 +597,13 @@
>
撤回支付审核
</el-button>
<el-button
v-if="canShipRow(detailData)"
<el-button
v-perms="['tcm.prescriptionOrder/editTime']"
size="small"
@click="orderTimeDialogRef?.open(detailData)"
>修改创建时间</el-button>
<el-button
v-if="canShipRow(detailData)"
v-perms="['tcm.prescriptionOrder/ship']"
type="primary"
size="small"
@@ -2566,12 +2576,13 @@
</div>
</el-drawer>
<!-- consumer/prescription/index诊单 edit 同一套界面只读诊单与全部分页签 -->
<TcmDiagnosisEditView ref="diagnosisViewRef" />
<!-- consumer/prescription/index诊单 edit 同一套界面只读诊单与全部分页签 -->
<TcmDiagnosisEditView ref="diagnosisViewRef" />
<prescription-order-time-dialog ref="orderTimeDialogRef" @saved="onOrderTimeSaved" />
</div>
</template>
<script lang="ts" setup name="prescriptionOrderListH5">
<script lang="ts" setup name="prescriptionOrderListH5">
import { computed, onMounted, reactive, ref, nextTick, watch, defineAsyncComponent } from 'vue'
import {
Refresh,
@@ -2585,7 +2596,8 @@ import {
Wallet,
User
} from '@element-plus/icons-vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import PrescriptionOrderTimeDialog from './components/PrescriptionOrderTimeDialog.vue'
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
import {
@@ -3513,7 +3525,15 @@ function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | nul
const detailVisible = ref(false)
const detailLoading = ref(false)
const detailData = ref<Record<string, any> | null>(null)
const detailData = ref<Record<string, any> | null>(null)
const orderTimeDialogRef = ref<InstanceType<typeof PrescriptionOrderTimeDialog>>()
async function onOrderTimeSaved(id: number) {
getLists()
if (detailVisible.value && Number(detailData.value?.id) === id) {
await openDetail(id)
}
}
const detailServicePackageText = computed(() =>
formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
@@ -3838,7 +3858,8 @@ function logActionText(act: string) {
patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额',
complete: '完成订单',
refund: '退款'
refund: '退款',
edit_time: '修改创建时间'
}
return m[act] || act
}
+299 -21
View File
@@ -94,15 +94,32 @@
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item label="跟进人">
<el-input
v-model="queryParams.follow_user"
<el-form-item label="跟进人">
<el-input
v-model="queryParams.follow_user"
placeholder="跟进人姓名(后台姓名或企微账号)"
clearable
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item label="添加时间">
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item label="渠道">
<el-select
v-model="queryParams.add_way"
clearable
filterable
placeholder="选择或搜索添加渠道"
style="width: 240px"
@change="resetPage"
>
<el-option
v-for="option in ADD_WAY_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="添加时间">
<el-date-picker
v-model="addTimeRange"
type="daterange"
@@ -235,6 +252,32 @@
<span v-else class="text-gray-400"></span>
</template>
</el-table-column>
<el-table-column label="添加渠道" min-width="160">
<template #default="{ row }">
<div v-if="customerAddSources(row).length" class="flex items-center gap-1">
<el-tooltip
v-for="source in customerAddSources(row).slice(0, 1)"
:key="source.key"
:content="addSourceTooltip(source)"
placement="top"
>
<span class="inline-block max-w-[150px] truncate align-middle">
{{ source.label }}
</span>
</el-tooltip>
<el-tooltip
v-if="customerAddSources(row).length > 1"
:content="remainingAddSourcesTooltip(row)"
placement="top"
>
<span class="text-primary whitespace-nowrap cursor-help">
{{ customerAddSources(row).length - 1 }}
</span>
</el-tooltip>
</div>
<span v-else class="text-gray-400">未记录</span>
</template>
</el-table-column>
<el-table-column label="添加时间" width="160">
<template #default="{ row }">
{{ formatTime(firstExternalAddTime(row)) }}
@@ -245,9 +288,19 @@
{{ formatTime(row.update_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="viewDetail(row)">查看详情</el-button>
<el-button
v-perms="['qywx.customer/delete']"
type="danger"
link
:loading="deletingCustomerId === Number(row.id)"
:disabled="deletingCustomerId !== null"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
@@ -500,6 +553,21 @@
</el-descriptions-item>
<el-descriptions-item label="添加时间" :span="2">
{{ formatTime(firstExternalAddTime(currentCustomer)) }}
</el-descriptions-item>
<el-descriptions-item label="添加渠道" :span="2">
<div v-if="customerAddSources(currentCustomer).length" class="flex flex-wrap gap-1">
<el-tooltip
v-for="source in customerAddSources(currentCustomer)"
:key="source.key"
:content="addSourceTooltip(source)"
placement="top"
>
<el-tag size="small" type="info" effect="plain">
{{ source.label }}
</el-tag>
</el-tooltip>
</div>
<span v-else class="text-gray-400">未记录</span>
</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">
{{ formatTime(currentCustomer.update_time) }}
@@ -549,8 +617,9 @@ import { Refresh, Setting, DataLine, CollectionTag } from '@element-plus/icons-v
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
import {
qywxCustomerLists,
qywxCustomerSync,
qywxCustomerLists,
qywxCustomerDelete,
qywxCustomerSync,
qywxCustomerStats,
qywxSyncSettingsGet,
qywxSyncSettingsSave,
@@ -563,6 +632,7 @@ const syncing = ref(false)
const showSyncSettings = ref(false)
const showDetail = ref(false)
const currentCustomer = ref<any>(null)
const deletingCustomerId = ref<number | null>(null)
const stats = reactive({
total: 0,
@@ -591,17 +661,19 @@ const syncSettings = reactive({
interval: 3600
})
const queryParams = reactive<{
name: string
follow_user: string
tag_ids: string[]
const queryParams = reactive<{
name: string
follow_user: string
add_way: number | ''
tag_ids: string[]
add_time_start: string
add_time_end: string
dedupe_mode: 'first' | 'any'
}>({
name: '',
follow_user: '',
tag_ids: [],
name: '',
follow_user: '',
add_way: '',
tag_ids: [],
add_time_start: '',
add_time_end: '',
dedupe_mode: 'first'
@@ -639,6 +711,22 @@ interface TagStatsPayload {
groups: TagGroup[]
}
interface AddChannel {
state: string
label: string
source_type: 'promotion_pool' | 'state'
pool_id: number
user_id: string
event_time: number
}
interface AddSource extends AddChannel {
key: string
add_way: number | null
channel_label: string
staff_name: string
}
const tagStats = reactive<TagStatsPayload>({
total_tags: 0,
total_relations: 0,
@@ -847,10 +935,11 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
params: queryParams
})
function handleReset() {
queryParams.name = ''
queryParams.follow_user = ''
queryParams.tag_ids = []
function handleReset() {
queryParams.name = ''
queryParams.follow_user = ''
queryParams.add_way = ''
queryParams.tag_ids = []
queryParams.add_time_start = ''
queryParams.add_time_end = ''
queryParams.dedupe_mode = 'first'
@@ -964,6 +1053,34 @@ function viewDetail(row: any) {
showDetail.value = true
}
async function handleDelete(row: Record<string, any>) {
const id = Number(row.id)
if (!Number.isInteger(id) || id <= 0 || deletingCustomerId.value !== null) return
const customerName = String(row.name || row.external_userid || '该客户')
try {
await feedback.confirm(
`确定删除企业微信客户“${customerName}”吗?此操作仅删除系统内的同步记录,不会删除企业微信中的客户关系;后续重新同步时可能再次出现。`
)
} catch {
return
}
deletingCustomerId.value = id
try {
await qywxCustomerDelete({ id })
if (pager.page > 1 && pager.lists.length === 1) {
pager.page -= 1
}
await Promise.all([getLists(), loadStats(), loadTagStats()])
feedback.msgSuccess('删除成功')
} catch (e: any) {
feedback.msgError(e?.message || e?.msg || '删除失败')
} finally {
deletingCustomerId.value = null
}
}
/** 列表接口会写入 admin_nameadmin.work_wechat_userid = userid */
function formatFollowUser(user: Record<string, any>) {
const adminName = String(user?.admin_name ?? '').trim()
@@ -985,6 +1102,167 @@ function followStaffTooltip(user: Record<string, any>) {
return parts.join('')
}
function customerAddChannels(row: Record<string, any> | null | undefined): AddChannel[] {
if (!row) return []
if (Array.isArray(row.add_channels)) {
return row.add_channels
.map((channel: Record<string, any>): AddChannel => ({
state: String(channel?.state ?? '').trim(),
label: String(channel?.label ?? channel?.state ?? '').trim(),
source_type: channel?.source_type === 'promotion_pool' ? 'promotion_pool' : 'state',
pool_id: Number(channel?.pool_id ?? 0),
user_id: String(channel?.user_id ?? '').trim(),
event_time: Number(channel?.event_time ?? 0)
}))
.filter((channel: AddChannel) => channel.state !== '')
}
// 兼容仅返回原始渠道数组的旧接口/灰度节点。
if (!Array.isArray(row.add_channel_states)) return []
return row.add_channel_states
.map((state: unknown) => String(state ?? '').trim())
.filter((state: string) => state !== '')
.map((state: string) => ({
state,
label: state,
source_type: 'state' as const,
pool_id: 0,
user_id: '',
event_time: 0
}))
}
const ADD_WAY_LABELS: Record<number, string> = {
0: '未知添加方式',
1: '通过扫描二维码添加',
2: '通过搜索手机号添加',
3: '通过名片分享添加',
4: '通过群聊添加',
5: '通过手机通讯录添加',
6: '通过微信联系人添加',
8: '安装第三方应用时自动添加',
9: '通过搜索邮箱添加',
10: '通过视频号添加',
11: '通过日程参与人添加',
12: '通过会议参与人添加',
13: '通过微信好友添加',
14: '通过智慧硬件专属客服添加',
15: '通过上门服务客服添加',
16: '通过获客链接添加',
17: '通过定制开发添加',
18: '通过需求回复添加',
21: '通过第三方售前客服添加',
22: '通过可能的商务伙伴添加',
24: '通过接受微信好友申请添加',
201: '通过内部成员共享添加',
202: '通过管理员或负责人分配添加'
}
const ADD_WAY_OPTIONS = Object.entries(ADD_WAY_LABELS).map(([value, label]) => ({
value: Number(value),
label
}))
function normalizeAddWay(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) return value
if (typeof value !== 'string' || !/^\d+$/.test(value.trim())) return null
return Number(value.trim())
}
function addWayLabel(addWay: number) {
return ADD_WAY_LABELS[addWay] || `其他添加方式(${addWay}`
}
function customerAddSources(row: Record<string, any> | null | undefined): AddSource[] {
if (!row) return []
const channels = customerAddChannels(row)
const usedChannelIndexes = new Set<number>()
const sources: AddSource[] = []
const followUsers = Array.isArray(row.follow_users) ? row.follow_users : []
followUsers.forEach((user: Record<string, any>, index: number) => {
const userId = String(user?.userid ?? user?.UserId ?? '').trim()
const state = String(user?.state ?? user?.State ?? '').trim()
const addWay = normalizeAddWay(user?.add_way ?? user?.AddWay)
let channelIndex = channels.findIndex(
(channel, i) =>
!usedChannelIndexes.has(i) &&
userId !== '' &&
state !== '' &&
channel.user_id === userId &&
channel.state === state
)
if (channelIndex < 0 && state !== '') {
channelIndex = channels.findIndex(
(channel, i) => !usedChannelIndexes.has(i) && channel.state === state
)
}
if (channelIndex < 0 && userId !== '') {
channelIndex = channels.findIndex(
(channel, i) => !usedChannelIndexes.has(i) && channel.user_id === userId
)
}
const channel = channelIndex >= 0 ? channels[channelIndex] : undefined
if (channelIndex >= 0) usedChannelIndexes.add(channelIndex)
if (addWay === null && state === '' && !channel) return
const labelFromApi = String(user?.add_way_label ?? '').trim()
const sourceType = channel?.source_type ?? (/^zyt_pool:[1-9]\d*$/.test(state) ? 'promotion_pool' : 'state')
const label = labelFromApi || (addWay !== null
? addWayLabel(addWay)
: sourceType === 'promotion_pool'
? '通过获客链接添加'
: '通过其他渠道添加')
sources.push({
key: `follow:${index}:${userId}:${addWay ?? 'unknown'}:${state}`,
add_way: addWay,
label,
state: state || channel?.state || '',
channel_label: channel?.label || '',
source_type: sourceType,
pool_id: channel?.pool_id || 0,
user_id: userId || channel?.user_id || '',
staff_name: formatFollowUser(user),
event_time: channel?.event_time || Number(user?.createtime ?? 0)
})
})
// 兼容事件日志中仍有记录、但当前 follow_users 已不存在或旧接口未返回 add_way 的客户。
channels.forEach((channel, index) => {
if (usedChannelIndexes.has(index)) return
sources.push({
...channel,
key: `channel:${index}:${channel.user_id}:${channel.state}`,
add_way: channel.source_type === 'promotion_pool' ? 16 : null,
label: channel.source_type === 'promotion_pool' ? '通过获客链接添加' : '通过其他渠道添加',
channel_label: channel.label,
staff_name: channel.user_id || '—'
})
})
return sources.sort((a, b) => b.event_time - a.event_time)
}
function addSourceTooltip(source: AddSource) {
const parts: string[] = []
parts.push(`添加方式:${source.label}`)
if (source.source_type === 'promotion_pool' && source.channel_label) {
parts.push(`获客助手方案:${source.channel_label}`)
}
if (source.staff_name && source.staff_name !== '—') parts.push(`跟进人:${source.staff_name}`)
if (source.event_time > 0) parts.push(`添加时间:${formatTime(source.event_time)}`)
if (source.state) parts.push(`渠道参数:${source.state}`)
return parts.join('')
}
function remainingAddSourcesTooltip(row: Record<string, any>) {
return customerAddSources(row).slice(1).map(addSourceTooltip).join('\n')
}
/**
* 添加时间:优先接口字段 external_first_add_time(同步写入 + 列表对未回填行按 JSON 兜底);
* 再解析 follow_users;最后退回 create_time
+453 -122
View File
@@ -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,56 @@
</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>
<strong class="metric-value">
{{ formatMetric(metric.key, metric.type) }}
<em
v-if="metric.key === 'add_fans_count' && hasDeletedFans(dashboard.summary.deleted_fans_count)"
class="deleted-fans-marker"
title="加粉总数中已删除"
>
-{{ formatNumber(dashboard.summary.deleted_fans_count) }}
</em>
</strong>
<small>{{ metric.hint }}<template v-if="metric.key === 'add_fans_count' && canViewDeletedFans">-N为其中已删除</template></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>
@@ -145,7 +154,7 @@
<div class="panel-heading panel-heading--table">
<div>
<h2>明细数据列表</h2>
<p>展开部门可查看人员明细加粉=总进线=区间有效加粉员工+客户去重须会话同意剔除已删客户继承客户扫一扫/搜手机号/名片分享添加及区间前已加过的重加挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/挂号看挂号后流失预约率=面诊/预约看预约后未面诊面诊接诊率=接诊诊单/面诊接诊率=接诊诊单/总进线</p>
<p>展开部门可查看人员明细加粉=总进线=区间新增员工+客户组合同一员工的同一客户只计一次同一客户进入不同员工分别计数包含区间内添加后已删客户剔除继承客户扫一扫/搜手机号/名片分享添加及区间前已存在的相同组合<template v-if="canViewDeletedFans">-N表示加粉组合中已删除</template>挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/挂号看挂号后流失预约率=面诊/预约看预约后未面诊面诊接诊率=接诊诊单/面诊接诊率=接诊诊单/总进线</p>
</div>
<span>{{ dashboard.rows.length }} 个顶层节点</span>
</div>
@@ -156,7 +165,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 +187,54 @@
</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="92" align="right">
<template #default="{ row }">
<button
v-if="hasFans(row.add_fans_count)"
type="button"
class="fan-count-value fan-detail-trigger fan-detail-trigger--table"
title="查看该行加粉组合明细"
@click.stop="openFansDetail(row)"
>
{{ formatNumber(row.add_fans_count) }}
<em
v-if="hasDeletedFans(row.deleted_fans_count)"
class="deleted-fans-marker"
title="加粉总数中已删除"
>
-{{ formatNumber(row.deleted_fans_count) }}
</em>
</button>
<span v-else class="fan-count-value is-empty">0</span>
</template>
</el-table-column>
<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>
@@ -270,6 +299,75 @@
</div>
</div>
</section>
<el-dialog
v-model="fansDetailVisible"
:title="fansDetailTitle"
width="920px"
top="7vh"
append-to-body
destroy-on-close
class="fans-detail-dialog"
@closed="resetFansDetail"
>
<div class="fans-detail-context">
<span><b>统计区间</b>{{ detailRangeText }}</span>
<span><b>媒体渠道</b>{{ detailChannelText }}</span>
<span><b>数据范围</b>{{ dashboard.meta.scope_label || '当前权限范围' }}</span>
</div>
<el-table
v-loading="fansDetailLoading"
:data="fansDetailRows"
:row-class-name="fansDetailRowClassName"
height="430"
class="fans-detail-table"
>
<el-table-column label="客户" min-width="190">
<template #default="{ row }">
<div class="customer-cell">
<strong class="customer-name" :class="{ 'is-deleted': isDeletedFan(row) }">
{{ fanCustomerName(row) }}
</strong>
<small v-if="row.customer_name && row.external_userid">{{ row.external_userid }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="归属员工" min-width="160">
<template #default="{ row }">{{ fanOwnerName(row) }}</template>
</el-table-column>
<el-table-column label="添加时间" min-width="180">
<template #default="{ row }">{{ fanAddTime(row) }}</template>
</el-table-column>
<el-table-column v-if="canViewDeletedFansDetail" label="状态" width="150" align="center">
<template #default="{ row }">
<div v-if="isDeletedFan(row)" class="deleted-status">
<el-tag type="danger" effect="dark" size="small">已删除</el-tag>
<small v-if="row.delete_time">{{ row.delete_time }}</small>
</div>
<el-tag v-else type="success" effect="plain" size="small">正常</el-tag>
</template>
</el-table-column>
<template #empty>
<el-empty :image-size="68" description="当前条件下暂无加粉组合明细" />
</template>
</el-table>
<div class="fans-detail-footer">
<span> {{ formatNumber(fansDetailPager.total) }} 位客户</span>
<el-pagination
v-if="fansDetailPager.total > 0"
v-model:current-page="fansDetailPager.page_no"
v-model:page-size="fansDetailPager.page_size"
:page-sizes="[10, 20, 50, 100]"
:total="fansDetailPager.total"
layout="total, sizes, prev, pager, next"
background
@current-change="loadFansDetail"
@size-change="handleFansDetailSizeChange"
/>
</div>
</el-dialog>
</div>
</template>
@@ -278,7 +376,14 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Lock, Refresh } from '@element-plus/icons-vue'
import vCharts from 'vue-echarts'
import { firstVisitConversionOverview, type FirstVisitConversionParams } from '@/api/first_visit'
import {
firstVisitConversionFansDetail,
firstVisitConversionOverview,
type FirstVisitConversionFanDetailRow,
type FirstVisitConversionFansDetailParams,
type FirstVisitConversionFansDetailResult,
type FirstVisitConversionParams
} from '@/api/first_visit'
type MetricType = 'count' | 'money' | 'ratio'
type MediaChannelOption = {
@@ -287,13 +392,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, can_view_deleted_fans: false
},
filters: {
departments: [] as any[],
@@ -319,6 +429,14 @@ const query = reactive<FirstVisitConversionParams>({
media_channel_code: ''
})
const customDateRange = ref<string[]>([])
const fansDetailVisible = ref(false)
const fansDetailLoading = ref(false)
const fansDetailCanViewDeletedFans = ref(false)
const fansDetailRows = ref<FirstVisitConversionFanDetailRow[]>([])
const fansDetailPager = reactive({ page_no: 1, page_size: 20, total: 0 })
const fansDetailEntity = ref<{ type: 'dept' | 'member'; id: string | number; adminId?: number; name: string }>({
type: 'dept', id: 0, name: '当前行'
})
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
const timeOptions = [
{ label: '今日', value: 'today' },
@@ -330,7 +448,7 @@ const timeOptions = [
{ label: '自定义', value: 'custom' }
]
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,须会话同意,剔除已删客户、继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加' },
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间新增员工+客户组合(不同员工分别计数,含已删除)' },
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
@@ -347,30 +465,68 @@ 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))
// 仅服务端按登录账号精确判定为admin时放行;缺失、字符串或角色权限均不能兜底放行。
const canViewDeletedFans = computed(() => dashboard.meta.can_view_deleted_fans === true)
const canViewDeletedFansDetail = computed(() => canViewDeletedFans.value && fansDetailCanViewDeletedFans.value)
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 fansDetailTitle = computed(() => `${fansDetailEntity.value.name} · 加粉组合明细`)
const detailRangeText = computed(() => {
const startDate = dashboard.meta.start_date || query.start_date || ''
const endDate = dashboard.meta.end_date || query.end_date || ''
return startDate && endDate ? `${startDate}${endDate}` : (dashboard.meta.time_label || '当前区间')
})
const detailChannelText = computed(() => dashboard.meta.selected_media_channel_name || '全部渠道')
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'],
@@ -385,10 +541,12 @@ const targetChartOption = computed(() => ({
}))
let latestDashboardRequestId = 0
let latestFansDetailRequestId = 0
async function loadDashboard() {
const requestId = ++latestDashboardRequestId
loading.value = true
dashboard.meta.can_view_deleted_fans = false
try {
const params: FirstVisitConversionParams = { ...query }
if (params.time_type === 'custom') {
@@ -437,6 +595,92 @@ function handleDeptChange() {
loadDashboard()
}
function openFansDetail(row: any) {
fansDetailEntity.value = {
type: row.type ? 'member' : 'dept',
id: row.id,
adminId: Number(row.admin_id || 0) || undefined,
name: row.name || '当前行'
}
fansDetailPager.page_no = 1
fansDetailRows.value = []
fansDetailPager.total = 0
fansDetailVisible.value = true
loadFansDetail()
}
async function loadFansDetail() {
if (!fansDetailVisible.value) return
const requestId = ++latestFansDetailRequestId
fansDetailLoading.value = true
fansDetailCanViewDeletedFans.value = false
try {
const params: FirstVisitConversionFansDetailParams = {
...query,
time_type: query.time_type,
start_date: dashboard.meta.start_date || query.start_date,
end_date: dashboard.meta.end_date || query.end_date,
media_channel_code: dashboard.meta.selected_media_channel_code || query.media_channel_code || '',
entity_type: fansDetailEntity.value.type,
entity_id: fansDetailEntity.value.id,
admin_id: fansDetailEntity.value.adminId,
page_no: fansDetailPager.page_no,
page_size: fansDetailPager.page_size
}
const result = await firstVisitConversionFansDetail(params) as FirstVisitConversionFansDetailResult
if (requestId !== latestFansDetailRequestId) return
fansDetailCanViewDeletedFans.value = result?.can_view_deleted_fans === true
const rows = Array.isArray(result?.lists) ? result.lists : (Array.isArray(result?.rows) ? result.rows : [])
fansDetailRows.value = rows
fansDetailPager.total = Number(result?.count ?? result?.total ?? rows.length) || 0
} catch (error: any) {
if (requestId !== latestFansDetailRequestId) return
fansDetailRows.value = []
fansDetailPager.total = 0
ElMessage.error(error?.message || '加粉组合明细加载失败')
} finally {
if (requestId === latestFansDetailRequestId) fansDetailLoading.value = false
}
}
function handleFansDetailSizeChange() {
fansDetailPager.page_no = 1
loadFansDetail()
}
function resetFansDetail() {
++latestFansDetailRequestId
fansDetailLoading.value = false
fansDetailCanViewDeletedFans.value = false
fansDetailRows.value = []
fansDetailPager.page_no = 1
fansDetailPager.total = 0
}
function hasFans(value: any) {
return Math.round(Number(value || 0)) > 0
}
function isDeletedFan(row: FirstVisitConversionFanDetailRow) {
return canViewDeletedFansDetail.value && Boolean(row.is_deleted)
}
function fanCustomerName(row: FirstVisitConversionFanDetailRow) {
return row.customer_name || row.external_userid || '未知客户'
}
function fanOwnerName(row: FirstVisitConversionFanDetailRow) {
return row.wecom_staff_name || row.wecom_userid || '未绑定员工'
}
function fanAddTime(row: FirstVisitConversionFanDetailRow) {
return row.add_time || '-'
}
function fansDetailRowClassName({ row }: { row: FirstVisitConversionFanDetailRow }) {
return isDeletedFan(row) ? 'is-deleted-fan-row' : ''
}
function formatMetric(key: string, type: MetricType) {
const value = dashboard.summary[key]
if (type === 'money') return formatMoney(value)
@@ -448,18 +692,22 @@ function formatNumber(value: any) {
return Math.round(Number(value || 0)).toLocaleString('zh-CN')
}
function hasDeletedFans(value: any) {
return canViewDeletedFans.value && Math.round(Number(value || 0)) > 0
}
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 +722,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 +736,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 +801,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 +813,11 @@ onMounted(loadDashboard)
color: #98a2b3;
font-size: 12px;
}
&.is-group span:first-child {
font-weight: 650;
color: #172033;
}
}
.metric-grid {
@@ -583,8 +836,43 @@ onMounted(loadDashboard)
small { color: #a0a9b6; font-size: 11px; }
}
.metric-value,
.fan-count-value {
display: inline-flex;
align-items: baseline;
justify-content: flex-end;
gap: 3px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.metric-value { display: flex; justify-content: flex-start; }
.fan-detail-trigger {
margin: 0;
padding: 0;
border: 0;
color: inherit;
background: transparent;
font: inherit;
cursor: pointer;
&:focus-visible { outline: 2px solid #2f78df; outline-offset: 3px; border-radius: 3px; }
&:disabled { cursor: default; }
}
.deleted-fans-marker {
color: #c65f26;
font-size: .58em;
font-style: normal;
font-weight: 650;
}
.fan-count-value .deleted-fans-marker { font-size: 11px; }
.fan-detail-trigger--table { color: #1769aa; font-weight: 650; }
.fan-detail-trigger--table:hover { color: #0f4f82; }
.fan-count-value.is-empty { color: #98a2b3; }
.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 +886,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; }
@@ -645,6 +933,47 @@ onMounted(loadDashboard)
.legend-line.is-target { border-color: #2f78df; border-top-style: dashed; }
.target-chart { width: 100%; height: 260px; }
.fans-detail-context {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
margin: -4px 0 14px;
padding: 10px 12px;
border: 1px solid #e4e9ef;
border-radius: 7px;
color: #5d6b7e;
background: #f7f9fb;
font-size: 12px;
span { display: inline-flex; gap: 7px; }
b { color: #8a95a5; font-weight: 500; }
}
.fans-detail-table {
width: 100%;
.customer-cell { display: grid; gap: 3px; }
.customer-name { color: #273347; font-weight: 650; }
.customer-name.is-deleted { color: #c24137; text-decoration: line-through; text-decoration-color: #e8a39e; }
.customer-cell small { overflow: hidden; color: #98a2b3; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
:deep(.is-deleted-fan-row > td.el-table__cell) { background: #fff3f2 !important; }
}
.deleted-status {
display: inline-grid;
justify-items: center;
gap: 4px;
small { color: #b14e47; font-size: 10px; }
}
.fans-detail-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 14px;
color: #8491a2;
font-size: 12px;
}
@media (max-width: 1180px) {
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
.range-text { margin-left: 0; }
@@ -658,7 +987,9 @@ 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; }
.fans-detail-footer { align-items: flex-start; flex-direction: column; }
.fans-detail-footer :deep(.el-pagination) { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; }
}
</style>
@@ -125,7 +125,12 @@
</div>
</template>
</el-table-column>
<el-table-column label="预约时间" min-width="165">
<el-table-column label="挂号类型" min-width="110">
<template #default="{ row }">
{{ Number(row.appointment_id) > 0 ? appointmentTypeDescription(row.appointment_type) : '—' }}
</template>
</el-table-column>
<el-table-column label="预约时间" min-width="165">
<template #default="{ row }">
<span :class="['appointment-time', { empty: !row.appointment_time_text }]">
{{ row.appointment_time_text || '暂无预约' }}
@@ -324,7 +329,8 @@
</div>
</template>
<script setup lang="ts">
<script setup lang="ts">
import { appointmentTypeDescription } from '@/utils/appointment-type'
import { computed, defineAsyncComponent, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import dayjs from 'dayjs'
@@ -0,0 +1,252 @@
<template>
<div class="automation-form">
<el-alert class="automation-note" type="info" show-icon :closable="false" title="自动化设置只作用于之后新添加的客户,不会写入企微获客链接详情中的“欢迎语/客户标签”配置。" description="系统会在客户添加回调中立即发送渠道欢迎语并添加标签,后台任务负责失败重试及其他补偿。测试时请使用系统复制的、带渠道参数的链接。" />
<section class="automation-section" :class="{ 'is-disabled': receptionDisabled }">
<h3 class="form-section-title">接待设置</h3>
<el-form-item label="接待模式">
<el-radio-group v-model="config.reception_mode" :disabled="receptionDisabled">
<el-radio value="always">全天接待</el-radio>
<el-radio value="scheduled">按星期时段自动上下线</el-radio>
</el-radio-group>
<p class="field-help">成员仍受每日上限有效期及启用状态限制时间统一使用北京时间</p>
</el-form-item>
<div v-if="config.reception_mode === 'scheduled'" class="reception-schedules">
<div v-for="(slot, index) in config.reception_schedule" :key="index" class="schedule-card">
<div class="schedule-heading"><strong>接待时段 {{ index + 1 }}</strong><el-button type="danger" text size="small" :disabled="receptionDisabled" @click="config.reception_schedule.splice(index, 1)">删除时段</el-button></div>
<el-checkbox-group v-model="slot.weekdays" class="weekday-select" :disabled="receptionDisabled"><el-checkbox v-for="(day, i) in weekdays" :key="day" :value="i + 1">{{ day }}</el-checkbox></el-checkbox-group>
<div class="time-row"><el-time-picker v-model="slot.start" :disabled="receptionDisabled" format="HH:mm" value-format="HH:mm" placeholder="开始时间" :clearable="false" /><span>至</span><el-time-picker v-model="slot.end" :disabled="receptionDisabled" format="HH:mm" value-format="HH:mm" placeholder="结束时间" :clearable="false" /><small v-if="slot.end < slot.start">次日结束</small></div>
<el-select v-model="slot.member_admin_ids" :disabled="receptionDisabled" multiple filterable clearable placeholder="从上方主接待成员中选择" style="width: 100%">
<el-option v-for="member in mainMembers" :key="member.id" :value="Number(member.id)" :label="memberLabel(member)" />
</el-select>
<p v-if="slot.member_admin_ids.some((id) => !mainMemberIds.includes(id))" class="inline-error">该时段含已从主接待移除的成员请重新选择</p>
</div>
<el-button :icon="Plus" :disabled="receptionDisabled || config.reception_schedule.length >= 30" @click="addReceptionSlot">添加接待时段</el-button>
<p class="field-help">最多 30 个时段跨午夜时段归属开始日例如星期一 22:00 02:00 包含星期二凌晨接待时段重叠时取成员并集</p>
</div>
<el-form-item label="备用成员" :required="config.reception_mode === 'scheduled'">
<el-select v-model="config.backup_member_admin_ids" :disabled="receptionDisabled" multiple filterable clearable collapse-tags collapse-tags-tooltip :max-collapse-tags="3" :multiple-limit="500" placeholder="主接待成员均不可用时由备用成员接待" style="width: 100%">
<el-option v-for="member in members" :key="member.id" :value="Number(member.id)" :label="memberLabel(member)" :disabled="backupExcludedIds.includes(Number(member.id))" />
<el-option v-for="id in missingBackupIds" :key="`missing-${id}`" :value="id" :label="`成员 ${id}(当前不可选,请移除后重新选择)`" disabled />
</el-select>
<p class="field-help">备用成员不能与主接待重复按时段模式至少配置一名备用成员仅当无可用主接待时进入官方成员范围</p>
</el-form-item>
</section>
<section class="automation-section" :class="{ 'is-disabled': customerDisabled }">
<h3 class="form-section-title">客户设置</h3>
<el-form-item label="自动添加客户标签">
<el-switch v-model="config.tags_enabled" :disabled="customerDisabled || tagsCreating" />
<div v-if="hasMultipleTags" class="legacy-tags-warning full-width" role="alert">
<p>原方案设置了多个标签{{ selectedTagNames }}现在仅支持单选请重新选择一个标签或清空原标签</p>
<el-button size="small" :disabled="customerDisabled || tagsCreating" @click="selectedTag = ''">清空原标签</el-button>
</div>
<div v-if="config.tags_enabled" class="full-width tags-content">
<div class="tag-select-row">
<el-select v-model="selectedTag" :disabled="customerDisabled || tagsCreating" :loading="tagsLoading" filterable clearable placeholder="选择一个企业微信客户标签" aria-label="企业微信客户标签" class="tag-select">
<el-option-group v-for="group in tagGroups" :key="group.group_id" :label="group.group_name">
<el-option v-for="tag in group.tag" :key="tag.id" :value="tag.id" :label="tag.name" />
</el-option-group>
<el-option-group v-if="unknownTagIds.length" label="已选标签(名称暂不可用)"><el-option v-for="id in unknownTagIds" :key="id" :value="id" :label="`已选标签 · ${id}`" /></el-option-group>
</el-select>
<el-button :icon="Plus" :disabled="customerDisabled || tagsCreating" @click="showCustomTag = !showCustomTag">自定义标签</el-button>
<el-button :icon="Refresh" :disabled="customerDisabled || tagsCreating" :loading="tagsLoading" @click="loadTags">{{ tagsError ? '重试' : '刷新标签' }}</el-button>
</div>
<p v-if="tagsError" role="alert" class="inline-error">{{ tagsError }} 已保留原有标签点击重试重新加载</p>
<p v-else class="field-help">每个方案只选一个标签可选择已有企业微信标签也可自定义创建客户添加成功后由系统调用企微接口打标不会显示在企微获客链接详情的客户标签配置中</p>
<div v-if="showCustomTag" class="custom-tag-editor">
<label for="promotion-custom-tag-name">自定义标签名称</label>
<div class="custom-tag-row">
<el-input id="promotion-custom-tag-name" v-model="customTagName" :disabled="customerDisabled || tagsCreating" maxlength="30" show-word-limit placeholder="例如:官网咨询" @input="customTagError = ''" @keydown.enter.prevent="createCustomTag" />
<el-button type="primary" :disabled="customerDisabled || tagsLoading" :loading="tagsCreating" @click="createCustomTag">创建并选用</el-button>
</div>
<p class="field-help">创建到企业微信推广渠道分组同组同名标签会复用创建后即保存到企微标签库取消方案编辑不会删除标签</p>
<p v-if="customTagError" role="alert" class="inline-error">{{ customTagError }} 原有选择未改变</p>
</div>
<p v-if="customTagSuccess" role="status" class="tag-success">{{ customTagSuccess }}</p>
</div>
</el-form-item>
<el-form-item label="自动设置客户备注">
<el-switch v-model="config.remark_enabled" :disabled="customerDisabled" />
<div v-if="config.remark_enabled" class="full-width remark-content">
<div class="token-buttons"><el-button v-for="token in templateTokens" :key="token.value" size="small" :disabled="customerDisabled" @click="insertRemark(token.value)">插入{{ token.label }}</el-button></div>
<el-input ref="remarkInput" v-model="config.remark_template" :disabled="customerDisabled" maxlength="200" show-word-limit placeholder="例如:官网-{customer_name}" @select="rememberRemarkSelection" @keyup="rememberRemarkSelection" @click="rememberRemarkSelection" @blur="rememberRemarkSelection" />
<div class="remark-preview"><span>备注预览</span><strong>{{ remarkPreview || '—' }}</strong><small>{{ Array.from(remarkPreview).length }}/20 </small></div>
<p class="field-help">示例客户张女士员工{{ employeeName }}添加时间格式为 YYYY-MM-DD生成后的备注最多保留前 20 </p>
</div>
</el-form-item>
<el-form-item label="自动设置客户描述">
<el-switch v-model="config.description_enabled" :disabled="customerDisabled" />
<el-input v-if="config.description_enabled" v-model="config.description" class="description-input" :disabled="customerDisabled" type="textarea" :rows="3" maxlength="150" show-word-limit placeholder="请输入客户描述,最多 150 字" />
</el-form-item>
</section>
<section class="automation-section" :class="{ 'is-disabled': welcomeDisabled }">
<h3 class="form-section-title">欢迎语设置</h3>
<el-form-item label="欢迎语模式">
<el-radio-group v-model="config.welcome_mode" :disabled="welcomeDisabled || anyUploading">
<el-radio value="channel">渠道欢迎语</el-radio>
<el-radio value="default">默认欢迎语</el-radio>
<el-radio value="none">不发送欢迎语</el-radio>
</el-radio-group>
<p v-if="config.welcome_mode === 'default'" class="field-help">沿用企业微信后台配置本系统不发送欢迎语</p>
<p v-else-if="config.welcome_mode === 'none'" class="field-help warning-help">仅关闭本系统欢迎语无法覆盖或关闭企业微信后台已配置的欢迎语</p>
<p v-else class="field-help warning-help">渠道欢迎语由客户添加回调即时发送不会显示在企微获客链接详情中同一接待成员若已配置企微原生欢迎语企微可能不再下发欢迎码请先关闭冲突配置</p>
</el-form-item>
<template v-if="config.welcome_mode === 'channel'">
<div class="welcome-block"><h4>基础渠道欢迎语</h4><p class="field-help">未开启分时欢迎语或新客户添加时间未匹配任何时段时使用以下内容</p>
<WelcomeMessageEditor v-model="config.welcome" :disabled="welcomeDisabled" :employee-name="employeeName" @busy="(busy) => updateBusy('basic', busy)" />
</div>
<el-form-item class="schedule-switch" label="分时欢迎语"><el-switch v-model="config.welcome_schedule_enabled" :disabled="welcomeDisabled || anyUploading" /><span class="switch-help">按客户添加时的北京时间匹配时段不能重叠</span></el-form-item>
<div v-if="config.welcome_schedule_enabled">
<div v-for="(slot, index) in config.welcome_schedule" :key="index" class="schedule-card welcome-schedule">
<div class="schedule-heading"><strong>欢迎语时段 {{ index + 1 }}</strong><el-button type="danger" text size="small" :disabled="welcomeDisabled || anyUploading" @click="removeWelcomeSlot(index)">删除时段</el-button></div>
<el-checkbox-group v-model="slot.weekdays" class="weekday-select" :disabled="welcomeDisabled"><el-checkbox v-for="(day, i) in weekdays" :key="day" :value="i + 1">{{ day }}</el-checkbox></el-checkbox-group>
<div class="time-row"><el-time-picker v-model="slot.start" :disabled="welcomeDisabled" format="HH:mm" value-format="HH:mm" placeholder="开始时间" :clearable="false" /><span>至</span><el-time-picker v-model="slot.end" :disabled="welcomeDisabled" format="HH:mm" value-format="HH:mm" placeholder="结束时间" :clearable="false" /><small v-if="slot.end < slot.start">次日结束</small></div>
<WelcomeMessageEditor :model-value="slot" :disabled="welcomeDisabled" :employee-name="employeeName" @update:model-value="(message) => Object.assign(slot, message)" @busy="(busy) => updateBusy(`slot-${index}`, busy)" />
</div>
<el-button :icon="Plus" :disabled="welcomeDisabled || anyUploading || config.welcome_schedule.length >= 30" @click="addWelcomeSlot">添加欢迎语时段</el-button>
<p class="field-help">最多 30 个时段,支持跨午夜。时段外自动使用基础渠道欢迎语,不会随机选择内容。</p>
</div>
</template>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { Plus, Refresh } from '@element-plus/icons-vue'
import { wecomPromotionCreateTag, wecomPromotionTagOptions } from '@/api/first_visit'
import type { WecomPromotionTagGroup } from '@/api/first_visit'
import WelcomeMessageEditor from './WelcomeMessageEditor.vue'
import { previewTemplate, templateTokens, validateCustomTagName, weekdays } from './promotion-automation'
import type { PromotionAutomationConfig, PromotionMemberChoice } from './promotion-automation'
type AutomationSection = 'reception' | 'customer' | 'welcome'
const props = defineProps<{
modelValue: PromotionAutomationConfig
mainMemberIds: number[]
members: PromotionMemberChoice[]
disabled?: boolean
disabledSections?: AutomationSection[]
backupExcludedMemberIds?: number[]
}>()
const emit = defineEmits<{ 'update:modelValue': [config: PromotionAutomationConfig]; busy: [value: boolean] }>()
const config = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) })
const sectionDisabled = (section: AutomationSection) => Boolean(props.disabled || props.disabledSections?.includes(section))
const receptionDisabled = computed(() => sectionDisabled('reception'))
const customerDisabled = computed(() => sectionDisabled('customer'))
const welcomeDisabled = computed(() => sectionDisabled('welcome'))
const backupExcludedIds = computed(() => props.backupExcludedMemberIds || props.mainMemberIds)
const mainMembers = computed(() => props.members.filter((member) => props.mainMemberIds.includes(Number(member.id))))
const missingBackupIds = computed(() => config.value.backup_member_admin_ids.filter((id) => !props.members.some((member) => Number(member.id) === id)))
const employeeName = computed(() => mainMembers.value[0]?.name || '小陈')
const remarkPreview = computed(() => previewTemplate(config.value.remark_template, employeeName.value, 20))
const remarkInput = ref<{ input?: HTMLInputElement }>()
const remarkSelection = ref({ start: 0, end: 0 })
const tagGroups = ref<WecomPromotionTagGroup[]>([])
const tagsLoading = ref(false)
const tagsError = ref('')
const tagsLoaded = ref(false)
const tagsCreating = ref(false)
const showCustomTag = ref(false)
const customTagName = ref('')
const customTagError = ref('')
const customTagSuccess = ref('')
const hasMultipleTags = computed(() => config.value.tag_ids.length > 1)
const selectedTag = computed({
get: () => config.value.tag_ids.length === 1 ? config.value.tag_ids[0] : '',
set: (id: string | undefined) => {
config.value.tag_ids = id ? [id] : []
customTagSuccess.value = ''
}
})
const selectedTagNames = computed(() => {
const tags = tagGroups.value.flatMap((group) => group.tag)
return config.value.tag_ids.map((id) => tags.find((tag) => tag.id === id)?.name || id).join('、')
})
const busyEditors = ref(new Set<string>())
const anyUploading = computed(() => busyEditors.value.size > 0)
watch([tagsCreating, anyUploading], ([creating, uploading]) => emit('busy', creating || uploading), { flush: 'sync' })
const unknownTagIds = computed(() => {
const ids = new Set(tagGroups.value.flatMap((group) => group.tag.map((tag) => tag.id)))
return config.value.tag_ids.filter((id) => !ids.has(id))
})
watch([() => config.value.tags_enabled, customerDisabled], ([enabled, sectionIsDisabled]) => {
if (enabled && !sectionIsDisabled && !tagsLoaded.value && !tagsLoading.value) void loadTags()
}, { immediate: true })
function memberLabel(member: PromotionMemberChoice) { return `${member.name} · ${member.dept_names?.join(' / ') || member.userid || '未分部门'}` }
function addReceptionSlot() { config.value.reception_schedule.push({ weekdays: [1, 2, 3, 4, 5], start: '09:00', end: '18:00', member_admin_ids: [...props.mainMemberIds] }) }
function addWelcomeSlot() { config.value.welcome_schedule.push({ weekdays: [1, 2, 3, 4, 5], start: '09:00', end: '18:00', text: '', attachments: [] }) }
function removeWelcomeSlot(index: number) { config.value.welcome_schedule.splice(index, 1); busyEditors.value.clear() }
function updateBusy(key: string, busy: boolean) { busy ? busyEditors.value.add(key) : busyEditors.value.delete(key) }
async function loadTags() {
if (tagsCreating.value || tagsLoading.value) return
tagsLoading.value = true
tagsError.value = ''
try {
const result = await wecomPromotionTagOptions()
if (!Array.isArray(result?.tag_groups)) throw new Error('标签接口未返回有效的标签列表。')
tagGroups.value = result.tag_groups.map((group) => ({ ...group, tag: Array.isArray(group.tag) ? group.tag : [] }))
tagsLoaded.value = true
} catch (error: unknown) {
tagsError.value = error instanceof Error ? error.message : typeof error === 'string' ? error : '企业微信标签加载失败。'
} finally { tagsLoading.value = false }
}
async function createCustomTag() {
if (customerDisabled.value || tagsCreating.value || tagsLoading.value) return
customTagError.value = validateCustomTagName(customTagName.value)
customTagSuccess.value = ''
if (customTagError.value) return
tagsCreating.value = true
try {
const result = await wecomPromotionCreateTag({ name: customTagName.value.trim() })
if (!result?.tag?.id || !result.tag.name || !result.group_id) throw new Error('企业微信未返回有效的标签 ID,请刷新标签后确认。')
let group = tagGroups.value.find((item) => item.group_id === result.group_id)
if (!group) {
group = { group_id: result.group_id, group_name: result.group_name, tag: [] }
tagGroups.value.push(group)
}
const existing = group.tag.findIndex((tag) => tag.id === result.tag.id)
if (existing >= 0) group.tag[existing] = result.tag
else group.tag.push(result.tag)
selectedTag.value = result.tag.id
customTagSuccess.value = `${result.reused ? '已选用已有' : '已创建并选用'}标签“${result.tag.name}”,保存方案后生效。`
customTagName.value = ''
showCustomTag.value = false
} catch (error: unknown) {
customTagError.value = error instanceof Error ? error.message : typeof error === 'string' ? error : '自定义标签创建失败,请刷新确认或重试。'
} finally { tagsCreating.value = false }
}
function rememberRemarkSelection() {
const input = remarkInput.value?.input
if (input) remarkSelection.value = { start: input.selectionStart || 0, end: input.selectionEnd || 0 }
}
async function insertRemark(token: string) {
const { start, end } = remarkSelection.value
config.value.remark_template = config.value.remark_template.slice(0, start) + token + config.value.remark_template.slice(end)
await nextTick()
const cursor = start + token.length
remarkInput.value?.input?.focus()
remarkInput.value?.input?.setSelectionRange(cursor, cursor)
remarkSelection.value = { start: cursor, end: cursor }
}
onBeforeUnmount(() => emit('busy', false))
</script>
<style scoped>
.automation-form { width: 100%; }.automation-note { margin-top: 22px; }.automation-note :deep(.el-alert__description) { line-height: 1.7; }
.automation-section { min-width: 0; transition: opacity .2s ease; }.automation-section.is-disabled { opacity: .58; }
.form-section-title { margin: 28px 0 18px; padding: 0 0 12px; border-bottom: 1px solid #ebeef5; font-size: 15px; font-weight: 600; color: #303133; }.field-help { width: 100%; font-size: 12px; line-height: 1.7; margin: 6px 0 0; color: #909399; }.warning-help { color: #9f6d14; }.full-width { width: 100%; }.inline-error { width: 100%; color: #d93026; font-size: 12px; line-height: 1.7; margin: 8px 0 0; }
.schedule-card { padding: 16px; border: 1px solid #e4e7ed; border-radius: 6px; background: #fafbfd; margin-bottom: 12px; }.schedule-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 13px; }.weekday-select { display: flex; flex-wrap: wrap; gap: 0 18px; }.weekday-select :deep(.el-checkbox) { margin-right: 0; }.time-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 12px 0; }.time-row :deep(.el-date-editor.el-input) { width: 150px; }.time-row > span { font-size: 12px; color: #909399; }.time-row > small { font-size: 12px; color: #b88230; }.reception-schedules { margin: 0 0 20px; }
.tags-content, .remark-content, .description-input { margin-top: 12px; }.tag-select-row { display: flex; gap: 10px; width: 100%; }.tag-select { flex: 1; min-width: 0; }.token-buttons { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }.token-buttons .el-button + .el-button { margin-left: 0; }.remark-preview { display: flex; gap: 14px; align-items: center; padding: 10px 12px; background: #f5f7fa; margin-top: 8px; border-radius: 4px; line-height: 1.7; }.remark-preview span, .remark-preview small { color: #909399; font-size: 12px; }.remark-preview strong { color: #303133; font-size: 13px; font-weight: 500; overflow-wrap: anywhere; }.remark-preview small { margin-left: auto; white-space: nowrap; }.welcome-block h4 { font-size: 13px; font-weight: 600; margin: 0 0 4px; }.welcome-block > .field-help { margin-bottom: 12px; }.schedule-switch { margin-top: 24px; }.switch-help { margin-left: 12px; color: #909399; font-size: 12px; }.welcome-schedule { background: #fff; }
.tag-select-row .el-button + .el-button { margin-left: 0; }
.custom-tag-editor { margin-top: 12px; padding: 14px; background: #f5f7fa; border: 1px solid #e4e7ed; border-radius: 4px; }
.custom-tag-editor label { display: block; font-size: 13px; color: #606266; margin-bottom: 8px; }
.custom-tag-row { display: flex; align-items: center; gap: 10px; }
.custom-tag-row .el-input { flex: 1; min-width: 0; }
.legacy-tags-warning { margin-top: 10px; padding: 10px 12px; background: #fdf6ec; border: 1px solid #faecd8; border-radius: 4px; color: #9f6d14; }
.legacy-tags-warning p { margin: 0 0 6px; font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; }
.tag-success { margin: 8px 0 0; color: #27864c; font-size: 12px; line-height: 1.7; }
@media (max-width: 620px) { .tag-select-row { flex-direction: column; }.remark-preview { flex-wrap: wrap; }.automation-form :deep(.el-radio) { margin-right: 14px; }.weekday-select { gap: 0 12px; } }
</style>
@@ -239,7 +239,7 @@
<div class="install-tip">
<el-icon><CircleCheck /></el-icon>
<span><strong>无需暴露链接</strong>浮窗点击后仍由服务端执行可用性筛选权重随机和访问统计</span>
<span><strong>一个方案一个官方链接</strong>浮窗会直接打开企业微信链接多名医助由企业微信执行均衡分流</span>
</div>
</section>
</section>
@@ -274,6 +274,8 @@ interface PromotionPool {
name?: string
public_key?: string
script_url?: string
main_url?: string
compat_go_url?: string
go_url?: string
install_code?: string
trigger_code?: string
@@ -437,11 +439,12 @@ async function copySnippet(value: string | undefined, label: string) {
}
function openTestLink() {
if (!props.pool.go_url) {
const targetUrl = props.pool.main_url || props.pool.go_url || props.pool.compat_go_url
if (!targetUrl) {
ElMessage.warning('当前方案暂无测试链接')
return
}
window.open(props.pool.go_url, '_blank', 'noopener,noreferrer')
window.open(targetUrl, '_blank', 'noopener,noreferrer')
}
</script>
@@ -0,0 +1,246 @@
<template>
<div class="welcome-editor">
<div class="welcome-editor__fields">
<div class="text-tools">
<el-popover placement="bottom-start" trigger="click" :width="240" :disabled="disabled">
<template #reference><el-button size="small" :disabled="disabled"> 插入表情</el-button></template>
<div class="emoji-grid"><button v-for="emoji in emojis" :key="emoji" type="button" @click="insertText(emoji)">{{ emoji }}</button></div>
</el-popover>
<el-button v-for="token in templateTokens" :key="token.value" size="small" :disabled="disabled" @click="insertText(token.value)">{{ token.label }}</el-button>
</div>
<el-input
ref="textInput"
:model-value="modelValue.text"
type="textarea"
:rows="6"
:disabled="disabled"
placeholder="请输入欢迎语,也可插入客户昵称、员工昵称和添加日期"
@update:model-value="updateText"
@select="rememberSelection"
@keyup="rememberSelection"
@click="rememberSelection"
@blur="rememberSelection"
/>
<div class="text-count" :class="{ 'is-error': textTooLong }">{{ Array.from(modelValue.text).length }}/1200 · {{ utf8Length(modelValue.text) }}/4000 字节</div>
<div class="attachments-heading">
<strong>附件 <span>{{ modelValue.attachments.length }}/9</span></strong>
<el-dropdown :disabled="disabled || modelValue.attachments.length >= 9 || uploading" @command="addAttachment">
<el-button size="small" :disabled="disabled || modelValue.attachments.length >= 9 || uploading" :icon="Plus">添加附件</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="item in attachmentTypes" :key="item.value" :command="item.value">{{ item.label }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div v-if="!modelValue.attachments.length" class="attachment-empty">可添加图片网页小程序视频或文件</div>
<div v-for="(attachment, index) in modelValue.attachments" :key="index" class="attachment-card">
<div class="attachment-card__heading">
<strong>{{ index + 1 }}. {{ attachmentLabel(attachment.msgtype) }}</strong>
<div>
<el-button text size="small" :disabled="disabled || uploading || index === 0" @click="moveAttachment(index, -1)">上移</el-button>
<el-button text size="small" :disabled="disabled || uploading || index === modelValue.attachments.length - 1" @click="moveAttachment(index, 1)">下移</el-button>
<el-button text type="danger" size="small" :disabled="disabled || uploading" @click="removeAttachment(index)">删除</el-button>
</div>
</div>
<template v-if="attachment.msgtype === 'image' || attachment.msgtype === 'video' || attachment.msgtype === 'file'">
<div class="upload-field">
<el-button size="small" :icon="Upload" :loading="uploadingIndex === index" :disabled="disabled || uploading" @click="selectFile(index, attachment.msgtype)">{{ assetId(attachment) ? '重新上传' : '上传' }}{{ attachmentLabel(attachment.msgtype) }}</el-button>
<span :class="assetId(attachment) ? 'asset-ready' : 'muted'">{{ assetName(attachment) }}</span>
</div>
<small class="field-tip">{{ attachment.msgtype === 'image' ? 'JPG / PNG,最多 10MB。' : attachment.msgtype === 'video' ? 'MP4,最多 10MB。' : '文件最多 20MB。' }}上传成功后由服务端保存素材未上传完成不能提交</small>
</template>
<template v-else-if="attachment.msgtype === 'link'">
<label class="attachment-label">网页标题 <span>{{ utf8Length(attachment.link.title) }}/128 字节</span></label>
<el-input v-model="attachment.link.title" :disabled="disabled" placeholder="必填:网页标题" />
<label class="attachment-label">网页地址</label>
<el-input v-model="attachment.link.url" :disabled="disabled" placeholder="https://" />
<label class="attachment-label">网页描述 <span>{{ utf8Length(attachment.link.desc) }}/512 字节</span></label>
<el-input v-model="attachment.link.desc" :disabled="disabled" type="textarea" :rows="2" placeholder="选填:网页摘要" />
<label class="attachment-label">网页封面地址</label>
<el-input v-model="attachment.link.picurl" :disabled="disabled" placeholder="选填:公网可访问的 HTTPS 图片地址" />
</template>
<template v-else-if="attachment.msgtype === 'miniprogram'">
<label class="attachment-label">小程序标题 <span>{{ utf8Length(attachment.miniprogram.title) }}/64 字节</span></label>
<el-input v-model="attachment.miniprogram.title" :disabled="disabled" placeholder="必填:小程序标题" />
<label class="attachment-label">AppID</label>
<el-input v-model="attachment.miniprogram.appid" :disabled="disabled" placeholder="必填:小程序 AppID" />
<label class="attachment-label">页面路径</label>
<el-input v-model="attachment.miniprogram.page" :disabled="disabled" placeholder="pages/index/index,可包含查询参数" />
<div class="upload-field mini-upload">
<el-button size="small" :icon="Upload" :loading="uploadingIndex === index" :disabled="disabled || uploading" @click="selectFile(index, 'image')">{{ attachment.miniprogram.pic_asset_id ? '更换封面' : '上传小程序封面' }}</el-button>
<span :class="assetId(attachment) ? 'asset-ready' : 'muted'">{{ assetName(attachment) }}</span>
</div>
<small class="field-tip">JPG / PNG最多 10MB</small>
</template>
<div v-if="uploadErrors[index]" role="alert" class="upload-error">{{ uploadErrors[index] }} 请重试上传原素材未被替换</div>
</div>
<input ref="fileInput" class="file-input" type="file" :accept="fileAccept" @change="uploadSelectedFile" />
</div>
<aside class="welcome-preview" aria-label="欢迎语手机预览">
<div class="phone-heading"><span></span><strong>{{ employeeName || '接待员工' }}</strong><span>···</span></div>
<div class="phone-content">
<div class="preview-time">添加成功 · 预览示例</div>
<div v-if="modelValue.text" class="chat-row"><span class="chat-avatar">{{ (employeeName || '员').slice(0, 1) }}</span><div class="chat-bubble">{{ previewTemplate(modelValue.text, employeeName) }}</div></div>
<div v-for="(attachment, index) in modelValue.attachments" :key="index" class="chat-row">
<span class="chat-avatar">{{ (employeeName || '员').slice(0, 1) }}</span>
<div class="chat-bubble attachment-preview">
<template v-if="attachment.msgtype === 'image'">
<img v-if="assetPreview(attachment)" :src="assetPreview(attachment)" alt="欢迎语图片预览" />
<div v-else class="media-placeholder"><el-icon><Picture /></el-icon><span>{{ assetId(attachment) ? '已保存的图片素材' : '待上传图片' }}</span></div>
</template>
<template v-else-if="attachment.msgtype === 'link'">
<strong>{{ attachment.link.title || '网页标题' }}</strong><p>{{ attachment.link.desc || '网页摘要' }}</p><small>网页链接</small>
</template>
<template v-else-if="attachment.msgtype === 'miniprogram'">
<small>小程序</small><strong>{{ attachment.miniprogram.title || '小程序标题' }}</strong>
<img v-if="assetPreview(attachment)" :src="assetPreview(attachment)" alt="小程序封面预览" />
<div v-else class="media-placeholder"><el-icon><Grid /></el-icon><span>小程序封面</span></div>
</template>
<template v-else><el-icon class="file-icon"><VideoPlay v-if="attachment.msgtype === 'video'" /><Document v-else /></el-icon><strong>{{ assetName(attachment) }}</strong><small>{{ attachmentLabel(attachment.msgtype) }}</small></template>
</div>
</div>
<div v-if="!modelValue.text && !modelValue.attachments.length" class="preview-empty">编辑内容后在这里预览</div>
</div>
<div class="phone-input"><span></span><span class="phone-input__blank" /><span></span></div>
<p class="preview-note">仅为排版示意昵称与时间会替换为实际值</p>
</aside>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Document, Grid, Picture, Plus, Upload, VideoPlay } from '@element-plus/icons-vue'
import { wecomPromotionUploadWelcomeMedia } from '@/api/first_visit'
import { previewTemplate, templateTokens, utf8Length } from './promotion-automation'
import type { WelcomeAttachment, WelcomeMessage } from './promotion-automation'
const props = defineProps<{ modelValue: WelcomeMessage; disabled?: boolean; employeeName?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: WelcomeMessage]; busy: [value: boolean] }>()
const textInput = ref<{ textarea?: HTMLTextAreaElement }>()
const fileInput = ref<HTMLInputElement>()
const selection = ref({ start: 0, end: 0 })
const uploadingIndex = ref(-1)
const uploading = computed(() => uploadingIndex.value >= 0)
const uploadErrors = ref<Record<number, string>>({})
const assetCache = ref<Record<string, { name: string; url?: string }>>({})
const selectedUpload = ref<{ index: number; type: 'image' | 'video' | 'file' }>({ index: 0, type: 'image' })
const fileAccept = ref('image/jpeg,image/png')
const emojis = ['😊', '😀', '👋', '🌹', '❤️', '👍', '🙏', '🎉', '☀️', '✨', '💐', '🤝', '💬', '✅', '🌿', '🍀']
const attachmentTypes: Array<{ value: WelcomeAttachment['msgtype']; label: string }> = [
{ value: 'image', label: '图片' }, { value: 'link', label: '网页' }, { value: 'miniprogram', label: '小程序' },
{ value: 'video', label: '视频' }, { value: 'file', label: '文件' }
]
const textTooLong = computed(() => Array.from(props.modelValue.text).length > 1200 || utf8Length(props.modelValue.text) > 4000)
function updateText(text: string) { emit('update:modelValue', { ...props.modelValue, text }) }
function rememberSelection() {
const textarea = textInput.value?.textarea
if (textarea) selection.value = { start: textarea.selectionStart, end: textarea.selectionEnd }
}
async function insertText(text: string) {
if (props.disabled) return
const { start, end } = selection.value
updateText(props.modelValue.text.slice(0, start) + text + props.modelValue.text.slice(end))
await nextTick()
const cursor = start + text.length
textInput.value?.textarea?.focus()
textInput.value?.textarea?.setSelectionRange(cursor, cursor)
selection.value = { start: cursor, end: cursor }
}
function attachmentLabel(type: string) { return attachmentTypes.find((item) => item.value === type)?.label || '附件' }
function addAttachment(type: WelcomeAttachment['msgtype']) {
if (props.disabled || uploading.value || props.modelValue.attachments.length >= 9) return
let attachment: WelcomeAttachment
if (type === 'image') attachment = { msgtype: 'image', image: { asset_id: '' } }
else if (type === 'video') attachment = { msgtype: 'video', video: { asset_id: '' } }
else if (type === 'file') attachment = { msgtype: 'file', file: { asset_id: '' } }
else if (type === 'link') attachment = { msgtype: 'link', link: { title: '', url: '', desc: '', picurl: '' } }
else attachment = { msgtype: 'miniprogram', miniprogram: { title: '', appid: '', page: '', pic_asset_id: '' } }
emit('update:modelValue', { ...props.modelValue, attachments: [...props.modelValue.attachments, attachment] })
}
function removeAttachment(index: number) {
uploadErrors.value = {}
emit('update:modelValue', { ...props.modelValue, attachments: props.modelValue.attachments.filter((_, position) => position !== index) })
}
function moveAttachment(index: number, direction: number) {
const attachments = [...props.modelValue.attachments]
;[attachments[index], attachments[index + direction]] = [attachments[index + direction], attachments[index]]
uploadErrors.value = {}
emit('update:modelValue', { ...props.modelValue, attachments })
}
function assetId(attachment: WelcomeAttachment): string {
if (attachment.msgtype === 'image') return attachment.image.asset_id || ''
if (attachment.msgtype === 'video') return attachment.video.asset_id
if (attachment.msgtype === 'file') return attachment.file.asset_id
if (attachment.msgtype === 'miniprogram') return attachment.miniprogram.pic_asset_id
return ''
}
function assetName(attachment: WelcomeAttachment) {
const id = assetId(attachment)
if (id) return assetCache.value[id]?.name || `已保存素材 ${id.slice(-10)}`
return attachment.msgtype === 'image' && attachment.image.pic_url ? '已保存的企微图片' : '尚未上传'
}
function assetPreview(attachment: WelcomeAttachment) { return assetCache.value[assetId(attachment)]?.url || '' }
async function selectFile(index: number, type: 'image' | 'video' | 'file') {
selectedUpload.value = { index, type }
fileAccept.value = type === 'image' ? '.jpg,.jpeg,.png' : type === 'video' ? '.mp4' : '*'
await nextTick()
fileInput.value?.click()
}
async function uploadSelectedFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
const { index, type } = selectedUpload.value
const limit = type === 'file' ? 20 : 10
let error = ''
if (file.size <= 5 || file.size > limit * 1024 * 1024) error = `文件必须大于 5 字节且不超过 ${limit}MB`
else if (type === 'image' && !/\.(jpe?g|png)$/i.test(file.name)) error = '图片仅支持 JPG / PNG 格式'
else if (type === 'video' && !/\.mp4$/i.test(file.name)) error = '视频仅支持 MP4 格式'
if (error) { uploadErrors.value[index] = error; return }
const attachment = props.modelValue.attachments[index]
if (!attachment) return
uploadingIndex.value = index
emit('busy', true)
delete uploadErrors.value[index]
try {
const result = await wecomPromotionUploadWelcomeMedia(file, type)
if (!result?.asset_id) throw new Error('上传接口未返回素材 ID')
const id = String(result.asset_id)
assetCache.value[id] = { name: result.name || file.name, url: type === 'image' ? URL.createObjectURL(file) : undefined }
if (attachment.msgtype === 'image') { attachment.image = { asset_id: id } }
else if (attachment.msgtype === 'video') attachment.video.asset_id = id
else if (attachment.msgtype === 'file') attachment.file.asset_id = id
else if (attachment.msgtype === 'miniprogram') attachment.miniprogram.pic_asset_id = id
ElMessage.success('素材已上传')
} catch (error: unknown) {
uploadErrors.value[index] = error instanceof Error ? error.message : typeof error === 'string' ? error : '素材上传失败'
} finally {
uploadingIndex.value = -1
emit('busy', false)
}
}
onBeforeUnmount(() => {
Object.values(assetCache.value).forEach((asset) => { if (asset.url) URL.revokeObjectURL(asset.url) })
emit('busy', false)
})
</script>
<style scoped>
.welcome-editor { display: grid; grid-template-columns: minmax(0, 1fr) 260px; align-items: start; gap: 22px; width: 100%; }
.welcome-editor__fields { min-width: 0; }
.text-tools { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }.text-tools .el-button + .el-button { margin-left: 0; }
.emoji-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: 4px; }.emoji-grid button { border: 0; background: none; padding: 4px; cursor: pointer; font-size: 20px; }
.text-count { text-align: right; font-size: 12px; color: #909399; margin-top: 4px; }.text-count.is-error, .upload-error { color: #d93026; }
.attachments-heading, .attachment-card__heading { display: flex; justify-content: space-between; align-items: center; gap: 8px; }.attachments-heading { margin: 16px 0 10px; }.attachments-heading strong { font-size: 13px; }.attachments-heading strong span { color: #909399; font-weight: 400; }
.attachment-empty { padding: 18px 12px; color: #909399; background: #f7f8fa; border: 1px dashed #dcdfe6; border-radius: 4px; font-size: 12px; }
.attachment-card { border: 1px solid #e4e7ed; border-radius: 5px; padding: 12px; margin-top: 10px; }.attachment-card__heading { margin-bottom: 10px; }.attachment-card__heading strong { font-size: 13px; }.attachment-card__heading .el-button { padding: 4px; margin: 0; }
.attachment-label { display: block; font-size: 12px; color: #606266; margin: 10px 0 4px; }.attachment-label span { color: #909399; float: right; }.upload-field { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; font-size: 12px; overflow-wrap: anywhere; }.mini-upload { margin-top: 12px; }.asset-ready { color: #178758; }.muted { color: #909399; }.field-tip { display: block; color: #909399; line-height: 1.6; margin-top: 6px; }.upload-error { font-size: 12px; line-height: 1.6; margin-top: 6px; }.file-input { display: none; }
.welcome-preview { width: 260px; border: 1px solid #dcdfe6; border-radius: 20px; padding: 7px; background: #fff; overflow: hidden; }
.phone-heading { display: flex; justify-content: space-between; align-items: center; padding: 13px 12px; background: #ededed; border-radius: 14px 14px 0 0; font-size: 13px; }.phone-heading > span { font-size: 19px; }
.phone-content { min-height: 330px; max-height: 520px; overflow: auto; background: #ededed; padding: 0 10px 18px; }.preview-time { font-size: 10px; text-align: center; color: #999; padding: 12px 0 18px; }.chat-row { display: flex; gap: 7px; margin-bottom: 12px; align-items: flex-start; }.chat-avatar { width: 27px; height: 27px; background: #6e92ae; color: white; flex-shrink: 0; border-radius: 4px; display: grid; place-items: center; font-size: 11px; }.chat-bubble { background: #fff; padding: 9px 10px; border-radius: 4px; font-size: 12px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; max-width: 172px; }.attachment-preview { width: 172px; }.attachment-preview strong { display: block; font-weight: 500; font-size: 12px; }.attachment-preview p { color: #909399; font-size: 10px; margin: 6px 0; }.attachment-preview small { display: block; font-size: 9px; color: #909399; margin-top: 7px; }.attachment-preview img { width: 100%; max-height: 160px; object-fit: contain; display: block; }.media-placeholder { background: #f2f5f7; height: 85px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; color: #909399; font-size: 10px; }.media-placeholder .el-icon, .file-icon { font-size: 28px; color: #8babc3; }.preview-empty { text-align: center; color: #aaa; font-size: 12px; margin-top: 100px; }.phone-input { display: flex; gap: 10px; padding: 9px; background: #f6f6f6; border-radius: 0 0 14px 14px; align-items: center; color: #909399; }.phone-input__blank { flex: 1; height: 24px; border-radius: 3px; background: white; }.preview-note { margin: 10px 6px 6px; font-size: 11px; color: #909399; line-height: 1.6; }
@media (max-width: 850px) { .welcome-editor { grid-template-columns: 1fr; }.welcome-preview { margin: 8px auto 0; } }
</style>
@@ -0,0 +1,214 @@
export interface PromotionMemberChoice {
id: number
name: string
userid: string
dept_names: string[]
}
export type WelcomeAttachment =
| { msgtype: 'image'; image: { asset_id?: string; pic_url?: string } }
| { msgtype: 'video'; video: { asset_id: string } }
| { msgtype: 'file'; file: { asset_id: string } }
| { msgtype: 'link'; link: { title: string; url: string; desc: string; picurl?: string } }
| { msgtype: 'miniprogram'; miniprogram: { title: string; appid: string; page: string; pic_asset_id: string } }
export interface WelcomeMessage {
text: string
attachments: WelcomeAttachment[]
}
export interface WeeklySlot {
weekdays: number[]
start: string
end: string
}
export interface ReceptionSlot extends WeeklySlot {
member_admin_ids: number[]
}
export interface WelcomeSlot extends WeeklySlot, WelcomeMessage {}
export interface PromotionAutomationConfig {
reception_mode: 'always' | 'scheduled'
reception_schedule: ReceptionSlot[]
backup_member_admin_ids: number[]
tags_enabled: boolean
tag_ids: string[]
remark_enabled: boolean
remark_template: string
description_enabled: boolean
description: string
welcome_mode: 'default' | 'channel' | 'none'
welcome: WelcomeMessage
welcome_schedule_enabled: boolean
welcome_schedule: WelcomeSlot[]
}
export type PromotionAutomationSaveConfig = Omit<PromotionAutomationConfig,
'tags_enabled' | 'remark_enabled' | 'description_enabled' | 'welcome_schedule_enabled'> & {
tags_enabled: 0 | 1
remark_enabled: 0 | 1
description_enabled: 0 | 1
welcome_schedule_enabled: 0 | 1
}
export const weekdays = ['星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日']
export const templateTokens = [
{ label: '客户昵称', value: '{customer_name}' },
{ label: '员工昵称', value: '{employee_name}' },
{ label: '添加时间', value: '{add_time}' }
]
export const utf8Length = (value: string): number => new TextEncoder().encode(value).length
export function validateCustomTagName(value: string): string {
if (!value.trim()) return '请输入自定义标签名称'
if (/[\p{C}\u2028\u2029]/u.test(value)) return ''
if (Array.from(value.trim()).length > 30) return '标签名称最多 30 个字符'
return ''
}
export function defaultAutomationConfig(): PromotionAutomationConfig {
return {
reception_mode: 'always', reception_schedule: [], backup_member_admin_ids: [],
tags_enabled: false, tag_ids: [], remark_enabled: false, remark_template: '{customer_name}',
description_enabled: false, description: '', welcome_mode: 'default',
welcome: { text: '', attachments: [] }, welcome_schedule_enabled: false, welcome_schedule: []
}
}
// Only copy the editable contract. Server-resolved userids never come back in a save payload.
export function cloneAutomationConfig(source?: Partial<PromotionAutomationConfig> | null): PromotionAutomationConfig {
const defaults = defaultAutomationConfig()
if (!source) return defaults
const copy = JSON.parse(JSON.stringify(source)) as PromotionAutomationConfig
return {
reception_mode: copy.reception_mode === 'scheduled' ? 'scheduled' : 'always',
reception_schedule: (copy.reception_schedule || []).map(({ weekdays, start, end, member_admin_ids }) => ({
weekdays: [...weekdays], start, end, member_admin_ids: member_admin_ids.map(Number)
})),
backup_member_admin_ids: (copy.backup_member_admin_ids || []).map(Number),
tags_enabled: Boolean(copy.tags_enabled), tag_ids: [...(copy.tag_ids || [])],
remark_enabled: Boolean(copy.remark_enabled), remark_template: copy.remark_template ?? defaults.remark_template,
description_enabled: Boolean(copy.description_enabled), description: copy.description || '',
welcome_mode: copy.welcome_mode || 'default',
welcome: { text: copy.welcome?.text || '', attachments: copy.welcome?.attachments || [] },
welcome_schedule_enabled: Boolean(copy.welcome_schedule_enabled),
welcome_schedule: (copy.welcome_schedule || []).map(({ weekdays, start, end, text, attachments }) => ({
weekdays: [...weekdays], start, end, text: text || '', attachments: attachments || []
}))
}
}
/** Keep the save payload compatible with backends and transports that use 0/1 switches. */
export function serializeAutomationConfig(config: PromotionAutomationConfig): PromotionAutomationSaveConfig {
return {
...config,
tags_enabled: config.tags_enabled ? 1 : 0,
remark_enabled: config.remark_enabled ? 1 : 0,
description_enabled: config.description_enabled ? 1 : 0,
welcome_schedule_enabled: config.welcome_schedule_enabled ? 1 : 0
}
}
export function previewTemplate(template: string, employee = '小陈', limit?: number): string {
const date = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit'
}).format(new Date())
const text = template.replace(/\{(customer_name|employee_name|add_time)\}/g, (token) => ({
'{customer_name}': '张女士', '{employee_name}': employee, '{add_time}': date
}[token] || token))
return limit ? Array.from(text).slice(0, limit).join('') : text
}
export function isWebUrl(value: string): boolean {
try { return ['https:', 'http:'].includes(new URL(value).protocol) } catch { return false }
}
function validateSlot(slot: WeeklySlot, label: string): string {
if (!slot.weekdays.length || slot.weekdays.some((day) => day < 1 || day > 7)) return `${label}请选择星期`
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(slot.start) || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(slot.end)) return `${label}请填写有效的起止时间`
if (slot.start === slot.end) return `${label}开始与结束时间不能相同,全天接待请选择全天模式`
return ''
}
export function validateWelcomeMessage(message: WelcomeMessage, label: string, required = true): string {
if (Array.from(message.text).length > 1200) return `${label}正文不能超过 1200 字`
if (utf8Length(message.text) > 4000) return `${label}正文不能超过 4000 UTF-8 字节,请减少表情或文字`
if (message.attachments.length > 9) return `${label}最多添加 9 个附件`
if (required && !message.text.trim() && !message.attachments.length) return `${label}请填写正文或添加附件`
for (const [index, attachment] of message.attachments.entries()) {
const prefix = `${label}${index + 1} 个附件:`
switch (attachment.msgtype) {
case 'image':
if (!attachment.image.asset_id && !isWebUrl(attachment.image.pic_url || '')) return prefix + '请上传图片或填写有效图片地址'
break
case 'video':
if (!attachment.video.asset_id) return prefix + '请先上传视频'
break
case 'file':
if (!attachment.file.asset_id) return prefix + '请先上传文件'
break
case 'link':
if (!attachment.link.title.trim() || !isWebUrl(attachment.link.url)) return prefix + '请填写网页标题和有效的 HTTP/HTTPS 链接'
if (utf8Length(attachment.link.title) > 128 || utf8Length(attachment.link.desc) > 512) return prefix + '网页标题限 128 字节,描述限 512 字节'
if (attachment.link.picurl && !isWebUrl(attachment.link.picurl)) return prefix + '网页封面地址无效'
break
case 'miniprogram':
if (!attachment.miniprogram.title.trim() || !attachment.miniprogram.appid.trim() || !attachment.miniprogram.page.trim() || !attachment.miniprogram.pic_asset_id) return prefix + '请填写小程序标题、AppID、页面路径并上传封面'
if (utf8Length(attachment.miniprogram.title) > 64) return prefix + '小程序标题不能超过 64 字节'
break
}
}
return ''
}
/** Weekly minute occupancy also catches Sunday-to-Monday and overnight overlap. */
export function welcomeScheduleOverlap(slots: WeeklySlot[]): boolean {
const minutes = new Set<number>()
for (const slot of slots) {
const [sh, sm] = slot.start.split(':').map(Number)
const [eh, em] = slot.end.split(':').map(Number)
const start = sh * 60 + sm
const duration = (eh * 60 + em - start + 1440) % 1440
for (const day of new Set(slot.weekdays)) {
for (let offset = 0; offset < duration; offset++) {
const minute = ((day - 1) * 1440 + start + offset) % 10080
if (minutes.has(minute)) return true
minutes.add(minute)
}
}
}
return false
}
export function validateAutomationConfig(config: PromotionAutomationConfig, mainMemberIds: number[]): string {
if (config.backup_member_admin_ids.some((id) => mainMemberIds.includes(id))) return '备用成员不能与主接待成员重复'
if (config.reception_schedule.length > 30 || config.welcome_schedule.length > 30) return '每类时段最多添加 30 条'
if (config.reception_mode === 'scheduled') {
if (!config.reception_schedule.length) return '按时段接待至少需要一个接待时段'
if (!config.backup_member_admin_ids.length) return '按时段接待至少需要一名备用成员,以承接非接待时段的客户'
}
for (const [index, slot] of config.reception_schedule.entries()) {
const error = validateSlot(slot, `接待时段 ${index + 1}`)
if (error) return error
if (!slot.member_admin_ids.length) return `接待时段 ${index + 1}:请至少选择一名成员`
if (slot.member_admin_ids.some((id) => !mainMemberIds.includes(id))) return `接待时段 ${index + 1}:成员必须来自主接待成员,请重新选择`
}
if (config.tags_enabled && !config.tag_ids.length) return '启用客户标签后,请选择一个企业微信标签,或创建自定义标签'
if (config.tag_ids.length > 1) return '客户标签只能选择一个,请重新选择或清空原标签'
if (config.remark_enabled && !config.remark_template.trim()) return '请填写客户备注模板'
if (Array.from(config.remark_template).length > 200) return '客户备注模板不能超过 200 字'
if (config.description_enabled && !config.description.trim()) return '请填写客户描述'
if (Array.from(config.description).length > 150) return '客户描述不能超过 150 字'
const error = validateWelcomeMessage(config.welcome, '基础渠道欢迎语:', config.welcome_mode === 'channel')
if (error) return error
if (config.welcome_mode === 'channel' && config.welcome_schedule_enabled && !config.welcome_schedule.length) return '请至少添加一个分时欢迎语时段'
for (const [index, slot] of config.welcome_schedule.entries()) {
const error = validateSlot(slot, `欢迎语时段 ${index + 1}`) || validateWelcomeMessage(slot, `欢迎语时段 ${index + 1}`)
if (error) return error
}
if (welcomeScheduleOverlap(config.welcome_schedule)) return '分时欢迎语的时间范围不能重叠(跨午夜时段归属开始日)'
return ''
}
File diff suppressed because it is too large Load Diff
+89 -5
View File
@@ -677,6 +677,32 @@
<el-option label="驼奶费用" :value="8" />
</el-select>
</el-form-item>
<el-form-item
v-if="canEditOrderTime && isEditPaymentTimeEditable"
label="支付时间"
prop="payment_time"
>
<el-date-picker
v-model="editOrderForm.payment_time"
type="datetime"
placeholder="请选择支付时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
:clearable="false"
class="w-full"
/>
</el-form-item>
<el-form-item v-if="canEditOrderTime" label="创建时间" prop="create_time">
<el-date-picker
v-model="editOrderForm.create_time"
type="datetime"
placeholder="请选择创建时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
:clearable="false"
class="w-full"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editOrderDialogVisible = false">取消</el-button>
@@ -782,6 +808,7 @@
<script setup lang="ts" name="orderList">
import { computed } from 'vue'
import { usePaging } from '@/hooks/usePaging'
import { hasPermission } from '@/utils/perm'
import {
orderLists,
orderDetail,
@@ -1015,9 +1042,33 @@ const editOrderFormRef = ref()
const editOrderLoading = ref(false)
const editPatientLoading = ref(false)
const editPatientList = ref<any[]>([])
const editOrderForm = ref<{ id: number; patient_id: number | null; order_type: number } | null>(null)
type EditOrderForm = {
id: number
patient_id: number | null
order_type: number
status: number
payment_time: string
create_time: string
}
const editOrderForm = ref<EditOrderForm | null>(null)
const canEditOrderTime = computed(() => hasPermission(['order.order/editTime']))
const isEditPaymentTimeEditable = computed(() => [2, 4].includes(editOrderForm.value?.status ?? 0))
const editOrderRules = {
order_type: [{ required: true, message: '请选择订单类型', trigger: 'change' }]
order_type: [{ required: true, message: '请选择订单类型', trigger: 'change' }],
payment_time: [
{
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (isEditPaymentTimeEditable.value && !value) {
callback(new Error('请选择支付时间'))
return
}
callback()
},
trigger: 'change'
}
],
create_time: [{ required: true, message: '请选择创建时间', trigger: 'change' }]
}
//
@@ -1289,11 +1340,37 @@ const getCreateTypeText = (row: any) => {
}
//
const normalizeOrderDateTime = (value: unknown) => {
if (value === null || value === undefined || value === '' || value === '-') return ''
const raw = String(value).trim()
const canonicalDateTime = raw.match(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/)?.[0]
if (canonicalDateTime) return canonicalDateTime.replace('T', ' ')
const numericTimestamp = /^\d{10,13}$/.test(raw) ? Number(raw) : 0
const parsed = new Date(
numericTimestamp
? numericTimestamp < 1_000_000_000_000
? numericTimestamp * 1000
: numericTimestamp
: raw
)
if (Number.isNaN(parsed.getTime())) return ''
const pad = (part: number) => String(part).padStart(2, '0')
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())} ${pad(
parsed.getHours()
)}:${pad(parsed.getMinutes())}:${pad(parsed.getSeconds())}`
}
const handleEditOrder = (row: any) => {
editOrderForm.value = {
id: row.id,
patient_id: row.patient_id || null,
order_type: row.order_type
order_type: row.order_type,
status: Number(row.status),
payment_time: [2, 4].includes(Number(row.status)) ? normalizeOrderDateTime(row.payment_time) : '',
create_time: normalizeOrderDateTime(row.create_time)
}
editPatientList.value = row.patient ? [row.patient] : []
editOrderDialogVisible.value = true
@@ -1320,11 +1397,18 @@ const submitEditOrder = async () => {
try {
await editOrderFormRef.value?.validate()
editOrderLoading.value = true
await orderEdit({
const payload: Record<string, unknown> = {
id: editOrderForm.value.id,
patient_id: editOrderForm.value.patient_id ?? 0,
order_type: editOrderForm.value.order_type
})
}
if (canEditOrderTime.value) {
payload.create_time = editOrderForm.value.create_time
if (isEditPaymentTimeEditable.value) {
payload.payment_time = editOrderForm.value.payment_time
}
}
await orderEdit(payload)
feedback.msgSuccess('保存成功')
editOrderDialogVisible.value = false
getLists()
+16 -5
View File
@@ -215,6 +215,7 @@ const PAGE_SIZE = 15
interface QueueRow {
id: number
patient_id: number
source_patient_id?: number
patient_name: string
patient_phone?: string
diagnosis_id?: number
@@ -224,6 +225,7 @@ interface QueueRow {
assistant_name?: string
appointment_date?: string
appointment_time?: string
appointment_type?: string | null
gender?: number
age?: number | null
status?: number
@@ -436,19 +438,28 @@ const handleSearch = async () => {
}
const handleCall = async (row: QueueRow) => {
if (!row.patient_id) {
const sourcePatientId = Number(row.source_patient_id || 0)
const diagnosisId = Number(row.diagnosis_id || 0)
if (!sourcePatientId) {
feedback.msgWarning('患者信息不完整')
return
}
if (!diagnosisId) {
feedback.msgWarning('预约信息不完整,无法发起聊天')
return
}
try {
const res = await getCallSignature({
patient_id: row.patient_id,
diagnosis_id: row.diagnosis_id || row.id
patient_id: sourcePatientId,
appointment_id: Number(row.id),
diagnosis_id: diagnosisId
})
chatDialogRef.value?.open({
patientId: row.patient_id,
patientId: sourcePatientId,
patientName: row.patient_name,
diagnosisId: row.diagnosis_id || row.id,
diagnosisId,
appointmentId: Number(row.id),
appointmentType: row.appointment_type,
signatureData: res
})
} catch (error: any) {
@@ -0,0 +1,352 @@
<template>
<div class="desktop-workstation">
<el-form
ref="formRef"
class="ls-form"
:model="formData"
:rules="rules"
label-width="140px"
scroll-to-error
>
<el-card shadow="never" class="!border-none">
<el-alert type="info" :closable="false" show-icon class="mb-5">
<template #title>医生工作站升级包</template>
<div class="leading-6">
桌面端启动后会检测本页发布的版本请上传或填写与
<code>一键打包</code>
产物一致的安装包并填入打包目录中的 SHA-256Windows 推荐使用
Setup.exe用户点击立即更新后会自动安装并重启macOS 继续使用 ZIP
安装包通常超过 200MB本页上传按钮会直传到已配置的腾讯云 COS也可以
自行上传到其他对象存储 / CDN 后粘贴地址
</div>
</el-alert>
<div class="text-xl font-medium mb-[20px]">升级策略</div>
<el-form-item label="自动检测" prop="enabled">
<div>
<el-switch
v-model="formData.enabled"
:active-value="1"
:inactive-value="0"
/>
<span class="ml-2">{{ formData.enabled ? '开启' : '关闭' }}</span>
<div class="form-tips">关闭后桌面端不会提示或强制升级</div>
</div>
</el-form-item>
<el-form-item label="最新版本号" prop="latest_version">
<div class="w-80">
<el-input
v-model.trim="formData.latest_version"
placeholder="须与 pyproject.toml / 客户端版本一致,例如 0.2.0"
maxlength="20"
/>
</div>
</el-form-item>
<el-form-item label="强制升级" prop="force_update">
<div>
<el-switch
v-model="formData.force_update"
:active-value="1"
:inactive-value="0"
/>
<span class="ml-2">{{ formData.force_update ? '开启' : '关闭' }}</span>
<div class="form-tips">
开启后低于最新版本且已配置对应平台安装包的客户端必须升级后才能继续使用
</div>
</div>
</el-form-item>
<el-form-item label="最低可用版本" prop="min_version">
<div class="w-80">
<el-input
v-model.trim="formData.min_version"
placeholder="可选,例如 0.1.0;低于该版本也会强制升级"
maxlength="20"
/>
</div>
</el-form-item>
<el-form-item label="更新标题" prop="title">
<div class="w-[480px]">
<el-input
v-model.trim="formData.title"
placeholder="例如:医生工作站 0.2.0"
maxlength="80"
show-word-limit
/>
</div>
</el-form-item>
<el-form-item label="更新说明" prop="notes">
<div class="w-[640px]">
<el-input
v-model="formData.notes"
type="textarea"
:rows="6"
maxlength="4000"
show-word-limit
placeholder="医生端弹窗中展示的更新说明"
/>
</div>
</el-form-item>
</el-card>
<el-card
v-for="item in platforms"
:key="item.key"
shadow="never"
class="!border-none mt-4"
>
<div class="text-xl font-medium mb-[20px]">{{ item.label }}</div>
<el-form-item label="安装包类型">
<el-select v-model="formData.packages[item.key].type" class="w-80">
<el-option label="便携压缩包(ZIP" value="archive" />
<el-option
v-if="item.key === 'windows_x64'"
label="Windows 安装程序(Inno Setup EXE"
value="inno_setup"
/>
</el-select>
<div class="form-tips ml-3">
{{
formData.packages[item.key].type === 'inno_setup'
? '推荐:退出客户端后静默安装,必要时显示 Windows 权限确认。'
: '兼容旧版客户端的 ZIP 覆盖更新。'
}}
</div>
</el-form-item>
<el-form-item label="安装包地址">
<div class="w-[640px]">
<el-input
v-model.trim="formData.packages[item.key].url"
:placeholder="
formData.packages[item.key].type === 'inno_setup'
? 'https://.../DoctorWorkstation-Setup-....exe'
: 'https://...zip 或站内 uploads/file/... 路径'
"
/>
</div>
</el-form-item>
<el-form-item label="上传安装包">
<div>
<upload
v-perms="['setting.desktop_workstation/setConfig']"
type="desktop_package"
direct
:limit="1"
:multiple="false"
:show-progress="true"
:data="{ cid: 0 }"
@success="(response: any) => handleUploadSuccess(item.key, response)"
@change="(file: any) => handleUploadFile(item.key, file)"
>
<el-button type="primary" plain>选择安装包并上传</el-button>
</upload>
<div class="form-tips">
安装包将分片直传腾讯云 COS不经过业务服务器支持 EXE / ZIP最大
2GBWindows 自动安装程序必须使用 HTTPS 地址并开启证书校验
</div>
</div>
</el-form-item>
<el-form-item label="SHA-256">
<div class="w-[640px]">
<el-input
v-model.trim="formData.packages[item.key].sha256"
placeholder="64 位十六进制,来自 SHA256SUMS.txt 或打包生成的 .sha256"
/>
</div>
</el-form-item>
<el-form-item label="文件名">
<div class="w-[480px]">
<el-input
v-model.trim="formData.packages[item.key].filename"
:placeholder="
formData.packages[item.key].type === 'inno_setup'
? 'DoctorWorkstation-Setup-Windows-x64-0.2.0.exe'
: 'DoctorWorkstation-Windows-x64-0.2.0.zip'
"
/>
</div>
</el-form-item>
<el-form-item label="文件大小">
<div class="flex items-center gap-3">
<el-input
v-model.number="formData.packages[item.key].size"
class="w-48"
placeholder="字节"
/>
<span class="text-tx-secondary text-sm">{{
formatSize(formData.packages[item.key].size)
}}</span>
</div>
</el-form-item>
</el-card>
</el-form>
<footer-btns v-perms="['setting.desktop_workstation/setConfig']">
<el-button type="primary" @click="handleSubmit">保存</el-button>
</footer-btns>
</div>
</template>
<script lang="ts" setup name="desktopWorkstation">
import type { FormInstance } from 'element-plus'
import {
getDesktopWorkstationConfig,
setDesktopWorkstationConfig,
type DesktopPackage,
type DesktopPackageType
} from '@/api/setting/desktop_workstation'
import Upload from '@/components/upload/index.vue'
import feedback from '@/utils/feedback'
type PlatformKey = 'windows_x64' | 'macos_arm64' | 'macos_x64'
const emptyPackage = (type: DesktopPackageType = 'archive'): DesktopPackage => ({
url: '',
sha256: '',
size: 0,
filename: '',
type
})
const formRef = shallowRef<FormInstance>()
const formData = reactive({
enabled: 1,
latest_version: '',
min_version: '',
force_update: 0,
title: '',
notes: '',
packages: {
windows_x64: emptyPackage('inno_setup'),
macos_arm64: emptyPackage(),
macos_x64: emptyPackage()
}
})
const platforms: { key: PlatformKey; label: string }[] = [
{ key: 'windows_x64', label: 'Windows 64 位安装包' },
{ key: 'macos_arm64', label: 'macOS Apple 芯片安装包' },
{ key: 'macos_x64', label: 'macOS Intel 安装包' }
]
const rules = {
latest_version: [
{
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (formData.enabled === 1 && !String(value || '').trim()) {
callback(new Error('启用自动检测时请填写最新版本号'))
return
}
if (value && !/^\d+(?:\.\d+){0,3}$/.test(value.trim())) {
callback(new Error('版本号须为 x.y.z 数字格式'))
return
}
callback()
},
trigger: ['blur', 'change']
}
],
min_version: [
{
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (value && !/^\d+(?:\.\d+){0,3}$/.test(value.trim())) {
callback(new Error('版本号须为 x.y.z 数字格式'))
return
}
callback()
},
trigger: ['blur']
}
]
}
const formatSize = (size: number) => {
const bytes = Number(size) || 0
if (bytes <= 0) return '未填写'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
const assignPackage = (key: PlatformKey, row: Partial<DesktopPackage> | undefined) => {
const current = formData.packages[key]
current.url = String(row?.url || '')
current.sha256 = String(row?.sha256 || '')
current.size = Number(row?.size || 0)
current.filename = String(row?.filename || '')
current.type =
row?.type === 'inno_setup' || (key === 'windows_x64' && !row?.url && !row?.filename)
? 'inno_setup'
: 'archive'
}
const getData = async () => {
const data = await getDesktopWorkstationConfig()
formData.enabled = Number(data.enabled ?? 0) ? 1 : 0
formData.latest_version = data.latest_version || ''
formData.min_version = data.min_version || ''
formData.force_update = Number(data.force_update ?? 0) ? 1 : 0
formData.title = data.title || ''
formData.notes = data.notes || ''
assignPackage('windows_x64', data.packages?.windows_x64)
assignPackage('macos_arm64', data.packages?.macos_arm64)
assignPackage('macos_x64', data.packages?.macos_x64)
}
const sha256File = async (file: File) => {
const buffer = await file.arrayBuffer()
const digest = await crypto.subtle.digest('SHA-256', buffer)
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
}
const handleUploadFile = async (key: PlatformKey, file: any) => {
const raw = file?.raw as File | undefined
if (!raw) return
formData.packages[key].filename = raw.name || formData.packages[key].filename
formData.packages[key].size = Number(raw.size || 0)
if (key === 'windows_x64' && raw.name?.toLowerCase().endsWith('.exe')) {
formData.packages[key].type = 'inno_setup'
} else if (raw.name?.toLowerCase().endsWith('.zip')) {
formData.packages[key].type = 'archive'
}
try {
formData.packages[key].sha256 = await sha256File(raw)
} catch (error) {
console.warn(error)
feedback.msgWarning('浏览器计算 SHA-256 失败,请手动填写校验值')
}
}
const handleUploadSuccess = (key: PlatformKey, response: any) => {
const payload = response?.data || {}
if (payload.uri) {
formData.packages[key].url = payload.uri
} else if (payload.url) {
formData.packages[key].url = payload.url
}
if (payload.name && !formData.packages[key].filename) {
formData.packages[key].filename = payload.name
}
}
const handleSubmit = async () => {
await formRef.value?.validate()
await setDesktopWorkstationConfig({
enabled: formData.enabled,
latest_version: formData.latest_version,
min_version: formData.min_version,
force_update: formData.force_update,
title: formData.title,
notes: formData.notes,
packages: {
windows_x64: { ...formData.packages.windows_x64 },
macos_arm64: { ...formData.packages.macos_arm64 },
macos_x64: { ...formData.packages.macos_x64 }
}
})
await getData()
}
getData()
</script>
+47 -36
View File
@@ -163,7 +163,9 @@
</el-table-column>
<!-- <el-table-column label="预约类型" prop="appointment_type_desc" width="100" show-overflow-tooltip /> -->
<el-table-column label="面诊类型" width="110">
<template #default="{ row }">{{ appointmentTypeDescription(row.appointment_type) }}</template>
</el-table-column>
<el-table-column label="确认诊单" width="90" align="center">
<template #default="{ row }">
@@ -219,8 +221,9 @@
</el-button>
<template v-if="row.status !== 3">
<el-button
v-perms="['tcm.diagnosis/videoQr']"
<el-button
v-if="canAppointmentVideoCall(row.appointment_type)"
v-perms="['tcm.diagnosis/videoQr']"
type="warning"
link
size="small"
@@ -237,7 +240,7 @@
size="small"
@click="handleChat(row)"
>
通话
{{ canAppointmentVideoCall(row.appointment_type) ? '通话' : '图文沟通' }}
</el-button>
<el-button
v-perms="['doctor.appointment/complete']"
@@ -340,8 +343,8 @@
<span class="detail-value">{{ detailData.period === 'morning' ? '上午' : '下午' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">预约类型</span>
<span class="detail-value">{{ detailData.appointment_type_desc }}</span>
<span class="detail-label">面诊类型</span>
<span class="detail-value">{{ appointmentTypeDescription(detailData.appointment_type) }}</span>
</div>
</div>
</div>
@@ -555,7 +558,8 @@
</div>
</template>
<script setup lang="ts">
<script setup lang="ts">
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
import { usePaging } from '@/hooks/usePaging'
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
@@ -996,34 +1000,37 @@ const handleEdit = (row: any) => {
}
//
const handleChat = async (row: any) => {
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
// ID
if (!row.diagnosis_id && !row.id) {
feedback.msgWarning('预约信息不完整,无法发起聊天')
return
}
try {
//
const res = await getCallSignature({
patient_id: row.patient_id,
diagnosis_id: row.diagnosis_id || row.id
})
console.log('获取聊天签名成功:', res)
//
chatDialogRef.value?.open({
patientId: row.patient_id,
patientName: row.patient_name,
diagnosisId: row.diagnosis_id || row.id,
signatureData: res //
})
const handleChat = async (row: any) => {
const sourcePatientId = Number(row.source_patient_id || 0)
const diagnosisId = Number(row.diagnosis_id || 0)
if (!sourcePatientId) {
feedback.msgWarning('患者信息不完整')
return
}
if (!diagnosisId) {
feedback.msgWarning('预约信息不完整,无法发起聊天')
return
}
try {
//
const res = await getCallSignature({
patient_id: sourcePatientId,
diagnosis_id: diagnosisId,
appointment_id: Number(row.id)
})
console.log('获取聊天签名成功:', res)
//
chatDialogRef.value?.open({
patientId: sourcePatientId,
patientName: row.patient_name,
diagnosisId,
appointmentId: Number(row.id),
appointmentType: row.appointment_type,
signatureData: res //
})
} catch (error: any) {
console.error('获取聊天签名失败:', error)
feedback.msgError(error.message || '获取聊天签名失败')
@@ -1031,7 +1038,11 @@ const handleChat = async (row: any) => {
}
// pages/login/login
const handleMiniProgramQRCode = async (row: any) => {
const handleMiniProgramQRCode = async (row: any) => {
if (!canAppointmentVideoCall(row.appointment_type)) {
feedback.msgWarning('仅视频问诊可生成视频二维码')
return
}
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
+45 -26
View File
@@ -108,8 +108,12 @@
<span class="apt-h5-row-key">预约</span>
<span class="apt-h5-row-val">{{ row.appointment_date || '-' }} {{ row.appointment_time || '' }}</span>
</div>
<div class="apt-h5-row">
<span class="apt-h5-row-key">医生</span>
<div class="apt-h5-row">
<span class="apt-h5-row-key">面诊类型</span>
<span class="apt-h5-row-val">{{ appointmentTypeDescription(row.appointment_type) }}</span>
</div>
<div class="apt-h5-row">
<span class="apt-h5-row-key">医生</span>
<span class="apt-h5-row-val">{{ row.doctor_name || '-' }}</span>
</div>
<div class="apt-h5-row">
@@ -147,7 +151,7 @@
:icon="Phone"
@click="handleChat(row)"
>
通话
{{ canAppointmentVideoCall(row.appointment_type) ? '通话' : '图文沟通' }}
</el-button>
<el-button
v-if="hasPermission(['tcm.diagnosis/kaifang'])"
@@ -179,7 +183,7 @@
>
<div v-if="moreSheetRow" class="apt-h5-actions">
<button
v-if="moreSheetRow.status !== 3 && hasPermission(['tcm.diagnosis/videoQr'])"
v-if="moreSheetRow.status !== 3 && canAppointmentVideoCall(moreSheetRow.appointment_type) && hasPermission(['tcm.diagnosis/videoQr'])"
class="apt-h5-action"
@click="runAction('videoQr')"
>
@@ -236,7 +240,7 @@
<div><span>医生</span><strong>{{ detailData.doctor_name || '-' }}</strong></div>
<div><span>预约时间</span><strong>{{ detailData.appointment_date || '-' }} {{ detailData.appointment_time || '' }}</strong></div>
<div><span>时段</span><strong>{{ detailData.period === 'morning' ? '上午' : detailData.period === 'afternoon' ? '下午' : '-' }}</strong></div>
<div><span>预约类型</span><strong>{{ detailData.appointment_type_desc || '-' }}</strong></div>
<div><span>面诊类型</span><strong>{{ appointmentTypeDescription(detailData.appointment_type) }}</strong></div>
</div>
</section>
@@ -336,7 +340,7 @@
plain
@click="handleChat(detailActionRow)"
>
通话
{{ canAppointmentVideoCall(detailActionRow.appointment_type) ? '通话' : '图文沟通' }}
</el-button>
<el-button
v-if="detailActionRow.status !== 3 && hasPermission(['doctor.appointment/complete'])"
@@ -407,7 +411,8 @@
</div>
</template>
<script setup lang="ts" name="tcmAppointmentH5">
<script setup lang="ts" name="tcmAppointmentH5">
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
import { appointmentDetail, appointmentLists, cancelAppointment, completeAppointment } from '@/api/doctor'
import { getWeappConfig } from '@/api/channel/weapp'
import {
@@ -642,22 +647,31 @@ const handleEdit = (row: any) => {
}
editRef.value?.open('edit', row.patient_id)
}
const handleChat = async (row: any) => {
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
try {
const res = await getCallSignature({
patient_id: row.patient_id,
diagnosis_id: row.diagnosis_id || row.id
})
chatDialogRef.value?.open({
patientId: row.patient_id,
patientName: row.patient_name,
diagnosisId: row.diagnosis_id || row.id,
signatureData: res
})
const handleChat = async (row: any) => {
const sourcePatientId = Number(row.source_patient_id || 0)
const diagnosisId = Number(row.diagnosis_id || 0)
if (!sourcePatientId) {
feedback.msgWarning('患者信息不完整')
return
}
if (!diagnosisId) {
feedback.msgWarning('预约信息不完整,无法发起聊天')
return
}
try {
const res = await getCallSignature({
patient_id: sourcePatientId,
diagnosis_id: diagnosisId,
appointment_id: Number(row.id)
})
chatDialogRef.value?.open({
patientId: sourcePatientId,
patientName: row.patient_name,
diagnosisId,
appointmentId: Number(row.id),
appointmentType: row.appointment_type,
signatureData: res
})
} catch (e: any) {
feedback.msgError(e?.msg || e?.message || '获取通话签名失败')
}
@@ -770,7 +784,11 @@ const qrcodeDialogVisible = ref(false)
const qrcodeLoading = ref(false)
const qrcodeUrl = ref('')
const currentQRCodeRow = ref<any>(null)
const handleMiniProgramQRCode = async (row: any) => {
const handleMiniProgramQRCode = async (row: any) => {
if (!canAppointmentVideoCall(row.appointment_type)) {
feedback.msgWarning('仅视频问诊可生成视频二维码')
return
}
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
@@ -1177,8 +1195,9 @@ onUnmounted(() => {
line-height: 20px;
}
.apt-h5-row-key {
flex: 0 0 38px;
.apt-h5-row-key {
flex: 0 0 60px;
white-space: nowrap;
color: #8a94a6;
}
@@ -30,10 +30,11 @@
</el-radio-group>
</el-form-item>
<!-- 预约类型 -->
<el-form-item label="预约类型:">
<el-radio-group v-model="form.appointmentType">
<el-radio value="video">视频问诊</el-radio>
<!-- 挂号类型 -->
<el-form-item label="挂号类型:">
<el-radio-group v-model="form.appointmentType">
<el-radio value="video">视频问诊</el-radio>
<el-radio value="text">图文问诊</el-radio>
</el-radio-group>
</el-form-item>
@@ -262,9 +263,8 @@ const form = reactive({
channel_source_detail: '' as string
})
/** 仅这些渠道字典 name 需填「自媒体补充」(与后台字典名称完全一致) */
/** 自媒体4H/4Q 无需补充;仅这些渠道字典 name 需填「自媒体补充」(与后台字典名称完全一致) */
const CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL = new Set([
'自媒体4H',
'自媒体3Q',
'自媒体3H',
'自媒体2H',
@@ -2,29 +2,47 @@
<div class="im-chat-record-panel">
<el-alert type="info" show-icon :closable="false" class="mb-4" title="说明">
<p class="panel-tip">
展示单聊记录已合并患者
展示患者所有诊单中
<strong>所有医生 / 医助账号</strong>分别产生的会话按时间排序
数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档点击下方按钮可立即触发后台同步
打开页面后自动加载并同步最新消息页面可见期间每 30 秒继续检查更新
</p>
</el-alert>
<div class="toolbar mb-3">
<el-button type="primary" link :loading="syncing" @click="triggerSync">
<el-icon class="mr-1"><Promotion /></el-icon>
同步最新后台异步
同步最新
</el-button>
<el-button type="primary" link :loading="loading" @click="reloadArchived">
<el-icon class="mr-1"><Refresh /></el-icon>
重新加载已归档
</el-button>
</div>
<div v-loading="loading" class="chat-wrap">
<template v-if="!loading && rows.length">
</div>
<p v-if="syncing" class="sync-status" role="status" aria-live="polite">
<template v-if="history.phase === 'checking_accounts'">
正在核验聊天账号<span v-if="history.candidateAccounts">{{ history.checkedAccounts }} / {{ history.candidateAccounts }}</span>
</template>
<template v-else>正在同步聊天记录<span v-if="history.totalPeers">已处理 {{ history.processedPeers }} / {{ history.totalPeers }} 个会话</span>新增 {{ history.inserted }} </template>
</p>
<p v-else-if="history.lastSyncAt" class="sync-status">上次同步{{ formatTime(history.lastSyncAt) }}</p>
<p v-if="history.skippedAccounts" class="sync-status">已跳过 {{ history.skippedAccounts }} 个未注册或已失效的聊天账号已有归档不受影响</p>
<el-alert v-if="history.readError" type="error" show-icon :closable="false" class="mb-3" :title="`读取聊天记录失败:${history.readError}`" />
<el-alert v-if="history.syncError" type="error" show-icon :closable="false" class="mb-3" :title="`同步失败:${history.syncError}`" />
<el-alert v-if="history.partialErrors.length" type="warning" show-icon :closable="false" class="mb-3" title="聊天记录未完全同步">
<p v-for="error in history.partialErrors.slice(0, 3)" :key="error" class="panel-tip">{{ error }}</p>
<details v-if="history.partialErrors.length > 3">
<summary>查看其余 {{ history.partialErrors.length - 3 }} 项同步问题</summary>
<p v-for="error in history.partialErrors.slice(3)" :key="error" class="panel-tip">{{ error }}</p>
</details>
</el-alert>
<div v-loading="loading && !rows.length" class="chat-wrap">
<template v-if="rows.length">
<div class="chat-list">
<div
v-for="item in rows"
:key="item.raw.msg_id"
:key="`${item.raw.from_account || ''}-${item.raw.to_account || ''}-${item.raw.msg_id}`"
class="chat-row"
:class="item.raw.is_from_doctor ? 'from-doctor' : 'from-patient'"
>
@@ -64,31 +82,40 @@
</div>
</div>
</template>
<el-empty v-else-if="!loading" description="暂无 IM 聊天记录" />
<el-empty v-else-if="!loading" :description="emptyDescription" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, shallowRef, watch, computed } from 'vue'
import { watch, computed, toRefs, onMounted, onBeforeUnmount, onActivated, onDeactivated } from 'vue'
import dayjs from 'dayjs'
import { Refresh, Promotion } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getImChatMessages, triggerImChatSync } from '@/api/tcm'
import type { FriendlyParse } from '@/utils/im-business-message-parse'
import { parseImBusinessPayload } from '@/utils/im-business-message-parse'
import { parseImBusinessPayload } from '@/utils/im-business-message-parse'
import { createImChatHistory } from '@/utils/im-chat-history'
const props = defineProps<{
diagnosisId: number
}>()
const loading = ref(false)
const syncing = ref(false)
const rawRows = shallowRef<any[]>([])
const patientImId = ref('')
const patientName = ref('')
const patientImHint = computed(() => patientImId.value || 'patient_*')
const documentVisible = () => typeof document === 'undefined' || document.visibilityState !== 'hidden'
const controller = createImChatHistory({
load: (diagnosisId) => getImChatMessages({ diagnosis_id: diagnosisId, only_archived: 1 }),
sync: (diagnosisId, syncToken) => triggerImChatSync({ diagnosis_id: diagnosisId, ...(syncToken ? { sync_token: syncToken } : {}) }),
notify: (kind, message) => ElMessage[kind](message),
visible: documentVisible()
})
const history = controller.state
const { loading, syncing, rows: rawRows, patientName } = toRefs(history)
const emptyDescription = computed(() => {
if (history.readError) return '聊天记录读取失败,请重试'
if (history.syncError || history.partialErrors.length) return '暂未读取到聊天记录,请重试同步'
if (syncing.value) return '正在同步聊天记录…'
return history.hasLoaded ? '暂无聊天记录' : '正在加载聊天记录…'
})
interface EnrichedRow {
raw: any
@@ -109,16 +136,21 @@ const MSG_TYPE_LABELS: Record<string, string> = {
}
const rows = computed<EnrichedRow[]>(() =>
rawRows.value.map((row) => {
const fr = parseImFriendly(row)
let tag = ''
if (fr?.tag) {
tag = fr.tag
} else {
tag = MSG_TYPE_LABELS[row.msg_type] || (row.msg_type !== 'other' ? row.msg_type : '')
}
return { raw: row, friendly: fr, tag }
})
rawRows.value.flatMap((row) => {
const messages = row.msg_type === 'composite' && Array.isArray(row.parts)
? row.parts.map((part: any, index: number) => ({ ...row, ...part, msg_id: `${row.msg_id}:${index}` }))
: [row]
return messages.map((row: any) => {
const fr = parseImFriendly(row)
let tag = ''
if (fr?.tag) {
tag = fr.tag
} else {
tag = MSG_TYPE_LABELS[row.msg_type] || (row.msg_type !== 'other' ? row.msg_type : '')
}
return { raw: row, friendly: fr, tag }
})
})
)
function formatTime(ts: number | undefined) {
@@ -145,54 +177,37 @@ function senderLabel(row: any) {
return patientName.value ? `患者(${patientName.value}` : '患者'
}
async function load() {
if (!props.diagnosisId) return
loading.value = true
try {
const res = (await getImChatMessages({
diagnosis_id: props.diagnosisId,
only_archived: 1
})) as {
lists?: any[]
patient_im_id?: string
patient_name?: string
}
rawRows.value = res?.lists || []
patientImId.value = res?.patient_im_id || ''
patientName.value = res?.patient_name || ''
} catch (e) {
console.error(e)
rawRows.value = []
} finally {
loading.value = false
}
}
function reloadArchived() {
load()
}
async function triggerSync() {
if (!props.diagnosisId) return
syncing.value = true
try {
await triggerImChatSync({ diagnosis_id: props.diagnosisId })
ElMessage.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')
} catch (e) {
console.error(e)
ElMessage.error('发起同步失败')
} finally {
syncing.value = false
}
}
function reloadArchived() {
return controller.reload()
}
function triggerSync() {
return controller.sync()
}
watch(
() => props.diagnosisId,
() => {
load()
},
(diagnosisId) => controller.setDiagnosis(diagnosisId),
{ immediate: true }
)
)
let activated = true
function updateVisibility() {
controller.setVisible(activated && documentVisible())
}
onMounted(() => document.addEventListener('visibilitychange', updateVisibility))
onActivated(() => {
activated = true
updateVisibility()
})
onDeactivated(() => {
activated = false
updateVisibility()
})
onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', updateVisibility)
controller.dispose()
})
defineExpose({ refresh: reloadArchived })
</script>
@@ -209,9 +224,15 @@ defineExpose({ refresh: reloadArchived })
font-size: 12px;
}
}
.toolbar {
.toolbar {
display: flex;
align-items: center;
align-items: center;
flex-wrap: wrap;
}
.sync-status {
margin: 0 0 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.chat-wrap {
min-height: 120px;
+93 -45
View File
@@ -259,7 +259,20 @@
</div>
</template>
</el-table-column>
<el-table-column label="确认" width="88" align="center">
<el-table-column label="挂号类型" min-width="120">
<template #default="{ row }">
<template v-if="appointmentRows(row).length">
<div v-for="apt in appointmentRows(row)" :key="apt.id || `${apt.doctor_id}-${apt.time_text}`" class="mb-2">
<div>{{ appointmentTypeDescription(apt.appointment_type) }}</div>
<div v-if="appointmentRows(row).length > 1" class="text-xs text-gray-500">
{{ apt.doctor_name || '—' }} · {{ apt.time_text || '—' }}
</div>
</div>
</template>
<span v-else class="apt-none"></span>
</template>
</el-table-column>
<el-table-column label="确认" width="88" align="center">
<template #default="{ row }">
<span v-if="isDiagnosisConfirmed(row)" class="status-confirmed">已确认</span>
<span v-else class="status-unconfirmed">未确认</span>
@@ -388,7 +401,7 @@
<el-icon><Remove /></el-icon>取消指派
</el-dropdown-item>
<el-dropdown-item v-if="isAppointmentActiveForVideo(row) && hasPermission(['tcm.diagnosis/videoQr'])" command="videoQr"><el-icon><Picture /></el-icon>视频二维码</el-dropdown-item>
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahao']) && isAppointmentActiveForVideo(row)" command="confirmQr"><el-icon><Picture /></el-icon>二维码</el-dropdown-item>
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahao']) && hasActiveAppointment(row)" command="confirmQr"><el-icon><Picture /></el-icon>二维码</el-dropdown-item>
<el-dropdown-item
v-if="canCancelAppointmentFromDropdown(row) && hasPermission(['tcm.diagnosis/guahao'])"
command="cancelApt"
@@ -731,7 +744,8 @@ import { usePaging } from '@/hooks/usePaging'
import { getDictData } from '@/api/app'
import { getWeappConfig } from '@/api/channel/weapp'
import feedback from '@/utils/feedback'
import { hasPermission } from '@/utils/perm'
import { hasPermission } from '@/utils/perm'
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
import { Loading, Warning, List, CircleCheck, Clock, ArrowDown, Picture, User, Document, Delete, CircleClose, Remove } from '@element-plus/icons-vue'
import useUserStore from '@/stores/modules/user'
import { useRoute, useRouter } from 'vue-router'
@@ -1377,10 +1391,11 @@ type VideoCallHint = {
end_time?: number
}
const canWatchCallEntry = (row: any) =>
isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall'])
const canWatchCallEntry = (row: any) =>
isAppointmentActiveForVideo(row) && isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall'])
const watchCallState = (row: any) => (row.video_call_hint?.state as string) || 'none'
const watchCallState = (row: any) =>
isAppointmentActiveForVideo(row) ? (row.video_call_hint?.state as string) || 'none' : 'none'
const watchCallLiveTooltip = (row: any) => {
const h = row.video_call_hint as VideoCallHint | undefined
@@ -1404,7 +1419,11 @@ const watchCallEnterTooltip = (row: any) => {
return watchCallLiveTooltip(row)
}
const onWatchCallEntryClick = (row: any) => {
const onWatchCallEntryClick = (row: any) => {
if (!canWatchCallEntry(row)) {
feedback.msgWarning('仅已预约的视频问诊可进入视频旁观')
return
}
if (watchCallState(row) !== 'live') {
feedback.msgWarning('医生尚未接通或未同步房间号,请稍后再试')
return
@@ -1716,7 +1735,7 @@ const handleRowAction = (cmd: string, row: any) => {
case 'cancelAssign': handleCancelAssign(row); break
case 'videoQr':
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅已预约」状态可生成视频二维码')
feedback.msgWarning('仅已预约的视频问诊可生成视频二维码')
return
}
handleVideoQRCode(row)
@@ -1753,7 +1772,8 @@ const appointmentRows = (row: any) => {
status: row.appointment_status,
doctor_id: row.appointment_doctor_id,
doctor_name: row.appointment_doctor_name,
time_text: row.appointment_time_text
time_text: row.appointment_time_text,
appointment_type: row.appointment_type
}]
}
@@ -1775,9 +1795,35 @@ const appointmentCellClasses = (row: any) => {
}
}
/** 仅已预约(1)可进视频/小程序码 */
const isAppointmentActiveForVideo = (row: any) =>
row.has_appointment && Number(row.appointment_status) === 1
/**
* 找到当前可用的已预约挂号
* 同一诊单当天可能先完成一条挂号随后又新增一条预约此时行级 appointment_* 仍可能指向旧记录
*/
const activeAppointment = (row: any, videoOnly = false) =>
appointmentRows(row).find((apt: any) =>
Number(apt?.status) === 1 && (!videoOnly || canAppointmentVideoCall(apt.appointment_type))
) ?? null
/** 二维码必须使用已预约挂号对应的医生和时间,不能继续沿用行级旧挂号字段。 */
const activeAppointmentRow = (row: any, videoOnly = false) => {
const apt = activeAppointment(row, videoOnly)
if (!apt) return null
return {
...row,
has_appointment: 1,
appointment_id: apt.id,
appointment_status: apt.status,
appointment_doctor_id: apt.doctor_id,
appointment_doctor_name: apt.doctor_name,
appointment_time_text: apt.time_text,
appointment_type: apt.appointment_type
}
}
/** 诊单二维码适用于所有已预约挂号;视频入口仅适用于视频问诊。 */
const hasActiveAppointment = (row: any) => !!activeAppointment(row)
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row, true)
/** 已预约、已过号可取消(后端同步限制),针对行上主字段 */
const canCancelAppointmentRow = (row: any) => {
@@ -1961,18 +2007,19 @@ const submitFillIdCard = async () => {
}
}
// - (1)
const handleVideoQRCode = async (row: any) => {
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
return
}
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
lastQRCodeType.value = 'video'
currentQRCodePatient.value = row
// -
const handleVideoQRCode = async (row: any) => {
const qrcodeRow = activeAppointmentRow(row, true)
if (!qrcodeRow) {
feedback.msgWarning('仅已预约的视频问诊可生成视频二维码')
return
}
if (!qrcodeRow.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
lastQRCodeType.value = 'video'
currentQRCodePatient.value = qrcodeRow
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
@@ -1983,11 +2030,11 @@ const handleVideoQRCode = async (row: any) => {
feedback.msgError('小程序未配置,请先配置小程序信息')
qrcodeDialogVisible.value = false
return
}
const result = await generateMiniProgramQrcode({
doctor_id: row.appointment_doctor_id,
diagnosis_id: row.appointment_doctor_id,
patient_id: row.patient_id,
}
const result = await generateMiniProgramQrcode({
doctor_id: qrcodeRow.appointment_doctor_id,
diagnosis_id: qrcodeRow.appointment_doctor_id,
patient_id: qrcodeRow.patient_id,
share_user_id: userStore.userInfo?.id || '',
mini_program_path: 'pages/login/login'
})
@@ -2004,17 +2051,18 @@ const handleVideoQRCode = async (row: any) => {
}
//
const handleMiniProgramQRCode = async (row: any) => {
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可使用诊单二维码')
return
}
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
lastQRCodeType.value = 'confirm'
currentQRCodePatient.value = row
const handleMiniProgramQRCode = async (row: any) => {
const qrcodeRow = activeAppointmentRow(row)
if (!qrcodeRow) {
feedback.msgWarning('仅「已预约」状态可使用诊单二维码')
return
}
if (!qrcodeRow.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
lastQRCodeType.value = 'confirm'
currentQRCodePatient.value = qrcodeRow
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
@@ -2033,11 +2081,11 @@ const handleMiniProgramQRCode = async (row: any) => {
//
const currentUser = userStore.userInfo
//
const result = await generateMiniProgramQrcode({
diagnosis_id: row.id,
doctor_id: row.appointment_doctor_id,
patient_id: row.patient_id,
//
const result = await generateMiniProgramQrcode({
diagnosis_id: qrcodeRow.id,
doctor_id: qrcodeRow.appointment_doctor_id,
patient_id: qrcodeRow.patient_id,
share_user_id: currentUser?.id || ''
})
+83 -41
View File
@@ -155,13 +155,15 @@
>
<span class="dh5-apt-status">{{ appointmentStatusLabelByStatus(apt.status) }}</span>
<span class="dh5-apt-doctor">{{ apt.doctor_name || '-' }}</span>
<span class="dh5-apt-time">{{ apt.time_text || '-' }}</span>
<span class="dh5-apt-time">{{ apt.time_text || '-' }}</span>
<span class="dh5-apt-type">挂号类型{{ appointmentTypeDescription(apt.appointment_type) }}</span>
</div>
</template>
<template v-else>
<span class="dh5-apt-status">{{ appointmentStatusLabel(row) }}</span>
<span class="dh5-apt-doctor">{{ row.appointment_doctor_name || '-' }}</span>
<span class="dh5-apt-time">{{ row.appointment_time_text || '-' }}</span>
<span class="dh5-apt-time">{{ row.appointment_time_text || '-' }}</span>
<span class="dh5-apt-type">挂号类型{{ appointmentTypeDescription(row.appointment_type) }}</span>
</template>
</template>
<span v-else class="dh5-apt-none">未挂号</span>
@@ -299,7 +301,7 @@
<span>视频二维码</span>
</button>
<button
v-if="isAppointmentActiveForVideo(moreSheetRow) && hasPermission(['tcm.diagnosis/guahao'])"
v-if="hasActiveAppointment(moreSheetRow) && hasPermission(['tcm.diagnosis/guahao'])"
class="dh5-action"
@click="runAction('confirmQr')"
>
@@ -584,7 +586,8 @@
</div>
</template>
<script setup lang="ts" name="tcmDiagnosisH5">
<script setup lang="ts" name="tcmDiagnosisH5">
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
import {
tcmDiagnosisLists,
tcmDiagnosisDelete,
@@ -924,7 +927,8 @@ const appointmentRows = (row: any) => {
status: row.appointment_status,
doctor_id: row.appointment_doctor_id,
doctor_name: row.appointment_doctor_name,
time_text: row.appointment_time_text
time_text: row.appointment_time_text,
appointment_type: row.appointment_type
}]
}
@@ -946,8 +950,32 @@ const appointmentRowClass = (row: any) => {
}
}
const isAppointmentActiveForVideo = (row: any) =>
row.has_appointment && Number(row.appointment_status) === 1
/**
* 同一诊单可能同时有已完成的旧挂号和已预约的新挂号二维码应使用明细里的有效预约
*/
const activeAppointment = (row: any, videoOnly = false) =>
appointmentRows(row).find((apt: any) =>
Number(apt?.status) === 1 && (!videoOnly || canAppointmentVideoCall(apt.appointment_type))
) ?? null
const activeAppointmentRow = (row: any, videoOnly = false) => {
const apt = activeAppointment(row, videoOnly)
if (!apt) return null
return {
...row,
has_appointment: 1,
appointment_id: apt.id,
appointment_status: apt.status,
appointment_doctor_id: apt.doctor_id,
appointment_doctor_name: apt.doctor_name,
appointment_time_text: apt.time_text,
appointment_type: apt.appointment_type
}
}
const hasActiveAppointment = (row: any) => !!activeAppointment(row)
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row, true)
const canCancelAppointmentRow = (row: any) => {
const s = Number(row.appointment_status)
@@ -961,9 +989,10 @@ const isAssignedAssistant = (row: any) => {
if (aid === null || aid === undefined || aid === '') return false
return Number(aid) === Number(userStore.userInfo?.id)
}
const canWatchCallEntry = (row: any) =>
isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall'])
const watchCallState = (row: any) => (row.video_call_hint?.state as string) || 'none'
const canWatchCallEntry = (row: any) =>
isAppointmentActiveForVideo(row) && isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall'])
const watchCallState = (row: any) =>
isAppointmentActiveForVideo(row) ? (row.video_call_hint?.state as string) || 'none' : 'none'
const watchCallShowEnterButton = (row: any) => {
const st = watchCallState(row)
return st === 'live' || st === 'pending_room'
@@ -995,7 +1024,11 @@ const watchCallVisibleForRow = (row: any) => {
}
const watchCallDialogVisible = ref(false)
const watchCallDiagnosisId = ref(0)
const onWatchCallEntryClick = (row: any) => {
const onWatchCallEntryClick = (row: any) => {
if (!canWatchCallEntry(row)) {
feedback.msgWarning('仅已预约的视频问诊可进入视频旁观')
return
}
if (watchCallState(row) !== 'live') {
feedback.msgWarning('医生未接通或未同步房间号,请稍后再试')
return
@@ -1067,7 +1100,7 @@ const runAction = (cmd: string) => {
case 'assign': handleSingleAssign(row); break
case 'videoQr':
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅已预约」状态可生成视频二维码')
feedback.msgWarning('仅已预约的视频问诊可生成视频二维码')
return
}
handleVideoQRCode(row); break
@@ -1241,13 +1274,14 @@ const qrcodeAppointmentTimeText = computed(() => {
return p.appointment_time_text || '—'
})
const handleVideoQRCode = async (row: any) => {
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码'); return
}
if (!row.patient_id) { feedback.msgWarning('患者信息不完整'); return }
lastQRCodeType.value = 'video'
currentQRCodePatient.value = row
const handleVideoQRCode = async (row: any) => {
const qrcodeRow = activeAppointmentRow(row, true)
if (!qrcodeRow) {
feedback.msgWarning('仅已预约的视频问诊可生成视频二维码'); return
}
if (!qrcodeRow.patient_id) { feedback.msgWarning('患者信息不完整'); return }
lastQRCodeType.value = 'video'
currentQRCodePatient.value = qrcodeRow
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
@@ -1258,11 +1292,11 @@ const handleVideoQRCode = async (row: any) => {
feedback.msgError('小程序未配置')
qrcodeDialogVisible.value = false
return
}
const result = await generateMiniProgramQrcode({
doctor_id: row.appointment_doctor_id,
diagnosis_id: row.appointment_doctor_id,
patient_id: row.patient_id,
}
const result = await generateMiniProgramQrcode({
doctor_id: qrcodeRow.appointment_doctor_id,
diagnosis_id: qrcodeRow.appointment_doctor_id,
patient_id: qrcodeRow.patient_id,
share_user_id: userStore.userInfo?.id || '',
mini_program_path: 'pages/login/login'
})
@@ -1275,13 +1309,14 @@ const handleVideoQRCode = async (row: any) => {
}
}
const handleMiniProgramQRCode = async (row: any) => {
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可使用诊单二维码'); return
}
if (!row.patient_id) { feedback.msgWarning('患者信息不完整'); return }
lastQRCodeType.value = 'confirm'
currentQRCodePatient.value = row
const handleMiniProgramQRCode = async (row: any) => {
const qrcodeRow = activeAppointmentRow(row)
if (!qrcodeRow) {
feedback.msgWarning('仅「已预约」状态可使用诊单二维码'); return
}
if (!qrcodeRow.patient_id) { feedback.msgWarning('患者信息不完整'); return }
lastQRCodeType.value = 'confirm'
currentQRCodePatient.value = qrcodeRow
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
@@ -1292,11 +1327,11 @@ const handleMiniProgramQRCode = async (row: any) => {
feedback.msgError('小程序未配置')
qrcodeDialogVisible.value = false
return
}
const result = await generateMiniProgramQrcode({
diagnosis_id: row.id,
doctor_id: row.appointment_doctor_id,
patient_id: row.patient_id,
}
const result = await generateMiniProgramQrcode({
diagnosis_id: qrcodeRow.id,
doctor_id: qrcodeRow.appointment_doctor_id,
patient_id: qrcodeRow.patient_id,
share_user_id: userStore.userInfo?.id || ''
})
if (result?.qrcode_url) qrcodeUrl.value = result.qrcode_url
@@ -1783,8 +1818,9 @@ $dh5-card-bg: #ffffff;
}
}
.dh5-apt-item {
display: flex;
.dh5-apt-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 4px 8px;
@@ -1808,11 +1844,17 @@ $dh5-card-bg: #ffffff;
font-weight: 500;
}
.dh5-apt-time {
.dh5-apt-time {
color: $dh5-text-mute;
font-size: 12px;
margin-left: auto;
}
margin-left: auto;
}
.dh5-apt-type {
flex-basis: 100%;
color: $dh5-text-mute;
font-size: 12px;
}
.dh5-apt-none {
color: $dh5-warn;
+146
View File
@@ -0,0 +1,146 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const ts = require('typescript')
const vue = require('vue')
const { renderToString } = require('@vue/server-renderer')
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
const src = path.join(__dirname, '../src')
function moduleFrom(source, mockRequire = require, browserWindow = {}) {
const code = ts.transpileModule(source.replaceAll('import.meta.env.DEV', 'false'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', 'window', code)(mockRequire, module, module.exports, browserWindow)
return module.exports
}
const read = (file) => fs.readFileSync(path.join(src, file), 'utf8')
const types = moduleFrom(read('utils/appointment-type.ts'))
const { descriptor } = parse(read('components/chat-dialog/index.vue'))
const script = compileScript(descriptor, { id: 'appointment-call-mode' })
const template = compileTemplate({ source: descriptor.template.content, filename: 'chat-dialog.vue', id: 'appointment-call-mode', compilerOptions: { bindingMetadata: script.bindings } })
assert.deepEqual(template.errors, [])
const render = moduleFrom(template.code).render
const passthrough = { setup: (_, { slots }) => () => vue.h('div', [slots.default?.(), slots.headerToolbar?.()]) }
const picker = text => ({ setup: () => () => vue.h('button', text) })
function chat(policy = {}) {
const requests = [], warnings = [], sdkCalls = []
const guard = moduleFrom(read('utils/appointment-call-guard.ts'))
const server = Object.fromEntries(['calls', 'call', 'groupCall'].map(name => [name, async params => { sdkCalls.push({ name, params }) }]))
const api = { getCallSignature: async params => { requests.push(params); return policy } }
const result = moduleFrom(script.content, name => {
if (name === 'vue') return { ...vue, onMounted() {}, onUnmounted() {} }
if (name === '@/utils/appointment-type') return types
if (name === '@/utils/appointment-call-guard') return guard
if (name === '@/utils/im-chat-archive-trigger') return moduleFrom(read('utils/im-chat-archive-trigger.ts'))
if (name === '@/api/tcm') return api
if (name === '@/utils/feedback') return { default: { msgWarning: text => warnings.push(text) } }
if (name === '@/stores/modules/user') return { default: () => ({ userInfo: {} }) }
if (name === '@/utils/call-local-recorder') return { CallLocalRecorder: class {} }
if (name === '@tencentcloud/chat-uikit-vue3') return {
useLoginState: () => ({}), useConversationListState: () => ({ activeConversation: vue.ref(null) }),
Chat: passthrough, UIKitProvider: passthrough, MessageInput: passthrough, MessageList: passthrough,
EmojiPicker: picker('表情'), ImagePicker: picker('图片'), FilePicker: picker('文件'),
AudioCallPicker: picker('语音拨打'), VideoCallPicker: picker('视频拨打')
}
if (name === '@tencentcloud/call-uikit-vue') return { TUICallKitServer: server, TUICallType: { VIDEO_CALL: 2 }, TUIStore: {}, StoreName: {}, NAME: {} }
return {}
}).default.setup({}, { expose() {} })
result.visible.value = true
result.isReady.value = true
result.isCallReady.value = true
result.patientId.value = 50
result.diagnosisId.value = 70
result.appointmentId.value = 90
result.syncAppointmentCallPolicy(policy)
guard.registerAppointmentCallGuard(result.prepareAppointmentCall)
return { state: result, guard, server, requests, warnings, sdkCalls, api }
}
const policy = type => ({ appointment_id: 90, appointment_type: type, appointment_type_desc: types.appointmentTypeDescription(type), can_video_call: type === 'video', can_audio_call: type === 'video' || type === 'phone', call_disabled_reason: type === 'text' ? '图文问诊不支持音视频通话' : '当前挂号不支持该通话方式' })
test('type labels and video capability preserve legacy video while rejecting text and unknown', () => {
for (const value of ['text', 'phone', 'unknown']) assert.equal(types.canAppointmentVideoCall(value), false)
for (const value of ['video', '', ' ', null, undefined]) assert.equal(types.canAppointmentVideoCall(value), true)
assert.equal(types.appointmentTypeDescription('text'), '图文问诊')
})
test('rendered real chat template keeps text tools while excluding call controls in text mode', async () => {
async function markup(type) {
const { state } = chat(policy(type))
const app = vue.createSSRApp({ render: () => render({}, [], {}, vue.proxyRefs(state)) })
app.config.warnHandler = () => {}
for (const name of ['el-tag', 'el-button', 'el-tooltip', 'el-icon', 'el-alert']) app.component(name, passthrough)
const context = {}
await renderToString(app, context)
return context.teleports.body
}
const text = await markup('text')
assert.match(text, /图文问诊/)
for (const tool of ['表情', '图片', '文件']) assert.match(text, new RegExp(tool))
assert.doesNotMatch(text, /视频拨打|语音拨打|群视频/)
const video = await markup('video')
assert.match(video, /视频问诊/)
assert.match(video, /视频拨打/)
assert.match(video, /群视频/)
})
test('text mode hides all live call entries and refuses direct group invocation', async () => {
const { state, requests, sdkCalls } = chat(policy('text'))
assert.equal(state.appointmentTypeLabel.value, '图文问诊')
assert.equal(state.canVideoCall.value, false)
assert.equal(state.canAudioCall.value, false)
await state.startGroupVideoCall()
await assert.rejects(state.prepareAppointmentCall(2), /图文问诊/)
assert.deepEqual(requests, [])
assert.deepEqual(sdkCalls, [])
assert.match(descriptor.template.content, /<VideoCallPicker v-if="isCallReady && canVideoCall"/)
assert.match(descriptor.template.content, /<AudioCallPicker v-if="isCallReady && canAudioCall"/)
})
test('all SDK outgoing paths stop before touching SDK when current appointment is text', async () => {
const { state, server, sdkCalls } = chat(policy('text'))
state.installTUICallKitRoomHooks()
for (const method of ['call', 'calls', 'groupCall']) await assert.rejects(server[method]({ type: 2 }), /图文问诊/)
assert.deepEqual(sdkCalls, [])
})
test('video preflight uses actual appointment and prevents a server-side change to text', async () => {
const { state, requests, api } = chat(policy('video'))
assert.equal(state.canVideoCall.value, true)
await state.prepareAppointmentCall(2)
assert.deepEqual(requests, [{ patient_id: 50, diagnosis_id: 70, appointment_id: 90 }])
api.getCallSignature = async () => policy('text')
await assert.rejects(state.prepareAppointmentCall(2), /图文问诊/)
assert.equal(state.canVideoCall.value, false)
assert.equal(state.appointmentTypeLabel.value, '图文问诊')
})
test('historical phone is audio-only; missing server confirmation never enables video', async () => {
const { state } = chat(policy('phone'))
assert.equal(state.canAudioCall.value, true)
assert.equal(state.canVideoCall.value, false)
await state.prepareAppointmentCall(1)
await assert.rejects(state.prepareAppointmentCall(2))
state.syncAppointmentCallPolicy({ appointment_type: 'video' })
assert.equal(state.canVideoCall.value, false)
state.syncAppointmentCallPolicy({ appointment_type: null, appointment_type_desc: '未挂号', can_video_call: false, can_audio_call: false })
assert.equal(state.appointmentTypeLabel.value, '未挂号')
assert.equal(state.canVideoCall.value, false)
})
test('singleton call guard switches to current chat and cancels a pending old context', async () => {
const guard = moduleFrom(read('utils/appointment-call-guard.ts'))
let resolveOld
const releaseOld = guard.registerAppointmentCallGuard(() => new Promise(resolve => { resolveOld = resolve }))
const pending = guard.checkAppointmentOutgoingCall({ type: 2 })
const releaseNew = guard.registerAppointmentCallGuard(async () => { throw new Error('图文问诊') })
releaseOld()
await assert.rejects(guard.checkAppointmentOutgoingCall({ type: 2 }), /图文问诊/)
resolveOld()
await assert.rejects(pending, /问诊已切换/)
releaseNew()
await assert.rejects(guard.checkAppointmentOutgoingCall({ type: 2 }), /先打开/)
})
@@ -0,0 +1,125 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const { execFileSync } = require('node:child_process')
const { DatabaseSync } = require('node:sqlite')
const ts = require('typescript')
const vue = require('vue')
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
const root = path.resolve(__dirname, '../..')
const sql = JSON.parse(execFileSync(process.env.PHP_BINARY || 'php', [path.join(root, 'server/tests/AppointmentTypeFilterSqlFixture.php')], { encoding: 'utf8' }))
const db = new DatabaseSync(':memory:')
db.exec(`
CREATE TABLE doctor_appointment (id INTEGER, patient_id INTEGER, doctor_id INTEGER, assistant_id INTEGER, appointment_type TEXT, status INTEGER, appointment_date TEXT, appointment_time TEXT);
CREATE TABLE tcm_diagnosis (id INTEGER, patient_id INTEGER, patient_name TEXT, phone TEXT, gender INTEGER, age INTEGER, weight INTEGER, height INTEGER, assistant_id INTEGER, delete_time INTEGER);
CREATE TABLE admin (id INTEGER, name TEXT);
INSERT INTO tcm_diagnosis VALUES (100, 1000, '患者甲', '', 1, 30, 60, 170, 301, NULL), (101, 1001, '患者乙', '', 1, 31, 61, 171, 302, NULL), (102, 1002, '已删除', '', 1, 30, 60, 170, 301, 1);
INSERT INTO admin VALUES (201, '医生甲'), (202, '医生乙'), (301, '医助甲'), (302, '医助乙');
`)
const add = db.prepare('INSERT INTO doctor_appointment VALUES (?, ?, ?, 301, ?, ?, ?, ?)')
const fixtures = [
[1, 100, 201, 'video', 1],
[2, 100, 201, null, 1],
[3, 100, 201, '', 2],
[4, 101, 202, ' ', 3],
[5, 101, 202, '\t\r\n\v ', 4],
[6, 100, 201, 'text', 1],
[7, 101, 202, 'text', 3],
[8, 100, 201, 'phone', 1],
[9, 100, 201, 'unknown', 1],
[10, 100, 201, 'Video', 1],
[11, 100, 201, 'video ', 1],
[12, 102, 201, null, 1],
[13, 102, 201, 'text', 1]
]
for (const [id, patient, doctor, type, status] of fixtures) add.run(id, patient, doctor, type, status, '2026-09-09', `${String(id).padStart(2, '0')}:00:00`)
// SQLite's explicit BINARY collation has the same exact text comparison semantics used here.
// The remaining statements are the SQL emitted by the real ThinkPHP list/count/tab paths.
const execute = statement => db.prepare(statement.replace(/BINARY a\.appointment_type/g, 'a.appointment_type COLLATE BINARY')).all()
const result = name => ({
ids: execute(sql[name].lists).map(row => row.id),
count: execute(sql[name].count)[0].think_count,
tabs: Object.fromEntries(execute(sql[name].tabs).map(row => [row.status, row.cnt]))
})
test('video query includes legacy blanks and keeps list, total and status counts aligned', () => {
assert.deepEqual(result('video'), { ids: [1, 2, 3, 4, 5], count: 5, tabs: { 1: 2, 2: 1, 3: 1, 4: 1 } })
})
test('text query excludes video, unknown and historical phone records', () => {
assert.deepEqual(result('text'), { ids: [6, 7], count: 2, tabs: { 1: 1, 3: 1 } })
})
test('all and omitted filters retain historical types without admitting deleted diagnoses', () => {
for (const name of ['omitted', 'all', 'null']) {
const actual = result(name)
assert.equal(actual.count, 11)
assert.deepEqual(actual.ids.toSorted((a, b) => a - b), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
assert.equal(Object.values(actual.tabs).reduce((sum, count) => sum + count, 0), 11)
}
})
test('type filter composes with status, doctor and patient filters without OR leakage', () => {
assert.deepEqual(result('video_status_1'), { ids: [1, 2], count: 2, tabs: { 1: 2, 2: 1, 3: 1, 4: 1 } })
assert.deepEqual(result('text_doctor'), { ids: [6], count: 1, tabs: { 1: 1 } })
assert.deepEqual(result('video_patient'), { ids: [4, 5], count: 2, tabs: { 3: 1, 4: 1 } })
})
test('pagination limits only visible rows while count and tabs retain the full filtered set', () => {
assert.deepEqual(result('video_page'), { ids: [2, 3], count: 5, tabs: { 1: 2, 2: 1, 3: 1, 4: 1 } })
})
test('unsupported filter values return no rows or counts instead of being converted to video', () => {
for (const name of Object.keys(sql).filter(name => name.startsWith('invalid_'))) {
assert.deepEqual(result(name), { ids: [], count: 0, tabs: {} }, name)
}
})
function moduleFrom(source, dependencies = require, globals = {}) {
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', ...Object.keys(globals), compiled)(dependencies, module, module.exports, ...Object.values(globals))
return module.exports
}
const pageFile = path.join(root, 'admin/src/views/consumer/prescription/guahao.vue')
const { descriptor, errors } = parse(fs.readFileSync(pageFile, 'utf8'), { filename: pageFile })
assert.deepEqual(errors, [])
const script = compileScript(descriptor, { id: 'appointment-type-filter' })
test('actual page and paging hook send type on query and clear it together with all fields on reset', async () => {
const calls = []
const paging = moduleFrom(fs.readFileSync(path.join(root, 'admin/src/hooks/usePaging.ts'), 'utf8'))
const component = moduleFrom(script.content, name => {
if (name === 'vue') return vue
if (name === '@/hooks/usePaging') return paging
if (name === '@/api/doctor') return { appointmentLists: async params => { calls.push({ ...params }); return { lists: [], count: 0 } } }
if (name === '@/utils/appointment-type') return { appointmentTypeDescription: value => value }
return {}
}, { reactive: vue.reactive, ref: vue.ref, computed: vue.computed, onMounted() {}, onActivated() {} })
const page = component.default.setup({}, { expose() {} })
assert.equal(calls[0].appointment_type, '')
page.pager.page = 4
page.queryParams.appointment_type = 'text'
page.queryParams.patient_name = '张'
page.resetPage()
assert.equal(calls.at(-1).page_no, 1)
assert.equal(calls.at(-1).appointment_type, 'text')
assert.equal(calls.at(-1).patient_name, '张')
page.pager.page = 3
page.queryParams.appointment_type = 'video'
page.resetPage()
assert.equal(calls.at(-1).appointment_type, 'video')
page.resetFilter()
assert.equal(calls.at(-1).appointment_type, '')
assert.equal(calls.at(-1).patient_name, '')
assert.equal(calls.at(-1).page_no, 1)
await Promise.resolve()
})
test('updated filter template compiles with existing page bindings', () => {
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: pageFile, id: 'appointment-type-filter', compilerOptions: { bindingMetadata: script.bindings } }).errors, [])
})
@@ -0,0 +1,89 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const ts = require('typescript')
const source = fs.readFileSync(path.join(__dirname, '../src/utils/im-chat-archive-trigger.ts'), 'utf8')
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
const mod = { exports: {} }
new Function('module', 'exports', compiled)(mod, mod.exports)
const { createImChatArchiveTrigger } = mod.exports
const deferred = () => {
let resolve, reject
const promise = new Promise((a, b) => { resolve = a; reject = b })
return { promise, resolve, reject }
}
test('all pages use server-owned current-doctor scope and carry the continuation token', async () => {
const calls = []
const sync = createImChatArchiveTrigger(async params => {
calls.push(params)
return { completed: calls.length === 3, sync_token: 'next-page' }
})
await sync(12116)
assert.deepEqual(calls, [
{ diagnosis_id: 12116, scope: 'current', sync_token: undefined },
{ diagnosis_id: 12116, scope: 'current', sync_token: 'next-page' },
{ diagnosis_id: 12116, scope: 'current', sync_token: 'next-page' }
])
})
test('many events while syncing coalesce, then perform one catch-up scan for new messages', async () => {
const page = deferred()
let calls = 0
const sync = createImChatArchiveTrigger(() => {
calls++
return calls === 1 ? page.promise : Promise.resolve({ completed: true })
})
const first = sync(10)
assert.equal(sync(10), first)
assert.equal(sync(10), first)
assert.equal(calls, 1)
page.resolve({ completed: true })
await first
assert.equal(calls, 2)
await sync(10)
assert.equal(calls, 3)
})
test('switching patients keeps in-flight requests bound to their original diagnosis', async () => {
const old = deferred(), calls = []
const sync = createImChatArchiveTrigger(params => {
calls.push(params)
if (params.diagnosis_id === 10 && !params.sync_token) return old.promise
return Promise.resolve({ completed: true })
})
const first = sync(10)
await sync(20)
old.resolve({ completed: false, sync_token: 'old-patient' })
await first
assert.deepEqual(calls.map(p => [p.diagnosis_id, p.sync_token]), [[10, undefined], [20, undefined], [10, 'old-patient']])
})
test('partial failure and network failure are reported and do not poison later retries', async () => {
const warnings = []
let count = 0
const sync = createImChatArchiveTrigger(async () => {
count++
if (count === 1) return { completed: true, errors: ['cloud unavailable'] }
if (count === 2) throw new Error('request timeout')
return { completed: true }
}, error => warnings.push(error.message))
await sync(10)
await sync(10)
await sync(10)
assert.deepEqual(warnings, ['cloud unavailable', 'request timeout'])
assert.equal(count, 3)
})
test('invalid IDs never request and missing progress stops a malformed response loop', async () => {
const warnings = []
let count = 0
const sync = createImChatArchiveTrigger(async () => { count++; return { completed: false } }, error => warnings.push(error.message))
for (const id of [0, -1, NaN, 1.5]) await sync(id)
assert.equal(count, 0)
await sync(10)
assert.equal(count, 1)
assert.match(warnings[0], /未返回进度/)
})
+392
View File
@@ -0,0 +1,392 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const ts = require('typescript')
const { parse, compileScript, compileTemplate, compileStyle } = require('@vue/compiler-sfc')
function loadTs(filename, dependencies = require) {
const source = fs.readFileSync(filename, 'utf8')
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', compiled)(dependencies, module, module.exports)
return module.exports
}
const { createImChatHistory } = loadTs(path.join(__dirname, '../src/utils/im-chat-history.ts'))
const archive = (id, text = '已归档消息') => ({ lists: [{ msg_id: id, text }], patient_im_id: `patient_${id}`, patient_name: `患者${id}` })
const progress = (completed, extra = {}) => ({ completed, sync_token: 'session-token', inserted: 2, processed_peers: 1, total_peers: 2, ...extra })
const deferred = () => {
let resolve, reject
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
const settle = async () => { for (let i = 0; i < 50; i++) await Promise.resolve() }
function fixture(overrides = {}) {
let time = 100000, nextTimer = 0
const timers = new Map(), calls = [], notices = []
const controller = createImChatHistory({
load: async (id) => { calls.push(['load', id]); return overrides.load ? overrides.load(id) : archive(id) },
sync: async (id, token) => { calls.push(['sync', id, token]); return overrides.sync ? overrides.sync(id, token) : progress(true) },
notify: (kind, message) => notices.push({ kind, message }),
now: () => time,
setTimer: (callback, delay) => { const id = ++nextTimer; timers.set(id, { callback, at: time + delay }); return id },
clearTimer: (id) => timers.delete(id),
visible: overrides.visible
})
return {
controller, state: controller.state, calls, notices, timers,
setTime(value) { time = value },
advance(ms) {
time += ms
for (const [id, timer] of [...timers]) {
if (timer.at <= time) { timers.delete(id); timer.callback() }
}
}
}
}
test('archive appears immediately while automatic cloud sync is pending, then refreshes on completion', async () => {
const pending = deferred()
let reads = 0
const f = fixture({ load: async () => archive(++reads), sync: () => pending.promise })
f.controller.setDiagnosis(10)
await settle()
assert.equal(f.state.rows[0].msg_id, 1)
assert.equal(f.state.syncing, true)
assert.equal(f.state.loading, false)
assert.equal(f.timers.size, 0)
pending.resolve(progress(true))
await settle()
assert.equal(f.state.rows[0].msg_id, 2)
assert.equal(f.state.syncing, false)
assert.equal(f.timers.size, 1)
assert.deepEqual(f.notices, [])
f.controller.dispose()
})
test('account verification progress and skipped staff are displayed separately from sync failures', async () => {
const checked = deferred(), finished = deferred()
let step = 0
const f = fixture({ sync: async () => {
step++
if (step === 1) return progress(false, { phase: 'checking_accounts', checked_accounts: 100, candidate_accounts: 185, skipped_accounts: 98 })
if (step === 2) return checked.promise
return finished.promise
} })
f.controller.setDiagnosis(1)
await settle()
assert.equal(f.state.phase, 'checking_accounts')
assert.equal(f.state.checkedAccounts, 100)
assert.equal(f.state.candidateAccounts, 185)
assert.deepEqual(f.state.partialErrors, [])
checked.resolve(progress(false, { phase: 'syncing', checked_accounts: 185, candidate_accounts: 185, skipped_accounts: 183, total_peers: 1 }))
await settle()
assert.equal(f.state.totalPeers, 1)
assert.equal(f.state.skippedAccounts, 183)
finished.resolve(progress(true, { phase: 'completed', skipped_accounts: 183, total_peers: 1 }))
await settle()
assert.equal(f.state.syncError, '')
assert.deepEqual(f.state.partialErrors, [])
f.controller.setDiagnosis(2)
assert.equal(f.state.skippedAccounts, 0)
f.controller.dispose()
})
test('completion waits for initial archive read and starts a fresh read after it', async () => {
const initial = deferred()
let reads = 0
const f = fixture({ load: () => ++reads === 1 ? initial.promise : Promise.resolve(archive(2)) })
f.controller.setDiagnosis(10)
await settle()
assert.equal(reads, 1)
initial.resolve(archive(1))
await settle()
assert.equal(reads, 2)
assert.equal(f.state.rows[0].msg_id, 2)
assert.equal(f.timers.size, 1)
f.controller.dispose()
})
test('sync tokens continue across pages and archive refreshes every three pages and on completion', async () => {
const lastPage = deferred()
let pages = 0, reads = 0
const f = fixture({
load: async () => archive(++reads),
sync: async () => ++pages < 4 ? progress(false, { sync_token: `token-${pages}`, inserted: pages }) : lastPage.promise
})
f.controller.setDiagnosis(10)
await settle()
assert.equal(pages, 4)
assert.equal(reads, 2)
assert.deepEqual(f.calls.filter(call => call[0] === 'sync').map(call => call[2]), [undefined, 'token-1', 'token-2', 'token-3'])
assert.equal(f.state.inserted, 3)
lastPage.resolve(progress(true, { inserted: 4, processed_peers: 2 }))
await settle()
assert.equal(reads, 3)
assert.equal(f.state.inserted, 4)
f.controller.dispose()
})
test('a slow page refreshes archive after two seconds even before three pages', async () => {
const lastPage = deferred()
let pages = 0, reads = 0
const f = fixture({
load: async () => archive(++reads),
sync: async () => {
if (++pages === 1) { f.setTime(102100); return progress(false) }
return lastPage.promise
}
})
f.controller.setDiagnosis(10)
await settle()
assert.equal(reads, 2)
assert.equal(f.state.syncing, true)
f.controller.dispose()
lastPage.resolve(progress(true))
await settle()
})
test('switching diagnosis ignores old archive, old sync errors, and old completion timers', async () => {
const oldRead = deferred(), oldSync = deferred()
const f = fixture({ load: (id) => id === 1 ? oldRead.promise : Promise.resolve(archive(id)), sync: (id) => id === 1 ? oldSync.promise : Promise.resolve(progress(true)) })
f.controller.setDiagnosis(1)
await settle()
f.controller.setDiagnosis(2)
await settle()
oldRead.resolve(archive(1))
oldSync.reject(new Error('旧患者同步失败'))
await settle()
assert.equal(f.state.patientName, '患者2')
assert.equal(f.state.rows[0].msg_id, 2)
assert.equal(f.state.syncError, '')
assert.equal(f.timers.size, 1)
assert.equal(f.calls.filter(call => call[0] === 'load' && call[1] === 1).length, 1)
f.controller.dispose()
})
test('dispose invalidates in-flight requests and never refreshes or schedules after completion', async () => {
const pending = deferred()
const f = fixture({ sync: () => pending.promise })
f.controller.setDiagnosis(1)
await settle()
const before = f.calls.length
f.controller.dispose()
pending.resolve(progress(true))
await settle()
assert.equal(f.calls.length, before)
assert.equal(f.timers.size, 0)
assert.equal(f.state.syncing, false)
f.controller.setVisible(true)
f.controller.setDiagnosis(2)
assert.equal(f.calls.length, before)
})
test('read and sync failures preserve existing rows and expose the original errors', async () => {
let readFails = false, syncFails = false
const f = fixture({
load: async () => { if (readFails) throw { response: { data: { msg: '归档服务读取失败' } } }; return archive(1) },
sync: async () => { if (syncFails) throw new Error('腾讯云凭证无效'); return progress(true) }
})
f.controller.setDiagnosis(1)
await settle()
readFails = true
await f.controller.reload()
assert.equal(f.state.rows[0].msg_id, 1)
assert.equal(f.state.readError, '归档服务读取失败')
syncFails = true
await f.controller.sync()
assert.equal(f.state.rows[0].msg_id, 1)
assert.equal(f.state.syncError, '腾讯云凭证无效')
assert.deepEqual(f.notices, [{ kind: 'error', message: '腾讯云凭证无效' }])
assert.equal(f.timers.size, 1)
f.controller.dispose()
})
test('only a manual completed sync emits success; concurrent clicks share the in-flight run', async () => {
let pending
const f = fixture({ sync: () => pending ? pending.promise : Promise.resolve(progress(true)) })
f.controller.setDiagnosis(1)
await settle()
assert.deepEqual(f.notices, [])
pending = deferred()
const manual = f.controller.sync()
const duplicate = f.controller.sync()
await settle()
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 2)
assert.deepEqual(f.notices, [])
pending.resolve(progress(true, { inserted: 7 }))
await Promise.all([manual, duplicate])
assert.deepEqual(f.notices, [{ kind: 'success', message: '聊天记录已更新,本次新增 7 条' }])
assert.equal(f.timers.size, 1)
f.controller.dispose()
})
test('completed partial failure refreshes rows, ends the loop, and never reports full success', async () => {
let partial = false, reads = 0
const f = fixture({ load: async () => archive(++reads), sync: async () => progress(true, partial ? { error: '医生账号失败', errors: ['医生账号失败', '医助账号失败'] } : {}) })
f.controller.setDiagnosis(1)
await settle()
partial = true
await f.controller.sync()
assert.deepEqual([...f.state.partialErrors], ['医生账号失败', '医助账号失败'])
assert.equal(f.state.rows[0].msg_id, 3)
assert.equal(f.state.syncing, false)
assert.equal(f.notices.length, 1)
assert.equal(f.notices[0].kind, 'warning')
assert.match(f.notices[0].message, /医生账号失败;医助账号失败/)
f.controller.dispose()
})
test('missing token and missing completed state stop safely without claiming success', async () => {
for (const response of [{ completed: false }, { inserted: 0 }]) {
const f = fixture({ sync: async () => response })
f.controller.setDiagnosis(1)
await settle()
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 1)
assert.match(f.state.syncError, /同步接口未返回/)
assert.equal(f.state.rows[0].msg_id, 1)
assert.equal(f.notices.length, 0)
f.controller.dispose()
}
})
test('one 30-second timer starts only after completion and never overlaps a pending run', async () => {
let pending
const f = fixture({ sync: () => pending ? pending.promise : Promise.resolve(progress(true)) })
f.controller.setDiagnosis(1)
await settle()
f.advance(29999)
await settle()
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 1)
pending = deferred()
f.advance(1)
await settle()
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 2)
assert.equal(f.timers.size, 0)
f.advance(90000)
await settle()
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 2)
pending.resolve(progress(true))
await settle()
assert.equal(f.timers.size, 1)
f.controller.dispose()
assert.equal(f.timers.size, 0)
})
test('hidden or deactivated panels stop scheduling and resume with existing rows retained', async () => {
const f = fixture({ visible: false })
f.controller.setDiagnosis(1)
await settle()
assert.deepEqual(f.calls, [])
f.controller.setVisible(true)
await settle()
assert.equal(f.state.rows[0].msg_id, 1)
f.controller.setVisible(false)
assert.equal(f.timers.size, 0)
const before = f.calls.length
f.advance(60000)
await settle()
assert.equal(f.calls.length, before)
f.controller.setVisible(true)
assert.equal(f.state.rows[0].msg_id, 1)
await settle()
assert.equal(f.timers.size, 1)
f.controller.dispose()
})
test('IM API functions retain raw backend reasons, send progress tokens, and disable retries', async () => {
const calls = []
let reply = { code: 1, data: progress(true), msg: '不应自动显示的成功提示', show: 1 }
const request = {}
for (const method of ['get', 'post']) request[method] = async (config, options) => { calls.push({ method, config, options }); return reply }
const api = loadTs(path.join(__dirname, '../src/api/tcm.ts'), (name) => {
if (name === '@/utils/request') return { default: request }
throw new Error(`Unexpected import: ${name}`)
})
assert.equal((await api.triggerImChatSync({ diagnosis_id: 2, sync_token: 'resume' })).completed, true)
reply = { code: 1, data: archive(2) }
assert.equal((await api.getImChatMessages({ diagnosis_id: 2, only_archived: 1 })).lists[0].msg_id, 2)
assert.deepEqual(calls[0].config.data, { diagnosis_id: 2, sync_token: 'resume' })
for (const call of calls) {
assert.equal(call.config.timeout, 30000)
assert.deepEqual(call.options, { isTransformResponse: false, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 })
}
reply = { code: 0, data: [], msg: '腾讯云错误:签名校验失败' }
await assert.rejects(api.triggerImChatSync({ diagnosis_id: 2 }), /腾讯云错误:签名校验失败/)
})
test('chat panel script, template, and scoped styles compile', () => {
const filename = path.join(__dirname, '../src/views/tcm/diagnosis/components/ImChatRecordPanel.vue')
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
assert.deepEqual(errors, [])
const script = compileScript(descriptor, { id: 'im-chat-history' })
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'im-chat-history', compilerOptions: { bindingMetadata: script.bindings } }).errors, [])
assert.deepEqual(compileStyle({ source: descriptor.styles[0].content, filename, id: 'im-chat-history', scoped: true, preprocessLang: 'scss' }).errors, [])
})
test('actual panel setup wires archived reads, continued sync, patient switches, and lifecycle cleanup', async () => {
const vue = require('vue')
const hooks = {}, listeners = new Map(), timers = new Map(), apiCalls = []
const pending = deferred()
let timerId = 0
const originalDocument = global.document
global.document = {
visibilityState: 'visible',
addEventListener: (name, callback) => listeners.set(name, callback),
removeEventListener: (name) => listeners.delete(name)
}
const scope = vue.effectScope()
try {
const filename = path.join(__dirname, '../src/views/tcm/diagnosis/components/ImChatRecordPanel.vue')
const { descriptor } = parse(fs.readFileSync(filename, 'utf8'), { filename })
const script = compileScript(descriptor, { id: 'im-chat-panel-setup' })
const compiled = ts.transpileModule(script.content, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', compiled)((name) => {
if (name === 'vue') return { ...vue, ...Object.fromEntries(['onMounted', 'onBeforeUnmount', 'onActivated', 'onDeactivated'].map(hook => [hook, callback => { hooks[hook] = callback }])) }
if (name === 'dayjs') return { default: require('dayjs') }
if (name === '@element-plus/icons-vue') return {}
if (name === 'element-plus') return { ElMessage: { success() {}, warning() {}, error() {} } }
if (name === '@/utils/im-business-message-parse') return { parseImBusinessPayload: () => null }
if (name === '@/utils/im-chat-history') return { createImChatHistory: (deps) => createImChatHistory({ ...deps, setTimer: callback => { const id = ++timerId; timers.set(id, callback); return id }, clearTimer: id => timers.delete(id) }) }
if (name === '@/api/tcm') return {
getImChatMessages: async params => { apiCalls.push(['read', params]); return archive(params.diagnosis_id) },
triggerImChatSync: async params => { apiCalls.push(['sync', params]); return params.diagnosis_id === 1 ? pending.promise : progress(true) }
}
throw new Error(`Unexpected panel import: ${name}`)
}, module, module.exports)
const props = vue.reactive({ diagnosisId: 1 })
const panel = scope.run(() => module.exports.default.setup(props, { expose() {} }))
hooks.onMounted()
await settle()
assert.equal(panel.rows.value[0].raw.msg_id, 1)
assert.deepEqual(apiCalls[0], ['read', { diagnosis_id: 1, only_archived: 1 }])
assert.deepEqual(apiCalls[1], ['sync', { diagnosis_id: 1 }])
props.diagnosisId = 2
await settle()
assert.equal(panel.rows.value[0].raw.msg_id, 2)
pending.resolve(progress(true))
await settle()
assert.equal(panel.rows.value[0].raw.msg_id, 2)
assert.equal(timers.size, 1)
panel.history.rows = [{
msg_id: 'multi-part', msg_type: 'composite', from_account: 'doctor_20', to_account: 'patient_2', time: 1700000000,
parts: [{ msg_type: 'text', text: '第一段' }, { msg_type: 'image', image_url: 'https://example.invalid/chat.png' }, { msg_type: 'text', text: '第三段' }]
}]
assert.deepEqual(panel.rows.value.map(item => [item.raw.msg_id, item.raw.msg_type]), [
['multi-part:0', 'text'], ['multi-part:1', 'image'], ['multi-part:2', 'text']
])
assert.equal(panel.rows.value[2].raw.text, '第三段')
assert.ok(panel.rows.value.every(item => item.raw.to_account === 'patient_2'))
global.document.visibilityState = 'hidden'
listeners.get('visibilitychange')()
assert.equal(timers.size, 0)
hooks.onBeforeUnmount()
assert.equal(listeners.size, 0)
} finally {
scope.stop()
global.document = originalDocument
}
})
@@ -0,0 +1,102 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
const ts = require('typescript')
const vue = require('vue')
const filename = path.join(__dirname, '../src/views/consumer/prescription/components/PrescriptionOrderTimeDialog.vue')
const source = fs.readFileSync(filename, 'utf8')
const { descriptor, errors } = parse(source, { filename })
assert.deepEqual(errors, [])
const script = compileScript(descriptor, { id: 'order-time-test' })
const template = compileTemplate({
source: descriptor.template.content,
filename,
id: 'order-time-test',
compilerOptions: { bindingMetadata: script.bindings }
})
assert.deepEqual(template.errors, [])
const compiled = ts.transpileModule(script.content, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText
function dialog(save = async () => ({})) {
const calls = []
const events = []
const module = { exports: {} }
const mockRequire = (name) => {
if (name === 'vue') return vue
if (name === '@/api/tcm') return {
prescriptionOrderEditTime: async (payload) => {
calls.push(payload)
return save(payload)
}
}
if (name === '@/utils/feedback') return { default: { msgSuccess() {} } }
throw new Error(`Unexpected dependency: ${name}`)
}
new Function('require', 'module', 'exports', compiled)(mockRequire, module, module.exports)
const state = module.exports.default.setup({}, {
expose() {},
emit: (...event) => events.push(event)
})
state.formRef.value = { validate: async () => true, clearValidate() {} }
return { state, calls, events }
}
test('opening and saving preserves creation time seconds and submits only the selected order', async () => {
const { state, calls, events } = dialog()
state.open({ id: 42, order_no: 'RX42', create_time: '2026-09-09 11:12:37' })
assert.equal(state.form.create_time, '2026-09-09 11:12:37')
state.form.create_time = '2026-08-31 09:08:07'
await state.submit()
assert.deepEqual(calls, [{ id: 42, create_time: '2026-08-31 09:08:07' }])
assert.deepEqual(events, [['saved', 42]])
assert.equal(state.visible.value, false)
})
test('legacy seconds, milliseconds and strings populate the same local picker time', () => {
const { state } = dialog()
const date = new Date(2026, 8, 9, 11, 12, 37)
for (const value of [date.getTime() / 1000, String(date.getTime() / 1000), date.getTime(), '2026-09-09T11:12:37']) {
state.open({ id: 1, create_time: value })
assert.equal(state.form.create_time, '2026-09-09 11:12:37')
}
state.open({ id: 1, create_time: '2026-09-09 11:12' })
assert.equal(state.form.create_time, '2026-09-09 11:12:00')
state.open({ id: 1, create_time: 0 })
assert.equal(state.form.create_time, '')
})
test('validation failures do not send requests, and failed saves retain editable input', async () => {
const { state, calls, events } = dialog(async () => { throw new Error('denied') })
state.open({ id: 9, create_time: '2026-09-09 11:12:37' })
state.formRef.value.validate = async () => { throw new Error('required') }
await state.submit()
assert.equal(calls.length, 0)
state.formRef.value.validate = async () => true
await state.submit()
assert.equal(state.visible.value, true)
assert.equal(state.submitting.value, false)
assert.equal(state.form.create_time, '2026-09-09 11:12:37')
assert.deepEqual(events, [])
})
test('a pending request cannot submit twice or switch its target order', async () => {
let finish
const pending = new Promise((resolve) => { finish = resolve })
const { state, calls, events } = dialog(() => pending)
state.open({ id: 7, create_time: '2026-09-09 11:12:37' })
const saving = state.submit()
await state.submit()
await new Promise((resolve) => setImmediate(resolve))
state.open({ id: 8, create_time: '2026-09-08 00:00:00' })
await state.submit()
assert.equal(calls.length, 1)
assert.equal(state.form.id, 7)
finish({})
await saving
assert.deepEqual(events, [['saved', 7]])
})
@@ -0,0 +1,362 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const { parse, compileScript, compileTemplate, compileStyle } = require('@vue/compiler-sfc')
const ts = require('typescript')
const vue = require('vue')
const filename = path.join(__dirname, '../src/views/first_visit/wecom_promotion/index.vue')
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
assert.deepEqual(errors, [])
const script = compileScript(descriptor, { id: 'member-sync-test' })
const template = compileTemplate({ source: descriptor.template.content, filename, id: 'member-sync-test', compilerOptions: { bindingMetadata: script.bindings } })
assert.deepEqual(template.errors, [])
assert.deepEqual(compileStyle({ source: descriptor.styles[0].content, filename, id: 'member-sync-test', scoped: true, preprocessLang: 'scss' }).errors, [])
function loadModule(source, mockRequire) {
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', compiled)(mockRequire, module, module.exports)
return module.exports
}
const automation = loadModule(fs.readFileSync(path.join(path.dirname(filename), 'components/promotion-automation.ts'), 'utf8'), require)
const member = (id, enabled = 1) => ({ id, admin_id: id, userid: `member-${id}`, name: `医助 ${id}`, enabled, reception_available: enabled === 1, is_in_remote_range: true })
const pool = (id = 1) => ({ id, name: `方案 ${id}`, status: 1, can_operate: true, can_manage_access: true, member_admin_ids: [1, 2], member_rules: [member(1), member(2, 0)], official_link: { range_userids: ['member-1', 'member-2'] }, dispatch_sync: { status: 1 } })
function page(api = {}, pools = [pool()]) {
const messages = []
const emitMessage = (type, value) => messages.push({ type, message: typeof value === 'string' ? value : value.message })
const ElMessage = (value) => emitMessage(value.type, value)
for (const type of ['success', 'warning', 'error']) ElMessage[type] = (value) => emitMessage(type, value)
const instance = loadModule(script.content, (name) => {
if (name === 'vue') return { ...vue, onMounted() {} }
if (name === 'element-plus') return { ElMessage, ElMessageBox: {} }
if (name === '@element-plus/icons-vue') return {}
if (name === '@/api/first_visit') return { wecomPromotionOverview: async () => ({ pools }), ...api }
if (name.endsWith('.vue')) return {}
if (name === './components/promotion-automation') return automation
throw new Error(`Unexpected dependency: ${name}`)
}).default.setup({}, { expose() {} })
Object.assign(instance.overview, { pools })
instance.selectedPoolId.value = pools[0]?.id
return { state: instance, messages }
}
test('disabled local member still in remote snapshot is explicitly pending removal', () => {
const { state } = page()
assert.equal(state.routeStatus(member(2, 0)).label, '待移出(仍在企微)')
assert.equal(state.routeStatus({ ...member(1), is_in_remote_range: '0' }).label, '待加入企微')
assert.match(state.selectedSyncState.value.description, /当前计划:医助 1;上次企微确认:医助 1、医助 2/)
state.overview.pools[0].dispatch_sync = { status: 3, last_error: '可信 IP 校验失败' }
assert.match(state.selectedSyncState.value.description, /可信 IP 校验失败/)
})
test('single toggles never infer remote success from an empty error or planned dispatch', async () => {
const calls = []
const { state, messages } = page({ wecomPromotionToggleMember: async (payload) => { calls.push(payload); return { sync_error: '', dispatch: { queued: true } } } })
await state.handleMemberToggle(member(2), false)
assert.deepEqual(calls, [{ id: 2, status: 0 }])
assert.equal(messages.at(-1).type, 'warning')
assert.match(messages.at(-1).message, /尚未确认同步/)
assert.doesNotMatch(messages.at(-1).message, /已确认同步|已重新计算/)
assert.match(state.operationResultText(state.operationResults.value[0]), /尚未确认同步/)
})
test('single rule save reports explicit remote confirmation and preserves precise failures', async () => {
const { state, messages } = page({ wecomPromotionSaveMember: async () => ({ sync_status: 'failed', sync_error: '企微成员范围不一致' }) })
Object.assign(state.memberForm, { id: 2, active_range: [] })
await state.saveMemberRule()
assert.equal(messages.at(-1).type, 'warning')
assert.match(messages.at(-1).message, /企微成员范围不一致/)
state.notifySavedResult('本地已保存', { sync_status: 'synced' })
assert.equal(messages.at(-1).type, 'success')
assert.match(messages.at(-1).message, /企微成员范围已确认同步/)
})
test('pool saves with queued work retain a warning instead of implying official link completion', async () => {
const { state, messages } = page({ wecomPromotionSavePool: async () => ({ id: 1, sync_status: 'pending', sync_queued: true, sync_error: '' }) })
Object.assign(state.poolForm, { id: 1, name: '方案 1', member_admin_ids: [1] })
await state.savePool()
assert.equal(state.poolDialogVisible.value, false)
assert.equal(messages.at(-1).type, 'warning')
assert.match(messages.at(-1).message, /尚未确认同步/)
assert.equal(state.operationResults.value[0].sync_queued, true)
})
test('manual retry targets only its pool and does not import remote links', async () => {
const calls = []
const { state, messages } = page({ wecomPromotionSyncMemberRange: async (payload) => { calls.push(payload); return { pool_id: 1, sync_status: 'synced', sync_error: '', sync_queued: false } } })
await state.syncMemberRange(1)
assert.deepEqual(calls, [{ pool_id: 1 }])
assert.equal(state.operationResults.value[0].sync_status, 'synced')
assert.equal(state.syncingPoolId.value, 0)
assert.equal(messages.at(-1).type, 'success')
state.overview.pools[0].can_operate = false
await state.syncMemberRange(1)
assert.equal(calls.length, 1)
})
test('batch sync awaits all queued successful pools with at most two concurrent requests', async () => {
const pending = []
let active = 0
let maximum = 0
const { state } = page({ wecomPromotionSyncMemberRange: ({ pool_id }) => new Promise((resolve) => {
active++
maximum = Math.max(maximum, active)
pending.push({ id: pool_id, finish: (result) => { active--; resolve({ pool_id, ...result }) } })
}) })
const results = [1, 2, 3].map((id) => ({ id, name: `方案 ${id}`, success: true, sync_queued: true }))
results.push({ id: 4, name: '保存失败方案', success: false, sync_queued: true, error: '无权限' })
const saving = state.syncBatchResults(results)
assert.deepEqual(pending.map((item) => item.id), [1, 2])
pending[0].finish({ sync_status: 'synced', sync_error: '' })
await new Promise((resolve) => setImmediate(resolve))
assert.deepEqual(pending.map((item) => item.id), [1, 2, 3])
assert.equal(state.batchSyncProgress.completed, 1)
pending[1].finish({ sync_status: 'failed', sync_error: '范围校验失败' })
pending[2].finish({ sync_status: 'pending', sync_error: '' })
await saving
assert.equal(maximum, 2)
assert.equal(state.batchSyncProgress.completed, 3)
assert.deepEqual(state.operationResults.value.map((item) => item.sync_status), ['synced', 'failed', 'pending', undefined])
assert.match(state.operationResultText(state.operationResults.value[1]), /范围校验失败/)
assert.match(state.operationResultText(state.operationResults.value[3]), /本地保存失败:无权限/)
})
test('batch save retains partial failures and never reports queued work as remote success', async () => {
const pools = [pool(1), pool(2), pool(3)]
const syncedIds = []
const { state, messages } = page({
wecomPromotionBatchUpdatePools: async () => ({ updated: 2, failed: 1, member_updated: 2, results: [
{ id: 1, name: '方案 1', success: true, sync_queued: true },
{ id: 2, name: '方案 2', success: true, sync_queued: true },
{ id: 3, name: '方案 3', success: false, error: '保存失败' }
] }),
wecomPromotionSyncMemberRange: async ({ pool_id }) => {
syncedIds.push(pool_id)
if (pool_id === 2) throw '企微请求超时'
return { pool_id, sync_status: 'synced', sync_error: '', sync_queued: false }
}
}, pools)
Object.assign(state.batchConfigApply, { member_status: true })
Object.assign(state.batchConfigForm, { pool_ids: [1, 2, 3], member_admin_ids: [2], member_status: 0 })
state.batchConfigDialogVisible.value = true
await state.saveBatchConfig()
assert.deepEqual(syncedIds, [1, 2])
assert.equal(state.savingBatchConfig.value, false)
assert.equal(state.batchConfigDialogVisible.value, false)
assert.deepEqual(state.selectedPoolIds.value, [2, 3])
assert.match(messages.at(-1).message, /企微已确认同步 1 个,1 个尚未确认同步;1 个本地保存失败/)
assert.equal(messages.at(-1).type, 'warning')
assert.match(state.operationResults.value[1].sync_error, /企微请求超时/)
})
test('direct batch sync snapshots and deduplicates selection, limits concurrency, and waits for refresh', async () => {
const pools = [pool(1), pool(2), pool(3), pool(4)]
const pending = []
const unrelatedCalls = []
let active = 0
let maximum = 0
let refreshCount = 0
let finishRefresh
const api = Object.fromEntries([
'wecomPromotionBatchUpdatePools', 'wecomPromotionSavePool', 'wecomPromotionSaveMember',
'wecomPromotionBatchSetOperators', 'wecomPromotionSyncCustomers', 'wecomPromotionToggleMember'
].map((name) => [name, async (payload) => { unrelatedCalls.push({ name, payload }); return {} }]))
const { state, messages } = page({
...api,
wecomPromotionOverview: () => {
refreshCount++
return new Promise((resolve) => { finishRefresh = () => resolve({ pools }) })
},
wecomPromotionSyncMemberRange: ({ pool_id }) => new Promise((resolve) => {
active++
maximum = Math.max(maximum, active)
pending.push({ id: pool_id, finish: () => {
active--
resolve({ pool_id, sync_status: 'synced', sync_error: '', sync_queued: false })
} })
})
}, pools)
state.selectedPoolIds.value = [1, 1, 2, 3]
let completed = false
const syncing = state.syncSelectedMemberRanges().then(() => { completed = true })
assert.equal(state.syncingBatchMembers.value, true)
assert.deepEqual(pending.map((item) => item.id), [1, 2])
assert.deepEqual({ ...state.batchSyncProgress }, { completed: 0, total: 3 })
// A changed ref must not alter the already-dispatched batch's work list.
state.selectedPoolIds.value = [4]
pending[0].finish()
await new Promise((resolve) => setImmediate(resolve))
assert.deepEqual(pending.map((item) => item.id), [1, 2, 3])
assert.equal(state.batchSyncProgress.completed, 1)
assert.equal(refreshCount, 0)
pending[1].finish()
await new Promise((resolve) => setImmediate(resolve))
assert.equal(state.batchSyncProgress.completed, 2)
assert.equal(completed, false)
assert.equal(refreshCount, 0)
pending[2].finish()
await new Promise((resolve) => setImmediate(resolve))
assert.equal(maximum, 2)
assert.equal(refreshCount, 1)
assert.equal(state.syncingBatchMembers.value, true)
assert.equal(state.batchSyncProgress.completed, 3)
assert.equal(completed, false)
finishRefresh()
await syncing
assert.equal(state.syncingBatchMembers.value, false)
assert.deepEqual({ ...state.batchSyncProgress }, { completed: 0, total: 0 })
assert.deepEqual(state.selectedPoolIds.value, [])
assert.deepEqual(state.operationResults.value.map((item) => item.id), [1, 2, 3])
assert.ok(state.operationResults.value.every((item) => item.sync_only && item.sync_status === 'synced'))
assert.deepEqual(unrelatedCalls, [])
assert.equal(messages.at(-1).type, 'success')
assert.match(messages.at(-1).message, /已确认同步 3 个/)
assert.doesNotMatch(messages.at(-1).message, /本地.*保存/)
for (const result of state.operationResults.value) {
assert.match(state.operationResultText(result), /已确认同步/)
assert.doesNotMatch(state.operationResultText(result), /本地.*保存/)
}
})
test('direct batch sync keeps failed, pending and blocked pools selected with precise results', async () => {
const pools = [pool(1), pool(2), pool(3), { ...pool(4), official_link: null }, { ...pool(5), can_operate: false }]
const calls = []
let refreshCount = 0
const { state, messages } = page({
wecomPromotionOverview: async () => { refreshCount++; return { pools } },
wecomPromotionSyncMemberRange: async ({ pool_id }) => {
calls.push(pool_id)
if (pool_id === 2) throw new Error('企微请求超时,请检查网络')
return { pool_id, sync_status: pool_id === 1 ? 'synced' : 'pending', sync_queued: pool_id === 3, sync_error: '' }
}
}, pools)
state.selectedPoolIds.value = [1, 2, 3, 4, 5]
await state.syncSelectedMemberRanges()
assert.deepEqual(calls, [1, 2, 3])
assert.equal(refreshCount, 1)
assert.deepEqual(state.selectedPoolIds.value, [2, 3, 4, 5])
assert.deepEqual(state.operationResults.value.map((item) => item.sync_status), ['synced', 'failed', 'pending', 'blocked', 'blocked'])
assert.ok(state.operationResults.value.every((item) => item.sync_only))
assert.match(state.operationResults.value[1].sync_error, /企微请求超时,请检查网络/)
assert.equal(state.operationResults.value[2].sync_queued, true)
assert.match(state.operationResultText(state.operationResults.value[3]), /链接/)
assert.match(state.operationResultText(state.operationResults.value[4]), /权限|无权/)
for (const result of state.operationResults.value) assert.doesNotMatch(state.operationResultText(result), /本地.*保存/)
assert.equal(messages.at(-1).type, 'warning')
assert.match(messages.at(-1).message, /已确认同步 1 个/)
assert.match(messages.at(-1).message, /4 个尚未确认同步/)
assert.doesNotMatch(messages.at(-1).message, /本地.*保存/)
assert.equal(state.syncingBatchMembers.value, false)
assert.deepEqual({ ...state.batchSyncProgress }, { completed: 0, total: 0 })
})
test('direct batch sync prevents duplicate requests and freezes member and selection changes while busy', async () => {
const pools = [pool(1), pool(2)]
const calls = []
const toggles = []
let finish
const { state } = page({
wecomPromotionSyncMemberRange: ({ pool_id }) => {
calls.push(pool_id)
return new Promise((resolve) => { finish = () => resolve({ pool_id, sync_status: 'synced' }) })
},
wecomPromotionToggleMember: async (payload) => { toggles.push(payload); return { sync_status: 'synced' } }
}, pools)
state.selectedPoolIds.value = [1]
const syncing = state.syncSelectedMemberRanges()
await state.syncSelectedMemberRanges()
await state.syncMemberRange(2)
await state.handleMemberToggle(member(2), false)
state.togglePoolSelection(pools[0], false)
state.togglePoolSelection(pools[1], true)
assert.deepEqual(state.selectedPoolIds.value, [1])
state.toggleAllPoolSelection(false)
assert.deepEqual(state.selectedPoolIds.value, [1])
state.toggleAllPoolSelection(true)
assert.deepEqual(state.selectedPoolIds.value, [1])
assert.deepEqual(calls, [1])
assert.deepEqual(toggles, [])
finish()
await syncing
assert.equal(state.syncingBatchMembers.value, false)
})
test('direct batch sync cannot begin while a single sync, member toggle or batch save is active', async () => {
const calls = []
const { state } = page({ wecomPromotionSyncMemberRange: async (payload) => { calls.push(payload); return { sync_status: 'synced' } } })
state.selectedPoolIds.value = [1]
for (const [busy, value] of [[state.syncingPoolId, 1], [state.togglingMemberId, 2], [state.savingBatchConfig, true]]) {
busy.value = value
await state.syncSelectedMemberRanges()
assert.deepEqual(calls, [])
assert.equal(state.syncingBatchMembers.value, false)
busy.value = typeof value === 'boolean' ? false : 0
}
})
test('direct batch sync rejects empty and oversized selections but accepts 100 unique pools', async () => {
const pools = Array.from({ length: 101 }, (_, index) => pool(index + 1))
const calls = []
const { state, messages } = page({ wecomPromotionSyncMemberRange: async ({ pool_id }) => {
calls.push(pool_id)
return { pool_id, sync_status: 'synced' }
} }, pools)
await state.syncSelectedMemberRanges()
assert.equal(messages.at(-1).type, 'warning')
assert.match(messages.at(-1).message, /选择|勾选/)
assert.deepEqual(calls, [])
state.selectedPoolIds.value = pools.map((item) => item.id)
await state.syncSelectedMemberRanges()
assert.equal(messages.at(-1).type, 'warning')
assert.match(messages.at(-1).message, /100/)
assert.deepEqual(calls, [])
assert.equal(state.syncingBatchMembers.value, false)
state.selectedPoolIds.value = [...pools.slice(0, 100).map((item) => item.id), 1]
await state.syncSelectedMemberRanges()
assert.deepEqual(calls, pools.slice(0, 100).map((item) => item.id))
assert.equal(messages.at(-1).type, 'success')
})
test('shared operators can select and sync pools while access and configuration stay manager-only', async () => {
const pools = [
{ ...pool(1), can_manage_access: false },
{ ...pool(2), can_operate: false },
{ ...pool(3), can_operate: false, can_manage_access: false },
pool(4)
]
const calls = []
const { state, messages } = page({ wecomPromotionSyncMemberRange: async ({ pool_id }) => {
calls.push(pool_id)
return { pool_id, sync_status: 'synced' }
} }, pools)
assert.deepEqual(state.selectablePoolIds.value, [1, 2, 4])
state.togglePoolSelection(pools[0], true)
state.togglePoolSelection(pools[2], true)
await vue.nextTick()
assert.deepEqual(state.selectedPoolIds.value, [1])
state.openAccessDialog()
assert.equal(state.accessDialogVisible.value, false)
assert.equal(messages.at(-1).type, 'warning')
state.openBatchConfigDialog()
assert.equal(state.batchConfigDialogVisible.value, false)
assert.equal(messages.at(-1).type, 'warning')
await state.syncSelectedMemberRanges()
assert.deepEqual(calls, [1])
state.toggleAllPoolSelection(true)
assert.deepEqual(state.selectedPoolIds.value, [1, 2, 4])
state.openAccessDialog()
assert.deepEqual(state.accessForm.pool_ids, [2, 4])
state.openBatchConfigDialog()
assert.deepEqual(state.batchConfigForm.pool_ids, [2, 4])
state.overview.pools[0].can_operate = false
await vue.nextTick()
assert.deepEqual(state.selectedPoolIds.value, [2, 4])
})
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -45,9 +45,10 @@ export default defineConfig(({ mode }) => {
: 'http://127.0.0.1:8080'
return {
base: '/admin/',
server: {
host: '0.0.0.0',
base: '/admin/',
server: {
port: 5555,
host: '0.0.0.0',
hmr: true,
open: true,
proxy: {
+2
View File
@@ -1,6 +1,8 @@
.env
.venv/
.venv-build/
.venv-win7-check/
.build-tools/
.uv-cache/
.uv-python/
__pycache__/
+6 -5
View File
@@ -6,12 +6,13 @@ set "POWERSHELL_EXE=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"
"%POWERSHELL_EXE%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%PROJECT_ROOT%scripts\package_windows.ps1" %*
set "RESULT=%ERRORLEVEL%"
if "%RESULT%"=="0" (
if "%RESULT%"=="0" if /I "%~1"=="-ValidateOnly" (
echo.
echo Package ready in: %PROJECT_ROOT%dist
if /I not "%~1"=="-ValidateOnly" (
start "" "%SystemRoot%\explorer.exe" "%PROJECT_ROOT%dist"
)
echo Validation complete. No installer was generated.
) else if "%RESULT%"=="0" (
echo.
echo Installer and portable package ready in: %PROJECT_ROOT%dist
start "" "%SystemRoot%\explorer.exe" "%PROJECT_ROOT%dist"
) else (
echo.
echo DoctorWorkstation packaging failed. Exit code: %RESULT%
+4 -2
View File
@@ -9,16 +9,18 @@
Windows 直接在项目根目录双击:
- `一键运行_医生工作站.bat`:优先启动现有成品;没有成品时自动使用 `uv` 准备源码环境并运行。
- `一键打包_医生工作站.bat`:自动同步锁定的 Python/Node 依赖,检查冻结 QtWebEngine/QtMultimedia 文件,执行应用与媒体离屏冒烟验证,最后生成 `dist/DoctorWorkstation-Windows-x64-<版本>.zip` 和 SHA-256 文件
- `一键打包_医生工作站.bat`:自动同步锁定的 Python/Node 依赖,检查冻结 QtWebEngine/QtMultimedia 文件,执行应用与媒体离屏冒烟验证,最后生成 `dist/DoctorWorkstation-Setup-Windows-x64-<版本>.exe` 安装包、便携 ZIP 和 SHA-256 文件。首次使用会自动下载固定版本的 Inno Setup,并校验下载文件的 SHA-256 与数字签名
英文稳定别名分别是 `Run_DoctorWorkstation.bat``Build_DoctorWorkstation.bat`。分发 ZIP 解压后,可直接双击其中的 `Start_DoctorWorkstation.bat`
发布新版本时,可在管理后台的“医生工作站升级包”中为 Windows 选择 `Inno Setup EXE`,填入一键打包生成的 Setup 地址、SHA-256、文件大小和文件名。客户端启动时自动检测;发现新版本后,用户点击“立即更新”即可完成下载、校验、退出、静默安装和自动重启。生产地址必须使用 HTTPS 并开启证书校验,正式 Setup 还应配置组织的 Authenticode 代码签名。
macOS 在 Finder 中双击:
- `一键运行.command`:优先打开现有 `DoctorWorkstation.app`,否则自动准备源码环境并运行。
- `一键打包.command`:构建、QtWebEngine/QtMultimedia 文件门禁、签名检查和两项冻结冒烟验证后,生成 `.app`、可分发 ZIP 及 SHA-256 文件。
Windows 打包机需预先安装 `uv` 与 Node.js 20+;脚本会自动处理项目虚拟环境锁定依赖。首次打包需要联网下载依赖,之后会复用本机缓存。macOS 发布源码中的根 `.command` 与操作型 `scripts/*.sh` 必须以 Git mode `100755` 跟踪;源码压缩包在传输中丢失权限时,可在项目目录执行一次 `chmod +x *.command scripts/*.sh`。若 Gatekeeper 拦截未签名内部测试版,请使用右键“打开”。
Windows 打包机需预先安装 `uv` 与 Node.js 20+;脚本会自动处理项目虚拟环境锁定依赖和安装器编译器。首次打包需要联网下载依赖,之后会复用本机缓存。当前 Python 3.11+/PySide6/Qt 6 成品的真实最低系统是 Windows 10 1809Qt 6 不支持 Windows 7,不能只降低安装器版本门槛来伪装兼容。Windows 7 SP1 必须另行维护 Python 3.8.10 + PySide2/Qt 5.15 的遗留构建,并在干净 Win7 虚拟机完成视频、媒体、安装、升级和卸载验收。macOS 发布源码中的根 `.command` 与操作型 `scripts/*.sh` 必须以 Git mode `100755` 跟踪;源码压缩包在传输中丢失权限时,可在项目目录执行一次 `chmod +x *.command scripts/*.sh`。若 Gatekeeper 拦截未签名内部测试版,请使用右键“打开”。
## 已实现范围
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

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