Compare commits
300
Commits
d-4-30
...
master-6-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be15531ab3 | ||
|
|
0a781bda54 | ||
|
|
cbf992d6df | ||
|
|
7aec1a9713 | ||
|
|
01bc355bed | ||
|
|
1b746b46c0 | ||
|
|
ceb313b1a7 | ||
|
|
576ab05681 | ||
|
|
132a17b946 | ||
|
|
75dfaa0dcd | ||
|
|
e83dd07553 | ||
|
|
c589bfa647 | ||
|
|
17ad153821 | ||
|
|
e4312ab5d1 | ||
|
|
7f4fad7b62 | ||
|
|
9b5cd5b2b9 | ||
|
|
94e787ae80 | ||
|
|
7b46204454 | ||
|
|
9f3db8f281 | ||
|
|
6cb8688587 | ||
|
|
0e5b5beb9c | ||
|
|
8d635bb7ed | ||
|
|
0f974be7a1 | ||
|
|
2b1ce61e72 | ||
|
|
6efc340bd8 | ||
|
|
c435e6ae4e | ||
|
|
dfb8838ecc | ||
|
|
7f08771dd0 | ||
|
|
afd88714ec | ||
|
|
bb21825c16 | ||
|
|
c3e4dfa479 | ||
|
|
33f8f669ad | ||
|
|
3ebe3c58c8 | ||
|
|
0225e9f971 | ||
|
|
4f066b560e | ||
|
|
13f58a5fdc | ||
|
|
36be8fedad | ||
|
|
b9d2541b32 | ||
|
|
261f8315de | ||
|
|
ffe4849c27 | ||
|
|
19af50c344 | ||
|
|
012830de4a | ||
|
|
8b0fcd7050 | ||
|
|
57e4892140 | ||
|
|
95fff1262b | ||
|
|
0dd9cdcba9 | ||
|
|
5b233fb0ab | ||
|
|
3325be0622 | ||
|
|
9af5c5be63 | ||
|
|
0e46bf8e0d | ||
|
|
b93313d935 | ||
|
|
c1dfb01f53 | ||
|
|
3f999b312d | ||
|
|
50abe5dece | ||
|
|
58c224a081 | ||
|
|
f718df833e | ||
|
|
f068acf390 | ||
|
|
769d912eef | ||
|
|
5aa0cda252 | ||
|
|
552da3aec2 | ||
|
|
ed5285b58a | ||
|
|
0d35d1b6c3 | ||
|
|
27c2ada4ec | ||
|
|
6be5b4c45d | ||
|
|
fbf3d47b2a | ||
|
|
82c4d7e0a2 | ||
|
|
867a6a97cf | ||
|
|
a3ee72b34d | ||
|
|
a08031b901 | ||
|
|
665481a6d8 | ||
|
|
30b344b776 | ||
|
|
e104249707 | ||
|
|
9b6d9ce007 | ||
|
|
69da8066a6 | ||
|
|
edc9993403 | ||
|
|
8f14f3190e | ||
|
|
71a3c63182 | ||
|
|
d10678763f | ||
|
|
2beace89f4 | ||
|
|
e5d0d7db93 | ||
|
|
35a42078ce | ||
|
|
e8e28d19fb | ||
|
|
163e40c73f | ||
|
|
d46cfff079 | ||
|
|
275a7a550d | ||
|
|
5430fe0417 | ||
|
|
f314e2e271 | ||
|
|
a4fb55d164 | ||
|
|
295c8f0623 | ||
|
|
4b749a1ee0 | ||
|
|
679e61057b | ||
|
|
7f7b8f0a64 | ||
|
|
7e94c5dfd7 | ||
|
|
003810114b | ||
|
|
37c5849103 | ||
|
|
55521b8e11 | ||
|
|
e4f181e4fe | ||
|
|
bba989c0af | ||
|
|
d5e0ba04b7 | ||
|
|
22ec53c070 | ||
|
|
183b6a1acf | ||
|
|
49c8c95966 | ||
|
|
c7c264497e | ||
|
|
436f69c272 | ||
|
|
dc72a8ccaf | ||
|
|
499c969233 | ||
|
|
3b44300216 | ||
|
|
8bb7b5d30a | ||
|
|
4c3becf33e | ||
|
|
a42fc6453a | ||
|
|
dad06304f1 | ||
|
|
03b80310f6 | ||
|
|
8d015fa962 | ||
|
|
f639ef2fbb | ||
|
|
ea8e6d7c1b | ||
|
|
ba1c067dce | ||
|
|
5e96fda88f | ||
|
|
076cd0fcf9 | ||
|
|
44e97861d2 | ||
|
|
93e6d02ce4 | ||
|
|
60cb2b80cd | ||
|
|
752eddb0ba | ||
|
|
61d14d1b45 | ||
|
|
a83c583a28 | ||
|
|
485ba7a380 | ||
|
|
1005859404 | ||
|
|
ea6c73c063 | ||
|
|
3e4039efb0 | ||
|
|
c9ad4ae2ed | ||
|
|
abf8026d45 | ||
|
|
26b442b5da | ||
|
|
1cb00e9fbe | ||
|
|
9208e7eaaa | ||
|
|
a1392f9491 | ||
|
|
2ea0e1d54b | ||
|
|
d96fa6f4a1 | ||
|
|
0fb51e12d1 | ||
|
|
4f9494db0b | ||
|
|
4e87b080a0 | ||
|
|
2f3038aba1 | ||
|
|
131fb7d9c7 | ||
|
|
762a724204 | ||
|
|
8090e0aab3 | ||
|
|
9df6330d41 | ||
|
|
fd63e23eaf | ||
|
|
c1143f4629 | ||
|
|
149fdffd49 | ||
|
|
439d5a3ec9 | ||
|
|
9e24d49210 | ||
|
|
90ac43fc82 | ||
|
|
8b6b709987 | ||
|
|
fb6ca9ce85 | ||
|
|
08290b329e | ||
|
|
1d66f84bd3 | ||
|
|
a2d9c1a03f | ||
|
|
d79db88349 | ||
|
|
9f5fb650af | ||
|
|
15b4339c90 | ||
|
|
5ce331394d | ||
|
|
7ba9dabdb4 | ||
|
|
611f3dcd5c | ||
|
|
d3388b160e | ||
|
|
f83d4c06cb | ||
|
|
4941ea3e21 | ||
|
|
e35696e153 | ||
|
|
800c6af7f1 | ||
|
|
e1df584ed5 | ||
|
|
876ef0f8a0 | ||
|
|
928f75e016 | ||
|
|
37fa160c24 | ||
|
|
0eaacbb4ce | ||
|
|
1e8b5c4646 | ||
|
|
fdcfa30810 | ||
|
|
fabaa84373 | ||
|
|
da017bdf20 | ||
|
|
5d94ffab3e | ||
|
|
796ca12fb8 | ||
|
|
4caa98af3a | ||
|
|
c644f2554f | ||
|
|
bb87e4fb4c | ||
|
|
e83dac756d | ||
|
|
0c1709d589 | ||
|
|
a3892001d3 | ||
|
|
039105580c | ||
|
|
f47f431bdf | ||
|
|
7add364e20 | ||
|
|
0ebea74fde | ||
|
|
1156787327 | ||
|
|
15cf6324f6 | ||
|
|
1338b8735c | ||
|
|
82f229adf2 | ||
|
|
048e43a662 | ||
|
|
d8d4da197e | ||
|
|
a0325a8fb1 | ||
|
|
091db50841 | ||
|
|
1774a87dd7 | ||
|
|
62d15efbaf | ||
|
|
c468c57cda | ||
|
|
bacdd6386f | ||
|
|
ea290c4c7b | ||
|
|
31b4afdcb7 | ||
|
|
379d2ef404 | ||
|
|
5cdb7ebb9e | ||
|
|
496a2b870b | ||
|
|
471bdf12b7 | ||
|
|
8fe4683e64 | ||
|
|
c34409edde | ||
|
|
0a37335f3c | ||
|
|
985916dea7 | ||
|
|
3330613254 | ||
|
|
3f12829204 | ||
|
|
f28500e449 | ||
|
|
fd9053a290 | ||
|
|
dc1c5d5b17 | ||
|
|
1297d9f510 | ||
|
|
daaf6c4a14 | ||
|
|
68e2e0b7d7 | ||
|
|
c78ba50331 | ||
|
|
0026a92697 | ||
|
|
ee1b140902 | ||
|
|
f81a3b5d94 | ||
|
|
8719cdd6d6 | ||
|
|
33fb055bfe | ||
|
|
14735af0b3 | ||
|
|
623bda2ecb | ||
|
|
dfbb6d82b3 | ||
|
|
7473a3e03d | ||
|
|
eb9b31f8f5 | ||
|
|
f20a45a1ad | ||
|
|
0f66d1438f | ||
|
|
43110e015d | ||
|
|
7d7a4aa4d2 | ||
|
|
8c640abb26 | ||
|
|
ffabe78942 | ||
|
|
81fcef2156 | ||
|
|
4dc9ec1878 | ||
|
|
3f05a610b3 | ||
|
|
6c997559a2 | ||
|
|
d39b1ed7ee | ||
|
|
0e81fb5037 | ||
|
|
36847ea70c | ||
|
|
9b40ebb8b7 | ||
|
|
9948bc28df | ||
|
|
a9474356ff | ||
|
|
4e23c4bf79 | ||
|
|
b6e6394e51 | ||
|
|
576ef8e033 | ||
|
|
a52380aa63 | ||
|
|
49e7169630 | ||
|
|
d53f492d2a | ||
|
|
3d3abff842 | ||
|
|
90ac63c905 | ||
|
|
320d3783ac | ||
|
|
5966049020 | ||
|
|
755491c263 | ||
|
|
79203d948e | ||
|
|
01d56f3b4f | ||
|
|
477fcf6180 | ||
|
|
3014a99db5 | ||
|
|
c4dd196e72 | ||
|
|
581c74f11a | ||
|
|
7a2f285442 | ||
|
|
375f54263a | ||
|
|
ed4a4c4960 | ||
|
|
9d405ebed9 | ||
|
|
a7fd55aa1c | ||
|
|
c389fea73c | ||
|
|
05e8f4ddd7 | ||
|
|
da9a493e8d | ||
|
|
64ab3197ec | ||
|
|
dddc4d202a | ||
|
|
71180d4fd8 | ||
|
|
1942387050 | ||
|
|
77c1fc908d | ||
|
|
ef4800e82a | ||
|
|
815ea9e025 | ||
|
|
f7ce3d18d8 | ||
|
|
de78f3b218 | ||
|
|
671c7f16bd | ||
|
|
30b5a13f3a | ||
|
|
c830008dfc | ||
|
|
919d0f856e | ||
|
|
49d0fed14e | ||
|
|
19bfeef98f | ||
|
|
cd51f55701 | ||
|
|
17cb0e8319 | ||
|
|
3ef10846b4 | ||
|
|
40eec808ef | ||
|
|
03db6d2677 | ||
|
|
c03e7051d7 | ||
|
|
d6e105f912 | ||
|
|
0da27ed745 | ||
|
|
1d6a2b9cbf | ||
|
|
28fbbd28b7 | ||
|
|
22b8b38b5d | ||
|
|
81b2808eaf | ||
|
|
25eb3340de | ||
|
|
c9150ca4b4 | ||
|
|
b9165f6b1b | ||
|
|
e0ab9ebc4b |
@@ -11,7 +11,12 @@
|
||||
"Bash(php think *)",
|
||||
"Bash(python3 -c ' *)",
|
||||
"Bash(python3 *)",
|
||||
"Bash(awk '/<template>/,/<\\\\/template>/{print NR\": \"$0; if\\(/<\\\\/template>/\\) exit}' D:/web/zyt/admin/src/views/consumer/prescription/order_list.vue)"
|
||||
"Bash(awk '/<template>/,/<\\\\/template>/{print NR\": \"$0; if\\(/<\\\\/template>/\\) exit}' D:/web/zyt/admin/src/views/consumer/prescription/order_list.vue)",
|
||||
"Bash(npx eslint *)",
|
||||
"Bash(git *)",
|
||||
"Bash(tail -c 100000 /Users/long/.claude/projects/-Users-long-Work-zyt/7be9f770-afc3-497d-87a3-2ffa1fbe3fd1.jsonl)",
|
||||
"Read(//tmp/**)",
|
||||
"Bash(npx --no-install vue-tsc --noEmit)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,3 +25,12 @@ bin-release/
|
||||
/.shared
|
||||
/.cursor
|
||||
/.codex
|
||||
/.agents
|
||||
/.claude
|
||||
/.claude
|
||||
/.claude
|
||||
/.kbctx
|
||||
/server/.spool
|
||||
/server/.claude
|
||||
/.spool
|
||||
TUICallKit-Vue3/.env
|
||||
|
||||
Vendored
+1
-3
@@ -1,5 +1,3 @@
|
||||
{
|
||||
"kiroAgent.configureMCP": "Disabled"
|
||||
|
||||
|
||||
"kiroAgent.configureMCP": "Enabled"
|
||||
}
|
||||
|
||||
@@ -3,20 +3,55 @@
|
||||
|
||||
These instructions are for AI assistants working in this project.
|
||||
|
||||
Use the `/trellis:start` command when starting a new session to:
|
||||
- Initialize your developer identity
|
||||
- Understand current project context
|
||||
- Read relevant guidelines
|
||||
This project is managed by Trellis. The working knowledge you need lives under `.trellis/`:
|
||||
|
||||
Use `@/.trellis/` to learn:
|
||||
- Development workflow (`workflow.md`)
|
||||
- Project structure guidelines (`spec/`)
|
||||
- Developer workspace (`workspace/`)
|
||||
- `.trellis/workflow.md` — development phases, when to create tasks, skill routing
|
||||
- `.trellis/spec/` — package- and layer-scoped coding guidelines (read before writing code in a given layer)
|
||||
- `.trellis/workspace/` — per-developer journals and session traces
|
||||
- `.trellis/tasks/` — active and archived tasks (PRDs, research, jsonl context)
|
||||
|
||||
If you're using Codex, project-scoped helpers may also live in:
|
||||
- `.agents/skills/` for reusable Trellis skills
|
||||
- `.codex/agents/` for optional custom subagents
|
||||
If a Trellis command is available on your platform (e.g. `/trellis:finish-work`, `/trellis:continue`), prefer it over manual steps. Not every platform exposes every command.
|
||||
|
||||
Keep this managed block so 'trellis update' can refresh the instructions.
|
||||
If you're using Codex or another agent-capable tool, additional project-scoped helpers may live in:
|
||||
- `.agents/skills/` — reusable Trellis skills
|
||||
- `.codex/agents/` — optional custom subagents
|
||||
|
||||
## Subagents
|
||||
|
||||
- ALWAYS wait for every spawned subagent to reach a terminal status before yielding, acting on partial results, or spawning followups.
|
||||
- On Codex, this means calling the `wait` tool with the subagent's thread id (requires `multi_agent_v2`). Do NOT infer completion from elapsed time.
|
||||
- On Claude Code / OpenCode, this means awaiting the Task/agent tool result before continuing.
|
||||
- NEVER cancel or re-spawn a subagent that hasn't finished. If a subagent appears stuck, raise the wait timeout (Codex default 30s, max 1h) before judging it broken.
|
||||
- Spawn subagents automatically when:
|
||||
- Parallelizable work (e.g., install + verify, npm test + typecheck, multiple tasks from plan)
|
||||
- Long-running or blocking tasks where a worker can run independently
|
||||
- Isolation for risky changes or checks
|
||||
|
||||
### Codex-only — `spawn_agent` parameters
|
||||
|
||||
When calling `spawn_agent`, ALWAYS pass `fork_turns="none"`. Without it the child inherits the parent transcript and sees your prior `spawn_agent(...)` records, then applies the "wait for spawned subagents" rule to itself — causing `wait_agent` self-deadlock.
|
||||
|
||||
```text
|
||||
spawn_agent(agent_type="trellis-implement", message="...", fork_turns="none")
|
||||
```
|
||||
|
||||
### Codex-only — multi-subagent close-loop
|
||||
|
||||
When `wait` returns a `completed` notification, treat it as an event signal — not as "all done". Run this loop:
|
||||
|
||||
1. Maintain an `expected_agents` set of dispatched sub-agent thread IDs.
|
||||
2. After each `wait` update:
|
||||
1. Call `list_agents` to inspect ALL live agents' status.
|
||||
2. For each agent now in a terminal state:
|
||||
- Verify its promised deliverable exists (e.g. `{task_dir}/research/*.md`).
|
||||
- Read or summarize as needed.
|
||||
- `close_agent` to release the slot.
|
||||
- Remove from `expected_agents`.
|
||||
3. If `expected_agents` still contains running agents → keep waiting.
|
||||
4. If `expected_agents` is empty → continue main flow.
|
||||
3. Never `wait` on an agent that has already reported `completed`.
|
||||
4. If a `completed` agent is missing its deliverable, treat it as failed — surface that in your report instead of re-waiting.
|
||||
|
||||
Managed by Trellis. Edits outside this block are preserved; edits inside may be overwritten by a future `trellis update`.
|
||||
|
||||
<!-- TRELLIS:END -->
|
||||
|
||||
@@ -10,6 +10,7 @@ lerna-debug.log*
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
unpackage/dist
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
+13
-2
@@ -15,11 +15,22 @@ const globalData = {
|
||||
let avatarUrl = ref(""); // 声明为响应式变量
|
||||
uni.CallManager = new CallManager();
|
||||
onLaunch(() => {
|
||||
// iOS 静音键模式下 InnerAudioContext 默认不发声,
|
||||
// 必须在 onLaunch 全局调用此 API,单实例 obeyMuteSwitch 在 iOS 不可靠
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
uni.setInnerAudioOption({
|
||||
obeyMuteSwitch: false,
|
||||
mixWithOther: true,
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('setInnerAudioOption failed:', e)
|
||||
}
|
||||
// #endif
|
||||
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: function (loginRes) {
|
||||
console.log('xx');
|
||||
console.log(loginRes.code);
|
||||
if( uni.getStorageSync('token')){
|
||||
userinfo(loginRes.code);
|
||||
}else{
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
## 简介
|
||||
本 demo 演示了如何在 uni-app 项目中集成 [TUICallKit](https://www.npmjs.com/package/@trtc/calls-uikit-wx-uniapp) 音视频通话组件。
|
||||
|
||||
## 两种开发方式
|
||||
|
||||
本工程同时支持 **HBuilderX** 和 **命令行 (vite)** 两套编译链路,二选一即可。
|
||||
|
||||
### 方式 A:命令行 (vite)
|
||||
|
||||
```bash
|
||||
npm install # 安装依赖
|
||||
npm run dev:mp-weixin # watch 模式,产物 dist/dev/mp-weixin
|
||||
npm run build:mp-weixin # 生产构建,产物 dist/build/mp-weixin
|
||||
```
|
||||
|
||||
构建完成后用微信开发者工具打开对应 `dist/.../mp-weixin` 目录即可。
|
||||
|
||||
支持平台:`mp-weixin` / `h5` / `app`(脚本里有 dev/build 两组)。
|
||||
|
||||
### 方式 B:HBuilderX
|
||||
|
||||
HBuilderX 3.4+ 会自动识别 `vite.config.ts` 并切换到 vite 编译模式。
|
||||
|
||||
1. 用 HBuilderX 打开工程根目录
|
||||
2. 菜单「运行」→「运行到小程序模拟器」→「微信开发者工具」
|
||||
3. 产物会输出到 `unpackage/dist/dev/mp-weixin`
|
||||
|
||||
|
||||
## 环境准备
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { onHide, onUnload } from '@dcloudio/uni-app'
|
||||
import { onUnload } from '@dcloudio/uni-app'
|
||||
import Avatar from '../Avatar/Avatar.vue';
|
||||
import TRTCPusher from '@tencentcloud/trtc-component-uniapp/src/components/TRTCPusher.vue';
|
||||
import TRTCPlayer from '@tencentcloud/trtc-component-uniapp/src/components/TRTCPlayer.vue';
|
||||
@@ -337,6 +337,8 @@ onUnload(() => {
|
||||
const callRole = callParticipantInfo?.value?.selfInfo?.role;
|
||||
|
||||
if (callStatus === CallStatus.IDLE) return;
|
||||
// 已接通:页面卸载(返回、切后台、关闭小程序等)不主动 signaling 挂断,医生端可继续面诊;结束请点「挂断」
|
||||
if (callStatus === CallStatus.CONNECTED) return;
|
||||
if (callStatus === CallStatus.CALLING) {
|
||||
if (callRole === CallRole.CALLER) {
|
||||
hangup();
|
||||
@@ -344,9 +346,6 @@ onUnload(() => {
|
||||
reject();
|
||||
}
|
||||
}
|
||||
if (callStatus === CallStatus.CONNECTED) {
|
||||
hangup();
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -277,6 +277,8 @@ const { proxy } = getCurrentInstance()
|
||||
let unsubscribeWxAppShow = null;
|
||||
let allowChatForegroundRelogin = false;
|
||||
let foregroundReloginTimer = null;
|
||||
/** 避免 onUnload 重复上报离开 */
|
||||
let patientLeaveNotified = false;
|
||||
|
||||
const CONFIRMATION_MESSAGE = `我已确认以下信息:
|
||||
1、本人确认已在线下确诊;
|
||||
@@ -611,6 +613,7 @@ const { proxy } = getCurrentInstance()
|
||||
}
|
||||
onLoad(async (options) => {
|
||||
try {
|
||||
patientLeaveNotified = false;
|
||||
console.log('进入onMounted:', options);
|
||||
const app = getApp();
|
||||
const userID = options.userID|| app.globalData.userID;
|
||||
@@ -790,6 +793,11 @@ const { proxy } = getCurrentInstance()
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
// 患者退出聊天页:通知后端(与 notifyOpen 对称,供管理端轮询)
|
||||
if (!patientLeaveNotified && targetUserID && String(targetUserID).startsWith('doctor_')) {
|
||||
patientLeaveNotified = true;
|
||||
void notifyDoctorOnChatClose();
|
||||
}
|
||||
allowChatForegroundRelogin = false;
|
||||
// #ifdef MP-WEIXIN
|
||||
if (typeof unsubscribeWxAppShow === 'function') {
|
||||
@@ -855,6 +863,47 @@ const { proxy } = getCurrentInstance()
|
||||
console.warn('后端通知失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 离开会话页时通知医生:IM 自定义消息 + 后端全局事件 */
|
||||
async function notifyDoctorOnChatClose() {
|
||||
if (!targetUserID || !targetUserID.startsWith('doctor_')) return;
|
||||
const doctorId = targetUserID.replace('doctor_', '');
|
||||
const userData = uni.getStorageSync('userData') || {};
|
||||
const patientName = userData.name || patient_data.value?.patient_name || '患者';
|
||||
const patientId = (currentUserID || '').replace('patient_', '') || '';
|
||||
try {
|
||||
if (chat) {
|
||||
const customPayload = {
|
||||
businessID: 'patient_closed_chat',
|
||||
patientId,
|
||||
patientName,
|
||||
doctorId,
|
||||
time: Date.now()
|
||||
};
|
||||
const imMsg = chat.createCustomMessage({
|
||||
to: targetUserID,
|
||||
conversationType: TencentCloudChat.TYPES.CONV_C2C,
|
||||
payload: { data: JSON.stringify(customPayload) }
|
||||
});
|
||||
await chat.sendMessage(imMsg);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('IM 离开通知发送失败:', e);
|
||||
}
|
||||
try {
|
||||
await proxy.apiUrl({
|
||||
url: '/api/chat/notifyClose',
|
||||
method: 'POST',
|
||||
data: {
|
||||
doctor_id: doctorId,
|
||||
patient_id: patientId,
|
||||
patient_name: patientName
|
||||
}
|
||||
}, false);
|
||||
} catch (e) {
|
||||
console.warn('后端离开通知失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function applyDoctorEnteredSignalFromRawMessage(msg) {
|
||||
if (!msg || msg.type !== TencentCloudChat.TYPES.MSG_CUSTOM) return;
|
||||
@@ -956,6 +1005,9 @@ const { proxy } = getCurrentInstance()
|
||||
if (customData.businessID === 'patient_opened_chat') {
|
||||
return null; // 返回null表示不显示此消息
|
||||
}
|
||||
if (customData.businessID === 'patient_closed_chat') {
|
||||
return null;
|
||||
}
|
||||
// 医生进入诊室信令(admin 发送):仅更新顶部卡片,不在聊天列表展示
|
||||
if (customData.businessID === 'doctor_entered_consult_room') {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
<template>
|
||||
<view v-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
|
||||
<!-- 展开后的菜单卡片(从下往上动画) -->
|
||||
<view v-if="expanded" class="menu-list" @click.stop>
|
||||
<view class="menu-item" @click="goto('/training/pages/dumbbell')">
|
||||
<view class="menu-icon menu-icon--dumbbell">
|
||||
<view class="dumbbell-shape">
|
||||
<view class="dumbbell-plate dumbbell-plate-left" />
|
||||
<view class="dumbbell-bar" />
|
||||
<view class="dumbbell-plate dumbbell-plate-right" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="menu-info">
|
||||
<text class="menu-title">哑铃</text>
|
||||
<text class="menu-sub">力量塑形</text>
|
||||
</view>
|
||||
<view class="menu-arrow">›</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @click="goto('/training/pages/foot-pedal')">
|
||||
<view class="menu-icon menu-icon--pedal">
|
||||
<view class="foot-pedal-shape">
|
||||
<view class="pedal-bar pedal-bar-left" />
|
||||
<view class="pedal-bar pedal-bar-right" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="menu-info">
|
||||
<text class="menu-title">脚蹬器</text>
|
||||
<text class="menu-sub">下肢有氧</text>
|
||||
</view>
|
||||
<view class="menu-arrow">›</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @click="goto('/training/pages/pilates-ring')">
|
||||
<view class="menu-icon menu-icon--pilates">
|
||||
<view class="pilates-ring-shape" />
|
||||
</view>
|
||||
<view class="menu-info">
|
||||
<text class="menu-title">瑜伽环</text>
|
||||
<text class="menu-sub">核心塑形</text>
|
||||
</view>
|
||||
<view class="menu-arrow">›</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @click="goto('/training/pages/grip-ring')">
|
||||
<view class="menu-icon menu-icon--grip">
|
||||
<view class="grip-ring-shape" />
|
||||
</view>
|
||||
<view class="menu-info">
|
||||
<text class="menu-title">握力环</text>
|
||||
<text class="menu-sub">握力训练</text>
|
||||
</view>
|
||||
<view class="menu-arrow">›</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @click="goto('/training/pages/metronome')">
|
||||
<view class="menu-icon menu-icon--metro">
|
||||
<view class="metro-bar metro-bar-1" />
|
||||
<view class="metro-bar metro-bar-2" />
|
||||
<view class="metro-bar metro-bar-3" />
|
||||
</view>
|
||||
<view class="menu-info">
|
||||
<text class="menu-title">耗糖节拍器</text>
|
||||
<text class="menu-sub">健走配速</text>
|
||||
</view>
|
||||
<view class="menu-arrow">›</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 主浮动按钮(单字"练" + 呼吸光环) -->
|
||||
<view class="fab-container">
|
||||
<view v-if="!expanded" class="fab-pulse" />
|
||||
<view v-if="!expanded" class="fab-pulse fab-pulse-2" />
|
||||
<view class="entry-fab" :class="{ expanded }" @click="toggle">
|
||||
<view v-if="expanded" class="icon-close">
|
||||
<view class="cross-bar bar-1" />
|
||||
<view class="cross-bar bar-2" />
|
||||
</view>
|
||||
<!-- 心跳波形 icon: 健康主题,SVG 折线 -->
|
||||
<view v-else class="fab-icon" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
bottom?: number
|
||||
}>(),
|
||||
{ bottom: 200 },
|
||||
)
|
||||
|
||||
const isDevMode = (): boolean => {
|
||||
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */
|
||||
try {
|
||||
return process.env.NODE_ENV !== 'production'
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const visible = ref(isDevMode())
|
||||
const expanded = ref(false)
|
||||
|
||||
const toggle = () => {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
const goto = (url: string) => {
|
||||
expanded.value = false
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$brand: #10b981;
|
||||
$brand-deep: #047857;
|
||||
$brand-light: #34d399;
|
||||
|
||||
.dev-entry-wrap {
|
||||
position: fixed;
|
||||
right: 24rpx;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 主浮动按钮
|
||||
* ============================================================ */
|
||||
.fab-container {
|
||||
position: relative;
|
||||
width: 108rpx;
|
||||
height: 108rpx;
|
||||
}
|
||||
|
||||
.entry-fab {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(140deg, $brand-light 0%, $brand-deep 100%);
|
||||
box-shadow:
|
||||
0 8rpx 20rpx rgba(16, 185, 129, 0.42),
|
||||
0 2rpx 6rpx rgba(15, 23, 42, 0.12),
|
||||
inset 0 -8rpx 16rpx rgba(0, 0, 0, 0.12),
|
||||
inset 0 8rpx 16rpx rgba(255, 255, 255, 0.18);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
background: linear-gradient(140deg, #94a3b8 0%, #475569 100%);
|
||||
transform: rotate(135deg);
|
||||
box-shadow:
|
||||
0 6rpx 16rpx rgba(15, 23, 42, 0.2),
|
||||
inset 0 -6rpx 14rpx rgba(0, 0, 0, 0.12),
|
||||
inset 0 6rpx 14rpx rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.fab-icon {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='2,12 7,12 9.5,7 12,17 14.5,9 16.5,12 22,12'/></svg>");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.22));
|
||||
animation: fab-icon-beat 1.6s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
@keyframes fab-icon-beat {
|
||||
0%, 60%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
20% {
|
||||
transform: scale(1.12);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 关闭态 X 图标 (CSS 几何) */
|
||||
.icon-close {
|
||||
position: relative;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
|
||||
.cross-bar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 5rpx;
|
||||
background: #fff;
|
||||
border-radius: 3rpx;
|
||||
transform-origin: center;
|
||||
}
|
||||
.bar-1 {
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
}
|
||||
.bar-2 {
|
||||
transform: translateY(-50%) rotate(-45deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 呼吸光环(2 圈错相位扩散) */
|
||||
.fab-pulse {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(16, 185, 129, 0.25);
|
||||
animation: fab-ring 2.4s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
.fab-pulse-2 {
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
@keyframes fab-ring {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.55);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 展开菜单
|
||||
* ============================================================ */
|
||||
.menu-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
animation: slide-up 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20rpx) scale(0.92);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
padding: 14rpx 20rpx 14rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
box-shadow:
|
||||
0 8rpx 24rpx rgba(15, 23, 42, 0.1),
|
||||
0 1rpx 2rpx rgba(15, 23, 42, 0.06);
|
||||
min-width: 280rpx;
|
||||
transition: transform 0.15s;
|
||||
backdrop-filter: blur(12rpx);
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
background: #f0fdf4;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2rpx 8rpx rgba(16, 185, 129, 0.32);
|
||||
|
||||
&--train {
|
||||
background: linear-gradient(140deg, $brand-light, $brand-deep);
|
||||
}
|
||||
&--dumbbell {
|
||||
background: linear-gradient(140deg, #93c5fd, #3b82f6);
|
||||
box-shadow: 0 2rpx 8rpx rgba(59, 130, 246, 0.36);
|
||||
}
|
||||
&--pedal {
|
||||
background: linear-gradient(140deg, #fdba74, #f97316);
|
||||
box-shadow: 0 2rpx 8rpx rgba(249, 115, 22, 0.36);
|
||||
}
|
||||
&--pilates {
|
||||
background: linear-gradient(140deg, #c4b5fd, #8b5cf6);
|
||||
box-shadow: 0 2rpx 8rpx rgba(139, 92, 246, 0.36);
|
||||
}
|
||||
&--grip {
|
||||
background: linear-gradient(140deg, #5eead4, #14b8a6);
|
||||
box-shadow: 0 2rpx 8rpx rgba(20, 184, 166, 0.36);
|
||||
}
|
||||
&--metro {
|
||||
background: linear-gradient(140deg, #fbbf24, #d97706);
|
||||
box-shadow: 0 2rpx 8rpx rgba(245, 158, 11, 0.36);
|
||||
gap: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-icon-text {
|
||||
font-size: 32rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 哑铃小 icon */
|
||||
.dumbbell-shape {
|
||||
width: 40rpx;
|
||||
height: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dumbbell-plate {
|
||||
width: 10rpx;
|
||||
height: 18rpx;
|
||||
background: #fff;
|
||||
border-radius: 3rpx;
|
||||
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.dumbbell-bar {
|
||||
width: 16rpx;
|
||||
height: 5rpx;
|
||||
background: #fff;
|
||||
border-radius: 2rpx;
|
||||
margin: 0 2rpx;
|
||||
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
/* 脚蹬器小 icon: 左右踏板 */
|
||||
.foot-pedal-shape {
|
||||
width: 40rpx;
|
||||
height: 28rpx;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.pedal-bar {
|
||||
width: 14rpx;
|
||||
background: #fff;
|
||||
border-radius: 4rpx 4rpx 2rpx 2rpx;
|
||||
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.pedal-bar-left {
|
||||
height: 18rpx;
|
||||
transform: rotate(-12deg);
|
||||
}
|
||||
|
||||
.pedal-bar-right {
|
||||
height: 22rpx;
|
||||
transform: rotate(12deg);
|
||||
}
|
||||
|
||||
/* 瑜伽环小 icon: 双弧阻力环 */
|
||||
.pilates-ring-shape {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-radius: 50%;
|
||||
border: 5rpx solid #fff;
|
||||
position: relative;
|
||||
box-shadow:
|
||||
0 1rpx 2rpx rgba(0, 0, 0, 0.18),
|
||||
inset 0 1rpx 2rpx rgba(255, 255, 255, 0.6);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 3rpx solid rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
/* 握力环小 icon: 白色描边的环(甜甜圈) */
|
||||
.grip-ring-shape {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
border-radius: 50%;
|
||||
border: 7rpx solid #fff;
|
||||
box-shadow:
|
||||
0 1rpx 2rpx rgba(0, 0, 0, 0.18),
|
||||
inset 0 1rpx 2rpx rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* 节拍器小 icon: 3 根高低柱模拟均衡器/节拍 */
|
||||
.metro-bar {
|
||||
width: 4rpx;
|
||||
background: #fff;
|
||||
border-radius: 2rpx;
|
||||
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.metro-bar-1 {
|
||||
height: 16rpx;
|
||||
}
|
||||
.metro-bar-2 {
|
||||
height: 28rpx;
|
||||
}
|
||||
.metro-bar-3 {
|
||||
height: 22rpx;
|
||||
}
|
||||
|
||||
.menu-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rpx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-size: 28rpx;
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.menu-sub {
|
||||
font-size: 20rpx;
|
||||
color: #94a3b8;
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
|
||||
.menu-arrow {
|
||||
font-size: 32rpx;
|
||||
color: #cbd5e1;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
margin-left: 4rpx;
|
||||
}
|
||||
</style>
|
||||
+10
-4
@@ -1,5 +1,11 @@
|
||||
import App from './App'
|
||||
var baseUrl ='https://admin.zhenyangtang.com.cn/';
|
||||
|
||||
function joinApiUrl(base, path) {
|
||||
const b = String(base || '').replace(/\/+$/, '')
|
||||
const p = String(path || '').replace(/^\/+/, '')
|
||||
return p ? `${b}/${p}` : b
|
||||
}
|
||||
// #ifndef VUE3
|
||||
import Vue from 'vue'
|
||||
import './uni.promisify.adaptor'
|
||||
@@ -20,13 +26,13 @@ Vue.prototype.apiUrl =function apiurl(promise, Loading = true){
|
||||
if (promise.data) {
|
||||
data = promise.data
|
||||
}
|
||||
let url=Vue.prototype.$url+promise.url
|
||||
let url = joinApiUrl(Vue.prototype.$url, promise.url)
|
||||
|
||||
return new Promise(function(resolve, reject) {
|
||||
uni.request({
|
||||
url: url,
|
||||
data: data,
|
||||
timeout: 15000,
|
||||
timeout: promise.timeout != null ? promise.timeout : 15000,
|
||||
method: promise.method ? promise.method : 'POST',
|
||||
header: header,
|
||||
sslVerify: false,
|
||||
@@ -73,7 +79,7 @@ function apiUrl(promise, Loading = true) {
|
||||
|
||||
// 处理请求数据和URL
|
||||
const data = promise.data || {}
|
||||
const url = baseUrl + (promise.url || '')
|
||||
const url = joinApiUrl(baseUrl, promise.url || '')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 可选:根据Loading参数显示加载中
|
||||
@@ -84,7 +90,7 @@ function apiUrl(promise, Loading = true) {
|
||||
uni.request({
|
||||
url: url,
|
||||
data: data,
|
||||
timeout: 15000,
|
||||
timeout: promise.timeout != null ? promise.timeout : 15000,
|
||||
method: promise.method || 'POST', // 简化三元表达式
|
||||
header: header,
|
||||
sslVerify: false,
|
||||
|
||||
@@ -57,7 +57,15 @@
|
||||
"optimization" : {
|
||||
"subPackages" : true
|
||||
},
|
||||
"usingComponents" : true
|
||||
"usingComponents" : true,
|
||||
"requiredBackgroundModes" : ["audio"],
|
||||
|
||||
"plugins" : {
|
||||
"WechatSI" : {
|
||||
"version" : "0.3.5",
|
||||
"provider" : "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mp-alipay" : {
|
||||
"usingComponents" : true
|
||||
|
||||
Generated
+10540
-1784
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,55 @@
|
||||
{
|
||||
"name": "tuicallkit-vue3",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"description": "TUICallKit Vue3 uni-app 工程(兼容 HBuilderX & vite CLI)",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"dev:mp-weixin": "UNI_INPUT_DIR=\"$PWD\" uni -p mp-weixin",
|
||||
"dev:h5": "UNI_INPUT_DIR=\"$PWD\" uni",
|
||||
"dev:app": "UNI_INPUT_DIR=\"$PWD\" uni -p app",
|
||||
"build:mp-weixin": "UNI_INPUT_DIR=\"$PWD\" uni build -p mp-weixin",
|
||||
"build:h5": "UNI_INPUT_DIR=\"$PWD\" uni build",
|
||||
"build:app": "UNI_INPUT_DIR=\"$PWD\" uni build -p app"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-app-harmony": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-app-plus": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-components": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-alipay": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-baidu": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-jd": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-kuaishou": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-lark": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-qq": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-toutiao": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-xhs": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-quickapp-webview": "3.0.0-4060620250520001",
|
||||
"@trtc/calls-uikit-wx-uniapp": "^4.1.0",
|
||||
"@uni-ku/bundle-optimizer": "^1.3.16",
|
||||
"tuikit-atomicx-uniapp-wx-standard": "^1.1.9"
|
||||
"tuikit-atomicx-uniapp-wx-standard": "^1.1.9",
|
||||
"vue": "^3.4.21",
|
||||
"vue-i18n": "^9.1.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/types": "^3.4.8",
|
||||
"@dcloudio/uni-automator": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-stacktracey": "3.0.0-4060620250520001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
|
||||
"@types/node": "^20.11.0",
|
||||
"@vue/runtime-core": "^3.4.21",
|
||||
"less": "^4.6.4",
|
||||
"sass": "^1.77.8",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.2.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,106 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "training",
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/grip-ring",
|
||||
"style": {
|
||||
"navigationBarTitleText": "握力环训练",
|
||||
"navigationBarBackgroundColor": "#f8fafc",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f8fafc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/pilates-ring",
|
||||
"style": {
|
||||
"navigationBarTitleText": "瑜伽环训练",
|
||||
"navigationBarBackgroundColor": "#f8fafc",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f8fafc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/foot-pedal",
|
||||
"style": {
|
||||
"navigationBarTitleText": "脚蹬器训练",
|
||||
"navigationBarBackgroundColor": "#f8fafc",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f8fafc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/dumbbell",
|
||||
"style": {
|
||||
"navigationBarTitleText": "哑铃训练",
|
||||
"navigationBarBackgroundColor": "#f8fafc",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f8fafc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/metronome",
|
||||
"style": {
|
||||
"navigationBarTitleText": "耗糖节拍器",
|
||||
"navigationBarBackgroundColor": "#f8fafc",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f8fafc"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "tongji",
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "血糖记录",
|
||||
"navigationBarBackgroundColor": "#204e2b",
|
||||
"navigationBarTextStyle": "white",
|
||||
"backgroundColor": "#faf9f5",
|
||||
"enablePullDownRefresh": true,
|
||||
"mp-weixin": {
|
||||
"usingPlugins": {
|
||||
"WechatSI": {
|
||||
"version": "0.3.5",
|
||||
"provider": "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/more",
|
||||
"style": {
|
||||
"navigationBarTitleText": "日常护理",
|
||||
"navigationBarBackgroundColor": "#204e2b",
|
||||
"navigationBarTextStyle": "white",
|
||||
"backgroundColor": "#faf9f5",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/weekly",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "血糖管理",
|
||||
"backgroundColor": "#f4fbf4",
|
||||
"enablePullDownRefresh": true,
|
||||
"mp-weixin": {
|
||||
"usingPlugins": {
|
||||
"WechatSI": {
|
||||
"version": "0.3.5",
|
||||
"provider": "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tabBar": {
|
||||
|
||||
@@ -53,6 +53,18 @@
|
||||
<text class="time-value">{{ card.create_time || '-' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 快捷入口 -->
|
||||
<view class="card-actions">
|
||||
<view class="card-action-btn primary" @click.stop="viewDailyRecord(card)">
|
||||
<text class="card-action-icon">📈</text>
|
||||
<text class="card-action-text">日常记录</text>
|
||||
</view>
|
||||
<view class="card-action-btn" @click.stop="viewCardDetail(card)">
|
||||
<text class="card-action-icon">📝</text>
|
||||
<text class="card-action-text">查看 / 编辑</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
@@ -138,6 +150,16 @@ const viewCardDetail = (card) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 查看日常记录(血糖血压 / 饮食 / 运动 / 跟踪备注 + 波浪图)
|
||||
const viewDailyRecord = (card) => {
|
||||
const url = `/tongji/pages/index?diagnosis_id=${card.id}` +
|
||||
`&patient_id=${card.patient_id || ''}` +
|
||||
`&patient_name=${encodeURIComponent(card.patient_name || '')}` +
|
||||
`&age=${card.age || ''}` +
|
||||
`&gender=${card.gender || ''}`
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
// 新建就诊卡(patient_id 创建后自动生成,仅需登录)
|
||||
const createCard = () => {
|
||||
const token = uni.getStorageSync('token')
|
||||
@@ -310,6 +332,54 @@ const formatDate = (timestamp) => {
|
||||
border-top: 4rpx solid #e8eaed;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-top: 28rpx;
|
||||
padding-top: 28rpx;
|
||||
border-top: 4rpx solid #e8eaed;
|
||||
}
|
||||
|
||||
.card-action-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
padding: 24rpx 12rpx;
|
||||
background: #f6faff;
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid #d6e6fb;
|
||||
min-height: 80rpx;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: linear-gradient(135deg, #1890ff, #0ea5a4);
|
||||
border-color: transparent;
|
||||
|
||||
.card-action-icon,
|
||||
.card-action-text {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-action-icon {
|
||||
font-size: 36rpx;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.card-action-text {
|
||||
font-size: 30rpx;
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.time-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -778,6 +778,7 @@ const showDatePicker = ref(false)
|
||||
const diagnosisId = ref(null)
|
||||
const patientId = ref(null)
|
||||
const isAddMode = ref(false) // 新建模式
|
||||
const returnUrl = ref('') // 保存成功后跳回(如日常记录页)
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
@@ -1023,6 +1024,13 @@ const loadCardDetail = async () => {
|
||||
const options = currentPage.options || {}
|
||||
diagnosisId.value = options.id
|
||||
isAddMode.value = options.add === '1' || options.add === 1
|
||||
if (options.returnUrl) {
|
||||
try {
|
||||
returnUrl.value = decodeURIComponent(String(options.returnUrl))
|
||||
} catch (e) {
|
||||
returnUrl.value = String(options.returnUrl)
|
||||
}
|
||||
}
|
||||
|
||||
const userData = uni.getStorageSync('userData')
|
||||
patientId.value = userData?.diagnosis?.patient_id
|
||||
@@ -1302,7 +1310,12 @@ const submitForm = async () => {
|
||||
}
|
||||
uni.showToast({ title: isAddMode.value ? '创建成功' : '保存成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
const back = returnUrl.value
|
||||
if (back && back.startsWith('/')) {
|
||||
uni.redirectTo({ url: back })
|
||||
} else {
|
||||
uni.navigateBack()
|
||||
}
|
||||
}, 1500)
|
||||
} else {
|
||||
uni.showToast({ title: res.msg || '保存失败', icon: 'none' })
|
||||
|
||||
@@ -88,13 +88,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DEV 开发悬浮入口(生产环境自动隐藏) -->
|
||||
<dev-training-entry />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
||||
import DevTrainingEntry from '@/components/dev-training-entry/index.vue'
|
||||
export default {
|
||||
name: 'profileB',
|
||||
components: { DevTrainingEntry },
|
||||
data() {
|
||||
return {
|
||||
userInfo: {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<view v-if="show" class="celebrate-root" @touchmove.stop.prevent>
|
||||
<view
|
||||
v-for="p in particles"
|
||||
:key="p.id"
|
||||
class="celebrate-particle"
|
||||
:style="p.style"
|
||||
/>
|
||||
<view v-if="title" class="celebrate-card" :class="{ pop: cardPop }">
|
||||
<view class="celebrate-card-glow" />
|
||||
<TongjiIcon name="sparkles" size="lg" color="#204E2B" />
|
||||
<text class="celebrate-card-title">{{ title }}</text>
|
||||
<text v-if="subtitle" class="celebrate-card-sub">{{ subtitle }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import TongjiIcon from './TongjiIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
subtitle: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const particles = ref([])
|
||||
const cardPop = ref(false)
|
||||
|
||||
const COLORS = ['#204E2B', '#386641', '#AFE2B3', '#FBBF24', '#727970', '#DC2626']
|
||||
|
||||
function buildParticles() {
|
||||
const list = []
|
||||
for (let i = 0; i < 18; i++) {
|
||||
const left = 8 + Math.random() * 84
|
||||
const delay = Math.random() * 0.35
|
||||
const hue = COLORS[i % COLORS.length]
|
||||
const size = 10 + Math.floor(Math.random() * 14)
|
||||
list.push({
|
||||
id: `${Date.now()}_${i}`,
|
||||
style: {
|
||||
left: `${left}%`,
|
||||
width: `${size}rpx`,
|
||||
height: `${size}rpx`,
|
||||
background: hue,
|
||||
animationDelay: `${delay}s`
|
||||
}
|
||||
})
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (v) {
|
||||
particles.value = buildParticles()
|
||||
cardPop.value = false
|
||||
setTimeout(() => {
|
||||
cardPop.value = true
|
||||
}, 30)
|
||||
} else {
|
||||
particles.value = []
|
||||
cardPop.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.celebrate-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.celebrate-particle {
|
||||
position: absolute;
|
||||
top: -20rpx;
|
||||
border-radius: 4rpx;
|
||||
opacity: 0.9;
|
||||
animation: celebrate-fall 1.6s ease-in forwards;
|
||||
}
|
||||
@keyframes celebrate-fall {
|
||||
0% {
|
||||
transform: translateY(0) rotate(0deg) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(110vh) rotate(540deg) scale(0.4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.celebrate-card {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 38%;
|
||||
transform: translate(-50%, -50%) scale(0.82);
|
||||
width: 78%;
|
||||
max-width: 560rpx;
|
||||
padding: 36rpx 32rpx 32rpx;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-radius: 28rpx;
|
||||
box-shadow: 0 20rpx 60rpx rgba(8, 145, 178, 0.22);
|
||||
border: 2rpx solid rgba(8, 145, 178, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
|
||||
&.pop {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
.celebrate-card-glow {
|
||||
position: absolute;
|
||||
inset: -20rpx;
|
||||
border-radius: 36rpx;
|
||||
background: radial-gradient(circle, rgba(34, 211, 238, 0.2), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.celebrate-card-title {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 16rpx;
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
color: #1b1c1a;
|
||||
}
|
||||
.celebrate-card-sub {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 10rpx;
|
||||
font-size: 26rpx;
|
||||
color: #475569;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<view
|
||||
class="sugar-tree-graphic"
|
||||
:class="[`lv-${clampedLevel}`, `tier-${visualTier}`, `size-${size}`, { watering: watering, 'is-max': clampedLevel >= MAX_LEVEL }]"
|
||||
>
|
||||
<view class="stg-glow" />
|
||||
<view v-if="clampedLevel >= 7" class="stg-aura" />
|
||||
<image v-if="!treeUseFallback" class="stg-image" :src="treeImageSrc" mode="aspectFit" @error="treeUseFallback = true" />
|
||||
<text v-else class="stg-emoji">{{ treeEmoji }}</text>
|
||||
<view v-if="clampedLevel >= 7" class="stg-sparkle stg-sparkle-a" />
|
||||
<view v-if="clampedLevel >= 8" class="stg-sparkle stg-sparkle-b" />
|
||||
<view v-if="clampedLevel >= MAX_LEVEL" class="stg-sparkle stg-sparkle-c" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { svgToDataUrl } from '../utils/svgDataUrl.js'
|
||||
import { TREE_MAX_LEVEL, TREE_LEVELS } from '../utils/treeLevels.js'
|
||||
|
||||
const MAX_LEVEL = TREE_MAX_LEVEL
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
const treeUseFallback = ref(true)
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
const treeUseFallback = ref(false)
|
||||
// #endif
|
||||
|
||||
const props = defineProps({
|
||||
level: { type: Number, default: 0 },
|
||||
size: { type: String, default: 'md' },
|
||||
watering: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const clampedLevel = computed(() => Math.min(MAX_LEVEL, Math.max(0, Number(props.level) || 0)))
|
||||
const visualTier = computed(() => {
|
||||
const lv = clampedLevel.value
|
||||
if (lv <= 0) return 0
|
||||
if (lv <= 2) return 1
|
||||
if (lv <= 4) return 2
|
||||
if (lv <= 6) return 3
|
||||
if (lv <= 8) return 4
|
||||
return 5
|
||||
})
|
||||
const treeEmoji = computed(() => (TREE_LEVELS[clampedLevel.value] || TREE_LEVELS[0]).emoji)
|
||||
|
||||
function buildTreeSvg(level) {
|
||||
const pot = `
|
||||
<ellipse cx="24" cy="50" rx="15" ry="3" fill="#204e2b" opacity="0.12"/>
|
||||
<path d="M11 46h26c1.2 0 2 1 2 2.2v3.8c0 1-.8 1.8-1.8 1.8H10.8c-1 0-1.8-.8-1.8-1.8v-3.8c0-1.2.8-2.2 2-2.2z" fill="#E7E5E4"/>
|
||||
<path d="M12.5 46h23c.8 0 1.5.7 1.5 1.5v2.2c0 .6-.5 1.1-1.1 1.1H12.1c-.6 0-1.1-.5-1.1-1.1v-2.2c0-.8.7-1.5 1.5-1.5z" fill="#D6D3D1"/>
|
||||
<ellipse cx="24" cy="46.5" rx="9" ry="1.6" fill="#A8A29E" opacity="0.35"/>
|
||||
`
|
||||
const soil = `<ellipse cx="24" cy="44.5" rx="8" ry="2.2" fill="#386641" opacity="0.18"/>`
|
||||
|
||||
if (level <= 0) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 56" fill="none">
|
||||
${pot}${soil}
|
||||
<circle cx="24" cy="41.5" r="2.2" fill="#a8a29e" opacity="0.7"/>
|
||||
<path d="M24 41.5v3.5" stroke="#78716c" stroke-width="1" stroke-linecap="round"/>
|
||||
</svg>`
|
||||
}
|
||||
|
||||
const trunkH = level >= 6 ? 16 : level >= 3 ? 14 : 10
|
||||
const trunkY = 46 - trunkH
|
||||
const trunk = `<rect x="22.2" y="${trunkY}" width="3.6" height="${trunkH}" rx="1.8" fill="#78716C"/>
|
||||
<rect x="22.6" y="${trunkY + 1}" width="2.8" height="${trunkH - 1}" rx="1.4" fill="#A8A29E" opacity="0.35"/>`
|
||||
|
||||
const leaf = (cx, cy, r, fill, opacity = 1) =>
|
||||
`<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}" opacity="${opacity}"/>
|
||||
<circle cx="${cx - r * 0.25}" cy="${cy - r * 0.2}" r="${r * 0.35}" fill="#eef6ef" opacity="0.55"/>`
|
||||
|
||||
const bloom =
|
||||
level >= 7
|
||||
? leaf(17, 18, 3, '#fda4af', 0.95) +
|
||||
leaf(31, 17, 2.8, '#f9a8d4', 0.9) +
|
||||
leaf(24, 13, 3.2, '#fb7185', 0.95) +
|
||||
`<circle cx="24" cy="12" r="1.3" fill="#fef3c7"/>`
|
||||
: ''
|
||||
const crown =
|
||||
level >= 9
|
||||
? leaf(12, 22, 6, '#34d399', 0.9) +
|
||||
leaf(36, 22, 6, '#34d399', 0.9) +
|
||||
leaf(24, 10, 7, '#386641', 0.95) +
|
||||
bloom
|
||||
: bloom
|
||||
|
||||
let canopy = ''
|
||||
if (level === 1) {
|
||||
canopy = leaf(24, 38, 4, '#afe2b3') + `<path d="M24 38v-5" stroke="#386641" stroke-width="1.2" stroke-linecap="round"/>`
|
||||
} else if (level === 2) {
|
||||
canopy = leaf(24, 32, 5.5, '#34d399') + leaf(20, 34, 3.5, '#6ee7b7', 0.9)
|
||||
} else if (level <= 4) {
|
||||
canopy =
|
||||
leaf(24, 28, 7, '#34d399') +
|
||||
leaf(17, 30, 5, '#6ee7b7', 0.9) +
|
||||
leaf(31, 30, 5, '#6ee7b7', 0.9)
|
||||
} else if (level <= 6) {
|
||||
canopy =
|
||||
leaf(24, 24, 9, '#22c55e') +
|
||||
leaf(14, 26, 7, '#4ade80', 0.92) +
|
||||
leaf(34, 26, 7, '#4ade80', 0.92) +
|
||||
leaf(24, 16, 6, '#386641', 0.88)
|
||||
} else {
|
||||
canopy =
|
||||
leaf(24, 20, 10, '#204e2b') +
|
||||
leaf(13, 24, 8, '#34d399', 0.95) +
|
||||
leaf(35, 24, 8, '#34d399', 0.95) +
|
||||
crown
|
||||
}
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 56" fill="none">${pot}${soil}${trunk}${canopy}</svg>`
|
||||
}
|
||||
|
||||
const treeImageSrc = computed(() => svgToDataUrl(buildTreeSvg(clampedLevel.value)))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sugar-tree-graphic {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.size-sm { width: 64rpx; height: 72rpx; }
|
||||
.size-md { width: 80rpx; height: 88rpx; }
|
||||
.size-lg { width: 112rpx; height: 124rpx; }
|
||||
|
||||
.stg-glow {
|
||||
position: absolute;
|
||||
inset: 6%;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(32, 78, 43, 0.22) 0%, transparent 68%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.lv-0 .stg-glow { background: radial-gradient(circle, rgba(148, 163, 184, 0.25) 0%, transparent 70%); }
|
||||
.tier-4 .stg-glow,
|
||||
.tier-5 .stg-glow,
|
||||
.is-max .stg-glow {
|
||||
background: radial-gradient(circle, rgba(251, 191, 36, 0.3) 0%, rgba(32, 78, 43, 0.15) 55%, transparent 72%);
|
||||
}
|
||||
.stg-aura {
|
||||
position: absolute;
|
||||
inset: -8%;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid rgba(253, 224, 71, 0.35);
|
||||
animation: stg-aura-pulse 2.4s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes stg-aura-pulse {
|
||||
0%, 100% { transform: scale(0.92); opacity: 0.5; }
|
||||
50% { transform: scale(1.05); opacity: 1; }
|
||||
}
|
||||
.stg-image {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.35s ease;
|
||||
}
|
||||
.sugar-tree-graphic.watering .stg-image,
|
||||
.sugar-tree-graphic.watering .stg-emoji {
|
||||
animation: stg-water-bounce 0.65s ease;
|
||||
}
|
||||
.stg-emoji {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
line-height: 1;
|
||||
font-size: 72rpx;
|
||||
}
|
||||
.size-sm .stg-emoji { font-size: 48rpx; }
|
||||
.size-md .stg-emoji { font-size: 60rpx; }
|
||||
.size-lg .stg-emoji { font-size: 88rpx; }
|
||||
@keyframes stg-water-bounce {
|
||||
0%, 100% { transform: scale(1); }
|
||||
35% { transform: scale(1.1) translateY(-6rpx); }
|
||||
60% { transform: scale(0.96) translateY(2rpx); }
|
||||
}
|
||||
.stg-sparkle {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: #fde68a;
|
||||
box-shadow: 0 0 6rpx rgba(253, 224, 71, 0.8);
|
||||
pointer-events: none;
|
||||
animation: stg-sparkle-twinkle 1.8s ease-in-out infinite;
|
||||
}
|
||||
.stg-sparkle-a { top: 4%; right: 16%; }
|
||||
.stg-sparkle-b { top: 12%; left: 10%; width: 6rpx; height: 6rpx; animation-delay: 0.4s; }
|
||||
.stg-sparkle-c { top: 22%; right: 28%; width: 10rpx; height: 10rpx; animation-delay: 0.8s; }
|
||||
@keyframes stg-sparkle-twinkle {
|
||||
0%, 100% { opacity: 0.4; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1.2); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<view class="tj-icon-wrap" :class="[`tj-icon-wrap--${size}`]">
|
||||
<image
|
||||
v-if="!useFallback"
|
||||
class="tj-icon"
|
||||
:class="[`tj-icon--${name}`, `tj-icon--${size}`]"
|
||||
:src="iconSrc"
|
||||
mode="aspectFit"
|
||||
@error="onImageError"
|
||||
/>
|
||||
<text
|
||||
v-else
|
||||
class="tj-icon-fallback"
|
||||
:class="[`tj-icon-fallback--${size}`]"
|
||||
:style="{ color }"
|
||||
>{{ fallbackGlyph }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { svgToDataUrl } from '../utils/svgDataUrl.js'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
size: { type: String, default: 'md' },
|
||||
color: { type: String, default: '#204E2B' }
|
||||
})
|
||||
|
||||
const useFallback = ref(false)
|
||||
|
||||
/** Lucide 风格描边路径 */
|
||||
const ICON_PATHS = {
|
||||
view: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
|
||||
ticket: '<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/>',
|
||||
calendar: '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
|
||||
flame: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
|
||||
'check-circle': '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/>',
|
||||
glucose: '<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>',
|
||||
heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
|
||||
activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
||||
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||
person: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||
send: '<path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/>',
|
||||
'alert-triangle': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||
minus: '<path d="M5 12h14"/>',
|
||||
droplet: '<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.5-2.5-5.5C15 7 12 2 12 2S9 7 7.5 9.5 5 13 5 15a7 7 0 0 0 7 7z"/>',
|
||||
sparkles: '<path d="m12 3-1.9 5.8L4 12l5.8 1.9L12 21l1.9-5.8L20 12l-5.8-1.9L12 3Z"/><path d="M5 3v4M19 17v4M3 5h4M17 19h4"/>',
|
||||
trophy: '<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20 17 22M18 2H6v7a6 6 0 0 0 12 0V2Z"/>',
|
||||
share: '<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13"/>',
|
||||
plus: '<path d="M5 12h14M12 5v14"/>',
|
||||
refresh: '<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8M3 3v5h5M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16M21 21v-5h-5"/>',
|
||||
volume: '<path d="M11 5 6 9H2v6h4l5 4V5zM15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14"/>',
|
||||
pause: '<rect width="4" height="16" x="14" y="4" rx="1"/><rect width="4" height="16" x="6" y="4" rx="1"/>',
|
||||
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
|
||||
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>',
|
||||
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41-1.41M17.66 6.34l1.41-1.41M6.34 4.93l1.41 1.41"/>',
|
||||
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
||||
sunset: '<path d="M12 10V2M18.364 5.636l-2.12 2.12M5.636 18.364l2.12-2.12M22 18h-3M5 18H2M18.364 18.364l-2.12-2.12M5.636 5.636l2.12 2.12M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/>',
|
||||
mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>',
|
||||
camera: '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
|
||||
bulb: '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1.3.5 2.6 1.5 3.5.8.8 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
|
||||
leaf: '<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/><path d="M2 21c0-3 1.85-5.36 5.08-6"/>',
|
||||
utensils: '<path d="M3 2v7c0 1.1.9 2 2 2a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2a5 5 0 0 0-3 4.5v6a2 2 0 0 0 2 2h1Z"/><path d="M18 15v7"/>',
|
||||
egg: '<path d="M12 22c4.97 0 8-3.27 8-7.31C20 9.65 16.42 2 12 2S4 9.65 4 14.69C4 18.73 7.03 22 12 22Z"/>',
|
||||
home: '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
|
||||
'plus-circle': '<circle cx="12" cy="12" r="10"/><path d="M8 12h8M12 8v8"/>',
|
||||
'chevron-right': '<path d="m9 18 6-6-6-6"/>'
|
||||
}
|
||||
|
||||
/** 图片加载失败时的 emoji 回退 */
|
||||
const ICON_FALLBACK = {
|
||||
view: '👁',
|
||||
ticket: '🎫',
|
||||
calendar: '📅',
|
||||
flame: '🔥',
|
||||
'check-circle': '✓',
|
||||
glucose: '💧',
|
||||
heart: '❤',
|
||||
activity: '🏃',
|
||||
users: '👥',
|
||||
user: '👤',
|
||||
person: '👤',
|
||||
send: '➤',
|
||||
'alert-triangle': '⚠',
|
||||
minus: '—',
|
||||
droplet: '💧',
|
||||
sparkles: '✨',
|
||||
trophy: '🏆',
|
||||
share: '↗',
|
||||
plus: '+',
|
||||
refresh: '↻',
|
||||
volume: '🔊',
|
||||
pause: '⏸',
|
||||
play: '▶',
|
||||
info: '!',
|
||||
sun: '☀',
|
||||
moon: '🌙',
|
||||
sunset: '☀',
|
||||
mic: '🎤',
|
||||
camera: '📷',
|
||||
bulb: '💡',
|
||||
leaf: '🥬',
|
||||
utensils: '🍴',
|
||||
egg: '🍳',
|
||||
home: '🏠',
|
||||
'plus-circle': '⊕',
|
||||
'chevron-right': '›'
|
||||
}
|
||||
|
||||
const strokeColor = computed(() => {
|
||||
const c = String(props.color || '#204E2B').trim()
|
||||
return /^#[0-9A-Fa-f]{3,8}$/.test(c) ? c : '#204E2B'
|
||||
})
|
||||
|
||||
const iconSrc = computed(() => {
|
||||
const path = ICON_PATHS[props.name] || ICON_PATHS.view
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${strokeColor.value}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
|
||||
return svgToDataUrl(svg)
|
||||
})
|
||||
|
||||
const fallbackGlyph = computed(() => ICON_FALLBACK[props.name] || ICON_FALLBACK.view)
|
||||
|
||||
function onImageError() {
|
||||
useFallback.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tj-icon-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tj-icon-wrap--sm {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
}
|
||||
.tj-icon-wrap--md {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
.tj-icon-wrap--lg {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
}
|
||||
.tj-icon-wrap--xl {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
}
|
||||
.tj-icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.tj-icon-fallback {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
.tj-icon-fallback--sm {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.tj-icon-fallback--md {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
.tj-icon-fallback--lg {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
.tj-icon-fallback--xl {
|
||||
font-size: 42rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { requestAiStream, parsePartialDietPlainText } from '../utils/aiStreamRequest.js'
|
||||
|
||||
const DIET_AI_PREFILL_KEY = 'tongji_diet_ai_prefill'
|
||||
|
||||
const emptyDietAiRecommend = () => ({
|
||||
breakfast: '',
|
||||
lunch: '',
|
||||
dinner: '',
|
||||
tips: '',
|
||||
avoid: [],
|
||||
analysis: [],
|
||||
exercise: null,
|
||||
disclaimer: '',
|
||||
rules_summary: '',
|
||||
source: '',
|
||||
date: ''
|
||||
})
|
||||
|
||||
/** AI 饮食助手(今日推荐 + 能不能吃) */
|
||||
export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage }) {
|
||||
const dietAiRecommend = ref(emptyDietAiRecommend())
|
||||
const dietAiLoading = ref(false)
|
||||
const dietAiStreaming = ref(false)
|
||||
const dietAiReplacing = ref(false)
|
||||
const dietAiStreamField = ref('')
|
||||
let dietStreamBuffer = ''
|
||||
const dietAiAskText = ref('')
|
||||
const dietAiAsking = ref(false)
|
||||
const dietAiAskResult = ref({ advice: '', level: '', level_label: '', portion: '', food: '' })
|
||||
|
||||
const hasDietAiMealContent = computed(() => {
|
||||
const r = dietAiRecommend.value
|
||||
return !!(r.breakfast || r.lunch || r.dinner || r.tips)
|
||||
})
|
||||
|
||||
function detectDietStreamField(text) {
|
||||
const tail = String(text || '').match(/(?:^|\n)(早餐|午餐|晚餐|提示|少碰)[::]\s*([^\n]*)$/)
|
||||
if (!tail) return 'breakfast'
|
||||
const map = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' }
|
||||
return map[tail[1]] || 'breakfast'
|
||||
}
|
||||
|
||||
function mergePartialDietFromStream() {
|
||||
const partial = parsePartialDietPlainText(dietStreamBuffer)
|
||||
const next = { ...dietAiRecommend.value }
|
||||
for (const key of ['breakfast', 'lunch', 'dinner', 'tips']) {
|
||||
if (partial[key] != null) next[key] = partial[key]
|
||||
}
|
||||
if (Array.isArray(partial.avoid)) next.avoid = partial.avoid
|
||||
dietAiRecommend.value = next
|
||||
dietAiStreamField.value = detectDietStreamField(dietStreamBuffer)
|
||||
}
|
||||
|
||||
function applyDietRecommendPayload(payload) {
|
||||
if (!payload) return
|
||||
const prevAnalysis = dietAiRecommend.value.analysis
|
||||
const prevExercise = dietAiRecommend.value.exercise
|
||||
dietAiRecommend.value = {
|
||||
...emptyDietAiRecommend(),
|
||||
...payload,
|
||||
avoid: Array.isArray(payload.avoid) ? payload.avoid : [],
|
||||
analysis: Array.isArray(payload.analysis) && payload.analysis.length
|
||||
? payload.analysis
|
||||
: (Array.isArray(prevAnalysis) ? prevAnalysis : []),
|
||||
exercise: (payload.exercise && typeof payload.exercise === 'object')
|
||||
? payload.exercise
|
||||
: (prevExercise || null)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDietDonePayload(payload) {
|
||||
if (!payload) return payload
|
||||
if (payload.source !== 'rule' || !dietStreamBuffer) return payload
|
||||
|
||||
const streamed = parsePartialDietPlainText(dietStreamBuffer)
|
||||
if (!streamed.breakfast) return payload
|
||||
|
||||
return {
|
||||
...emptyDietAiRecommend(),
|
||||
...payload,
|
||||
breakfast: streamed.breakfast,
|
||||
lunch: streamed.lunch || payload.lunch || '',
|
||||
dinner: streamed.dinner || payload.dinner || '',
|
||||
tips: streamed.tips || payload.tips || '',
|
||||
avoid: (Array.isArray(streamed.avoid) && streamed.avoid.length) ? streamed.avoid : (payload.avoid || []),
|
||||
source: 'ai',
|
||||
disclaimer: 'AI 建议供参考,仍请结合医嘱与血糖监测。'
|
||||
}
|
||||
}
|
||||
|
||||
function onDietAiStreamEvent(event, payload) {
|
||||
if (event === 'analysis') {
|
||||
if (Array.isArray(payload?.analysis)) {
|
||||
dietAiRecommend.value = { ...dietAiRecommend.value, analysis: payload.analysis }
|
||||
}
|
||||
} else if (event === 'exercise') {
|
||||
if (payload?.exercise && typeof payload.exercise === 'object') {
|
||||
dietAiRecommend.value = { ...dietAiRecommend.value, exercise: payload.exercise }
|
||||
}
|
||||
} else if (event === 'delta') {
|
||||
dietAiStreaming.value = true
|
||||
if (payload.buffer != null) {
|
||||
dietStreamBuffer = payload.buffer
|
||||
} else {
|
||||
dietStreamBuffer += payload.text || ''
|
||||
}
|
||||
mergePartialDietFromStream()
|
||||
} else if (event === 'done') {
|
||||
applyDietRecommendPayload(resolveDietDonePayload(payload))
|
||||
dietStreamBuffer = ''
|
||||
dietAiStreamField.value = ''
|
||||
} else if (event === 'error') {
|
||||
showUserToast(formatUserMessage(payload?.message, '饮食建议加载失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDietAiRecommendFallback(refresh) {
|
||||
const res = await proxy.apiUrl({
|
||||
url: '/api/tcm/dailyDietAiRecommend',
|
||||
method: 'GET',
|
||||
timeout: 35000,
|
||||
data: {
|
||||
diagnosis_id: diagnosisId.value,
|
||||
refresh: refresh ? 1 : 0,
|
||||
...(refresh ? { _t: Date.now() } : {})
|
||||
}
|
||||
}, false)
|
||||
if (res?.code === 1 && res.data) {
|
||||
applyDietRecommendPayload(res.data)
|
||||
} else if (res?.msg) {
|
||||
showUserToast(res.msg)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDietAiRecommend(refresh = false) {
|
||||
if (!diagnosisId.value || dietAiLoading.value) return
|
||||
dietAiLoading.value = true
|
||||
dietAiStreaming.value = false
|
||||
dietAiReplacing.value = !!refresh
|
||||
dietStreamBuffer = ''
|
||||
dietAiStreamField.value = ''
|
||||
if (refresh) {
|
||||
applyDietRecommendPayload(emptyDietAiRecommend())
|
||||
}
|
||||
try {
|
||||
await requestAiStream({
|
||||
baseUrl: proxy.$url,
|
||||
url: '/api/tcm/dailyDietAiRecommendStream',
|
||||
method: 'GET',
|
||||
data: {
|
||||
diagnosis_id: diagnosisId.value,
|
||||
refresh: refresh ? 1 : 0,
|
||||
...(refresh ? { _t: Date.now() } : {})
|
||||
},
|
||||
onEvent: onDietAiStreamEvent,
|
||||
fallback: () => fetchDietAiRecommendFallback(refresh)
|
||||
})
|
||||
} catch (e) {
|
||||
try {
|
||||
await fetchDietAiRecommendFallback(refresh)
|
||||
} catch (e2) {
|
||||
showUserToast('饮食建议加载失败')
|
||||
}
|
||||
} finally {
|
||||
dietAiLoading.value = false
|
||||
dietAiStreaming.value = false
|
||||
dietAiReplacing.value = false
|
||||
dietAiStreamField.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function refreshDietAiRecommend() {
|
||||
if (dietAiLoading.value) return
|
||||
fetchDietAiRecommend(true)
|
||||
}
|
||||
|
||||
async function askDietAiFoodFallback(q) {
|
||||
const res = await proxy.apiUrl({
|
||||
url: '/api/tcm/dailyDietAiAsk',
|
||||
method: 'POST',
|
||||
timeout: 35000,
|
||||
data: {
|
||||
diagnosis_id: diagnosisId.value,
|
||||
question: q
|
||||
}
|
||||
}, false)
|
||||
if (res?.code === 1 && res.data) {
|
||||
dietAiAskResult.value = {
|
||||
advice: res.data.advice || '',
|
||||
level: res.data.level || '',
|
||||
level_label: res.data.level_label || '',
|
||||
portion: res.data.portion || '',
|
||||
food: res.data.food || q
|
||||
}
|
||||
} else {
|
||||
showUserToast(formatUserMessage(res?.msg, '咨询失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function askDietAiFood() {
|
||||
const q = String(dietAiAskText.value || '').trim()
|
||||
if (!q) {
|
||||
showUserToast('请输入想咨询的食物')
|
||||
return
|
||||
}
|
||||
if (!diagnosisId.value) return
|
||||
dietAiAsking.value = true
|
||||
dietAiAskResult.value = { advice: '', level: '', level_label: '', portion: '', food: '' }
|
||||
try {
|
||||
await requestAiStream({
|
||||
baseUrl: proxy.$url,
|
||||
url: '/api/tcm/dailyDietAiAskStream',
|
||||
method: 'POST',
|
||||
data: {
|
||||
diagnosis_id: diagnosisId.value,
|
||||
question: q
|
||||
},
|
||||
onEvent: (event, payload) => {
|
||||
if (event === 'start' && !dietAiAskResult.value.advice) {
|
||||
dietAiAskResult.value = { ...dietAiAskResult.value, advice: payload.message || '正在想…' }
|
||||
} else if (event === 'delta') {
|
||||
dietAiAskResult.value = {
|
||||
...dietAiAskResult.value,
|
||||
advice: payload.advice || dietAiAskResult.value.advice
|
||||
}
|
||||
} else if (event === 'done') {
|
||||
dietAiAskResult.value = {
|
||||
advice: payload.advice || '',
|
||||
level: payload.level || '',
|
||||
level_label: payload.level_label || '',
|
||||
portion: payload.portion || '',
|
||||
food: payload.food || q
|
||||
}
|
||||
} else if (event === 'error') {
|
||||
showUserToast(formatUserMessage(payload?.message, '咨询失败'))
|
||||
}
|
||||
},
|
||||
fallback: () => askDietAiFoodFallback(q)
|
||||
})
|
||||
} catch (e) {
|
||||
try {
|
||||
await askDietAiFoodFallback(q)
|
||||
} catch (e2) {
|
||||
showUserToast('网络异常,请稍后再试')
|
||||
}
|
||||
} finally {
|
||||
dietAiAsking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onDietAiAskInput(e) {
|
||||
dietAiAskText.value = e?.detail?.value ?? ''
|
||||
}
|
||||
|
||||
function saveDietPrefillToStorage() {
|
||||
const r = dietAiRecommend.value
|
||||
if (!r.breakfast) return
|
||||
try {
|
||||
uni.setStorageSync(DIET_AI_PREFILL_KEY, {
|
||||
breakfast: r.breakfast,
|
||||
lunch: r.lunch,
|
||||
dinner: r.dinner,
|
||||
tips: r.tips || ''
|
||||
})
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function goMoreDietForm(withPrefill = false) {
|
||||
if (withPrefill) saveDietPrefillToStorage()
|
||||
uni.navigateTo({ url: '/tongji/pages/more?openDiet=1' })
|
||||
}
|
||||
|
||||
async function applyDietAiToForm() {
|
||||
if (!dietAiRecommend.value.breakfast) {
|
||||
await fetchDietAiRecommend(false)
|
||||
}
|
||||
goMoreDietForm(true)
|
||||
}
|
||||
|
||||
function openDietForm() {
|
||||
goMoreDietForm(false)
|
||||
}
|
||||
|
||||
return {
|
||||
dietAiRecommend,
|
||||
dietAiLoading,
|
||||
dietAiStreaming,
|
||||
dietAiReplacing,
|
||||
dietAiStreamField,
|
||||
dietAiAskText,
|
||||
dietAiAsking,
|
||||
dietAiAskResult,
|
||||
hasDietAiMealContent,
|
||||
fetchDietAiRecommend,
|
||||
refreshDietAiRecommend,
|
||||
askDietAiFood,
|
||||
onDietAiAskInput,
|
||||
applyDietAiToForm,
|
||||
openDietForm
|
||||
}
|
||||
}
|
||||
|
||||
/** more 页打开饮食表单时读取并清除预填 */
|
||||
export function consumeDietAiPrefill() {
|
||||
try {
|
||||
const data = uni.getStorageSync(DIET_AI_PREFILL_KEY)
|
||||
uni.removeStorageSync(DIET_AI_PREFILL_KEY)
|
||||
if (data && typeof data === 'object' && data.breakfast) return data
|
||||
} catch (e) {}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ref } from 'vue'
|
||||
import { formatUserMessage } from '../utils/tongjiHelpers.js'
|
||||
|
||||
/** 登录 / 就诊卡门禁(index 与 more 共用) */
|
||||
export function useTongjiAuth(proxy) {
|
||||
const authChecking = ref(false)
|
||||
let gateRedirected = false
|
||||
let authSessionPromise = null
|
||||
|
||||
function hasAuthToken() {
|
||||
return !!String(uni.getStorageSync('token') || '').trim()
|
||||
}
|
||||
|
||||
function clearAuthStorage() {
|
||||
uni.removeStorageSync('token')
|
||||
authSessionPromise = null
|
||||
}
|
||||
|
||||
function wxLoginGetCode() {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: (res) => {
|
||||
if (res && res.code) resolve(res.code)
|
||||
else reject(new Error('微信登录未返回 code'))
|
||||
},
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function mnpLoginWithCode(code) {
|
||||
const res = await proxy.apiUrl({
|
||||
url: '/api/login/mnpLogin',
|
||||
method: 'POST',
|
||||
data: { code }
|
||||
}, false)
|
||||
if (res && res.code === 1 && res.data && res.data.token) {
|
||||
uni.setStorageSync('token', res.data.token)
|
||||
uni.setStorageSync('userData', res.data)
|
||||
return res.data
|
||||
}
|
||||
clearAuthStorage()
|
||||
throw new Error(formatUserMessage(res?.msg, '登录失败'))
|
||||
}
|
||||
|
||||
async function doWxLogin() {
|
||||
const code = await wxLoginGetCode()
|
||||
await mnpLoginWithCode(code)
|
||||
return hasAuthToken()
|
||||
}
|
||||
|
||||
async function verifyOrLogin() {
|
||||
if (!hasAuthToken()) {
|
||||
return doWxLogin()
|
||||
}
|
||||
try {
|
||||
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
|
||||
if (res && res.code === 1 && res.data) {
|
||||
uni.setStorageSync('userData', res.data)
|
||||
return true
|
||||
}
|
||||
} catch (e) {
|
||||
/* 网络异常走重新登录 */
|
||||
}
|
||||
clearAuthStorage()
|
||||
try {
|
||||
return await doWxLogin()
|
||||
} catch (e2) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLoggedIn() {
|
||||
if (authSessionPromise) {
|
||||
return authSessionPromise
|
||||
}
|
||||
authSessionPromise = verifyOrLogin()
|
||||
.then((ok) => {
|
||||
if (!ok) authSessionPromise = null
|
||||
return !!ok
|
||||
})
|
||||
.catch(() => {
|
||||
authSessionPromise = null
|
||||
return false
|
||||
})
|
||||
return authSessionPromise
|
||||
}
|
||||
|
||||
function redirectToCardEntry(returnPath) {
|
||||
if (gateRedirected) return
|
||||
gateRedirected = true
|
||||
const returnUrl = encodeURIComponent(returnPath || '/tongji/pages/index')
|
||||
uni.redirectTo({
|
||||
url: `/pages/Card/edit_card?add=1&returnUrl=${returnUrl}`
|
||||
})
|
||||
}
|
||||
|
||||
function resetGateRedirect() {
|
||||
gateRedirected = false
|
||||
}
|
||||
|
||||
function isGateRedirected() {
|
||||
return gateRedirected
|
||||
}
|
||||
|
||||
return {
|
||||
authChecking,
|
||||
hasAuthToken,
|
||||
ensureLoggedIn,
|
||||
redirectToCardEntry,
|
||||
resetGateRedirect,
|
||||
isGateRedirected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# VitalMint Health (Stitch)
|
||||
|
||||
Source: Google Stitch MCP · project `Modern WeChat UI Redesign`
|
||||
Screen HTML: `design-system/stitch-weekly.html`
|
||||
|
||||
## Colors
|
||||
|
||||
- background / surface: `#f4fbf4`
|
||||
- primary: `#006c49`
|
||||
- primary-container: `#10b981`
|
||||
- tertiary-container (餐后): `#fc7c78`
|
||||
- on-surface: `#161d19`
|
||||
- error (high glucose): `#ba1a1a`
|
||||
- warning: `#ea580c`
|
||||
|
||||
## Layout (weekly.vue)
|
||||
|
||||
1. TopAppBar — 头像 + 问候 + 朗读按钮
|
||||
2. 录入今日血糖 — 全宽圆角主按钮
|
||||
3. 今日血糖 — 双列卡片 + 查看更多
|
||||
4. 血糖趋势 — 单卡片内含统计、图表、7/30 切换
|
||||
5. AI 饮食 — 渐变边框白底卡片
|
||||
6. 能不能吃 — 独立卡片
|
||||
|
||||
## Styles
|
||||
|
||||
- `styles/stitch-vitalmint-theme.scss` — 设计 token
|
||||
- `styles/weekly-stitch.scss` — 页面组件样式 (vm-*)
|
||||
@@ -0,0 +1,398 @@
|
||||
<!DOCTYPE html><html lang="zh-CN" style=""><head>
|
||||
<meta charset="utf-8">
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<title>血糖管理</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"on-error-container": "#93000a",
|
||||
"on-secondary-fixed-variant": "#3e4943",
|
||||
"on-background": "#161d19",
|
||||
"error-container": "#ffdad6",
|
||||
"surface-container-highest": "#dde4dd",
|
||||
"surface-container-high": "#e3eae3",
|
||||
"primary-fixed-dim": "#4edea3",
|
||||
"on-secondary-container": "#5b6760",
|
||||
"on-primary": "#ffffff",
|
||||
"inverse-surface": "#2b322d",
|
||||
"tertiary-fixed-dim": "#ffb3af",
|
||||
"primary": "#006c49",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"on-primary-fixed-variant": "#005236",
|
||||
"on-surface-variant": "#3c4a42",
|
||||
"surface-container": "#e8f0e9",
|
||||
"tertiary-container": "#fc7c78",
|
||||
"on-tertiary": "#ffffff",
|
||||
"on-tertiary-container": "#711419",
|
||||
"surface-container-low": "#eef6ee",
|
||||
"on-secondary-fixed": "#131e19",
|
||||
"inverse-primary": "#4edea3",
|
||||
"surface-tint": "#006c49",
|
||||
"secondary-fixed-dim": "#bdcac1",
|
||||
"on-primary-container": "#00422b",
|
||||
"on-secondary": "#ffffff",
|
||||
"secondary-container": "#d9e6dd",
|
||||
"tertiary": "#a43a3a",
|
||||
"on-error": "#ffffff",
|
||||
"secondary-fixed": "#d9e6dd",
|
||||
"background": "#f4fbf4",
|
||||
"tertiary-fixed": "#ffdad7",
|
||||
"outline": "#6c7a71",
|
||||
"primary-fixed": "#6ffbbe",
|
||||
"on-tertiary-fixed": "#410005",
|
||||
"outline-variant": "#bbcabf",
|
||||
"primary-container": "#10b981",
|
||||
"on-tertiary-fixed-variant": "#842225",
|
||||
"surface-variant": "#dde4dd",
|
||||
"on-primary-fixed": "#002113",
|
||||
"on-surface": "#161d19",
|
||||
"surface-bright": "#f4fbf4",
|
||||
"inverse-on-surface": "#ebf3eb",
|
||||
"surface": "#f4fbf4",
|
||||
"surface-dim": "#d4dcd5",
|
||||
"secondary": "#55615a",
|
||||
"error": "#ba1a1a"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.25rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"full": "9999px",
|
||||
"2xl": "1.5rem",
|
||||
"3xl": "2rem"
|
||||
},
|
||||
"spacing": {
|
||||
"container-margin": "20px",
|
||||
"stack-gap": "16px",
|
||||
"section-margin": "32px",
|
||||
"inline-gap": "12px",
|
||||
"card-padding": "20px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"body-lg": [
|
||||
"Manrope"
|
||||
],
|
||||
"headline-lg": [
|
||||
"Manrope"
|
||||
],
|
||||
"headline-md": [
|
||||
"Manrope"
|
||||
],
|
||||
"display-lg": [
|
||||
"Manrope"
|
||||
],
|
||||
"headline-lg-mobile": [
|
||||
"Manrope"
|
||||
],
|
||||
"label-md": [
|
||||
"Manrope"
|
||||
],
|
||||
"body-md": [
|
||||
"Manrope"
|
||||
]
|
||||
},
|
||||
"fontSize": {
|
||||
"body-lg": [
|
||||
"16px",
|
||||
{
|
||||
"lineHeight": "24px",
|
||||
"fontWeight": "500"
|
||||
}
|
||||
],
|
||||
"headline-lg": [
|
||||
"24px",
|
||||
{
|
||||
"lineHeight": "32px",
|
||||
"letterSpacing": "-0.01em",
|
||||
"fontWeight": "700"
|
||||
}
|
||||
],
|
||||
"headline-md": [
|
||||
"20px",
|
||||
{
|
||||
"lineHeight": "28px",
|
||||
"fontWeight": "700"
|
||||
}
|
||||
],
|
||||
"display-lg": [
|
||||
"32px",
|
||||
{
|
||||
"lineHeight": "40px",
|
||||
"letterSpacing": "-0.02em",
|
||||
"fontWeight": "800"
|
||||
}
|
||||
],
|
||||
"headline-lg-mobile": [
|
||||
"22px",
|
||||
{
|
||||
"lineHeight": "28px",
|
||||
"fontWeight": "700"
|
||||
}
|
||||
],
|
||||
"label-md": [
|
||||
"12px",
|
||||
{
|
||||
"lineHeight": "16px",
|
||||
"letterSpacing": "0.02em",
|
||||
"fontWeight": "600"
|
||||
}
|
||||
],
|
||||
"body-md": [
|
||||
"14px",
|
||||
{
|
||||
"lineHeight": "20px",
|
||||
"fontWeight": "400"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
.icon-fill {
|
||||
font-variation-settings: 'FILL' 1;
|
||||
}
|
||||
|
||||
.ambient-shadow {
|
||||
box-shadow: 0 12px 30px -10px rgba(0, 108, 73, 0.08), 0 4px 10px -4px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.btn-press:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-surface font-body-lg antialiased pb-24 md:pb-0 min-h-screen">
|
||||
<!-- Desktop Sidebar Shell (Hidden on Mobile) -->
|
||||
<div class="hidden md:flex fixed left-0 top-0 h-full w-64 bg-surface-container-lowest border-r border-surface-dim z-50 flex-col">
|
||||
<div class="p-6">
|
||||
<h1 class="text-headline-lg font-headline-lg text-primary">VitalMint</h1>
|
||||
</div>
|
||||
<nav class="flex-1 px-4 flex flex-col gap-2 mt-4">
|
||||
<a class="flex items-center gap-3 px-4 py-3 rounded-xl bg-primary-container text-on-primary-container font-bold" href="#">
|
||||
<span class="material-symbols-outlined icon-fill">home</span>
|
||||
<span class="">首页</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-on-surface-variant hover:bg-surface-container-high transition-colors" href="#">
|
||||
<span class="material-symbols-outlined">add_circle</span>
|
||||
<span class="">记录</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-on-surface-variant hover:bg-surface-container-high transition-colors" href="#">
|
||||
<span class="material-symbols-outlined">analytics</span>
|
||||
<span class="">动态</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-on-surface-variant hover:bg-surface-container-high transition-colors" href="#">
|
||||
<span class="material-symbols-outlined">person</span>
|
||||
<span class="">我的</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
<!-- Main Content Area -->
|
||||
<main class="md:ml-64 w-full max-w-[1200px] mx-auto min-h-screen flex flex-col md:flex-row">
|
||||
<!-- Left/Main Column: Dashboard -->
|
||||
<div class="flex-1 w-full">
|
||||
<!-- TopAppBar -->
|
||||
<header class="bg-surface dark:bg-surface-dim docked full-width top-0 flex justify-between items-center px-container-margin py-4 w-full sticky z-40">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-primary-container flex items-center justify-center overflow-hidden border-2 border-surface-container-lowest">
|
||||
<span class="material-symbols-outlined text-primary">person</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-headline-lg-mobile font-headline-lg-mobile text-on-surface dark:text-on-background">下午好,用户16295733</h1>
|
||||
<p class="text-label-md font-label-md text-on-surface-variant opacity-80">今日血糖已记录</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="w-10 h-10 rounded-full flex items-center justify-center hover:bg-surface-container dark:hover:bg-surface-container-high transition-colors text-primary dark:text-primary-fixed-dim">
|
||||
<span class="material-symbols-outlined" data-icon="volume_up">volume_up</span>
|
||||
</button>
|
||||
</header>
|
||||
<!-- Content Canvas -->
|
||||
<div class="px-container-margin pb-section-margin flex flex-col gap-6 mt-2">
|
||||
<!-- 1. Primary Action (Prominent) -->
|
||||
<section>
|
||||
<button class="w-full bg-primary text-on-primary h-14 rounded-2xl flex items-center justify-center gap-2 text-body-lg font-bold btn-press shadow-[0_8px_16px_rgba(0,108,73,0.2)] transition-all">
|
||||
<span class="material-symbols-outlined icon-fill">add</span>
|
||||
录入今日血糖
|
||||
</button>
|
||||
</section>
|
||||
<!-- 2. 血糖趋势 (Trends) - Moved to top -->
|
||||
<section class="bg-surface-container-lowest rounded-3xl p-5 ambient-shadow border border-surface-container-highest/30">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 class="text-headline-md font-headline-md text-on-surface">血糖趋势</h2>
|
||||
<p class="text-label-md font-label-md text-on-surface-variant">最近 7 天 · mmol/L</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-label-md font-label-md text-on-surface-variant">
|
||||
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-primary"></div>空腹</div>
|
||||
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-tertiary"></div>餐后</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Stats Row -->
|
||||
<div class="flex justify-between items-center bg-surface-container-low rounded-xl p-4 mb-6">
|
||||
<div class="flex flex-col items-center flex-1 border-r border-surface-container-highest">
|
||||
<span class="text-headline-md font-headline-md text-tertiary">1</span>
|
||||
<span class="text-label-md font-label-md text-on-surface-variant mt-1">天偏高</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center flex-1 border-r border-surface-container-highest">
|
||||
<span class="text-headline-md font-headline-md text-primary">1</span>
|
||||
<span class="text-label-md font-label-md text-on-surface-variant mt-1">天正常</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center flex-1">
|
||||
<span class="text-headline-md font-headline-md text-on-surface">2</span>
|
||||
<span class="text-label-md font-label-md text-on-surface-variant mt-1">天有记录</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Faux Chart Area -->
|
||||
<div class="h-[180px] w-full relative flex items-end pt-4 pb-6 border-b border-surface-container-highest border-dashed">
|
||||
<!-- Y Axis -->
|
||||
<div class="absolute left-0 top-0 bottom-6 flex flex-col justify-between text-[10px] text-on-surface-variant">
|
||||
<span class="">23.0</span>
|
||||
<span class="">18.0</span>
|
||||
<span class="">13.0</span>
|
||||
<span class="">8.0</span>
|
||||
<span class="">3.0</span>
|
||||
</div>
|
||||
<!-- Target Line -->
|
||||
<div class="absolute left-6 right-0 bottom-12 border-b border-tertiary border-dashed opacity-30"></div>
|
||||
<span class="absolute left-6 bottom-12 text-[10px] text-tertiary -translate-y-full mb-1">空腹阈 7</span>
|
||||
<!-- Chart Lines -->
|
||||
<div class="absolute inset-0 left-8 overflow-hidden">
|
||||
<div class="absolute bottom-6 right-8 w-1 h-[100px] bg-primary/20 rounded-t-full transform rotate-45 origin-bottom"></div>
|
||||
<div class="absolute bottom-[80px] right-2 w-1 h-[60px] bg-tertiary/20 rounded-t-full transform rotate-12 origin-bottom"></div>
|
||||
<div class="absolute right-[15%] bottom-[15%] w-2 h-2 rounded-full border-[1.5px] border-primary bg-white z-10"></div>
|
||||
<div class="absolute right-[5%] bottom-[75%] w-2 h-2 rounded-full border-[1.5px] border-tertiary bg-white z-10"></div>
|
||||
<!-- Tooltips -->
|
||||
<div class="absolute right-[2%] bottom-[85%] bg-tertiary text-white text-[10px] px-1.5 py-0.5 rounded font-bold">18</div>
|
||||
<div class="absolute right-[2%] bottom-[65%] bg-tertiary text-white text-[10px] px-1.5 py-0.5 rounded font-bold">20</div>
|
||||
</div>
|
||||
<!-- X Axis -->
|
||||
<div class="absolute left-8 right-0 bottom-0 flex justify-between text-[10px] text-on-surface-variant translate-y-full pt-2">
|
||||
<span class="">05-23</span>
|
||||
<span class="">05-25</span>
|
||||
<span class="">05-27</span>
|
||||
<span class="">05-29</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- 3. 最新测量 & 概览 (Combined Section) -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<!-- Latest Reading -->
|
||||
<div class="bg-error-container text-on-error-container rounded-3xl p-6 ambient-shadow relative overflow-hidden flex flex-col items-center text-center justify-center min-h-[160px]">
|
||||
<div class="absolute inset-0 opacity-10 pointer-events-none" style="background-image: radial-gradient(circle at 2px 2px, currentColor 1px, transparent 0); background-size: 16px 16px;"></div>
|
||||
<span class="text-label-md font-label-md uppercase tracking-wider mb-1 opacity-90 z-10">最新测量 · 餐后</span>
|
||||
<div class="flex items-baseline gap-1 z-10">
|
||||
<span class="text-[56px] font-extrabold leading-none tracking-tighter">20.00</span>
|
||||
<span class="text-body-lg font-body-lg opacity-80 font-medium">mmol/L</span>
|
||||
</div>
|
||||
<div class="mt-3 inline-flex items-center gap-1.5 bg-white/30 backdrop-blur-sm px-3 py-1 rounded-full z-10">
|
||||
<span class="material-symbols-outlined text-[16px] icon-fill">warning</span>
|
||||
<span class="text-label-md font-label-md font-bold">偏高,请遵医嘱</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Today's Summary -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-surface-container-lowest rounded-2xl p-4 ambient-shadow flex flex-col gap-2 border border-surface-container-highest/50">
|
||||
<span class="text-label-md font-label-md text-on-surface-variant">空腹</span>
|
||||
<div class="flex items-baseline gap-1 text-tertiary">
|
||||
<span class="text-headline-lg font-headline-lg">18.00</span>
|
||||
</div>
|
||||
<span class="text-[10px] text-tertiary bg-error-container/50 px-2 py-0.5 rounded-sm inline-block w-fit">偏高</span>
|
||||
</div>
|
||||
<div class="bg-surface-container-lowest rounded-2xl p-4 ambient-shadow flex flex-col gap-2 border border-surface-container-highest/50">
|
||||
<span class="text-label-md font-label-md text-on-surface-variant">餐后</span>
|
||||
<div class="flex items-baseline gap-1 text-tertiary">
|
||||
<span class="text-headline-lg font-headline-lg">20.00</span>
|
||||
</div>
|
||||
<span class="text-[10px] text-tertiary bg-error-container/50 px-2 py-0.5 rounded-sm inline-block w-fit">偏高</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- 4. AI 饮食建议 -->
|
||||
<section class="bg-gradient-to-br from-primary/10 to-primary/5 rounded-3xl p-5 border border-primary/10 relative overflow-hidden">
|
||||
<div class="absolute -right-8 -top-8 w-32 h-32 bg-primary-fixed-dim/20 rounded-full blur-2xl"></div>
|
||||
<div class="flex justify-between items-center mb-4 relative z-10">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-[18px]">temp_preferences_custom</span>
|
||||
</div>
|
||||
<h3 class="text-headline-md font-headline-md text-on-surface">AI 饮食建议</h3>
|
||||
</div>
|
||||
<button class="text-label-md font-label-md text-primary flex items-center gap-1 bg-white/50 px-3 py-1.5 rounded-full hover:bg-white transition-colors">
|
||||
<span class="material-symbols-outlined text-[14px]">refresh</span>
|
||||
换一换
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-body-md font-body-md text-on-surface-variant mb-4 relative z-10">读您近 7 天 · 30 天血糖,定制今日三餐</p>
|
||||
<div class="flex flex-col gap-3 relative z-10">
|
||||
<div class="bg-white/80 backdrop-blur-md rounded-2xl p-4 shadow-sm border border-white">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="material-symbols-outlined text-orange-400">light_mode</span>
|
||||
<span class="font-bold text-on-surface">早餐</span>
|
||||
</div>
|
||||
<p class="text-body-md font-body-md text-on-surface-variant">小米粥(少米多水)加煮鸡蛋一个</p>
|
||||
</div>
|
||||
<div class="bg-white/80 backdrop-blur-md rounded-2xl p-4 shadow-sm border border-white">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="material-symbols-outlined text-primary">eco</span>
|
||||
<span class="font-bold text-on-surface">午餐</span>
|
||||
</div>
|
||||
<p class="text-body-md font-body-md text-on-surface-variant">清蒸鱼块、蒜蓉炒苋菜、二米饭(小米掺大米)</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Spacer for bottom nav on mobile -->
|
||||
<section class="sticky bottom-20 md:bottom-6 z-30 px-container-margin md:px-0 mb-4">
|
||||
<div class="bg-white/90 backdrop-blur-md rounded-full p-2 pl-5 flex items-center gap-3 shadow-[0_8px_32px_rgba(0,108,73,0.15)] border border-primary/10">
|
||||
<div class="w-8 h-8 rounded-full bg-primary/10 text-primary flex items-center justify-center">
|
||||
<span class="material-symbols-outlined icon-fill text-[18px]">sparkles</span>
|
||||
</div>
|
||||
<input type="text" placeholder="输入食物名称或健康疑问,咨询 AI..." class="flex-1 bg-transparent border-none focus:ring-0 text-body-md placeholder:text-on-surface-variant/60 py-2">
|
||||
<button class="bg-primary text-on-primary h-10 w-10 rounded-full flex items-center justify-center btn-press shadow-sm">
|
||||
<span class="material-symbols-outlined">send</span>
|
||||
</button>
|
||||
</div>
|
||||
</section><div class="h-8 md:hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right Column: Secondary Info (Visible on Tablet/Desktop) -->
|
||||
<aside class="hidden lg:flex w-80 flex-col gap-section-margin pt-4 pr-container-margin">
|
||||
<div class="bg-surface-container-lowest rounded-3xl p-6 ambient-shadow">
|
||||
<h3 class="text-headline-md font-headline-md text-on-surface mb-4">运动降糖</h3>
|
||||
<div class="bg-error-container/30 rounded-xl p-4 mb-4">
|
||||
<p class="text-body-md font-body-md text-on-surface">近期血糖偏高,三顿饭后都动一动,最能帮着把糖降下来。</p>
|
||||
</div>
|
||||
<ul class="flex flex-col gap-3 text-body-md text-on-surface-variant">
|
||||
<li class="flex gap-2"><span class="font-bold text-tertiary">1</span> <span class="">早饭后快走 15 分钟</span></li>
|
||||
<li class="flex gap-2"><span class="font-bold text-tertiary">2</span> <span class="">午饭后原地踏步 20-30 分钟</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
<!-- BottomNavBar (Mobile Only) -->
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</body></html>
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"projects/15362588878567114380/screens/259c8c233434481881d0bacddb59fa7f","title":"血糖管理重构版","screenshot":{"name":"projects/15362588878567114380/files/c18dd3e3d7774a16897d78b40073b9f2","downloadUrl":"https://lh3.googleusercontent.com/aida/ADBb0ugpswBosy3eCG_j3kJlxCWpntXQGvRJvMuepINJTcB47Nf4ymWU81mrjex1ICPdAXn-p_JjyUokOtms4zIJpXL49692zt16mIJhEC16nfU9kH5hwAj1s7v3PlAZbUGWjYjPTdfVMaOCveS6Ohuh_IeyRTD6MmdsFZ2-H7Whg6zy4MiZeGLxRw0ckCOrNbTRObtuzk_dlUc49eDGeRagYtuL9xyS6pno5CiwBzSkGp872chdZVn9JOK-2n1P"},"htmlCode":{"name":"projects/15362588878567114380/files/ca56c13fc2d6481d9de60727f7b70f3f","downloadUrl":"https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ8Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpbCiVodG1sXzk5NTVmZjZjNTFkODQxMmY4MzIxNWNkYmIwNWI2MTMzEgsSBxCEnv3unRAYAZIBJAoKcHJvamVjdF9pZBIWQhQxNTM2MjU4ODg3ODU2NzExNDM4MA&filename=&opi=89354086","mimeType":"text/html"},"width":"780","height":"4122","deviceType":"MOBILE"}
|
||||
@@ -0,0 +1,351 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="light" lang="zh-CN"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>血糖管理</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"on-tertiary-fixed": "#410005",
|
||||
"surface-bright": "#f4fbf4",
|
||||
"on-primary-container": "#00422b",
|
||||
"surface-container": "#e8f0e9",
|
||||
"surface-container-high": "#e3eae3",
|
||||
"surface-dim": "#d4dcd5",
|
||||
"primary-container": "#10b981",
|
||||
"on-secondary-fixed": "#131e19",
|
||||
"surface": "#f4fbf4",
|
||||
"on-surface": "#161d19",
|
||||
"on-tertiary": "#ffffff",
|
||||
"tertiary-container": "#fc7c78",
|
||||
"secondary": "#55615a",
|
||||
"on-error-container": "#93000a",
|
||||
"on-background": "#161d19",
|
||||
"secondary-fixed-dim": "#bdcac1",
|
||||
"inverse-on-surface": "#ebf3eb",
|
||||
"surface-container-highest": "#dde4dd",
|
||||
"on-error": "#ffffff",
|
||||
"on-surface-variant": "#3c4a42",
|
||||
"secondary-fixed": "#d9e6dd",
|
||||
"inverse-surface": "#2b322d",
|
||||
"on-secondary": "#ffffff",
|
||||
"on-tertiary-fixed-variant": "#842225",
|
||||
"outline-variant": "#bbcabf",
|
||||
"on-primary-fixed-variant": "#005236",
|
||||
"on-secondary-container": "#5b6760",
|
||||
"on-primary": "#ffffff",
|
||||
"primary": "#006c49",
|
||||
"outline": "#6c7a71",
|
||||
"tertiary-fixed": "#ffdad7",
|
||||
"error": "#ba1a1a",
|
||||
"tertiary": "#a43a3a",
|
||||
"surface-tint": "#006c49",
|
||||
"inverse-primary": "#4edea3",
|
||||
"on-tertiary-container": "#711419",
|
||||
"primary-fixed-dim": "#4edea3",
|
||||
"on-secondary-fixed-variant": "#3e4943",
|
||||
"tertiary-fixed-dim": "#ffb3af",
|
||||
"surface-container-low": "#eef6ee",
|
||||
"error-container": "#ffdad6",
|
||||
"on-primary-fixed": "#002113",
|
||||
"background": "#f4fbf4",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"primary-fixed": "#6ffbbe",
|
||||
"surface-variant": "#dde4dd",
|
||||
"secondary-container": "#d9e6dd"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.25rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"full": "9999px",
|
||||
"2xl": "1.5rem"
|
||||
},
|
||||
"spacing": {
|
||||
"section-margin": "32px",
|
||||
"stack-gap": "16px",
|
||||
"card-padding": "20px",
|
||||
"inline-gap": "12px",
|
||||
"container-margin": "20px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"headline-md": ["Manrope"],
|
||||
"label-md": ["Manrope"],
|
||||
"body-lg": ["Manrope"],
|
||||
"headline-lg-mobile": ["Manrope"],
|
||||
"headline-lg": ["Manrope"],
|
||||
"display-lg": ["Manrope"],
|
||||
"body-md": ["Manrope"]
|
||||
},
|
||||
"boxShadow": {
|
||||
'ambient': '0 10px 30px -10px rgba(0, 108, 73, 0.08)',
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
.material-symbols-outlined.fill-icon {
|
||||
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-surface text-on-surface font-body-md antialiased md:max-w-[600px] md:mx-auto pb-24">
|
||||
<!-- TopAppBar -->
|
||||
<header class="flex justify-between items-center px-container-margin py-4 w-full bg-surface top-0 sticky z-40">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-surface-container-high flex items-center justify-center overflow-hidden">
|
||||
<span class="material-symbols-outlined text-outline">person</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-headline-lg-mobile font-headline-lg-mobile text-on-surface">下午好,用户16295733</h1>
|
||||
<p class="text-label-md font-label-md text-on-surface-variant">今日血糖已记录</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="flex flex-col items-center justify-center bg-surface-container-low border border-outline-variant rounded-xl px-3 py-1.5 hover:bg-surface-container transition-colors">
|
||||
<span class="material-symbols-outlined text-primary mb-0.5">volume_up</span>
|
||||
<span class="text-[10px] font-label-md text-on-surface-variant">朗读血糖</span>
|
||||
</button>
|
||||
</header>
|
||||
<main class="px-container-margin flex flex-col gap-section-margin mt-4">
|
||||
<!-- Primary Action -->
|
||||
<section>
|
||||
<button class="w-full bg-primary text-on-primary rounded-full py-4 flex items-center justify-center gap-2 hover:bg-on-primary-fixed-variant transition-colors shadow-ambient active:scale-95 duration-150">
|
||||
<span class="material-symbols-outlined">add</span>
|
||||
<span class="text-body-lg font-body-lg font-bold">录入今日血糖</span>
|
||||
</button>
|
||||
</section>
|
||||
<!-- Current Status Cards -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="text-headline-md font-headline-md">今日血糖</h2>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- Fasting -->
|
||||
<div class="bg-surface-container-lowest rounded-2xl p-card-padding shadow-ambient border border-error-container">
|
||||
<div class="text-label-md font-label-md text-on-surface-variant mb-1">空腹</div>
|
||||
<div class="flex items-baseline gap-1 mb-2">
|
||||
<span class="text-display-lg font-display-lg text-error">18.00</span>
|
||||
<span class="text-label-md font-label-md text-error">mmol/L</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-error bg-error-container/30 px-2 py-1 rounded-md w-fit">
|
||||
<span class="material-symbols-outlined text-[14px]">warning</span>
|
||||
<span class="text-[10px] font-label-md">偏高,请遵医嘱</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Post-meal -->
|
||||
<div class="bg-surface-container-lowest rounded-2xl p-card-padding shadow-ambient border border-error-container">
|
||||
<div class="text-label-md font-label-md text-on-surface-variant mb-1">餐后</div>
|
||||
<div class="flex items-baseline gap-1 mb-2">
|
||||
<span class="text-display-lg font-display-lg text-error">20.00</span>
|
||||
<span class="text-label-md font-label-md text-error">mmol/L</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-error bg-error-container/30 px-2 py-1 rounded-md w-fit">
|
||||
<span class="material-symbols-outlined text-[14px]">warning</span>
|
||||
<span class="text-[10px] font-label-md">偏高,请遵医嘱</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="w-full flex items-center justify-between bg-surface-container-lowest rounded-xl p-4 shadow-ambient mt-2">
|
||||
<div>
|
||||
<div class="text-body-md font-body-md font-bold">查看更多</div>
|
||||
<div class="text-label-md font-label-md text-on-surface-variant">健康日历 · 运动 · 家人互动</div>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-outline">chevron_right</span>
|
||||
</button>
|
||||
</section>
|
||||
<!-- Data Overview: Trend -->
|
||||
<section class="bg-surface-container-lowest rounded-2xl p-card-padding shadow-ambient">
|
||||
<div class="flex justify-between items-end mb-4">
|
||||
<div>
|
||||
<h2 class="text-headline-md font-headline-md">血糖趋势</h2>
|
||||
<p class="text-label-md font-label-md text-on-surface-variant">最近 7 天 · mmol/L</p>
|
||||
</div>
|
||||
<div class="flex gap-3 text-label-md font-label-md">
|
||||
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-primary"></div>空腹</div>
|
||||
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-tertiary-container"></div>餐后</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Summary Stats -->
|
||||
<div class="flex bg-surface-container-low rounded-xl p-3 mb-6 divide-x divide-outline-variant">
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<span class="text-headline-md font-headline-md text-error">1</span>
|
||||
<span class="text-label-md font-label-md text-on-surface-variant">天偏高</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<span class="text-headline-md font-headline-md text-primary">1</span>
|
||||
<span class="text-label-md font-label-md text-on-surface-variant">天正常</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<span class="text-headline-md font-headline-md text-on-surface">2</span>
|
||||
<span class="text-label-md font-label-md text-on-surface-variant">天有记录</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Chart Area Placeholder -->
|
||||
<div class="relative h-48 w-full mb-6 border-b border-l border-outline-variant">
|
||||
<!-- Y Axis Labels -->
|
||||
<div class="absolute -left-6 top-0 bottom-0 flex flex-col justify-between text-[10px] text-on-surface-variant">
|
||||
<span>23.0</span>
|
||||
<span>18.0</span>
|
||||
<span>13.0</span>
|
||||
<span>8.0</span>
|
||||
<span>3.0</span>
|
||||
</div>
|
||||
<!-- X Axis Labels -->
|
||||
<div class="absolute -bottom-5 left-0 right-0 flex justify-between text-[10px] text-on-surface-variant">
|
||||
<span>05-23</span>
|
||||
<span>05-25</span>
|
||||
<span>05-27</span>
|
||||
<span>05-29</span>
|
||||
</div>
|
||||
<!-- Threshold Line -->
|
||||
<div class="absolute bottom-[25%] w-full border-t border-dashed border-error opacity-50">
|
||||
<span class="absolute -top-4 text-[10px] text-error">空腹阈值 7</span>
|
||||
</div>
|
||||
<!-- Abstract Chart representation -->
|
||||
<svg class="absolute inset-0 h-full w-full" preserveaspectratio="none" viewbox="0 0 100 100">
|
||||
<path d="M 80 80 L 95 30" fill="none" stroke="#006c49" stroke-width="2"></path>
|
||||
<path d="M 85 85 L 95 15" fill="none" stroke="#fc7c78" stroke-width="2"></path>
|
||||
<circle cx="80" cy="80" fill="#ffffff" r="2" stroke="#006c49" stroke-width="1.5"></circle>
|
||||
<circle cx="95" cy="30" fill="#ffffff" r="2" stroke="#006c49" stroke-width="1.5"></circle>
|
||||
<circle cx="85" cy="85" fill="#ffffff" r="2" stroke="#fc7c78" stroke-width="1.5"></circle>
|
||||
<circle cx="95" cy="15" fill="#ffffff" r="2" stroke="#fc7c78" stroke-width="1.5"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Time Toggles -->
|
||||
<div class="flex bg-surface-container-low rounded-full p-1 w-full max-w-[240px] mx-auto mt-8">
|
||||
<button class="flex-1 py-2 text-label-md font-label-md bg-primary text-on-primary rounded-full shadow-sm">最近 7 天</button>
|
||||
<button class="flex-1 py-2 text-label-md font-label-md text-on-surface-variant rounded-full">最近 30 天</button>
|
||||
</div>
|
||||
</section>
|
||||
<!-- AI Nutrition Section -->
|
||||
<section class="bg-gradient-to-br from-primary-container/20 to-primary/10 rounded-2xl p-[2px] shadow-ambient overflow-hidden relative">
|
||||
<div class="bg-surface-container-lowest rounded-2xl p-card-padding relative z-10">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-on-primary text-[18px]">auto_awesome</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-body-lg font-body-lg font-bold">AI 饮食建议</h3>
|
||||
<span class="text-[10px] bg-primary/10 text-primary px-2 py-0.5 rounded-full border border-primary/20">智能定制</span>
|
||||
</div>
|
||||
<p class="text-[10px] text-on-surface-variant mt-0.5">读您近 7 天 · 30 天血糖,定制今日三餐</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="flex items-center gap-1 text-label-md font-label-md text-primary bg-primary/10 px-3 py-1.5 rounded-full">
|
||||
<span class="material-symbols-outlined text-[14px]">refresh</span>换一换
|
||||
</button>
|
||||
</div>
|
||||
<!-- Meals List -->
|
||||
<div class="flex flex-col gap-3 mb-4">
|
||||
<div class="text-label-md font-label-md text-primary font-bold flex items-center gap-1">
|
||||
<div class="w-1 h-3 bg-primary rounded-full"></div> 今日三餐
|
||||
</div>
|
||||
<!-- Breakfast -->
|
||||
<div class="bg-surface p-3 rounded-xl border border-surface-container-highest">
|
||||
<div class="flex items-center gap-1.5 mb-1.5">
|
||||
<span class="material-symbols-outlined text-tertiary-container text-[16px] fill-icon">wb_sunny</span>
|
||||
<span class="text-label-md font-label-md font-bold">早餐</span>
|
||||
</div>
|
||||
<p class="text-body-md font-body-md">小米粥(少米多水)加煮鸡蛋一个</p>
|
||||
</div>
|
||||
<!-- Lunch -->
|
||||
<div class="bg-primary/5 p-3 rounded-xl border-l-2 border-primary">
|
||||
<div class="flex items-center gap-1.5 mb-1.5">
|
||||
<span class="material-symbols-outlined text-primary text-[16px] fill-icon">wb_twilight</span>
|
||||
<span class="text-label-md font-label-md font-bold text-primary">午餐</span>
|
||||
</div>
|
||||
<p class="text-body-md font-body-md">清蒸鱼块、蒜蓉炒苋菜、二米饭(小米掺大米)</p>
|
||||
</div>
|
||||
<!-- Dinner -->
|
||||
<div class="bg-surface p-3 rounded-xl border border-surface-container-highest">
|
||||
<div class="flex items-center gap-1.5 mb-1.5">
|
||||
<span class="material-symbols-outlined text-[#7e85cc] text-[16px] fill-icon">dark_mode</span>
|
||||
<span class="text-label-md font-label-md font-bold">晚餐</span>
|
||||
</div>
|
||||
<p class="text-body-md font-body-md">玉米糝粥(稀)配凉拌黄瓜</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tips -->
|
||||
<div class="bg-surface-container-low rounded-xl p-3 mb-4 text-body-md font-body-md text-on-surface-variant flex gap-2">
|
||||
<span class="material-symbols-outlined text-primary text-[16px] mt-0.5">info</span>
|
||||
<p>小贴士:先吃菜和肉再吃主食,每餐七分饱,血糖高就少喝粥汤。</p>
|
||||
</div>
|
||||
<!-- Avoid List -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-1 text-error text-label-md font-label-md mb-2">
|
||||
<span class="material-symbols-outlined text-[14px]">warning</span> 今日尽量少碰
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">白馒头</span>
|
||||
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">油条</span>
|
||||
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">粘豆包</span>
|
||||
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">糯米饭</span>
|
||||
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">西瓜</span>
|
||||
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">含糖饮料</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button class="flex-1 bg-primary text-on-primary rounded-xl py-3 text-body-md font-body-md font-bold hover:bg-on-primary-fixed-variant transition-colors">填入今日饮食</button>
|
||||
<button class="flex-1 bg-surface-container border border-outline-variant text-on-surface rounded-xl py-3 text-body-md font-body-md font-bold hover:bg-surface-container-high transition-colors">手动记录</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Decorative background elements -->
|
||||
<div class="absolute top-0 right-0 w-32 h-32 bg-primary/10 rounded-full blur-2xl -mr-10 -mt-10 pointer-events-none"></div>
|
||||
<div class="absolute bottom-0 left-0 w-24 h-24 bg-tertiary-container/10 rounded-full blur-xl -ml-10 -mb-10 pointer-events-none"></div>
|
||||
</section>
|
||||
<!-- Ask AI -->
|
||||
<section class="bg-surface-container-lowest rounded-2xl p-4 shadow-ambient border border-surface-container-highest">
|
||||
<h3 class="text-body-md font-body-md font-bold mb-1">能不能吃?</h3>
|
||||
<p class="text-[10px] text-on-surface-variant mb-3">输入食物名称,结合您的档案判断</p>
|
||||
<div class="flex gap-2">
|
||||
<input class="flex-1 bg-surface rounded-xl border border-outline-variant px-3 py-2 text-body-md focus:ring-2 focus:ring-primary focus:border-primary outline-none" placeholder="如:白馒头、地瓜、粘豆包" type="text"/>
|
||||
<button class="bg-primary text-on-primary px-4 py-2 rounded-xl flex items-center gap-1 hover:bg-on-primary-fixed-variant transition-colors">
|
||||
<span class="material-symbols-outlined text-[18px]">auto_awesome</span>
|
||||
<span class="text-body-md font-body-md font-bold">问 AI</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Disclaimer -->
|
||||
<footer class="text-center py-6">
|
||||
<p class="text-[10px] text-outline">数据仅供参考,请遵医嘱</p>
|
||||
</footer>
|
||||
</main>
|
||||
<!-- BottomNavBar -->
|
||||
<nav class="fixed bottom-0 w-full z-50 rounded-t-2xl shadow-[0_-4px_20px_0_rgba(0,0,0,0.05)] bg-surface-container-lowest md:hidden flex justify-around items-center h-16 px-4">
|
||||
<button class="flex flex-col items-center justify-center text-primary font-bold w-16">
|
||||
<span class="material-symbols-outlined fill-icon mb-1">home</span>
|
||||
<span class="text-[10px] font-label-md">首页</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant opacity-60 w-16 hover:bg-surface-container-high transition-all rounded-xl py-1">
|
||||
<span class="material-symbols-outlined mb-1">add_circle</span>
|
||||
<span class="text-[10px] font-label-md">记录</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant opacity-60 w-16 hover:bg-surface-container-high transition-all rounded-xl py-1">
|
||||
<span class="material-symbols-outlined mb-1">analytics</span>
|
||||
<span class="text-[10px] font-label-md">动态</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center text-on-surface-variant opacity-60 w-16 hover:bg-surface-container-high transition-all rounded-xl py-1">
|
||||
<span class="material-symbols-outlined mb-1">person</span>
|
||||
<span class="text-[10px] font-label-md">我的</span>
|
||||
</button>
|
||||
</nav>
|
||||
</body></html>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,993 @@
|
||||
/**
|
||||
|
||||
* 日常记录统一主题 — 与甄养堂小程序 TCM Care 品牌色一致
|
||||
|
||||
* 来源:pages/index/index.vue · tabBar selectedColor #204e2b
|
||||
|
||||
*/
|
||||
|
||||
|
||||
|
||||
.daily-page {
|
||||
|
||||
/* 品牌主色 */
|
||||
|
||||
--primary: #204e2b;
|
||||
|
||||
--primary-dark: #163d22;
|
||||
|
||||
--primary-container: #386641;
|
||||
|
||||
--primary-grad: linear-gradient(160deg, #204e2b 0%, #386641 100%);
|
||||
|
||||
--primary-light: #eef6ef;
|
||||
|
||||
--primary-soft: #cfe8d3;
|
||||
|
||||
--primary-glow: rgba(32, 78, 43, 0.14);
|
||||
|
||||
--on-primary-container: #afe2b3;
|
||||
|
||||
|
||||
|
||||
--success: #386641;
|
||||
|
||||
--success-light: #eef6ef;
|
||||
|
||||
|
||||
|
||||
--danger: #dc2626;
|
||||
|
||||
--danger-light: #fef2f2;
|
||||
|
||||
--danger-glow: rgba(220, 38, 38, 0.1);
|
||||
|
||||
|
||||
|
||||
--warning: #d97706;
|
||||
|
||||
--warning-light: #fff7ed;
|
||||
|
||||
|
||||
|
||||
--text-primary: #1b1c1a;
|
||||
|
||||
--text-secondary: #414941;
|
||||
|
||||
--text-muted: #727970;
|
||||
|
||||
|
||||
|
||||
--surface: #ffffff;
|
||||
|
||||
--surface-muted: #f4f4f0;
|
||||
|
||||
--border: #e3e2df;
|
||||
|
||||
--border-soft: rgba(32, 78, 43, 0.12);
|
||||
|
||||
|
||||
|
||||
--slate-50: #faf9f5;
|
||||
|
||||
--slate-100: #f4f4f0;
|
||||
|
||||
--slate-200: #e9e8e4;
|
||||
|
||||
--slate-300: #e3e2df;
|
||||
|
||||
--slate-400: #727970;
|
||||
|
||||
--slate-600: #414941;
|
||||
|
||||
--slate-900: #1b1c1a;
|
||||
|
||||
|
||||
|
||||
--shadow-premium: 0 8rpx 28rpx rgba(32, 78, 43, 0.06);
|
||||
|
||||
--shadow-sm: 0 4rpx 16rpx rgba(32, 78, 43, 0.05);
|
||||
|
||||
--shadow-glow-primary: 0 8rpx 20rpx rgba(32, 78, 43, 0.18);
|
||||
|
||||
--shadow-glow-danger: 0 8rpx 20rpx rgba(220, 38, 38, 0.12);
|
||||
|
||||
--border-premium: 1rpx solid var(--border-soft);
|
||||
|
||||
|
||||
|
||||
background: var(--slate-50);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Hero:品牌绿渐变 */
|
||||
|
||||
.daily-page .hero-bg {
|
||||
|
||||
background: var(--primary-grad);
|
||||
|
||||
&::after {
|
||||
|
||||
background: radial-gradient(circle at 88% 12%, rgba(255, 255, 255, 0.1) 0%, transparent 55%);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .hero-orb {
|
||||
|
||||
display: none;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .hero-pill.voice.speaking {
|
||||
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
|
||||
border-color: rgba(255, 255, 255, 0.95);
|
||||
|
||||
animation: none;
|
||||
|
||||
box-shadow: 0 0 0 3rpx rgba(255, 255, 255, 0.35);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .hero-status-chip.pending {
|
||||
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .hero-status-chip.done {
|
||||
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
|
||||
border-color: rgba(255, 255, 255, 0.32);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 指标卡 */
|
||||
|
||||
.daily-page .stat-fasting,
|
||||
|
||||
.daily-page .stat-postprandial,
|
||||
|
||||
.daily-page .stat-other {
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .stat-fasting .stat-card-tag,
|
||||
|
||||
.daily-page .stat-postprandial .stat-card-tag,
|
||||
|
||||
.daily-page .stat-other .stat-card-tag {
|
||||
|
||||
background: var(--primary-light);
|
||||
|
||||
color: var(--primary-dark);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .stat-card-value {
|
||||
|
||||
background: none;
|
||||
|
||||
-webkit-background-clip: unset;
|
||||
|
||||
background-clip: unset;
|
||||
|
||||
color: var(--text-primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .stat-card-value.is-high {
|
||||
|
||||
background: none;
|
||||
|
||||
color: var(--danger);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .stat-card-trend.trend-down {
|
||||
|
||||
background: var(--primary-light);
|
||||
|
||||
.stat-card-trend-arrow,
|
||||
|
||||
.stat-card-trend-delta {
|
||||
|
||||
color: var(--primary-dark);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .more-entry-arrow {
|
||||
|
||||
color: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .card-chip.active {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
box-shadow: var(--shadow-glow-primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .range-item.active {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
box-shadow: var(--shadow-glow-primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .legend-dot-fasting {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .legend-dot-postprandial {
|
||||
|
||||
background: var(--warning);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .streak-strip {
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border-color: var(--border-soft);
|
||||
|
||||
&::after {
|
||||
|
||||
background: radial-gradient(circle, rgba(32, 78, 43, 0.08) 0%, transparent 70%);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .streak-num {
|
||||
|
||||
color: var(--primary-dark);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 日历:品牌绿深浅 */
|
||||
|
||||
.daily-page .calendar-cell.level-1 .calendar-cell-mark,
|
||||
|
||||
.daily-page .calendar-legend-cell.level-1 {
|
||||
|
||||
background: var(--primary-soft);
|
||||
|
||||
border-color: #b8dcc0;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .calendar-cell.level-2 .calendar-cell-mark,
|
||||
|
||||
.daily-page .calendar-legend-cell.level-2 {
|
||||
|
||||
background: var(--on-primary-container);
|
||||
|
||||
border-color: #86c992;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .calendar-cell.level-3 .calendar-cell-mark,
|
||||
|
||||
.daily-page .calendar-legend-cell.level-3 {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
border-color: var(--primary-dark);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .day-block-tag.tag-blood,
|
||||
|
||||
.daily-page .day-block-tag.tag-diet,
|
||||
|
||||
.daily-page .day-block-tag.tag-exercise,
|
||||
|
||||
.daily-page .day-block-tag.tag-tracking {
|
||||
|
||||
background: var(--primary-light);
|
||||
|
||||
color: var(--primary-dark);
|
||||
|
||||
border: 1rpx solid var(--border-soft);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .day-block-tag.tag-diet,
|
||||
|
||||
.daily-page .day-block-tag.tag-exercise {
|
||||
|
||||
background: var(--surface-muted);
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
border-color: var(--border);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .pg-points-badge {
|
||||
|
||||
background: var(--primary-light);
|
||||
|
||||
border-color: var(--border-soft);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .pg-points-num {
|
||||
|
||||
color: var(--primary-dark);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .record-quick-chip.pending {
|
||||
|
||||
border-color: var(--border);
|
||||
|
||||
background: var(--surface-muted);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .record-quick-chip.done {
|
||||
|
||||
border-color: rgba(56, 102, 65, 0.35);
|
||||
|
||||
background: var(--success-light);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .task-action-btn.pending {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .water-btn {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .family-like-strip,
|
||||
|
||||
.daily-page .encourage-strip {
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border: 1rpx solid var(--border);
|
||||
|
||||
box-shadow: var(--shadow-premium);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .card-title,
|
||||
|
||||
.daily-page .hero-title {
|
||||
|
||||
color: inherit;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .report-share-btn,
|
||||
|
||||
.daily-page .report-share-btn::after {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
box-shadow: var(--shadow-glow-primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .input-modal-btn.primary {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .input-field-dot-fasting {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .input-field-dot-postprandial {
|
||||
|
||||
background: var(--primary-container);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .input-field-dot-other {
|
||||
|
||||
background: var(--text-muted);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .voice-switch.on .voice-switch-track {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .heatmap-foot-cta {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ========== 适老极简首页(index) ========== */
|
||||
|
||||
.daily-page .elder-header {
|
||||
|
||||
padding: 48rpx 32rpx 24rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-greet {
|
||||
|
||||
display: block;
|
||||
|
||||
font-size: 44rpx;
|
||||
|
||||
font-weight: 700;
|
||||
|
||||
color: var(--text-primary);
|
||||
|
||||
line-height: 1.35;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-status {
|
||||
|
||||
display: block;
|
||||
|
||||
margin-top: 16rpx;
|
||||
|
||||
font-size: 32rpx;
|
||||
|
||||
font-weight: 600;
|
||||
|
||||
line-height: 1.4;
|
||||
|
||||
|
||||
|
||||
&.is-done {
|
||||
|
||||
color: var(--success);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
&.is-pending {
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-card-scroll {
|
||||
|
||||
margin-top: 24rpx;
|
||||
|
||||
width: 100%;
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-card-row {
|
||||
|
||||
display: inline-flex;
|
||||
|
||||
flex-wrap: nowrap;
|
||||
|
||||
gap: 16rpx;
|
||||
|
||||
padding: 2rpx 0;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-card-chip {
|
||||
|
||||
flex-shrink: 0;
|
||||
|
||||
padding: 12rpx 28rpx;
|
||||
|
||||
border-radius: 999rpx;
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border: 2rpx solid var(--border);
|
||||
|
||||
|
||||
|
||||
text {
|
||||
|
||||
font-size: 28rpx;
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
&.active {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
border-color: var(--primary);
|
||||
|
||||
|
||||
|
||||
text {
|
||||
|
||||
color: #fff;
|
||||
|
||||
font-weight: 600;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-actions {
|
||||
|
||||
padding: 0 32rpx 24rpx;
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-direction: row;
|
||||
|
||||
align-items: stretch;
|
||||
|
||||
gap: 16rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-btn {
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
justify-content: center;
|
||||
|
||||
gap: 12rpx;
|
||||
|
||||
min-height: 112rpx;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
border-radius: 24rpx;
|
||||
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-btn-text {
|
||||
|
||||
font-size: 36rpx;
|
||||
|
||||
font-weight: 700;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-btn-primary {
|
||||
|
||||
flex: 8;
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
|
||||
|
||||
.elder-btn-text {
|
||||
|
||||
color: #fff;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-btn-voice {
|
||||
|
||||
flex: 2;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
gap: 8rpx;
|
||||
|
||||
padding: 12rpx 8rpx;
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border: 2rpx solid var(--border-soft);
|
||||
|
||||
|
||||
|
||||
.elder-btn-text {
|
||||
|
||||
color: var(--primary-dark);
|
||||
|
||||
font-size: 24rpx;
|
||||
|
||||
line-height: 1.25;
|
||||
|
||||
text-align: center;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
&.speaking {
|
||||
|
||||
border-color: var(--primary);
|
||||
|
||||
background: var(--primary-light);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
&.disabled {
|
||||
|
||||
opacity: 0.45;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-today-panel {
|
||||
|
||||
margin: 0 32rpx 24rpx;
|
||||
|
||||
padding: 32rpx;
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border-radius: 24rpx;
|
||||
|
||||
border: 1rpx solid var(--border);
|
||||
|
||||
box-shadow: var(--shadow-premium);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-panel-title {
|
||||
|
||||
display: block;
|
||||
|
||||
font-size: 34rpx;
|
||||
|
||||
font-weight: 700;
|
||||
|
||||
color: var(--text-primary);
|
||||
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-today-grid {
|
||||
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
gap: 20rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric {
|
||||
|
||||
padding: 24rpx;
|
||||
|
||||
border-radius: 20rpx;
|
||||
|
||||
background: var(--surface-muted);
|
||||
|
||||
border: 2rpx solid var(--border);
|
||||
|
||||
|
||||
|
||||
&.is-high {
|
||||
|
||||
border-color: rgba(220, 38, 38, 0.35);
|
||||
|
||||
background: var(--danger-light);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric-label {
|
||||
|
||||
display: block;
|
||||
|
||||
font-size: 28rpx;
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
margin-bottom: 12rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric-value-row {
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: baseline;
|
||||
|
||||
gap: 8rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric-value {
|
||||
|
||||
font-size: 56rpx;
|
||||
|
||||
font-weight: 800;
|
||||
|
||||
color: var(--text-primary);
|
||||
|
||||
line-height: 1.1;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric.is-high .elder-metric-value {
|
||||
|
||||
color: var(--danger);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric-unit {
|
||||
|
||||
font-size: 26rpx;
|
||||
|
||||
color: var(--text-muted);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric-flag {
|
||||
|
||||
display: block;
|
||||
|
||||
margin-top: 12rpx;
|
||||
|
||||
font-size: 26rpx;
|
||||
|
||||
color: var(--danger);
|
||||
|
||||
font-weight: 600;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-more-link {
|
||||
|
||||
margin: 0 32rpx 16rpx;
|
||||
|
||||
padding: 28rpx 32rpx;
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
justify-content: space-between;
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border-radius: 24rpx;
|
||||
|
||||
border: 1rpx solid var(--border);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-more-title {
|
||||
|
||||
display: block;
|
||||
|
||||
font-size: 32rpx;
|
||||
|
||||
font-weight: 700;
|
||||
|
||||
color: var(--text-primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-more-sub {
|
||||
|
||||
display: block;
|
||||
|
||||
margin-top: 8rpx;
|
||||
|
||||
font-size: 26rpx;
|
||||
|
||||
color: var(--text-muted);
|
||||
|
||||
line-height: 1.45;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-more-arrow {
|
||||
|
||||
font-size: 48rpx;
|
||||
|
||||
color: var(--primary);
|
||||
|
||||
font-weight: 300;
|
||||
|
||||
padding-left: 16rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .footer-tip {
|
||||
|
||||
text-align: center;
|
||||
|
||||
font-size: 26rpx;
|
||||
|
||||
color: var(--text-muted);
|
||||
|
||||
padding: 16rpx 32rpx 48rpx;
|
||||
|
||||
line-height: 1.6;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-chart-block .range-bar {
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.daily-page .glucose-history-block {
|
||||
|
||||
padding-bottom: 8rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Google Stitch MCP · VitalMint Health
|
||||
* 设计 token 与 stitch-weekly.html tailwind.config 一一对应
|
||||
*/
|
||||
|
||||
.weekly-page {
|
||||
--primary: #006c49;
|
||||
--on-primary: #ffffff;
|
||||
--on-primary-fixed-variant: #005236;
|
||||
--primary-container: #10b981;
|
||||
--on-primary-container: #00422b;
|
||||
--inverse-primary: #4edea3;
|
||||
|
||||
--surface: #f4fbf4;
|
||||
--on-surface: #161d19;
|
||||
--on-surface-variant: #3c4a42;
|
||||
--surface-container-lowest: #ffffff;
|
||||
--surface-container-low: #eef6ee;
|
||||
--surface-container: #e8f0e9;
|
||||
--surface-container-high: #e3eae3;
|
||||
--surface-container-highest: #dde4dd;
|
||||
--surface-variant: #dde4dd;
|
||||
|
||||
--outline: #6c7a71;
|
||||
--outline-variant: #bbcabf;
|
||||
|
||||
--error: #ba1a1a;
|
||||
--on-error-container: #93000a;
|
||||
--error-container: #ffdad6;
|
||||
--tertiary: #a43a3a;
|
||||
--tertiary-container: #fc7c78;
|
||||
|
||||
--st-container-margin: 40rpx;
|
||||
--st-section-gap: 64rpx;
|
||||
--st-card-padding: 40rpx;
|
||||
--st-stack-gap: 32rpx;
|
||||
--st-inline-gap: 24rpx;
|
||||
--st-radius-2xl: 48rpx;
|
||||
--st-radius-xl: 24rpx;
|
||||
--st-radius-lg: 16rpx;
|
||||
--st-shadow-ambient: 0 20rpx 60rpx -20rpx rgba(0, 108, 73, 0.08);
|
||||
|
||||
min-height: 100vh;
|
||||
background: var(--surface);
|
||||
color: var(--on-surface);
|
||||
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/* weekly.vue 录入弹窗 — 沿用 MCP 主色 */
|
||||
|
||||
.weekly-page .input-modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
backdrop-filter: blur(12px);
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
animation: input-modal-mask-in 0.38s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.weekly-page .input-modal {
|
||||
width: 100%;
|
||||
max-width: 750rpx;
|
||||
background: var(--surface-container-lowest);
|
||||
border-radius: 36rpx 36rpx 0 0;
|
||||
padding: 16rpx 0 calc(20rpx + env(safe-area-inset-bottom));
|
||||
box-shadow: 0 -12rpx 36rpx rgba(15, 23, 42, 0.12);
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: input-modal-rise 0.46s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes input-modal-mask-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes input-modal-rise {
|
||||
from { transform: translate3d(0, 100%, 0); opacity: 0.96; }
|
||||
to { transform: translate3d(0, 0, 0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes input-modal-content-in {
|
||||
from { opacity: 0; transform: translate3d(0, 24rpx, 0); }
|
||||
to { opacity: 1; transform: translate3d(0, 0, 0); }
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-grip {
|
||||
width: 80rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 999rpx;
|
||||
background: var(--surface-container-highest);
|
||||
margin: 0 auto 8rpx;
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12rpx 32rpx 16rpx;
|
||||
border-bottom: 1rpx solid var(--surface-container-low);
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-sub {
|
||||
font-size: 26rpx;
|
||||
color: var(--on-surface-variant);
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-close {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999rpx;
|
||||
background: var(--surface-container-low);
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-close-icon {
|
||||
font-size: 42rpx;
|
||||
color: var(--outline);
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.weekly-page .phone-gate-body {
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.weekly-page .phone-gate-tip {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: var(--on-surface-variant);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.weekly-page .phone-gate-gender-label {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.weekly-page .phone-gate-gender-row {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.weekly-page .phone-gate-gender-opt {
|
||||
flex: 1;
|
||||
min-height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--st-radius-xl);
|
||||
background: var(--surface-container-lowest);
|
||||
border: 2rpx solid var(--outline-variant);
|
||||
color: var(--on-surface-variant);
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.weekly-page .phone-gate-gender-opt.active {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.weekly-page .phone-gate-btn {
|
||||
width: 100%;
|
||||
min-height: 96rpx;
|
||||
line-height: 96rpx;
|
||||
border-radius: var(--st-radius-xl);
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.weekly-page .phone-gate-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-loading {
|
||||
min-height: 520rpx;
|
||||
padding: 120rpx 0;
|
||||
text-align: center;
|
||||
color: var(--outline);
|
||||
font-size: 30rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-body {
|
||||
flex: 1;
|
||||
min-height: 520rpx;
|
||||
padding: 20rpx 32rpx 28rpx;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-body-ready {
|
||||
animation: input-modal-content-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-tip {
|
||||
background: var(--surface-container-low);
|
||||
border: 1rpx dashed var(--outline-variant);
|
||||
border-radius: 14rpx;
|
||||
padding: 18rpx 20rpx;
|
||||
font-size: 26rpx;
|
||||
color: var(--on-surface-variant);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
.weekly-page .input-section-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
margin: 18rpx 0 14rpx;
|
||||
}
|
||||
|
||||
.weekly-page .input-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
padding: 14rpx 0;
|
||||
border-bottom: 1rpx solid var(--surface-container-low);
|
||||
min-height: 88rpx;
|
||||
}
|
||||
|
||||
.weekly-page .input-field-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
width: 180rpx;
|
||||
font-size: 30rpx;
|
||||
color: var(--on-surface);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.weekly-page .input-field-dot {
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.weekly-page .input-field-dot-fasting {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.weekly-page .input-field-dot-postprandial {
|
||||
background: var(--tertiary-container);
|
||||
}
|
||||
|
||||
.weekly-page .input-field-dot-other {
|
||||
background: var(--outline);
|
||||
}
|
||||
|
||||
.weekly-page .input-field-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--surface);
|
||||
border-radius: 16rpx;
|
||||
padding: 18rpx 20rpx;
|
||||
min-height: 76rpx;
|
||||
border: 2rpx solid transparent;
|
||||
}
|
||||
|
||||
.weekly-page .input-field-input input {
|
||||
width: 100%;
|
||||
font-size: 34rpx;
|
||||
color: var(--on-surface);
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.weekly-page .input-placeholder {
|
||||
color: var(--outline);
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.weekly-page .input-field-textarea textarea {
|
||||
width: 100%;
|
||||
min-height: 140rpx;
|
||||
background: var(--surface);
|
||||
border-radius: 14rpx;
|
||||
padding: 20rpx 22rpx;
|
||||
font-size: 30rpx;
|
||||
color: var(--on-surface);
|
||||
border: 2rpx solid var(--outline-variant);
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-actions {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
padding: 20rpx 32rpx 0;
|
||||
border-top: 1rpx solid var(--surface-container-low);
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-btn {
|
||||
flex: 1;
|
||||
height: 100rpx;
|
||||
border-radius: var(--st-radius-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-btn.primary {
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-btn.danger {
|
||||
flex: 0 0 180rpx;
|
||||
background: var(--error-container);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.weekly-page .input-modal-btn.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
/**
|
||||
* weekly.vue — 与 code.html (Stitch Modern WeChat UI) 1:1
|
||||
*/
|
||||
|
||||
.weekly-page {
|
||||
--st-shadow-ambient: 0 24rpx 60rpx -20rpx rgba(0, 108, 73, 0.08), 0 8rpx 20rpx -8rpx rgba(0, 0, 0, 0.03);
|
||||
--st-shadow-cta: 0 16rpx 32rpx rgba(0, 108, 73, 0.2);
|
||||
--st-shadow-float: 0 16rpx 64rpx rgba(0, 108, 73, 0.15);
|
||||
}
|
||||
|
||||
/* ===== Header ===== */
|
||||
.weekly-page .st-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: var(--st-container-margin);
|
||||
padding-right: var(--st-container-margin);
|
||||
padding-bottom: 32rpx;
|
||||
/* padding-top / padding-right 由 initHeaderSafeArea 动态设置 */
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.weekly-page .st-header-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.weekly-page .st-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--st-inline-gap);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.weekly-page .st-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-container);
|
||||
border: 4rpx solid var(--surface-container-lowest);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.weekly-page .st-greet {
|
||||
display: block;
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.27;
|
||||
color: var(--on-surface);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.weekly-page .st-status {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: var(--on-surface-variant);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.weekly-page .st-voice-round {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.weekly-page .st-voice-round.disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.weekly-page .st-card-scroll {
|
||||
margin: 0 var(--st-container-margin) 8rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.weekly-page .st-card-row {
|
||||
display: inline-flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-card-chip {
|
||||
padding: 12rpx 28rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: var(--on-surface-variant);
|
||||
background: var(--surface-container-lowest);
|
||||
border: 1rpx solid var(--outline-variant);
|
||||
}
|
||||
|
||||
.weekly-page .st-card-chip.active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
|
||||
/* ===== Main ===== */
|
||||
.weekly-page .st-main {
|
||||
padding: 0 var(--st-container-margin) 32rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 48rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-main-spacer {
|
||||
height: 32rpx;
|
||||
}
|
||||
|
||||
/* CTA — rounded-2xl h-14 */
|
||||
.weekly-page .st-cta {
|
||||
width: 100%;
|
||||
height: 112rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
border-radius: var(--st-radius-2xl);
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
box-shadow: var(--st-shadow-cta);
|
||||
}
|
||||
|
||||
.weekly-page .st-cta:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* ===== Chart card — rounded-3xl ===== */
|
||||
.weekly-page .st-chart-card {
|
||||
padding: 40rpx;
|
||||
border-radius: 48rpx;
|
||||
background: var(--surface-container-lowest);
|
||||
border: 1rpx solid rgba(221, 228, 221, 0.3);
|
||||
box-shadow: var(--st-shadow-ambient);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 48rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-title {
|
||||
display: block;
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-sub {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-legend {
|
||||
display: flex;
|
||||
gap: var(--st-inline-gap);
|
||||
}
|
||||
|
||||
.weekly-page .st-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.weekly-page .st-legend-item.inactive {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.weekly-page .st-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.weekly-page .st-dot-fasting {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.weekly-page .st-dot-post {
|
||||
background: var(--tertiary);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-summary {
|
||||
display: flex;
|
||||
padding: 32rpx;
|
||||
margin-bottom: 48rpx;
|
||||
border-radius: var(--st-radius-xl);
|
||||
background: var(--surface-container-low);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-summary-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border-right: 1rpx solid var(--surface-container-highest);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-summary-item:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-summary-num {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-summary-num.is-high {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-summary-num.is-good {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-summary-label {
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-area {
|
||||
position: relative;
|
||||
height: 360rpx;
|
||||
border-bottom: 1rpx dashed var(--surface-container-highest);
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-canvas {
|
||||
width: 100%;
|
||||
height: 360rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-empty,
|
||||
.weekly-page .st-chart-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 26rpx;
|
||||
color: var(--outline);
|
||||
}
|
||||
|
||||
/* ===== 最新测量 ===== */
|
||||
.weekly-page .st-latest-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-latest-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 320rpx;
|
||||
padding: 48rpx 40rpx;
|
||||
border-radius: 48rpx;
|
||||
background: var(--surface-container-lowest);
|
||||
box-shadow: var(--st-shadow-ambient);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.weekly-page .st-latest-hero.is-high {
|
||||
background: var(--error-container);
|
||||
color: var(--on-error-container);
|
||||
}
|
||||
|
||||
.weekly-page .st-latest-hero-pattern {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.1;
|
||||
background-image: radial-gradient(circle at 4rpx 4rpx, currentColor 2rpx, transparent 0);
|
||||
background-size: 32rpx 32rpx;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.weekly-page .st-latest-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-latest-value-row {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-latest-value {
|
||||
font-size: 112rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.weekly-page .st-latest-hero.is-high .st-latest-value,
|
||||
.weekly-page .st-latest-hero.is-high .st-latest-unit {
|
||||
color: var(--on-error-container);
|
||||
}
|
||||
|
||||
.weekly-page .st-latest-unit {
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.weekly-page .st-latest-flag {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 24rpx;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 8rpx 24rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.weekly-page .st-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-summary-card {
|
||||
padding: 32rpx;
|
||||
border-radius: var(--st-radius-2xl);
|
||||
background: var(--surface-container-lowest);
|
||||
border: 1rpx solid rgba(221, 228, 221, 0.5);
|
||||
box-shadow: var(--st-shadow-ambient);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-summary-label {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.weekly-page .st-summary-value {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.weekly-page .st-summary-value.is-high {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
|
||||
.weekly-page .st-summary-tag {
|
||||
align-self: flex-start;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
color: var(--tertiary);
|
||||
background: rgba(255, 218, 214, 0.5);
|
||||
}
|
||||
|
||||
/* ===== AI 饮食 ===== */
|
||||
.weekly-page .st-ai-section {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 40rpx;
|
||||
border-radius: 48rpx;
|
||||
background: linear-gradient(135deg, rgba(0, 108, 73, 0.1) 0%, rgba(0, 108, 73, 0.05) 100%);
|
||||
border: 1rpx solid rgba(0, 108, 73, 0.1);
|
||||
}
|
||||
|
||||
.weekly-page .st-ai-deco {
|
||||
position: absolute;
|
||||
width: 256rpx;
|
||||
height: 256rpx;
|
||||
top: -64rpx;
|
||||
right: -64rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(78, 222, 163, 0.2);
|
||||
filter: blur(40rpx);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.weekly-page .st-ai-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-ai-head-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-ai-icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.weekly-page .st-ai-icon.thinking {
|
||||
animation: st-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes st-pulse {
|
||||
50% { opacity: 0.65; }
|
||||
}
|
||||
|
||||
.weekly-page .st-ai-title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.weekly-page .st-ai-refresh {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.weekly-page .st-ai-refresh.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.weekly-page .st-ai-sub {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
margin-bottom: 32rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.43;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.weekly-page .st-meal-list {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-meal-glass {
|
||||
padding: 32rpx;
|
||||
border-radius: var(--st-radius-2xl);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border: 1rpx solid #ffffff;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.weekly-page .st-meal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-meal-label {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.weekly-page .st-meal-text {
|
||||
font-size: 28rpx;
|
||||
line-height: 1.43;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.weekly-page .st-skeleton-line {
|
||||
height: 28rpx;
|
||||
margin-bottom: 16rpx;
|
||||
border-radius: 8rpx;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.weekly-page .st-skeleton-hint {
|
||||
font-size: 26rpx;
|
||||
color: var(--outline);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ===== 底部浮动输入 ===== */
|
||||
.weekly-page .st-float-ask {
|
||||
position: fixed;
|
||||
left: var(--st-container-margin);
|
||||
right: var(--st-container-margin);
|
||||
bottom: calc(24rpx + env(safe-area-inset-bottom));
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
padding: 16rpx 16rpx 16rpx 40rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(20rpx);
|
||||
border: 1rpx solid rgba(0, 108, 73, 0.1);
|
||||
box-shadow: var(--st-shadow-float);
|
||||
}
|
||||
|
||||
.weekly-page .st-float-icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 108, 73, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 64rpx;
|
||||
font-size: 28rpx;
|
||||
color: var(--on-surface);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-placeholder {
|
||||
color: rgba(60, 74, 66, 0.6);
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-send {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 108, 73, 0.2);
|
||||
}
|
||||
|
||||
.weekly-page .st-float-send.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-send:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.weekly-page .st-float-result {
|
||||
margin-top: 16rpx;
|
||||
padding: 24rpx 32rpx;
|
||||
border-radius: var(--st-radius-xl);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1rpx solid var(--outline-variant);
|
||||
box-shadow: var(--st-shadow-ambient);
|
||||
}
|
||||
|
||||
.weekly-page .st-float-badge {
|
||||
display: inline-block;
|
||||
margin-bottom: 8rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
background: rgba(0, 108, 73, 0.1);
|
||||
}
|
||||
|
||||
.weekly-page .st-float-advice {
|
||||
font-size: 28rpx;
|
||||
line-height: 1.5;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.weekly-page .ai-stream-cursor {
|
||||
animation: st-blink 0.8s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes st-blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.weekly-page .chart-tip {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
padding: 12rpx 16rpx;
|
||||
border-radius: 12rpx;
|
||||
background: rgba(22, 29, 25, 0.88);
|
||||
color: #fff;
|
||||
font-size: 22rpx;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.weekly-page .chart-tip-date {
|
||||
font-weight: 700;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.weekly-page .chart-tip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/** 拼接 API 根路径与相对路径,避免双斜杠 */
|
||||
export function joinApiUrl(base, path) {
|
||||
const b = String(base || '').replace(/\/+$/, '')
|
||||
const p = String(path || '').replace(/^\/+/, '')
|
||||
return p ? `${b}/${p}` : b
|
||||
}
|
||||
|
||||
function appendQuery(url, data) {
|
||||
if (!data || typeof data !== 'object') return url
|
||||
const keys = Object.keys(data)
|
||||
if (!keys.length) return url
|
||||
const qs = keys
|
||||
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(data[k] ?? '')}`)
|
||||
.join('&')
|
||||
return url + (url.includes('?') ? '&' : '?') + qs
|
||||
}
|
||||
|
||||
function decodeChunkData(data) {
|
||||
if (typeof data === 'string') return data
|
||||
if (!data) return ''
|
||||
if (typeof TextDecoder !== 'undefined' && data instanceof ArrayBuffer) {
|
||||
return new TextDecoder('utf-8').decode(new Uint8Array(data))
|
||||
}
|
||||
if (data instanceof ArrayBuffer) {
|
||||
const bytes = new Uint8Array(data)
|
||||
let str = ''
|
||||
for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i])
|
||||
try {
|
||||
return decodeURIComponent(escape(str))
|
||||
} catch (e) {
|
||||
return str
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 从 SSE 文本缓冲中解析并消费完整事件 */
|
||||
export function consumeSseBuffer(buffer, onEvent) {
|
||||
let rest = buffer
|
||||
let idx = rest.indexOf('\n\n')
|
||||
while (idx !== -1) {
|
||||
const block = rest.slice(0, idx)
|
||||
rest = rest.slice(idx + 2)
|
||||
let event = 'message'
|
||||
let dataLine = ''
|
||||
block.split('\n').forEach((line) => {
|
||||
if (line.startsWith('event:')) event = line.slice(6).trim()
|
||||
else if (line.startsWith('data:')) dataLine += line.slice(5).trim()
|
||||
})
|
||||
if (dataLine) {
|
||||
try {
|
||||
onEvent(event, JSON.parse(dataLine))
|
||||
} catch (e) {
|
||||
onEvent(event, dataLine)
|
||||
}
|
||||
}
|
||||
idx = rest.indexOf('\n\n')
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
/** 从流式纯文本中提取三餐(支持未写完的最后一行) */
|
||||
export function parsePartialDietPlainText(text) {
|
||||
const out = {}
|
||||
if (!text) return out
|
||||
|
||||
const lineRe = /^(早餐|午餐|晚餐|提示|少碰)[::]\s*(.*)$/gm
|
||||
let match
|
||||
while ((match = lineRe.exec(text)) !== null) {
|
||||
const key = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[match[1]]
|
||||
const val = (match[2] || '').trim()
|
||||
if (key === 'avoid') {
|
||||
out.avoid = val.split(/[、,,;;\s]+/).map((s) => s.trim()).filter(Boolean)
|
||||
} else {
|
||||
out[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
const tail = text.match(/(?:^|\n)(早餐|午餐|晚餐|提示|少碰)[::]\s*([^\n]*)$/)
|
||||
if (tail) {
|
||||
const key = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[tail[1]]
|
||||
const val = (tail[2] || '').trim()
|
||||
if (key === 'avoid') {
|
||||
if (val) out.avoid = val.split(/[、,,;;\s]+/).map((s) => s.trim()).filter(Boolean)
|
||||
} else {
|
||||
out[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** @deprecated 使用 parsePartialDietPlainText */
|
||||
export function parsePartialDietJson(text) {
|
||||
return parsePartialDietPlainText(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 流式请求(微信小程序 enableChunked;其它端走 fallback)
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.baseUrl
|
||||
* @param {string} opts.url
|
||||
* @param {string} [opts.method]
|
||||
* @param {object} [opts.data]
|
||||
* @param {function(string, object):void} opts.onEvent
|
||||
* @param {function():Promise<object>} [opts.fallback] 不支持流式时的降级请求
|
||||
*/
|
||||
export function requestAiStream(opts) {
|
||||
const {
|
||||
baseUrl,
|
||||
url,
|
||||
method = 'GET',
|
||||
data = {},
|
||||
onEvent,
|
||||
fallback
|
||||
} = opts
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = uni.getStorageSync('token') || ''
|
||||
let sseBuffer = ''
|
||||
let requestUrl = joinApiUrl(baseUrl, url)
|
||||
if (method === 'GET') {
|
||||
requestUrl = appendQuery(requestUrl, data)
|
||||
}
|
||||
|
||||
const task = wx.request({
|
||||
url: requestUrl,
|
||||
method,
|
||||
data: method === 'POST' ? data : {},
|
||||
enableChunked: true,
|
||||
timeout: 60000,
|
||||
header: {
|
||||
token,
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'Cache-Control': 'no-cache'
|
||||
},
|
||||
success: (res) => {
|
||||
if (res && res.data) {
|
||||
sseBuffer += decodeChunkData(res.data)
|
||||
sseBuffer = consumeSseBuffer(sseBuffer, onEvent)
|
||||
}
|
||||
resolve(res)
|
||||
},
|
||||
fail: (err) => reject(err)
|
||||
})
|
||||
|
||||
if (task && typeof task.onChunkReceived === 'function') {
|
||||
task.onChunkReceived((res) => {
|
||||
sseBuffer += decodeChunkData(res.data)
|
||||
sseBuffer = consumeSseBuffer(sseBuffer, onEvent)
|
||||
})
|
||||
} else if (typeof fallback === 'function') {
|
||||
fallback().then(resolve).catch(reject)
|
||||
} else {
|
||||
reject(new Error('当前环境不支持流式请求'))
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
if (typeof fallback === 'function') {
|
||||
return fallback()
|
||||
}
|
||||
return Promise.reject(new Error('当前环境不支持流式请求'))
|
||||
// #endif
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 将 SVG 字符串转为小程序可用的 data URL(base64 在真机上更稳定)
|
||||
*/
|
||||
|
||||
function utf8ToBytes(str) {
|
||||
const encoded = encodeURIComponent(str)
|
||||
const bytes = []
|
||||
for (let i = 0; i < encoded.length; i++) {
|
||||
if (encoded.charCodeAt(i) === 37) {
|
||||
bytes.push(parseInt(encoded.substring(i + 1, i + 3), 16))
|
||||
i += 2
|
||||
} else {
|
||||
bytes.push(encoded.charCodeAt(i))
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes)
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {
|
||||
return uni.arrayBufferToBase64(bytes.buffer)
|
||||
}
|
||||
if (typeof btoa !== 'undefined') {
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
let result = ''
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const a = bytes[i]
|
||||
const b = i + 1 < bytes.length ? bytes[i + 1] : 0
|
||||
const c = i + 2 < bytes.length ? bytes[i + 2] : 0
|
||||
result += chars[a >> 2]
|
||||
result += chars[((a & 3) << 4) | (b >> 4)]
|
||||
result += i + 1 < bytes.length ? chars[((b & 15) << 2) | (c >> 6)] : '='
|
||||
result += i + 2 < bytes.length ? chars[c & 63] : '='
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function svgToDataUrl(svg) {
|
||||
const normalized = String(svg || '').trim()
|
||||
if (!normalized) return ''
|
||||
const base64 = bytesToBase64(utf8ToBytes(normalized))
|
||||
return `data:image/svg+xml;base64,${base64}`
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** 将接口 msg / 异常对象转为可展示的字符串,避免 [object Object] */
|
||||
export function formatUserMessage(msg, fallback = '') {
|
||||
if (msg == null || msg === '') return fallback
|
||||
if (typeof msg === 'string') return msg
|
||||
if (typeof msg === 'number' || typeof msg === 'boolean') return String(msg)
|
||||
if (Array.isArray(msg)) {
|
||||
const parts = msg.map((item) => {
|
||||
if (item == null) return ''
|
||||
if (typeof item === 'string') return item
|
||||
if (typeof item === 'number' || typeof item === 'boolean') return String(item)
|
||||
if (typeof item === 'object') {
|
||||
return item.msg || item.message || item.error || item.title || item.label || ''
|
||||
}
|
||||
return String(item)
|
||||
}).filter(Boolean)
|
||||
return parts.length ? parts.join(';') : fallback
|
||||
}
|
||||
if (typeof msg === 'object') {
|
||||
return msg.msg || msg.message || msg.error || msg.title || msg.label || fallback
|
||||
}
|
||||
return String(msg)
|
||||
}
|
||||
|
||||
export function showUserToast(title, options = {}) {
|
||||
const text = formatUserMessage(title, '')
|
||||
if (!text) return
|
||||
uni.showToast({
|
||||
title: text,
|
||||
icon: options.icon || 'none',
|
||||
duration: options.duration
|
||||
})
|
||||
}
|
||||
|
||||
export function formatDate(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
export function parseDate(str) {
|
||||
if (!str) return new Date()
|
||||
const [y, m, d] = str.split('-').map(Number)
|
||||
return new Date(y, (m || 1) - 1, d || 1)
|
||||
}
|
||||
|
||||
export function toNumber(v) {
|
||||
if (v === null || v === undefined || v === '') return null
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) && n !== 0 ? n : null
|
||||
}
|
||||
|
||||
export function getWeekday(dateStr) {
|
||||
const w = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
return w[parseDate(dateStr).getDay()]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/** 控糖树等级配置(前后端算法需保持一致:每级 50 积分,最高 Lv.9) */
|
||||
export const TREE_MAX_LEVEL = 9
|
||||
export const TREE_XP_PER_LEVEL = 50
|
||||
|
||||
export const TREE_LEVELS = [
|
||||
{ level: 0, name: '种子眠', emoji: '🫘', mood: '困困', desc: '稳糖种子在土里打盹' },
|
||||
{ level: 1, name: '破土芽', emoji: '🌱', mood: '探头', desc: '探出第一抹新绿' },
|
||||
{ level: 2, name: '展两叶', emoji: '🌿', mood: '好奇', desc: '两片嫩叶迎风展' },
|
||||
{ level: 3, name: '小树苗', emoji: '🪴', mood: '精神', desc: '身子骨硬朗起来' },
|
||||
{ level: 4, name: '青枝繁', emoji: '🌳', mood: '茁壮', desc: '枝叶渐密,元气足' },
|
||||
{ level: 5, name: '拔节高', emoji: '🌲', mood: '挺拔', desc: '一节一节往上蹿' },
|
||||
{ level: 6, name: '稳糖冠', emoji: '💚', mood: '沉稳', desc: '树冠成形,习惯成自然' },
|
||||
{ level: 7, name: '初绽香', emoji: '🌸', mood: '开心', desc: '枝头冒出第一朵花' },
|
||||
{ level: 8, name: '漫开花', emoji: '🌺', mood: '灿烂', desc: '花开满枝,越记越稳' },
|
||||
{ level: 9, name: '圆满树', emoji: '🏆', mood: '荣耀', desc: '满级大树,习惯大师' }
|
||||
]
|
||||
|
||||
const WHISPERS_BY_LEVEL = {
|
||||
0: ['种子在睡觉,记一笔就醒啦。', '今天浇第一滴水,芽就要冒出来。'],
|
||||
1: ['破土啦!再坚持几天就长高。', '小芽最喜欢规律的记录了。'],
|
||||
2: ['两片叶子为你鼓掌。', '空腹餐后都记全,我会长得更快。'],
|
||||
3: ['我已经是一棵小树啦。', '家人点赞的时候,我也会发光。'],
|
||||
4: ['枝叶越来越密,您真棒。', '连续记录,我会开出更多叶子。'],
|
||||
5: ['拔节中!习惯比完美更重要。', '再浇一点水,我就更高啦。'],
|
||||
6: ['习惯成自然,树冠成形啦。', '您今天的坚持,小树都记得。'],
|
||||
7: ['开花啦!闻到春天的味道了吗?', '稳糖花只开给坚持的人。'],
|
||||
8: ['满树花香,您已是控糖达人。', '明天继续来,花儿会更艳。'],
|
||||
9: ['满级大树陪您一路稳糖。', '圆满不是终点,习惯才是。']
|
||||
}
|
||||
|
||||
export function calcTreeFromPoints(points) {
|
||||
const pts = Math.max(0, Number(points) || 0)
|
||||
const level = Math.min(TREE_MAX_LEVEL, Math.floor(pts / TREE_XP_PER_LEVEL))
|
||||
const xpInLevel = level >= TREE_MAX_LEVEL ? TREE_XP_PER_LEVEL : pts % TREE_XP_PER_LEVEL
|
||||
const progress = level >= TREE_MAX_LEVEL ? 100 : Math.round((xpInLevel / TREE_XP_PER_LEVEL) * 100)
|
||||
const meta = TREE_LEVELS[level] || TREE_LEVELS[0]
|
||||
const nextMeta = level < TREE_MAX_LEVEL ? TREE_LEVELS[level + 1] : null
|
||||
const pointsToNext = level >= TREE_MAX_LEVEL ? 0 : TREE_XP_PER_LEVEL - xpInLevel
|
||||
return {
|
||||
level,
|
||||
progress,
|
||||
name: meta.name,
|
||||
emoji: meta.emoji,
|
||||
mood: meta.mood,
|
||||
desc: meta.desc,
|
||||
xpInLevel,
|
||||
xpNeed: TREE_XP_PER_LEVEL,
|
||||
pointsToNext,
|
||||
nextName: nextMeta?.name || '',
|
||||
isMax: level >= TREE_MAX_LEVEL
|
||||
}
|
||||
}
|
||||
|
||||
export function pickTreeWhisper(level) {
|
||||
const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))
|
||||
const list = WHISPERS_BY_LEVEL[lv] || WHISPERS_BY_LEVEL[0]
|
||||
return list[Math.floor(Math.random() * list.length)]
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
|
||||
export interface MetronomeOptions {
|
||||
initialBpm?: number
|
||||
bpmMin?: number
|
||||
bpmMax?: number
|
||||
clickSrc?: string
|
||||
accentSrc?: string
|
||||
accentEvery?: number
|
||||
poolSize?: number
|
||||
volume?: number // 节拍音音量 0-1
|
||||
silent?: boolean // 静音模式:只驱动节拍回调,不发声(如握力环只需视觉节拍)
|
||||
onBeat?: (beatIndex: number, isAccent: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 音频实例池:每个 click 用一个独立 InnerAudioContext,循环复用。
|
||||
* 解决两个问题:
|
||||
* 1. seek+play 模式在某些设备上首拍冷启动延迟(100-300ms)
|
||||
* 2. BPM 偏快时上一拍音频还没播完,下一拍 play 会被阻塞或丢失
|
||||
*/
|
||||
class AudioPool {
|
||||
private list: UniApp.InnerAudioContext[] = []
|
||||
private cursor = 0
|
||||
private warmedUp = false
|
||||
private targetVolume = 1
|
||||
|
||||
constructor(
|
||||
private src: string,
|
||||
private size: number,
|
||||
volume = 1,
|
||||
) {
|
||||
this.targetVolume = volume
|
||||
}
|
||||
|
||||
private create() {
|
||||
for (let i = 0; i < this.size; i++) {
|
||||
const ctx = uni.createInnerAudioContext()
|
||||
ctx.src = this.src
|
||||
ctx.obeyMuteSwitch = false
|
||||
ctx.autoplay = false
|
||||
ctx.volume = this.targetVolume
|
||||
this.list.push(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
setVolume(volume: number) {
|
||||
this.targetVolume = Math.max(0, Math.min(1, volume))
|
||||
this.list.forEach((ctx) => {
|
||||
try {
|
||||
ctx.volume = this.targetVolume
|
||||
} catch (_) {}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 预热:短暂播放,让音频文件下载/解码到内存
|
||||
* 真正首次 play 不会再卡冷启动
|
||||
*/
|
||||
warmUp() {
|
||||
if (this.warmedUp) return
|
||||
if (this.list.length === 0) this.create()
|
||||
|
||||
this.list.forEach((ctx) => {
|
||||
try {
|
||||
ctx.volume = 0 // 静音预热,避免进入页面时响声
|
||||
ctx.play()
|
||||
setTimeout(() => {
|
||||
try {
|
||||
ctx.stop()
|
||||
ctx.volume = this.targetVolume // 恢复目标音量
|
||||
} catch (_) {}
|
||||
}, 60)
|
||||
} catch (_) {}
|
||||
})
|
||||
this.warmedUp = true
|
||||
}
|
||||
|
||||
play() {
|
||||
if (this.list.length === 0) {
|
||||
this.create()
|
||||
this.warmUp()
|
||||
}
|
||||
const ctx = this.list[this.cursor]
|
||||
this.cursor = (this.cursor + 1) % this.list.length
|
||||
try {
|
||||
// 强制停止所有正在播放的音频,避免重叠
|
||||
this.list.forEach(c => {
|
||||
try { c.stop() } catch (_) {}
|
||||
})
|
||||
// iOS 上 seek(0) + play() 不一定能从头播放;stop() + play() 更稳
|
||||
ctx.play()
|
||||
} catch (_) {
|
||||
try { ctx.play() } catch (__) {}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.list.forEach((ctx) => {
|
||||
try {
|
||||
ctx.destroy?.()
|
||||
} catch (_) {}
|
||||
})
|
||||
this.list = []
|
||||
this.warmedUp = false
|
||||
}
|
||||
}
|
||||
|
||||
/* InnerAudio 兼容本地路径和网络 URL,这里默认走 CDN,跟 BgAudio 保持一致便于维护
|
||||
首次播放会下载 ~3KB,实测 100~300ms 完成,然后小程序会缓存,后续触发零延迟 */
|
||||
const DEFAULT_CLICK_SRC =
|
||||
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105128557ef8669.mp3'
|
||||
|
||||
export function useMetronome(options: MetronomeOptions = {}) {
|
||||
const {
|
||||
initialBpm = 80,
|
||||
bpmMin = 40,
|
||||
bpmMax = 240,
|
||||
clickSrc = DEFAULT_CLICK_SRC,
|
||||
accentSrc,
|
||||
poolSize = 4,
|
||||
volume = 1,
|
||||
silent = false,
|
||||
onBeat,
|
||||
} = options
|
||||
|
||||
const bpm = ref<number>(initialBpm)
|
||||
const isPlaying = ref<boolean>(false)
|
||||
const beatIndex = ref<number>(0)
|
||||
const isAccent = ref<boolean>(false)
|
||||
const accentEveryRef = ref<number>(options.accentEvery ?? 4)
|
||||
|
||||
const intervalMs = computed(() => 60000 / bpm.value)
|
||||
|
||||
let clickPool: AudioPool | null = null
|
||||
let accentPool: AudioPool | null = null
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let nextTickAt = 0
|
||||
|
||||
const ensureAudio = () => {
|
||||
if (silent) return
|
||||
if (!clickPool) {
|
||||
clickPool = new AudioPool(clickSrc, poolSize, volume)
|
||||
clickPool.warmUp()
|
||||
}
|
||||
if (accentSrc && !accentPool) {
|
||||
accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2)), volume)
|
||||
accentPool.warmUp()
|
||||
}
|
||||
}
|
||||
|
||||
const playSound = (accent: boolean) => {
|
||||
if (accent && accentPool) {
|
||||
accentPool.play()
|
||||
} else if (clickPool) {
|
||||
clickPool.play()
|
||||
}
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
if (!isPlaying.value) return
|
||||
|
||||
const every = Math.max(1, accentEveryRef.value)
|
||||
const accent = beatIndex.value % every === 0
|
||||
isAccent.value = accent
|
||||
|
||||
playSound(accent)
|
||||
onBeat?.(beatIndex.value, accent)
|
||||
beatIndex.value++
|
||||
|
||||
nextTickAt += intervalMs.value
|
||||
const nextDelay = Math.max(0, nextTickAt - Date.now())
|
||||
|
||||
timer = setTimeout(tick, nextDelay)
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (isPlaying.value) return
|
||||
ensureAudio()
|
||||
isPlaying.value = true
|
||||
beatIndex.value = 0
|
||||
nextTickAt = Date.now()
|
||||
tick()
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
isPlaying.value = false
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
const setBpm = (val: number) => {
|
||||
bpm.value = Math.max(bpmMin, Math.min(bpmMax, Math.round(val)))
|
||||
}
|
||||
|
||||
const setAccentEvery = (n: number) => {
|
||||
accentEveryRef.value = Math.max(1, Math.min(16, Math.round(n)))
|
||||
beatIndex.value = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态更新音频源(用于切换音色)
|
||||
*/
|
||||
const updateAudioSrc = (newClickSrc: string, newAccentSrc?: string) => {
|
||||
const wasPlaying = isPlaying.value
|
||||
if (wasPlaying) stop()
|
||||
|
||||
// 销毁旧的音频池
|
||||
clickPool?.destroy()
|
||||
accentPool?.destroy()
|
||||
clickPool = null
|
||||
accentPool = null
|
||||
|
||||
// 创建新的音频池(静默预热,不播放)
|
||||
clickPool = new AudioPool(newClickSrc, poolSize)
|
||||
if (newAccentSrc) {
|
||||
accentPool = new AudioPool(newAccentSrc, Math.max(2, Math.ceil(poolSize / 2)))
|
||||
}
|
||||
|
||||
// 播放一次预览音效(适中音量)
|
||||
const previewCtx = uni.createInnerAudioContext()
|
||||
previewCtx.src = newClickSrc
|
||||
previewCtx.obeyMuteSwitch = false
|
||||
previewCtx.volume = 0.4
|
||||
try {
|
||||
previewCtx.play()
|
||||
setTimeout(() => {
|
||||
try {
|
||||
previewCtx.destroy?.()
|
||||
} catch (_) {}
|
||||
}, 500)
|
||||
} catch (_) {}
|
||||
|
||||
// 恢复播放状态
|
||||
if (wasPlaying) start()
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动预加载(推荐在页面 onShow 时调用,让用户进页面就完成预热)
|
||||
* 同时再次设置 setInnerAudioOption,防 App.vue 全局设置失效(iOS 静音键)
|
||||
*/
|
||||
const preload = () => {
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
uni.setInnerAudioOption({
|
||||
obeyMuteSwitch: false,
|
||||
mixWithOther: true,
|
||||
})
|
||||
} catch (_) {}
|
||||
// #endif
|
||||
ensureAudio()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
clickPool?.destroy()
|
||||
accentPool?.destroy()
|
||||
clickPool = null
|
||||
accentPool = null
|
||||
})
|
||||
|
||||
return {
|
||||
bpm,
|
||||
isPlaying,
|
||||
beatIndex,
|
||||
isAccent,
|
||||
intervalMs,
|
||||
accentEvery: accentEveryRef,
|
||||
start,
|
||||
stop,
|
||||
setBpm,
|
||||
setAccentEvery,
|
||||
updateAudioSrc,
|
||||
preload,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
<template>
|
||||
<view class="crush-canvas-container">
|
||||
<canvas
|
||||
type="2d"
|
||||
id="crush"
|
||||
class="crush-canvas"
|
||||
></canvas>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
|
||||
|
||||
// ============================================================
|
||||
// 类型定义
|
||||
// ============================================================
|
||||
|
||||
type CrushItemType = 'egg' | 'walnut' | 'can' | 'balloon'
|
||||
type ParticleShape = 'circle' | 'square' | 'triangle' | 'rect'
|
||||
|
||||
// 碎片(主体飞溅物)
|
||||
interface Debris {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
size: number
|
||||
color: string
|
||||
shape: ParticleShape
|
||||
rotation: number
|
||||
rotationSpeed: number
|
||||
life: number
|
||||
decay: number
|
||||
gravity: number
|
||||
glow: boolean
|
||||
swing: number // 水平摇摆幅度(confetti 彩纸用),0 表示不摇摆
|
||||
age: number
|
||||
}
|
||||
|
||||
// 火花(细小高速亮点,带拖尾)
|
||||
interface Spark {
|
||||
x: number
|
||||
y: number
|
||||
px: number // 上一帧位置(拖尾起点)
|
||||
py: number
|
||||
vx: number
|
||||
vy: number
|
||||
size: number
|
||||
color: string
|
||||
life: number
|
||||
decay: number
|
||||
gravity: number
|
||||
}
|
||||
|
||||
// 闪烁星光(四角星高光)
|
||||
interface Sparkle {
|
||||
x: number
|
||||
y: number
|
||||
size: number
|
||||
rotation: number
|
||||
spin: number
|
||||
color: string
|
||||
life: number
|
||||
decay: number
|
||||
}
|
||||
|
||||
// 中心爆闪
|
||||
interface Flash {
|
||||
x: number
|
||||
y: number
|
||||
radius: number
|
||||
maxRadius: number
|
||||
color: string
|
||||
life: number
|
||||
decay: number
|
||||
}
|
||||
|
||||
interface Shockwave {
|
||||
x: number
|
||||
y: number
|
||||
radius: number
|
||||
maxRadius: number
|
||||
color: string
|
||||
width: number
|
||||
life: number
|
||||
decay: number
|
||||
}
|
||||
|
||||
interface CrushItemConfig {
|
||||
colors: string[] // 碎片主体色
|
||||
sparkColors: string[] // 火花/高光色(偏亮)
|
||||
shapes: ParticleShape[]
|
||||
debrisCount: number
|
||||
sparkCount: number
|
||||
sparkleCount: number
|
||||
speedMin: number
|
||||
speedMax: number
|
||||
sizeMin: number
|
||||
sizeMax: number
|
||||
gravity: number
|
||||
flashColor: string // 中心爆闪核心色
|
||||
shockwaveColor: string
|
||||
confetti?: boolean // balloon: 碎片改为彩纸飘落
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 物品差异化配置
|
||||
// ============================================================
|
||||
|
||||
const CRUSH_ITEMS: Record<CrushItemType, CrushItemConfig> = {
|
||||
egg: {
|
||||
colors: ['#FFEB3B', '#FFF59D', '#FFFFFF', '#FFD54F', '#FFC107'],
|
||||
sparkColors: ['#FFFFFF', '#FFF9C4', '#FFEE58'],
|
||||
shapes: ['circle', 'circle', 'circle'],
|
||||
debrisCount: 20,
|
||||
sparkCount: 16,
|
||||
sparkleCount: 5,
|
||||
speedMin: 3,
|
||||
speedMax: 6,
|
||||
sizeMin: 4,
|
||||
sizeMax: 10,
|
||||
gravity: 0.34,
|
||||
flashColor: '#FFFDE7',
|
||||
shockwaveColor: 'rgba(255, 224, 130, 0.85)',
|
||||
},
|
||||
walnut: {
|
||||
colors: ['#5D4037', '#795548', '#8D6E63', '#A1887F', '#3E2723'],
|
||||
sparkColors: ['#FFCC80', '#FFB74D', '#FFE0B2'],
|
||||
shapes: ['square', 'triangle', 'rect'],
|
||||
debrisCount: 22,
|
||||
sparkCount: 18,
|
||||
sparkleCount: 5,
|
||||
speedMin: 2.8,
|
||||
speedMax: 5,
|
||||
sizeMin: 4,
|
||||
sizeMax: 9,
|
||||
gravity: 0.42,
|
||||
flashColor: '#FFE0B2',
|
||||
shockwaveColor: 'rgba(255, 167, 38, 0.8)',
|
||||
},
|
||||
can: {
|
||||
colors: ['#90A4AE', '#CFD8DC', '#B0BEC5', '#78909C', '#ECEFF1', '#607D8B'],
|
||||
sparkColors: ['#FFFFFF', '#E1F5FE', '#B3E5FC'],
|
||||
shapes: ['rect', 'rect', 'triangle', 'square'],
|
||||
debrisCount: 24,
|
||||
sparkCount: 20,
|
||||
sparkleCount: 6,
|
||||
speedMin: 4,
|
||||
speedMax: 7,
|
||||
sizeMin: 3,
|
||||
sizeMax: 11,
|
||||
gravity: 0.36,
|
||||
flashColor: '#FFFFFF',
|
||||
shockwaveColor: 'rgba(207, 216, 220, 0.9)',
|
||||
},
|
||||
balloon: {
|
||||
colors: ['#FF5252', '#FF4081', '#E040FB', '#7C4DFF', '#536DFE', '#448AFF', '#FFEB3B', '#69F0AE', '#FF6E40'],
|
||||
sparkColors: ['#FFFFFF', '#FF80AB', '#82B1FF', '#FFFF8D'],
|
||||
shapes: ['rect', 'rect', 'square'],
|
||||
debrisCount: 28,
|
||||
sparkCount: 18,
|
||||
sparkleCount: 8,
|
||||
speedMin: 4,
|
||||
speedMax: 7.5,
|
||||
sizeMin: 4,
|
||||
sizeMax: 8,
|
||||
gravity: 0.14,
|
||||
flashColor: '#FCE4EC',
|
||||
shockwaveColor: 'rgba(255, 64, 129, 0.85)',
|
||||
confetti: true,
|
||||
},
|
||||
}
|
||||
|
||||
const PHYSICS = {
|
||||
AIR_RESISTANCE: 0.985,
|
||||
SPARK_RESISTANCE: 0.92,
|
||||
DEBRIS_DECAY: 0.018,
|
||||
SPARK_DECAY: 0.035,
|
||||
SPARKLE_DECAY: 0.045,
|
||||
FLASH_DECAY: 0.11,
|
||||
SHOCKWAVE_DECAY: 0.045,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 状态
|
||||
// ============================================================
|
||||
|
||||
const canvasNode = ref<any>(null)
|
||||
const ctx = ref<any>(null)
|
||||
const canvasWidth = ref<number>(240)
|
||||
const canvasHeight = ref<number>(240)
|
||||
const dpr = ref<number>(1)
|
||||
|
||||
const rafId = ref<number | null>(null)
|
||||
const renderRunning = ref<boolean>(false)
|
||||
|
||||
const debrisList = ref<Debris[]>([])
|
||||
const sparkList = ref<Spark[]>([])
|
||||
const sparkleList = ref<Sparkle[]>([])
|
||||
const flashList = ref<Flash[]>([])
|
||||
const shockwaves = ref<Shockwave[]>([])
|
||||
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
// 由父级 prop 信号驱动触发特效(避免跨组件 ref 调用, 小程序端更稳)
|
||||
const props = defineProps<{
|
||||
crushSignal?: { type: CrushItemType; nonce: number } | null
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.crushSignal,
|
||||
(sig) => {
|
||||
if (sig) triggerCrush(sig.type)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
initCanvas()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 初始化 Canvas 2D
|
||||
// ============================================================
|
||||
|
||||
function initCanvas() {
|
||||
setTimeout(() => {
|
||||
if (!instance) return
|
||||
|
||||
const query = uni.createSelectorQuery().in(instance)
|
||||
query.select('#crush')
|
||||
.fields({ node: true, size: true } as any)
|
||||
.exec((res: any[]) => {
|
||||
if (!res || !res[0] || !res[0].node) {
|
||||
// 降级:如果获取不到 node(非微信平台),不渲染
|
||||
console.warn('[crush-canvas] canvas 2d not supported on this platform')
|
||||
return
|
||||
}
|
||||
|
||||
const canvas = res[0].node
|
||||
const width = res[0].width
|
||||
const height = res[0].height
|
||||
|
||||
// 获取设备像素比
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
const pixelRatio = systemInfo.pixelRatio || 1
|
||||
|
||||
// 设置 canvas 物理像素尺寸(高清)
|
||||
canvas.width = width * pixelRatio
|
||||
canvas.height = height * pixelRatio
|
||||
|
||||
// 获取 2D 上下文
|
||||
const context = canvas.getContext('2d')
|
||||
context.scale(pixelRatio, pixelRatio)
|
||||
|
||||
// 保存状态
|
||||
canvasNode.value = canvas
|
||||
ctx.value = context
|
||||
canvasWidth.value = width
|
||||
canvasHeight.value = height
|
||||
dpr.value = pixelRatio
|
||||
})
|
||||
}, 200)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 触发捏碎特效
|
||||
// ============================================================
|
||||
|
||||
function triggerCrush(itemType: CrushItemType) {
|
||||
if (!ctx.value || !canvasNode.value) return
|
||||
|
||||
const item = CRUSH_ITEMS[itemType]
|
||||
if (!item) return
|
||||
|
||||
const centerX = canvasWidth.value / 2
|
||||
const centerY = canvasHeight.value / 2
|
||||
const maxR = Math.min(canvasWidth.value, canvasHeight.value)
|
||||
|
||||
// 1. 中心爆闪
|
||||
flashList.value.push({
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
radius: maxR * 0.12,
|
||||
maxRadius: maxR * 0.62,
|
||||
color: item.flashColor,
|
||||
life: 1.0,
|
||||
decay: PHYSICS.FLASH_DECAY,
|
||||
})
|
||||
|
||||
// 2. 双层冲击波
|
||||
shockwaves.value.push({
|
||||
x: centerX, y: centerY,
|
||||
radius: 12, maxRadius: maxR * 0.62,
|
||||
color: item.shockwaveColor, width: 5,
|
||||
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY,
|
||||
})
|
||||
shockwaves.value.push({
|
||||
x: centerX, y: centerY,
|
||||
radius: 4, maxRadius: maxR * 0.42,
|
||||
color: item.shockwaveColor, width: 3,
|
||||
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY * 1.3,
|
||||
})
|
||||
|
||||
// 3. 碎片(主体飞溅)
|
||||
for (let i = 0; i < item.debrisCount; i++) {
|
||||
const angle = (Math.PI * 2 * i) / item.debrisCount + (Math.random() - 0.5) * 0.7
|
||||
const speed = item.speedMin + Math.random() * (item.speedMax - item.speedMin)
|
||||
const size = item.sizeMin + Math.random() * (item.sizeMax - item.sizeMin)
|
||||
const color = item.colors[Math.floor(Math.random() * item.colors.length)]
|
||||
const shape = item.shapes[Math.floor(Math.random() * item.shapes.length)]
|
||||
|
||||
// confetti(彩纸): 初速带强烈向上偏移 + 水平摇摆下落
|
||||
const confetti = !!item.confetti
|
||||
const vy0 = confetti
|
||||
? Math.sin(angle) * speed - 2.5 // 先向上窜
|
||||
: Math.sin(angle) * speed
|
||||
|
||||
debrisList.value.push({
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: vy0,
|
||||
size,
|
||||
color,
|
||||
shape,
|
||||
rotation: Math.random() * Math.PI * 2,
|
||||
rotationSpeed: (Math.random() - 0.5) * (confetti ? 0.5 : 0.3),
|
||||
life: 1.0,
|
||||
decay: PHYSICS.DEBRIS_DECAY * (confetti ? 0.7 : 1),
|
||||
gravity: item.gravity,
|
||||
glow: !confetti, // 彩纸不发光,实色碎片发光
|
||||
swing: confetti ? 0.6 + Math.random() * 1.2 : 0,
|
||||
age: Math.random() * Math.PI * 2,
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 火花(细小高速亮点,带拖尾)
|
||||
for (let i = 0; i < item.sparkCount; i++) {
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const speed = item.speedMax * (0.9 + Math.random() * 0.8)
|
||||
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
|
||||
sparkList.value.push({
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
px: centerX,
|
||||
py: centerY,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
size: 1.5 + Math.random() * 2,
|
||||
color,
|
||||
life: 1.0,
|
||||
decay: PHYSICS.SPARK_DECAY * (0.8 + Math.random() * 0.6),
|
||||
gravity: item.gravity * 0.3,
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 闪烁星光
|
||||
for (let i = 0; i < item.sparkleCount; i++) {
|
||||
const r = maxR * (0.08 + Math.random() * 0.32)
|
||||
const a = Math.random() * Math.PI * 2
|
||||
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
|
||||
sparkleList.value.push({
|
||||
x: centerX + Math.cos(a) * r,
|
||||
y: centerY + Math.sin(a) * r,
|
||||
size: 10 + Math.random() * 16,
|
||||
rotation: Math.random() * Math.PI,
|
||||
spin: (Math.random() - 0.5) * 0.16,
|
||||
color,
|
||||
life: 1.0 + Math.random() * 0.4, // 错峰出现
|
||||
decay: PHYSICS.SPARKLE_DECAY * (0.8 + Math.random() * 0.5),
|
||||
})
|
||||
}
|
||||
|
||||
ensureRenderLoop()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 渲染循环
|
||||
// ============================================================
|
||||
|
||||
function ensureRenderLoop() {
|
||||
if (renderRunning.value) return
|
||||
renderRunning.value = true
|
||||
renderLoop()
|
||||
}
|
||||
|
||||
function renderLoop() {
|
||||
if (!canvasNode.value || !ctx.value) {
|
||||
renderRunning.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const w = canvasWidth.value
|
||||
const h = canvasHeight.value
|
||||
|
||||
// 碎片
|
||||
for (let i = debrisList.value.length - 1; i >= 0; i--) {
|
||||
const p = debrisList.value[i]
|
||||
p.vy += p.gravity
|
||||
p.vx *= PHYSICS.AIR_RESISTANCE
|
||||
p.vy *= PHYSICS.AIR_RESISTANCE
|
||||
p.x += p.vx
|
||||
p.y += p.vy
|
||||
p.age += 0.2
|
||||
if (p.swing > 0) p.x += Math.sin(p.age) * p.swing // 彩纸摇摆
|
||||
p.rotation += p.rotationSpeed
|
||||
p.life -= p.decay
|
||||
if (p.life <= 0 || p.x < -60 || p.x > w + 60 || p.y > h + 60) {
|
||||
debrisList.value.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 火花
|
||||
for (let i = sparkList.value.length - 1; i >= 0; i--) {
|
||||
const s = sparkList.value[i]
|
||||
s.px = s.x
|
||||
s.py = s.y
|
||||
s.vy += s.gravity
|
||||
s.vx *= PHYSICS.SPARK_RESISTANCE
|
||||
s.vy *= PHYSICS.SPARK_RESISTANCE
|
||||
s.x += s.vx
|
||||
s.y += s.vy
|
||||
s.life -= s.decay
|
||||
if (s.life <= 0) sparkList.value.splice(i, 1)
|
||||
}
|
||||
|
||||
// 闪烁星光
|
||||
for (let i = sparkleList.value.length - 1; i >= 0; i--) {
|
||||
const sp = sparkleList.value[i]
|
||||
sp.rotation += sp.spin
|
||||
sp.life -= sp.decay
|
||||
if (sp.life <= 0) sparkleList.value.splice(i, 1)
|
||||
}
|
||||
|
||||
// 中心爆闪
|
||||
for (let i = flashList.value.length - 1; i >= 0; i--) {
|
||||
const f = flashList.value[i]
|
||||
f.radius += (f.maxRadius - f.radius) * 0.35
|
||||
f.life -= f.decay
|
||||
if (f.life <= 0) flashList.value.splice(i, 1)
|
||||
}
|
||||
|
||||
// 冲击波
|
||||
for (let i = shockwaves.value.length - 1; i >= 0; i--) {
|
||||
const sw = shockwaves.value[i]
|
||||
sw.radius += (sw.maxRadius - sw.radius) * 0.16
|
||||
sw.life -= sw.decay
|
||||
if (sw.life <= 0) shockwaves.value.splice(i, 1)
|
||||
}
|
||||
|
||||
draw()
|
||||
|
||||
const alive =
|
||||
debrisList.value.length > 0 ||
|
||||
sparkList.value.length > 0 ||
|
||||
sparkleList.value.length > 0 ||
|
||||
flashList.value.length > 0 ||
|
||||
shockwaves.value.length > 0
|
||||
|
||||
if (alive) {
|
||||
rafId.value = canvasNode.value.requestAnimationFrame(renderLoop) as unknown as number
|
||||
} else {
|
||||
rafId.value = null
|
||||
renderRunning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 绘制
|
||||
// ============================================================
|
||||
|
||||
function draw() {
|
||||
const c = ctx.value
|
||||
const w = canvasWidth.value
|
||||
const h = canvasHeight.value
|
||||
|
||||
c.clearRect(0, 0, w, h)
|
||||
|
||||
// —— 1. 冲击波(底层) ——
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
shockwaves.value.forEach(sw => {
|
||||
c.beginPath()
|
||||
c.arc(sw.x, sw.y, sw.radius, 0, Math.PI * 2)
|
||||
c.strokeStyle = applyAlphaToRgba(sw.color, Math.min(1, sw.life))
|
||||
c.lineWidth = sw.width * sw.life
|
||||
c.stroke()
|
||||
})
|
||||
|
||||
// —— 2. 中心爆闪(叠加发光) ——
|
||||
c.globalCompositeOperation = 'lighter'
|
||||
flashList.value.forEach(f => {
|
||||
const grd = c.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.radius)
|
||||
const a = Math.min(1, f.life)
|
||||
grd.addColorStop(0, hexToRgba(f.color, 0.95 * a))
|
||||
grd.addColorStop(0.4, hexToRgba(f.color, 0.5 * a))
|
||||
grd.addColorStop(1, hexToRgba(f.color, 0))
|
||||
c.fillStyle = grd
|
||||
c.beginPath()
|
||||
c.arc(f.x, f.y, f.radius, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
})
|
||||
|
||||
// —— 3. 碎片(实体,带辉光) ——
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
debrisList.value.forEach(p => {
|
||||
const alpha = Math.min(1, p.life)
|
||||
const currentSize = p.size * (0.65 + p.life * 0.35)
|
||||
|
||||
// 辉光: 同色低透明大圆衬底
|
||||
if (p.glow) {
|
||||
c.fillStyle = hexToRgba(p.color, alpha * 0.22)
|
||||
c.beginPath()
|
||||
c.arc(p.x, p.y, currentSize * 1.9, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
}
|
||||
|
||||
c.fillStyle = hexToRgba(p.color, alpha)
|
||||
drawDebrisShape(c, p, currentSize)
|
||||
})
|
||||
|
||||
// —— 4. 火花(拖尾 + 亮点,叠加发光) ——
|
||||
c.globalCompositeOperation = 'lighter'
|
||||
sparkList.value.forEach(s => {
|
||||
const a = Math.min(1, s.life)
|
||||
// 拖尾线
|
||||
c.strokeStyle = hexToRgba(s.color, a * 0.8)
|
||||
c.lineWidth = s.size * a
|
||||
c.lineCap = 'round'
|
||||
c.beginPath()
|
||||
c.moveTo(s.px, s.py)
|
||||
c.lineTo(s.x, s.y)
|
||||
c.stroke()
|
||||
// 头部亮点
|
||||
c.fillStyle = hexToRgba(s.color, a)
|
||||
c.beginPath()
|
||||
c.arc(s.x, s.y, s.size * a, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
})
|
||||
|
||||
// —— 5. 闪烁星光(四角星,叠加发光) ——
|
||||
sparkleList.value.forEach(sp => {
|
||||
// life 在 1→0, 用 sin 做"先亮后灭"的缩放
|
||||
const t = Math.max(0, Math.min(1, sp.life))
|
||||
const scale = Math.sin(Math.min(1, sp.life) * Math.PI) // 0→1→0
|
||||
if (scale <= 0.02) return
|
||||
const a = t
|
||||
drawStar(c, sp.x, sp.y, sp.size * scale, sp.size * scale * 0.32, sp.rotation, hexToRgba(sp.color, a))
|
||||
})
|
||||
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
}
|
||||
|
||||
function drawDebrisShape(c: any, p: Debris, size: number) {
|
||||
switch (p.shape) {
|
||||
case 'circle':
|
||||
c.beginPath()
|
||||
c.arc(p.x, p.y, size, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
break
|
||||
case 'square':
|
||||
c.save()
|
||||
c.translate(p.x, p.y)
|
||||
c.rotate(p.rotation)
|
||||
c.fillRect(-size, -size, size * 2, size * 2)
|
||||
c.restore()
|
||||
break
|
||||
case 'rect':
|
||||
c.save()
|
||||
c.translate(p.x, p.y)
|
||||
c.rotate(p.rotation)
|
||||
c.fillRect(-size * 1.6, -size * 0.5, size * 3.2, size)
|
||||
c.restore()
|
||||
break
|
||||
case 'triangle':
|
||||
c.save()
|
||||
c.translate(p.x, p.y)
|
||||
c.rotate(p.rotation)
|
||||
c.beginPath()
|
||||
c.moveTo(0, -size)
|
||||
c.lineTo(size, size)
|
||||
c.lineTo(-size, size)
|
||||
c.closePath()
|
||||
c.fill()
|
||||
c.restore()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 四角星(尖锐十字星),用于高光闪烁
|
||||
function drawStar(c: any, cx: number, cy: number, outerR: number, innerR: number, rotation: number, fill: string) {
|
||||
const points = 4
|
||||
c.save()
|
||||
c.translate(cx, cy)
|
||||
c.rotate(rotation)
|
||||
c.beginPath()
|
||||
for (let i = 0; i < points * 2; i++) {
|
||||
const r = i % 2 === 0 ? outerR : innerR
|
||||
const a = (Math.PI * i) / points
|
||||
const x = Math.cos(a) * r
|
||||
const y = Math.sin(a) * r
|
||||
if (i === 0) c.moveTo(x, y)
|
||||
else c.lineTo(x, y)
|
||||
}
|
||||
c.closePath()
|
||||
c.fillStyle = fill
|
||||
c.fill()
|
||||
c.restore()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具函数
|
||||
// ============================================================
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
|
||||
function applyAlphaToRgba(rgba: string, alphaMultiplier: number): string {
|
||||
const match = rgba.match(/rgba?\(([^)]+)\)/)
|
||||
if (!match) return rgba
|
||||
|
||||
const parts = match[1].split(',').map(s => s.trim())
|
||||
const r = parts[0]
|
||||
const g = parts[1]
|
||||
const b = parts[2]
|
||||
const a = parts[3] ? parseFloat(parts[3]) : 1
|
||||
return `rgba(${r}, ${g}, ${b}, ${a * alphaMultiplier})`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 清理
|
||||
// ============================================================
|
||||
|
||||
function cleanup() {
|
||||
if (rafId.value !== null && canvasNode.value) {
|
||||
try {
|
||||
canvasNode.value.cancelAnimationFrame(rafId.value)
|
||||
} catch (_) {}
|
||||
rafId.value = null
|
||||
}
|
||||
renderRunning.value = false
|
||||
debrisList.value = []
|
||||
sparkList.value = []
|
||||
sparkleList.value = []
|
||||
flashList.value = []
|
||||
shockwaves.value = []
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.crush-canvas-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.crush-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,383 @@
|
||||
<template>
|
||||
<view class="walker-canvas-container">
|
||||
<canvas
|
||||
canvas-id="walker"
|
||||
id="walker"
|
||||
class="walker-canvas"
|
||||
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
|
||||
@touchstart="onTouchStart"
|
||||
@touchmove="onTouchMove"
|
||||
@touchend="onTouchEnd"
|
||||
@touchcancel="onTouchCancel"
|
||||
></canvas>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
isPlaying: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
bpm: {
|
||||
type: Number,
|
||||
default: 110
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canvasWidth: 320,
|
||||
canvasHeight: 400,
|
||||
ctx: null,
|
||||
timer: null,
|
||||
renderRunning: false,
|
||||
|
||||
// Interaction State (Spring Physics)
|
||||
coreScale: 1,
|
||||
coreTargetScale: 1,
|
||||
coreVelocity: 0,
|
||||
pressed: false,
|
||||
|
||||
// Animation Physics (Time)
|
||||
time: 0,
|
||||
lastFrameTime: 0,
|
||||
|
||||
// Metronome State
|
||||
lastBeatTime: 0,
|
||||
|
||||
// Energy Waves
|
||||
waveAmplitudeMultiplier: 1,
|
||||
|
||||
// Shockwaves
|
||||
shockwaves: [],
|
||||
|
||||
// Rotation for outer ring
|
||||
ringAngle: 0
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.initCanvas();
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.renderRunning = false;
|
||||
},
|
||||
watch: {
|
||||
isPlaying(newVal) {
|
||||
if (newVal) {
|
||||
this.waveAmplitudeMultiplier = 1;
|
||||
this.ensureRenderLoop();
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initCanvas() {
|
||||
// Delay to ensure Flexbox layout is fully complete before measuring
|
||||
setTimeout(() => {
|
||||
const query = uni.createSelectorQuery().in(this);
|
||||
query.select('.walker-canvas-container').boundingClientRect(data => {
|
||||
if (data && data.width > 0) {
|
||||
this.canvasWidth = data.width;
|
||||
this.canvasHeight = data.height > 100 ? data.height : data.width * 1.2;
|
||||
} else {
|
||||
const sys = uni.getSystemInfoSync();
|
||||
this.canvasWidth = sys.windowWidth - 30; // approximate padding
|
||||
this.canvasHeight = this.canvasWidth * 1.2;
|
||||
}
|
||||
|
||||
if (!this.ctx) {
|
||||
this.ctx = uni.createCanvasContext('walker', this);
|
||||
this.lastFrameTime = Date.now();
|
||||
this.ensureRenderLoop();
|
||||
}
|
||||
}).exec();
|
||||
}, 100);
|
||||
},
|
||||
|
||||
// Expose a method to be called from parent when a beat hits
|
||||
triggerBeat() {
|
||||
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
|
||||
// Add a shockwave
|
||||
this.shockwaves.push({
|
||||
radius: coreRadius,
|
||||
maxRadius: coreRadius * 2.2,
|
||||
opacity: 1
|
||||
});
|
||||
|
||||
// Spike the wave amplitude
|
||||
this.waveAmplitudeMultiplier = 2.5;
|
||||
|
||||
// Slight pulse to the core
|
||||
this.coreVelocity -= 0.05;
|
||||
|
||||
this.ensureRenderLoop();
|
||||
},
|
||||
|
||||
onTouchStart(e) {
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
// Center
|
||||
const cx = this.canvasWidth / 2;
|
||||
const cy = this.canvasHeight / 2 - 30;
|
||||
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
|
||||
|
||||
const dx = touch.x - cx;
|
||||
const dy = touch.y - cy;
|
||||
const dist = Math.sqrt(dx*dx + dy*dy);
|
||||
|
||||
// Hit box slightly larger than the visual circle
|
||||
if (dist <= coreRadius + 40) {
|
||||
this.pressed = true;
|
||||
this.coreTargetScale = 0.9;
|
||||
this.ensureRenderLoop();
|
||||
}
|
||||
},
|
||||
onTouchMove(e) {
|
||||
if (!this.pressed) return;
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const cx = this.canvasWidth / 2;
|
||||
const cy = this.canvasHeight / 2 - 30;
|
||||
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
|
||||
|
||||
const dx = touch.x - cx;
|
||||
const dy = touch.y - cy;
|
||||
const dist = Math.sqrt(dx*dx + dy*dy);
|
||||
|
||||
// Slid out of the hit box - cancel the press
|
||||
if (dist > coreRadius + 40) {
|
||||
this.pressed = false;
|
||||
this.coreTargetScale = 1;
|
||||
}
|
||||
},
|
||||
onTouchEnd() {
|
||||
// Unconditionally reset the visual scale
|
||||
this.coreTargetScale = 1;
|
||||
// Only emit toggle when the press has not been cancelled by drag-out
|
||||
if (this.pressed) {
|
||||
this.$emit('toggle-play');
|
||||
}
|
||||
this.pressed = false;
|
||||
},
|
||||
onTouchCancel() {
|
||||
// System interruption (incoming call, notification pull-down, etc.):
|
||||
// reset visuals only — do NOT count this as a user toggle intent.
|
||||
this.coreTargetScale = 1;
|
||||
this.pressed = false;
|
||||
},
|
||||
|
||||
ensureRenderLoop() {
|
||||
if (this.renderRunning) return;
|
||||
this.renderRunning = true;
|
||||
this.renderLoop();
|
||||
},
|
||||
|
||||
renderLoop() {
|
||||
// Use a fixed time delta to prevent rubber-banding/jitter on unstable JS timers
|
||||
const fixedDt = 0.016;
|
||||
|
||||
if (this.isPlaying) {
|
||||
this.time += fixedDt;
|
||||
} else {
|
||||
this.time += fixedDt * 0.2; // slow drift when paused
|
||||
}
|
||||
|
||||
// Spring Physics for Core Scale
|
||||
const tension = 120;
|
||||
const friction = 12;
|
||||
const force = (this.coreTargetScale - this.coreScale) * tension;
|
||||
this.coreVelocity += force * fixedDt;
|
||||
this.coreVelocity *= Math.exp(-friction * fixedDt);
|
||||
this.coreScale += this.coreVelocity * fixedDt;
|
||||
|
||||
// Decay wave amplitude back to 1
|
||||
if (this.waveAmplitudeMultiplier > 1) {
|
||||
this.waveAmplitudeMultiplier -= fixedDt * 3;
|
||||
if (this.waveAmplitudeMultiplier < 1) this.waveAmplitudeMultiplier = 1;
|
||||
} else if (!this.isPlaying && this.waveAmplitudeMultiplier > 0.1) {
|
||||
this.waveAmplitudeMultiplier -= fixedDt * 2;
|
||||
if (this.waveAmplitudeMultiplier < 0.1) this.waveAmplitudeMultiplier = 0.1;
|
||||
}
|
||||
|
||||
this.draw();
|
||||
|
||||
this.ctx.draw(false);
|
||||
|
||||
// Decide whether to continue the render loop. Short-circuit on isPlaying first for perf.
|
||||
const shouldContinue =
|
||||
this.isPlaying ||
|
||||
this.shockwaves.length > 0 ||
|
||||
Math.abs(this.coreScale - this.coreTargetScale) >= 0.001 ||
|
||||
Math.abs(this.coreVelocity) >= 0.001 ||
|
||||
this.waveAmplitudeMultiplier > 0.11;
|
||||
|
||||
if (shouldContinue) {
|
||||
// Polyfill requestAnimationFrame for uni-app
|
||||
this.timer = setTimeout(() => {
|
||||
this.renderLoop();
|
||||
}, 1000 / 60);
|
||||
} else {
|
||||
this.timer = null;
|
||||
this.renderRunning = false;
|
||||
}
|
||||
},
|
||||
|
||||
draw() {
|
||||
const ctx = this.ctx;
|
||||
const W = this.canvasWidth;
|
||||
const H = this.canvasHeight;
|
||||
|
||||
// Clear entire canvas to ensure it is completely transparent and blends with the page background
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
// Dynamic majestic sizing to fill the huge screen area
|
||||
const coreRadius = Math.min(W, H) * 0.32; // Make the central circle MASSIVE
|
||||
const ringRadius = coreRadius + 18;
|
||||
const cx = W / 2;
|
||||
const cy = H / 2 - 30; // Slightly above center
|
||||
|
||||
// 3. Draw Shockwaves (Ripples)
|
||||
for (let i = this.shockwaves.length - 1; i >= 0; i--) {
|
||||
const sw = this.shockwaves[i];
|
||||
sw.radius += (sw.maxRadius - sw.radius) * 0.08;
|
||||
sw.opacity -= 0.03;
|
||||
|
||||
if (sw.opacity <= 0) {
|
||||
this.shockwaves.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, sw.radius, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = `rgba(16, 185, 129, ${sw.opacity * 0.5})`;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 4. Draw Energy Waves (Bottom area)
|
||||
this.drawWaves(ctx, W, H);
|
||||
|
||||
// 5. Draw Interactive BPM Core
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.scale(this.coreScale, this.coreScale);
|
||||
|
||||
// The Core Button (Emerald Gradient)
|
||||
ctx.setShadow(0, 12, 24, 'rgba(16, 185, 129, 0.3)');
|
||||
|
||||
const coreGrad = ctx.createLinearGradient(-coreRadius, -coreRadius, coreRadius, coreRadius);
|
||||
coreGrad.addColorStop(0, '#34d399'); // Light Mint
|
||||
coreGrad.addColorStop(1, '#047857'); // Deep Emerald
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, coreRadius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = coreGrad;
|
||||
ctx.fill();
|
||||
|
||||
// Clear shadow for internal elements
|
||||
ctx.setShadow(0, 0, 0, 'transparent');
|
||||
|
||||
// Inner Subtle Highlight (Glass edge)
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, coreRadius - 2, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Text - BPM Number (MASSIVE font)
|
||||
const fontSize = Math.floor(coreRadius * 0.7); // Dynamic font size based on radius
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = `bold ${fontSize}px "DIN Condensed", "Inter", sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
// Removed shadowBlur for text to save low-end GPU performance
|
||||
ctx.fillText(this.bpm.toString(), 0, -12);
|
||||
|
||||
// Text - 'BPM' Label
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
|
||||
ctx.font = '500 16px "Inter", sans-serif';
|
||||
ctx.fillText('BPM', 0, coreRadius * 0.3);
|
||||
|
||||
// Play/Pause Icon
|
||||
ctx.fillStyle = '#ffffff';
|
||||
const iconY = coreRadius * 0.55;
|
||||
if (this.isPlaying) {
|
||||
// Pause Icon (Two vertical bars)
|
||||
ctx.fillRect(-8, iconY - 6, 5, 14);
|
||||
ctx.fillRect(3, iconY - 6, 5, 14);
|
||||
} else {
|
||||
// Play Icon (Triangle)
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-5, iconY - 8);
|
||||
ctx.lineTo(9, iconY);
|
||||
ctx.lineTo(-5, iconY + 8);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
drawWaves(ctx, W, H) {
|
||||
const baseY = H * 0.82;
|
||||
const dynamicAmp = H * 0.08;
|
||||
// Reduced to 2 layers to save GPU/Bridge rendering time and ensure 60fps
|
||||
const waves = [
|
||||
{ color: 'rgba(52, 211, 153, 0.3)', speed: 1.5, freq: 0.012, amp: dynamicAmp * 0.7, offset: 0 },
|
||||
{ color: 'rgba(16, 185, 129, 0.6)', speed: 2.5, freq: 0.015, amp: dynamicAmp, offset: Math.PI }
|
||||
];
|
||||
|
||||
const activeAmp = this.waveAmplitudeMultiplier;
|
||||
const t = this.time;
|
||||
|
||||
waves.forEach(wave => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, H);
|
||||
ctx.lineTo(0, baseY);
|
||||
|
||||
// Increased step size to drastically reduce JS-to-Native bridge commands
|
||||
const step = 25;
|
||||
for (let x = 0; x <= W; x += step) {
|
||||
// Removed edge envelope taper so waves crash cleanly into the edge of the screen
|
||||
const y = baseY + Math.sin(x * wave.freq + t * wave.speed + wave.offset) * wave.amp * activeAmp;
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
// Add one final line to exactly W to ensure flush edge
|
||||
const finalY = baseY + Math.sin(W * wave.freq + t * wave.speed + wave.offset) * wave.amp * activeAmp;
|
||||
ctx.lineTo(W, finalY);
|
||||
|
||||
ctx.lineTo(W, H);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = wave.color;
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.walker-canvas-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.walker-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent; /* Pure transparent to blend into page */
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,551 @@
|
||||
<template>
|
||||
<view class="page" :style="rootStyle">
|
||||
<!-- ===== 统一运动律动视区 (Canvas 律动舞台) ===== -->
|
||||
<view class="stage-canvas-wrapper">
|
||||
<WalkerCanvas
|
||||
ref="walkerCanvasRef"
|
||||
:bpm="currentBpm"
|
||||
:is-playing="isPlaying"
|
||||
@toggle-play="onCenterTap"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- ===== 音色选择 ===== -->
|
||||
<view class="sound-selector">
|
||||
<view
|
||||
class="sound-option"
|
||||
:class="{ active: currentSound === 'crisp' }"
|
||||
@click="onSoundSelect('crisp')"
|
||||
>
|
||||
<text class="sound-icon">✨</text>
|
||||
<text class="sound-label">清脆</text>
|
||||
</view>
|
||||
<view
|
||||
class="sound-option"
|
||||
:class="{ active: currentSound === 'wood' }"
|
||||
@click="onSoundSelect('wood')"
|
||||
>
|
||||
<text class="sound-icon">🪵</text>
|
||||
<text class="sound-label">木鱼</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 推荐档位(3 选 1) ===== -->
|
||||
<view class="presets">
|
||||
<view
|
||||
v-for="p in LOOP_PRESETS"
|
||||
:key="p.id"
|
||||
class="preset"
|
||||
:class="{ active: currentLoop === p.id }"
|
||||
@click="onPresetTap(p.id)"
|
||||
>
|
||||
<text class="preset-name">{{ p.label }}</text>
|
||||
<text class="preset-bpm">{{ p.bpm }} BPM</text>
|
||||
<text class="preset-desc">{{ p.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 自定义节奏区域(含标题栏+折叠内容) ===== -->
|
||||
<view class="custom-section">
|
||||
<!-- 标题栏(始终显示) -->
|
||||
<view class="custom-header" @click="onToggleCustom">
|
||||
<text class="custom-title">{{ customExpanded ? '✕ 收起自定义' : '⚙ 自定义节奏' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 折叠内容(仅展开时显示) -->
|
||||
<view v-if="customExpanded" class="custom-content">
|
||||
<!-- BPM 微调 -->
|
||||
<view class="row">
|
||||
<text class="row-label">BPM</text>
|
||||
<view class="bpm-stepper">
|
||||
<view class="step-btn" @click="onBpmDelta(-5)">−5</view>
|
||||
<view class="step-btn" @click="onBpmDelta(-1)">−1</view>
|
||||
<view class="step-val">{{ currentBpm }}</view>
|
||||
<view class="step-btn" @click="onBpmDelta(1)">+1</view>
|
||||
<view class="step-btn" @click="onBpmDelta(5)">+5</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 拍号 -->
|
||||
<view class="row">
|
||||
<text class="row-label">拍号</text>
|
||||
<view class="meter-tabs">
|
||||
<view
|
||||
v-for="n in [2, 3, 4]"
|
||||
:key="n"
|
||||
class="meter-tab"
|
||||
:class="{ active: customAccent === n }"
|
||||
@click="onAccentSelect(n)"
|
||||
>{{ n }}/4</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onShow, onHide, onUnload } from '@dcloudio/uni-app'
|
||||
import { useMetronome } from '../hooks/useMetronome'
|
||||
import WalkerCanvas from './components/walker-canvas.vue'
|
||||
|
||||
type LoopId = 'slow' | 'normal' | 'brisk'
|
||||
interface LoopPreset {
|
||||
id: LoopId
|
||||
bpm: number
|
||||
label: string
|
||||
desc: string
|
||||
}
|
||||
|
||||
const LOOP_PRESETS: readonly LoopPreset[] = [
|
||||
{ id: 'slow', bpm: 110, label: '慢走', desc: '热身 · 恢复' },
|
||||
{ id: 'normal', bpm: 130, label: '健走', desc: '日常 · 通勤' },
|
||||
{ id: 'brisk', bpm: 150, label: '快走', desc: '提速 · 燃脂' },
|
||||
]
|
||||
|
||||
const customExpanded = ref<boolean>(false)
|
||||
const currentLoop = ref<LoopId | null>('normal')
|
||||
const currentSound = ref<'crisp' | 'wood'>('crisp')
|
||||
const walkerCanvasRef = ref<any>(null)
|
||||
|
||||
/* 节拍器音效配置 */
|
||||
const SOUND_PRESETS = {
|
||||
crisp: {
|
||||
normal: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260527/202605271539371ebe13672.mp3',
|
||||
accent: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260527/202605271539376ff628472.mp3',
|
||||
},
|
||||
wood: {
|
||||
normal: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3',
|
||||
accent: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3',
|
||||
},
|
||||
}
|
||||
|
||||
// 统一采用高精度引擎,彻底抛弃卡顿的后台播放引擎
|
||||
const fg = useMetronome({
|
||||
initialBpm: 130,
|
||||
accentEvery: 2,
|
||||
clickSrc: SOUND_PRESETS.crisp.normal,
|
||||
accentSrc: SOUND_PRESETS.crisp.accent,
|
||||
poolSize: 4,
|
||||
// 【完美音画同步核心】
|
||||
onBeat: (index, isAccent) => {
|
||||
if (walkerCanvasRef.value && walkerCanvasRef.value.triggerBeat) {
|
||||
walkerCanvasRef.value.triggerBeat()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isPlaying = computed(() => fg.isPlaying.value)
|
||||
const currentBpm = computed(() => fg.bpm.value)
|
||||
const customAccent = computed(() => fg.accentEvery.value)
|
||||
const intervalMs = computed(() => 60000 / fg.bpm.value)
|
||||
|
||||
/* ============================================================
|
||||
* 中央圆按钮
|
||||
* ============================================================ */
|
||||
const onCenterTap = () => {
|
||||
if (fg.isPlaying.value) {
|
||||
fg.stop()
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
} else {
|
||||
fg.start()
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 档位卡片
|
||||
* ============================================================ */
|
||||
const onPresetTap = (id: LoopId) => {
|
||||
const preset = LOOP_PRESETS.find(p => p.id === id)
|
||||
if (!preset) return
|
||||
|
||||
currentLoop.value = id
|
||||
fg.setBpm(preset.bpm)
|
||||
if (!fg.isPlaying.value) {
|
||||
fg.start()
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 自定义折叠区切换
|
||||
* ============================================================ */
|
||||
const onToggleCustom = () => {
|
||||
customExpanded.value = !customExpanded.value
|
||||
if (customExpanded.value) {
|
||||
fg.preload()
|
||||
} else {
|
||||
// 收起时,如果当前 BPM 刚好匹配某个档位,高亮对应档位卡片
|
||||
const matched = LOOP_PRESETS.find(p => p.bpm === fg.bpm.value)
|
||||
currentLoop.value = matched ? matched.id : null
|
||||
}
|
||||
}
|
||||
|
||||
const onBpmDelta = (delta: number) => {
|
||||
fg.setBpm(fg.bpm.value + delta)
|
||||
currentLoop.value = null // 用户手动微调后取消档位高亮
|
||||
}
|
||||
|
||||
const onAccentSelect = (n: number) => {
|
||||
fg.setAccentEvery(n)
|
||||
}
|
||||
|
||||
const onSoundSelect = (sound: 'crisp' | 'wood') => {
|
||||
currentSound.value = sound
|
||||
const preset = SOUND_PRESETS[sound]
|
||||
fg.updateAudioSrc(preset.normal, preset.accent)
|
||||
}
|
||||
|
||||
const rootStyle = computed(() => ({
|
||||
'--beat-duration': `${intervalMs.value}ms`,
|
||||
}))
|
||||
|
||||
onShow(() => {
|
||||
fg.preload()
|
||||
})
|
||||
|
||||
function cleanupMetronome() {
|
||||
fg.stop()
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
}
|
||||
|
||||
onHide(cleanupMetronome)
|
||||
onUnload(cleanupMetronome)
|
||||
|
||||
onUnmounted(cleanupMetronome)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ============================================================
|
||||
* 设计 token —— 浅色清爽
|
||||
* ============================================================ */
|
||||
$bg-color: #f8fafc;
|
||||
$card-bg: #ffffff;
|
||||
$card-border: rgba(15, 23, 42, 0.06);
|
||||
$text-1: #0f172a;
|
||||
$text-2: #475569;
|
||||
$text-3: #94a3b8;
|
||||
$brand: #10b981;
|
||||
$brand-soft: #d1fae5;
|
||||
$brand-deep: #047857;
|
||||
$warn: #f59e0b;
|
||||
$warn-soft: #fef3c7;
|
||||
$radius-card: 24rpx;
|
||||
$shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
/* ============================================================
|
||||
* 页面容器
|
||||
* ============================================================ */
|
||||
.page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 30rpx 28rpx 50rpx;
|
||||
background: $bg-color;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 统一运动律动视区 (Canvas)
|
||||
* ============================================================ */
|
||||
.stage-canvas-wrapper {
|
||||
width: 100vw;
|
||||
margin-left: -28rpx;
|
||||
margin-right: -28rpx;
|
||||
flex-shrink: 0; /* 防止被压缩 */
|
||||
height: 600rpx; /* 固定高度,不随内容变化 */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 24rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 音色选择器
|
||||
* ============================================================ */
|
||||
.sound-selector {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.sound-option {
|
||||
flex: 1;
|
||||
background: $card-bg;
|
||||
border: 2rpx solid $card-border;
|
||||
border-radius: $radius-card;
|
||||
padding: 20rpx 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10rpx;
|
||||
box-shadow: $shadow-sm;
|
||||
transition: all 0.18s;
|
||||
|
||||
.sound-icon {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.sound-label {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: $text-2;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
||||
border-color: $brand;
|
||||
box-shadow:
|
||||
0 8rpx 20rpx rgba(16, 185, 129, 0.18),
|
||||
inset 0 0 0 2rpx rgba(16, 185, 129, 0.4);
|
||||
|
||||
.sound-label {
|
||||
color: $brand-deep;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 三档位推荐
|
||||
* ============================================================ */
|
||||
.presets {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.preset {
|
||||
flex: 1;
|
||||
background: $card-bg;
|
||||
border: 2rpx solid $card-border;
|
||||
border-radius: $radius-card;
|
||||
padding: 26rpx 8rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
box-shadow: $shadow-sm;
|
||||
transition: all 0.18s;
|
||||
|
||||
.preset-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
.preset-bpm {
|
||||
font-size: 22rpx;
|
||||
color: $text-2;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.preset-desc {
|
||||
font-size: 18rpx;
|
||||
color: $text-3;
|
||||
letter-spacing: 1rpx;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: $brand-soft;
|
||||
border-color: $brand;
|
||||
box-shadow:
|
||||
0 8rpx 20rpx rgba(16, 185, 129, 0.18),
|
||||
inset 0 0 0 2rpx rgba(16, 185, 129, 0.4);
|
||||
|
||||
.preset-name {
|
||||
color: $brand-deep;
|
||||
}
|
||||
.preset-bpm {
|
||||
color: $brand-deep;
|
||||
}
|
||||
.preset-desc {
|
||||
color: $brand;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 自定义节奏区域(标题栏 + 折叠内容)
|
||||
* ============================================================ */
|
||||
.custom-section {
|
||||
width: 100%;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.custom-header {
|
||||
width: 100%;
|
||||
padding: 16rpx 6rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.custom-title {
|
||||
font-size: 22rpx;
|
||||
color: $text-3;
|
||||
letter-spacing: 0.5rpx;
|
||||
|
||||
&:active {
|
||||
color: $text-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-content {
|
||||
width: 100%;
|
||||
background: $card-bg;
|
||||
border: 2rpx solid rgba(245, 158, 11, 0.25);
|
||||
border-radius: $radius-card;
|
||||
box-shadow: 0 6rpx 18rpx rgba(245, 158, 11, 0.08);
|
||||
padding: 20rpx 24rpx 22rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.custom-warn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 4rpx;
|
||||
|
||||
.warn-dot {
|
||||
font-size: 18rpx;
|
||||
color: $warn;
|
||||
}
|
||||
.warn-text {
|
||||
font-size: 22rpx;
|
||||
color: #b45309;
|
||||
letter-spacing: 0.5rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
|
||||
.row-label {
|
||||
font-size: 24rpx;
|
||||
color: $text-2;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1rpx;
|
||||
flex-shrink: 0;
|
||||
width: 70rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.bpm-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
|
||||
.step-btn {
|
||||
min-width: 56rpx;
|
||||
height: 52rpx;
|
||||
line-height: 52rpx;
|
||||
text-align: center;
|
||||
font-size: 22rpx;
|
||||
color: $text-2;
|
||||
background: #f1f5f9;
|
||||
border-radius: 10rpx;
|
||||
font-weight: 600;
|
||||
padding: 0 10rpx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&:active {
|
||||
background: #e2e8f0;
|
||||
transform: scale(0.94);
|
||||
}
|
||||
}
|
||||
.step-val {
|
||||
min-width: 84rpx;
|
||||
height: 52rpx;
|
||||
line-height: 52rpx;
|
||||
text-align: center;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: $text-1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: $warn-soft;
|
||||
border-radius: 10rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.meter-tabs {
|
||||
display: flex;
|
||||
gap: 8rpx;
|
||||
|
||||
.meter-tab {
|
||||
height: 52rpx;
|
||||
line-height: 52rpx;
|
||||
padding: 0 18rpx;
|
||||
font-size: 22rpx;
|
||||
color: $text-2;
|
||||
background: #f1f5f9;
|
||||
border-radius: 10rpx;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1rpx;
|
||||
|
||||
&.active {
|
||||
background: $warn;
|
||||
color: #fff;
|
||||
box-shadow: 0 4rpx 10rpx rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 底部行(已废弃,保留样式以防引用)
|
||||
* ============================================================ */
|
||||
.bottom-row {
|
||||
width: 100%;
|
||||
margin-top: 6rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 6rpx;
|
||||
}
|
||||
|
||||
.bottom-link {
|
||||
font-size: 22rpx;
|
||||
color: $text-3;
|
||||
letter-spacing: 0.5rpx;
|
||||
padding: 8rpx 4rpx;
|
||||
|
||||
&:active {
|
||||
color: $text-2;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-hint {
|
||||
font-size: 22rpx;
|
||||
color: $text-3;
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,215 @@
|
||||
# 训练模块静态资源
|
||||
|
||||
本目录用于存放"练一练"功能的所有音频素材。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
training/static/
|
||||
├── audio/ # 当前为空 —— 节拍器音频已全部迁移到 COS CDN
|
||||
├── voice/ # (规划中) TTS 语音教练
|
||||
│ ├── numbers/ 数字报数 1~30
|
||||
│ └── prompts/ 开始/休息/再来 等口令
|
||||
├── bgm/ # (规划中) 训练/休息背景乐
|
||||
└── footprint.svg # 脚印图标(早期方案残留,可保留作为备用素材)
|
||||
```
|
||||
|
||||
> 节拍器音频已完全走 CDN(`gz-1349751149.cos.ap-guangzhou.myqcloud.com`),减少小程序包体积约 210 KB。`useMetronomeBg.ts` / `useMetronome.ts` 中的 URL 即源信息,更换音色只需改 hook 里的常量。
|
||||
|
||||
## 准备步骤
|
||||
|
||||
### 1. 语音素材(小米 MiMo TTS 自动生成)
|
||||
|
||||
需要 Node 18+(用内置 `fetch`)。先准备小米 MiMo API Key:
|
||||
|
||||
- 文档: https://platform.xiaomimimo.com/
|
||||
- API Key 形如 `sk-xxxxxxxx`
|
||||
|
||||
```bash
|
||||
export MIMO_API_KEY=sk-your-key-here
|
||||
|
||||
cd uniapp
|
||||
node scripts/generate-voice.mjs
|
||||
```
|
||||
|
||||
会自动调用 `mimo-v2.5-tts` 模型生成 39 个 mp3(30 个数字 + 9 个口令)到 `voice/` 目录。
|
||||
|
||||
**可选参数**:
|
||||
|
||||
```bash
|
||||
# 换音色(默认 冰糖;可选:冰糖/茉莉/苏打/白桦/Mia/Chloe/Milo/Dean)
|
||||
node scripts/generate-voice.mjs --voice 茉莉
|
||||
|
||||
# 换格式(默认 mp3,需要跟 hooks/useVoiceCoach.ts 里的 .mp3 后缀对应)
|
||||
node scripts/generate-voice.mjs --format wav
|
||||
|
||||
# 强制重新生成(默认存在则跳过)
|
||||
node scripts/generate-voice.mjs --force
|
||||
```
|
||||
|
||||
**音色推荐**(中文女声更适合健身教练):
|
||||
- `冰糖`:温柔甜美,亲和力强(默认)
|
||||
- `茉莉`:清爽利落,有"运动博主"感
|
||||
- `苏打`:年轻男声,有力量感
|
||||
- `白桦`:成熟男声,沉稳
|
||||
|
||||
### 2. 节拍器 click / 循环音轨
|
||||
|
||||
**全部托管在腾讯云 COS(2026-05-26 上传)**,本地不再保留:
|
||||
|
||||
| 用途 | 文件 | 大小 | 引擎 |
|
||||
|---|---|---|---|
|
||||
| 单拍 click(基础) | `click.mp3` | 3.4 KB | InnerAudio |
|
||||
| 单拍 click(木鱼,推荐) | `click-wood.mp3` | 4.6 KB | InnerAudio (custom 模式) |
|
||||
| 循环音轨 慢走 80 BPM | `loop_80bpm_2.mp3` | 71 KB | BgAudio (preset) |
|
||||
| 循环音轨 健走 110 BPM | `loop_110bpm_2.mp3` | 65 KB | BgAudio (preset) |
|
||||
| 循环音轨 快走 130 BPM | `loop_130bpm_2.mp3` | 66 KB | BgAudio (preset) |
|
||||
|
||||
> 微信 `BackgroundAudioManager.src` 只接受 https URL,不支持包内资源,所以必须走 CDN。
|
||||
> COS 域名已加入小程序后台 `downloadFile 合法域名`,跑步视频也走同一域名。
|
||||
|
||||
更换音色:去 [pixabay.com/sound-effects](https://pixabay.com/sound-effects/) 找新素材 → 上传到 COS → 在 `useMetronomeBg.ts` / `useMetronome.ts` 里替换 URL 即可。
|
||||
|
||||
### 3. 背景音乐
|
||||
|
||||
每首 30 秒~2 分钟即可(loop 后听不出接缝)。
|
||||
|
||||
推荐来源(免费可商用):
|
||||
- [pixabay.com/music](https://pixabay.com/music) 搜 "calm" / "meditation" / "lofi"
|
||||
- [freemusicarchive.org](https://freemusicarchive.org)
|
||||
- [bensound.com](https://bensound.com)(含署名)
|
||||
|
||||
文件大小建议 < 1MB(mp3 128kbps 单声道即可)。
|
||||
|
||||
## 在代码里被引用的位置
|
||||
|
||||
- `training/hooks/useMetronome.ts` → `DEFAULT_CLICK_SRC` (CDN: click.mp3)
|
||||
- `training/hooks/useMetronomeBg.ts` → `LOOP_PRESETS[*].src` (CDN: loop_*.mp3)
|
||||
- `training/pages/metronome.vue` → `CLICK_WOOD_URL` (CDN: click-wood.mp3, custom 模式)
|
||||
- `training/hooks/useVoiceCoach.ts` → `voice/numbers/*.mp3`、`voice/prompts/*.mp3` (规划中)
|
||||
- `training/hooks/useTrainingBgm.ts` → `bgm/*.mp3` (规划中)
|
||||
|
||||
如需修改路径,修改对应 hook 文件里的常量即可。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 微信小程序对单个文件大小有 10MB 上限,本目录所有文件加起来建议控制在 5MB 以内。
|
||||
- 小程序整包大小限制(主包 2MB / 总包 20MB),如果资源较大建议放 CDN 而非本地 static。
|
||||
- 把 `useVoiceCoach.ts` 里的 `VOICE_BASE` 改成 CDN URL 即可。
|
||||
|
||||
---
|
||||
|
||||
## 后台/锁屏播放设计备忘(未来健身模块用)
|
||||
|
||||
> 节拍器目前用 `InnerAudioContext` + `requiredBackgroundModes:["audio"]` 已能覆盖 5~10 分钟健走场景。
|
||||
> 真要做长时间训练 BGM、锁屏控制条等高级能力,参考下面的 `BackgroundAudioManager` 方案。
|
||||
|
||||
### 引擎选择对照表
|
||||
|
||||
健身模块的音频天然分两类,配两套引擎不冲突:
|
||||
|
||||
| 音频类型 | 时长 | 推荐引擎 | 理由 |
|
||||
|---|---|---|---|
|
||||
| 训练/休息 BGM | 30s~2min 循环 | **BackgroundAudioManager** | 长流、需要后台/锁屏不停 |
|
||||
| 语音教练("开始/休息/再来") | 1~3s 单句 | InnerAudioContext | 短促,前台用即可 |
|
||||
| 报数(1, 2, 3...) | 0.5~1s | InnerAudioContext + 池子 | 高频短促 |
|
||||
| 节拍器 click | 50~150ms | InnerAudioContext + 池子 | 极短,BgAudio 不接受 |
|
||||
|
||||
**关键约束:BgAudio 是全局单例,一次只能播 1 个音频**。所以让它专门播 BGM 这类"长流",其他短音用 InnerAudio 配合,互不打架。
|
||||
|
||||
### BackgroundAudioManager 限制清单(踩坑预警)
|
||||
|
||||
1. **全局单例**:整个小程序同一时刻只能播一个,多场景要协调切换
|
||||
2. **音频时长 ≥ 1 秒**:太短的 click 会被微信判定异常忽略
|
||||
3. **必填 metadata**:`title` / `coverImgUrl` / `singer` / `epname` / `webUrl` 缺一会报错
|
||||
4. **切 src 有延迟**:200~500ms 初始化抖动,频繁切换会卡顿
|
||||
5. **必须声明 `requiredBackgroundModes:["audio"]`** 才能后台播放
|
||||
6. **iOS 锁屏豁免**:声明后能锁屏继续播,无 5 分钟时长限制(vs InnerAudio 的 5min)
|
||||
7. **会显示系统控制条**:锁屏/通知栏出现带封面+暂停按钮的控制条
|
||||
|
||||
### 推荐架构(健身模块上线时)
|
||||
|
||||
```
|
||||
training/hooks/
|
||||
├── useTrainingBgm.ts → BackgroundAudioManager (后台/锁屏继续放音乐)
|
||||
├── useVoiceCoach.ts → InnerAudioContext (语音指导,前台用)
|
||||
└── useMetronome.ts → InnerAudioContext (节拍器,池子方案,保持现状)
|
||||
```
|
||||
|
||||
页面 `pages.json` 声明:
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "pages/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "练一练",
|
||||
"requiredBackgroundModes": ["audio"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
锁屏会显示 BGM 控制条,老人能直接在锁屏点暂停。训练页面通过 `bgm.onPause()` 监听同步暂停训练,体验连贯。
|
||||
|
||||
### 必备资产清单
|
||||
|
||||
到时候要准备的文件:
|
||||
|
||||
- **BGM 2 首**:`bgm/train-light.mp3`(训练)、`bgm/rest-meditation.mp3`(休息)
|
||||
- 每个 30s~1min,128kbps 单声道,30~80KB
|
||||
- 推荐来源:[pixabay.com/music](https://pixabay.com/music) 搜 "calm" / "meditation" / "lofi"
|
||||
- **锁屏封面**:`audio/cover.jpg` 200×200
|
||||
- 简洁绿色背景 + 训练 emoji 即可
|
||||
|
||||
### BackgroundAudioManager API 速查
|
||||
|
||||
```ts
|
||||
const bgm = uni.getBackgroundAudioManager()
|
||||
|
||||
/* 必填 metadata,缺一会报错 */
|
||||
bgm.title = '健走训练中'
|
||||
bgm.coverImgUrl = '/training/static/audio/cover.jpg'
|
||||
bgm.epname = '甄养堂'
|
||||
bgm.singer = '健走节拍'
|
||||
bgm.webUrl = '' // 必填,空字符串可
|
||||
|
||||
/* src 一旦赋值会自动播放 */
|
||||
bgm.src = '/training/static/audio/bgm/train-light.mp3'
|
||||
|
||||
/* 控制 */
|
||||
bgm.pause() // 暂停 (锁屏控制条仍在)
|
||||
bgm.play() // 继续
|
||||
bgm.stop() // 真停 + 隐藏控制条
|
||||
bgm.seek(30) // 跳到 30s
|
||||
|
||||
/* 事件监听 — 用户从锁屏点暂停时会触发 */
|
||||
bgm.onPause(() => { /* 同步训练 UI 状态 */ })
|
||||
bgm.onPlay(() => {})
|
||||
bgm.onStop(() => {})
|
||||
bgm.onEnded(() => { /* 不 loop 时触发,可手动接下一首 */ })
|
||||
bgm.onError((err) => { console.error(err) })
|
||||
```
|
||||
|
||||
### 切换不同场景音乐的模式
|
||||
|
||||
```ts
|
||||
function switchBgm(scene: 'training' | 'resting' | 'none') {
|
||||
if (scene === 'none') {
|
||||
bgm.stop()
|
||||
return
|
||||
}
|
||||
bgm.title = scene === 'training' ? '健走训练中' : '休息恢复中'
|
||||
bgm.src = scene === 'training'
|
||||
? '/training/static/audio/bgm/train-light.mp3'
|
||||
: '/training/static/audio/bgm/rest-meditation.mp3'
|
||||
/* 注:setSrc 会自动播放,有 200~500ms 切换延迟 */
|
||||
}
|
||||
```
|
||||
|
||||
### 节拍器要不要也升级到 BgAudio?
|
||||
|
||||
**目前不需要**。节拍器升级 BgAudio 的代价:
|
||||
- 需要预合成 3 个档位的循环 mp3(80/110/130 BPM × 2 拍)
|
||||
- 必须砍掉右下角微调按钮(预合成 mp3 改不了 BPM)
|
||||
- 切档位有 200~500ms 卡顿
|
||||
|
||||
如果未来发现"健走 30 分钟以上锁屏会停"才考虑改。当前 5~10 分钟场景 InnerAudio + `requiredBackgroundModes` 够用。
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 150">
|
||||
<g fill="#e2e8f0" fill-opacity="0.92">
|
||||
<path d="M 50,30 C 38,30 28,32 24,40 C 19,50 18,60 22,70 C 26,82 26,92 28,102 C 30,122 40,140 50,140 C 60,140 70,122 72,102 C 74,92 74,82 78,70 C 82,60 81,50 76,40 C 72,32 62,30 50,30 Z"/>
|
||||
<ellipse cx="30" cy="20" rx="6.5" ry="8" transform="rotate(-18 30 20)"/>
|
||||
<ellipse cx="43" cy="11" rx="5" ry="6.8" transform="rotate(-6 43 11)"/>
|
||||
<ellipse cx="55" cy="9" rx="4.5" ry="6.2" transform="rotate(4 55 9)"/>
|
||||
<ellipse cx="66" cy="13" rx="4" ry="5.5" transform="rotate(13 66 13)"/>
|
||||
<ellipse cx="76" cy="20" rx="3.5" ry="5" transform="rotate(22 76 20)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 730 B |
@@ -0,0 +1,276 @@
|
||||
# 握力环训练音效采集清单
|
||||
|
||||
> 更新日期: 2026-05-28
|
||||
> 用途: 为握力环训练页面添加完整音效体系
|
||||
|
||||
---
|
||||
|
||||
## 📋 总体规划
|
||||
|
||||
| 类型 | 数量 | 单文件大小 | 总大小预算 | 优先级 |
|
||||
|------|------|-----------|-----------|--------|
|
||||
| 节拍音效 | 1-3 个音色 | 5-10 KB | < 30 KB | P0 |
|
||||
| 捏碎音效 | 4 个 | 20-50 KB | < 200 KB | P0 |
|
||||
| 背景音乐 | 1-2 首 | 200-500 KB | < 1 MB | P1 |
|
||||
| 语音鼓励 | 4-8 句 | 10-30 KB | < 200 KB | P1 |
|
||||
|
||||
**总计预算**: < 1.5 MB(都放 CDN,不影响小程序包体积)
|
||||
|
||||
---
|
||||
|
||||
## 🥁 一、节拍音效 (P0 - 必需)
|
||||
|
||||
### 用途
|
||||
训练时按 BPM 节奏播放,引导用户握紧/松开。
|
||||
|
||||
### 推荐音色 (任选 1-3 个)
|
||||
|
||||
| 音色 | 推荐度 | 描述 | Pixabay 搜索词 |
|
||||
|------|--------|------|---------------|
|
||||
| 木鱼 | ⭐⭐⭐⭐⭐ | 温暖、东方意境,适合养生主题 | `wood block`, `wooden tap`, `muyu` |
|
||||
| 心跳 | ⭐⭐⭐⭐ | 有运动感,代入感强 | `heart beat`, `heartbeat single` |
|
||||
| 轻柔敲击 | ⭐⭐⭐⭐ | 不刺耳,适合长时间训练 | `soft tap`, `gentle knock` |
|
||||
| 竹片声 | ⭐⭐⭐ | 清脆,有节奏感 | `bamboo tap`, `bamboo click` |
|
||||
| 鼓点 | ⭐⭐⭐ | 有力量感,适合"重度"档位 | `kick drum soft`, `tom drum tap` |
|
||||
|
||||
### 技术要求
|
||||
- **格式**: mp3
|
||||
- **时长**: 50-150ms (不能太长,会重叠)
|
||||
- **采样率**: 22050Hz 或 44100Hz
|
||||
- **声道**: 单声道
|
||||
- **大小**: < 10 KB
|
||||
|
||||
### 命名规范
|
||||
```
|
||||
grip-tick-wood.mp3 # 木鱼
|
||||
grip-tick-heart.mp3 # 心跳
|
||||
grip-tick-soft.mp3 # 轻柔
|
||||
grip-tick-bamboo.mp3 # 竹片
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💥 二、捏碎音效 (P0 - 必需)
|
||||
|
||||
### 用途
|
||||
达成奖励里程碑时播放,与粒子特效同步。
|
||||
|
||||
### 音效清单
|
||||
|
||||
| 奖励 | 触发次数 | Pixabay 搜索词 | 时长 | 备注 |
|
||||
|------|---------|---------------|------|------|
|
||||
| 🥚 鸡蛋 | 8-12 | `egg crack`, `eggshell break`, `crack sound` | 0.5-1s | 清脆短促 |
|
||||
| 🥜 核桃 | 15-20 | `nut crack`, `walnut break`, `wood snap` | 0.5-1s | 木质爆裂感 |
|
||||
| 🥫 易拉罐 | 25-30 | `can crush`, `aluminum crush`, `metal crunch` | 1-2s | 金属挤压 |
|
||||
| 🎈 气球 | 40-50 | `balloon pop`, `pop burst`, `balloon explosion` | 0.3-0.5s | 短促爆裂 |
|
||||
|
||||
### 技术要求
|
||||
- **格式**: mp3
|
||||
- **时长**: 0.3-2s
|
||||
- **大小**: < 50 KB
|
||||
- **音量**: 适中(避免突然吓到用户)
|
||||
|
||||
### 命名规范
|
||||
```
|
||||
crush-egg.mp3
|
||||
crush-walnut.mp3
|
||||
crush-can.mp3
|
||||
crush-balloon.mp3
|
||||
```
|
||||
|
||||
### 试听建议
|
||||
下载前一定要试听,优先选择:
|
||||
- ✅ 干净,无杂音
|
||||
- ✅ 中等音量(峰值不爆音)
|
||||
- ✅ 短促有力(不拖沓)
|
||||
- ❌ 避免: 太长、太尖锐、有人声
|
||||
|
||||
---
|
||||
|
||||
## 🎵 三、背景音乐 BGM (P1 - 推荐)
|
||||
|
||||
### 用途
|
||||
训练时持续播放,营造轻松氛围,缓解握力训练的枯燥感。
|
||||
|
||||
### 风格推荐
|
||||
|
||||
| 风格 | 描述 | Pixabay 搜索词 | 适合人群 |
|
||||
|------|------|---------------|---------|
|
||||
| **Lo-Fi** | 慵懒电子,流行选择 | `lofi calm`, `lofi study`, `lofi chill` | 年轻人 |
|
||||
| **冥想轻音乐** | 钢琴 + 自然音,放松 | `meditation`, `calm piano`, `relaxing` | 中老年 |
|
||||
| **轻爵士** | 优雅放松,有质感 | `light jazz`, `cafe jazz`, `bossa nova` | 通用 |
|
||||
| **自然环境音** | 鸟叫/流水/雨声 | `nature ambient`, `forest sounds`, `rain bgm` | 喜欢自然的用户 |
|
||||
| **东方禅意** | 古筝/笛子,养生 | `chinese zen`, `oriental calm`, `guqin` | 养生主题契合 |
|
||||
|
||||
### 推荐场景搭配
|
||||
- **轻度档位 (60 BPM)**: 冥想轻音乐 (节奏舒缓)
|
||||
- **中度档位 (80 BPM)**: 轻爵士 / Lo-Fi (适中)
|
||||
- **重度档位 (100 BPM)**: 轻快电子 / 健身流行
|
||||
|
||||
### 技术要求
|
||||
- **格式**: mp3
|
||||
- **时长**: 30s - 2min (循环播放)
|
||||
- **比特率**: 128 kbps (单声道 64 kbps 也可)
|
||||
- **大小**: < 500 KB
|
||||
- **循环点**: 选择起止点接近的片段,避免明显接缝
|
||||
|
||||
### 命名规范
|
||||
```
|
||||
bgm-grip-light.mp3 # 轻度档位 BGM
|
||||
bgm-grip-medium.mp3 # 中度档位 BGM (可选)
|
||||
bgm-grip-heavy.mp3 # 重度档位 BGM (可选)
|
||||
```
|
||||
|
||||
或简化:
|
||||
```
|
||||
bgm-grip-default.mp3 # 默认 BGM (所有档位通用)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎙️ 四、语音鼓励 (P1 - 推荐)
|
||||
|
||||
### 用途
|
||||
达成奖励时播放鼓励语音,增强成就感。
|
||||
|
||||
### 两种方案
|
||||
|
||||
#### 方案 A: 小米 MiMo TTS 自动生成 (推荐)
|
||||
|
||||
项目已有 `useVoiceCoach.ts` + `generate-voice.mjs` 脚本支持。
|
||||
|
||||
**操作步骤**:
|
||||
1. 申请小米 MiMo API Key: https://platform.xiaomimimo.com/
|
||||
2. 配置环境变量: `export MIMO_API_KEY=sk-xxx`
|
||||
3. 在 `generate-voice.mjs` 添加握力环话术
|
||||
4. 运行脚本自动生成
|
||||
|
||||
**话术清单**:
|
||||
```javascript
|
||||
const gripPrompts = [
|
||||
{ id: 'grip-egg', text: '握力不错!' },
|
||||
{ id: 'grip-walnut', text: '力量惊人!' },
|
||||
{ id: 'grip-can', text: '太强了!' },
|
||||
{ id: 'grip-balloon', text: '完美!继续保持!' },
|
||||
{ id: 'grip-start', text: '开始训练!' },
|
||||
{ id: 'grip-pause', text: '休息一下!' },
|
||||
{ id: 'grip-encourage-1', text: '加油,再来!' },
|
||||
{ id: 'grip-encourage-2', text: '坚持住!' },
|
||||
]
|
||||
```
|
||||
|
||||
**音色推荐**: `茉莉` (清爽利落,有"运动博主"感)
|
||||
|
||||
#### 方案 B: 手工录制
|
||||
|
||||
如果不想用 TTS,可以自己录制(质感更好,但费时):
|
||||
- 工具: 手机录音 / Audacity
|
||||
- 环境: 安静的房间,距离麦克风 15-30cm
|
||||
- 后期: 去噪 + 标准化音量
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 五、采集工具推荐
|
||||
|
||||
### 在线裁剪
|
||||
- [audiotrimmer.com](https://audiotrimmer.com) - 简单的 mp3 裁剪
|
||||
- [mp3cut.net](https://mp3cut.net) - 功能更全
|
||||
|
||||
### 桌面工具(免费)
|
||||
- **Audacity** (Mac/Windows/Linux) - 专业级,可以剪辑、降噪、标准化
|
||||
- **Logic Pro** (Mac, 付费) - 更专业的音频处理
|
||||
|
||||
### 音量标准化
|
||||
统一音量(避免有的太响有的太轻):
|
||||
```bash
|
||||
# 使用 ffmpeg (Mac: brew install ffmpeg)
|
||||
ffmpeg -i input.mp3 -af "loudnorm=I=-16:LRA=11:TP=-1.5" output.mp3
|
||||
```
|
||||
|
||||
或在 Audacity 里: Effect → Normalize (峰值 -3dB)
|
||||
|
||||
---
|
||||
|
||||
## ☁️ 六、上传到 CDN
|
||||
|
||||
### 项目使用的 COS 信息
|
||||
- **域名**: `gz-1349751149.cos.ap-guangzhou.myqcloud.com`
|
||||
- **目录建议**: `uploads/training/grip-ring/`
|
||||
|
||||
### 上传途径
|
||||
1. **腾讯云控制台**: https://console.cloud.tencent.com/cos
|
||||
2. **管理后台** (如果项目有上传入口)
|
||||
3. **找团队 COS 管理员上传**
|
||||
|
||||
### 上传后获得的 URL 格式
|
||||
```
|
||||
https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/training/grip-ring/crush-egg.mp3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 七、最终采集清单 (一次性收齐)
|
||||
|
||||
### 最小可行版本 (MVP) - 4 个文件
|
||||
```
|
||||
☐ grip-tick-wood.mp3 # 节拍音 - 木鱼
|
||||
☐ crush-egg.mp3 # 鸡蛋
|
||||
☐ crush-walnut.mp3 # 核桃
|
||||
☐ crush-can.mp3 # 易拉罐
|
||||
☐ crush-balloon.mp3 # 气球
|
||||
```
|
||||
|
||||
### 完整版本 - 10 个文件
|
||||
```
|
||||
节拍音 (3 选 1 或全部):
|
||||
☐ grip-tick-wood.mp3
|
||||
☐ grip-tick-heart.mp3
|
||||
☐ grip-tick-soft.mp3
|
||||
|
||||
捏碎音效 (必需 4 个):
|
||||
☐ crush-egg.mp3
|
||||
☐ crush-walnut.mp3
|
||||
☐ crush-can.mp3
|
||||
☐ crush-balloon.mp3
|
||||
|
||||
BGM (1-3 首):
|
||||
☐ bgm-grip-default.mp3
|
||||
☐ bgm-grip-meditation.mp3 (可选)
|
||||
☐ bgm-grip-lofi.mp3 (可选)
|
||||
|
||||
语音鼓励 (TTS 自动生成,8 个):
|
||||
☐ grip-egg.mp3
|
||||
☐ grip-walnut.mp3
|
||||
☐ grip-can.mp3
|
||||
☐ grip-balloon.mp3
|
||||
☐ grip-start.mp3
|
||||
☐ grip-pause.mp3
|
||||
☐ grip-encourage-1.mp3
|
||||
☐ grip-encourage-2.mp3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 八、采集完成后
|
||||
|
||||
把所有 URL 整理给我,我会:
|
||||
1. 更新 `grip-ring.vue` 中的 REWARDS 配置 (替换捏碎音效 URL)
|
||||
2. 创建/更新 `useMetronome.ts` 的音色配置 (支持切换节拍音色)
|
||||
3. 在 `grip-ring.vue` 集成 `useTrainingBgm` (添加 BGM)
|
||||
4. 在 `grip-ring.vue` 集成 `useVoiceCoach` (添加语音鼓励)
|
||||
5. 在 UI 上添加音色/BGM 切换选项 (可选)
|
||||
|
||||
预计代码改动: 50-100 行,1-2 小时工作量。
|
||||
|
||||
---
|
||||
|
||||
## 📚 参考链接
|
||||
|
||||
- [Pixabay 音效](https://pixabay.com/sound-effects/) - 免费可商用
|
||||
- [Pixabay 音乐](https://pixabay.com/music/) - 免费可商用
|
||||
- [Freesound](https://freesound.org) - 大量音效,部分需署名
|
||||
- [Mixkit](https://mixkit.co/free-sound-effects/) - 免费音效库
|
||||
- [Bensound](https://bensound.com) - 免费音乐(需署名)
|
||||
- [Free Music Archive](https://freemusicarchive.org) - 免费音乐
|
||||
|
||||
**版权提示**: 务必确认音效/音乐是"免费商用"(Public Domain 或 CC0),避免侵权风险。
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 卡路里计算工具
|
||||
* 基于标准 MET (Metabolic Equivalent of Task) 公式
|
||||
*/
|
||||
|
||||
export interface FoodItem {
|
||||
name: string
|
||||
emoji: string
|
||||
calories: number
|
||||
unit: string
|
||||
}
|
||||
|
||||
export interface FoodComparison {
|
||||
food: FoodItem
|
||||
ratio: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface CalorieCalculationConfig {
|
||||
totalReps: number
|
||||
bpm: number
|
||||
userWeight?: number // 默认 60kg
|
||||
}
|
||||
|
||||
// 食物卡路里数据库
|
||||
export const FOOD_CALORIES: Record<string, FoodItem> = {
|
||||
apple: { name: '苹果', emoji: '🍎', calories: 52, unit: '个(100g)' },
|
||||
egg: { name: '鸡蛋', emoji: '🥚', calories: 70, unit: '个' },
|
||||
chocolate: { name: '巧克力', emoji: '🍫', calories: 54, unit: '块(10g)' },
|
||||
banana: { name: '香蕉', emoji: '🍌', calories: 89, unit: '根' },
|
||||
rice: { name: '米饭', emoji: '🍚', calories: 116, unit: '碗(100g)' },
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算卡路里消耗
|
||||
* 公式:卡路里 = MET × 体重(kg) × 时长(小时)
|
||||
*
|
||||
* @param config 计算配置
|
||||
* @returns 卡路里消耗(kcal,保留一位小数)
|
||||
*/
|
||||
export function calculateCalories(config: CalorieCalculationConfig): number {
|
||||
const MET = 3.5 // 握力环训练的标准 MET 值
|
||||
const weight = config.userWeight || 60 // 默认 60kg
|
||||
|
||||
// 边界情况:无效输入
|
||||
if (config.totalReps <= 0 || config.bpm <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const secondsPerRep = 60 / config.bpm
|
||||
const activeTimeHours = (config.totalReps * secondsPerRep) / 3600
|
||||
|
||||
const calories = MET * weight * activeTimeHours
|
||||
return Math.round(calories * 10) / 10 // 保留一位小数
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动匹配最接近的食物对比
|
||||
*
|
||||
* @param burnedCalories 消耗的卡路里
|
||||
* @returns 食物对比信息
|
||||
*/
|
||||
export function getFoodComparison(burnedCalories: number): FoodComparison {
|
||||
const foods = Object.values(FOOD_CALORIES)
|
||||
|
||||
// 边界情况:食物数据库为空
|
||||
if (foods.length === 0) {
|
||||
throw new Error('食物数据库为空')
|
||||
}
|
||||
|
||||
// 边界情况:卡路里为 0 或负数
|
||||
if (burnedCalories <= 0) {
|
||||
return {
|
||||
food: foods[0],
|
||||
ratio: 0,
|
||||
message: `暂无消耗`,
|
||||
}
|
||||
}
|
||||
|
||||
// 找到卡路里最接近的食物
|
||||
const closest = foods.reduce((prev, curr) => {
|
||||
const prevDiff = Math.abs(prev.calories - burnedCalories)
|
||||
const currDiff = Math.abs(curr.calories - burnedCalories)
|
||||
return currDiff < prevDiff ? curr : prev
|
||||
})
|
||||
|
||||
const ratio = burnedCalories / closest.calories
|
||||
const ratioRounded = Math.round(ratio * 100) / 100 // 保留两位小数
|
||||
|
||||
// 格式化显示:ratio < 1 时显示分数形式更直观
|
||||
let displayText: string
|
||||
if (ratioRounded < 1) {
|
||||
displayText = `相当于 ${ratioRounded.toFixed(2)} 个${closest.name} ${closest.emoji}`
|
||||
} else {
|
||||
displayText = `相当于 ${ratioRounded.toFixed(2)} ${closest.unit}${closest.name} ${closest.emoji}`
|
||||
}
|
||||
|
||||
return {
|
||||
food: closest,
|
||||
ratio: ratioRounded,
|
||||
message: displayText,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时长(秒 → 分:秒)
|
||||
*
|
||||
* @param seconds 秒数
|
||||
* @returns 格式化字符串(如 "3:45")
|
||||
*/
|
||||
export function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
|
||||
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
|
||||
// 编译产物默认输出到 ./dist/<mode>/<platform>
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
// 屏蔽 TUICallKit SDK 内部 @import / legacy-js-api 弃用警告
|
||||
// 待 TUICallKit 升级 @use 语法后可移除
|
||||
silenceDeprecations: ['legacy-js-api', 'import'],
|
||||
},
|
||||
},
|
||||
},
|
||||
// dev 热重载卡顿修复:
|
||||
// UNI_INPUT_DIR=$PWD 让工程根=源码目录, chokidar 会监听整个仓库,
|
||||
// 包含 node_modules / dist(编译产物) / unpackage 等巨大目录, 导致每次热重载卡住,
|
||||
// 写 dist 又触发监听抖动。这里收窄监听范围。
|
||||
// 注意: 自定义 ignored 会覆盖 vite 默认值, 故 node_modules/.git 必须一并列出。
|
||||
server: {
|
||||
watch: {
|
||||
ignored: [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
'**/dist/**',
|
||||
'**/unpackage/**',
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
NODE_ENV = 'development'
|
||||
|
||||
# Base API
|
||||
# 后端接口根地址(含协议与端口),例如 http://127.0.0.1:8080;
|
||||
# 相对路径 /uploads/… 若此项为完整 URL,会与后端对齐;未填时由 vite 将 /uploads 代理到默认 http://127.0.0.1:8080。
|
||||
VITE_APP_BASE_URL=''
|
||||
Generated
+52
@@ -19,10 +19,12 @@
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.7.9",
|
||||
"cos-js-sdk-v5": "^1.10.1",
|
||||
"css-color-function": "^1.3.3",
|
||||
"echarts": "^5.6.0",
|
||||
"element-plus": "^2.9.4",
|
||||
"highlight.js": "^11.11.1",
|
||||
"hls.js": "^1.6.16",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -6064,6 +6066,16 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cos-js-sdk-v5": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmmirror.com/cos-js-sdk-v5/-/cos-js-sdk-v5-1.10.1.tgz",
|
||||
"integrity": "sha512-a4SRfCY5g6Z35C7OWe9te/S1zk77rVQzfpvZ33gmTdJQzKxbNbEG7Aw/v453XwVMsQB352FIf7KRMm5Ya/wlZQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fast-xml-parser": "4.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
@@ -7580,6 +7592,28 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz",
|
||||
"integrity": "sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/naturalintelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strnum": "^1.0.5"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
@@ -8231,6 +8265,12 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.16",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
||||
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/html-void-elements": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
|
||||
@@ -12299,6 +12339,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/strnum/-/strnum-1.1.2.tgz",
|
||||
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
|
||||
@@ -21,10 +21,12 @@
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.7.9",
|
||||
"cos-js-sdk-v5": "^1.10.1",
|
||||
"css-color-function": "^1.3.3",
|
||||
"echarts": "^5.6.0",
|
||||
"element-plus": "^2.9.4",
|
||||
"highlight.js": "^11.11.1",
|
||||
"hls.js": "^1.6.16",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
Generated
+9731
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 账号管理 API
|
||||
export function apiAssetUserList(params: any) {
|
||||
return request.get({ url: '/asset.AssetUser/lists', params })
|
||||
}
|
||||
export function apiAssetUserAdd(params: any) {
|
||||
return request.post({ url: '/asset.AssetUser/add', params })
|
||||
}
|
||||
export function apiAssetUserEdit(params: any) {
|
||||
return request.post({ url: '/asset.AssetUser/edit', params })
|
||||
}
|
||||
export function apiAssetUserDelete(params: any) {
|
||||
return request.post({ url: '/asset.AssetUser/delete', params })
|
||||
}
|
||||
|
||||
// 资源管理 API
|
||||
export function apiAssetResourceList(params: any) {
|
||||
return request.get({ url: '/asset.AssetResource/lists', params })
|
||||
}
|
||||
export function apiAssetResourceAdd(params: any) {
|
||||
return request.post({ url: '/asset.AssetResource/add', params })
|
||||
}
|
||||
export function apiAssetResourceEdit(params: any) {
|
||||
return request.post({ url: '/asset.AssetResource/edit', params })
|
||||
}
|
||||
export function apiAssetResourceDelete(params: any) {
|
||||
return request.post({ url: '/asset.AssetResource/delete', params })
|
||||
}
|
||||
@@ -66,6 +66,16 @@ export function appointmentLists(params: any) {
|
||||
return request.get({ url: '/doctor.appointment/lists', params })
|
||||
}
|
||||
|
||||
/** 后台编辑挂号(预约日期/时段/类型/状态/备注/医助) */
|
||||
export function appointmentAdminEdit(params: any) {
|
||||
return request.post({ url: '/doctor.appointment/edit', params })
|
||||
}
|
||||
|
||||
/** 批量修改挂号渠道来源(与 edit 共用 doctor.appointment/edit 权限) */
|
||||
export function appointmentBatchEditChannel(params: any) {
|
||||
return request.post({ url: '/doctor.appointment/batchEditChannel', params })
|
||||
}
|
||||
|
||||
// 获取挂号详情
|
||||
export function appointmentDetail(params: any) {
|
||||
return request.get({ url: '/doctor.appointment/detail', params })
|
||||
|
||||
+85
-36
@@ -4,48 +4,50 @@ import useAppStore from '@/stores/modules/app'
|
||||
import { getToken } from '@/utils/auth'
|
||||
import request from '@/utils/request'
|
||||
|
||||
export type MaterialUploadType = 'image' | 'video' | 'file'
|
||||
|
||||
/** 本地上传素材(与 `components/upload` 相同:POST /upload/{type},字段 file + cid) */
|
||||
export async function uploadMaterialFile(
|
||||
file: File,
|
||||
type: MaterialUploadType = 'image',
|
||||
cid: string | number = 0
|
||||
) {
|
||||
const appStore = useAppStore()
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('cid', String(cid))
|
||||
const url = `${config.baseUrl}${config.urlPrefix}/upload/${type}`
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
token: getToken() || '',
|
||||
version: appStore.config.version || ''
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.code !== RequestCodeEnum.SUCCESS) {
|
||||
throw new Error(json.msg || '上传失败')
|
||||
}
|
||||
return json.data as { id: number; uri: string; url: string }
|
||||
}
|
||||
|
||||
/** 本地上传图片(与素材库「本地上传」同一接口),返回 data 含 url 相对路径 */
|
||||
export async function uploadImageBlob(file: Blob, filename = 'screenshot.jpg') {
|
||||
const appStore = useAppStore()
|
||||
const formData = new FormData()
|
||||
formData.append('file', file, filename)
|
||||
formData.append('cid', '0')
|
||||
const url = `${config.baseUrl}${config.urlPrefix}/upload/image`
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
token: getToken() || '',
|
||||
version: appStore.config.version || ''
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.code !== RequestCodeEnum.SUCCESS) {
|
||||
throw new Error(json.msg || '上传失败')
|
||||
}
|
||||
return json.data as { id: number; uri: string; url: string }
|
||||
const fileObj =
|
||||
file instanceof File
|
||||
? file
|
||||
: new File([file], filename, { type: file.type || 'image/jpeg' })
|
||||
return uploadMaterialFile(fileObj, 'image', 0)
|
||||
}
|
||||
|
||||
/** 本地上传视频(管理端 /upload/video),返回 data 含 uri 完整访问地址、url 相对路径 */
|
||||
export async function uploadVideoBlob(file: Blob, filename = 'recording.webm') {
|
||||
const appStore = useAppStore()
|
||||
const formData = new FormData()
|
||||
formData.append('file', file, filename)
|
||||
formData.append('cid', '0')
|
||||
const url = `${config.baseUrl}${config.urlPrefix}/upload/video`
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
token: getToken() || '',
|
||||
version: appStore.config.version || ''
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.code !== RequestCodeEnum.SUCCESS) {
|
||||
throw new Error(json.msg || '上传失败')
|
||||
}
|
||||
return json.data as { id: number; uri: string; url: string }
|
||||
const fileObj =
|
||||
file instanceof File
|
||||
? file
|
||||
: new File([file], filename, { type: file.type || 'video/webm' })
|
||||
return uploadMaterialFile(fileObj, 'video', 0)
|
||||
}
|
||||
|
||||
export function fileCateAdd(params: Record<string, any>) {
|
||||
@@ -88,3 +90,50 @@ export function fileMove(params: Record<string, any>) {
|
||||
export function fileRename(params: { id: number; name: string }) {
|
||||
return request.post({ url: '/file/rename', params })
|
||||
}
|
||||
|
||||
/** 浏览器直传 OSS - 凭证类型 */
|
||||
export interface OssCredentialsResponse {
|
||||
provider: string
|
||||
fallback: boolean
|
||||
bucket?: string
|
||||
region?: string
|
||||
host?: string
|
||||
cdn_domain?: string
|
||||
key_prefix?: string
|
||||
max_size?: number
|
||||
duration?: number
|
||||
expired_time?: number
|
||||
start_time?: number
|
||||
credentials?: {
|
||||
tmpSecretId: string
|
||||
tmpSecretKey: string
|
||||
sessionToken: string
|
||||
}
|
||||
}
|
||||
|
||||
/** 申请 STS 临时凭证 */
|
||||
export function getOssCredentials(params: { type: 'video' }) {
|
||||
return request.post({
|
||||
url: '/upload/ossCredentials',
|
||||
params
|
||||
}) as Promise<OssCredentialsResponse>
|
||||
}
|
||||
|
||||
/** 直传完成回执:写 file 表 + HEAD 校验 */
|
||||
export function confirmOssUpload(params: {
|
||||
type: 'video'
|
||||
key: string
|
||||
name: string
|
||||
size: number
|
||||
content_type: string
|
||||
cid?: number
|
||||
}) {
|
||||
return request.post({ url: '/upload/ossConfirm', params }) as Promise<{
|
||||
id: number
|
||||
cid: number
|
||||
type: number
|
||||
name: string
|
||||
uri: string
|
||||
url: string
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -104,3 +104,8 @@ export function orderActionLogStats(params?: { start_time?: string; end_time?: s
|
||||
export function orderBatchAssignAssistant(params: { order_ids: number[]; assistant_id: number }) {
|
||||
return request.post({ url: '/order.order/assignAssistant', params })
|
||||
}
|
||||
|
||||
/** 设置/取消支付单豁免权 */
|
||||
export function orderSetExempt(params: { id: number; is_exempt: 0 | 1 }) {
|
||||
return request.post({ url: '/order.order/setExempt', params })
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function deptDetail(params: any) {
|
||||
return request.get({ url: '/dept.dept/detail', params })
|
||||
}
|
||||
|
||||
// 部门列表全部
|
||||
export function deptAll() {
|
||||
return request.get({ url: '/dept.dept/all' })
|
||||
// 部门列表全部;apply_data_scope=1 时按当前账号角色数据权限收窄树(与 DataScopeService 一致)
|
||||
export function deptAll(params?: { apply_data_scope?: number }) {
|
||||
return request.get({ url: '/dept.dept/all', params })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function getSelfInputOverview(params: any) {
|
||||
return request.get({ url: '/stats.self_input/overview', params })
|
||||
}
|
||||
|
||||
/** 自媒体来源下拉(业绩+账户消耗已录入值去重) */
|
||||
export function getSelfInputMediaSourceOptions() {
|
||||
return request.get({ url: '/stats.self_input/mediaSourceOptions' })
|
||||
}
|
||||
|
||||
export function personalYejiLists(params: any) {
|
||||
return request.get({ url: '/stats.personal_yeji/lists', params })
|
||||
}
|
||||
|
||||
export function personalYejiAdd(data: any) {
|
||||
return request.post({ url: '/stats.personal_yeji/add', data })
|
||||
}
|
||||
|
||||
export function personalYejiEdit(data: any) {
|
||||
return request.post({ url: '/stats.personal_yeji/edit', data })
|
||||
}
|
||||
|
||||
export function personalYejiDetail(params: any) {
|
||||
return request.get({ url: '/stats.personal_yeji/detail', params })
|
||||
}
|
||||
|
||||
export function personalYejiDelete(params: any) {
|
||||
return request.post({ url: '/stats.personal_yeji/delete', params })
|
||||
}
|
||||
|
||||
export function personalAccountCostLists(params: any) {
|
||||
return request.get({ url: '/stats.personal_account_cost/lists', params })
|
||||
}
|
||||
|
||||
export function personalAccountCostAdd(data: any) {
|
||||
return request.post({ url: '/stats.personal_account_cost/add', data })
|
||||
}
|
||||
|
||||
export function personalAccountCostEdit(data: any) {
|
||||
return request.post({ url: '/stats.personal_account_cost/edit', data })
|
||||
}
|
||||
|
||||
export function personalAccountCostDetail(params: any) {
|
||||
return request.get({ url: '/stats.personal_account_cost/detail', params })
|
||||
}
|
||||
|
||||
export function personalAccountCostDelete(params: any) {
|
||||
return request.post({ url: '/stats.personal_account_cost/delete', params })
|
||||
}
|
||||
@@ -42,3 +42,150 @@ export function yejiStatsLeaderboard(params: {
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/leaderboard', params })
|
||||
}
|
||||
|
||||
/** 「未归属中心」行:按订单创建人拆解合计业绩(与看板「合计业绩」未归属部分同口径) */
|
||||
export function yejiStatsUnassignedBreakdown(params: {
|
||||
start_date: string
|
||||
end_date: string
|
||||
dept_ids?: string
|
||||
channel_code?: string
|
||||
tag_id?: string
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/unassignedBreakdown', params })
|
||||
}
|
||||
|
||||
/** 部门行「进线数据」逐条明细(与看板同口径) */
|
||||
export function yejiStatsLeadLines(params: {
|
||||
start_date: string
|
||||
end_date: string
|
||||
dept_id: number
|
||||
dept_ids?: string
|
||||
channel_code?: string
|
||||
tag_id?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/leadLines', params })
|
||||
}
|
||||
|
||||
/** 医助排行榜「预约诊单」逐条挂号明细;业绩主表部门行传 dept_id(可与 assistant_id 二选一) */
|
||||
export function yejiStatsAppointmentLines(params: {
|
||||
start_date: string
|
||||
end_date: string
|
||||
assistant_id?: number
|
||||
dept_id?: number
|
||||
dept_ids?: string
|
||||
channel_code?: string
|
||||
tag_id?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/appointmentLines', params })
|
||||
}
|
||||
|
||||
/** 二中心复诊:部门行下钻医助 × 业务订单笔数 */
|
||||
export function yejiStatsRevisitBreakdown(params: {
|
||||
start_date: string
|
||||
end_date: string
|
||||
dept_id: number
|
||||
revisit_slot: number
|
||||
dept_ids?: string
|
||||
channel_code?: string
|
||||
tag_id?: string
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/revisitBreakdown', params })
|
||||
}
|
||||
|
||||
export function doctorDailyStatsOverview(params: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
channel_code?: string
|
||||
dept_ids?: number[] | string
|
||||
}) {
|
||||
return request.get({ url: '/stats.doctorDailyStats/overview', params })
|
||||
}
|
||||
|
||||
/** 提成结算接口计算较重,单独放宽超时(默认多为 60s) */
|
||||
const COMMISSION_SETTLEMENT_TIMEOUT_MS = 120000
|
||||
|
||||
/** 提成结算业绩(独立于业绩看板 yejiStats) */
|
||||
export function commissionSettlementOverview(params: {
|
||||
settlement_month: string
|
||||
/** 与 tcm.prescriptionOrder/lists 同源:create_time between */
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
/** 默认 3=履约完成,与列表 fulfillment_status 一致 */
|
||||
fulfillment_status?: number
|
||||
/** 传 1 时仅统计 is_system_auto=1;显式时段下默认不传(含手动) */
|
||||
require_system_auto_prescription?: 0 | 1
|
||||
dept_ids?: number[] | string
|
||||
channel_code?: string
|
||||
}) {
|
||||
return request.get(
|
||||
{ url: '/stats.commissionSettlement/overview', params, timeout: COMMISSION_SETTLEMENT_TIMEOUT_MS },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export function commissionSettlementDeptOptions() {
|
||||
return request.get({ url: '/stats.commissionSettlement/deptOptions' })
|
||||
}
|
||||
|
||||
export function commissionSettlementChannelOptions() {
|
||||
return request.get({ url: '/stats.commissionSettlement/channelOptions' })
|
||||
}
|
||||
|
||||
/** 提成核对:订单明细分页 bucket: 空|current|deferred;appt_channel_value 可与 assistant_id/doctor_id 组合(0=未匹配挂号渠道) */
|
||||
export function commissionSettlementOrderLines(params: {
|
||||
settlement_month: string
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
fulfillment_status?: number
|
||||
require_system_auto_prescription?: 0 | 1
|
||||
dept_ids?: number[] | string
|
||||
channel_code?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
bucket?: string
|
||||
assistant_id?: number
|
||||
doctor_id?: number
|
||||
appt_channel_value?: number
|
||||
}) {
|
||||
return request.get(
|
||||
{ url: '/stats.commissionSettlement/orderLines', params, timeout: COMMISSION_SETTLEMENT_TIMEOUT_MS },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export function commissionSettlementConfirmStatus(params: {
|
||||
settlement_month: string
|
||||
dept_ids?: number[] | string
|
||||
channel_code?: string
|
||||
}) {
|
||||
return request.get({ url: '/stats.commissionSettlement/confirmStatus', params })
|
||||
}
|
||||
|
||||
export function commissionSettlementSaveReconcile(params: Record<string, any>) {
|
||||
return request.post({ url: '/stats.commissionSettlement/saveReconcile', params })
|
||||
}
|
||||
|
||||
export function commissionSettlementConfirmFinalize(params: Record<string, any>) {
|
||||
return request.post(
|
||||
{ url: '/stats.commissionSettlement/confirmFinalize', params, timeout: COMMISSION_SETTLEMENT_TIMEOUT_MS },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
/** 撤回「确定本期业绩」:清除顺延结转,状态变为可再次核对/确定 */
|
||||
export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
|
||||
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
||||
}
|
||||
|
||||
/** 医助个人业绩概览 */
|
||||
export function assistantPerformanceOverview(params: {
|
||||
time_type?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}) {
|
||||
return request.get({ url: '/stats.assistantPerformance/overview', params })
|
||||
}
|
||||
|
||||
+126
-6
@@ -5,8 +5,39 @@ export function tcmDiagnosisLists(params: any) {
|
||||
return request.get({ url: '/tcm.diagnosis/lists', params })
|
||||
}
|
||||
|
||||
/** 二诊只读病例详情(T1+T2):返回 appointment + diagnosis + 未服务天数(不含跟踪记录) */
|
||||
export function diagnosisReadonlyDetail(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.diagnosis/readonlyDetail', params })
|
||||
}
|
||||
|
||||
/** 跟踪信息(血糖血压 / 饮食 / 运动)按日期区间 lazy load — 由 TrackingMatrix 调用 */
|
||||
export function diagnosisTrackingWindow(params: {
|
||||
id: number
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}) {
|
||||
return request.get({ url: '/tcm.diagnosis/trackingWindow', params })
|
||||
}
|
||||
|
||||
/** 新增跟踪备注(按天合并追加,仅文字) */
|
||||
export function diagnosisAddTrackingNote(params: {
|
||||
diagnosis_id: number
|
||||
tracking_content: string
|
||||
}) {
|
||||
return request.post({ url: '/tcm.diagnosis/addTrackingNote', params })
|
||||
}
|
||||
|
||||
/** 拉取跟踪备注列表(note_date DESC) */
|
||||
export function diagnosisTrackingNotes(params: { diagnosis_id: number }) {
|
||||
return request.get({ url: '/tcm.diagnosis/trackingNotes', params })
|
||||
}
|
||||
|
||||
// 医助理诊单统计(按部门、按人)
|
||||
export function assistantDiagnosisStats(params?: { start_time?: string; end_time?: string; days?: number }) {
|
||||
export function assistantDiagnosisStats(params?: {
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
days?: number
|
||||
}) {
|
||||
return request.get({ url: '/tcm.diagnosis/assistantDiagnosisStats', params })
|
||||
}
|
||||
|
||||
@@ -198,14 +229,25 @@ export function startCloudRecording(params: { diagnosis_id: number }) {
|
||||
return request.post({ url: '/tcm.diagnosis/startCloudRecording', params })
|
||||
}
|
||||
|
||||
/** 本地录制完成后,将已上传视频的访问地址写入通话记录(忽略取消令牌,避免与其它 POST 互斥取消) */
|
||||
export function attachLocalCallRecording(params: { diagnosis_id: number; file_url: string }) {
|
||||
/** 本地录制完成后,将已上传视频的访问地址写入通话记录(可显式传 call_record_id) */
|
||||
export function attachLocalCallRecording(params: {
|
||||
diagnosis_id: number
|
||||
file_url: string
|
||||
call_record_id?: number
|
||||
}) {
|
||||
return request.post(
|
||||
{ url: '/tcm.diagnosis/attachLocalCallRecording', params },
|
||||
{ ignoreCancelToken: true, isOpenRetry: false }
|
||||
)
|
||||
}
|
||||
|
||||
export function createManualCallRecord(params: { diagnosis_id: number }) {
|
||||
return request.post(
|
||||
{ url: '/tcm.diagnosis/createManualCallRecord', params },
|
||||
{ ignoreCancelToken: true, isOpenRetry: false }
|
||||
)
|
||||
}
|
||||
|
||||
/** 医助旁观当前进行中的视频通话(进房参数,Web TRTC 只拉流) */
|
||||
export function getAssistantWatchCallParams(params: { diagnosis_id: number }) {
|
||||
return request.get({ url: '/tcm.diagnosis/watchCall', params })
|
||||
@@ -230,6 +272,11 @@ export function getImChatMessages(params: { diagnosis_id: number; only_archived?
|
||||
return request.get({ url: '/tcm.diagnosis/getImChatMessages', params })
|
||||
}
|
||||
|
||||
/** 触发后台异步同步:从腾讯云 IM 拉取诊单聊天记录入归档表,请求即返回 */
|
||||
export function triggerImChatSync(data: { diagnosis_id: number }) {
|
||||
return request.post({ url: '/tcm.diagnosis/triggerImChatSync', data })
|
||||
}
|
||||
|
||||
// 获取企业微信聊天记录
|
||||
export function getWechatChatRecords(params: any) {
|
||||
return request.get({ url: '/tcm.diagnosis/getWechatChatRecords', params })
|
||||
@@ -272,6 +319,16 @@ export function prescriptionEdit(params: any) {
|
||||
return request.post({ url: '/tcm.prescription/edit', params })
|
||||
}
|
||||
|
||||
/** 仅修正处方笺患者姓名、手机号与性别(不改审核状态),写入业务订单日志表 */
|
||||
export function prescriptionPatchPatient(params: {
|
||||
id: number
|
||||
patient_name: string
|
||||
phone: string
|
||||
gender: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescription/patchPatient', params })
|
||||
}
|
||||
|
||||
// 删除处方
|
||||
export function prescriptionDelete(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescription/delete', params })
|
||||
@@ -298,7 +355,11 @@ export function prescriptionVoid(params: { id: number }) {
|
||||
}
|
||||
|
||||
/** 处方审核:action approve | reject(驳回同时作废处方) */
|
||||
export function prescriptionAudit(params: { id: number; action: 'approve' | 'reject'; remark?: string }) {
|
||||
export function prescriptionAudit(params: {
|
||||
id: number
|
||||
action: 'approve' | 'reject'
|
||||
remark?: string
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescription/audit', params })
|
||||
}
|
||||
|
||||
@@ -312,6 +373,11 @@ export function prescriptionOrderLists(params: any) {
|
||||
return request.get({ url: '/tcm.prescriptionOrder/lists', params })
|
||||
}
|
||||
|
||||
/** 处方业务订单导出(export=1 预估条数,export=2 下载 Excel) */
|
||||
export function prescriptionOrderExport(params: any) {
|
||||
return request.get({ url: '/tcm.prescriptionOrder/export', params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单下可关联的支付单(已支付 zyt_order;已占用且未撤回的会排除;编辑时传 prescription_order_id 保留当前单已选)。
|
||||
* 服务端仅返回创建时间在 2026-04-20(含)之后的支付单;编辑时本单已关联的旧单仍会出现在列表中。
|
||||
@@ -396,6 +462,7 @@ export function prescriptionOrderAddPayOrder(params: {
|
||||
order_type: number
|
||||
pay_amount: number
|
||||
pay_remark?: string
|
||||
completion_request?: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params })
|
||||
}
|
||||
@@ -403,11 +470,18 @@ export function prescriptionOrderAddPayOrder(params: {
|
||||
/** 为「已发货」订单关联已有支付单并重置支付审核为待审核 */
|
||||
export function prescriptionOrderLinkPayOrder(params: {
|
||||
id: number
|
||||
pay_order_id: number
|
||||
pay_order_id?: number
|
||||
pay_order_ids?: number[]
|
||||
completion_request?: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/linkPayOrder', params })
|
||||
}
|
||||
|
||||
/** 已发货/已签收:仅提交完单申请(不新增/关联支付单) */
|
||||
export function prescriptionOrderRequestCompletion(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params })
|
||||
}
|
||||
|
||||
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态) */
|
||||
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
|
||||
@@ -419,7 +493,11 @@ export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
|
||||
}
|
||||
|
||||
/** 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单) */
|
||||
export function prescriptionOrderPreviewGancaoRecipel(params: { id: number; dose_count?: number; medication_days?: number }) {
|
||||
export function prescriptionOrderPreviewGancaoRecipel(params: {
|
||||
id: number
|
||||
dose_count?: number
|
||||
medication_days?: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/previewGancaoRecipel', params })
|
||||
}
|
||||
|
||||
@@ -428,6 +506,16 @@ export function prescriptionOrderLogs(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.prescriptionOrder/logs', params })
|
||||
}
|
||||
|
||||
/** 修改订单金额 */
|
||||
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
|
||||
}
|
||||
|
||||
/** 设置发货类型:gancao 甘草药房 / direct 洛阳药房 */
|
||||
export function prescriptionOrderSetShipMode(params: { id: number; ship_mode: 'gancao' | 'direct' }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/setShipMode', params })
|
||||
}
|
||||
|
||||
// ========== 处方库 ==========
|
||||
|
||||
// 处方库列表
|
||||
@@ -454,3 +542,35 @@ export function prescriptionLibraryDelete(params: { id: number }) {
|
||||
export function prescriptionLibraryDetail(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.prescriptionLibrary/detail', params })
|
||||
}
|
||||
|
||||
// ========== 诊单待办事项(T5) ==========
|
||||
|
||||
/** 待办列表(按 diagnosis_id) */
|
||||
export function diagnosisTodoLists(params: {
|
||||
diagnosis_id: number
|
||||
page_no?: number
|
||||
page_size?: number
|
||||
status?: number
|
||||
creator_id?: number
|
||||
}) {
|
||||
return request.get({ url: '/tcm.diagnosisTodo/lists', params })
|
||||
}
|
||||
|
||||
/** 新增待办(remind_time 为 unix 秒级时间戳) */
|
||||
export function diagnosisTodoAdd(params: {
|
||||
diagnosis_id: number
|
||||
content: string
|
||||
remind_time: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.diagnosisTodo/add', params })
|
||||
}
|
||||
|
||||
/** 取消待办(仅 status=0 / 创建人或超管) */
|
||||
export function diagnosisTodoCancel(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.diagnosisTodo/cancel', params })
|
||||
}
|
||||
|
||||
/** 待办详情 */
|
||||
export function diagnosisTodoDetail(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.diagnosisTodo/detail', params })
|
||||
}
|
||||
|
||||
@@ -9,17 +9,35 @@
|
||||
v-for="item in notifications"
|
||||
:key="item.id"
|
||||
class="chat-notify-card"
|
||||
:class="{ 'chat-notify-card-complete': item.type === 'consultation_complete' }"
|
||||
:class="{
|
||||
'chat-notify-card-complete': item.type === 'consultation_complete',
|
||||
'chat-notify-card-left': item.type === 'patient_left_chat'
|
||||
}"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<div class="chat-notify-icon" :class="{ 'chat-notify-icon-complete': item.type === 'consultation_complete' }">
|
||||
<div
|
||||
class="chat-notify-icon"
|
||||
:class="{
|
||||
'chat-notify-icon-complete': item.type === 'consultation_complete',
|
||||
'chat-notify-icon-left': item.type === 'patient_left_chat'
|
||||
}"
|
||||
>
|
||||
<el-icon :size="22">
|
||||
<ChatDotRound v-if="item.type !== 'consultation_complete'" />
|
||||
<CircleCheck v-else />
|
||||
<ChatDotRound v-if="item.type !== 'consultation_complete' && item.type !== 'patient_left_chat'" />
|
||||
<CircleCheck v-else-if="item.type === 'consultation_complete'" />
|
||||
<Right v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="chat-notify-body">
|
||||
<div class="chat-notify-title">{{ item.type === 'consultation_complete' ? '面诊结束' : '患者打开会话' }}</div>
|
||||
<div class="chat-notify-title">
|
||||
{{
|
||||
item.type === 'consultation_complete'
|
||||
? '面诊结束'
|
||||
: item.type === 'patient_left_chat'
|
||||
? '患者离开会话'
|
||||
: '患者打开会话'
|
||||
}}
|
||||
</div>
|
||||
<div class="chat-notify-desc">
|
||||
<template v-if="item.type === 'consultation_complete'">
|
||||
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
|
||||
@@ -27,6 +45,10 @@
|
||||
<span class="doctor-name">{{ item.doctor_name || '医生' }}</span>
|
||||
完成,请及时跟进
|
||||
</template>
|
||||
<template v-else-if="item.type === 'patient_left_chat'">
|
||||
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
|
||||
已离开问诊会话页面
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="patient-name">{{ item.patient_name || '患者' }}</span>
|
||||
已打开与您的会话,请及时查看
|
||||
@@ -45,14 +67,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ChatDotRound, CircleCheck, Close } from '@element-plus/icons-vue'
|
||||
import { ChatDotRound, CircleCheck, Close, Right } from '@element-plus/icons-vue'
|
||||
import { getChatNotifications } from '@/api/chat'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
export interface ChatNotifyItem {
|
||||
id: string
|
||||
type?: 'patient_opened_chat' | 'consultation_complete'
|
||||
type?: 'patient_opened_chat' | 'patient_left_chat' | 'consultation_complete'
|
||||
doctor_id?: number
|
||||
assistant_id?: number
|
||||
patient_id?: string
|
||||
@@ -173,6 +195,15 @@ html.dark .chat-notify-card:hover {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
}
|
||||
|
||||
.chat-notify-card-left {
|
||||
border-color: rgba(148, 163, 184, 0.35);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.chat-notify-icon-left {
|
||||
background: linear-gradient(135deg, #94a3b8 0%, #64748b 100%);
|
||||
}
|
||||
|
||||
.chat-notify-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<el-button>导出</el-button>
|
||||
</template>
|
||||
<div>
|
||||
<p v-if="props.exportHint" class="text-sm text-gray-500 mb-3 leading-relaxed">{{ props.exportHint }}</p>
|
||||
<el-form ref="formRef" :model="formData" label-width="120px" :rules="formRules">
|
||||
<el-form-item label="数据量:">
|
||||
预计导出{{ exportData.count }}条数据, 共{{ exportData.sum_page }}页,每页{{
|
||||
@@ -79,6 +80,11 @@ const props = defineProps({
|
||||
fetchFun: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
/** 可选:导出弹窗内提示文案(如说明与列表筛选一致) */
|
||||
exportHint: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
const popupRef = shallowRef<InstanceType<typeof Popup>>()
|
||||
|
||||
@@ -9,7 +9,17 @@
|
||||
@close="handleClose"
|
||||
>
|
||||
<template v-if="!hiddenUpload" #trigger>
|
||||
<div class="material-select__trigger clearfix" @click.stop>
|
||||
<div
|
||||
class="material-select__trigger clearfix"
|
||||
:class="{ 'is-drop-hover': dropActive }"
|
||||
tabindex="0"
|
||||
@click.stop
|
||||
@dragenter.prevent.stop="onTriggerDragEnter"
|
||||
@dragleave.prevent.stop="onTriggerDragLeave"
|
||||
@dragover.prevent.stop="onTriggerDragOver"
|
||||
@drop.prevent.stop="onTriggerDrop"
|
||||
@paste="onTriggerPaste"
|
||||
>
|
||||
<draggable class="draggable" v-model="fileList" animation="300" item-key="id">
|
||||
<template v-slot:item="{ element, index }">
|
||||
<div
|
||||
@@ -82,8 +92,10 @@
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import Draggable from 'vuedraggable'
|
||||
|
||||
import { uploadMaterialFile } from '@/api/file'
|
||||
import Popup from '@/components/popup/index.vue'
|
||||
import useAppStore from '@/stores/modules/app'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
import FileItem from './file.vue'
|
||||
import Material from './index.vue'
|
||||
@@ -149,6 +161,11 @@ export default defineComponent({
|
||||
excludeDomain: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** 拖拽/粘贴直传时的素材分组 id(与弹窗内「本地上传」一致,默认未分组) */
|
||||
uploadCid: {
|
||||
type: [Number, String],
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
|
||||
@@ -162,8 +179,149 @@ export default defineComponent({
|
||||
const select = ref<any[]>([])
|
||||
const isAdd = ref(true)
|
||||
const currentIndex = ref(-1)
|
||||
const dragDepth = ref(0)
|
||||
const externalUploading = ref(false)
|
||||
const { disabled, limit, modelValue } = toRefs(props)
|
||||
const { getImageUrl } = useAppStore()
|
||||
const appStore = useAppStore()
|
||||
const getImageUrl = (url: string) => appStore.getImageUrl(url)
|
||||
|
||||
const allowDropPaste = computed(
|
||||
() =>
|
||||
!props.hiddenUpload &&
|
||||
(props.type === 'image' || props.type === 'video' || props.type === 'file')
|
||||
)
|
||||
|
||||
const dropActive = computed(() => dragDepth.value > 0)
|
||||
|
||||
function hasFileDrag(dt: DataTransfer | null) {
|
||||
if (!dt) return false
|
||||
const types = Array.from(dt.types as unknown as string[])
|
||||
return types.includes('Files') || types.includes('application/x-moz-file')
|
||||
}
|
||||
|
||||
function isAcceptedExtension(fileName: string, uploadType: string): boolean {
|
||||
if (uploadType === 'file') return true
|
||||
const ext = fileName.includes('.')
|
||||
? fileName.split('.').pop()?.toLowerCase() || ''
|
||||
: ''
|
||||
if (uploadType === 'image') {
|
||||
if (!ext) return true
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'ico'].includes(ext)
|
||||
}
|
||||
if (uploadType === 'video') {
|
||||
if (!ext) return true
|
||||
return ['wmv', 'avi', 'mpg', 'mpeg', '3gp', 'mov', 'mp4', 'flv', 'rmvb', 'mkv'].includes(
|
||||
ext
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function toRelativeStoredUrl(fullUrl: string): string {
|
||||
if (!fullUrl || !fullUrl.startsWith('http')) return fullUrl
|
||||
const oss = String(appStore.config?.oss_domain || '')
|
||||
.trim()
|
||||
.replace(/\/$/, '')
|
||||
if (oss && fullUrl.startsWith(oss)) {
|
||||
return fullUrl.slice(oss.length).replace(/^\//, '')
|
||||
}
|
||||
const origin =
|
||||
typeof window !== 'undefined' ? window.location.origin.replace(/\/$/, '') : ''
|
||||
if (origin && fullUrl.startsWith(origin)) {
|
||||
return fullUrl.slice(origin.length).replace(/^\//, '')
|
||||
}
|
||||
try {
|
||||
return new URL(fullUrl).pathname.replace(/^\//, '')
|
||||
} catch {
|
||||
return fullUrl
|
||||
}
|
||||
}
|
||||
|
||||
function clipboardFiles(e: ClipboardEvent): File[] {
|
||||
const uploadType = props.type
|
||||
const fromFiles = Array.from(e.clipboardData?.files || []).filter((f) =>
|
||||
isAcceptedExtension(f.name, uploadType)
|
||||
)
|
||||
if (fromFiles.length) return fromFiles
|
||||
if (uploadType !== 'image') return []
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return []
|
||||
const out: File[] = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i]
|
||||
if (it.kind === 'file' && it.type.startsWith('image/')) {
|
||||
const f = it.getAsFile()
|
||||
if (f) out.push(f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function runExternalUpload(files: File[]) {
|
||||
if (externalUploading.value) return
|
||||
const maxSlots =
|
||||
limit.value === -1 ? Infinity : Math.max(0, limit.value - fileList.value.length)
|
||||
if (!maxSlots) {
|
||||
feedback.msgWarning('已达到上传数量上限')
|
||||
return
|
||||
}
|
||||
const queue = files.slice(0, maxSlots)
|
||||
if (!queue.length) return
|
||||
|
||||
const uploadType =
|
||||
props.type === 'video' ? 'video' : props.type === 'file' ? 'file' : 'image'
|
||||
externalUploading.value = true
|
||||
try {
|
||||
const paths: string[] = []
|
||||
for (const file of queue) {
|
||||
const data = await uploadMaterialFile(file, uploadType, props.uploadCid)
|
||||
paths.push(props.excludeDomain ? toRelativeStoredUrl(data.url) : data.url)
|
||||
}
|
||||
fileList.value = [...fileList.value, ...paths]
|
||||
const valueImg = limit.value != 1 ? fileList.value : fileList.value[0] || ''
|
||||
emit('update:modelValue', valueImg)
|
||||
emit('change', valueImg)
|
||||
handleClose()
|
||||
} catch (err: any) {
|
||||
feedback.msgError(err?.message || '上传失败')
|
||||
} finally {
|
||||
externalUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onTriggerDragEnter = (e: DragEvent) => {
|
||||
if (!allowDropPaste.value || disabled.value) return
|
||||
if (!hasFileDrag(e.dataTransfer)) return
|
||||
dragDepth.value++
|
||||
}
|
||||
|
||||
const onTriggerDragLeave = () => {
|
||||
if (!allowDropPaste.value) return
|
||||
dragDepth.value = Math.max(0, dragDepth.value - 1)
|
||||
}
|
||||
|
||||
const onTriggerDragOver = (e: DragEvent) => {
|
||||
if (!allowDropPaste.value || disabled.value) return
|
||||
if (hasFileDrag(e.dataTransfer)) e.preventDefault()
|
||||
}
|
||||
|
||||
const onTriggerDrop = async (e: DragEvent) => {
|
||||
dragDepth.value = 0
|
||||
if (!allowDropPaste.value || disabled.value) return
|
||||
const files = Array.from(e.dataTransfer?.files || []).filter((f) =>
|
||||
isAcceptedExtension(f.name, props.type)
|
||||
)
|
||||
if (!files.length) return
|
||||
await runExternalUpload(files)
|
||||
}
|
||||
|
||||
const onTriggerPaste = async (e: ClipboardEvent) => {
|
||||
if (!allowDropPaste.value || disabled.value) return
|
||||
const files = clipboardFiles(e)
|
||||
if (!files.length) return
|
||||
e.preventDefault()
|
||||
await runExternalUpload(files)
|
||||
}
|
||||
const tipsText = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'image':
|
||||
@@ -264,7 +422,13 @@ export default defineComponent({
|
||||
showPreview,
|
||||
handlePreview,
|
||||
handleClose,
|
||||
getImageUrl
|
||||
getImageUrl,
|
||||
dropActive,
|
||||
onTriggerDragEnter,
|
||||
onTriggerDragLeave,
|
||||
onTriggerDragOver,
|
||||
onTriggerDrop,
|
||||
onTriggerPaste
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -272,6 +436,13 @@ export default defineComponent({
|
||||
|
||||
<style scoped lang="scss">
|
||||
.material-select {
|
||||
.material-select__trigger {
|
||||
outline: none;
|
||||
&.is-drop-hover {
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
|
||||
}
|
||||
}
|
||||
.material-upload,
|
||||
.material-preview {
|
||||
position: relative;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@
|
||||
:show-file-list="false"
|
||||
:headers="headers"
|
||||
:data="data"
|
||||
:http-request="useDirect ? httpRequest : undefined"
|
||||
:on-progress="handleProgress"
|
||||
:on-success="handleSuccess"
|
||||
:on-exceed="handleExceed"
|
||||
@@ -41,7 +42,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { ElUpload } from 'element-plus'
|
||||
import type { ElUpload, UploadRequestOptions } from 'element-plus'
|
||||
import { computed, defineComponent, ref, shallowRef } from 'vue'
|
||||
|
||||
import config from '@/config'
|
||||
@@ -49,6 +50,10 @@ import { RequestCodeEnum } from '@/enums/requestEnums'
|
||||
import useAppStore from '@/stores/modules/app'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import feedback from '@/utils/feedback'
|
||||
import {
|
||||
DirectUploadFallbackError,
|
||||
uploadVideoDirectToCos
|
||||
} from '@/utils/oss-direct-upload'
|
||||
|
||||
export default defineComponent({
|
||||
components: {},
|
||||
@@ -77,6 +82,11 @@ export default defineComponent({
|
||||
showProgress: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 视频直传到 OSS(绕开服务器中转,仅 type=video 生效)
|
||||
direct: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['change', 'error', 'success', 'allSuccess'],
|
||||
@@ -92,6 +102,9 @@ 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 handleProgress = () => {
|
||||
visible.value = true
|
||||
}
|
||||
@@ -138,16 +151,79 @@ export default defineComponent({
|
||||
return '.jpg,.png,.gif,.jpeg,.ico'
|
||||
case 'video':
|
||||
return '.wmv,.avi,.mpg,.mpeg,.3gp,.mov,.mp4,.flv,.rmvb,.mkv'
|
||||
case 'voice':
|
||||
return '.mp3,.wav,.wma,.m4a,.aac,.amr'
|
||||
default:
|
||||
return '*'
|
||||
}
|
||||
})
|
||||
|
||||
// 走 COS 直传:成功时模拟老接口的响应 envelope,失败/降级时回到默认 XHR
|
||||
const httpRequest = async (options: UploadRequestOptions) => {
|
||||
visible.value = true
|
||||
try {
|
||||
const data = await uploadVideoDirectToCos({
|
||||
file: options.file,
|
||||
type: props.type as any,
|
||||
cid: Number((options.data as any)?.cid ?? 0),
|
||||
onProgress(info) {
|
||||
// 触发 ElUpload 内部进度(保持与默认上传一致的体验)
|
||||
;(options as any).onProgress?.({
|
||||
percent: info.percent,
|
||||
loaded: info.loaded,
|
||||
total: info.total
|
||||
})
|
||||
}
|
||||
})
|
||||
;(options as any).onSuccess?.({ code: RequestCodeEnum.SUCCESS, msg: 'ok', data })
|
||||
} catch (err: any) {
|
||||
if (err instanceof DirectUploadFallbackError) {
|
||||
feedback.msgWarning('当前存储不支持直传,已切换为普通上传')
|
||||
await defaultXhrUpload(options)
|
||||
return
|
||||
}
|
||||
;(options as any).onError?.(err instanceof Error ? err : new Error(String(err?.message ?? err)))
|
||||
}
|
||||
}
|
||||
|
||||
// 当 STS 不可用时,回退到 ElUpload 原生 XHR 行为(POST /upload/{type})
|
||||
const defaultXhrUpload = (options: UploadRequestOptions) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', action.value)
|
||||
Object.entries(headers.value).forEach(([k, v]) => xhr.setRequestHeader(k, String(v ?? '')))
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (!e.lengthComputable) return
|
||||
const percent = (e.loaded / e.total) * 100
|
||||
;(options as any).onProgress?.({ percent, loaded: e.loaded, total: e.total })
|
||||
}
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText)
|
||||
;(options as any).onSuccess?.(json)
|
||||
} catch (e) {
|
||||
;(options as any).onError?.(new Error('上传响应解析失败'))
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
;(options as any).onError?.(new Error('网络错误'))
|
||||
resolve()
|
||||
}
|
||||
const formData = new FormData()
|
||||
formData.append('file', options.file)
|
||||
Object.entries(options.data || {}).forEach(([k, v]) => formData.append(k, String(v)))
|
||||
xhr.send(formData)
|
||||
})
|
||||
|
||||
return {
|
||||
uploadRefs,
|
||||
action,
|
||||
headers,
|
||||
visible,
|
||||
fileList,
|
||||
useDirect,
|
||||
httpRequest,
|
||||
getAccept,
|
||||
handleProgress,
|
||||
handleSuccess,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//引入柱状图图表,图表后缀都为 Chart
|
||||
import {
|
||||
BarChart,
|
||||
FunnelChart,
|
||||
LineChart,
|
||||
MapChart,
|
||||
PictorialBarChart,
|
||||
@@ -43,6 +44,7 @@ echarts.use([
|
||||
AriaComponent,
|
||||
ParallelComponent,
|
||||
BarChart,
|
||||
FunnelChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
MapChart,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="layout-default flex h-screen w-full">
|
||||
<div class="app-aside">
|
||||
<!-- shrink-0:避免主内容区(宽表格/图表)把侧栏压缩到折叠宽度 -->
|
||||
<div class="app-aside shrink-0">
|
||||
<layout-sidebar />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -82,6 +82,24 @@ export const constantRoutes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: '/tcm/diagnosis/h5',
|
||||
component: () => import('@/views/tcm/diagnosis/index_h5.vue')
|
||||
},
|
||||
{
|
||||
// T1+T2:患者信息详情页(隐藏路由 — 不在侧边栏出现,由诊单列表的「查看」/双击行驱动跳转)
|
||||
path: '/tcm/diagnosis-readonly',
|
||||
component: LAYOUT,
|
||||
meta: { hidden: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: () => import('@/views/tcm/diagnosis/readonly.vue'),
|
||||
name: 'tcmDiagnosisReadonly',
|
||||
meta: {
|
||||
title: '患者信息详情',
|
||||
hidden: true,
|
||||
activeMenu: '/tcm/diagnosis'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
// {
|
||||
// path: '/dev_tools',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { getConfig } from '@/api/app'
|
||||
import configs from '@/config'
|
||||
|
||||
interface AppSate {
|
||||
config: Record<string, any>
|
||||
@@ -26,8 +27,22 @@ const useAppStore = defineStore({
|
||||
getImageUrl(url: string) {
|
||||
if (!url || typeof url !== 'string') return ''
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url
|
||||
const domain = this.config.oss_domain || (typeof window !== 'undefined' ? window.location.origin + '/' : '')
|
||||
return domain ? `${domain.replace(/\/$/, '')}${url.startsWith('/') ? url : '/' + url}` : url
|
||||
const path = url.startsWith('/') ? url : '/' + url
|
||||
const oss = String(this.config.oss_domain || '').replace(/\/$/, '')
|
||||
if (oss) {
|
||||
return `${oss}${path}`
|
||||
}
|
||||
// 开发环境:后台接口与 Vite 不同端口时,相对路径不能拼到 location.origin(应对接到 php 静态域名)
|
||||
const apiRoot = String(configs.baseUrl || '/').replace(/\/+$/, '')
|
||||
if (apiRoot.startsWith('http://') || apiRoot.startsWith('https://')) {
|
||||
try {
|
||||
return new URL(path, `${apiRoot}/`).href
|
||||
} catch {
|
||||
return `${apiRoot}${path}`
|
||||
}
|
||||
}
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
return origin ? `${origin}${path}` : path
|
||||
},
|
||||
getConfig() {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 血糖 / 血压 阈值与异常判定工具
|
||||
*
|
||||
* 阈值规则(与 reception/index.vue 抽离前一致):
|
||||
* - 年龄 < 50:空腹血糖 ≥ 7、餐后/其他血糖 ≥ 9 视为偏高
|
||||
* - 年龄 ≥ 50:空腹血糖 ≥ 8、餐后/其他血糖 ≥ 10 视为偏高
|
||||
* - 收缩压 > 140 / 舒张压 > 90 视为偏高
|
||||
*
|
||||
* 任何字段缺失或非数值 → 视为「不偏高」(避免误报红色 ↑)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 把任意值转成有限数字;空 / 非法 → null
|
||||
*/
|
||||
export function toNumeric(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const num = Number(value as any)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据年龄返回血糖阈值;年龄缺失/非法 → null(=不判定偏高)
|
||||
*/
|
||||
export function getBloodSugarThresholds(
|
||||
ageValue: unknown
|
||||
): { fasting: number; postprandial: number } | null {
|
||||
const age = toNumeric(ageValue)
|
||||
if (age === null) return null
|
||||
if (age < 50) return { fasting: 7, postprandial: 9 }
|
||||
return { fasting: 8, postprandial: 10 }
|
||||
}
|
||||
|
||||
export function isHighFastingBloodSugar(value: unknown, ageValue: unknown): boolean {
|
||||
const num = toNumeric(value)
|
||||
const thresholds = getBloodSugarThresholds(ageValue)
|
||||
return num !== null && thresholds !== null && num >= thresholds.fasting
|
||||
}
|
||||
|
||||
export function isHighPostprandialBloodSugar(value: unknown, ageValue: unknown): boolean {
|
||||
const num = toNumeric(value)
|
||||
const thresholds = getBloodSugarThresholds(ageValue)
|
||||
return num !== null && thresholds !== null && num >= thresholds.postprandial
|
||||
}
|
||||
|
||||
/**
|
||||
* 「其他血糖」按餐后阈值判定(与 reception 原实现一致)
|
||||
*/
|
||||
export function isHighOtherBloodSugar(value: unknown, ageValue: unknown): boolean {
|
||||
const num = toNumeric(value)
|
||||
const thresholds = getBloodSugarThresholds(ageValue)
|
||||
return num !== null && thresholds !== null && num >= thresholds.postprandial
|
||||
}
|
||||
|
||||
export function isHighSystolicPressure(value: unknown): boolean {
|
||||
const num = toNumeric(value)
|
||||
return num !== null && num > 140
|
||||
}
|
||||
|
||||
export function isHighDiastolicPressure(value: unknown): boolean {
|
||||
const num = toNumeric(value)
|
||||
return num !== null && num > 90
|
||||
}
|
||||
|
||||
/**
|
||||
* 一行血糖血压记录是否有任一压偏高
|
||||
*/
|
||||
export function isHighBloodPressure(row: {
|
||||
systolic_pressure?: unknown
|
||||
diastolic_pressure?: unknown
|
||||
} | null | undefined): boolean {
|
||||
if (!row) return false
|
||||
return isHighSystolicPressure(row.systolic_pressure) || isHighDiastolicPressure(row.diastolic_pressure)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一行记录的血压两值格式化为 `xxx / yyy`,缺值 → `—`
|
||||
*/
|
||||
export function formatBp(row: {
|
||||
systolic_pressure?: unknown
|
||||
diastolic_pressure?: unknown
|
||||
} | null | undefined): string {
|
||||
const sys = row?.systolic_pressure
|
||||
const dia = row?.diastolic_pressure
|
||||
if (sys == null && dia == null) return '—'
|
||||
return `${sys ?? '—'} / ${dia ?? '—'}`
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 病例 / 接诊页通用展示辅助
|
||||
*
|
||||
* - maskPhone:手机号脱敏 138****1234
|
||||
* - formatGender:0/1 → 女/男
|
||||
* - formatPeriod:morning/afternoon → 上午/下午
|
||||
* - joinArray:数组 → 顿号拼接,非数组 → toString
|
||||
* - makeTextOf:工厂,传入 diag ref/getter,返回闭包后的 textOf 函数
|
||||
*
|
||||
* 翻译规则(textOf):诊单字典字段由后端 `AppointmentLogic::enrichDiagnosisLabels`
|
||||
* 预翻译为 `<field>_text`,前端优先读 `*_text`;找不到时回退原值。
|
||||
*/
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
export function maskPhone(phone?: string | null): string {
|
||||
if (!phone) return '—'
|
||||
if (phone.length < 11) return phone
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
||||
}
|
||||
|
||||
export function formatGender(g?: number | null): string {
|
||||
if (g === 1) return '男'
|
||||
if (g === 0) return '女'
|
||||
return '—'
|
||||
}
|
||||
|
||||
export function formatPeriod(period?: string | null): string {
|
||||
if (period === 'morning') return '上午'
|
||||
if (period === 'afternoon') return '下午'
|
||||
return '全天'
|
||||
}
|
||||
|
||||
export function joinArray(arr: unknown): string {
|
||||
if (!arr) return ''
|
||||
if (Array.isArray(arr)) return arr.filter(Boolean).join('、')
|
||||
return String(arr)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建闭包了具体 diag 的 textOf 取值器。
|
||||
* 同时支持 `Ref` / `() => any` / 普通对象,避免在调用方处反复样板。
|
||||
*/
|
||||
export function makeTextOf(diagSource: Ref<any> | (() => any) | Record<string, any>) {
|
||||
const resolve = (): Record<string, any> => {
|
||||
if (typeof diagSource === 'function') {
|
||||
return (diagSource as () => any)() || {}
|
||||
}
|
||||
if (diagSource && typeof diagSource === 'object' && 'value' in diagSource) {
|
||||
return ((diagSource as Ref<any>).value ?? {}) as Record<string, any>
|
||||
}
|
||||
return (diagSource as Record<string, any>) || {}
|
||||
}
|
||||
|
||||
return function textOf(field: string): string {
|
||||
const d = resolve()
|
||||
const t = d[field + '_text']
|
||||
if (t !== undefined && t !== null && t !== '') return String(t)
|
||||
const raw = d[field]
|
||||
if (raw === undefined || raw === null || raw === '') return ''
|
||||
return Array.isArray(raw) ? raw.join('、') : String(raw)
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,15 @@ export function parseImBusinessPayload(raw: string): FriendlyParse | null {
|
||||
return { main: '患者进入聊天', sub: parts.length ? parts.join(' · ') : undefined, tag: '诊室' }
|
||||
}
|
||||
|
||||
if (o.businessID === 'patient_closed_chat') {
|
||||
const parts: string[] = []
|
||||
if (o.patientName) parts.push(`患者:${String(o.patientName)}`)
|
||||
if (o.patientId != null && o.patientId !== '') parts.push(`患者 ID:${String(o.patientId)}`)
|
||||
const t = formatBizTime(o.time)
|
||||
if (t) parts.push(t)
|
||||
return { main: '患者离开聊天页', sub: parts.length ? parts.join(' · ') : undefined, tag: '诊室' }
|
||||
}
|
||||
|
||||
if (o.businessID === 'user_typing_status') {
|
||||
return { main: '对方正在输入…', tag: '状态' }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import COS from 'cos-js-sdk-v5'
|
||||
|
||||
import {
|
||||
confirmOssUpload,
|
||||
getOssCredentials,
|
||||
type OssCredentialsResponse
|
||||
} from '@/api/file'
|
||||
|
||||
export type DirectUploadType = 'video'
|
||||
|
||||
export interface DirectUploadProgress {
|
||||
/** 0-100 */
|
||||
percent: number
|
||||
loaded: number
|
||||
total: number
|
||||
speed: number
|
||||
}
|
||||
|
||||
export interface DirectUploadResult {
|
||||
id: number
|
||||
cid: number
|
||||
type: number
|
||||
name: string
|
||||
uri: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface DirectUploadOptions {
|
||||
file: File
|
||||
type: DirectUploadType
|
||||
cid?: number
|
||||
onProgress?: (progress: DirectUploadProgress) => void
|
||||
/** 返回 false 时由调用方自行降级到老链路 */
|
||||
onFallback?: (provider: string) => boolean | void
|
||||
}
|
||||
|
||||
const SLICE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const ASYNC_LIMIT = 3
|
||||
|
||||
function buildKey(prefix: string, file: File): string {
|
||||
const ext = (file.name.split('.').pop() || 'mp4').toLowerCase()
|
||||
const ts = Date.now()
|
||||
const rand = Math.random().toString(36).slice(2, 10)
|
||||
return `${prefix}${ts}-${rand}.${ext}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 直传到腾讯云 COS(含 STS 凭证申请、分片上传、回执)
|
||||
* 不支持降级 / fallback=true 时抛错,由调用方决定走老链路。
|
||||
*/
|
||||
export async function uploadVideoDirectToCos(options: DirectUploadOptions): Promise<DirectUploadResult> {
|
||||
const credentials: OssCredentialsResponse = await getOssCredentials({ type: options.type })
|
||||
|
||||
if (credentials.fallback) {
|
||||
const handled = options.onFallback?.(credentials.provider) ?? false
|
||||
if (!handled) {
|
||||
throw new DirectUploadFallbackError(credentials.provider)
|
||||
}
|
||||
// 调用方自行处理,本函数早退
|
||||
throw new DirectUploadFallbackError(credentials.provider)
|
||||
}
|
||||
|
||||
if (!credentials.credentials || !credentials.bucket || !credentials.region || !credentials.key_prefix) {
|
||||
throw new Error('STS 凭证返回缺失字段')
|
||||
}
|
||||
|
||||
if (credentials.max_size && options.file.size > credentials.max_size) {
|
||||
const mb = Math.round(credentials.max_size / 1024 / 1024)
|
||||
throw new Error(`视频体积超出上限(${mb}MB)`)
|
||||
}
|
||||
|
||||
const cred = credentials.credentials
|
||||
const expiredTime = credentials.expired_time ?? 0
|
||||
const startTime = credentials.start_time ?? Math.floor(Date.now() / 1000)
|
||||
|
||||
const cos = new COS({
|
||||
getAuthorization(_options, callback) {
|
||||
callback({
|
||||
TmpSecretId: cred.tmpSecretId,
|
||||
TmpSecretKey: cred.tmpSecretKey,
|
||||
SecurityToken: cred.sessionToken,
|
||||
StartTime: startTime,
|
||||
ExpiredTime: expiredTime
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const key = buildKey(credentials.key_prefix, options.file)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
cos.uploadFile(
|
||||
{
|
||||
Bucket: credentials.bucket as string,
|
||||
Region: credentials.region as string,
|
||||
Key: key,
|
||||
Body: options.file,
|
||||
SliceSize: SLICE_SIZE,
|
||||
AsyncLimit: ASYNC_LIMIT,
|
||||
onProgress(info) {
|
||||
if (!options.onProgress) return
|
||||
const total = info.total || options.file.size
|
||||
options.onProgress({
|
||||
percent: Math.min(99, Math.round((info.percent || 0) * 100)),
|
||||
loaded: info.loaded || 0,
|
||||
total,
|
||||
speed: info.speed || 0
|
||||
})
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
options.onProgress?.({ percent: 100, loaded: options.file.size, total: options.file.size, speed: 0 })
|
||||
|
||||
return confirmed
|
||||
}
|
||||
|
||||
/** 当后端 fallback=true 时抛出,调用方据此降级到老链路 */
|
||||
export class DirectUploadFallbackError extends Error {
|
||||
readonly provider: string
|
||||
constructor(provider: string) {
|
||||
super(`COS 直传不可用,当前驱动: ${provider}`)
|
||||
this.name = 'DirectUploadFallbackError'
|
||||
this.provider = provider
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<div class="asset-resource-container">
|
||||
<!-- 搜索区域 -->
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form class="ls-form" :model="searchData" inline>
|
||||
<el-form-item class="w-[280px]" label="标题">
|
||||
<el-input
|
||||
placeholder="请输入标题关键词"
|
||||
v-model="searchData.title"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="上传时间">
|
||||
<el-date-picker
|
||||
v-model="searchData.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
clearable
|
||||
style="width: 280px;"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div class="mb-4">
|
||||
<el-button type="primary" @click="handleAdd">上传资源</el-button>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="queryParams.type" @tab-change="handleTabChange">
|
||||
<el-tab-pane label="图片" name="1"></el-tab-pane>
|
||||
<el-tab-pane label="视频" name="2"></el-tab-pane>
|
||||
<el-tab-pane label="语音" name="3"></el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading">
|
||||
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
||||
<el-table-column prop="title" label="标题" min-width="80" />
|
||||
<el-table-column label="预览" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-image v-if="row.type == 1" :src="row.file_url" style="width: 50px; height: 50px;" :preview-src-list="[row.file_url]" preview-teleported />
|
||||
<el-link v-else :href="row.file_url" target="_blank" type="primary">点击预览</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联账号" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="!row.users?.length" type="info" size="small">公开资源</el-tag>
|
||||
<span v-else>{{ row.users?.map((u: any) => u.phone).join(', ') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="上传时间" width="180" />
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page_no"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="getList"
|
||||
@current-change="getList"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增/编辑资源弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑资源' : '新增分发资源'" width="600px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="formData" :rules="computedRules" label-width="100px">
|
||||
<el-form-item label="资源类型" prop="type" v-if="!isEdit">
|
||||
<el-radio-group v-model="formData.type">
|
||||
<el-radio :label="1">图片</el-radio>
|
||||
<el-radio :label="2">视频</el-radio>
|
||||
<el-radio :label="3">语音</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="formData.title" placeholder="请输入资源标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="资源文件" prop="file_url" v-if="!isEdit">
|
||||
<material-picker v-if="formData.type === 1" v-model="formData.file_url" :limit="1" type="image" />
|
||||
<upload v-else-if="formData.type === 2" type="video" direct :multiple="false" :limit="1" :show-progress="true" @success="handleUploadSuccess">
|
||||
<el-button type="primary">上传视频 (OSS直传)</el-button>
|
||||
</upload>
|
||||
<upload v-else-if="formData.type === 3" type="voice" direct :multiple="false" :limit="1" :show-progress="true" @success="handleUploadSuccess">
|
||||
<el-button type="primary">上传语音 (OSS直传)</el-button>
|
||||
</upload>
|
||||
<div v-if="formData.file_url && formData.type !== 1" class="ml-4 text-primary truncate max-w-xs" :title="formData.file_url">
|
||||
已上传: {{ formData.file_url }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联账号" prop="user_ids">
|
||||
<el-select v-model="formData.user_ids" multiple filterable placeholder="不选择则为公开资源" clearable style="width: 100%;">
|
||||
<el-option v-for="item in userOptions" :key="item.id" :label="item.phone" :value="item.id" />
|
||||
</el-select>
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px;">不选择账号则资源为公开资源,所有人可见</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitForm" :loading="submitLoading">{{ isEdit ? '保存修改' : '确定发布' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { apiAssetResourceList, apiAssetResourceAdd, apiAssetResourceEdit, apiAssetResourceDelete, apiAssetUserList } from '@/api/asset'
|
||||
import MaterialPicker from '@/components/material/picker.vue'
|
||||
import Upload from '@/components/upload/index.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
|
||||
const searchData = reactive<any>({
|
||||
title: '',
|
||||
dateRange: []
|
||||
})
|
||||
|
||||
const queryParams = reactive<any>({
|
||||
type: '1',
|
||||
page_no: 1,
|
||||
page_size: 15
|
||||
})
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
const isEdit = ref(false)
|
||||
const editId = ref(0)
|
||||
|
||||
const userOptions = ref<any[]>([])
|
||||
|
||||
const formData = reactive<any>({
|
||||
type: 1,
|
||||
title: '',
|
||||
file_url: '',
|
||||
cover_url: '',
|
||||
user_ids: []
|
||||
})
|
||||
|
||||
const computedRules = computed(() => {
|
||||
if (isEdit.value) {
|
||||
return {
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||
file_url: [{ required: true, message: '请输入或上传文件获取链接', trigger: 'blur' }]
|
||||
}
|
||||
})
|
||||
|
||||
const buildQueryParams = () => {
|
||||
const params: any = { ...queryParams }
|
||||
if (searchData.title) {
|
||||
params.title = searchData.title
|
||||
}
|
||||
if (searchData.dateRange && searchData.dateRange.length === 2) {
|
||||
params.start_time = searchData.dateRange[0]
|
||||
params.end_time = searchData.dateRange[1]
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await apiAssetResourceList(buildQueryParams())
|
||||
tableData.value = res.lists
|
||||
total.value = res.count
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUserOptions = async () => {
|
||||
try {
|
||||
const res = await apiAssetUserList({ page_no: 1, page_size: 9999 })
|
||||
userOptions.value = res.lists
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
queryParams.page_no = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
searchData.title = ''
|
||||
searchData.dateRange = []
|
||||
queryParams.page_no = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleTabChange = () => {
|
||||
queryParams.page_no = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
editId.value = 0
|
||||
Object.assign(formData, { type: parseInt(queryParams.type), title: '', file_url: '', cover_url: '', user_ids: [] })
|
||||
fetchUserOptions()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: any) => {
|
||||
isEdit.value = true
|
||||
editId.value = row.id
|
||||
Object.assign(formData, {
|
||||
type: row.type,
|
||||
title: row.title,
|
||||
file_url: row.file_url,
|
||||
cover_url: row.cover_url || '',
|
||||
user_ids: row.users?.map((u: any) => u.id) || []
|
||||
})
|
||||
fetchUserOptions()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleUploadSuccess = (response: any) => {
|
||||
formData.file_url = response?.data?.uri || response?.data?.url || ''
|
||||
ElMessage.success('上传成功')
|
||||
}
|
||||
|
||||
const handleDelete = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该资源吗?用户将无法再看到。', '提示', { type: 'warning' })
|
||||
await apiAssetResourceDelete({ id: row.id })
|
||||
ElMessage.success('删除成功')
|
||||
getList()
|
||||
} catch (e) {
|
||||
// canceled
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await apiAssetResourceEdit({
|
||||
id: editId.value,
|
||||
title: formData.title,
|
||||
user_ids: formData.user_ids
|
||||
})
|
||||
ElMessage.success('编辑成功')
|
||||
} else {
|
||||
await apiAssetResourceAdd(formData)
|
||||
ElMessage.success('上传分发成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
getList()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="asset-user-container">
|
||||
<!-- 搜索区域 -->
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form class="ls-form" :model="searchData" inline>
|
||||
<el-form-item class="w-[280px]" label="手机号">
|
||||
<el-input
|
||||
placeholder="请输入手机号"
|
||||
v-model="searchData.phone"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div class="mb-4">
|
||||
<el-button type="primary" @click="handleAdd">新增账号</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading">
|
||||
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
||||
<el-table-column prop="phone" label="手机号" />
|
||||
<el-table-column prop="create_time" label="创建时间" width="180" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
active-text="正常"
|
||||
inactive-text="禁用"
|
||||
inline-prompt
|
||||
@change="handleStatusChange(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page_no"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="getList"
|
||||
@current-change="getList"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 编辑/新增弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="80px">
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="formData.phone" placeholder="请输入手机号(作为登录账号)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="formData.password" placeholder="为空则默认为 123456" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-switch v-model="formData.status" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitForm" :loading="submitLoading">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { apiAssetUserList, apiAssetUserAdd, apiAssetUserEdit, apiAssetUserDelete } from '@/api/asset'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
|
||||
const searchData = reactive({
|
||||
phone: ''
|
||||
})
|
||||
|
||||
const queryParams = reactive<any>({
|
||||
page_no: 1,
|
||||
page_size: 15
|
||||
})
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
status: 1
|
||||
})
|
||||
|
||||
const rules = {
|
||||
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { ...queryParams, ...searchData }
|
||||
const res = await apiAssetUserList(params)
|
||||
tableData.value = res.lists
|
||||
total.value = res.count
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
queryParams.page_no = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
searchData.phone = ''
|
||||
queryParams.page_no = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleStatusChange = async (row: any) => {
|
||||
try {
|
||||
await apiAssetUserEdit({ id: row.id, status: row.status })
|
||||
ElMessage.success(row.status === 1 ? '已启用' : '已禁用')
|
||||
} catch (e) {
|
||||
// 失败时回滚状态
|
||||
row.status = row.status === 1 ? 0 : 1
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
dialogTitle.value = '新增账号'
|
||||
Object.assign(formData, { id: '', phone: '', password: '', status: 1 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: any) => {
|
||||
dialogTitle.value = '编辑账号'
|
||||
Object.assign(formData, { id: row.id, phone: row.phone, password: '', status: row.status })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该账号及其关联资源吗?', '提示', { type: 'warning' })
|
||||
await apiAssetUserDelete({ id: row.id })
|
||||
ElMessage.success('删除成功')
|
||||
getList()
|
||||
} catch (e) {
|
||||
// canceled
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (formData.id) {
|
||||
await apiAssetUserEdit(formData)
|
||||
} else {
|
||||
await apiAssetUserAdd(formData)
|
||||
}
|
||||
ElMessage.success('操作成功')
|
||||
dialogVisible.value = false
|
||||
getList()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
@@ -60,25 +60,26 @@
|
||||
|
||||
<el-divider content-position="left">组织信息</el-divider>
|
||||
|
||||
<!-- 归属部门 -->
|
||||
<!-- 归属部门:数据权限内仅末级可选,父级仅用于展开 -->
|
||||
<el-form-item label="归属部门" prop="dept_id">
|
||||
<el-tree-select
|
||||
class="flex-1"
|
||||
v-model="formData.dept_id"
|
||||
:data="optionsData.dept"
|
||||
clearable
|
||||
multiple
|
||||
node-key="id"
|
||||
:props="{
|
||||
label: 'name',
|
||||
disabled(data: any) {
|
||||
return data.status !== 1
|
||||
}
|
||||
}"
|
||||
check-strictly
|
||||
:default-expand-all="true"
|
||||
placeholder="请选择归属部门"
|
||||
/>
|
||||
<div class="w-full">
|
||||
<el-tree-select
|
||||
class="flex-1 w-full"
|
||||
v-model="formData.dept_id"
|
||||
:data="optionsData.dept"
|
||||
clearable
|
||||
multiple
|
||||
node-key="id"
|
||||
:props="{
|
||||
label: 'name',
|
||||
disabled: deptTreeNodeDisabled
|
||||
}"
|
||||
check-strictly
|
||||
:default-expand-all="true"
|
||||
placeholder="请选择末级归属部门"
|
||||
/>
|
||||
<div class="form-tips">仅可选末级部门(含下级的父部门不可选),与数据权限范围一致。</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 岗位 -->
|
||||
@@ -215,6 +216,13 @@ import { getDictData } from '@/api/app'
|
||||
import { useDictOptions } from '@/hooks/useDictOptions'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
/** 归属部门:禁止选父级,仅末级可选(status=1 且无子节点) */
|
||||
function deptTreeNodeDisabled(data: { status?: number; children?: unknown[] }) {
|
||||
if (data.status !== 1) return true
|
||||
const ch = data.children
|
||||
return Array.isArray(ch) && ch.length > 0
|
||||
}
|
||||
|
||||
const emit = defineEmits(['success', 'close'])
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
const visible = ref(false)
|
||||
@@ -264,7 +272,8 @@ const { optionsData } = useDictOptions<{
|
||||
api: jobsAll
|
||||
},
|
||||
dept: {
|
||||
api: deptAll
|
||||
api: deptAll,
|
||||
params: { apply_data_scope: 1 }
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -54,25 +54,26 @@
|
||||
|
||||
<el-divider content-position="left">组织信息</el-divider>
|
||||
|
||||
<!-- 归属部门 -->
|
||||
<!-- 归属部门:数据权限内仅末级可选,父级仅用于展开 -->
|
||||
<el-form-item label="归属部门" prop="dept_id">
|
||||
<el-tree-select
|
||||
class="flex-1"
|
||||
v-model="formData.dept_id"
|
||||
:data="optionsData.dept"
|
||||
clearable
|
||||
multiple
|
||||
node-key="id"
|
||||
:props="{
|
||||
label: 'name',
|
||||
disabled(data: any) {
|
||||
return data.status !== 1
|
||||
}
|
||||
}"
|
||||
check-strictly
|
||||
:default-expand-all="true"
|
||||
placeholder="请选择归属部门"
|
||||
/>
|
||||
<div class="w-full">
|
||||
<el-tree-select
|
||||
class="flex-1 w-full"
|
||||
v-model="formData.dept_id"
|
||||
:data="optionsData.dept"
|
||||
clearable
|
||||
multiple
|
||||
node-key="id"
|
||||
:props="{
|
||||
label: 'name',
|
||||
disabled: deptTreeNodeDisabled
|
||||
}"
|
||||
check-strictly
|
||||
:default-expand-all="true"
|
||||
placeholder="请选择末级归属部门"
|
||||
/>
|
||||
<div class="form-tips">仅可选末级部门(含下级的父部门不可选),与数据权限范围一致。</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 岗位 -->
|
||||
@@ -254,6 +255,13 @@ import { getDictData } from '@/api/app'
|
||||
import { useDictOptions } from '@/hooks/useDictOptions'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
/** 归属部门:禁止选父级,仅末级可选(status=1 且无子节点) */
|
||||
function deptTreeNodeDisabled(data: { status?: number; children?: unknown[] }) {
|
||||
if (data.status !== 1) return true
|
||||
const ch = data.children
|
||||
return Array.isArray(ch) && ch.length > 0
|
||||
}
|
||||
|
||||
const emit = defineEmits(['success', 'close'])
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
const visible = ref(false)
|
||||
@@ -307,7 +315,8 @@ const { optionsData } = useDictOptions<{
|
||||
api: jobsAll
|
||||
},
|
||||
dept: {
|
||||
api: deptAll
|
||||
api: deptAll,
|
||||
params: { apply_data_scope: 1 }
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -99,11 +99,12 @@ import EditPopup from './edit.vue'
|
||||
|
||||
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
|
||||
|
||||
// 表单数据 - 固定role_id=1查询医生
|
||||
// 表单数据 - 固定 role_id=1 查询医生;数据范围与医助列表一致,仅看自己权限内成员
|
||||
const formData = reactive({
|
||||
account: '',
|
||||
name: '',
|
||||
role_id: 1 // 固定为医生角色
|
||||
role_id: 1,
|
||||
apply_data_scope: 1
|
||||
})
|
||||
|
||||
const showEdit = ref(false)
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
<template>
|
||||
<div class="guahao-list">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form class="mb-[-16px]" :model="queryParams" :inline="true" @submit.prevent>
|
||||
<el-form-item label="预约日期">
|
||||
<daterange-picker
|
||||
v-model:startTime="queryParams.start_date"
|
||||
v-model:endTime="queryParams.end_date"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="患者姓名">
|
||||
<el-input
|
||||
v-model="queryParams.patient_name"
|
||||
placeholder="模糊搜索"
|
||||
clearable
|
||||
class="!w-[160px]"
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="医生">
|
||||
<el-input
|
||||
v-model="queryParams.doctor_name"
|
||||
placeholder="模糊搜索"
|
||||
clearable
|
||||
class="!w-[140px]"
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="医助">
|
||||
<el-select
|
||||
v-model="queryParams.assistant_id"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
filterable
|
||||
class="!w-[180px]"
|
||||
>
|
||||
<el-option
|
||||
v-for="a in assistantOptions"
|
||||
:key="a.id"
|
||||
:label="a.name + (a.account ? ` (${a.account})` : '')"
|
||||
:value="a.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.status" placeholder="全部" clearable class="!w-[130px]">
|
||||
<el-option label="已预约" :value="1" />
|
||||
<el-option label="已取消" :value="2" />
|
||||
<el-option label="已完成" :value="3" />
|
||||
<el-option label="已过号" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="渠道">
|
||||
<el-select
|
||||
v-model="queryParams.channel_source"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
filterable
|
||||
class="!w-[200px]"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in channelOptions"
|
||||
:key="String(item.value)"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button @click="resetFilter">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||
<el-button
|
||||
v-perms="['doctor.appointment/edit']"
|
||||
type="primary"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="openBatchChannelDialog"
|
||||
>
|
||||
批量修改渠道
|
||||
</el-button>
|
||||
<span v-if="selectedRows.length > 0" class="text-sm text-gray-500">
|
||||
已选 {{ selectedRows.length }} 条
|
||||
</span>
|
||||
</div>
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
v-loading="pager.loading"
|
||||
row-key="id"
|
||||
:data="pager.lists"
|
||||
size="large"
|
||||
stripe
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="48" align="center" reserve-selection />
|
||||
<el-table-column label="ID" prop="id" width="72" align="center" />
|
||||
<el-table-column label="诊单/患者" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="text-sm">
|
||||
<div class="font-medium">{{ row.patient_name || '—' }}</div>
|
||||
<div class="text-gray-500">{{ maskPhone(row.patient_phone) }}</div>
|
||||
<div class="text-xs text-gray-400">诊单 #{{ row.diagnosis_id ?? row.patient_id }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="医生" prop="doctor_name" width="100" show-overflow-tooltip />
|
||||
<el-table-column label="医助" prop="assistant_name" width="100" show-overflow-tooltip />
|
||||
<el-table-column label="预约时间" min-width="128">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.appointment_date }}</div>
|
||||
<div class="text-gray-500">{{ formatHm(row.appointment_time) }} · {{ row.period_desc }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" prop="appointment_type_desc" width="100" />
|
||||
<el-table-column label="渠道" min-width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="text-sm">{{ row.channel_source_desc || '—' }}</div>
|
||||
<div v-if="row.channel_source_detail" class="text-xs text-gray-400 truncate">
|
||||
{{ row.channel_source_detail }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="96" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="
|
||||
row.status === 1 ? 'success' : row.status === 2 ? 'info' : row.status === 3 ? 'primary' : 'danger'
|
||||
"
|
||||
size="small"
|
||||
effect="light"
|
||||
>
|
||||
{{ row.status_desc }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" prop="remark" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="100" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button v-perms="['doctor.appointment/edit']" type="primary" link @click="openEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑挂号" width="560px" destroy-on-close @closed="resetEditForm">
|
||||
<el-form :model="editForm" label-width="96px">
|
||||
<el-form-item label="预约日期" required>
|
||||
<el-date-picker
|
||||
v-model="editForm.appointment_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="选择日期"
|
||||
class="!w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="预约时间" required>
|
||||
<el-time-picker
|
||||
v-model="editForm.appointment_time"
|
||||
format="HH:mm"
|
||||
value-format="HH:mm"
|
||||
placeholder="时间"
|
||||
class="!w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="时段" required>
|
||||
<el-radio-group v-model="editForm.period">
|
||||
<el-radio-button label="morning">上午</el-radio-button>
|
||||
<el-radio-button label="afternoon">下午</el-radio-button>
|
||||
<el-radio-button label="all">全天</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="问诊类型" required>
|
||||
<el-select v-model="editForm.appointment_type" class="!w-full">
|
||||
<el-option label="视频问诊" value="video" />
|
||||
<el-option label="图文问诊" value="text" />
|
||||
<el-option label="电话问诊" value="phone" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="渠道来源" required>
|
||||
<el-select
|
||||
v-model="editForm.channel_source"
|
||||
placeholder="请选择渠道来源"
|
||||
filterable
|
||||
class="!w-full"
|
||||
@change="onChannelSourceChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in channelOptions"
|
||||
:key="String(item.value)"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="needsChannelSourceDetail" label="自媒体补充" required>
|
||||
<el-input
|
||||
v-model="editForm.channel_source_detail"
|
||||
maxlength="128"
|
||||
show-word-limit
|
||||
placeholder="请输入自媒体相关补充内容"
|
||||
clearable
|
||||
class="!w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" required>
|
||||
<el-select v-model="editForm.status" class="!w-full">
|
||||
<el-option label="已预约" :value="1" />
|
||||
<el-option label="已取消" :value="2" />
|
||||
<el-option label="已完成" :value="3" />
|
||||
<el-option label="已过号" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="挂号医助">
|
||||
<el-select
|
||||
v-model="editForm.assistant_id"
|
||||
placeholder="不修改可留空"
|
||||
clearable
|
||||
filterable
|
||||
class="!w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="a in assistantOptions"
|
||||
:key="a.id"
|
||||
:label="a.name + (a.account ? ` (${a.account})` : '')"
|
||||
:value="a.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="editForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSaving" @click="submitEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="batchChannelVisible"
|
||||
title="批量修改渠道"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
@closed="resetBatchChannelForm"
|
||||
>
|
||||
<p class="text-sm text-gray-600 mb-4">将对已选 {{ selectedRows.length }} 条挂号写入同一渠道来源(仅改渠道,不改其它字段)。</p>
|
||||
<el-form :model="batchChannelForm" label-width="96px">
|
||||
<el-form-item label="渠道来源" required>
|
||||
<el-select
|
||||
v-model="batchChannelForm.channel_source"
|
||||
placeholder="请选择渠道来源"
|
||||
filterable
|
||||
class="!w-full"
|
||||
@change="onBatchChannelSourceChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in channelOptions"
|
||||
:key="String(item.value)"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="needsBatchChannelSourceDetail" label="自媒体补充" required>
|
||||
<el-input
|
||||
v-model="batchChannelForm.channel_source_detail"
|
||||
maxlength="128"
|
||||
show-word-limit
|
||||
placeholder="请输入自媒体相关补充内容"
|
||||
clearable
|
||||
class="!w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="batchChannelVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="batchChannelSaving" @click="submitBatchChannel">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="consumerPrescriptionGuahao">
|
||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||
import { getDictData } from '@/api/app'
|
||||
import { appointmentAdminEdit, appointmentBatchEditChannel, appointmentLists } from '@/api/doctor'
|
||||
import { getAssistants } from '@/api/tcm'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
const queryParams = reactive({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
patient_name: '',
|
||||
doctor_name: '',
|
||||
assistant_id: undefined as number | undefined,
|
||||
status: '' as number | '',
|
||||
channel_source: '' as string
|
||||
})
|
||||
|
||||
const queryInit = {
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
patient_name: '',
|
||||
doctor_name: '',
|
||||
assistant_id: undefined as number | undefined,
|
||||
status: '' as number | '',
|
||||
channel_source: ''
|
||||
}
|
||||
|
||||
const { pager, getLists, resetPage } = usePaging({
|
||||
fetchFun: appointmentLists,
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
const assistantOptions = ref<Array<{ id: number; name: string; account?: string }>>([])
|
||||
|
||||
const tableRef = ref<{ clearSelection?: () => void } | null>(null)
|
||||
const selectedRows = ref<Record<string, unknown>[]>([])
|
||||
|
||||
function onSelectionChange(rows: Record<string, unknown>[]) {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
/** 与诊单预约弹窗一致:这些字典 name 需填「自媒体补充」 */
|
||||
const CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL = new Set([
|
||||
'自媒体4H',
|
||||
'自媒体3Q',
|
||||
'自媒体3H',
|
||||
'自媒体2H',
|
||||
'自媒体2Q'
|
||||
])
|
||||
|
||||
function channelNameRequiresSelfMediaDetail(name: string) {
|
||||
return CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL.has(String(name ?? '').trim())
|
||||
}
|
||||
|
||||
const channelOptions = ref<Array<{ name: string; value: string }>>([])
|
||||
|
||||
const selectedChannelDictName = computed(() => {
|
||||
const v = editForm.channel_source
|
||||
if (v === '' || v == null) return ''
|
||||
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(v))
|
||||
return row ? String(row.name ?? '').trim() : ''
|
||||
})
|
||||
|
||||
const needsChannelSourceDetail = computed(() => channelNameRequiresSelfMediaDetail(selectedChannelDictName.value))
|
||||
|
||||
const batchSelectedChannelDictName = computed(() => {
|
||||
const v = batchChannelForm.channel_source
|
||||
if (v === '' || v == null) return ''
|
||||
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(v))
|
||||
return row ? String(row.name ?? '').trim() : ''
|
||||
})
|
||||
|
||||
const needsBatchChannelSourceDetail = computed(() =>
|
||||
channelNameRequiresSelfMediaDetail(batchSelectedChannelDictName.value)
|
||||
)
|
||||
|
||||
function onBatchChannelSourceChange(val: string) {
|
||||
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(val))
|
||||
const name = row ? String(row.name ?? '').trim() : ''
|
||||
if (!channelNameRequiresSelfMediaDetail(name)) {
|
||||
batchChannelForm.channel_source_detail = ''
|
||||
}
|
||||
}
|
||||
|
||||
const batchChannelVisible = ref(false)
|
||||
const batchChannelSaving = ref(false)
|
||||
const batchChannelForm = reactive({
|
||||
channel_source: '' as string,
|
||||
channel_source_detail: '' as string
|
||||
})
|
||||
|
||||
function openBatchChannelDialog() {
|
||||
if (selectedRows.value.length === 0) {
|
||||
feedback.msgWarning('请先勾选挂号记录')
|
||||
return
|
||||
}
|
||||
batchChannelForm.channel_source = ''
|
||||
batchChannelForm.channel_source_detail = ''
|
||||
batchChannelVisible.value = true
|
||||
}
|
||||
|
||||
function resetBatchChannelForm() {
|
||||
batchChannelForm.channel_source = ''
|
||||
batchChannelForm.channel_source_detail = ''
|
||||
}
|
||||
|
||||
async function submitBatchChannel() {
|
||||
if (selectedRows.value.length === 0) {
|
||||
feedback.msgWarning('请先勾选挂号记录')
|
||||
return
|
||||
}
|
||||
if (!batchChannelForm.channel_source) {
|
||||
feedback.msgError('请选择渠道来源')
|
||||
return
|
||||
}
|
||||
if (needsBatchChannelSourceDetail.value && !batchChannelForm.channel_source_detail.trim()) {
|
||||
feedback.msgError('请填写自媒体补充说明')
|
||||
return
|
||||
}
|
||||
batchChannelSaving.value = true
|
||||
try {
|
||||
const ids = selectedRows.value.map((r) => Number(r.id))
|
||||
await appointmentBatchEditChannel({
|
||||
ids,
|
||||
channel_source: batchChannelForm.channel_source,
|
||||
channel_source_detail: batchChannelForm.channel_source_detail.trim()
|
||||
})
|
||||
feedback.msgSuccess('批量修改成功')
|
||||
batchChannelVisible.value = false
|
||||
tableRef.value?.clearSelection?.()
|
||||
selectedRows.value = []
|
||||
getLists()
|
||||
} finally {
|
||||
batchChannelSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onChannelSourceChange(val: string) {
|
||||
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(val))
|
||||
const name = row ? String(row.name ?? '').trim() : ''
|
||||
if (!channelNameRequiresSelfMediaDetail(name)) {
|
||||
editForm.channel_source_detail = ''
|
||||
}
|
||||
}
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editSaving = ref(false)
|
||||
const editForm = reactive({
|
||||
id: 0,
|
||||
appointment_date: '',
|
||||
appointment_time: '' as string,
|
||||
period: 'morning' as 'morning' | 'afternoon' | 'all',
|
||||
appointment_type: 'video',
|
||||
status: 1,
|
||||
remark: '',
|
||||
assistant_id: undefined as number | undefined,
|
||||
channel_source: '' as string,
|
||||
channel_source_detail: '' as string
|
||||
})
|
||||
|
||||
function maskPhone(phone?: string) {
|
||||
const p = String(phone || '')
|
||||
if (p.length >= 11) {
|
||||
return p.slice(0, 3) + '****' + p.slice(-4)
|
||||
}
|
||||
return p || '—'
|
||||
}
|
||||
|
||||
function formatHm(t?: string) {
|
||||
const s = String(t || '')
|
||||
return s.length >= 5 ? s.slice(0, 5) : s || '—'
|
||||
}
|
||||
|
||||
function resetFilter() {
|
||||
Object.assign(queryParams, queryInit)
|
||||
resetPage()
|
||||
}
|
||||
|
||||
function rowPeriod(row: Record<string, unknown>): 'morning' | 'afternoon' | 'all' {
|
||||
const v = String(row.period ?? row.type ?? 'morning')
|
||||
if (v === 'afternoon' || v === 'all') {
|
||||
return v
|
||||
}
|
||||
return 'morning'
|
||||
}
|
||||
|
||||
async function loadAssistants() {
|
||||
try {
|
||||
const res: any = await getAssistants()
|
||||
assistantOptions.value = res?.lists ?? res ?? []
|
||||
} catch {
|
||||
assistantOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChannelOptions() {
|
||||
try {
|
||||
const data: any = await getDictData({ type: 'channels' })
|
||||
const rows = (data?.channels || []).filter((row: { status?: number }) => row.status !== 0)
|
||||
rows.sort((a: { sort?: number; id?: number }, b: { sort?: number; id?: number }) => {
|
||||
const ds = Number(b?.sort ?? 0) - Number(a?.sort ?? 0)
|
||||
if (ds !== 0) return ds
|
||||
return Number(b?.id ?? 0) - Number(a?.id ?? 0)
|
||||
})
|
||||
channelOptions.value = rows
|
||||
} catch {
|
||||
channelOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(row: Record<string, unknown>) {
|
||||
editForm.id = Number(row.id)
|
||||
editForm.appointment_date = String(row.appointment_date || '')
|
||||
editForm.appointment_time = formatHm(String(row.appointment_time || ''))
|
||||
editForm.period = rowPeriod(row)
|
||||
editForm.appointment_type = String(row.appointment_type || 'video')
|
||||
editForm.status = Number(row.status)
|
||||
editForm.remark = String(row.remark || '')
|
||||
const raw = row.appointment_assistant_id
|
||||
editForm.assistant_id =
|
||||
raw !== undefined && raw !== null && raw !== '' ? Number(raw) : undefined
|
||||
editForm.channel_source = String(row.channel_source ?? row.channels ?? '')
|
||||
editForm.channel_source_detail = String(row.channel_source_detail ?? '')
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
function resetEditForm() {
|
||||
editForm.id = 0
|
||||
editForm.appointment_date = ''
|
||||
editForm.appointment_time = ''
|
||||
editForm.period = 'morning'
|
||||
editForm.appointment_type = 'video'
|
||||
editForm.status = 1
|
||||
editForm.remark = ''
|
||||
editForm.assistant_id = undefined
|
||||
editForm.channel_source = ''
|
||||
editForm.channel_source_detail = ''
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editForm.appointment_date) {
|
||||
feedback.msgError('请选择预约日期')
|
||||
return
|
||||
}
|
||||
if (!editForm.appointment_time) {
|
||||
feedback.msgError('请选择预约时间')
|
||||
return
|
||||
}
|
||||
if (!editForm.channel_source) {
|
||||
feedback.msgError('请选择渠道来源')
|
||||
return
|
||||
}
|
||||
if (needsChannelSourceDetail.value && !editForm.channel_source_detail.trim()) {
|
||||
feedback.msgError('请填写自媒体补充说明')
|
||||
return
|
||||
}
|
||||
editSaving.value = true
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
id: editForm.id,
|
||||
appointment_date: editForm.appointment_date,
|
||||
appointment_time: editForm.appointment_time,
|
||||
period: editForm.period,
|
||||
appointment_type: editForm.appointment_type,
|
||||
status: editForm.status,
|
||||
remark: editForm.remark,
|
||||
assistant_id: editForm.assistant_id ?? '',
|
||||
channel_source: editForm.channel_source,
|
||||
channel_source_detail: editForm.channel_source_detail.trim()
|
||||
}
|
||||
await appointmentAdminEdit(payload)
|
||||
feedback.msgSuccess('保存成功')
|
||||
editVisible.value = false
|
||||
getLists()
|
||||
} finally {
|
||||
editSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAssistants()
|
||||
loadChannelOptions()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
getLists()
|
||||
})
|
||||
|
||||
getLists()
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,13 @@
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item class="w-[200px]" label="处方类型">
|
||||
<el-select v-model="formData.formula_type" placeholder="全部" clearable>
|
||||
<el-option label="全部" :value="''" />
|
||||
<el-option label="主方" value="主方" />
|
||||
<el-option label="辅方" value="辅方" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="w-[200px]" label="是否公开">
|
||||
<el-select v-model="formData.is_public" placeholder="全部" clearable>
|
||||
<el-option label="全部" :value="''" />
|
||||
@@ -37,6 +44,13 @@
|
||||
<el-table :data="pager.lists" size="large">
|
||||
<el-table-column label="ID" prop="id" min-width="60" />
|
||||
<el-table-column label="处方名称" prop="prescription_name" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="处方类型" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.formula_type === '辅方' ? 'warning' : 'primary'" size="small">
|
||||
{{ row.formula_type || '主方' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="药材数量" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.herbs?.length || 0 }}味
|
||||
@@ -114,6 +128,13 @@
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="处方类型" prop="formula_type">
|
||||
<el-radio-group v-model="editForm.formula_type">
|
||||
<el-radio value="主方">主方</el-radio>
|
||||
<el-radio value="辅方">辅方</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="药材配方" prop="herbs" required>
|
||||
<div class="w-full">
|
||||
@@ -200,6 +221,7 @@ import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
prescription_name: '',
|
||||
formula_type: '',
|
||||
is_public: ''
|
||||
})
|
||||
|
||||
@@ -212,6 +234,7 @@ const formRef = ref<FormInstance>()
|
||||
const editForm = reactive({
|
||||
id: 0,
|
||||
prescription_name: '',
|
||||
formula_type: '主方',
|
||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
||||
is_public: 0
|
||||
})
|
||||
@@ -267,6 +290,7 @@ const removeHerb = (index: number) => {
|
||||
const resetForm = () => {
|
||||
editForm.id = 0
|
||||
editForm.prescription_name = ''
|
||||
editForm.formula_type = '主方'
|
||||
editForm.herbs = []
|
||||
editForm.is_public = 0
|
||||
}
|
||||
@@ -283,6 +307,7 @@ const handleView = (row: any) => {
|
||||
editMode.value = 'view'
|
||||
editForm.id = row.id
|
||||
editForm.prescription_name = row.prescription_name || ''
|
||||
editForm.formula_type = row.formula_type || '主方'
|
||||
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
||||
editForm.is_public = row.is_public ?? 0
|
||||
showEdit.value = true
|
||||
@@ -293,6 +318,7 @@ const handleEdit = (row: any) => {
|
||||
editMode.value = 'edit'
|
||||
editForm.id = row.id
|
||||
editForm.prescription_name = row.prescription_name || ''
|
||||
editForm.formula_type = row.formula_type || '主方'
|
||||
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
||||
editForm.is_public = row.is_public ?? 0
|
||||
showEdit.value = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -234,6 +234,22 @@
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="showAuditAdminFilter" class="w-[200px]" label="下单人">
|
||||
<el-select
|
||||
v-model="queryParams.audit_admin_id"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部下单人"
|
||||
class="!w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="a in auditAdminOptions"
|
||||
:key="a.id"
|
||||
:label="a.name"
|
||||
:value="a.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="w-[200px]" label="医生">
|
||||
<el-select
|
||||
v-model="queryParams.doctor_id"
|
||||
@@ -677,7 +693,19 @@
|
||||
<el-row :gutter="16" class="mb-5">
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
|
||||
<div class="stat-title text-gray-500 text-xs mb-1">总金额</div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="stat-title text-gray-500 text-xs">总金额</div>
|
||||
<el-button
|
||||
v-if="canUpdateAmount(detailData)"
|
||||
v-perms="['tcm.prescriptionOrder/updateAmount']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
@click="openUpdateAmount"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="stat-value text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
@@ -819,6 +847,9 @@
|
||||
<el-descriptions-item label="用量">
|
||||
<template v-if="detailPrescription.dosage_amount">
|
||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||
</span>
|
||||
@@ -910,11 +941,12 @@
|
||||
<span class="text-red-500">¥{{ row.amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="82" align="center">
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="payOrderStatusTag(row.status)" size="small">
|
||||
{{ row.status_desc || orderStatusText(row.status) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
||||
@@ -967,11 +999,12 @@
|
||||
<span class="text-red-500">¥{{ row.amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="82" align="center">
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="payOrderStatusTag(row.status)" size="small">
|
||||
{{ row.status_desc || orderStatusText(row.status) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
||||
@@ -1091,7 +1124,8 @@
|
||||
{{ expressCompanyLabel(detailData.express_company) }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="备注" :span="3">{{ detailData.remark_extra || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="备注" :span="3">{{ detailData.remark_extra || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="医助备注" :span="3">{{ detailData.remark_assistant || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处方审核意见" :span="3">
|
||||
<span class="po-audit-remark">{{ detailData.prescription_audit_remark || '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
@@ -1217,6 +1251,47 @@
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog
|
||||
v-model="updateAmountVisible"
|
||||
title="修改订单金额"
|
||||
width="92%"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
@closed="resetUpdateAmountDialog"
|
||||
>
|
||||
<el-form
|
||||
ref="updateAmountFormRef"
|
||||
:model="updateAmountForm"
|
||||
:rules="updateAmountRules"
|
||||
label-width="84px"
|
||||
@submit.prevent="submitUpdateAmount"
|
||||
>
|
||||
<el-form-item label="当前金额">
|
||||
<div class="text-sm text-gray-700 font-medium">¥{{ formatMoney(detailData?.amount) }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="新金额" prop="amount">
|
||||
<el-input-number
|
||||
v-model="updateAmountForm.amount"
|
||||
:min="0.01"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
placeholder="请输入新金额"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="text-xs text-gray-400 pl-[84px] -mt-1 leading-relaxed">
|
||||
已完成或已取消订单不可修改。提交成功后会刷新详情与操作日志。
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="updateAmountVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="updateAmountSaving" @click="submitUpdateAmount">
|
||||
确认修改
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑(分步向导与「创建业务订单」弹窗统一) -->
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
@@ -1563,7 +1638,7 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注">
|
||||
<el-form-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注">
|
||||
<el-input
|
||||
v-model="editForm.remark_extra"
|
||||
type="textarea"
|
||||
@@ -1573,6 +1648,16 @@
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="医助备注">
|
||||
<el-input
|
||||
v-model="editForm.remark_assistant"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="选填,医助对内说明"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
@@ -2006,7 +2091,7 @@
|
||||
|
||||
<!-- A4 处方纸(药房联) -->
|
||||
<div v-if="prescriptionViewData" ref="prescriptionSlipPrintRef" class="rx-paper">
|
||||
<div class="rx-title">处方单</div>
|
||||
<div class="rx-title">{{ SLIP_TITLE }}</div>
|
||||
<div class="rx-notice">
|
||||
<span class="rx-notice-text">
|
||||
服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点
|
||||
@@ -2328,6 +2413,7 @@ import {
|
||||
prescriptionOrderLogisticsTrace,
|
||||
prescriptionOrderShip,
|
||||
prescriptionOrderLogs,
|
||||
prescriptionOrderUpdateAmount,
|
||||
prescriptionOrderAddPayOrder,
|
||||
prescriptionOrderComplete,
|
||||
prescriptionOrderRevokeRxAudit,
|
||||
@@ -2370,9 +2456,39 @@ function openDiagnosisPatientDetailFromOrder() {
|
||||
|
||||
/** 诊间医助角色(与 DiagnosisLists 等一致) */
|
||||
const TCM_ASSISTANT_ROLE_ID = 2
|
||||
/** 下单角色(与 server/config/project.php prescription_order_placer_role_ids 一致) */
|
||||
const ORDER_PLACER_ROLE_ID = 6
|
||||
/** 拥有以下角色时可按任意审核人筛选(与 prescription_order_placer_exempt_role_ids 一致) */
|
||||
const ORDER_PLACER_EXEMPT_ROLE_IDS = [3, 8]
|
||||
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
||||
|
||||
/** 「下单」角色:下单人筛选仅允许查本人审核过的订单 */
|
||||
const isOrderPlacerAuditFilterRestricted = computed(() => {
|
||||
const u = userStore.userInfo
|
||||
if (!u || Number(u.root) === 1) return false
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
if (!ids.includes(ORDER_PLACER_ROLE_ID)) return false
|
||||
return !ORDER_PLACER_EXEMPT_ROLE_IDS.some((rid) => ids.includes(rid))
|
||||
})
|
||||
|
||||
/** 超管 / 经理 / 管理员:可按任意下单人筛选 */
|
||||
const canSearchAnyAuditAdmin = computed(() => {
|
||||
const u = userStore.userInfo
|
||||
if (!u) return false
|
||||
if (Number(u.root) === 1) return true
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
return ORDER_PLACER_EXEMPT_ROLE_IDS.some((rid) => ids.includes(rid))
|
||||
})
|
||||
|
||||
/** 是否展示「下单人」筛选(下单角色 + 经理/管理员/超管) */
|
||||
const showAuditAdminFilter = computed(
|
||||
() => isOrderPlacerAuditFilterRestricted.value || canSearchAnyAuditAdmin.value
|
||||
)
|
||||
|
||||
/** 下单人下拉(lists extend.audit_admin_options,下单角色用户) */
|
||||
const auditAdminOptions = ref<Array<{ id: number; name: string }>>([])
|
||||
|
||||
/**
|
||||
* 是否展示「处方审核」筛选:纯医助(含角色 2 且不具备处方审核角色)仅能用「支付单审核」筛选
|
||||
*/
|
||||
@@ -2603,7 +2719,9 @@ const queryParams = reactive({
|
||||
prescription_audit_status: '' as number | '',
|
||||
payment_slip_audit_status: '' as number | '',
|
||||
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */
|
||||
supply_mode: '' as '' | 'gancao' | 'self'
|
||||
supply_mode: '' as '' | 'gancao' | 'self',
|
||||
/** 下单人(关联操作日志 audit_rx_* / audit_pay_*) */
|
||||
audit_admin_id: '' as number | ''
|
||||
})
|
||||
|
||||
const rxAuditTabs = [
|
||||
@@ -2697,6 +2815,11 @@ async function fetchLists(params: Record<string, unknown>) {
|
||||
}
|
||||
if (!p.order_no) delete p.order_no
|
||||
if (!String(p.patient_keyword || '').trim()) delete p.patient_keyword
|
||||
if (p.audit_admin_id === '' || p.audit_admin_id === undefined || p.audit_admin_id === null) {
|
||||
delete p.audit_admin_id
|
||||
} else {
|
||||
p.audit_admin_id = Number(p.audit_admin_id)
|
||||
}
|
||||
if (!String(p.express_keyword || '').trim()) delete p.express_keyword
|
||||
if (!p.express_company || p.express_company === '') delete p.express_company
|
||||
if (!String(p.start_time || '').trim() || !String(p.end_time || '').trim()) {
|
||||
@@ -2719,6 +2842,24 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
watch(
|
||||
() => (pager.extend as Record<string, unknown> | undefined)?.audit_admin_options,
|
||||
(raw) => {
|
||||
if (!Array.isArray(raw)) return
|
||||
auditAdminOptions.value = raw
|
||||
.map((r: unknown) => {
|
||||
if (r == null || typeof r !== 'object') return null
|
||||
const row = r as Record<string, unknown>
|
||||
const id = Number(row.id)
|
||||
const name = String(row.name ?? '').trim()
|
||||
if (!Number.isFinite(id) || id <= 0 || name === '') return null
|
||||
return { id, name }
|
||||
})
|
||||
.filter((x): x is { id: number; name: string } => x != null)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
/** 选医助部门后,医助下拉仅显示该部门及子部门成员(与后台含子级一致) */
|
||||
const filteredAssistantOptions = computed(() => {
|
||||
const deptRaw = queryParams.assistant_dept_id
|
||||
@@ -2789,6 +2930,9 @@ const listStats = computed(() => {
|
||||
'统计口径:支付单审核白名单角色,按全量订单;与下方筛选项(医生/医助/医助部门/患者等,不含「创建时间」)一致'
|
||||
} else if (scope === 'assistant') {
|
||||
scopeHint = '统计口径:仅计诊单医助为本人且满足下方其它筛选项的订单'
|
||||
} else if (scope === 'own_rx') {
|
||||
scopeHint =
|
||||
'统计口径:本人创建的订单 + 关联处方开方人为本人的订单(含医助代建);与下方筛选项(不含「创建时间」)一致'
|
||||
}
|
||||
const hasCustomTime =
|
||||
String(queryParams.start_time || '').trim() !== '' && String(queryParams.end_time || '').trim() !== ''
|
||||
@@ -2877,6 +3021,7 @@ function handleReset() {
|
||||
queryParams.prescription_audit_status = ''
|
||||
queryParams.payment_slip_audit_status = ''
|
||||
queryParams.supply_mode = ''
|
||||
queryParams.audit_admin_id = ''
|
||||
resetParams()
|
||||
}
|
||||
|
||||
@@ -2974,19 +3119,57 @@ function formatTime(v: unknown) {
|
||||
return String(v)
|
||||
}
|
||||
|
||||
/** 与 server/config/project.php order_edit_all_roles 一致 */
|
||||
const ORDER_EDIT_ALL_ROLE_IDS = [0, 3]
|
||||
|
||||
function canBypassCreatorDualAuditEditLock(): boolean {
|
||||
const u = userStore.userInfo
|
||||
if (!u) return false
|
||||
if (Number(u.root) === 1) return true
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
return ids.some((id) => ORDER_EDIT_ALL_ROLE_IDS.includes(id))
|
||||
}
|
||||
|
||||
function isCurrentUserOrderCreator(row: { creator_id?: number }) {
|
||||
const uid = Number(userStore.userInfo?.id)
|
||||
return uid > 0 && Number(row.creator_id) === uid
|
||||
}
|
||||
|
||||
function isDualAuditPassed(row: {
|
||||
prescription_audit_status?: number
|
||||
payment_slip_audit_status?: number
|
||||
}) {
|
||||
return Number(row.prescription_audit_status) === 1 && Number(row.payment_slip_audit_status) === 1
|
||||
}
|
||||
|
||||
function canEditRow(row: {
|
||||
creator_id?: number
|
||||
fulfillment_status?: number
|
||||
prescription_audit_status?: number
|
||||
payment_slip_audit_status?: number
|
||||
gancao_reciperl_order_no?: string | null
|
||||
gancao_submit_time?: number | null
|
||||
}) {
|
||||
// 已发货(5)、已签收(6)、已完成(3)、已取消(4) 不可编辑
|
||||
const fs = Number(row.fulfillment_status)
|
||||
if (fs !== 1 && fs !== 2) return false
|
||||
// 已成功提交甘草(已生成甘草处方单号 或 提交时间 > 0)不允许再编辑
|
||||
if (fs === 3 || fs === 4) return false
|
||||
|
||||
if (
|
||||
isCurrentUserOrderCreator(row) &&
|
||||
isDualAuditPassed(row) &&
|
||||
!canBypassCreatorDualAuditEditLock()
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||
const gcTime = Number(row.gancao_submit_time || 0)
|
||||
if (gcNo !== '' || gcTime > 0) return false
|
||||
return true
|
||||
const gcLocked = gcNo !== '' || gcTime > 0
|
||||
|
||||
if (gcLocked) {
|
||||
return [1, 2, 5, 7].includes(fs)
|
||||
}
|
||||
|
||||
return fs === 1 || fs === 2
|
||||
}
|
||||
|
||||
function canRxAudit(row: { prescription_audit_status?: number; fulfillment_status?: number }) {
|
||||
@@ -3055,9 +3238,7 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s
|
||||
}
|
||||
|
||||
function canQuickTrackRow(row: { fulfillment_status?: number }) {
|
||||
// 已发货(5) / 已签收(6) 后如需修改快递信息可单独使用此功能
|
||||
const fs = Number(row.fulfillment_status)
|
||||
return fs === 5 || fs === 6
|
||||
return Number(row.fulfillment_status) === 5
|
||||
}
|
||||
|
||||
function canUploadGancaoRow(row: {
|
||||
@@ -3116,6 +3297,12 @@ function handleCardMoreCommand(cmd: string, row: Record<string, any>) {
|
||||
else if (cmd === 'withdraw') confirmWithdraw(row as any)
|
||||
}
|
||||
|
||||
function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | null | undefined) {
|
||||
if (!row?.id) return false
|
||||
const fs = Number(row.fulfillment_status)
|
||||
return fs !== 3 && fs !== 4
|
||||
}
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailData = ref<Record<string, any> | null>(null)
|
||||
@@ -3325,8 +3512,65 @@ const logisticsTraceLoading = ref(false)
|
||||
const logisticsTracePayload = ref<Record<string, any> | null>(null)
|
||||
/** 顺丰/快递100:与运单一致的收件手机后四位(可覆盖订单收货手机) */
|
||||
const logisticsTracePhoneTail = ref('')
|
||||
/** 物流轨迹请求序号:同手机多运单或快速切换订单时,丢弃过期的异步响应 */
|
||||
let logisticsTraceRequestSeq = 0
|
||||
|
||||
type LogisticsTraceRequestContext = {
|
||||
requestSeq: number
|
||||
orderId: number
|
||||
trackingNumber: string
|
||||
}
|
||||
|
||||
function bumpLogisticsTraceRequestToken() {
|
||||
logisticsTraceRequestSeq += 1
|
||||
logisticsTracePayload.value = null
|
||||
}
|
||||
|
||||
function isCurrentLogisticsTraceContext(ctx: LogisticsTraceRequestContext): boolean {
|
||||
if (ctx.requestSeq !== logisticsTraceRequestSeq) return false
|
||||
if (Number(detailData.value?.id) !== ctx.orderId) return false
|
||||
return String(detailData.value?.tracking_number || '').trim() === ctx.trackingNumber
|
||||
}
|
||||
|
||||
function parseLogisticsTracePayload(
|
||||
res: unknown,
|
||||
expectedTrackingNumber: string,
|
||||
expectedOrderId: number
|
||||
): Record<string, any> | null {
|
||||
const raw = (res as { data?: unknown })?.data ?? res
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const payload = raw as Record<string, any>
|
||||
const respNum = String(payload.tracking_number || '').trim()
|
||||
if (respNum && respNum !== expectedTrackingNumber) return null
|
||||
const respOrderId = Number(payload.order_id)
|
||||
if (respOrderId > 0 && respOrderId !== expectedOrderId) return null
|
||||
return payload
|
||||
}
|
||||
|
||||
const detailLogs = ref<any[]>([])
|
||||
const updateAmountVisible = ref(false)
|
||||
const updateAmountSaving = ref(false)
|
||||
const updateAmountFormRef = ref<FormInstance>()
|
||||
const updateAmountForm = reactive({
|
||||
id: 0,
|
||||
amount: undefined as number | undefined
|
||||
})
|
||||
const updateAmountRules: FormRules = {
|
||||
amount: [
|
||||
{ required: true, message: '请输入订单金额', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num) || num <= 0) {
|
||||
callback(new Error('订单金额必须大于0'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function canViewPrescriptionOrderLogs() {
|
||||
const p = userStore.perms || []
|
||||
@@ -3362,6 +3606,7 @@ function logActionText(act: string) {
|
||||
revoke_pay_audit: '撤回支付审核',
|
||||
gancao_submit: '甘草下单',
|
||||
patch_rx_patient: '处方患者信息',
|
||||
update_amount: '修改订单金额',
|
||||
complete: '完成订单'
|
||||
}
|
||||
return m[act] || act
|
||||
@@ -3395,24 +3640,33 @@ function expressCompanyLabel(v: unknown) {
|
||||
}
|
||||
|
||||
async function fetchLogisticsTrace() {
|
||||
const id = Number(detailData.value?.id)
|
||||
if (!id) return
|
||||
const orderId = Number(detailData.value?.id)
|
||||
const trackingNumber = String(detailData.value?.tracking_number || '').trim()
|
||||
if (!orderId || !trackingNumber) return
|
||||
|
||||
const requestSeq = ++logisticsTraceRequestSeq
|
||||
const ctx: LogisticsTraceRequestContext = { requestSeq, orderId, trackingNumber }
|
||||
|
||||
logisticsTraceLoading.value = true
|
||||
try {
|
||||
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
|
||||
const params: { id: number; express_company?: string; phone_tail?: string } = {
|
||||
id,
|
||||
id: orderId,
|
||||
express_company: detailLogisticsExpress.value
|
||||
}
|
||||
if (digits.length >= 4) {
|
||||
params.phone_tail = digits
|
||||
}
|
||||
const res: any = await prescriptionOrderLogisticsTrace(params)
|
||||
logisticsTracePayload.value = (res?.data ?? res) as Record<string, any>
|
||||
if (!isCurrentLogisticsTraceContext(ctx)) return
|
||||
logisticsTracePayload.value = parseLogisticsTracePayload(res, trackingNumber, orderId)
|
||||
} catch {
|
||||
if (!isCurrentLogisticsTraceContext(ctx)) return
|
||||
logisticsTracePayload.value = null
|
||||
} finally {
|
||||
logisticsTraceLoading.value = false
|
||||
if (isCurrentLogisticsTraceContext(ctx)) {
|
||||
logisticsTraceLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3446,6 +3700,24 @@ function resetPatchRxPatientForm() {
|
||||
patchRxPatientForm.phone = ''
|
||||
}
|
||||
|
||||
function resetUpdateAmountDialog() {
|
||||
updateAmountForm.id = 0
|
||||
updateAmountForm.amount = undefined
|
||||
updateAmountFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
function openUpdateAmount() {
|
||||
const row = detailData.value
|
||||
if (!canUpdateAmount(row)) {
|
||||
feedback.msgWarning('当前订单状态不允许修改金额')
|
||||
return
|
||||
}
|
||||
updateAmountForm.id = Number(row?.id) || 0
|
||||
updateAmountForm.amount = Number(row?.amount) || undefined
|
||||
updateAmountVisible.value = true
|
||||
nextTick(() => updateAmountFormRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
async function refreshCurrentPrescriptionOrderDetail() {
|
||||
const id = detailData.value?.id
|
||||
if (!id) return
|
||||
@@ -3485,11 +3757,42 @@ async function submitPatchRxPatient() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUpdateAmount() {
|
||||
const form = updateAmountFormRef.value
|
||||
if (!form) return
|
||||
try {
|
||||
await form.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!canUpdateAmount(detailData.value)) {
|
||||
feedback.msgWarning('当前订单状态不允许修改金额')
|
||||
return
|
||||
}
|
||||
const orderId = updateAmountForm.id
|
||||
updateAmountSaving.value = true
|
||||
try {
|
||||
await prescriptionOrderUpdateAmount({
|
||||
id: orderId,
|
||||
amount: Number(updateAmountForm.amount)
|
||||
})
|
||||
feedback.msgSuccess('修改成功')
|
||||
updateAmountVisible.value = false
|
||||
await refreshCurrentPrescriptionOrderDetail()
|
||||
await fetchLogs(orderId)
|
||||
getLists()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
updateAmountSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(id: number) {
|
||||
// 修复 Bug:显式彻底清空缓存,防止前一次弹窗的数据残留
|
||||
detailData.value = null
|
||||
detailUnlinkedPayOrders.value = []
|
||||
logisticsTracePayload.value = null
|
||||
bumpLogisticsTraceRequestToken()
|
||||
detailLogisticsExpress.value = 'auto'
|
||||
logisticsTracePhoneTail.value = ''
|
||||
detailLogs.value = []
|
||||
@@ -3505,7 +3808,7 @@ async function openDetail(id: number) {
|
||||
const dig = String(d.recipient_phone || '').replace(/\D/g, '')
|
||||
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
|
||||
if (String(d.tracking_number || '').trim()) {
|
||||
fetchLogisticsTrace()
|
||||
void fetchLogisticsTrace()
|
||||
}
|
||||
fetchLogs(id)
|
||||
|
||||
@@ -3572,6 +3875,7 @@ const editForm = reactive({
|
||||
fee_type: 3,
|
||||
amount: 0,
|
||||
remark_extra: '',
|
||||
remark_assistant: '',
|
||||
pay_order_ids: [] as number[],
|
||||
internal_cost: undefined as number | string | undefined,
|
||||
/** 关联诊单医助 id(只读,来自订单详情;列表筛选用) */
|
||||
@@ -3734,7 +4038,25 @@ async function goEditOrderNextStep() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openEdit(row: { id: number }) {
|
||||
async function openEdit(row: {
|
||||
id: number
|
||||
creator_id?: number
|
||||
prescription_audit_status?: number
|
||||
payment_slip_audit_status?: number
|
||||
fulfillment_status?: number
|
||||
gancao_reciperl_order_no?: string | null
|
||||
gancao_submit_time?: number | null
|
||||
}) {
|
||||
if (!canEditRow(row)) {
|
||||
if (
|
||||
isCurrentUserOrderCreator(row) &&
|
||||
isDualAuditPassed(row) &&
|
||||
!canBypassCreatorDualAuditEditLock()
|
||||
) {
|
||||
feedback.msgWarning('处方与支付单均已审核通过,创建人不可再编辑')
|
||||
}
|
||||
return
|
||||
}
|
||||
editOrderStep.value = 0
|
||||
editOrderPrescription.value = null
|
||||
editVisible.value = true
|
||||
@@ -3822,6 +4144,7 @@ async function openEdit(row: { id: number }) {
|
||||
|
||||
editForm.amount = amt
|
||||
editForm.remark_extra = d.remark_extra || ''
|
||||
editForm.remark_assistant = d.remark_assistant || ''
|
||||
const pids = d.pay_order_ids
|
||||
editForm.pay_order_ids = Array.isArray(pids) ? pids.map((x: unknown) => Number(x)).filter((n) => !Number.isNaN(n)) : []
|
||||
editForm.internal_cost =
|
||||
@@ -3860,6 +4183,7 @@ async function submitEdit() {
|
||||
fee_type: editForm.fee_type,
|
||||
amount: editForm.amount,
|
||||
remark_extra: editForm.remark_extra || '',
|
||||
remark_assistant: editForm.remark_assistant || '',
|
||||
|
||||
// BUG FIX: 修复之前无法清空部分选填字段的问题
|
||||
medication_days: (editForm.medication_days != null && String(editForm.medication_days).trim() !== '') ? editForm.medication_days : '',
|
||||
@@ -4139,6 +4463,7 @@ async function submitQuickTrack() {
|
||||
fee_type: Number(d.fee_type) || 3,
|
||||
amount: Number(d.amount) || 0,
|
||||
remark_extra: d.remark_extra || '',
|
||||
remark_assistant: d.remark_assistant || '',
|
||||
internal_cost: (d.internal_cost != null && d.internal_cost !== '') ? d.internal_cost : '',
|
||||
pay_order_ids: Array.isArray(d.pay_order_ids) ? d.pay_order_ids : [],
|
||||
// 仅覆盖快递字段
|
||||
@@ -4156,8 +4481,8 @@ async function submitQuickTrack() {
|
||||
if (nd) detailData.value = nd
|
||||
} catch { /* 静默 */ }
|
||||
}
|
||||
// 履约中状态且刚填单号,提示是否立即发货
|
||||
if (canShipRow({ fulfillment_status: 2, tracking_number: quickTrackForm.tracking_number })) {
|
||||
// 仅保存前为履约中(2)时才询问确认发货;已发货(5)/已签收(6)等修改单号不再弹窗
|
||||
if (canShipRow({ fulfillment_status: Number(d.fulfillment_status) })) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`快递单号「${quickTrackForm.tracking_number}」已保存,是否立即确认发货?`,
|
||||
@@ -4459,6 +4784,7 @@ async function confirmRevokePayAudit(row: { id: number }) {
|
||||
// ─── 查看处方详情(处方单样式)────────────────────────────────────────────
|
||||
/** 处方笺抬头/页脚(与诊间打印一致) */
|
||||
const SLIP_HOSPITAL_NAME = '成都双流甄养堂互联网医院'
|
||||
const SLIP_TITLE = `${SLIP_HOSPITAL_NAME} 处方笺`
|
||||
const SLIP_COMPANY_LINE = '成都双流甄养堂互联网医院有限公司 联系方式:4001667339'
|
||||
const SLIP_ADDRESS_LINE = '地址:四川省成都市双流区黄甲街道黄龙大道二段280号'
|
||||
|
||||
@@ -4492,19 +4818,21 @@ const slipPerBagDosageText = computed(() => {
|
||||
const d = prescriptionViewData.value
|
||||
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return '—'
|
||||
if (d.dosage_amount == null || d.dosage_amount === '') return '—'
|
||||
return `${d.dosage_amount}${d.dosage_unit || 'g'}`
|
||||
const bags = Number(d.dosage_bag_count) > 0 ? Number(d.dosage_bag_count) : 1
|
||||
return `${d.dosage_amount}${d.dosage_unit || 'g'} · ${bags}袋`
|
||||
})
|
||||
|
||||
const slipPillGrams = computed(() => {
|
||||
const d = prescriptionViewData.value
|
||||
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return null
|
||||
/** 用药疗程以业务订单 medication_days 为准,缺省时回退处方 usage_days / 剂数 */
|
||||
/** 用药疗程以业务订单 medication_days 为准,缺省时回退处方 usage_days / 剂数;出丸 = 每袋用量×袋数×每天次数×服用天数 */
|
||||
const days = Number(d.medication_days ?? d.usage_days ?? d.dose_count)
|
||||
const times = Number(d.times_per_day)
|
||||
const doseG = Number(d.dosage_amount)
|
||||
const bags = Number(d.dosage_bag_count) > 0 ? Number(d.dosage_bag_count) : 1
|
||||
if (!Number.isFinite(days) || !Number.isFinite(times) || !Number.isFinite(doseG)) return null
|
||||
if (days <= 0 || times <= 0 || doseG <= 0) return null
|
||||
return days * times * doseG
|
||||
return days * times * doseG * bags
|
||||
})
|
||||
|
||||
/* ============================================================
|
||||
@@ -4597,21 +4925,25 @@ const rxUsageText = computed(() => {
|
||||
if (!v) return '—'
|
||||
if (v.usage_text) return v.usage_text
|
||||
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
|
||||
const bags = Number(v.bags_per_dose) > 0 ? Number(v.bags_per_dose) : 1
|
||||
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
|
||||
const unit = v.dosage_unit || 'g'
|
||||
const usageWay = v.usage_way || '温水送服'
|
||||
const usageTime = v.usage_time || ''
|
||||
const seg: string[] = []
|
||||
seg.push(`每天${times}次`)
|
||||
seg.push(`一次${bags}包(约${amount}${unit})`)
|
||||
seg.push(`每包${amount}${unit}`)
|
||||
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
|
||||
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
|
||||
seg.push(`一次${bags}袋`)
|
||||
seg.push(`每袋${amount}${unit}`)
|
||||
} else {
|
||||
seg.push(`一次${amount}${unit}`)
|
||||
}
|
||||
seg.push(usageWay)
|
||||
if (usageTime) seg.push(usageTime)
|
||||
return seg.join(', ')
|
||||
})
|
||||
|
||||
/** 出丸:优先用 slipPillGrams(按医嘱天数·次数·每包克数),否则按药材总量×剂数 */
|
||||
/** 出丸:优先用 slipPillGrams(每袋用量×袋数×每天次数×服用天数),否则按药材总量×剂数 */
|
||||
const rxOutPelletGrams = computed(() => {
|
||||
const pill = slipPillGrams.value
|
||||
if (typeof pill === 'number' && isFinite(pill) && pill > 0) return formatNum(pill)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -102,6 +102,18 @@
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="添加时间">
|
||||
<el-date-picker
|
||||
v-model="addTimeRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
clearable
|
||||
class="!w-[280px]"
|
||||
@change="onAddTimeRangeChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="标签">
|
||||
<el-select
|
||||
v-model="queryParams.tag_ids"
|
||||
@@ -557,12 +569,34 @@ const syncSettings = reactive({
|
||||
interval: 3600
|
||||
})
|
||||
|
||||
const queryParams = reactive<{ name: string; follow_user: string; tag_ids: string[] }>({
|
||||
const queryParams = reactive<{
|
||||
name: string
|
||||
follow_user: string
|
||||
tag_ids: string[]
|
||||
add_time_start: string
|
||||
add_time_end: string
|
||||
}>({
|
||||
name: '',
|
||||
follow_user: '',
|
||||
tag_ids: []
|
||||
tag_ids: [],
|
||||
add_time_start: '',
|
||||
add_time_end: ''
|
||||
})
|
||||
|
||||
/** 与列表「添加时间」列口径一致(库内 external_first_add_time / create_time) */
|
||||
const addTimeRange = ref<[string, string] | null>(null)
|
||||
|
||||
function onAddTimeRangeChange(val: [string, string] | null) {
|
||||
if (val && val.length === 2 && val[0] && val[1]) {
|
||||
queryParams.add_time_start = val[0]
|
||||
queryParams.add_time_end = val[1]
|
||||
} else {
|
||||
queryParams.add_time_start = ''
|
||||
queryParams.add_time_end = ''
|
||||
}
|
||||
resetPage()
|
||||
}
|
||||
|
||||
// ── 标签维度(筛选下拉 + 抽屉面板共用同一份数据) ──────────────────────────
|
||||
interface TagItem {
|
||||
tag_id: string
|
||||
@@ -793,6 +827,9 @@ function handleReset() {
|
||||
queryParams.name = ''
|
||||
queryParams.follow_user = ''
|
||||
queryParams.tag_ids = []
|
||||
queryParams.add_time_start = ''
|
||||
queryParams.add_time_end = ''
|
||||
addTimeRange.value = null
|
||||
resetParams()
|
||||
}
|
||||
|
||||
|
||||
+4078
-356
File diff suppressed because it is too large
Load Diff
@@ -22,16 +22,25 @@
|
||||
<el-select
|
||||
v-model="formData.media_channel_code"
|
||||
placeholder="请选择自媒体渠道"
|
||||
class="w-full"
|
||||
class="account-cost-channel-select w-full"
|
||||
filterable
|
||||
:disabled="mode === 'edit'"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in mediaChannelOptions"
|
||||
:key="item.code"
|
||||
:label="item.name"
|
||||
:value="item.code"
|
||||
/>
|
||||
<el-option-group
|
||||
v-for="g in mediaChannelGroups"
|
||||
:key="g.group_name"
|
||||
:label="g.group_name"
|
||||
>
|
||||
<el-option
|
||||
v-for="ch in g.channels"
|
||||
:key="ch.channel_code"
|
||||
:label="ch.channel_name"
|
||||
:value="ch.channel_code"
|
||||
>
|
||||
<div class="channel-opt-row">
|
||||
<span class="opt-name">{{ ch.channel_name }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="部门" prop="dept_id">
|
||||
@@ -78,9 +87,9 @@ import type { FormInstance } from 'element-plus'
|
||||
import { accountCostAdd, accountCostDetail, accountCostEdit } from '@/api/finance'
|
||||
import Popup from '@/components/popup/index.vue'
|
||||
|
||||
interface MediaChannelOption {
|
||||
code: string
|
||||
name: string
|
||||
interface MediaChannelGroup {
|
||||
group_name: string
|
||||
channels: { channel_code: string; channel_name: string }[]
|
||||
}
|
||||
|
||||
interface DeptOption {
|
||||
@@ -90,7 +99,7 @@ interface DeptOption {
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
mediaChannelOptions: MediaChannelOption[]
|
||||
mediaChannelGroups: MediaChannelGroup[]
|
||||
deptOptions: DeptOption[]
|
||||
defaultMediaChannelCode: string
|
||||
}>()
|
||||
@@ -197,3 +206,21 @@ defineExpose({
|
||||
getDetail,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.account-cost-channel-select {
|
||||
:deep(.channel-opt-row) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-right: 4px;
|
||||
}
|
||||
:deep(.opt-name) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,14 +27,25 @@
|
||||
placeholder="筛选全部渠道"
|
||||
clearable
|
||||
filterable
|
||||
class="w-[220px]"
|
||||
class="account-cost-channel-select w-[220px]"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in mediaChannelOptions"
|
||||
:key="item.code"
|
||||
:label="item.name"
|
||||
:value="item.code"
|
||||
/>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option-group
|
||||
v-for="g in mediaChannelGroups"
|
||||
:key="g.group_name"
|
||||
:label="g.group_name"
|
||||
>
|
||||
<el-option
|
||||
v-for="ch in g.channels"
|
||||
:key="ch.channel_code"
|
||||
:label="ch.channel_name"
|
||||
:value="ch.channel_code"
|
||||
>
|
||||
<div class="channel-opt-row">
|
||||
<span class="opt-name">{{ ch.channel_name }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="部门">
|
||||
@@ -78,7 +89,15 @@
|
||||
|
||||
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
|
||||
<el-table-column label="日期" prop="cost_date" min-width="120" />
|
||||
<el-table-column label="自媒体渠道" prop="media_channel_name" min-width="120" />
|
||||
<el-table-column label="自媒体渠道" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span v-if="getChannelGroupLabel(row.media_channel_code)">
|
||||
<el-tag size="small" class="mr-1">{{ getChannelGroupLabel(row.media_channel_code) }}</el-tag>
|
||||
{{ row.media_channel_name }}
|
||||
</span>
|
||||
<span v-else>{{ row.media_channel_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="部门" prop="dept_name" min-width="140" />
|
||||
<el-table-column label="账户消耗" min-width="120">
|
||||
<template #default="{ row }">¥{{ row.amount }}</template>
|
||||
@@ -117,7 +136,7 @@
|
||||
<edit-popup
|
||||
v-if="showEdit"
|
||||
ref="editRef"
|
||||
:media-channel-options="mediaChannelOptions"
|
||||
:media-channel-groups="mediaChannelGroups"
|
||||
:dept-options="deptOptions"
|
||||
:default-media-channel-code="defaultMediaChannelCode"
|
||||
@success="getLists"
|
||||
@@ -133,6 +152,11 @@ import feedback from '@/utils/feedback'
|
||||
|
||||
import EditPopup from './edit.vue'
|
||||
|
||||
interface MediaChannelGroup {
|
||||
group_name: string
|
||||
channels: { channel_code: string; channel_name: string }[]
|
||||
}
|
||||
|
||||
interface MediaChannelOption {
|
||||
code: string
|
||||
name: string
|
||||
@@ -160,7 +184,7 @@ const { pager, getLists, resetPage } = usePaging({
|
||||
params: queryParams,
|
||||
})
|
||||
|
||||
const mediaChannelOptions = computed<MediaChannelOption[]>(() => {
|
||||
const mediaChannelOptionsFlat = computed<MediaChannelOption[]>(() => {
|
||||
const options = pager.extend.media_channel_options
|
||||
if (!Array.isArray(options)) return []
|
||||
|
||||
@@ -170,6 +194,26 @@ const mediaChannelOptions = computed<MediaChannelOption[]>(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
const mediaChannelGroups = computed<MediaChannelGroup[]>(() => {
|
||||
const groups = pager.extend.media_channel_groups
|
||||
if (Array.isArray(groups) && groups.length > 0) {
|
||||
return groups as MediaChannelGroup[]
|
||||
}
|
||||
const flat = mediaChannelOptionsFlat.value
|
||||
if (!flat.length) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
group_name: '渠道',
|
||||
channels: flat.map(item => ({
|
||||
channel_code: item.code,
|
||||
channel_name: item.name,
|
||||
})),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const deptOptions = computed<DeptOption[]>(() => {
|
||||
const options = pager.extend.dept_options
|
||||
return Array.isArray(options) ? options : []
|
||||
@@ -177,6 +221,17 @@ const deptOptions = computed<DeptOption[]>(() => {
|
||||
|
||||
const defaultMediaChannelCode = computed(() => String(pager.extend.default_media_channel_code || ''))
|
||||
|
||||
const getChannelGroupLabel = (channelCode: string): string => {
|
||||
if (!channelCode) return ''
|
||||
for (const group of mediaChannelGroups.value) {
|
||||
const channel = group.channels.find(ch => ch.channel_code === channelCode)
|
||||
if (channel) {
|
||||
return group.group_name
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const treeProps = {
|
||||
value: 'id',
|
||||
label: 'name',
|
||||
@@ -213,3 +268,21 @@ const handleReset = () => {
|
||||
|
||||
getLists()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.account-cost-channel-select {
|
||||
:deep(.channel-opt-row) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-right: 4px;
|
||||
}
|
||||
:deep(.opt-name) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="toolbar__title">制定业绩目标</div>
|
||||
<div class="toolbar__desc">按部门设置每月销售目标,填 0 并保存将删除该记录</div>
|
||||
<div class="toolbar__desc">
|
||||
按部门设置每月销售目标,填 0 并保存将删除该记录。列表按当前账号
|
||||
<strong>角色数据权限</strong>
|
||||
仅展示<strong>本部门及下级</strong>(不展示权限外的上级组织;口径与业绩看板数据范围一致)。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar__center">
|
||||
@@ -59,8 +63,16 @@
|
||||
|
||||
<div v-if="!loading && formRows.length === 0" class="empty-state">
|
||||
<div class="empty-state__icon"><el-icon><OfficeBuilding /></el-icon></div>
|
||||
<p class="empty-state__title">暂无部门数据</p>
|
||||
<p class="empty-state__desc">请检查部门配置后重试</p>
|
||||
<p class="empty-state__title">
|
||||
{{ dataScopeLimited ? '数据权限内无可选部门' : '暂无部门数据' }}
|
||||
</p>
|
||||
<p class="empty-state__desc">
|
||||
{{
|
||||
dataScopeLimited
|
||||
? '当前角色数据范围未包含任何部门,或需在权限中绑定部门后再试。'
|
||||
: '请检查部门配置后重试'
|
||||
}}
|
||||
</p>
|
||||
<el-button size="small" @click="loadMatrix"><el-icon><Refresh /></el-icon>重新加载</el-button>
|
||||
</div>
|
||||
|
||||
@@ -161,6 +173,8 @@ const yearMonth = ref<string>(ymNow())
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const formRows = ref<Row[]>([])
|
||||
/** 接口返回:当前账号是否受数据权限收窄(非「全部」等) */
|
||||
const dataScopeLimited = ref(false)
|
||||
|
||||
// 钻取卡片栈:每次 drill-in 追加,close 时移除及其后续
|
||||
const drillStack = ref<Row[]>([])
|
||||
@@ -199,8 +213,10 @@ async function loadMatrix() {
|
||||
try {
|
||||
const res: any = await deptPerformanceTargetMonthMatrix({ year_month: yearMonth.value })
|
||||
formRows.value = ((res?.rows || []) as Record<string, any>[]).map(normalizeRow)
|
||||
dataScopeLimited.value = Boolean(res?.data_scope_limited)
|
||||
} catch {
|
||||
formRows.value = []
|
||||
dataScopeLimited.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -217,6 +233,7 @@ async function handleSave() {
|
||||
})
|
||||
if (res?.rows) {
|
||||
formRows.value = (res.rows as Record<string, any>[]).map(normalizeRow)
|
||||
dataScopeLimited.value = Boolean(res?.data_scope_limited)
|
||||
drillStack.value = []
|
||||
drilledId.value = null
|
||||
} else {
|
||||
|
||||
@@ -180,11 +180,12 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="订单状态" width="100">
|
||||
<el-table-column label="订单状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusTag(row.status)">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -270,6 +271,14 @@
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['order.order/setExempt']"
|
||||
:type="row.is_exempt === 1 ? 'warning' : 'default'"
|
||||
link
|
||||
@click="handleToggleExempt(row)"
|
||||
>
|
||||
{{ row.is_exempt === 1 ? '取消豁免' : '设为豁免' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -779,7 +788,8 @@ import {
|
||||
orderActionLogs,
|
||||
orderActionLogStats,
|
||||
orderBatchAssignAssistant,
|
||||
orderSplit
|
||||
orderSplit,
|
||||
orderSetExempt
|
||||
} from '@/api/order'
|
||||
import { getAssistants } from '@/api/tcm'
|
||||
import { generateOrderQrcode } from '@/api/tcm'
|
||||
@@ -1494,6 +1504,19 @@ const handleDelete = async (row: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleExempt = async (row: any) => {
|
||||
const newVal: 0 | 1 = row.is_exempt === 1 ? 0 : 1
|
||||
const actionText = newVal === 1 ? '设为豁免' : '取消豁免'
|
||||
try {
|
||||
await feedback.confirm(`确定要${actionText}该支付单吗?豁免后关联此单的业务订单将跳过定金门槛校验。`)
|
||||
await orderSetExempt({ id: row.id, is_exempt: newVal })
|
||||
feedback.msgSuccess(`${actionText}成功`)
|
||||
getLists()
|
||||
} catch (error) {
|
||||
// 用户取消操作
|
||||
}
|
||||
}
|
||||
|
||||
// 小程序码相关
|
||||
const qrcodeDialogVisible = ref(false)
|
||||
const qrcodeLoading = ref(false)
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
:src="getImageUrl(url)"
|
||||
:preview-src-list="note.tongue_images.map(getImageUrl)"
|
||||
:initial-index="idx"
|
||||
:z-index="IMAGE_PREVIEW_Z_INDEX"
|
||||
fit="cover"
|
||||
class="timeline-thumb"
|
||||
preview-teleported
|
||||
@@ -66,6 +67,7 @@
|
||||
:src="getImageUrl(url)"
|
||||
:preview-src-list="getImagePreviewList(note.report_files)"
|
||||
:initial-index="getImagePreviewIndex(note.report_files, idx)"
|
||||
:z-index="IMAGE_PREVIEW_Z_INDEX"
|
||||
fit="cover"
|
||||
class="timeline-thumb"
|
||||
preview-teleported
|
||||
@@ -128,6 +130,9 @@ const reportModel = ref<string[]>([])
|
||||
const prevTongueCount = ref(0)
|
||||
const prevReportCount = ref(0)
|
||||
|
||||
/** 高于诊单抽屉(最高 4000)与多层弹窗叠加后的遮罩,避免预览被挡住 */
|
||||
const IMAGE_PREVIEW_Z_INDEX = 8000
|
||||
|
||||
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']
|
||||
|
||||
const isImageFile = (url: string): boolean => {
|
||||
@@ -211,6 +216,7 @@ const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'r
|
||||
<style scoped lang="scss">
|
||||
.timeline-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
|
||||
@@ -111,126 +111,10 @@
|
||||
|
||||
<template v-else-if="detail">
|
||||
<!-- 患者信息 -->
|
||||
<div class="card patient-card">
|
||||
<div class="card-title">患者信息</div>
|
||||
<div class="patient-hero">
|
||||
<div class="patient-name">{{ apt.patient_name || '—' }}</div>
|
||||
<div class="patient-meta">
|
||||
<div>{{ maskPhone(apt.patient_phone) }} · {{ formatGender(diag.gender) }} · {{ diag.age != null ? diag.age + '岁' : '—' }}</div>
|
||||
<div>{{ diag.height ? diag.height + 'cm' : '—' }} / {{ diag.weight ? diag.weight + 'kg' : '—' }} · {{ diag.region || '—' }}</div>
|
||||
<div>预约:{{ apt.appointment_date }} {{ apt.appointment_time }} · {{ formatPeriod(apt.period) }}</div>
|
||||
<div>医生:{{ apt.doctor_name || '—' }} 医助:{{ apt.assistant_name || '—' }}</div>
|
||||
<div>状态:{{ apt.status_desc || '—' }} · {{ apt.has_prescription ? '已开方' : '未开方' }}</div>
|
||||
<div v-if="apt.remark">备注:{{ apt.remark }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PatientInfoCard :apt="apt" :diag="diag" />
|
||||
|
||||
<!-- 患者病例(完整、字段与 tcm/diagnosis/edit.vue 保持同步,只读展示) -->
|
||||
<div class="card case-card">
|
||||
<div class="card-title">
|
||||
患者病例
|
||||
<span class="case-sub">{{ caseTypeLabel }} · 诊断日期 {{ diag.diagnosis_date || apt.appointment_date || '—' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">基本信息</div>
|
||||
<div class="kv-grid four">
|
||||
<KVItem label="诊单ID" :value="diag.patient_id" />
|
||||
<KVItem label="姓名" :value="diag.patient_name" />
|
||||
<KVItem label="身份证号" :value="diag.id_card" />
|
||||
<KVItem label="手机号" :value="maskPhone(diag.phone || apt.patient_phone)" />
|
||||
<KVItem label="性别" :value="textOf('gender')" />
|
||||
<KVItem label="年龄" :value="diag.age != null ? diag.age + '岁' : ''" />
|
||||
<KVItem label="婚姻状态" :value="textOf('marital_status')" />
|
||||
<KVItem label="地区" :value="diag.region" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生命体征 -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">生命体征</div>
|
||||
<div class="kv-grid four">
|
||||
<KVItem label="身高" :value="diag.height ? diag.height + ' cm' : ''" />
|
||||
<KVItem label="体重" :value="diag.weight ? diag.weight + ' kg' : ''" />
|
||||
<MetricKVItem label="高压" :value="diag.systolic_pressure" unit=" mmHg" :high="isHighSystolicPressure(diag.systolic_pressure)" />
|
||||
<MetricKVItem label="低压" :value="diag.diastolic_pressure" unit=" mmHg" :high="isHighDiastolicPressure(diag.diastolic_pressure)" />
|
||||
<KVItem label="诊断类型" :value="textOf('diagnosis_type')" />
|
||||
<MetricKVItem label="空腹血糖" :value="diag.fasting_blood_sugar" unit=" mmol/L" :high="isHighFastingBloodSugar(diag.fasting_blood_sugar, diag.age)" />
|
||||
<KVItem label="在用药物" :value="diag.current_medications" multiline />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主诉 -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">主诉</div>
|
||||
<div class="kv-grid four">
|
||||
<KVItem label="当地医院诊断日期" :value="diag.diagnosis_date" />
|
||||
<KVItem label="发现糖尿病病史" :value="formatDiabetesDiscoveryDisplay ? formatDiabetesDiscoveryDisplay(diag.diabetes_discovery_year) : diag.diabetes_discovery_year" />
|
||||
<KVItem label="当地就诊医院" :value="diag.local_hospital_name" />
|
||||
<KVItem label="当地医院诊断结果" :value="joinArray(diag.local_hospital_diagnosis)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 现病史(后端已翻译 *_text) -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">现病史</div>
|
||||
<div class="kv-grid three">
|
||||
<KVItem label="口腔感觉" :value="textOf('appetite')" />
|
||||
<KVItem label="每日饮水量" :value="textOf('water_intake')" />
|
||||
<KVItem label="近月体重变化" :value="textOf('weight_change')" />
|
||||
<KVItem label="脂肪肝程度" :value="textOf('fatty_liver_degree')" />
|
||||
<KVItem label="饮食情况" :value="textOf('diet_condition')" span="2" />
|
||||
<KVItem label="肢体感觉" :value="textOf('body_feeling')" />
|
||||
<KVItem label="睡眠情况" :value="textOf('sleep_condition')" />
|
||||
<KVItem label="眼睛情况" :value="textOf('eye_condition')" />
|
||||
<KVItem label="头部感觉" :value="textOf('head_feeling')" />
|
||||
<KVItem label="出汗情况" :value="textOf('sweat_condition')" />
|
||||
<KVItem label="皮肤情况" :value="textOf('skin_condition')" />
|
||||
<KVItem label="小便情况" :value="textOf('urine_condition')" />
|
||||
<KVItem label="大便情况" :value="textOf('stool_condition')" />
|
||||
<KVItem label="腰肾情况" :value="textOf('kidney_condition')" />
|
||||
<KVItem label="其他补充" :value="diag.symptoms" span="3" multiline />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 既往史 -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">既往史</div>
|
||||
<div class="kv-grid three">
|
||||
<KVItem label="既往病史" :value="textOf('past_history')" span="3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 其他病史 -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">其他病史</div>
|
||||
<div class="kv-grid three">
|
||||
<KVItem label="外伤史" :value="textOf('trauma_history')" />
|
||||
<KVItem label="手术史" :value="textOf('surgery_history')" />
|
||||
<KVItem label="过敏史" :value="textOf('allergy_history')" />
|
||||
<KVItem label="家族病史" :value="textOf('family_history')" />
|
||||
<KVItem label="妊娠哺乳史" :value="textOf('pregnancy_history')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 病史补充 -->
|
||||
<div class="grid-block" v-if="diag.remark">
|
||||
<div class="grid-title">病史补充</div>
|
||||
<div class="kv-grid one">
|
||||
<KVItem label="" :value="diag.remark" multiline />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 处方意见 -->
|
||||
<div class="grid-block">
|
||||
<div class="grid-title">处方意见</div>
|
||||
<div class="rx-note">
|
||||
{{ apt.has_prescription ? '已开方。' : '当前未开方。' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 患者病例(与 tcm/diagnosis/readonly 共用同一组件,确保两处字段一致) -->
|
||||
<PatientCaseCard :apt="apt" :diag="diag" />
|
||||
|
||||
<!-- 医生备注 & 舌苔照片 -->
|
||||
<div class="card note-card">
|
||||
@@ -242,65 +126,16 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 血糖血压记录 -->
|
||||
<!-- 日常记录(只读,与只读病例页同口径) -->
|
||||
<div class="card metric-card">
|
||||
<div class="card-title">血糖血压记录</div>
|
||||
<div class="metric-viewport">
|
||||
<el-table
|
||||
:data="bloodRecords"
|
||||
size="small"
|
||||
stripe
|
||||
empty-text="暂无血糖血压记录"
|
||||
class="metric-table"
|
||||
max-height="360"
|
||||
>
|
||||
<el-table-column prop="record_date" label="记录日期" width="110" />
|
||||
<el-table-column prop="record_time" label="记录时间" width="100">
|
||||
<template #default="{ row }">{{ row.record_time || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="空腹血糖(mmol/L)" width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="metric-value" :class="{ high: isHighFastingBloodSugar(row.fasting_blood_sugar, diag.age) }">
|
||||
{{ row.fasting_blood_sugar ?? '—' }}
|
||||
<span v-if="isHighFastingBloodSugar(row.fasting_blood_sugar, diag.age)" class="metric-up">↑</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="餐后2小时血糖(mmol/L)" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="metric-value" :class="{ high: isHighPostprandialBloodSugar(row.postprandial_blood_sugar, diag.age) }">
|
||||
{{ row.postprandial_blood_sugar ?? '—' }}
|
||||
<span v-if="isHighPostprandialBloodSugar(row.postprandial_blood_sugar, diag.age)" class="metric-up">↑</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="其他血糖(mmol/L)" width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="metric-value" :class="{ high: isHighOtherBloodSugar(row.other_blood_sugar, diag.age) }">
|
||||
{{ row.other_blood_sugar ?? '—' }}
|
||||
<span v-if="isHighOtherBloodSugar(row.other_blood_sugar, diag.age)" class="metric-up">↑</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="血压(mmHg)" width="130">
|
||||
<template #default="{ row }">
|
||||
<span class="metric-value" :class="{ high: isHighBloodPressure(row) }">
|
||||
{{ formatBp(row) }}
|
||||
<span v-if="isHighBloodPressure(row)" class="metric-up">↑</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="西药" width="120">
|
||||
<template #default="{ row }">{{ row.western_medicine || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="胰岛素" width="120">
|
||||
<template #default="{ row }">{{ row.insulin || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="160">
|
||||
<template #default="{ row }">{{ row.remark || '—' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="card-title">日常记录</div>
|
||||
<DailyMatrix
|
||||
:diagnosis-id="Number(diag?.id ?? 0)"
|
||||
:patient-id="Number(diag?.patient_id ?? 0)"
|
||||
:age="diag?.age ?? null"
|
||||
:patient-name="diag?.patient_name || ''"
|
||||
:read-only="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
@@ -317,7 +152,11 @@
|
||||
>
|
||||
备注
|
||||
</el-button>
|
||||
<el-button class="float-action edit" size="large" @click="handleEditDiagnosis">
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/edit']"
|
||||
class="float-action edit"
|
||||
size="large"
|
||||
@click="handleEditDiagnosis">
|
||||
编辑病历
|
||||
</el-button>
|
||||
<el-button
|
||||
@@ -349,13 +188,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, onMounted, onUnmounted, h } from 'vue'
|
||||
import { defineAsyncComponent, onMounted, onUnmounted } from 'vue'
|
||||
import { receptionQueue, receptionDetail, notifyAssistant, addDoctorNote } from '@/api/patient'
|
||||
import NoteTimeline from './components/NoteTimeline.vue'
|
||||
import PatientInfoCard from '@/views/tcm/diagnosis/components/PatientInfoCard.vue'
|
||||
import PatientCaseCard from '@/views/tcm/diagnosis/components/PatientCaseCard.vue'
|
||||
import DailyMatrix from '@/views/tcm/diagnosis/components/DailyMatrix.vue'
|
||||
import { completeAppointment } from '@/api/doctor'
|
||||
import { getCallSignature } from '@/api/tcm'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
|
||||
import { formatGender } from '@/utils/diag-display'
|
||||
import {
|
||||
Phone,
|
||||
Search,
|
||||
@@ -366,55 +209,6 @@ import {
|
||||
const ChatDialog = defineAsyncComponent(() => import('@/components/chat-dialog/index.vue'))
|
||||
const DiagnosisEdit = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||
|
||||
/**
|
||||
* 局部渲染组件:左侧 label + 右侧 value,支持 span / 换行
|
||||
*/
|
||||
const KVItem = (props: { label: string; value: any; span?: string | number; multiline?: boolean }) => {
|
||||
const val = props.value
|
||||
const empty = val === undefined || val === null || val === '' || (Array.isArray(val) && val.length === 0)
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: ['kv-item', props.multiline ? 'multiline' : '', empty ? 'empty' : ''],
|
||||
style: props.span ? `grid-column: span ${props.span}` : ''
|
||||
},
|
||||
[
|
||||
h('span', { class: 'kv-label' }, props.label),
|
||||
h('span', { class: 'kv-value' }, empty ? '—' : String(val))
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const MetricKVItem = (props: {
|
||||
label: string
|
||||
value: any
|
||||
unit?: string
|
||||
span?: string | number
|
||||
high?: boolean
|
||||
}) => {
|
||||
const empty = props.value === undefined || props.value === null || props.value === ''
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: ['kv-item', 'metric-kv-item', empty ? 'empty' : ''],
|
||||
style: props.span ? `grid-column: span ${props.span}` : ''
|
||||
},
|
||||
[
|
||||
h('span', { class: 'kv-label' }, props.label),
|
||||
h(
|
||||
'span',
|
||||
{ class: ['kv-value', 'metric-kv-value', props.high ? 'high' : ''] },
|
||||
empty
|
||||
? '—'
|
||||
: [
|
||||
`${props.value}${props.unit || ''}`,
|
||||
props.high ? h('span', { class: 'metric-up' }, '↑') : null
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const POLL_MS = 5_000
|
||||
const PAGE_SIZE = 15
|
||||
|
||||
@@ -474,9 +268,7 @@ const noteSaving = ref(false)
|
||||
|
||||
const apt = computed<any>(() => detail.value?.appointment || {})
|
||||
const diag = computed<any>(() => detail.value?.diagnosis || {})
|
||||
const bloodRecords = computed<any[]>(() => detail.value?.blood_records || [])
|
||||
const doctorNotes = computed<any[]>(() => detail.value?.doctor_notes || [])
|
||||
|
||||
const todayMeta = computed(() => {
|
||||
const d = new Date()
|
||||
const week = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
|
||||
@@ -485,38 +277,12 @@ const todayMeta = computed(() => {
|
||||
})
|
||||
|
||||
|
||||
const caseTypeLabel = computed(() => {
|
||||
if (!diag.value) return '初诊'
|
||||
return diag.value.consultation_type === 'follow_up' ? '复诊' : '初诊'
|
||||
})
|
||||
|
||||
/**
|
||||
* 诊单字典字段由后端 AppointmentLogic::enrichDiagnosisLabels 预翻译,
|
||||
* 前端直接读 `<field>_text`;找不到时回退原值。
|
||||
*/
|
||||
const textOf = (field: string): string => {
|
||||
const d: any = diag.value || {}
|
||||
const t = d[field + '_text']
|
||||
if (t !== undefined && t !== null && t !== '') return String(t)
|
||||
const raw = d[field]
|
||||
if (raw === undefined || raw === null || raw === '') return ''
|
||||
return Array.isArray(raw) ? raw.join('、') : String(raw)
|
||||
}
|
||||
|
||||
const todayStr = () => {
|
||||
const d = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
const maskPhone = (phone?: string) => {
|
||||
if (!phone) return '—'
|
||||
if (phone.length < 11) return phone
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
||||
}
|
||||
|
||||
const formatGender = (g?: number) => (g === 1 ? '男' : g === 0 ? '女' : '—')
|
||||
|
||||
const formatGenderAge = (row: QueueRow) => {
|
||||
const parts = [formatGender(row.gender), row.age != null ? row.age + '岁' : '']
|
||||
return parts.filter((p) => p && p !== '—').join(' ')
|
||||
@@ -527,73 +293,6 @@ const formatTimeHM = (time?: string) => {
|
||||
return time.replace(/^(\d{2}:\d{2})(:\d{2})?$/, '$1')
|
||||
}
|
||||
|
||||
const formatPeriod = (period?: string) => {
|
||||
if (period === 'morning') return '上午'
|
||||
if (period === 'afternoon') return '下午'
|
||||
return '全天'
|
||||
}
|
||||
|
||||
const formatBp = (row: any) => {
|
||||
const sys = row?.systolic_pressure
|
||||
const dia = row?.diastolic_pressure
|
||||
if (sys == null && dia == null) return '—'
|
||||
return `${sys ?? '—'} / ${dia ?? '—'}`
|
||||
}
|
||||
|
||||
const toNumeric = (value: any) => {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
const getBloodSugarThresholds = (ageValue: any) => {
|
||||
const age = toNumeric(ageValue)
|
||||
if (age === null) return null
|
||||
if (age < 50) return { fasting: 7, postprandial: 9 }
|
||||
if (age >= 50) return { fasting: 8, postprandial: 10 }
|
||||
return null
|
||||
}
|
||||
|
||||
const isHighFastingBloodSugar = (value: any, ageValue: any) => {
|
||||
const num = toNumeric(value)
|
||||
const thresholds = getBloodSugarThresholds(ageValue)
|
||||
return num !== null && thresholds !== null && num >= thresholds.fasting
|
||||
}
|
||||
|
||||
const isHighPostprandialBloodSugar = (value: any, ageValue: any) => {
|
||||
const num = toNumeric(value)
|
||||
const thresholds = getBloodSugarThresholds(ageValue)
|
||||
return num !== null && thresholds !== null && num >= thresholds.postprandial
|
||||
}
|
||||
|
||||
const isHighOtherBloodSugar = (value: any, ageValue: any) => {
|
||||
const num = toNumeric(value)
|
||||
const thresholds = getBloodSugarThresholds(ageValue)
|
||||
return num !== null && thresholds !== null && num >= thresholds.postprandial
|
||||
}
|
||||
|
||||
const isHighBloodPressure = (row: any) => {
|
||||
const sys = toNumeric(row?.systolic_pressure)
|
||||
const dia = toNumeric(row?.diastolic_pressure)
|
||||
return (sys !== null && sys > 140) || (dia !== null && dia > 90)
|
||||
}
|
||||
|
||||
const isHighSystolicPressure = (value: any) => {
|
||||
const num = toNumeric(value)
|
||||
return num !== null && num > 140
|
||||
}
|
||||
|
||||
const isHighDiastolicPressure = (value: any) => {
|
||||
const num = toNumeric(value)
|
||||
return num !== null && num > 90
|
||||
}
|
||||
|
||||
const joinArray = (arr: any): string => {
|
||||
if (!arr) return ''
|
||||
if (Array.isArray(arr)) return arr.filter(Boolean).join('、')
|
||||
return String(arr)
|
||||
}
|
||||
|
||||
const fetchQueuePage = async (pageNo: number, status?: number, patientName?: string) => {
|
||||
const today = todayStr()
|
||||
const res: any = await receptionQueue({
|
||||
|
||||
@@ -65,6 +65,7 @@ const formData = reactive({
|
||||
name: '',
|
||||
desc: '',
|
||||
sort: 0,
|
||||
data_scope: 1,
|
||||
menu_id: [] as any[]
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div class="assistant-performance-page">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form :inline="true" class="stats-filter-form">
|
||||
<el-form-item label="时间范围">
|
||||
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
|
||||
<el-radio-button label="today">今天</el-radio-button>
|
||||
<el-radio-button label="yesterday">昨天</el-radio-button>
|
||||
<el-radio-button label="week">最近7天</el-radio-button>
|
||||
<el-radio-button label="month">最近30天</el-radio-button>
|
||||
<el-radio-button label="custom">自定义</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="queryParams.time_type === 'custom'">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
style="width: 260px"
|
||||
@change="handleCustomDateChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="fetchData">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="stats-kpi-grid">
|
||||
<div v-for="card in summaryCards" :key="card.key" class="stats-kpi-card" :class="card.cardClass">
|
||||
<div class="stats-kpi-label">{{ card.label }}</div>
|
||||
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
|
||||
{{ formatValue(card.key, card.type) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">业绩趋势</span>
|
||||
<span class="card-hint">{{ dateRangeText }} · 履约完成业绩</span>
|
||||
</div>
|
||||
</template>
|
||||
<v-charts
|
||||
v-if="chartHasData"
|
||||
class="stats-chart"
|
||||
:option="chartOption"
|
||||
autoresize
|
||||
/>
|
||||
<el-empty v-else description="暂无数据" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="assistantPerformancePage">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import vCharts from 'vue-echarts'
|
||||
import { assistantPerformanceOverview } from '@/api/stats'
|
||||
|
||||
const loading = ref(false)
|
||||
const dateRange = ref<string[]>([])
|
||||
const queryParams = reactive({
|
||||
time_type: 'month',
|
||||
start_date: '',
|
||||
end_date: ''
|
||||
})
|
||||
|
||||
const overview = reactive<Record<string, any>>({
|
||||
date_range: [],
|
||||
summary: {
|
||||
total_amount: 0,
|
||||
total_count: 0
|
||||
},
|
||||
chart: {
|
||||
dates: [],
|
||||
amounts: [],
|
||||
counts: []
|
||||
}
|
||||
})
|
||||
|
||||
const summaryCards = [
|
||||
{ key: 'total_amount', label: '业绩', type: 'money', cardClass: '' },
|
||||
{ key: 'total_count', label: '有效订单数', type: 'count', cardClass: '' }
|
||||
]
|
||||
|
||||
const dateRangeText = computed(() => {
|
||||
if (!overview.date_range?.length) return '未选择'
|
||||
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
||||
})
|
||||
|
||||
const chartHasData = computed(() => {
|
||||
return overview.chart.dates.length > 0 && overview.chart.amounts.some((v: number) => v > 0)
|
||||
})
|
||||
|
||||
const chartOption = computed(() => ({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: any) => {
|
||||
const p = params[0]
|
||||
return `${p.axisValue}<br/>${p.marker}${p.seriesName}: ¥${Number(p.value).toFixed(2)}`
|
||||
}
|
||||
},
|
||||
grid: { left: 60, right: 24, top: 36, bottom: 36 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: overview.chart.dates,
|
||||
axisLabel: {
|
||||
interval: overview.chart.dates.length > 15 ? 'auto' : 0,
|
||||
rotate: overview.chart.dates.length > 10 ? 30 : 0
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '金额(元)',
|
||||
axisLabel: {
|
||||
formatter: (val: number) => val >= 10000 ? (val / 10000).toFixed(1) + '万' : String(val)
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '业绩',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 6,
|
||||
lineStyle: { width: 3, color: '#4a78ff' },
|
||||
itemStyle: { color: '#4a78ff' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(74, 120, 255, 0.25)' },
|
||||
{ offset: 1, color: 'rgba(74, 120, 255, 0.02)' }
|
||||
]
|
||||
}
|
||||
},
|
||||
data: overview.chart.amounts
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
const formatValue = (key: string, type: string) => {
|
||||
const value = overview.summary?.[key] ?? 0
|
||||
if (type === 'money') return `¥${Number(value).toFixed(2)}`
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { time_type: queryParams.time_type }
|
||||
if (queryParams.time_type === 'custom') {
|
||||
params.start_date = dateRange.value[0] || ''
|
||||
params.end_date = dateRange.value[1] || ''
|
||||
}
|
||||
const res = await assistantPerformanceOverview(params)
|
||||
Object.assign(overview, res || {})
|
||||
} catch (error: any) {
|
||||
console.error('获取业绩数据失败:', error)
|
||||
ElMessage.error(error?.msg || '获取业绩数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeTypeChange = () => {
|
||||
if (queryParams.time_type !== 'custom') {
|
||||
dateRange.value = []
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCustomDateChange = () => {
|
||||
if (dateRange.value?.length === 2) {
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.time_type = 'month'
|
||||
dateRange.value = []
|
||||
fetchData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.assistant-performance-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stats-filter-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.stats-kpi-card {
|
||||
background: linear-gradient(145deg, #ffffff, #f5f8ff);
|
||||
border: 1px solid #ebf1ff;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
|
||||
}
|
||||
|
||||
.stats-kpi-card.is-cancelled {
|
||||
background: linear-gradient(145deg, #ffffff, #fff5f5);
|
||||
border-color: #ffe0e0;
|
||||
box-shadow: 0 10px 24px rgba(239, 68, 68, 0.06);
|
||||
}
|
||||
|
||||
.stats-kpi-label {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stats-kpi-value {
|
||||
margin-top: 12px;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.stats-kpi-value.is-money {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.stats-kpi-card.is-cancelled .stats-kpi-value {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.card-hint {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stats-chart {
|
||||
height: 360px;
|
||||
}
|
||||
</style>
|
||||
@@ -105,24 +105,27 @@
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" class="mt-4">
|
||||
<el-col :xs="24" :lg="14">
|
||||
<el-col :span="24">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>转化排行</span>
|
||||
<span class="card-hint">{{ dateRangeText }}</span>
|
||||
<span class="card-hint">{{ dateRangeText }} · TOP 10</span>
|
||||
</div>
|
||||
</template>
|
||||
<v-charts
|
||||
v-if="rankingChartHasData"
|
||||
class="stats-chart"
|
||||
class="stats-chart stats-chart-wide"
|
||||
:option="rankingChartOption"
|
||||
autoresize
|
||||
/>
|
||||
<el-empty v-else description="暂无图表数据" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="10">
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="mt-4">
|
||||
<el-col :xs="24" :lg="12">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
@@ -139,10 +142,7 @@
|
||||
<el-empty v-else description="暂无金额数据" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="mt-4">
|
||||
<el-col :xs="24" :lg="10">
|
||||
<el-col :xs="24" :lg="12">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
@@ -159,35 +159,82 @@
|
||||
<el-empty v-else description="暂无加粉数据" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="14">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="mt-4">
|
||||
<el-col :span="24">
|
||||
<el-card class="!border-none stats-detail-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>明细列表</span>
|
||||
<span class="card-hint">{{ entityLabel }}维度</span>
|
||||
<div class="card-header-main">
|
||||
<span class="card-title">明细列表</span>
|
||||
<span class="card-hint">{{ entityLabel }}维度 · {{ dateRangeText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table
|
||||
:data="pager.lists"
|
||||
:row-key="queryParams.dimension === 'dept' ? 'id' : undefined"
|
||||
:row-key="rowKeyFn"
|
||||
:tree-props="queryParams.dimension === 'dept' ? treeProps : undefined"
|
||||
:default-expand-all="false"
|
||||
size="large"
|
||||
:indent="20"
|
||||
:row-class-name="rowClassName"
|
||||
size="default"
|
||||
style="width: 100%"
|
||||
max-height="560"
|
||||
max-height="640"
|
||||
border
|
||||
>
|
||||
<el-table-column
|
||||
:label="entityLabel"
|
||||
prop="name"
|
||||
fixed="left"
|
||||
min-width="280"
|
||||
min-width="300"
|
||||
class-name="dept-name-column"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<span class="dept-name-cell" :title="row.name">
|
||||
{{ row.name }}
|
||||
</span>
|
||||
<div class="dept-name-cell" :title="row.name">
|
||||
<span
|
||||
v-if="isMemberRow(row)"
|
||||
class="member-icon"
|
||||
:class="{
|
||||
'is-leader': row.is_leader,
|
||||
'is-unbound': row.type === 'unbound'
|
||||
}"
|
||||
>
|
||||
<el-icon><User /></el-icon>
|
||||
</span>
|
||||
<span class="entity-name">{{ row.name }}</span>
|
||||
<el-tag
|
||||
v-if="row.type === 'member' && row.is_leader"
|
||||
size="small"
|
||||
type="warning"
|
||||
effect="light"
|
||||
round
|
||||
class="role-tag"
|
||||
>
|
||||
组长
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-if="row.type === 'member' && row.role"
|
||||
size="small"
|
||||
:type="row.role === '医生' ? 'success' : 'info'"
|
||||
effect="plain"
|
||||
round
|
||||
class="role-tag"
|
||||
>
|
||||
{{ row.role }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-if="row.type === 'unbound'"
|
||||
size="small"
|
||||
type="danger"
|
||||
effect="plain"
|
||||
round
|
||||
class="role-tag"
|
||||
>
|
||||
未绑定
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
@@ -215,6 +262,7 @@
|
||||
<script setup lang="ts" name="conversionStatsPage">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User } from '@element-plus/icons-vue'
|
||||
import vCharts from 'vue-echarts'
|
||||
|
||||
import { getConversionStatsOverview } from '@/api/stats'
|
||||
@@ -619,6 +667,27 @@ const renderMetric = (key: string, source: Record<string, any>, type = 'count',
|
||||
return String(value ?? 0)
|
||||
}
|
||||
|
||||
const rowKeyFn = (row: Record<string, any>) => {
|
||||
if (row?.id !== undefined && row?.id !== null) {
|
||||
return String(row.id)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const isMemberRow = (row: Record<string, any>) => {
|
||||
return row?.type === 'member' || row?.type === 'unbound'
|
||||
}
|
||||
|
||||
const rowClassName = ({ row }: { row: Record<string, any> }) => {
|
||||
if (row?.type === 'unbound') {
|
||||
return 'member-row member-row--unbound'
|
||||
}
|
||||
if (row?.type === 'member') {
|
||||
return row.is_leader ? 'member-row member-row--leader' : 'member-row'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchOverview()
|
||||
})
|
||||
@@ -672,6 +741,27 @@ onMounted(async () => {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-header-main {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-header-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.card-hint {
|
||||
@@ -680,19 +770,93 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.dept-name-cell {
|
||||
display: inline-block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.entity-name {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.member-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #e6f0ff;
|
||||
color: #4a78ff;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-icon.is-leader {
|
||||
background: #fff4e0;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.member-icon.is-unbound {
|
||||
background: #fde8e8;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.role-tag {
|
||||
margin-left: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.dept-name-column .cell) {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.member-row) td {
|
||||
background-color: #fafbff !important;
|
||||
}
|
||||
|
||||
:deep(.member-row--leader) td {
|
||||
background-color: #fff8ec !important;
|
||||
}
|
||||
|
||||
:deep(.member-row--leader .entity-name) {
|
||||
color: #d97706;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.member-row--unbound) td {
|
||||
background-color: #fff5f5 !important;
|
||||
}
|
||||
|
||||
:deep(.member-row--unbound .entity-name) {
|
||||
color: #ef4444;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stats-detail-card :deep(.el-card__body) {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.stats-chart {
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.stats-chart-wide {
|
||||
height: 380px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user