Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
928f72ec3d | ||
|
|
b4c11881b4 | ||
|
|
58ffde808f | ||
|
|
486acc465d | ||
|
|
398f9f3726 | ||
|
|
5bd5eae62d | ||
|
|
456dd667df | ||
|
|
ed48f8be31 | ||
|
|
43ad07208f | ||
|
|
4b8b4eb649 | ||
|
|
75e214dc08 | ||
|
|
74ff568ba4 | ||
|
|
2fa8492c56 | ||
|
|
b5b14516a1 | ||
|
|
381fe65367 | ||
|
|
af603a4e9a | ||
|
|
f24afa116f | ||
|
|
b8ccbaf567 | ||
|
|
af1db59c07 | ||
|
|
47094cc617 | ||
|
|
01c38d8c5b | ||
|
|
1f3e580cf8 | ||
|
|
562fe0ea0e | ||
|
|
d9bb94cd3f | ||
|
|
9646ccd3f6 | ||
|
|
43e5411b6a | ||
|
|
c06d293424 | ||
|
|
6c444a4a04 | ||
|
|
cc6173e5b0 | ||
|
|
5794f60c5d | ||
|
|
35f91ee37a | ||
|
|
bc1228a310 | ||
|
|
8b9df1154c | ||
|
|
5f53aa0bbd |
@@ -1,33 +1,34 @@
|
||||
# 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
|
||||
|
||||
@@ -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.…` → 拆出序言 + 编号列表
|
||||
- 短中文标签 `XX:YY` 开头 → 视为 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,6 +1,6 @@
|
||||
import App from './App'
|
||||
|
||||
var baseUrl = 'https://admin.zhenyangtang.com.cn/'
|
||||
var baseUrl = 'https://xt.zhenyangtang.com.cn/'
|
||||
|
||||
function joinApiUrl(base, path) {
|
||||
const b = String(base || '').replace(/\/+$/, '')
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
{
|
||||
"name" : "tongji-uniapp",
|
||||
"appid" : "__UNI__TONGJI01",
|
||||
"description" : "血糖管理 / 日常护理",
|
||||
"versionName" : "1.0.0",
|
||||
"versionCode" : "100",
|
||||
"transformPx" : false,
|
||||
"app-plus" : {
|
||||
"usingComponents" : true,
|
||||
"nvueStyleCompiler" : "uni-app",
|
||||
"compilerVersion" : 3,
|
||||
"splashscreen" : {
|
||||
"alwaysShowBeforeRender" : true,
|
||||
"waiting" : true,
|
||||
"autoclose" : true,
|
||||
"delay" : 0
|
||||
},
|
||||
"modules" : {},
|
||||
"distribute" : {
|
||||
"android" : {
|
||||
"permissions" : [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>"
|
||||
]
|
||||
},
|
||||
"ios" : {},
|
||||
"sdkConfigs" : {}
|
||||
}
|
||||
},
|
||||
"quickapp" : {},
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx79b9a0bfbfe7cbcd",
|
||||
"lazyCodeLoading" : "requiredComponents",
|
||||
"setting" : {
|
||||
"urlCheck" : false,
|
||||
"minified" : true
|
||||
},
|
||||
"optimization" : {
|
||||
"subPackages" : true
|
||||
},
|
||||
"usingComponents" : true,
|
||||
"requiredBackgroundModes" : [ "audio" ],
|
||||
"plugins" : {
|
||||
"WechatSI" : {
|
||||
"version" : "0.3.5",
|
||||
"provider" : "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mp-alipay" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-baidu" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-toutiao" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"uniStatistics" : {
|
||||
"enable" : false
|
||||
},
|
||||
"vueVersion" : "3"
|
||||
}
|
||||
{
|
||||
"name" : "tongji-uniapp",
|
||||
"appid" : "__UNI__TONGJI01",
|
||||
"description" : "血糖管理 / 日常护理",
|
||||
"versionName" : "1.0.0",
|
||||
"versionCode" : "100",
|
||||
"transformPx" : false,
|
||||
"app-plus" : {
|
||||
"usingComponents" : true,
|
||||
"nvueStyleCompiler" : "uni-app",
|
||||
"compilerVersion" : 3,
|
||||
"splashscreen" : {
|
||||
"alwaysShowBeforeRender" : true,
|
||||
"waiting" : true,
|
||||
"autoclose" : true,
|
||||
"delay" : 0
|
||||
},
|
||||
"modules" : {},
|
||||
"distribute" : {
|
||||
"android" : {
|
||||
"permissions" : [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>"
|
||||
]
|
||||
},
|
||||
"ios" : {},
|
||||
"sdkConfigs" : {}
|
||||
}
|
||||
},
|
||||
"quickapp" : {},
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx0c2a9d5ebad26a3f",
|
||||
"lazyCodeLoading" : "requiredComponents",
|
||||
"setting" : {
|
||||
"urlCheck" : false,
|
||||
"minified" : true
|
||||
},
|
||||
"optimization" : {
|
||||
"subPackages" : true
|
||||
},
|
||||
"usingComponents" : true,
|
||||
"requiredBackgroundModes" : [ "audio" ],
|
||||
"plugins" : {
|
||||
"WechatSI" : {
|
||||
"version" : "0.3.5",
|
||||
"provider" : "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mp-alipay" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-baidu" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-toutiao" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"uniStatistics" : {
|
||||
"enable" : false
|
||||
},
|
||||
"vueVersion" : "3"
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"tab-bar-dock.js","sources":["components/app-tab-bar/tab-bar-dock.vue","../../../soft/HBuilderX/plugins/uniapp-cli-vite/uniComponent:/RDovd2ViL3p5dC9Ub25namlVbmlBcHAvY29tcG9uZW50cy9hcHAtdGFiLWJhci90YWItYmFyLWRvY2sudnVl"],"sourcesContent":["<template>\r\n <view class=\"tab-dock\">\r\n <view class=\"tab-dock__inner\">\r\n <view class=\"tab-dock__item tab-dock__item--center\" @click=\"goWeekly\">\r\n <view class=\"tab-dock__fab\" :class=\"{ 'tab-dock__fab--active': active === 0 }\">\r\n <text class=\"tab-dock__fab-text\">糖</text>\r\n </view>\r\n <text class=\"tab-dock__label tab-dock__label--center\" :style=\"{ color: active === 0 ? activeColor : color }\">血糖</text>\r\n </view>\r\n\r\n <view class=\"tab-dock__item\" @click=\"goUser\">\r\n <image\r\n class=\"tab-dock__icon\"\r\n :src=\"active === 1 ? '/static/user/user_no.png' : '/static/user/user.png'\"\r\n mode=\"aspectFit\"\r\n />\r\n <text class=\"tab-dock__label\" :style=\"{ color: active === 1 ? activeColor : color }\">我的</text>\r\n </view>\r\n </view>\r\n <view class=\"tab-dock__safe\" />\r\n </view>\r\n</template>\r\n\r\n<script setup>\r\nconst props = defineProps({\r\n /** 0 血糖 1 我的 */\r\n active: {\r\n type: Number,\r\n default: 0\r\n }\r\n})\r\n\r\nconst color = '#8a8a8a'\r\nconst activeColor = '#204e2b'\r\n\r\nfunction goUser() {\r\n if (props.active === 1) return\r\n uni.redirectTo({ url: '/pages/user/user' })\r\n}\r\n\r\nfunction goWeekly() {\r\n if (props.active === 0) return\r\n uni.redirectTo({ url: '/tongji/pages/weekly' })\r\n}\r\n</script>\r\n\r\n<style scoped lang=\"scss\">\r\n.tab-dock {\r\n position: fixed;\r\n left: 0;\r\n right: 0;\r\n bottom: 0;\r\n z-index: 999;\r\n background: #ffffff;\r\n border-top: 1rpx solid rgba(0, 0, 0, 0.06);\r\n box-shadow: 0 -6rpx 20rpx rgba(0, 0, 0, 0.04);\r\n}\r\n\r\n.tab-dock__inner {\r\n display: flex;\r\n align-items: flex-end;\r\n height: 100rpx;\r\n}\r\n\r\n.tab-dock__item {\r\n flex: 1;\r\n height: 100rpx;\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n.tab-dock__item--center {\r\n justify-content: flex-end;\r\n padding-bottom: 6rpx;\r\n}\r\n\r\n.tab-dock__icon {\r\n width: 46rpx;\r\n height: 46rpx;\r\n}\r\n\r\n.tab-dock__label {\r\n margin-top: 6rpx;\r\n font-size: 22rpx;\r\n line-height: 1.2;\r\n}\r\n\r\n.tab-dock__label--center {\r\n margin-top: 8rpx;\r\n}\r\n\r\n.tab-dock__fab {\r\n width: 96rpx;\r\n height: 96rpx;\r\n margin-top: -48rpx;\r\n border-radius: 50%;\r\n background: linear-gradient(180deg, #2d7340 0%, #204e2b 100%);\r\n border: 6rpx solid #ffffff;\r\n box-shadow: 0 10rpx 24rpx rgba(32, 78, 43, 0.35);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n.tab-dock__fab--active {\r\n transform: scale(1.04);\r\n box-shadow: 0 12rpx 28rpx rgba(32, 78, 43, 0.42);\r\n}\r\n\r\n.tab-dock__fab-text {\r\n color: #ffffff;\r\n font-size: 38rpx;\r\n font-weight: 700;\r\n line-height: 1;\r\n}\r\n\r\n.tab-dock__safe {\r\n height: env(safe-area-inset-bottom);\r\n background: #ffffff;\r\n}\r\n</style>\r\n","import Component from 'D:/web/zyt/TongjiUniApp/components/app-tab-bar/tab-bar-dock.vue'\nwx.createComponent(Component)"],"names":["uni"],"mappings":";;AAgCA,MAAM,QAAQ;AACd,MAAM,cAAc;;;;;;;;;;;AATpB,UAAM,QAAQ;AAWd,aAAS,SAAS;AACd,UAAI,MAAM,WAAW;AAAG;AACxBA,oBAAAA,MAAI,WAAW,EAAE,KAAK,mBAAkB,CAAE;AAAA,IAC9C;AAEA,aAAS,WAAW;AAChB,UAAI,MAAM,WAAW;AAAG;AACxBA,oBAAAA,MAAI,WAAW,EAAE,KAAK,uBAAsB,CAAE;AAAA,IAClD;;;;;;;;;;;;;;AC1CA,GAAG,gBAAgB,SAAS;"}
|
||||
{"version":3,"file":"tab-bar-dock.js","sources":["components/app-tab-bar/tab-bar-dock.vue","../../../soft/HBuilderX/plugins/uniapp-cli-vite/uniComponent:/RDovd2ViL3p5dC9Ub25namlVbmlBcHAvY29tcG9uZW50cy9hcHAtdGFiLWJhci90YWItYmFyLWRvY2sudnVl"],"sourcesContent":["<template>\r\n <view class=\"tab-dock\">\r\n <view class=\"tab-dock__inner\">\r\n <view class=\"tab-dock__item tab-dock__item--center\" @click=\"goWeekly\">\r\n <view class=\"tab-dock__fab\" :class=\"{ 'tab-dock__fab--active': active === 0 }\">\r\n <text class=\"tab-dock__fab-text\">糖</text>\r\n </view>\r\n <text class=\"tab-dock__label tab-dock__label--center\" :style=\"{ color: active === 0 ? activeColor : color }\">血糖</text>\r\n </view>\r\n\r\n <view class=\"tab-dock__item\" @click=\"goUser\">\r\n <image\r\n class=\"tab-dock__icon\"\r\n :src=\"active === 1 ? '/static/user/user_no.png' : '/static/user/user.png'\"\r\n mode=\"aspectFit\"\r\n />\r\n <text class=\"tab-dock__label\" :style=\"{ color: active === 1 ? activeColor : color }\">我的</text>\r\n </view>\r\n </view>\r\n <view class=\"tab-dock__safe\" />\r\n </view>\r\n</template>\r\n\r\n<script setup>\r\nconst props = defineProps({\r\n /** 0 血糖 1 我的 */\r\n active: {\r\n type: Number,\r\n default: 0\r\n }\r\n})\r\n\r\nconst color = '#8a8a8a'\r\nconst activeColor = '#204e2b'\r\n\r\nfunction goUser() {\r\n if (props.active === 1) return\r\n uni.redirectTo({ url: '/pages/user/user' })\r\n}\r\n\r\nfunction goWeekly() {\r\n if (props.active === 0) return\r\n uni.redirectTo({ url: '/tongji/pages/weekly' })\r\n}\r\n</script>\r\n\r\n<style scoped lang=\"scss\">\r\n.tab-dock {\r\n position: fixed;\r\n left: 0;\r\n right: 0;\r\n bottom: 0;\r\n z-index: 999;\r\n background: #ffffff;\r\n border-top: 1rpx solid rgba(0, 0, 0, 0.06);\r\n box-shadow: 0 -6rpx 20rpx rgba(0, 0, 0, 0.04);\r\n}\r\n\r\n.tab-dock__inner {\r\n display: flex;\r\n align-items: flex-end;\r\n height: 100rpx;\r\n}\r\n\r\n.tab-dock__item {\r\n flex: 1;\r\n height: 100rpx;\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n.tab-dock__item--center {\r\n justify-content: flex-end;\r\n padding-bottom: 6rpx;\r\n}\r\n\r\n.tab-dock__icon {\r\n width: 46rpx;\r\n height: 46rpx;\r\n}\r\n\r\n.tab-dock__label {\r\n margin-top: 6rpx;\r\n font-size: 22rpx;\r\n line-height: 1.2;\r\n}\r\n\r\n.tab-dock__label--center {\r\n margin-top: 8rpx;\r\n}\r\n\r\n.tab-dock__fab {\r\n width: 96rpx;\r\n height: 96rpx;\r\n margin-top: -48rpx;\r\n border-radius: 50%;\r\n background: linear-gradient(180deg, #2d7340 0%, #204e2b 100%);\r\n border: 6rpx solid #ffffff;\r\n box-shadow: 0 10rpx 24rpx rgba(32, 78, 43, 0.35);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n.tab-dock__fab--active {\r\n transform: scale(1.04);\r\n box-shadow: 0 12rpx 28rpx rgba(32, 78, 43, 0.42);\r\n}\r\n\r\n.tab-dock__fab-text {\r\n color: #ffffff;\r\n font-size: 38rpx;\r\n font-weight: 700;\r\n line-height: 1;\r\n}\r\n\r\n.tab-dock__safe {\r\n height: env(safe-area-inset-bottom);\r\n background: #ffffff;\r\n}\r\n</style>\r\n","import Component from 'D:/web/zyt/TongjiUniApp/components/app-tab-bar/tab-bar-dock.vue'\nwx.createComponent(Component)"],"names":["color","activeColor","props","__props","goUser","uni","goWeekly","Component"],"mappings":"uDAgCMA,EAAQ,UACRC,EAAc,mFATpB,MAAMC,EAAQC,EAWd,SAASC,GAAS,CACVF,EAAM,SAAW,GACrBG,EAAAA,MAAI,WAAW,CAAE,IAAK,kBAAkB,CAAE,CAC9C,CAEA,SAASC,GAAW,CACZJ,EAAM,SAAW,GACrBG,EAAAA,MAAI,WAAW,CAAE,IAAK,sBAAsB,CAAE,CAClD,iOC1CA,GAAG,gBAAgBE,CAAS"}
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"VoiceSpeakMascot.js","sources":["../../../soft/HBuilderX/plugins/uniapp-cli-vite/uniComponent:/RDovd2ViL3p5dC9Ub25namlVbmlBcHAvdG9uZ2ppL2NvbXBvbmVudHMvVm9pY2VTcGVha01hc2NvdC52dWU"],"sourcesContent":["import Component from 'D:/web/zyt/TongjiUniApp/tongji/components/VoiceSpeakMascot.vue'\nwx.createComponent(Component)"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AACA,GAAG,gBAAgB,SAAS;"}
|
||||
{"version":3,"file":"VoiceSpeakMascot.js","sources":["../../../soft/HBuilderX/plugins/uniapp-cli-vite/uniComponent:/RDovd2ViL3p5dC9Ub25namlVbmlBcHAvdG9uZ2ppL2NvbXBvbmVudHMvVm9pY2VTcGVha01hc2NvdC52dWU"],"sourcesContent":["import Component from 'D:/web/zyt/TongjiUniApp/tongji/components/VoiceSpeakMascot.vue'\nwx.createComponent(Component)"],"names":["Component"],"mappings":"8XACA,GAAG,gBAAgBA,CAAS"}
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"useGameAuth.js","sources":["tongji/endless-game/composables/useGameAuth.js"],"sourcesContent":["/**\r\n * 识糖小课堂独立登录适配层。复用主小程序账号,但不依赖 tongji 其他页面代码。\r\n */\r\nexport function useGameAuth(proxy) {\r\n let loginPromise = null\r\n\r\n function hasToken() {\r\n return !!String(uni.getStorageSync('token') || '').trim()\r\n }\r\n\r\n function clearToken() {\r\n uni.removeStorageSync('token')\r\n }\r\n\r\n function wxLoginCode() {\r\n return new Promise((resolve, reject) => {\r\n uni.login({\r\n provider: 'weixin',\r\n success: (res) => res?.code ? resolve(res.code) : reject(new Error('微信登录未返回 code')),\r\n fail: reject\r\n })\r\n })\r\n }\r\n\r\n async function loginWithCode(code) {\r\n const res = await proxy.apiUrl({\r\n url: '/api/login/mnpLogin',\r\n method: 'POST',\r\n data: { code }\r\n }, false)\r\n if (res?.code !== 1 || !res.data?.token) {\r\n clearToken()\r\n throw new Error(res?.msg || '登录失败')\r\n }\r\n uni.setStorageSync('token', res.data.token)\r\n uni.setStorageSync('userData', res.data)\r\n return true\r\n }\r\n\r\n async function verifyOrLogin() {\r\n if (hasToken()) {\r\n try {\r\n const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)\r\n if (res?.code === 1 && res.data) {\r\n uni.setStorageSync('userData', res.data)\r\n return true\r\n }\r\n // 只有明确的登录失效才重新换取 token;其他业务错误先保留原登录。\r\n if (res?.code !== -1) return true\r\n } catch (_) {\r\n // 断网不清除仍可能有效的 token,成绩由离线队列稍后补传。\r\n return true\r\n }\r\n clearToken()\r\n }\r\n const code = await wxLoginCode()\r\n return loginWithCode(code)\r\n }\r\n\r\n async function ensureLoggedIn() {\r\n if (loginPromise) return loginPromise\r\n loginPromise = verifyOrLogin()\r\n .then(ok => !!ok)\r\n .catch(() => false)\r\n // 只合并同时发生的登录;成功结果不能永久缓存,否则 token 过期后无法恢复。\r\n .finally(() => { loginPromise = null })\r\n return loginPromise\r\n }\r\n\r\n return { ensureLoggedIn }\r\n}\r\n"],"names":["uni"],"mappings":";;AAGO,SAAS,YAAY,OAAO;AAC/B,MAAI,eAAe;AAEnB,WAAS,WAAW;AAChB,WAAO,CAAC,CAAC,OAAOA,oBAAI,eAAe,OAAO,KAAK,EAAE,EAAE,KAAM;AAAA,EAC5D;AAED,WAAS,aAAa;AAClBA,kBAAG,MAAC,kBAAkB,OAAO;AAAA,EAChC;AAED,WAAS,cAAc;AACnB,WAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACpCA,oBAAAA,MAAI,MAAM;AAAA,QACN,UAAU;AAAA,QACV,SAAS,CAAC,SAAQ,2BAAK,QAAO,QAAQ,IAAI,IAAI,IAAI,OAAO,IAAI,MAAM,cAAc,CAAC;AAAA,QAClF,MAAM;AAAA,MACtB,CAAa;AAAA,IACb,CAAS;AAAA,EACJ;AAED,iBAAe,cAAc,MAAM;;AAC/B,UAAM,MAAM,MAAM,MAAM,OAAO;AAAA,MAC3B,KAAK;AAAA,MACL,QAAQ;AAAA,MACR,MAAM,EAAE,KAAM;AAAA,IACjB,GAAE,KAAK;AACR,SAAI,2BAAK,UAAS,KAAK,GAAC,SAAI,SAAJ,mBAAU,QAAO;AACrC,iBAAY;AACZ,YAAM,IAAI,OAAM,2BAAK,QAAO,MAAM;AAAA,IACrC;AACDA,kBAAAA,MAAI,eAAe,SAAS,IAAI,KAAK,KAAK;AAC1CA,kBAAAA,MAAI,eAAe,YAAY,IAAI,IAAI;AACvC,WAAO;AAAA,EACV;AAED,iBAAe,gBAAgB;AAC3B,QAAI,SAAQ,GAAI;AACZ,UAAI;AACA,cAAM,MAAM,MAAM,MAAM,OAAO,EAAE,KAAK,kBAAkB,QAAQ,OAAQ,GAAE,KAAK;AAC/E,aAAI,2BAAK,UAAS,KAAK,IAAI,MAAM;AAC7BA,wBAAAA,MAAI,eAAe,YAAY,IAAI,IAAI;AACvC,iBAAO;AAAA,QACV;AAED,aAAI,2BAAK,UAAS;AAAI,iBAAO;AAAA,MAChC,SAAQ,GAAG;AAER,eAAO;AAAA,MACV;AACD,iBAAY;AAAA,IACf;AACD,UAAM,OAAO,MAAM,YAAa;AAChC,WAAO,cAAc,IAAI;AAAA,EAC5B;AAED,iBAAe,iBAAiB;AAC5B,QAAI;AAAc,aAAO;AACzB,mBAAe,cAAe,EACzB,KAAK,QAAM,CAAC,CAAC,EAAE,EACf,MAAM,MAAM,KAAK,EAEjB,QAAQ,MAAM;AAAE,qBAAe;AAAA,KAAM;AAC1C,WAAO;AAAA,EACV;AAED,SAAO,EAAE,eAAgB;AAC7B;;"}
|
||||
{"version":3,"file":"useGameAuth.js","sources":["tongji/endless-game/composables/useGameAuth.js"],"sourcesContent":["/**\r\n * 识糖小课堂独立登录适配层。复用主小程序账号,但不依赖 tongji 其他页面代码。\r\n */\r\nexport function useGameAuth(proxy) {\r\n let loginPromise = null\r\n\r\n function hasToken() {\r\n return !!String(uni.getStorageSync('token') || '').trim()\r\n }\r\n\r\n function clearToken() {\r\n uni.removeStorageSync('token')\r\n }\r\n\r\n function wxLoginCode() {\r\n return new Promise((resolve, reject) => {\r\n uni.login({\r\n provider: 'weixin',\r\n success: (res) => res?.code ? resolve(res.code) : reject(new Error('微信登录未返回 code')),\r\n fail: reject\r\n })\r\n })\r\n }\r\n\r\n async function loginWithCode(code) {\r\n const res = await proxy.apiUrl({\r\n url: '/api/login/mnpLogin',\r\n method: 'POST',\r\n data: { code }\r\n }, false)\r\n if (res?.code !== 1 || !res.data?.token) {\r\n clearToken()\r\n throw new Error(res?.msg || '登录失败')\r\n }\r\n uni.setStorageSync('token', res.data.token)\r\n uni.setStorageSync('userData', res.data)\r\n return true\r\n }\r\n\r\n async function verifyOrLogin() {\r\n if (hasToken()) {\r\n try {\r\n const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)\r\n if (res?.code === 1 && res.data) {\r\n uni.setStorageSync('userData', res.data)\r\n return true\r\n }\r\n // 只有明确的登录失效才重新换取 token;其他业务错误先保留原登录。\r\n if (res?.code !== -1) return true\r\n } catch (_) {\r\n // 断网不清除仍可能有效的 token,成绩由离线队列稍后补传。\r\n return true\r\n }\r\n clearToken()\r\n }\r\n const code = await wxLoginCode()\r\n return loginWithCode(code)\r\n }\r\n\r\n async function ensureLoggedIn() {\r\n if (loginPromise) return loginPromise\r\n loginPromise = verifyOrLogin()\r\n .then(ok => !!ok)\r\n .catch(() => false)\r\n // 只合并同时发生的登录;成功结果不能永久缓存,否则 token 过期后无法恢复。\r\n .finally(() => { loginPromise = null })\r\n return loginPromise\r\n }\r\n\r\n return { ensureLoggedIn }\r\n}\r\n"],"names":["useGameAuth","proxy","loginPromise","hasToken","uni","clearToken","wxLoginCode","resolve","reject","res","loginWithCode","code","_a","verifyOrLogin","ensureLoggedIn","ok"],"mappings":"0DAGO,SAASA,EAAYC,EAAO,CAC/B,IAAIC,EAAe,KAEnB,SAASC,GAAW,CAChB,MAAO,CAAC,CAAC,OAAOC,QAAI,eAAe,OAAO,GAAK,EAAE,EAAE,KAAM,CAC5D,CAED,SAASC,GAAa,CAClBD,EAAG,MAAC,kBAAkB,OAAO,CAChC,CAED,SAASE,GAAc,CACnB,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CACpCJ,EAAAA,MAAI,MAAM,CACN,SAAU,SACV,QAAUK,GAAQA,GAAA,MAAAA,EAAK,KAAOF,EAAQE,EAAI,IAAI,EAAID,EAAO,IAAI,MAAM,cAAc,CAAC,EAClF,KAAMA,CACtB,CAAa,CACb,CAAS,CACJ,CAED,eAAeE,EAAcC,EAAM,OAC/B,MAAMF,EAAM,MAAMR,EAAM,OAAO,CAC3B,IAAK,sBACL,OAAQ,OACR,KAAM,CAAE,KAAAU,CAAM,CACjB,EAAE,EAAK,EACR,IAAIF,GAAA,YAAAA,EAAK,QAAS,GAAK,GAACG,EAAAH,EAAI,OAAJ,MAAAG,EAAU,OAC9B,MAAAP,EAAY,EACN,IAAI,OAAMI,GAAA,YAAAA,EAAK,MAAO,MAAM,EAEtCL,OAAAA,EAAAA,MAAI,eAAe,QAASK,EAAI,KAAK,KAAK,EAC1CL,EAAAA,MAAI,eAAe,WAAYK,EAAI,IAAI,EAChC,EACV,CAED,eAAeI,GAAgB,CAC3B,GAAIV,EAAQ,EAAI,CACZ,GAAI,CACA,MAAMM,EAAM,MAAMR,EAAM,OAAO,CAAE,IAAK,iBAAkB,OAAQ,MAAQ,EAAE,EAAK,EAC/E,IAAIQ,GAAA,YAAAA,EAAK,QAAS,GAAKA,EAAI,KACvBL,OAAAA,EAAAA,MAAI,eAAe,WAAYK,EAAI,IAAI,EAChC,GAGX,IAAIA,GAAA,YAAAA,EAAK,QAAS,GAAI,MAAO,EAChC,MAAW,CAER,MAAO,EACV,CACDJ,EAAY,CACf,CACD,MAAMM,EAAO,MAAML,EAAa,EAChC,OAAOI,EAAcC,CAAI,CAC5B,CAED,eAAeG,GAAiB,CAC5B,OAAIZ,IACJA,EAAeW,EAAe,EACzB,KAAKE,GAAM,CAAC,CAACA,CAAE,EACf,MAAM,IAAM,EAAK,EAEjB,QAAQ,IAAM,CAAEb,EAAe,KAAM,EACnCA,EACV,CAED,MAAO,CAAE,eAAAY,CAAgB,CAC7B"}
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"svgDataUrl.js","sources":["tongji/utils/svgDataUrl.js"],"sourcesContent":["/**\r\n * 将 SVG 字符串转为小程序可用的 data URL(base64 在真机上更稳定)\r\n */\r\n\r\nfunction utf8ToBytes(str) {\r\n const encoded = encodeURIComponent(str)\r\n const bytes = []\r\n for (let i = 0; i < encoded.length; i++) {\r\n if (encoded.charCodeAt(i) === 37) {\r\n bytes.push(parseInt(encoded.substring(i + 1, i + 3), 16))\r\n i += 2\r\n } else {\r\n bytes.push(encoded.charCodeAt(i))\r\n }\r\n }\r\n return new Uint8Array(bytes)\r\n}\r\n\r\nfunction bytesToBase64(bytes) {\r\n if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {\r\n return uni.arrayBufferToBase64(bytes.buffer)\r\n }\r\n if (typeof btoa !== 'undefined') {\r\n let binary = ''\r\n for (let i = 0; i < bytes.length; i++) {\r\n binary += String.fromCharCode(bytes[i])\r\n }\r\n return btoa(binary)\r\n }\r\n const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\r\n let result = ''\r\n for (let i = 0; i < bytes.length; i += 3) {\r\n const a = bytes[i]\r\n const b = i + 1 < bytes.length ? bytes[i + 1] : 0\r\n const c = i + 2 < bytes.length ? bytes[i + 2] : 0\r\n result += chars[a >> 2]\r\n result += chars[((a & 3) << 4) | (b >> 4)]\r\n result += i + 1 < bytes.length ? chars[((b & 15) << 2) | (c >> 6)] : '='\r\n result += i + 2 < bytes.length ? chars[c & 63] : '='\r\n }\r\n return result\r\n}\r\n\r\nexport function svgToDataUrl(svg) {\r\n const normalized = String(svg || '').trim()\r\n if (!normalized) return ''\r\n const base64 = bytesToBase64(utf8ToBytes(normalized))\r\n return `data:image/svg+xml;base64,${base64}`\r\n}\r\n"],"names":["uni"],"mappings":";;AAIA,SAAS,YAAY,KAAK;AACtB,QAAM,UAAU,mBAAmB,GAAG;AACtC,QAAM,QAAQ,CAAE;AAChB,WAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACrC,QAAI,QAAQ,WAAW,CAAC,MAAM,IAAI;AAC9B,YAAM,KAAK,SAAS,QAAQ,UAAU,IAAI,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;AACxD,WAAK;AAAA,IACjB,OAAe;AACH,YAAM,KAAK,QAAQ,WAAW,CAAC,CAAC;AAAA,IACnC;AAAA,EACJ;AACD,SAAO,IAAI,WAAW,KAAK;AAC/B;AAEA,SAAS,cAAc,OAAO;AAC1B,MAAI,OAAOA,cAAG,UAAK,eAAe,OAAOA,cAAAA,MAAI,wBAAwB,YAAY;AAC7E,WAAOA,oBAAI,oBAAoB,MAAM,MAAM;AAAA,EAC9C;AACD,MAAI,OAAO,SAAS,aAAa;AAC7B,QAAI,SAAS;AACb,aAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACnC,gBAAU,OAAO,aAAa,MAAM,CAAC,CAAC;AAAA,IACzC;AACD,WAAO,KAAK,MAAM;AAAA,EACrB;AACD,QAAM,QAAQ;AACd,MAAI,SAAS;AACb,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK,GAAG;AACtC,UAAM,IAAI,MAAM,CAAC;AACjB,UAAM,IAAI,IAAI,IAAI,MAAM,SAAS,MAAM,IAAI,CAAC,IAAI;AAChD,UAAM,IAAI,IAAI,IAAI,MAAM,SAAS,MAAM,IAAI,CAAC,IAAI;AAChD,cAAU,MAAM,KAAK,CAAC;AACtB,cAAU,OAAQ,IAAI,MAAM,IAAM,KAAK,CAAE;AACzC,cAAU,IAAI,IAAI,MAAM,SAAS,OAAQ,IAAI,OAAO,IAAM,KAAK,CAAE,IAAI;AACrE,cAAU,IAAI,IAAI,MAAM,SAAS,MAAM,IAAI,EAAE,IAAI;AAAA,EACpD;AACD,SAAO;AACX;AAEO,SAAS,aAAa,KAAK;AAC9B,QAAM,aAAa,OAAO,OAAO,EAAE,EAAE,KAAM;AAC3C,MAAI,CAAC;AAAY,WAAO;AACxB,QAAM,SAAS,cAAc,YAAY,UAAU,CAAC;AACpD,SAAO,6BAA6B,MAAM;AAC9C;;"}
|
||||
{"version":3,"file":"svgDataUrl.js","sources":["tongji/utils/svgDataUrl.js"],"sourcesContent":["/**\r\n * 将 SVG 字符串转为小程序可用的 data URL(base64 在真机上更稳定)\r\n */\r\n\r\nfunction utf8ToBytes(str) {\r\n const encoded = encodeURIComponent(str)\r\n const bytes = []\r\n for (let i = 0; i < encoded.length; i++) {\r\n if (encoded.charCodeAt(i) === 37) {\r\n bytes.push(parseInt(encoded.substring(i + 1, i + 3), 16))\r\n i += 2\r\n } else {\r\n bytes.push(encoded.charCodeAt(i))\r\n }\r\n }\r\n return new Uint8Array(bytes)\r\n}\r\n\r\nfunction bytesToBase64(bytes) {\r\n if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {\r\n return uni.arrayBufferToBase64(bytes.buffer)\r\n }\r\n if (typeof btoa !== 'undefined') {\r\n let binary = ''\r\n for (let i = 0; i < bytes.length; i++) {\r\n binary += String.fromCharCode(bytes[i])\r\n }\r\n return btoa(binary)\r\n }\r\n const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\r\n let result = ''\r\n for (let i = 0; i < bytes.length; i += 3) {\r\n const a = bytes[i]\r\n const b = i + 1 < bytes.length ? bytes[i + 1] : 0\r\n const c = i + 2 < bytes.length ? bytes[i + 2] : 0\r\n result += chars[a >> 2]\r\n result += chars[((a & 3) << 4) | (b >> 4)]\r\n result += i + 1 < bytes.length ? chars[((b & 15) << 2) | (c >> 6)] : '='\r\n result += i + 2 < bytes.length ? chars[c & 63] : '='\r\n }\r\n return result\r\n}\r\n\r\nexport function svgToDataUrl(svg) {\r\n const normalized = String(svg || '').trim()\r\n if (!normalized) return ''\r\n const base64 = bytesToBase64(utf8ToBytes(normalized))\r\n return `data:image/svg+xml;base64,${base64}`\r\n}\r\n"],"names":["utf8ToBytes","str","encoded","bytes","i","bytesToBase64","uni","binary","chars","result","a","b","c","svgToDataUrl","svg","normalized"],"mappings":"uDAIA,SAASA,EAAYC,EAAK,CACtB,MAAMC,EAAU,mBAAmBD,CAAG,EAChCE,EAAQ,CAAE,EAChB,QAASC,EAAI,EAAGA,EAAIF,EAAQ,OAAQE,IAC5BF,EAAQ,WAAWE,CAAC,IAAM,IAC1BD,EAAM,KAAK,SAASD,EAAQ,UAAUE,EAAI,EAAGA,EAAI,CAAC,EAAG,EAAE,CAAC,EACxDA,GAAK,GAELD,EAAM,KAAKD,EAAQ,WAAWE,CAAC,CAAC,EAGxC,OAAO,IAAI,WAAWD,CAAK,CAC/B,CAEA,SAASE,EAAcF,EAAO,CAC1B,GAAI,OAAOG,EAAG,MAAK,KAAe,OAAOA,EAAAA,MAAI,qBAAwB,WACjE,OAAOA,QAAI,oBAAoBH,EAAM,MAAM,EAE/C,GAAI,OAAO,KAAS,IAAa,CAC7B,IAAII,EAAS,GACb,QAASH,EAAI,EAAGA,EAAID,EAAM,OAAQC,IAC9BG,GAAU,OAAO,aAAaJ,EAAMC,CAAC,CAAC,EAE1C,OAAO,KAAKG,CAAM,CACrB,CACD,MAAMC,EAAQ,mEACd,IAAIC,EAAS,GACb,QAASL,EAAI,EAAGA,EAAID,EAAM,OAAQC,GAAK,EAAG,CACtC,MAAMM,EAAIP,EAAMC,CAAC,EACXO,EAAIP,EAAI,EAAID,EAAM,OAASA,EAAMC,EAAI,CAAC,EAAI,EAC1CQ,EAAIR,EAAI,EAAID,EAAM,OAASA,EAAMC,EAAI,CAAC,EAAI,EAChDK,GAAUD,EAAME,GAAK,CAAC,EACtBD,GAAUD,GAAQE,EAAI,IAAM,EAAMC,GAAK,CAAE,EACzCF,GAAUL,EAAI,EAAID,EAAM,OAASK,GAAQG,EAAI,KAAO,EAAMC,GAAK,CAAE,EAAI,IACrEH,GAAUL,EAAI,EAAID,EAAM,OAASK,EAAMI,EAAI,EAAE,EAAI,GACpD,CACD,OAAOH,CACX,CAEO,SAASI,EAAaC,EAAK,CAC9B,MAAMC,EAAa,OAAOD,GAAO,EAAE,EAAE,KAAM,EAC3C,OAAKC,EAEE,6BADQV,EAAcL,EAAYe,CAAU,CAAC,CACV,GAFlB,EAG5B"}
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"treeSpecies.js","sources":["tongji/utils/treeSpecies.js"],"sourcesContent":["import { TREE_LEVELS, TREE_MAX_LEVEL, TREE_XP_PER_LEVEL } from './treeLevels.js'\r\n\r\n/** 可选树种:每级 emoji 与 treeLevels.js Lv.0~9 一一对应 */\r\nexport const TREE_SPECIES_LIST = [\r\n {\r\n id: 'classic',\r\n name: '稳糖灵树',\r\n tagline: '经典路线,从种子到圆满',\r\n stages: TREE_LEVELS.map((l) => l.emoji)\r\n },\r\n {\r\n id: 'bonsai',\r\n name: '雅韵盆景',\r\n tagline: '小巧精致,书桌旁的温柔陪伴',\r\n stages: ['🫘', '🌱', '🪴', '🎋', '🪴', '🌳', '🎍', '🌸', '🏵️', '🏆']\r\n },\r\n {\r\n id: 'pine',\r\n name: '苍劲青松',\r\n tagline: '挺拔向上,越记越稳',\r\n stages: ['🫘', '🌱', '🌿', '🌲', '🌲', '🌲', '⛰️', '🌲', '🏔️', '🏆']\r\n },\r\n {\r\n id: 'sakura',\r\n name: '樱花小树',\r\n tagline: '坚持打卡,枝头渐开',\r\n stages: ['🫘', '🌱', '🌿', '🌳', '🌸', '🌸', '🌸', '🌸', '🌺', '🏆']\r\n },\r\n {\r\n id: 'ginkgo',\r\n name: '金秋银杏',\r\n tagline: '叶色渐变,见证每一天',\r\n stages: ['🫘', '🌱', '🍃', '🌳', '🍂', '🍂', '🌳', '🍁', '🌟', '🏆']\r\n }\r\n]\r\n\r\nexport const TREE_SPECIES_STORAGE_KEY = 'tongji_tree_species_v1'\r\n\r\n/** 四项任务全勤时每日最多可领积分(与后端 taskDefs 一致) */\r\nexport const DAILY_MAX_TASK_POINTS = 40\r\n\r\nexport function getTreeSpecies(id) {\r\n return TREE_SPECIES_LIST.find((s) => s.id === id) || TREE_SPECIES_LIST[0]\r\n}\r\n\r\nexport function speciesEmojiAtLevel(speciesId, level) {\r\n const sp = getTreeSpecies(speciesId)\r\n const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))\r\n return sp.stages[lv] || sp.stages[0] || '🌱'\r\n}\r\n\r\n/** 当前树种成长路线图(供选择面板展示) */\r\nexport function speciesGrowthRoadmap(speciesId) {\r\n const sp = getTreeSpecies(speciesId)\r\n return TREE_LEVELS.map((meta, i) => {\r\n const xpTotal = i * TREE_XP_PER_LEVEL\r\n const daysHint = i === 0\r\n ? 0\r\n : Math.max(1, Math.ceil(xpTotal / DAILY_MAX_TASK_POINTS))\r\n return {\r\n level: i,\r\n name: meta.name,\r\n emoji: sp.stages[i] || meta.emoji,\r\n desc: meta.desc,\r\n xpTotal,\r\n daysHint\r\n }\r\n })\r\n}\r\n\r\n/** 全勤打卡大约多少天满级 */\r\nexport function estimateDaysToMaxLevel() {\r\n const totalXp = TREE_MAX_LEVEL * TREE_XP_PER_LEVEL\r\n return Math.max(1, Math.ceil(totalXp / DAILY_MAX_TASK_POINTS))\r\n}\r\n"],"names":["TREE_LEVELS","TREE_MAX_LEVEL","TREE_XP_PER_LEVEL"],"mappings":";;AAGY,MAAC,oBAAoB;AAAA,EAC7B;AAAA,IACI,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,SAAS;AAAA,IACT,QAAQA,wBAAW,YAAC,IAAI,CAAC,MAAM,EAAE,KAAK;AAAA,EACzC;AAAA,EACD;AAAA,IACI,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,SAAS;AAAA,IACT,QAAQ,CAAC,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,OAAO,IAAI;AAAA,EACvE;AAAA,EACD;AAAA,IACI,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,SAAS;AAAA,IACT,QAAQ,CAAC,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,OAAO,IAAI;AAAA,EACvE;AAAA,EACD;AAAA,IACI,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,SAAS;AAAA,IACT,QAAQ,CAAC,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,IAAI;AAAA,EACtE;AAAA,EACD;AAAA,IACI,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,SAAS;AAAA,IACT,QAAQ,CAAC,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,IAAI;AAAA,EACtE;AACL;AAKO,MAAM,wBAAwB;AAE9B,SAAS,eAAe,IAAI;AAC/B,SAAO,kBAAkB,KAAK,CAAC,MAAM,EAAE,OAAO,EAAE,KAAK,kBAAkB,CAAC;AAC5E;AAEO,SAAS,oBAAoB,WAAW,OAAO;AAClD,QAAM,KAAK,eAAe,SAAS;AACnC,QAAM,KAAK,KAAK,IAAIC,wBAAc,gBAAE,KAAK,IAAI,GAAG,OAAO,KAAK,KAAK,CAAC,CAAC;AACnE,SAAO,GAAG,OAAO,EAAE,KAAK,GAAG,OAAO,CAAC,KAAK;AAC5C;AAGO,SAAS,qBAAqB,WAAW;AAC5C,QAAM,KAAK,eAAe,SAAS;AACnC,SAAOD,oCAAY,IAAI,CAAC,MAAM,MAAM;AAChC,UAAM,UAAU,IAAIE,wBAAiB;AACrC,UAAM,WAAW,MAAM,IACjB,IACA,KAAK,IAAI,GAAG,KAAK,KAAK,UAAU,qBAAqB,CAAC;AAC5D,WAAO;AAAA,MACH,OAAO;AAAA,MACP,MAAM,KAAK;AAAA,MACX,OAAO,GAAG,OAAO,CAAC,KAAK,KAAK;AAAA,MAC5B,MAAM,KAAK;AAAA,MACX;AAAA,MACA;AAAA,IACH;AAAA,EACT,CAAK;AACL;AAGO,SAAS,yBAAyB;AACrC,QAAM,UAAUD,wBAAAA,iBAAiBC,wBAAiB;AAClD,SAAO,KAAK,IAAI,GAAG,KAAK,KAAK,UAAU,qBAAqB,CAAC;AACjE;;;;;;"}
|
||||
{"version":3,"file":"treeSpecies.js","sources":["tongji/utils/treeSpecies.js"],"sourcesContent":["import { TREE_LEVELS, TREE_MAX_LEVEL, TREE_XP_PER_LEVEL } from './treeLevels.js'\r\n\r\n/** 可选树种:每级 emoji 与 treeLevels.js Lv.0~9 一一对应 */\r\nexport const TREE_SPECIES_LIST = [\r\n {\r\n id: 'classic',\r\n name: '稳糖灵树',\r\n tagline: '经典路线,从种子到圆满',\r\n stages: TREE_LEVELS.map((l) => l.emoji)\r\n },\r\n {\r\n id: 'bonsai',\r\n name: '雅韵盆景',\r\n tagline: '小巧精致,书桌旁的温柔陪伴',\r\n stages: ['🫘', '🌱', '🪴', '🎋', '🪴', '🌳', '🎍', '🌸', '🏵️', '🏆']\r\n },\r\n {\r\n id: 'pine',\r\n name: '苍劲青松',\r\n tagline: '挺拔向上,越记越稳',\r\n stages: ['🫘', '🌱', '🌿', '🌲', '🌲', '🌲', '⛰️', '🌲', '🏔️', '🏆']\r\n },\r\n {\r\n id: 'sakura',\r\n name: '樱花小树',\r\n tagline: '坚持打卡,枝头渐开',\r\n stages: ['🫘', '🌱', '🌿', '🌳', '🌸', '🌸', '🌸', '🌸', '🌺', '🏆']\r\n },\r\n {\r\n id: 'ginkgo',\r\n name: '金秋银杏',\r\n tagline: '叶色渐变,见证每一天',\r\n stages: ['🫘', '🌱', '🍃', '🌳', '🍂', '🍂', '🌳', '🍁', '🌟', '🏆']\r\n }\r\n]\r\n\r\nexport const TREE_SPECIES_STORAGE_KEY = 'tongji_tree_species_v1'\r\n\r\n/** 四项任务全勤时每日最多可领积分(与后端 taskDefs 一致) */\r\nexport const DAILY_MAX_TASK_POINTS = 40\r\n\r\nexport function getTreeSpecies(id) {\r\n return TREE_SPECIES_LIST.find((s) => s.id === id) || TREE_SPECIES_LIST[0]\r\n}\r\n\r\nexport function speciesEmojiAtLevel(speciesId, level) {\r\n const sp = getTreeSpecies(speciesId)\r\n const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))\r\n return sp.stages[lv] || sp.stages[0] || '🌱'\r\n}\r\n\r\n/** 当前树种成长路线图(供选择面板展示) */\r\nexport function speciesGrowthRoadmap(speciesId) {\r\n const sp = getTreeSpecies(speciesId)\r\n return TREE_LEVELS.map((meta, i) => {\r\n const xpTotal = i * TREE_XP_PER_LEVEL\r\n const daysHint = i === 0\r\n ? 0\r\n : Math.max(1, Math.ceil(xpTotal / DAILY_MAX_TASK_POINTS))\r\n return {\r\n level: i,\r\n name: meta.name,\r\n emoji: sp.stages[i] || meta.emoji,\r\n desc: meta.desc,\r\n xpTotal,\r\n daysHint\r\n }\r\n })\r\n}\r\n\r\n/** 全勤打卡大约多少天满级 */\r\nexport function estimateDaysToMaxLevel() {\r\n const totalXp = TREE_MAX_LEVEL * TREE_XP_PER_LEVEL\r\n return Math.max(1, Math.ceil(totalXp / DAILY_MAX_TASK_POINTS))\r\n}\r\n"],"names":["TREE_SPECIES_LIST","TREE_LEVELS","l","DAILY_MAX_TASK_POINTS","getTreeSpecies","id","s","speciesEmojiAtLevel","speciesId","level","sp","lv","TREE_MAX_LEVEL","speciesGrowthRoadmap","meta","i","xpTotal","TREE_XP_PER_LEVEL","daysHint","estimateDaysToMaxLevel","totalXp"],"mappings":"gDAGaA,EAAoB,CAC7B,CACI,GAAI,UACJ,KAAM,OACN,QAAS,cACT,OAAQC,EAAW,YAAC,IAAKC,GAAMA,EAAE,KAAK,CACzC,EACD,CACI,GAAI,SACJ,KAAM,OACN,QAAS,gBACT,OAAQ,CAAC,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,MAAO,IAAI,CACvE,EACD,CACI,GAAI,OACJ,KAAM,OACN,QAAS,YACT,OAAQ,CAAC,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,MAAO,IAAI,CACvE,EACD,CACI,GAAI,SACJ,KAAM,OACN,QAAS,YACT,OAAQ,CAAC,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,IAAI,CACtE,EACD,CACI,GAAI,SACJ,KAAM,OACN,QAAS,aACT,OAAQ,CAAC,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,IAAI,CACtE,CACL,EAKaC,EAAwB,GAE9B,SAASC,EAAeC,EAAI,CAC/B,OAAOL,EAAkB,KAAMM,GAAMA,EAAE,KAAOD,CAAE,GAAKL,EAAkB,CAAC,CAC5E,CAEO,SAASO,EAAoBC,EAAWC,EAAO,CAClD,MAAMC,EAAKN,EAAeI,CAAS,EAC7BG,EAAK,KAAK,IAAIC,EAAc,eAAE,KAAK,IAAI,EAAG,OAAOH,CAAK,GAAK,CAAC,CAAC,EACnE,OAAOC,EAAG,OAAOC,CAAE,GAAKD,EAAG,OAAO,CAAC,GAAK,IAC5C,CAGO,SAASG,EAAqBL,EAAW,CAC5C,MAAME,EAAKN,EAAeI,CAAS,EACnC,OAAOP,cAAY,IAAI,CAACa,EAAMC,IAAM,CAChC,MAAMC,EAAUD,EAAIE,EAAiB,kBAC/BC,EAAWH,IAAM,EACjB,EACA,KAAK,IAAI,EAAG,KAAK,KAAKC,EAAUb,CAAqB,CAAC,EAC5D,MAAO,CACH,MAAOY,EACP,KAAMD,EAAK,KACX,MAAOJ,EAAG,OAAOK,CAAC,GAAKD,EAAK,MAC5B,KAAMA,EAAK,KACX,QAAAE,EACA,SAAAE,CACH,CACT,CAAK,CACL,CAGO,SAASC,GAAyB,CACrC,MAAMC,EAAUR,EAAAA,eAAiBK,EAAiB,kBAClD,OAAO,KAAK,IAAI,EAAG,KAAK,KAAKG,EAAUjB,CAAqB,CAAC,CACjE"}
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"uni-icons.js","sources":["uni_modules/uni-icons/components/uni-icons/uni-icons.vue","../../../soft/HBuilderX/plugins/uniapp-cli-vite/uniComponent:/RDovd2ViL3p5dC9Ub25namlVbmlBcHAvdW5pX21vZHVsZXMvdW5pLWljb25zL2NvbXBvbmVudHMvdW5pLWljb25zL3VuaS1pY29ucy52dWU"],"sourcesContent":["<template>\r\n\t<!-- #ifdef APP-NVUE -->\r\n\t<text :style=\"styleObj\" class=\"uni-icons\" @click=\"_onClick\">{{unicode}}</text>\r\n\t<!-- #endif -->\r\n\t<!-- #ifndef APP-NVUE -->\r\n\t<text :style=\"styleObj\" class=\"uni-icons\" :class=\"['uniui-'+type,customPrefix,customPrefix?type:'']\" @click=\"_onClick\">\r\n\t\t<slot></slot>\r\n\t</text>\r\n\t<!-- #endif -->\r\n</template>\r\n\r\n<script>\r\n\timport { fontData } from './uniicons_file_vue.js';\r\n\r\n\tconst getVal = (val) => {\r\n\t\tconst reg = /^[0-9]*$/g\r\n\t\treturn (typeof val === 'number' || reg.test(val)) ? val + 'px' : val;\r\n\t}\r\n\r\n\t// #ifdef APP-NVUE\r\n\tvar domModule = weex.requireModule('dom');\r\n\timport iconUrl from './uniicons.ttf'\r\n\tdomModule.addRule('fontFace', {\r\n\t\t'fontFamily': \"uniicons\",\r\n\t\t'src': \"url('\" + iconUrl + \"')\"\r\n\t});\r\n\t// #endif\r\n\r\n\t/**\r\n\t * Icons 图标\r\n\t * @description 用于展示 icons 图标\r\n\t * @tutorial https://ext.dcloud.net.cn/plugin?id=28\r\n\t * @property {Number} size 图标大小\r\n\t * @property {String} type 图标图案,参考示例\r\n\t * @property {String} color 图标颜色\r\n\t * @property {String} customPrefix 自定义图标\r\n\t * @event {Function} click 点击 Icon 触发事件\r\n\t */\r\n\texport default {\r\n\t\tname: 'UniIcons',\r\n\t\temits: ['click'],\r\n\t\tprops: {\r\n\t\t\ttype: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: ''\r\n\t\t\t},\r\n\t\t\tcolor: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: '#333333'\r\n\t\t\t},\r\n\t\t\tsize: {\r\n\t\t\t\ttype: [Number, String],\r\n\t\t\t\tdefault: 16\r\n\t\t\t},\r\n\t\t\tcustomPrefix: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: ''\r\n\t\t\t},\r\n\t\t\tfontFamily: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: ''\r\n\t\t\t}\r\n\t\t},\r\n\t\tdata() {\r\n\t\t\treturn {\r\n\t\t\t\ticons: fontData\r\n\t\t\t}\r\n\t\t},\r\n\t\tcomputed: {\r\n\t\t\tunicode() {\r\n\t\t\t\tlet code = this.icons.find(v => v.font_class === this.type)\r\n\t\t\t\tif (code) {\r\n\t\t\t\t\treturn code.unicode\r\n\t\t\t\t}\r\n\t\t\t\treturn ''\r\n\t\t\t},\r\n\t\t\ticonSize() {\r\n\t\t\t\treturn getVal(this.size)\r\n\t\t\t},\r\n\t\t\tstyleObj() {\r\n\t\t\t\tif (this.fontFamily !== '') {\r\n\t\t\t\t\treturn `color: ${this.color}; font-size: ${this.iconSize}; font-family: ${this.fontFamily};`\r\n\t\t\t\t}\r\n\t\t\t\treturn `color: ${this.color}; font-size: ${this.iconSize};`\r\n\t\t\t}\r\n\t\t},\r\n\t\tmethods: {\r\n\t\t\t_onClick() {\r\n\t\t\t\tthis.$emit('click')\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n</script>\r\n\r\n<style lang=\"scss\">\r\n\t/* #ifndef APP-NVUE */\r\n\t@import './uniicons.css';\r\n\r\n\t@font-face {\r\n\t\tfont-family: uniicons;\r\n\t\tsrc: url('./uniicons.ttf');\r\n\t}\r\n\r\n\t/* #endif */\r\n\t.uni-icons {\r\n\t\tfont-family: uniicons;\r\n\t\ttext-decoration: none;\r\n\t\ttext-align: center;\r\n\t}\r\n</style>\n","import Component from 'D:/web/zyt/TongjiUniApp/uni_modules/uni-icons/components/uni-icons/uni-icons.vue'\nwx.createComponent(Component)"],"names":["fontData"],"mappings":";;;AAcC,MAAM,SAAS,CAAC,QAAQ;AACvB,QAAM,MAAM;AACZ,SAAQ,OAAO,QAAQ,YAAY,IAAI,KAAK,GAAG,IAAK,MAAM,OAAO;AAClE;AAqBA,MAAK,YAAU;AAAA,EACd,MAAM;AAAA,EACN,OAAO,CAAC,OAAO;AAAA,EACf,OAAO;AAAA,IACN,MAAM;AAAA,MACL,MAAM;AAAA,MACN,SAAS;AAAA,IACT;AAAA,IACD,OAAO;AAAA,MACN,MAAM;AAAA,MACN,SAAS;AAAA,IACT;AAAA,IACD,MAAM;AAAA,MACL,MAAM,CAAC,QAAQ,MAAM;AAAA,MACrB,SAAS;AAAA,IACT;AAAA,IACD,cAAc;AAAA,MACb,MAAM;AAAA,MACN,SAAS;AAAA,IACT;AAAA,IACD,YAAY;AAAA,MACX,MAAM;AAAA,MACN,SAAS;AAAA,IACV;AAAA,EACA;AAAA,EACD,OAAO;AACN,WAAO;AAAA,MACN,OAAOA,2DAAO;AAAA,IACf;AAAA,EACA;AAAA,EACD,UAAU;AAAA,IACT,UAAU;AACT,UAAI,OAAO,KAAK,MAAM,KAAK,OAAK,EAAE,eAAe,KAAK,IAAI;AAC1D,UAAI,MAAM;AACT,eAAO,KAAK;AAAA,MACb;AACA,aAAO;AAAA,IACP;AAAA,IACD,WAAW;AACV,aAAO,OAAO,KAAK,IAAI;AAAA,IACvB;AAAA,IACD,WAAW;AACV,UAAI,KAAK,eAAe,IAAI;AAC3B,eAAO,UAAU,KAAK,KAAK,gBAAgB,KAAK,QAAQ,kBAAkB,KAAK,UAAU;AAAA,MAC1F;AACA,aAAO,UAAU,KAAK,KAAK,gBAAgB,KAAK,QAAQ;AAAA,IACzD;AAAA,EACA;AAAA,EACD,SAAS;AAAA,IACR,WAAW;AACV,WAAK,MAAM,OAAO;AAAA,IACnB;AAAA,EACD;AACD;;;;;;;;;;;AC1FD,GAAG,gBAAgB,SAAS;"}
|
||||
{"version":3,"file":"uni-icons.js","sources":["uni_modules/uni-icons/components/uni-icons/uni-icons.vue","../../../soft/HBuilderX/plugins/uniapp-cli-vite/uniComponent:/RDovd2ViL3p5dC9Ub25namlVbmlBcHAvdW5pX21vZHVsZXMvdW5pLWljb25zL2NvbXBvbmVudHMvdW5pLWljb25zL3VuaS1pY29ucy52dWU"],"sourcesContent":["<template>\r\n\t<!-- #ifdef APP-NVUE -->\r\n\t<text :style=\"styleObj\" class=\"uni-icons\" @click=\"_onClick\">{{unicode}}</text>\r\n\t<!-- #endif -->\r\n\t<!-- #ifndef APP-NVUE -->\r\n\t<text :style=\"styleObj\" class=\"uni-icons\" :class=\"['uniui-'+type,customPrefix,customPrefix?type:'']\" @click=\"_onClick\">\r\n\t\t<slot></slot>\r\n\t</text>\r\n\t<!-- #endif -->\r\n</template>\r\n\r\n<script>\r\n\timport { fontData } from './uniicons_file_vue.js';\r\n\r\n\tconst getVal = (val) => {\r\n\t\tconst reg = /^[0-9]*$/g\r\n\t\treturn (typeof val === 'number' || reg.test(val)) ? val + 'px' : val;\r\n\t}\r\n\r\n\t// #ifdef APP-NVUE\r\n\tvar domModule = weex.requireModule('dom');\r\n\timport iconUrl from './uniicons.ttf'\r\n\tdomModule.addRule('fontFace', {\r\n\t\t'fontFamily': \"uniicons\",\r\n\t\t'src': \"url('\" + iconUrl + \"')\"\r\n\t});\r\n\t// #endif\r\n\r\n\t/**\r\n\t * Icons 图标\r\n\t * @description 用于展示 icons 图标\r\n\t * @tutorial https://ext.dcloud.net.cn/plugin?id=28\r\n\t * @property {Number} size 图标大小\r\n\t * @property {String} type 图标图案,参考示例\r\n\t * @property {String} color 图标颜色\r\n\t * @property {String} customPrefix 自定义图标\r\n\t * @event {Function} click 点击 Icon 触发事件\r\n\t */\r\n\texport default {\r\n\t\tname: 'UniIcons',\r\n\t\temits: ['click'],\r\n\t\tprops: {\r\n\t\t\ttype: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: ''\r\n\t\t\t},\r\n\t\t\tcolor: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: '#333333'\r\n\t\t\t},\r\n\t\t\tsize: {\r\n\t\t\t\ttype: [Number, String],\r\n\t\t\t\tdefault: 16\r\n\t\t\t},\r\n\t\t\tcustomPrefix: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: ''\r\n\t\t\t},\r\n\t\t\tfontFamily: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: ''\r\n\t\t\t}\r\n\t\t},\r\n\t\tdata() {\r\n\t\t\treturn {\r\n\t\t\t\ticons: fontData\r\n\t\t\t}\r\n\t\t},\r\n\t\tcomputed: {\r\n\t\t\tunicode() {\r\n\t\t\t\tlet code = this.icons.find(v => v.font_class === this.type)\r\n\t\t\t\tif (code) {\r\n\t\t\t\t\treturn code.unicode\r\n\t\t\t\t}\r\n\t\t\t\treturn ''\r\n\t\t\t},\r\n\t\t\ticonSize() {\r\n\t\t\t\treturn getVal(this.size)\r\n\t\t\t},\r\n\t\t\tstyleObj() {\r\n\t\t\t\tif (this.fontFamily !== '') {\r\n\t\t\t\t\treturn `color: ${this.color}; font-size: ${this.iconSize}; font-family: ${this.fontFamily};`\r\n\t\t\t\t}\r\n\t\t\t\treturn `color: ${this.color}; font-size: ${this.iconSize};`\r\n\t\t\t}\r\n\t\t},\r\n\t\tmethods: {\r\n\t\t\t_onClick() {\r\n\t\t\t\tthis.$emit('click')\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n</script>\r\n\r\n<style lang=\"scss\">\r\n\t/* #ifndef APP-NVUE */\r\n\t@import './uniicons.css';\r\n\r\n\t@font-face {\r\n\t\tfont-family: uniicons;\r\n\t\tsrc: url('./uniicons.ttf');\r\n\t}\r\n\r\n\t/* #endif */\r\n\t.uni-icons {\r\n\t\tfont-family: uniicons;\r\n\t\ttext-decoration: none;\r\n\t\ttext-align: center;\r\n\t}\r\n</style>\n","import Component from 'D:/web/zyt/TongjiUniApp/uni_modules/uni-icons/components/uni-icons/uni-icons.vue'\nwx.createComponent(Component)"],"names":["getVal","val","_sfc_main","fontData","code","v","Component"],"mappings":"iGAcOA,EAAUC,GAEP,OAAOA,GAAQ,UADX,YAC2B,KAAKA,CAAG,EAAKA,EAAM,KAAOA,EAsB7DC,EAAU,CACd,KAAM,WACN,MAAO,CAAC,OAAO,EACf,MAAO,CACN,KAAM,CACL,KAAM,OACN,QAAS,EACT,EACD,MAAO,CACN,KAAM,OACN,QAAS,SACT,EACD,KAAM,CACL,KAAM,CAAC,OAAQ,MAAM,EACrB,QAAS,EACT,EACD,aAAc,CACb,KAAM,OACN,QAAS,EACT,EACD,WAAY,CACX,KAAM,OACN,QAAS,EACV,CACA,EACD,MAAO,CACN,MAAO,CACN,MAAOC,EAAO,QACf,CACA,EACD,SAAU,CACT,SAAU,CACT,IAAIC,EAAO,KAAK,MAAM,KAAKC,GAAKA,EAAE,aAAe,KAAK,IAAI,EAC1D,OAAID,EACIA,EAAK,QAEN,EACP,EACD,UAAW,CACV,OAAOJ,EAAO,KAAK,IAAI,CACvB,EACD,UAAW,CACV,OAAI,KAAK,aAAe,GAChB,UAAU,KAAK,KAAK,gBAAgB,KAAK,QAAQ,kBAAkB,KAAK,UAAU,IAEnF,UAAU,KAAK,KAAK,gBAAgB,KAAK,QAAQ,GACzD,CACA,EACD,QAAS,CACR,UAAW,CACV,KAAK,MAAM,OAAO,CACnB,CACD,CACD,0NC1FD,GAAG,gBAAgBM,CAAS"}
|
||||
@@ -1,2 +1,2 @@
|
||||
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const n=require("./common/vendor.js"),l={__name:"App",setup(e){const{proxy:o}=n.getCurrentInstance();n.onLaunch(()=>{try{n.index.setInnerAudioOption({obeyMuteSwitch:!1,mixWithOther:!0})}catch(r){n.index.__f__("warn","at App.vue:16","setInnerAudioOption failed:",r)}n.index.login({provider:"weixin",success:function(r){n.index.getStorageSync("token")?i(r.code):a(r.code)}})});const a=async r=>{try{const t=await o.apiUrl({url:"/api/login/mnpLogin",method:"POST",data:{code:r}},!1);t&&t.data&&t.data.token&&(n.index.setStorageSync("token",t.data.token),n.index.setStorageSync("userData",t.data))}catch{}},i=async r=>{try{const t=await o.apiUrl({url:"/api/user/info",method:"POST"},!1);if(t.code==-1){a(r);return}t&&t.data&&n.index.setStorageSync("userData",t.data)}catch{}};return()=>{}}};var d="https://admin.zhenyangtang.com.cn/";function f(e,o){const a=String(e||"").replace(/\/+$/,""),i=String(o||"").replace(/^\/+/,"");return i?`${a}/${i}`:a}function m(e,o=!0){const i={token:n.index.getStorageSync("token"),"content-type":"application/x-www-form-urlencoded",...e.header||{}},r=e.data||{},t=f(d,e.url||"");return new Promise((g,u)=>{o&&n.index.showLoading({title:"加载中...",mask:!0}),n.index.request({url:t,data:r,timeout:e.timeout!=null?e.timeout:15e3,method:e.method||"POST",header:i,sslVerify:!1,success:s=>{const c=s.data;c.code===-1&&n.index.removeStorageSync("token"),g(c)},fail:s=>{u(s)},complete:s=>{o&&n.index.hideLoading()}})})}function S(e){const o={};if(!e)return o;if(e.scene)try{decodeURIComponent(e.scene).split("&").forEach(i=>{const[r,t]=i.split("=");r&&t&&(o[r]=t)})}catch(a){n.index.__f__("error","at main.js:126","解析scene参数失败:",a)}return Object.keys(e).forEach(a=>{a!=="scene"&&e[a]&&(o[a]=e[a])}),o}function p(){const e=n.createSSRApp(l);return e.config.globalProperties.$url=d,e.config.globalProperties.apiUrl=m,e.config.globalProperties.$parsePageParams=S,{app:e}}p().app.mount("#app");exports.createApp=p;
|
||||
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const n=require("./common/vendor.js"),l={__name:"App",setup(e){const{proxy:o}=n.getCurrentInstance();n.onLaunch(()=>{try{n.index.setInnerAudioOption({obeyMuteSwitch:!1,mixWithOther:!0})}catch(r){n.index.__f__("warn","at App.vue:16","setInnerAudioOption failed:",r)}n.index.login({provider:"weixin",success:function(r){n.index.getStorageSync("token")?s(r.code):a(r.code)}})});const a=async r=>{try{const t=await o.apiUrl({url:"/api/login/mnpLogin",method:"POST",data:{code:r}},!1);t&&t.data&&t.data.token&&(n.index.setStorageSync("token",t.data.token),n.index.setStorageSync("userData",t.data))}catch{}},s=async r=>{try{const t=await o.apiUrl({url:"/api/user/info",method:"POST"},!1);if(t.code==-1){a(r);return}t&&t.data&&n.index.setStorageSync("userData",t.data)}catch{}};return()=>{}}};var d="https://xt.zhenyangtang.com.cn/";function f(e,o){const a=String(e||"").replace(/\/+$/,""),s=String(o||"").replace(/^\/+/,"");return s?`${a}/${s}`:a}function m(e,o=!0){const s={token:n.index.getStorageSync("token"),"content-type":"application/x-www-form-urlencoded",...e.header||{}},r=e.data||{},t=f(d,e.url||"");return new Promise((g,u)=>{o&&n.index.showLoading({title:"加载中...",mask:!0}),n.index.request({url:t,data:r,timeout:e.timeout!=null?e.timeout:15e3,method:e.method||"POST",header:s,sslVerify:!1,success:i=>{const c=i.data;c.code===-1&&n.index.removeStorageSync("token"),g(c)},fail:i=>{u(i)},complete:i=>{o&&n.index.hideLoading()}})})}function S(e){const o={};if(!e)return o;if(e.scene)try{decodeURIComponent(e.scene).split("&").forEach(s=>{const[r,t]=s.split("=");r&&t&&(o[r]=t)})}catch(a){n.index.__f__("error","at main.js:126","解析scene参数失败:",a)}return Object.keys(e).forEach(a=>{a!=="scene"&&e[a]&&(o[a]=e[a])}),o}function p(){const e=n.createSSRApp(l);return e.config.globalProperties.$url=d,e.config.globalProperties.apiUrl=m,e.config.globalProperties.$parsePageParams=S,{app:e}}p().app.mount("#app");exports.createApp=p;
|
||||
//# sourceMappingURL=../.sourcemap/mp-weixin/app.js.map
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"compileType": "miniprogram",
|
||||
"libVersion": "",
|
||||
"appid": "wx79b9a0bfbfe7cbcd",
|
||||
"appid": "wx0c2a9d5ebad26a3f",
|
||||
"projectname": "tongji-uniapp",
|
||||
"condition": {
|
||||
"search": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,21 +235,115 @@ 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>
|
||||
}
|
||||
}
|
||||
|
||||
export interface WecomPromotionBatchUpdatePoolResult {
|
||||
id: number
|
||||
name: string
|
||||
success: boolean
|
||||
sync_error?: string
|
||||
sync_queued?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface WecomPromotionBatchUpdatePoolsResult {
|
||||
pool_ids: number[]
|
||||
updated: number
|
||||
failed: number
|
||||
sync_error_count: number
|
||||
sync_queued_count: 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' })
|
||||
@@ -230,27 +369,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 +399,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,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 })
|
||||
}
|
||||
@@ -578,6 +578,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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -168,6 +168,7 @@ export function logActionText(act: string) {
|
||||
ship: '确认发货',
|
||||
withdraw: '撤销',
|
||||
link_pay_order: '关联支付单',
|
||||
unlink_pay_order: '移除收款关联',
|
||||
completion_request: '完单申请',
|
||||
auto_complete: '自动完成',
|
||||
revoke_rx_audit: '撤回处方审核',
|
||||
|
||||
@@ -331,9 +331,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',
|
||||
|
||||
@@ -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_name(admin.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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -436,19 +437,25 @@ 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,
|
||||
diagnosis_id: diagnosisId
|
||||
})
|
||||
chatDialogRef.value?.open({
|
||||
patientId: row.patient_id,
|
||||
patientId: sourcePatientId,
|
||||
patientName: row.patient_name,
|
||||
diagnosisId: row.diagnosis_id || row.id,
|
||||
diagnosisId,
|
||||
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-256。Windows 推荐使用
|
||||
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,最大
|
||||
2GB)。Windows 自动安装程序必须使用 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>
|
||||
@@ -996,34 +996,34 @@ 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
|
||||
})
|
||||
|
||||
console.log('获取聊天签名成功:', res)
|
||||
|
||||
// 直接打开聊天对话框,传入必要的参数
|
||||
chatDialogRef.value?.open({
|
||||
patientId: sourcePatientId,
|
||||
patientName: row.patient_name,
|
||||
diagnosisId,
|
||||
signatureData: res // 传入签名数据
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('获取聊天签名失败:', error)
|
||||
feedback.msgError(error.message || '获取聊天签名失败')
|
||||
|
||||
@@ -642,22 +642,28 @@ 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
|
||||
})
|
||||
chatDialogRef.value?.open({
|
||||
patientId: sourcePatientId,
|
||||
patientName: row.patient_name,
|
||||
diagnosisId,
|
||||
signatureData: res
|
||||
})
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.msg || e?.message || '获取通话签名失败')
|
||||
}
|
||||
|
||||
@@ -262,9 +262,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',
|
||||
|
||||
@@ -1775,9 +1775,31 @@ const appointmentCellClasses = (row: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅已预约(1)可进视频/小程序码 */
|
||||
const isAppointmentActiveForVideo = (row: any) =>
|
||||
row.has_appointment && Number(row.appointment_status) === 1
|
||||
/**
|
||||
* 找到当前可用的已预约挂号。
|
||||
* 同一诊单当天可能先完成一条挂号、随后又新增一条预约,此时行级 appointment_* 仍可能指向旧记录。
|
||||
*/
|
||||
const activeAppointment = (row: any) =>
|
||||
appointmentRows(row).find((apt: any) => Number(apt?.status) === 1) ?? null
|
||||
|
||||
/** 二维码必须使用已预约挂号对应的医生和时间,不能继续沿用行级旧挂号字段。 */
|
||||
const activeAppointmentRow = (row: any) => {
|
||||
const apt = activeAppointment(row)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/** 任一挂号记录处于已预约(1)即可进视频/小程序码。 */
|
||||
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row)
|
||||
|
||||
/** 已预约、已过号可取消(后端同步限制),针对行上主字段 */
|
||||
const canCancelAppointmentRow = (row: any) => {
|
||||
@@ -1962,17 +1984,18 @@ 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)
|
||||
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 +2006,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 +2027,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 +2057,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 || ''
|
||||
})
|
||||
|
||||
|
||||
@@ -946,8 +946,28 @@ const appointmentRowClass = (row: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const isAppointmentActiveForVideo = (row: any) =>
|
||||
row.has_appointment && Number(row.appointment_status) === 1
|
||||
/**
|
||||
* 同一诊单可能同时有已完成的旧挂号和已预约的新挂号,二维码应使用明细里的有效预约。
|
||||
*/
|
||||
const activeAppointment = (row: any) =>
|
||||
appointmentRows(row).find((apt: any) => Number(apt?.status) === 1) ?? null
|
||||
|
||||
const activeAppointmentRow = (row: any) => {
|
||||
const apt = activeAppointment(row)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row)
|
||||
|
||||
const canCancelAppointmentRow = (row: any) => {
|
||||
const s = Number(row.appointment_status)
|
||||
@@ -1241,13 +1261,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)
|
||||
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 +1279,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 +1296,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 +1314,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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
.env
|
||||
.venv/
|
||||
.venv-build/
|
||||
.venv-win7-check/
|
||||
.build-tools/
|
||||
.uv-cache/
|
||||
.uv-python/
|
||||
__pycache__/
|
||||
|
||||
@@ -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%
|
||||
|
||||
@@ -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 1809;Qt 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 拦截未签名内部测试版,请使用右键“打开”。
|
||||
|
||||
## 已实现范围
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 106 KiB |
@@ -0,0 +1,39 @@
|
||||
# AiConsultDialog 诊单工作区最终验收
|
||||
|
||||
日期:2026-08-18
|
||||
范围:诊单 `501`;复用 `tests/test_ai_consult_workspace_ui.py` 的 `WorkspaceRepository` 与即时异步执行方式;只读验收,未修改业务源码或测试。
|
||||
|
||||
## 结论
|
||||
|
||||
内容、控件数量、安全过滤与横向几何均通过。四个页签均有非空中文内容,没有出现 `['...']`、`{'...'}` 等 Python 集合 repr。默认窗口 `1280 × 820` 和最小窗口 `1080 × 680` 下,四页签横向滚动最大值均为 `0`,内容宽度等于视口宽度,未发现后代控件越界。
|
||||
|
||||
存在 1 类状态提示问题:测试仓储故意返回外诊单数据时,检查检验页和健康档案页已正确过滤这些数据,但把“已拒绝 1 条归属其他诊单的数据”呈现为红色 `error` 状态并显示“重新加载”。有效内容仍完整可见,安全边界也生效;建议后续改成 warning/info 提示,避免被误解为数据加载失败。
|
||||
|
||||
## 截图
|
||||
|
||||
- `01_case_records.png`:病历资料
|
||||
- `02_exam_tests.png`:检查检验
|
||||
- `03_prescriptions.png`:处方记录
|
||||
- `04_health_profile.png`:健康档案
|
||||
|
||||
截图均为 `1280 × 820`,按真实应用启动路径调用全局主题,字体为 `Microsoft YaHei UI`。
|
||||
|
||||
## 内容与控件核验
|
||||
|
||||
| 页签 | 核验结果 |
|
||||
| --- | --- |
|
||||
| 病历资料 | 文本 969 字符;`AiConsultCaseGrid` 1 个;中文集合已人类可读化;Python repr 标记 0 个。 |
|
||||
| 检查检验 | 文本 173 字符;时间线 1 个;舌苔缩略图 1 个;附件按钮 3 个,其中 PDF 报告 2 个;安全 HTTP(S) 按钮可用,本地 `file:` 附件按钮禁用。 |
|
||||
| 处方记录 | 文本 316 字符;处方卡 3 张;详情按钮 3 个;处方 ID 为 `5011 / 5012 / 5013`。 |
|
||||
| 健康档案 | 文本 443 字符;患者信息网格 1 个;本诊单健康概览 1 个;血糖/血压、饮食、运动三类跟踪记录均存在;手机号和身份证号已脱敏。 |
|
||||
|
||||
## 几何
|
||||
|
||||
- `1280 × 820`:记录面板约 `876 × 586`;有纵向滚动条时视口/内容宽度均为 `824`,检查检验页为 `834`;所有页签 `hbar maximum = 0`、后代越界量 `0`。
|
||||
- `1080 × 680`:记录面板 `676 × 446`;有纵向滚动条时视口/内容宽度均为 `624`,检查检验页为 `634`;所有页签 `hbar maximum = 0`、后代越界量 `0`。
|
||||
- 长内容通过纵向滚动呈现:病历、处方、健康档案的默认窗口纵向最大值分别为 `1064 / 215 / 455`;未发现横向裁切。
|
||||
|
||||
## Smoke
|
||||
|
||||
执行:`uv run --offline pytest -q tests/test_ai_consult_workspace_ui.py`
|
||||
结果:`11 passed`。仅有 pytest 缓存目录无写权限警告,不影响测试结果。
|
||||
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 149 KiB |
@@ -8,6 +8,21 @@ Both build scripts launch the frozen executable twice. `--media-smoke-test` is h
|
||||
|
||||
Run the build on the target operating system. PyInstaller cannot cross-build Windows and macOS artifacts.
|
||||
|
||||
## Brand assets
|
||||
|
||||
The approved source artwork is kept byte-for-byte at `resources/branding/brand-master.png`.
|
||||
`app-icon.png`, the multi-size Windows `app-icon.ico`, the macOS `app-icon.icns`,
|
||||
the login-page `brand-lockup.png`, and the video companion favicon are deterministic
|
||||
derivatives of that master. Regenerate them after replacing the approved master:
|
||||
|
||||
```powershell
|
||||
uv run --no-project --with pillow==11.3.0 python scripts/generate_brand_assets.py
|
||||
```
|
||||
|
||||
The full lockup is reserved for large brand placements. Window, taskbar, shortcut,
|
||||
installer, uninstaller, Dock, and browser icons use the text-free pictorial mark so
|
||||
the identity remains legible at 16–64 pixels.
|
||||
|
||||
## Windows
|
||||
|
||||
```powershell
|
||||
@@ -16,7 +31,15 @@ Run the build on the target operating system. PyInstaller cannot cross-build Win
|
||||
|
||||
The default interpreter is `.venv-build\Scripts\python.exe`; override it with `-Python C:\path\to\python.exe`.
|
||||
|
||||
For the one-click release ZIP and SHA-256 manifest, run `Build_DoctorWorkstation.bat`. It prepares locked dependencies, invokes the build/file/smoke gates, and archives only after all gates pass.
|
||||
For the one-click Windows installer, portable ZIP, and SHA-256 manifest, run `Build_DoctorWorkstation.bat` (or double-click `一键打包_医生工作站.bat`). It prepares locked dependencies, invokes the build/file/smoke gates, then compiles `DoctorWorkstation-Setup-Windows-x64-<version>.exe` with a pinned Inno Setup compiler. The compiler is downloaded from the official release on first use and accepted only after both its pinned SHA-256 and Authenticode signer pass validation. The portable ZIP is retained as a secondary artifact.
|
||||
|
||||
The current Python 3.11+/PySide6/Qt 6 runtime requires Windows 10 version 1809 or newer, so the installer declares `MinVersion=10.0.17763`. Do not lower that installer value to claim Windows 7 compatibility: Qt 6 does not support Windows 7. A real Windows 7 SP1 build requires a separately maintained legacy runtime (Python 3.8.10, PySide2/Qt 5.15, and a compatible freezer), plus clean Windows 7 SP1 VM validation for QtWebEngine, multimedia, installation, upgrade, and uninstall behavior.
|
||||
|
||||
After packaging, `scripts/smoke_windows_installer.ps1` silently installs the newest Setup artifact for the current user into an isolated temporary directory, runs the installed executable's smoke gate, silently uninstalls it, and verifies that the executable was removed. It intentionally retains only its small logs and isolated user-data directory under `%TEMP%` for diagnosis.
|
||||
|
||||
To publish an automatic Windows update, open **System settings → Doctor workstation update** in the admin site and select `Windows installer (Inno Setup EXE)`. Upload the generated `DoctorWorkstation-Setup-Windows-x64-<version>.exe` (or use an HTTPS CDN URL), then copy its SHA-256, byte size, and filename from the packaging output. New clients download and verify the installer, close themselves, run Inno Setup silently, and restart only after the installer succeeds. Existing ZIP metadata remains supported for older releases.
|
||||
|
||||
Automatic installer execution requires an HTTPS download with certificate verification (loopback development URLs are the only exception). Production Setup artifacts should also be Authenticode-signed before publication; the current local build can create an unsigned installer when no organization signing certificate is configured.
|
||||
|
||||
## macOS
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ their platform media backends.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -20,16 +21,59 @@ SOURCE_ROOT = PROJECT_ROOT / "src"
|
||||
ENTRY_POINT = SOURCE_ROOT / "doctor_workstation" / "__main__.py"
|
||||
VIDEO_DIST = PROJECT_ROOT / "video_companion" / "dist"
|
||||
RESOURCES = PROJECT_ROOT / "resources"
|
||||
WINDOWS_ICON = RESOURCES / "branding" / "app-icon.ico"
|
||||
MACOS_ICON = RESOURCES / "branding" / "app-icon.icns"
|
||||
ENTITLEMENTS = PROJECT_ROOT / "packaging" / "macos" / "entitlements.plist"
|
||||
VERSION_FILE = PROJECT_ROOT / "packaging" / "windows" / "version_info.txt"
|
||||
VERSION_SOURCE = SOURCE_ROOT / "doctor_workstation" / "__init__.py"
|
||||
VERSION_TEMPLATE = PROJECT_ROOT / "packaging" / "windows" / "version_info.template.txt"
|
||||
MEDIA_SMOKE_HOOK = PROJECT_ROOT / "packaging" / "runtime_media_smoke.py"
|
||||
|
||||
|
||||
def read_application_version():
|
||||
source = VERSION_SOURCE.read_text(encoding="utf-8")
|
||||
match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', source, re.MULTILINE)
|
||||
if not match:
|
||||
raise SystemExit(f"Application version is missing from {VERSION_SOURCE}")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def windows_version_tuple(version):
|
||||
match = re.match(r"^(\d+(?:\.\d+){0,3})", version)
|
||||
if not match:
|
||||
raise SystemExit(f"Application version is invalid for Windows resources: {version}")
|
||||
parts = [int(part) for part in match.group(1).split(".")]
|
||||
return tuple((parts + [0, 0, 0, 0])[:4])
|
||||
|
||||
|
||||
def generate_windows_version_file(version):
|
||||
template = VERSION_TEMPLATE.read_text(encoding="utf-8")
|
||||
version_tuple = ", ".join(str(part) for part in windows_version_tuple(version))
|
||||
rendered = template.replace("@VERSION_TUPLE@", version_tuple)
|
||||
rendered = rendered.replace("@VERSION_STRING@", version)
|
||||
output = PROJECT_ROOT / "build" / "generated" / "version_info.txt"
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(rendered, encoding="utf-8")
|
||||
return output
|
||||
|
||||
|
||||
APP_VERSION = read_application_version()
|
||||
if sys.platform == "win32":
|
||||
if not VERSION_TEMPLATE.is_file():
|
||||
raise SystemExit(f"Windows version template is missing: {VERSION_TEMPLATE}")
|
||||
VERSION_FILE = generate_windows_version_file(APP_VERSION)
|
||||
else:
|
||||
VERSION_FILE = None
|
||||
|
||||
if not ENTRY_POINT.is_file():
|
||||
raise SystemExit(f"Application entry point is missing: {ENTRY_POINT}")
|
||||
if not (VIDEO_DIST / "index.html").is_file():
|
||||
raise SystemExit("Build video_companion before running PyInstaller")
|
||||
if not MEDIA_SMOKE_HOOK.is_file():
|
||||
raise SystemExit(f"Frozen multimedia smoke hook is missing: {MEDIA_SMOKE_HOOK}")
|
||||
if sys.platform == "win32" and not WINDOWS_ICON.is_file():
|
||||
raise SystemExit(f"Windows application icon is missing: {WINDOWS_ICON}")
|
||||
if sys.platform == "darwin" and not MACOS_ICON.is_file():
|
||||
raise SystemExit(f"macOS application icon is missing: {MACOS_ICON}")
|
||||
|
||||
# Some Windows developer tools add an unrelated OpenSSL installation to PATH.
|
||||
# PyInstaller's dependency scanner would then pair Python's ``_ssl.pyd`` with
|
||||
@@ -98,9 +142,10 @@ exe = EXE(
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
icon=str(WINDOWS_ICON) if sys.platform == "win32" else None,
|
||||
codesign_identity=os.environ.get("MACOS_CODESIGN_IDENTITY") if is_macos else None,
|
||||
entitlements_file=str(ENTITLEMENTS) if is_macos else None,
|
||||
version=str(VERSION_FILE) if sys.platform == "win32" else None,
|
||||
version=str(VERSION_FILE) if VERSION_FILE else None,
|
||||
)
|
||||
|
||||
collection = COLLECT(
|
||||
@@ -116,7 +161,7 @@ if is_macos:
|
||||
app = BUNDLE(
|
||||
collection,
|
||||
name="DoctorWorkstation.app",
|
||||
icon=None,
|
||||
icon=str(MACOS_ICON),
|
||||
bundle_identifier="com.zyt.doctor-workstation",
|
||||
info_plist={
|
||||
"CFBundleDisplayName": "甄养堂医生工作站",
|
||||
|
||||