133 Commits

Author SHA1 Message Date
lq
777737d063 feat: 钉钉、企业微信webbook、企业微信应用api,问题:交互 2026-07-23 14:15:17 +08:00
lq
2b13a19731 feat: 钉钉、企业微信webbook、企业微信应用api,问题:交互 2026-07-21 14:01:41 +08:00
lq
4224130ad4 feat: 钉钉、企业微信webbook、企业微信应用api,问题:交互 2026-07-21 14:01:21 +08:00
lq
2e7bc80a11 feat: 钉钉、企业微信webbook 2026-07-20 15:05:34 +08:00
李琦
316bbabcd1 1. 修复了接诊页面的剂量、天数输入框过窄
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-07-14 09:21:40 +08:00
李琦
f671ea7d41 1. 修复了部分开方的错误
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2. 新增药品的ERP抓取数量倍数
2026-07-14 08:24:00 +08:00
李琦
c6fa14ee90 1. 医生新增测试医生
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
2. 回调分账链路的注释
3. 减少首页访问的时候数据返回量
2026-07-13 14:32:04 +08:00
李琦
83ce8afefc 1. 推广员文案修改
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-13 12:54:57 +08:00
李琦
a524982b87 1. 业务员查看维度增加
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-07-11 18:34:29 +08:00
李琦
464159121c 1. 业务员查看维度增加
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-11 18:08:38 +08:00
李琦
3e09ba5225 1. 业务员查看维度增加
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-11 17:35:33 +08:00
李琦
daafdcaed7 1. 业务员查看维度增加
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-11 17:20:57 +08:00
李琦
28ed398581 1. 业务员看板
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-11 16:12:42 +08:00
李琦
80b612a913 1. 计算部门开票价格
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-11 15:53:44 +08:00
李琦
e0cb5c660c 1. 开方页面代煎选择交互
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-07-10 15:11:18 +08:00
李琦
026d0e0a48 1. 特色方功能迭代(固定价格),固定价格目前统一使用药品id(-102),后期会考虑新增特色方的时候自动添加一个特色方药品
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2. 退款的时候可以退回分成
2026-07-10 15:10:49 +08:00
李琦
a05917ac18 1. 特色方功能迭代(固定价格),固定价格目前统一使用药品id(-102),后期会考虑新增特色方的时候自动添加一个特色方药品
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2. 退款的时候可以退回分成
2026-07-10 08:42:05 +08:00
李琦
d93e465d3c 1. 特色方功能迭代(固定价格),固定价格目前统一使用药品id(-102),后期会考虑新增特色方的时候自动添加一个特色方药品
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
2. 退款的时候可以退回分成
2026-07-09 15:45:39 +08:00
李琦
5620c39e1e 1. 特色方功能迭代(固定价格),固定价格目前统一使用药品id(-102),后期会考虑新增特色方的时候自动添加一个特色方药品
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2. 退款的时候可以退回分成
2026-07-06 18:12:12 +08:00
李琦
c02f685d83 1. 特色方功能迭代(固定价格),固定价格目前统一使用药品id(-102),后期会考虑新增特色方的时候自动添加一个特色方药品
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2. 退款的时候可以退回分成
2026-07-06 17:03:35 +08:00
李琦
7907c7cbf2 1. 特色方功能迭代
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-07-03 16:50:16 +08:00
李琦
c8b15cd9dd 1. 优化医生、诊所的设置
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-03 12:35:34 +08:00
李琦
92e145f366 1. 优化医生、诊所的设置
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-03 10:41:52 +08:00
李琦
949167217f 1. 优化医生、诊所的设置
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-07-02 16:21:09 +08:00
李琦
a38da5ce92 1. Excel交互组件封装优化
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-06-26 12:52:26 +08:00
李琦
69862cd982 1. Excel交互组件封装
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-06-25 16:09:07 +08:00
李琦
8cfef12a56 1. 加工费模块重构
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2. 快递费模块重构
3. 订单导出功能优化
4. 商品订单的折扣功能
5. 修复了一些已知BUG
2026-06-25 08:17:00 +08:00
李琦
e6306eea75 1. 加工费模块重构
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
2. 快递费模块重构
3. 订单导出功能优化
4. 商品订单的折扣功能
5. 修复了一些已知BUG
2026-06-24 08:11:15 +08:00
李琦
824dc2b0e3 1. 加工费模块重构
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
2. 快递费模块重构
3. 订单导出功能优化
4. 商品订单的折扣功能
5. 修复了一些已知BUG
2026-06-23 15:06:41 +08:00
李琦
8eb479696e 1. 加工费模块重构(1/3)
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-06-18 17:04:14 +08:00
李琦
75dd690188 1. 右键菜单通用组件封装
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2. 文件管理优化功能(后续需要新增分组、个人收藏)
3. 优化订单页面、诊所管理页面的布局,更加集中,减少表长度
4. 新增诊所的时候业务员支持气泡卡片点击选择,不用再输入业务码
5. 接口日志筛选优化
2026-06-18 16:42:51 +08:00
李琦
073f7f882b 1. 推广员功能增强
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-06-18 15:05:02 +08:00
李琦
d9db629c5d 1. 推广员功能增强
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-06-18 13:49:06 +08:00
李琦
f36ef45ee2 1. 推广员功能增强
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-06-18 13:24:58 +08:00
李琦
9a1435996e 1. 推广员功能增强
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-06-15 14:01:30 +08:00
李琦
4090c2ca3a 1. 推广员功能增强 2026-06-12 15:07:14 +08:00
李琦
2f37bf2909 1. 推广员功能增强
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-06-09 14:54:52 +08:00
李琦
a74f1cb106 1. 推广员功能增强
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-06-05 16:20:17 +08:00
李琦
b40c7402aa 1. 推广员功能增强
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-06-04 16:05:02 +08:00
李琦
ee25ed00fb 1. 订单追溯、模拟支付(仅限本地环境)
Some checks failed
Close stale issues / stale (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
2026-06-02 08:20:11 +08:00
李琦
6e5dec9f92 1. 切换账号功能
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
2026-06-01 13:29:14 +08:00
李琦
d254f99a23 1. 切换账号功能
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-05-30 16:58:05 +08:00
李琦
75d1b4db42 1. 管理员管理模块拆分
2. 图片上传自动压缩(大于850kb)
3. 省市区选择组件重构,支持搜索
2026-05-30 13:52:59 +08:00
李琦
402da17e3d 1. 管理员管理模块拆分
2. 图片上传自动压缩(大于850kb)
3. 省市区选择组件重构,支持搜索
2026-05-27 09:34:08 +08:00
李琦
68fd7b0045 1. 优化一些问题
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-05-20 10:12:09 +08:00
李琦
90a9bde586 1. 表格配置优化
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-05-19 09:52:55 +08:00
李琦
c978b978ec 1. 推广员
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-05-18 13:33:27 +08:00
李琦
0b1b08c7d7 1. 对账单、订单的改良
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-05-18 09:40:35 +08:00
李琦
56ac91c4af 1. 对账单、订单的改良
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-05-18 09:13:49 +08:00
李琦
cc81e45a5c 1. 对账单、订单的改良
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-05-14 11:00:47 +08:00
李琦
01f0a6b0f9 在线复诊挂号和订单校验
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-05-06 15:53:45 +08:00
李琦
014bf97121 设置mes编码
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-03-26 10:31:19 +08:00
李琦
6a8cc0458d 批量修改药品编号
Some checks failed
Close stale issues / stale (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
2026-03-25 08:55:39 +08:00
李琦
8bcae19369 登陆页面和医院审核
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-03-23 13:02:46 +08:00
李琦
235cccc146 转诊方优化
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-03-07 13:11:23 +08:00
李琦
8e81929bf1 转诊方优化
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-02-07 23:22:34 +08:00
李琦
2bbd943b39 转诊方优化
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-07 16:22:54 +08:00
李琦
6e0dc28f43 转诊方优化
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-07 14:17:23 +08:00
李琦
5158499dd1 转诊方优化
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-07 13:02:04 +08:00
李琦
dc9e629b31 转诊方优化
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-07 11:12:43 +08:00
李琦
99ea152081 优化西药诊所开中药处方的提示
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-07 09:41:12 +08:00
李琦
86c5b7431d 转接方 2026-02-04 14:16:05 +08:00
李琦
37d42263a8 转接方
Some checks failed
Close stale issues / stale (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
2026-02-04 08:53:52 +08:00
李琦
4c03953127 优化功能
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-02-02 15:42:35 +08:00
李琦
5ba5ec65c8 优化功能
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-01-22 14:53:29 +08:00
李琦
e33f2f1edc 优化功能
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-01-22 13:46:35 +08:00
李琦
721c5ca610 优化功能
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-01-15 13:09:58 +08:00
李琦
0245f8ff20 默认头像
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-01-14 15:59:49 +08:00
lq
59a9dd3d81 fix: 在线复诊的UI
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-01-12 12:39:00 +08:00
lq
f16cbf926e fix: 在线复诊的UI 2026-01-09 11:06:44 +08:00
lq
187260164b fix: 在线复诊的UI 2026-01-09 11:04:03 +08:00
lq
5eec8b370e fix: 常用方、患者管理
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-01-07 14:22:01 +08:00
lq
e0acff7faa fix: 常用方、患者管理 2025-12-31 11:13:16 +08:00
lq
ae364d4319 fix: 常用方、患者管理 2025-12-30 11:32:54 +08:00
lq
a8f7ffed61 fix: 常用方、患者管理 2025-12-30 10:04:39 +08:00
lq
5c1f59e351 fix: 常用方、患者管理
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-12-26 16:48:46 +08:00
lq
7dc90731dc fix: 常用方、患者管理 2025-12-26 13:28:17 +08:00
lq
d4b9cc32d5 fix: 常用方、患者管理 2025-12-26 09:19:22 +08:00
lq
f4e51dc773 fix: 常用诊断、常用医嘱 2025-12-25 16:00:42 +08:00
lq
7f2ed4b30a fix: OSS客户端直传 2025-12-25 14:27:10 +08:00
lq
660717439d fix: OSS客户端直传 2025-12-25 11:18:18 +08:00
lq
d81c2da3ba fix: OSS客户端直传 2025-12-24 16:19:23 +08:00
lq
73f858fc07 fix: OSS客户端直传 2025-12-24 16:05:21 +08:00
lq
84cafd66ae fix: OSS客户端直传 2025-12-23 14:34:22 +08:00
lq
e9b79b2963 fix: 在线复诊 2025-12-23 11:05:12 +08:00
lq
f2d7317982 fix: 修改要诊所供货价
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-12-19 15:58:31 +08:00
lq
806058eed7 fix: 首页药店分区入口、平台资质管理 2025-12-19 11:16:01 +08:00
lq
0d701f8158 fix: 诊所和药店分开管理
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-12-18 09:22:12 +08:00
lq
6a13e3a547 fix: 诊所和药店分开管理
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-12-17 14:49:06 +08:00
lq
6a13ca7d82 fix: 诊所和药店分开管理
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2025-12-17 14:13:16 +08:00
lq
64a3b45b6e fix: 诊所和药店分开管理
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2025-12-17 13:29:54 +08:00
lq
84930d8170 fix: 商城仓库管理
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2025-12-17 12:46:18 +08:00
lq
5d655892ee fix: 商城仓库管理 2025-12-17 10:12:54 +08:00
lq
881df2f437 fix: 商城仓库管理
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-12-03 09:36:06 +08:00
lq
8e324accff fix: 修复了一些问题 2025-12-03 09:35:41 +08:00
lq
09a423b8dc fix: 各种提交(日志相关、组件封装相关、订单相关
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-11-08 15:05:52 +08:00
lq
e637a46889 fix: 推广员
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-09-23 14:08:26 +08:00
lq
3e3f0519ad fix: 修复管理员、角色的字段问题
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-09-04 09:21:55 +08:00
lq
f7c8dd71ce fix: 修复表单功能按钮布局问题
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-09-02 10:18:06 +08:00
lq
c2e95ad60b fix: 修复表单功能按钮布局问题
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-09-01 14:54:25 +08:00
lq
862580c156 fix: 修复显示问题
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2025-09-01 14:45:35 +08:00
lq
bf3d02124d fix: 拒诊、聊天消息优化
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-08-30 15:31:03 +08:00
lq
70bf4f3019 fix: 删除无用字段
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-08-28 18:49:54 +08:00
lq
920b47aace fix: 删除无用命名,更新了一些细节
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2025-08-28 11:00:39 +08:00
lq
6f8f87fb09 fix: 轮播图
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-08-18 16:08:37 +08:00
lq
c0180d699f fix: 处方发送
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-08-02 16:57:57 +08:00
lq
55b67a6df4 fix: 处方发送
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-08-01 16:39:37 +08:00
lq
d2990661e2 fix: 音频文件
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-07-31 16:03:07 +08:00
lq
7fd5c489bc fix: 音频文件
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-07-29 14:30:03 +08:00
lq
7ef6fa2077 fix: 退出登录异常
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-07-25 13:19:40 +08:00
lq
c93cbec021 fix: 聊天功能(部分),图片发送,视频发送,音频发送已完成,好友列表,聊天记录以对接
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2025-07-25 13:02:19 +08:00
lq
2423326951 fix: 聊天功能(部分)
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-07-22 16:55:09 +08:00
lq
b17e6c53ad fix: 聊天功能(部分)
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-07-19 16:47:52 +08:00
lq
77d608404d fix: 新增药师、编辑药师
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-06-05 15:00:33 +08:00
lq
acc33467da fix: 新增药师、编辑药师
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-05-30 16:56:10 +08:00
lq
79e5a7c8e4 fix: 新增药师
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-05-29 17:02:04 +08:00
lq
3c9e64c242 fix: 药师审核处方、退款
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-05-26 16:16:31 +08:00
lq
6445162b85 fix: 药师详情
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-05-17 15:49:45 +08:00
lq
a3a09d967e fix: 协议设置
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2025-05-17 14:15:07 +08:00
lq
9ab3903952 fix: 药师管理
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-05-16 17:00:19 +08:00
lq
e27d525c43 fix: 加工费管理 2025-05-15 15:15:56 +08:00
lq
2db57369f0 fix: 仓库药品管理 - 诊所
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2025-05-14 15:18:27 +08:00
lq
a26722a655 fix: 仓库药品管理 - 诊所
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2025-05-14 15:04:09 +08:00
lq
0de0938956 fix: 仓库药品管理
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
2025-05-12 08:04:39 +08:00
lq
e1f8c76c4d fix: 仓库药品管理 2025-05-09 13:17:42 +08:00
lq
d7a446d3f9 fix: 仓库药品管理 2025-05-08 16:28:06 +08:00
lq
20c44595df fix: 修复登录过期的问题 2025-05-05 13:06:10 +08:00
lq
c5d1100088 fix: 中药管理 2025-04-30 14:01:47 +08:00
lq
87aa9f9655 fix: 西药管理 2025-04-29 16:03:38 +08:00
lq
a752648272 fix: 西药管理、西药、订单导出 2025-04-28 14:54:08 +08:00
lq
0c0f22efcb fix: 提现审核管理 2025-04-26 13:11:33 +08:00
lq
f649802c53 fix: 部分提现审核管理 2025-04-25 15:54:57 +08:00
lq
11cc8cfa95 fix: 删除测试页面 2025-04-23 08:32:10 +08:00
720 changed files with 165334 additions and 14740 deletions

View File

@@ -0,0 +1,10 @@
---
description:
alwaysApply: true
---
1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则
2. 注意不要生成太多的空行上一部分代码和下一部分代码中间的空行不要大于2行
3. 有封装好的方法、组件需要复用,不要重复造轮子
4. 小程序端的抽屉全部需要用page-container来防止用户意外退出页面记得使用v-if而不是v-show
5. 数据库的created_at、updated_at、deleted_at统一使用时间戳不要使用字符串并且我在查询器中一级格式化成字符串了无需再次格式化

View File

@@ -0,0 +1,233 @@
---
description: 萧康云医管理后台Vben Admin v5 + Vue 3 + TS + Ant Design Vue代码规范
alwaysApply: true
---
# 基础规范(所有项目通用)
1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则
2. 注意不要生成太多的空行,上一部分代码和下一部分代码中间的空行不要大于 2 行
3. 有封装好的方法、组件需要复用,不要重复造轮子
4. 小程序端的抽屉全部需要用 page-container 来防止用户意外退出页面(记得使用 v-if 而不是 v-show
5. 数据库的created_at、updated_at、deleted_at统一使用时间戳不要使用字符串并且我在查询器中一级格式化成字符串了无需再次格式化
# 全局架构规范
## 目录结构规范
- 业务页面统一放在 `apps/web-antd/src/views/<端>/<模块>/`system / business / doctor 等)
- 标准 CRUD 模块目录结构:
```
views/<端>/<模块>/
├── index.vue # 列表页Page + Grid + Modal
├── api/index.ts # 本模块 APICRUD
├── config/
│ ├── table.ts # vxe-grid 列定义 + proxyConfig
│ ├── search.ts # 顶部搜索表单 schema
│ └── form.ts # 新增/编辑弹窗 form schema
├── components/
│ └── modal.vue # 新增/编辑弹窗
└── utils/ # 模块工具(可选)
```
- 全局复用组件放 `apps/web-antd/src/components/`
- 表单内可用组件放 `apps/web-antd/src/components/form/components/`,文件名 kebab-case自动注册为 schema 的 `component` 名PascalCase 引用)
## 强制复用封装(禁止重复造轮子)
- 表格统一用 `useVbenVxeGrid`(来自 `#/adapter/vxe-table`**不要直接 new VxeGrid**
- 表单统一用 `useVbenForm`(来自 `#/adapter/form`**不要直接写 `<Form>` + 手动校验**
- 弹窗统一用 `useVbenModal`(来自 `@vben/common-ui`**不要用原生 `<Modal>`**
- 页面外壳统一用 `<Page>` 组件(来自 `@vben/common-ui`
- HTTP 请求统一用 `requestClient`(来自 `#/api/request`**禁止直接 axios / fetch**
- 行操作和工具栏按钮统一用 `<TableAction>` 组件(来自 `#/components/table-action`
- 业务选择器(员工、医生、门店、推广员、地区等)必须复用 `#/components/form/components/*-picker.vue`,不要重写
## API 层规范
- API 文件统一放在 `<模块>/api/index.ts`
- 函数命名约定:`get{Entity}List` / `get{Entity}Option` / `get{Entity}Info` / `create{Entity}` / `update{Entity}` / `delete{Entity}`
- URL 使用 kebab-case统一前缀变量 `const prefix = 'xxx/'`**URL 末尾不补 `/`**
- GET 用 `params`POST 用 `data`,例如:
```ts
import { requestClient } from '#/api/request';
const prefix = 'oa-robot/';
export async function getOaRobotList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
```
## 字段命名对齐后端
- 数据库字段在前端 schema 的 `field` / `fieldName` 中保持 snake_case如 `created_at`、`store_id`、`platform_code`**不要转换为 camelCase**
- 时间戳字段(`created_at` 等)后端查询器已格式化为字符串,前端**无需再次格式化**
- TS 变量用 camelCase常量用 UPPER_SNAKE_CASE类型用 PascalCase
## 表单校验
- 必填用字符串规则 `rules: 'required'`(选择项用 `'selectRequired'`),规则定义在 `#/adapter/form.ts` 的 `defineRules`
- 校验调用统一用 `formApi.validate().then(e => { if (e.valid) {...} })`
- 必填字段在 schema 中加 `rules: 'required'`,禁止散落 `validator` 写法
## 字典/枚举管理
- 模块内建 `config/constants.ts`,导出 `{ label, value }[]` 数组,禁止在 schema 中散落字面量
- 例如:
```ts
export const OA_MESSAGE_TYPE_OPTIONS = [
{ label: '文本', value: 'text' },
{ label: 'Markdown', value: 'markdown' },
];
```
## 路由规范
- 业务路由通过后端动态菜单驱动(`xk_menu` 表),**不要在前端 `router/routes/modules/` 静态注册业务路由**
- `defineOptions({ name: 'PascalCaseName' })` 必须与 `xk_menu.name` 字段一致
## Tab 用法
- 统一用 antd 原生 `Tabs` + `Tabs.TabPane`**不要找 Vben 自封装的 Tabs**
- Tab 选中状态持久化走 `localStorage`(参考 `system-config/index.vue` 的 `TAB_STORAGE_KEY` 模式)
## 抽屉用法小程序端admin 不涉及)
- 小程序端的抽屉用 `page-container` + `v-if`(不是 `v-show`)防止用户意外退出页面
## 中文注释
- 每个 `<script setup>` 顶部说明模块用途
- 每个主要函数(特别是有业务逻辑的)必须有中文注释
- 复杂的 schema 字段配置要注释意图
## 空行控制
- 上一部分代码和下一部分代码中间空行不超过 2 行
## Vben 组件使用规范
### VbenModal来自 @vben/common-ui
- 创建弹窗必须用 `useVbenModal`,禁止直接用 antd `Modal` 或原生 `<dialog>`
- 抽离弹窗内容到子组件时,外层用 `connectedComponent` 参数连接,内外组件共享 `modalApi`
- 底部按钮扩展 slot 优先级:`prepend-footer`(取消按钮左侧)> `center-footer`(取消/确定之间)> `append-footer`(确定右侧)
- **严禁覆盖 `#footer` slot**,会丢失默认的取消/确定按钮和 loading 行为
- 表单提交 loading 走 `modalApi.setState({ confirmLoading: true })`,禁止自己写 button loading
- 提交防抖/防重复用 `modalApi.lock()` / `unlock()`>5.5.3),禁止手动 disabled
- 拖拽开 `draggable: true`,需要拖出可视区时同时开 `overflow: true`
- 数据回填走 `modalApi.setData()` + `onOpenChange(isOpen) { const data = modalApi.getData() }`
### VbenDrawer来自 @vben/common-ui
- 创建抽屉必须用 `useVbenDrawer`,禁止直接用 antd `Drawer`
- 底部按钮扩展 slot 与 Modal 一致:`prepend-footer` / `center-footer` / `append-footer`
- **同样严禁覆盖 `#footer` slot**
- 关闭前校验用 `onBeforeClose`>5.5.2 支持 Promise禁止自己拦截
- 提交防抖/防重复用 `drawerApi.lock()` / `unlock()`>5.5.3
- 小程序端的「抽屉」概念与此无关,小程序端按现有规则用 `page-container`
### VbenVxeTable来自 #/adapter/vxe-table
- 表格必须用 `useVbenVxeGrid`,禁止直接 `new VxeGrid` 或 antd `Table`
- 远程数据加载走 `gridOptions.proxyConfig.ajax.query`**禁止自己 fetch 后 `data = []` 赋值**
- query 接口返回结构必须匹配适配器配置:`{ items: [], total: number }`(在 `apps/web-antd/src/adapter/vxe-table.ts` 的 `response` 字段统一配置)
- 分页参数从 `{ page }` 解构取:`page.currentPage` / `page.pageSize`,禁止自己读 URL
- 刷新表格用 `gridApi.query()`(保留当前页)或 `gridApi.reload()`(回到第一页),禁止整页 `window.location.reload()`
- 工具栏按钮插 slot`toolbar-actions`(标题左侧)、`toolbar-tools`(工具按钮左侧),禁止改 vxe-grid 内部模板
- 表格 loading 用 `gridApi.setLoading(bool)`,禁止自己遮罩
- 搜索表单由 `formOptions` 配置(底层是 Vben Form开关走 `gridOptions.toolbarConfig.search = true`
- 自定义列渲染走 `slots: { default: 'action' }` + Grid 子组件内 `<template #action="{ row }">`,禁止 column 渲染函数里写 JSX
### VbenForm来自 #/adapter/form
- 表单必须用 `useVbenForm`,禁止直接 `<form>` 或 antd `Form`
- 动态修改 schema 必须用 `formApi.updateSchema([...])`**vben form 没有 `setComponentProps` 这个方法**(这是已踩过的坑)
- 取值/赋值用 `formApi.getValues()` / `formApi.setValues(obj)`,禁止自己 `v-model` 收集
- 校验用 `formApi.validate().then(e => { if (e.valid) {...} })` 或 `formApi.validateAndSubmitForm()`
- 必填规则用字符串 `'required'` / `'selectRequired'`,规则在 `#/adapter/form.ts` 的 `defineRules` 注册
- schema 中字段名(`fieldName`)必须与后端字段 snake_case 对齐,禁止前端 camelCase
- 密码/敏感字段用 `VbenInputPassword` 组件,禁止用 `VbenInput` 配 `type="password"`
- 单选/多选选项来自接口时,必须在弹窗/页面 `onMounted` 或 `onOpenChange` 里拉取后通过 `updateSchema` 注入,禁止写死常量
# 专项模块规范
## OA 通知模块前端规则
### 组件与封装复用
- 所有 OA 配置入口(系统配置 tab、CRUD 页面、员工 OA 账号弹窗)必须复用 `Tabs.TabPane`、`useVbenVxeGrid`、`useVbenModal`、`useVbenForm`,禁止用原生 antd 表格/表单
### 平台动态化(禁止写死)
- **平台列表必须从接口 `oa-platform/list` 动态拉取**,禁止在前端写死 `['work_wechat', 'dingtalk', 'feishu']` 常量数组
- 系统配置 tab 中的平台开关行、机器人表单中的平台下拉、场景表单中的机器人分组,都要走接口动态渲染
- 平台编码统一为字符串 snake_case如 `work_wechat`、`dingtalk`、`feishu`),与后端 `xk_oa_platform.platform_code` 字段对齐
### 系统配置 tab 交互
- 系统配置 tab 中的「总开关关闭」必须禁用下方所有平台子开关(视觉上置灰),禁止总开关关闭时仍能切换子开关
- 平台子开关切换必须立即调 `oa-platform/update-enabled` 接口,**禁止依赖保存按钮批量提交**(避免总开关与子开关状态不一致)
- 系统配置页的总开关走现有 `getSystemConfigList` / `saveSystemConfig` 接口config_key = `oa_notify_enabled`**禁止新建专用接口**
### 表单字段约定
- @ 人配置必须按平台分组(用 Tabs 或 Collapse 分组),员工必须从 `xk_admin` 表选择(复用员工 picker 组件)
- OA 机器人表单中的 webhook、secret 字段必须用密码型输入框(`InputPassword`),禁止明文展示
- OA 场景表单中的机器人多选必须按平台分组展示(用 `Tabs` 或 `Collapse` 分组)
### 消息类型全量支持(数据库驱动 schema
- 场景弹窗每个平台 Tab 内的消息类型选项**必须**通过 `GET /oa-scene/message-types` 接口动态拉取,
按 `platform_code` 过滤当前平台支持的所有 `message_type`
- 每个消息类型对应的 payload 表单**必须**按后端返回的 `payload_schema`JSON 字符串)动态渲染,
禁止前端写死 schema数据库可启停类型前端硬编码会不同步
- payload 字段渲染规则(与后端 `payload_schema` 约定):
- `Textarea`:多行文本
- `Input` / `InputNumber`:单行文本/数字
- `RadioGroup` / `Select`:选项组
- `GalleryPickLink`:素材库选择器,透传 `props.acceptTypes: number[]` 过滤文件类型
- `TemplateCardEditor`:企微模板卡片专用编辑器(用 `views/system/oa-scene/components/template-card-form.vue`
- 提交结构:`message_config: { platform_code: { message_type, payload } }`
### image-gallery-picker 文件类型过滤
- `image-gallery-picker.vue` 和 `gallery-pick-link.vue` 都支持 `acceptTypes?: number[]` prop
- 取值对应 `xk_file_type.value`1=图片、2=视频、3=音频、6=文件 等
- 不传 `acceptTypes` 时显示全部类型(默认行为,保持向后兼容)
- 传数组时只展示匹配类型 tab且自动锁定到第一个匹配类型
- OA 场景中图片字段必须传 `[1]`、语音字段传 `[3]`、文件字段传 `[6]`,避免用户选错类型
### 通用拖拽可视化编辑器(`components/oa-template-card-editor/`
- 任何「左侧组件库 → 右侧画布拖拽组装 → 自动生成 JSON」的需求**必须复用此通用组件**,禁止重复造轮子
- schema 驱动:新增块类型只需扩展 `BlockSchema[]` 配置,禁止改组件内部
- SortableJS 用法参照 `views/system/role/components/quick-nav-transfer.vue`(动态 import `sortablejs/modular/sortable.complete.esm.js`
- 双向绑定走 `v-model``modelValue` + `update:modelValue`),禁止内部直接修改 props
- OA 场景中的企微模板卡片必须用 `views/system/oa-scene/components/template-card-form.vue`(在通用编辑器之上做 OA 封装)
### 测试发送(全类型循环)
- OA 机器人 modal 的「测试发送」必须循环跑该平台支持的所有消息类型(单平台最多 8 种)
- 后端返回 `results` 数组,前端必须遍历分类型展示成功/失败(不要只展示第一条)
- 测试按钮 loading 用独立状态(不要复用 modal 的 `confirmLoading`
- 测试发送结果以列表形式展示在表单下方,每种类型显示状态/耗时/错误信息
- 测试发送支持两套入口(共用 `testSendOaRobot` API
- **表单弹窗内测试**modal.vue用于「保存前验证密钥」直接传 webhook_url/secret不带 id
- **行级测试**(独立组件 `test-send-modal.vue`):用于列表中已有机器人,仅传 `id`,后端自动取已加密密钥
- 行级测试弹窗入参:`test_content` + `test_at_all` 开关 + `test_mobiles` 手机号多选
- 飞书 webhook 不支持手机号 @,前端必须在飞书平台下隐藏手机号字段并提示用户(仅 @all 有效)
### 群聊绑定(按平台过滤的 N:N 多选)
- 「OA群聊管理」是独立菜单pid=232 OA 通知父级下,菜单 id=233路由 `/system/oa-chat`
- 群聊管理页结构必须与 `oa-robot` 一致:`index.vue + api/index.ts + config/{table,search,form}.ts + components/modal.vue`
- 机器人表单中的「绑定群聊」字段必须用 `Checkbox.Group`
- 群聊列表通过 `getOaChatListByPlatform()` 一次性拉取全量(按平台分组的对象)
- 前端按当前 `platform_code` 过滤展示(平台切换时自动过滤掉不属于当前平台的勾选)
- 平台切换通过 `componentProps.onChange` 监听vben form 字段值变化无现成 watch必须通过 schema 注入 onChange 回调)
- 编辑场景下,机器人详情接口返回 `chat_ids: number[]`,前端 `selectedChatIds` 直接回显
- 提交时把 `chat_ids` 数组合并到 payload后端 `OaRobotController::notRequest` 已包含该字段)
- 列表展示用后端拼接好的 `chat_names` 字符串(按「、」拼接),详情接口才返回 `chat_ids` 数组

2
.gitignore vendored
View File

@@ -49,3 +49,5 @@ vite.config.ts.*
*.sln
*.sw?
.history
/.mimocode/
/.cursor/skills/

View File

@@ -0,0 +1,617 @@
<script setup>
import { ref, provide, nextTick, onMounted, onUnmounted } from 'vue';
import { message } from 'ant-design-vue';
import { useFileUpload } from '../composables/useFileUpload.ts';
import { useRecording } from '../composables/useRecording.ts';
import { useChatStore } from '../stores/chat.ts';
import { useThemeStore } from '../stores/theme.ts';
import { useUserStore } from '../stores/user.ts';
import { sendMessage } from '../utils/request.ts';
import CustomTextarea from './CustomTextarea.vue';
import EmojiPicker from './EmojiPicker.vue';
import FileUploadPreview from './FileUploadPreview.vue';
// 定义 props
const props = defineProps({
currentFriend: {
type: Object,
default: () => ({})
}
});
const userStore = useUserStore();
const chatStore = useChatStore();
const themeStore = useThemeStore();
const messageText = ref('');
const messageInputRef = ref(null);
const showEmojiPicker = ref(false);
// 使用文件上传组合式函数
const {
uploadPreview,
imageInput,
videoInput,
triggerFileInput,
handleFileUpload,
cancelUpload,
} = useFileUpload();
// 使用录音组合式函数
const { isRecording, startRecording, stopRecording } = useRecording();
// 发送上传的文件
const sendUploadedFile = () => {
if (!uploadPreview.value) return;
const messageObj = {
type: uploadPreview.value.type,
content: uploadPreview.value.url,
time: new Date().toLocaleTimeString().slice(0, 5),
senderId: `${userStore.currentUser.id}`,
receiverId: chatStore.currentFriend.id,
read: true,
duration: 0,
};
chatStore.addMessage(messageObj, userStore.currentUser.id);
// 发送到服务器
sendMessage({
senderId: userStore.currentUser.id,
receiverId: chatStore.currentFriend.id,
type: uploadPreview.value.type,
content: uploadPreview.value.url,
}).catch((error) => {
console.error('发送文件失败:', error);
message.error('文件发送失败');
});
uploadPreview.value = null;
};
// 提供给子组件
provide('uploadPreview', uploadPreview);
provide('cancelUpload', cancelUpload);
provide('sendFile', sendUploadedFile);
// 发送文本消息
const sendTextMessage = async () => {
const content = messageText.value.trim();
if (!content && !uploadPreview.value) return;
let messageContent = content;
let messageType = 'text';
// 如果有文件预览,发送文件
if (uploadPreview.value) {
messageContent = uploadPreview.value.url;
messageType = uploadPreview.value.type;
uploadPreview.value = null;
}
// 创建消息对象
const messageObj = {
type: messageType,
content: messageContent,
time: new Date().toLocaleTimeString().slice(0, 5),
senderId: `${userStore.currentUser.id}`,
receiverId: chatStore.currentFriend.id,
isSent: true,
read: true,
duration: 0,
};
// return console.log('发送消息:' + userStore.currentUser.id, messageObj);
// 添加到本地消息列表
chatStore.addMessage(messageObj, userStore.currentUser.id);
// 发送到服务器
try {
await sendMessage({
senderId: userStore.currentUser.id,
receiverId: chatStore.currentFriend.id,
roomId: chatStore.currentFriend.room_id,
type: messageType,
content: messageContent,
});
} catch (error) {
console.error('发送消息失败:', error);
message.error('消息发送失败');
}
// 清空输入框
messageText.value = '';
// 重置焦点
nextTick(() => {
if (messageInputRef.value) {
messageInputRef.value.focus();
}
});
};
// 处理键盘事件
const handleKeydown = (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendTextMessage();
}
};
// 处理粘贴文件
const handlePasteFile = (file) => {
console.log('检测到粘贴文件:', file);
// 根据文件类型自动选择处理方式
if (file.type.startsWith('image/')) {
handleFileFromPaste(file, 'image');
} else if (file.type.startsWith('video/')) {
handleFileFromPaste(file, 'video');
} else {
message.warning('不支持的文件类型');
}
};
// 处理粘贴的文件
const handleFileFromPaste = (file, type) => {
// 文件大小检查
const maxSize = type === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
if (file.size > maxSize) {
const maxSizeMB = maxSize / 1024 / 1024;
message.error(
`文件大小超过限制!${type === 'image' ? '图片' : '视频'}最大${maxSizeMB}MB`,
);
return;
}
const reader = new FileReader();
reader.addEventListener('load', (e) => {
uploadPreview.value = {
type,
url: e.target.result,
name: file.nick_name,
size: file.size,
file,
};
});
reader.onerror = (error) => {
console.error('文件读取失败:', error);
message.error('文件读取失败,请重试');
};
reader.readAsDataURL(file);
};
// 切换表情选择器
const toggleEmojiPicker = () => {
showEmojiPicker.value = !showEmojiPicker.value;
};
// 插入表情
const insertEmoji = (emoji) => {
messageText.value += emoji;
showEmojiPicker.value = false;
nextTick(() => {
if (messageInputRef.value) {
messageInputRef.value.focus();
}
});
};
// 监听录音完成事件
const handleAudioRecorded = (event) => {
const { url, duration } = event.detail;
const messageObj = {
type: 'audio',
content: url,
time: new Date().toLocaleTimeString().slice(0, 5),
senderId: `${userStore.currentUser.id}`,
receiverId: chatStore.currentFriend.id,
isSent: true,
read: true,
duration,
};
chatStore.addMessage(messageObj, userStore.currentUser.id);
// 发送到服务器
sendMessage({
senderId: userStore.currentUser.id,
receiverId: chatStore.currentFriend.id,
roomId: chatStore.currentFriend.room_id,
isSent: true,
type: 'audio',
content: url,
duration,
}).catch((error) => {
console.error('发送音频失败:', error);
message.error('音频发送失败');
});
};
onMounted(() => {
window.addEventListener('audioRecorded', handleAudioRecorded);
// 点击外部关闭表情选择器
document.addEventListener('click', (event) => {
if (
!event.target.closest('.emoji-picker') &&
!event.target.closest('.function-btn')
) {
showEmojiPicker.value = false;
}
});
});
onUnmounted(() => {
window.removeEventListener('audioRecorded', handleAudioRecorded);
});
// 定义 emits
const emit = defineEmits([
'sendMessage',
'openPrescription' // 添加开方事件
]);
// 开方功能触发函数
const handleOpenPrescription = () => {
// 获取当前会话的register_id这里假设可以通过某种方式获取
// 例如从聊天存储或props中获取
const registerId = props.currentFriend?.register_id || 0;
if (registerId) {
emit('openPrescription', registerId);
} else {
console.warn('无法获取当前会话的register_id');
// 可以添加一个提示或使用默认值
emit('openPrescription', 0);
}
};
</script>
<template>
<div class="message-input-container" :class="{ dark: themeStore.isDarkMode }">
<!-- 工具栏 -->
<div class="toolbar">
<button
class="toolbar-button prescription-button"
@click="handleOpenPrescription"
title="开方"
>
<i class="iconfont icon-prescription"></i>
开方
</button>
<button
class="toolbar-button"
@click="triggerFileInput('image')"
title="发送图片"
>
<i class="fas fa-image"></i>
图片
</button>
<button
class="toolbar-button"
@click="triggerFileInput('video')"
title="发送视频"
>
<i class="fas fa-video"></i>
视频
</button>
<button
:class="{ active: showEmojiPicker }"
class="toolbar-button"
title="选择表情"
@click="toggleEmojiPicker"
>
<i class="fas fa-smile"></i>
表情
</button>
<button
:class="{ recording: isRecording }"
class="toolbar-button record-btn"
title="按住录音"
@mousedown="startRecording"
@mouseleave="stopRecording"
@mouseup="stopRecording"
@touchend="stopRecording"
@touchstart="startRecording"
>
<i class="fas fa-microphone"></i>
录音
</button>
</div>
<!-- 文件上传预览 -->
<FileUploadPreview v-if="uploadPreview" />
<!-- 表情选择器 -->
<EmojiPicker v-if="showEmojiPicker" class="mb-10" @select="insertEmoji" />
<div class="input-container">
<!-- 输入框区域 -->
<div class="input-section">
<CustomTextarea
ref="messageInputRef"
v-model="messageText"
:auto-resize="true"
:detect-paste="true"
:max-rows="4"
class="message-textarea"
placeholder="输入消息... (支持拖拽文件)"
@keydown="handleKeydown"
@paste-file="handlePasteFile"
/>
<button
:disabled="!messageText.trim() && !uploadPreview"
class="send-btn"
title="发送消息"
@click="sendTextMessage"
>
<i class="fas fa-paper-plane"></i>
<div class="send-ripple"></div>
</button>
</div>
</div>
<!-- 隐藏的文件输入框 -->
<input
ref="imageInput"
accept="image/*"
class="hidden"
type="file"
@change="handleFileUpload"
/>
<input
ref="videoInput"
accept="video/*"
class="hidden"
type="file"
@change="handleFileUpload"
/>
</div>
</template>
<style scoped>
.message-input-container {
padding: 20px;
border-top: 1px solid #e2e8f0;
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
position: relative;
}
.message-input-container.dark {
border-top-color: #374151;
background: linear-gradient(135deg, #2d2d2d 0%, #1f2937 100%);
}
.input-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.toolbar {
display: flex;
align-items: center;
padding: 8px;
border-top: 1px solid #eee;
}
.toolbar-button {
background: none;
border: none;
cursor: pointer;
padding: 5px 10px;
border-radius: 4px;
margin-right: 5px;
display: flex;
align-items: center;
color: #666;
}
.toolbar-button:hover {
background-color: #f0f0f0;
}
.prescription-button {
color: #455cda;
font-weight: 500;
}
.prescription-button:hover {
background-color: #e6e9ff;
}
.function-buttons {
display: flex;
gap: 12px;
align-items: center;
}
.function-btn {
position: relative;
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
color: #64748b;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.function-btn:hover {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
transform: translateY(-2px) scale(1.05);
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.3);
}
.function-btn.active {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
transform: scale(1.1);
}
.function-btn.recording {
background: linear-gradient(135deg, #ff6b6b, #ee5a52);
color: white;
animation: pulse-recording 1s infinite;
}
@keyframes pulse-recording {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
.recording-wave {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 60px;
height: 60px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
animation: wave-expand 1.5s infinite;
}
@keyframes wave-expand {
0% {
transform: translate(-50%, -50%) scale(0.8);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(1.5);
opacity: 0;
}
}
.btn-ripple {
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: all 0.6s ease;
}
.function-btn:active .btn-ripple {
width: 100px;
height: 100px;
}
.message-input-container.dark .function-btn {
background: linear-gradient(135deg, #374151, #4b5563);
color: #9ca3af;
}
.message-input-container.dark .function-btn:hover {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
}
.input-section {
display: flex;
align-items: end;
gap: 12px;
position: relative;
}
.message-textarea {
flex: 1;
}
.send-btn {
position: relative;
width: 52px;
height: 52px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.3);
}
.send-btn:hover:not(:disabled) {
transform: translateY(-2px) scale(1.05);
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.4);
}
.send-btn:disabled {
background: #94a3b8;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.send-ripple {
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
transform: translate(-50%, -50%);
transition: all 0.6s ease;
}
.send-btn:active:not(:disabled) .send-ripple {
width: 80px;
height: 80px;
}
.hidden {
display: none;
}
/* 响应式设计 */
@media (max-width: 768px) {
.function-buttons {
gap: 8px;
}
.function-btn {
width: 40px;
height: 40px;
font-size: 16px;
}
.send-btn {
width: 48px;
height: 48px;
font-size: 16px;
}
}
</style>

View File

@@ -0,0 +1,793 @@
<script setup lang="ts">
import { ref, watch, computed, onMounted } from 'vue';
import {
Button,
Card,
Col,
Descriptions,
Image,
ImagePreviewGroup,
InputNumber,
message,
Modal,
notification,
RadioGroup,
RadioButton,
Row,
Select,
SelectOption,
Tag,
Textarea,
Timeline,
TimelineItem
} from 'ant-design-vue';
import { DeleteTwoTone, MinusOutlined, PlusOutlined } from '@ant-design/icons-vue';
import { debounce } from 'lodash-es';
import { getRegisterStatus } from '#/util/tool';
import {
addWestPrescription,
checkChineseMedicineConflictApi,
getDrugUseList,
getMyStoreListApi,
getPatientItem,
getProcessRuleList,
getProductListDoctorReception,
switchStoreApi,
} from '#/views/doctor/doctor-reception/api';
// 定义 props
const props = defineProps({
visible: {
type: Boolean,
default: false
},
registerId: {
type: [Number, String],
required: true
}
});
// 定义 emits
const emit = defineEmits(['update:visible', 'prescriptionSent']);
// 患者数据
const patientInfo = ref<null | any>(null);
const userPatientHealthInquiry = ref<null | any>(null);
const prescriptionList = ref([]);
// 药品使用相关数据
const drugUseNum = ref([]);
const drugUseFrequency = ref([]);
const drugUseType = ref([]);
const drugUnit = ref([]);
const drugTime = ref([]);
const drugUseWay = ref([]);
const myStoreList = ref([]);
const myStoreId = ref(0);
const activeCategory = ref(2);
const diagnosis = ref('');
const medicalAdvice = ref('');
const treatmentPrice = ref(0);
const ruleType = ref(1);
const drugList = ref([]);
// 计算商品总价
const totalProductCost = computed(() => {
if (currentDrugs.value.length === 0) {
return 0;
}
// 如果是中药的时候,计算总价格
if (activeCategory.value === 1) {
return currentDrugs.value.reduce(
(sum, drug) => sum + drug.price * (drug.number || 1) * dosage.value,
0,
);
}
return currentDrugs.value.reduce(
(sum, drug) => sum + drug.price * (drug.select_number || 1),
0,
);
});
// 计算总价
const totalCost = computed(() => {
return totalProductCost.value + processingFee.value;
});
const processRulePrice = ref(0);
const calcMethod = ref(0);
// 计算加工费
const processingFee = computed(() => {
if (ruleType.value === 1) {
return 0;
}
// 已选择药品为空的时候是0
if (currentDrugs.value.length === 0) {
return 0;
}
// 获取本次选择的加工规则
childProcessRuleList.value.find((item) => {
if (item.id === childProcessRuleId.value) {
calcMethod.value = item.calc_method;
processRulePrice.value = item.price;
}
});
// 模式1固定价格模式
if (calcMethod.value === 1) {
return processRulePrice.value;
}
// 模式2单价*用量
if (calcMethod.value === 2) {
return processRulePrice.value * dosage.value;
}
// 模式3单价*用量*数量
if (calcMethod.value === 3) {
const number = currentDrugs.value.reduce(
(sum, drug) => sum + drug.number,
0,
);
return processRulePrice.value * dosage.value * number;
}
return 0;
});
const visible = ref<boolean>(false);
const previewImage = ref([]);
// 说明书预览
const setVisible = (value, instruction = ''): void => {
previewImage.value = [];
if (instruction == '') {
message.error('该产品没有说明书!');
} else {
if (typeof instruction === 'string') {
// 如果有逗号,则进行分割 没有则追加到previewImage
if (instruction.includes(',')) {
instruction = instruction.split(',');
previewImage.value = instruction;
} else {
previewImage.value.push(instruction);
}
}
visible.value = value;
}
};
// 数量增加
const increment = (index: number) => {
currentDrugs.value[index].select_number++;
saveToLocalStorage();
};
// 数量减少
const decrement = (index: number) => {
if (currentDrugs.value[index].select_number > 1) {
currentDrugs.value[index].select_number--;
saveToLocalStorage();
}
};
// 保存到本地存储
const saveToLocalStorage = () => {
localStorage.setItem(
`prescriptionData-chat-${props.registerId}`,
JSON.stringify(currentDrugs.value),
);
};
// 删除药品(本地)
const removeDrug = (index: number) => {
currentDrugs.value.splice(index, 1);
updateLocalStorage();
};
// 修改缓存的处方信息
const updateLocalStorage = () => {
localStorage.setItem(
`prescriptionData-chat-${props.registerId}`,
JSON.stringify(currentDrugs.value),
);
getCurrentDrugs();
};
const currentDrugs = ref([]);
const selectProductId = ref(0);
// 获取缓存的处方信息
const getCurrentDrugs = () => {
currentDrugs.value = JSON.parse(
localStorage.getItem(`prescriptionData-chat-${props.registerId}`) || '[]',
);
};
const doctorSecondSign = ref(0);
const checkData = ref([]);
// 检查中药的相冲
const checkChineseMedicineConflict = () => {
if (activeCategory.value === 2) {
sendPrescription();
return;
}
notification.info({
message: '正在检查药物相冲',
duration: 1,
description: '正在检查药物相冲,请稍等...',
});
const names = currentDrugs.value.map((item) => item.drug_name);
checkChineseMedicineConflictApi({names: names}).then((res) => {
if (res.is_exist === true) {
checkData.value.message = res.message;
doctorSecondSignModal.value = true;
} else {
notification.success({
message: '检查成功',
duration: 3,
description: '暂无相冲药品',
});
sendPrescription();
}
});
return;
};
// 发送处方
const sendPrescription = () => {
if (currentDrugs.value.length === 0) {
message.error('请选择药品');
return;
}
if (activeCategory.value === 1) {
if (ruleType.value === 1) {
if (packageMethodId.value == null) {
message.error('请选择包法');
return;
}
} else {
if (processRuleId.value == null) {
message.error('请选择制剂');
return;
}
if (processRuleNoteId.value == null) {
message.error('请选择规格');
return;
}
if (childProcessRuleId.value == null) {
message.error('请选择备注');
}
}
}
if (diagnosis.value === '') {
message.error('诊断结果不能为空');
return;
}
if (medicalAdvice.value === '') {
message.error('医嘱不能为空');
return;
}
// 发送处方逻辑
addWestPrescription({
// 当前活动患者的个人信息
patient: patientInfo.value,
// 当前处方中的药品列表
drugs: currentDrugs.value,
// 诊断信息
diagnosis: diagnosis.value,
// 医嘱信息
medicalAdvice: medicalAdvice.value,
// 总费用
total: totalCost.value,
// 费用类别
category: category.value,
// 药品类型
drug_type: 2, // 默认为处方类型
// 从props获取的挂号ID
register_id: props.registerId,
// 治疗费用
treatment_price: treatmentPrice.value,
// 包装方式ID
package_method_id: packageMethodId.value,
// 加工规则ID
process_rule_id: processRuleId.value,
// 加工规则备注ID
process_rule_note_id: processRuleNoteId.value,
// 子加工规则ID
child_process_rule_id: childProcessRuleId.value,
// 加工规则类型
process_rule_type: ruleType.value,
// 处方类型
prescription_type: activeCategory.value,
// 加工费
processing_fee: processRulePrice.value,
// 用药剂量
dosage: dosage.value,
// 每日用药次数
day_dosage: dayDosage.value,
// 是否二次签名
doctor_second_sign: doctorSecondSign.value,
}).then(() => {
message.success('处方已发送');
// 清空当前数据
currentDrugs.value = [];
diagnosis.value = '';
medicalAdvice.value = '';
packageMethodId.value = 2;
processRulePrice.value = 0;
dosage.value = 7;
dayDosage.value = 2;
newDrugInfo.value = {};
updateLocalStorage();
emit('prescriptionSent');
});
};
// 药品分类
const categories = [
{label: '中药', value: 1},
{label: '中成(西)药', value: 2},
];
const category = ref(1);
// 基础配置
const processRuleList = ref([]);
// 子配置
const childProcessRuleList = ref([]);
// 规格
const processRuleNoteList = ref([]);
const processRuleId = ref();
const processRuleNoteId = ref();
const childProcessRuleId = ref();
function getProcessRuleListByDoctor(pid = 0, ruleId = 0) {
let data = {};
data =
ruleId === 0
? {
pid,
}
: {
rule_id: ruleId,
};
getProcessRuleList(data).then((value) => {
if (ruleId !== 0) {
processRuleNoteList.value = value;
} else if (pid === 0) {
processRuleList.value = value;
} else {
childProcessRuleList.value = value;
}
});
}
// 选择煎法
function selectProcessRule(id) {
processRuleId.value = id;
getProcessRuleListByDoctor(id);
}
// 选择规格
function selectProcessRuleNot(id) {
childProcessRuleId.value = id;
getProcessRuleListByDoctor(0, id);
}
// 选择备注
function selectProcessRuleNotCommit(id) {
processRuleNoteId.value = id;
}
const packageMethodId = ref(2);
const packageMethod = [
{label: '味包', value: 1},
{label: '剂包', value: 2},
];
function selectPackageMethod(id) {
packageMethodId.value = id;
}
// 剂量
const dosage = ref(7);
const dayDosage = ref(2);
function updateChineseNumber() {
updateLocalStorage();
}
// 新药品确认弹窗
const showNewDrugModal = ref(false);
// 二次签名确认弹窗
const doctorSecondSignModal = ref(false);
// 在新的药品数量框失去焦点后弹出新药品弹窗
function newDrugBlur() {
if (newDrugInfo.value.id > 0 && newDrugInfo.value.number > 0) {
showNewDrugModal.value = true;
}
}
function newDrugModalOk() {
addDrugByChinese();
showNewDrugModal.value = false;
}
function doctorSecondSignModalOk() {
doctorSecondSign.value = 1;
doctorSecondSignModal.value = false;
sendPrescription();
}
function updateChineseNumberGoNewDrug(event: KeyboardEvent) {
if (event.key === 'Enter') {
updateLocalStorage();
setTimeout(() => {
// 获取新药品卡片中的克数输入框并聚焦
const newDrugNumberInput = document.querySelector(
'.new-select-drug-name input',
);
if (newDrugNumberInput) {
(newDrugNumberInput as HTMLElement).focus();
}
}, 100);
}
}
// 获取药品列表
const getDrugListByWesternModal = debounce(async (searchText = '') => {
if (searchText === '' && activeCategory.value === 1) {
drugList.value = [];
return;
}
getCurrentDrugs();
try {
const res = await getProductListDoctorReception({
store_id: myStoreId.value,
type: activeCategory.value,
name: searchText,
});
drugList.value = res.map((item) => {
const matched = currentDrugs.value.find((v) => v.index_id === item.id);
return {
...item,
select_number: matched?.select_number || 0,
drug: {
...item.drug,
number: matched?.number || 1,
},
};
});
} catch (error) {
console.error('获取药品列表失败:', error);
message.error('获取药品列表失败');
}
}, 300);
// 选择药品
function selectNewDrugInfo() {
// 如果是新药品输入框,调用添加新药品方法
const check = currentDrugs.value.find((v) => v.id === newDrugInfo.value.id);
if (check) {
newDrugInfo.value.id = '';
message.error('该药品已经在处方中了!');
return;
}
const data = drugList.value.find((v) => v.drug_id === newDrugInfo.value.id);
newDrugInfo.value.price = data.price;
newDrugInfo.value.name = data.drug.drug_name;
setTimeout(() => {
// 获取新药品卡片中的克数输入框并聚焦
const newDrugNumberInput = document.querySelector(
'.new-number-input input',
);
if (newDrugNumberInput) {
(newDrugNumberInput as HTMLElement).focus();
}
}, 100);
}
const selectChineseIndex = ref(-1);
const selectChineseId = ref(0);
// 选择药品
function selectOldDrugInfo(id) {
const check = JSON.parse(
localStorage.getItem(`prescriptionData-chat-${props.registerId}`) || '[]',
).find((v) => v.id === id);
if (check) {
currentDrugs.value[selectChineseIndex.value].id = selectChineseId.value;
drugList.value = [];
message.error('该药品已经在处方中了!');
return;
}
const oldDrugInfo = currentDrugs.value[selectChineseIndex.value];
// 如果是新药品输入框,调用添加新药品方法
const data = drugList.value.find((v) => v.drug_id === id);
// 创建新的商品对象(避免直接修改原始数据)
const newProduct = {
// 索引ID用于在列表中查找
index_id: data.id,
// 药品ID
id: data.drug.id,
// 药品名称
drug_name: data.drug.drug_name,
// 药品数量
number: data.drug.number,
// 药品使用数量信息
use_num: drugTime.value.find((item) => item.id === data.drug.time_id),
// 药品使用类型信息
use_type: drugUseType.value.find((item) => item.id === data.drug.type_id),
// 药品使用频率信息
use_frequency: drugUseFrequency.value.find(
(item) => item.id === data.drug.frequency_id,
),
// 药品单位信息
unit: drugUnit.value.find((item) => item.id === data.drug.unit_id),
// 药品价格
price: data.price,
// 药品使用方式ID
way_id: data.drug?.way_id,
// 药品使用方式信息
use_ways: drugUseWay.value.find((item) => item.id === data.drug.way_id),
// 药品使用时间ID
time_id: data.drug.time_id,
// 药品类型ID
type_id: data.drug.type_id,
// 药品使用频率ID
frequency_id: data.drug.frequency_id,
// 药品单位ID
unit_id: data.drug.unit_id,
// 药品图片
image: data.drug.image,
// 药品说明书
instruction: data.drug.instruction,
// 药品类型
type: data.drug.type,
};
currentDrugs.value[selectChineseIndex.value] = newProduct;
updateLocalStorage();
setTimeout(() => {
// 获取新药品卡片中的克数输入框并聚焦
const newDrugNumberInput = document.querySelector(
`.old-number-input-${selectChineseIndex.value} input`,
);
if (newDrugNumberInput) {
(newDrugNumberInput as HTMLElement).focus();
}
}, 100);
}
// 写入当前修改的下标
function setSelectChineseIndex(index, id) {
selectChineseIndex.value = index;
selectChineseId.value = id;
}
// 查询中药列表
function searchOption(inputValue) {
getDrugListByWesternModal(inputValue);
}
// 添加中药
function addDrugByChinese() {
// 如果是新药品输入框,调用添加新药品方法
const check = currentDrugs.value.find(
(v) => v.id === newDrugInfo.value.id,
);
if (check) {
message.error('该药品已经在处方中了!');
return;
}
const data = drugList.value.find(
(v) => v.drug_id === newDrugInfo.value.id,
);
if (data === null || data === undefined) {
message.error('请选择药品');
return;
}
data.drug.number = newDrugInfo.value.number;
data.drug.way_id = newDrugInfo.value.way_id;
addProducts(data);
newDrugInfo.value = {};
}
// 添加操作
function selectDrugByNewDrugInfo(event: KeyboardEvent, isNewDrug: boolean) {
// 如果按下的是回车键
if (event.key === 'Enter') {
// 阻止默认行为(防止表单提交)
event.preventDefault();
if (isNewDrug) {
// 如果是新药品输入框,调用添加新药品方法
addDrugByChinese();
} else {
// 如果是已有药品输入框,调用更新数量方法
updateChineseNumber();
}
setTimeout(() => {
// 获取新药品卡片中的克数输入框并聚焦
const newDrugNumberInput = document.querySelector(
'.new-select-drug-name input',
);
if (newDrugNumberInput) {
(newDrugNumberInput as HTMLElement).focus();
}
}, 100);
}
}
// 添加商品到处方
function addProducts(data) {
// 查找是否已存在于选择列表中
const existItem = currentDrugs.value.find(
(item) => item.index_id === data.id,
);
if (existItem) {
message.warn('已经存在了');
return;
}
// 创建新的商品对象(避免直接修改原始数据)
const newProduct = {
// 索引ID用于在列表中查找
index_id: data.id,
// 药品ID
id: data.drug.id,
// 药品名称
drug_name: data.drug.drug_name,
// 药品数量
number: data.drug.number,
// 药品使用数量信息
use_num: drugTime.value.find((item) => item.id === data.drug.time_id),
// 药品使用类型信息
use_type: drugUseType.value.find((item) => item.id === data.drug.type_id),
// 药品使用频率信息
use_frequency: drugUseFrequency.value.find(
(item) => item.id === data.drug.frequency_id,
),
// 药品单位信息
unit: drugUnit.value.find((item) => item.id === data.drug.unit_id),
// 药品价格
price: data.price,
// 药品使用方式ID
way_id: data.drug?.way_id,
// 药品使用方式信息
use_ways: drugUseWay.value.find((item) => item.id === data.drug.way_id),
// 药品使用时间ID
time_id: data.drug.time_id,
// 药品类型ID
type_id: data.drug.type_id,
// 药品使用频率ID
frequency_id: data.drug.frequency_id,
// 药品单位ID
unit_id: data.drug.unit_id,
// 药品图片
image: data.drug.image,
// 药品说明书
instruction: data.drug.instruction,
// 药品类型
type: data.drug.type,
};
// 首次添加初始化数量为1
newProduct.select_number = 1;
// 添加到选中列表
currentDrugs.value.push(newProduct);
// 更新本地存储
updateLocalStorage();
// 提示成功
message.success(`已将${newProduct.drug_name}添加到清单中!`);
}
const newDrugInfo = ref({});
function selectProductChange(id) {
selectProductId.value = id;
}
// 选择中药用法
function selectDrugUseWayChange(id) {
currentDrugs.value = currentDrugs.value.map((item) =>
// 修改item下面drug的 frequency_id = id
item.index_id === selectProductId.value
? {
...item,
way_id: id,
use_ways: drugUseWay.value.find((value) => value.id === id),
}
: item,
);
updateLocalStorage();
}
// 切换tab
function tabChange(id) {
localStorage.setItem(`activeCategory-chat-${props.registerId}`, id);
currentDrugs.value = [];
updateLocalStorage();
activeCategory.value = id;
}
// 根据创制string来把字符串的逗号分开变成数组
function splitString(str: string) {
if (!str) {
return [];
}
return str.split(',');
}
// 获取我的诊所列表
function getMyStoreList() {
getMyStoreListApi().then((res) => {
myStoreList.value = res;
if (res.length > 0) {
myStoreId.value = res[0].id;
}
});
}
// 获取药品使用方式列表
function getDrugUseListByWesternModal() {
getDrugUseList().then((res) => {
// 更新各种药品使用相关数据
drugUseNum.value = res.drug_use_num;
drugUseFrequency.value = res.drug_use_frequency;
drugUseType.value = res.drug_use_type;
drugUnit.value = res.drug_unit;
drugTime.value = res.drug_time;
drugUseWay.value = res.drug_use_way;
});
}
// 获取患者信息
async function getPatientInfo() {
try {
const res = await getPatientItem(props.registerId);
patientInfo.value = res;
userPatientHealthInquiry.value = res.user_patient_health_inquiry;
prescriptionList.value = res.prescription;
} catch (error) {
console.error('获取患者信息失败:', error);
}
}
// 监听 visible prop 变化
watch(() => props.visible, (newVal) => {
visible.value = newVal;
});
onMounted(() => {
getMyStoreList();
getDrugUseListByWesternModal();
getProcessRuleListByDoctor();
getPatientInfo();
getCurrentDrugs();
});
</script>
<template>
<Modal
:open="visible"
:width="1200"
title="开方模块"
@cancel="emit('update:visible', false)"
@ok="sendPrescription"
>
</Modal>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,724 @@
<script setup lang="ts">
</script>
<template>
<div class="prescription-module">
<div style="width: 0; height: 0; overflow: hidden">
<ImagePreviewGroup
:preview="{
visible,
onVisibleChange: setVisible,
}"
:style="{ display: 'none' }"
>
<Image
v-for="image in previewImage"
v-if="visible === true"
:src="image"
:width="200"
/>
</ImagePreviewGroup>
</div>
<!-- 患者信息 -->
<Descriptions
v-if="patientInfo"
:column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 2, xs: 2 }"
bordered
title="患者信息"
class="mb-5"
>
<Descriptions.Item label="患者姓名">
{{ patientInfo.user_patient?.name }}
</Descriptions.Item>
<Descriptions.Item label="患者年龄">
{{ patientInfo.user_patient?.age }}
</Descriptions.Item>
<Descriptions.Item label="患者性别">
{{
patientInfo.user_patient?.sex === 1
? '男'
: patientInfo.user_patient?.sex === 2
? '女'
: '未填写'
}}
</Descriptions.Item>
</Descriptions>
<Descriptions
v-if="userPatientHealthInquiry"
:column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 2, xs: 2 }"
bordered
class="mt-5 mb-5"
title="健康信息"
>
<Descriptions.Item label="肝功能">
<span>{{
userPatientHealthInquiry.liver_function === 0 ? '正常' : '异常'
}}</span>
<p
v-if="userPatientHealthInquiry.liver_function === 1"
class="mt-3"
>
<Tag
v-for="item in splitString(
userPatientHealthInquiry.liver_index,
)"
color="#455cda"
>
{{ item }}
</Tag>
</p>
</Descriptions.Item>
<Descriptions.Item label="肾功能">
<span>{{
userPatientHealthInquiry.renal_function === 0 ? '正常' : '异常'
}}</span>
<p
v-if="userPatientHealthInquiry.renal_function === 1"
class="mt-3"
>
<Tag
v-for="item in splitString(
userPatientHealthInquiry.renal_index,
)"
color="#455cda"
>
{{ item }}
</Tag>
</p>
</Descriptions.Item>
<Descriptions.Item label="既往史">
<span>{{
userPatientHealthInquiry.person_status === 0 ? '无' : '有'
}}</span>
<p v-if="userPatientHealthInquiry.person_status === 1" class="mt-3">
<Tag
v-for="item in splitString(
userPatientHealthInquiry.person_history,
)"
color="#455cda"
>
{{ item }}
</Tag>
</p>
</Descriptions.Item>
<Descriptions.Item label="过敏史">
<span>{{
userPatientHealthInquiry.allergic_status === 0 ? '无' : '有'
}}</span>
<p
v-if="userPatientHealthInquiry.allergic_status === 1"
class="mt-3"
>
<Tag
v-for="item in splitString(
userPatientHealthInquiry.allergic_history,
)"
color="#455cda"
>
{{ item }}
</Tag>
</p>
</Descriptions.Item>
<Descriptions.Item label="家庭遗传史">
<span>{{
userPatientHealthInquiry.family_status === 0 ? '无' : '有'
}}</span>
<p v-if="userPatientHealthInquiry.family_status === 1" class="mt-3">
<Tag
v-for="item in splitString(
userPatientHealthInquiry.family_history,
)"
color="#455cda"
>
{{ item }}
</Tag>
</p>
</Descriptions.Item>
</Descriptions>
<Descriptions
:column="{ xxl: 1, xl: 1, lg: 1, md: 1, sm: 1, xs: 1 }"
class="mt-5 mb-5"
title="处方记录"
>
<Descriptions.Item>
<Timeline>
<TimelineItem v-for="item in prescriptionList" :key="item.id">
<Tag color="#455cda">{{ item.prescription_no }}</Tag>
<span class="time-line-item-created">{{
item.created_at
}}</span>
<Tag class="ml-5" :color="item.category === 1? '' : '#455cda'">
{{ item.prescription_type === 1 ? '中药处方' : item.prescription_type === 5 ? '产品服务包' : '西药处方' }} /
{{ item.category === 1 ? '自费' : '医保' }}
</Tag>
<Tag v-if="item.status === 0" color="warning">待审核</Tag>
<Tag v-else-if="item.status === 1" color="success">已通过</Tag>
<Tag v-else-if="item.status === 2" color="error">未通过</Tag>
<Tag v-else-if="item.status === 3" color="#455cda">无需审核</Tag>
<Tag v-else-if="item.status === 4" color="#455cda">无需审核</Tag>
<Tag color="#455cda">处方ID{{ item.id }}</Tag>
</TimelineItem>
</Timeline>
</Descriptions.Item>
</Descriptions>
<!-- 药品分类导航 -->
<div class="drug-categories">
<button
v-for="categoryItem in categories"
:key="categoryItem.value"
:class="{ active: activeCategory === categoryItem.value }"
@click="tabChange(categoryItem.value)"
>
{{ categoryItem.label }}
</button>
</div>
<div class="mt-5">
<RadioGroup
v-model:value="category"
>
<RadioButton :value="1">自费</RadioButton>
<RadioButton :value="2">医保</RadioButton>
</RadioGroup>
</div>
<!-- 已选药品列表 -->
<!-- 中药 -->
<div v-if="activeCategory === 1" class="mb-5 mt-3 flex" style="flex-wrap: wrap; width: 100%;">
<Row :gutter="[16, 16]">
<Col
v-for="(drug, index) in currentDrugs"
:key="drug.id"
:lg="12"
:md="24"
:sm="24"
:xl="12"
:xs="24"
:xxl="8"
>
<Card class="mt-0 prescription-card" title="">
<div>
<span class="card-index">{{ index + 1 }}、</span>
<p>
<Select
v-model:value="drug.id"
:filter-option="false"
class="old-select-drug-name"
style="min-width: 100px"
placeholder="药名"
show-search
@change="selectOldDrugInfo"
@dropdown-visible-change="
setSelectChineseIndex(index, drug.id)
"
@search="searchOption"
>
<SelectOption v-if="drugList.length === 0" :value="drug.id">
{{ drug.drug_name }}
</SelectOption>
<SelectOption
v-for="(value, index) in drugList"
v-else
:key="index"
:value="value.drug.id"
>
{{ value.drug.drug_name }}
</SelectOption>
</Select>
,
<InputNumber
v-model:value="drug.number"
:class="`w-1/5 old-number-input-${index}`"
style="min-width: 100px"
:controls="false"
@blur="updateChineseNumber"
@keydown="updateChineseNumberGoNewDrug($event)"
>
<template #addonAfter> g</template>
</InputNumber>
,
<span>
<Select
:value="drug?.way_id"
placeholder="用法"
@change="selectDrugUseWayChange"
@dropdown-visible-change="
selectProductChange(drug.index_id)
"
>
<SelectOption :value="0">煎服</SelectOption>
<SelectOption
v-for="(value, index) in drugUseWay"
:key="index"
:value="value.id"
>
{{ value.name }}
</SelectOption>
</Select>
</span>
<p class="card-price">
¥<span>{{ (drug.price * drug.number).toFixed(2) }}</span>
</p>
<Button
class="card-delete"
type="link"
@click="removeDrug(index)"
>
<DeleteTwoTone/>
</Button>
</p>
</div>
</Card>
</Col>
<Col
:lg="12"
:md="24"
:sm="24"
:xl="12"
:xs="24"
:xxl="8"
>
<Card class="prescription-card" title="">
<div>
<span class="card-index">{{ currentDrugs.length + 1 }}、</span>
<div>
<Select
v-model:value="newDrugInfo.id"
:filter-option="false"
class="new-select-drug-name w-1/5"
style="min-width: 100px"
placeholder="药名"
show-search
@change="selectNewDrugInfo"
@search="searchOption"
>
<SelectOption
v-for="(value, index) in drugList"
:key="index"
:value="value.drug_id"
>
{{ value.drug.drug_name }}
</SelectOption>
</Select>
,
<InputNumber
v-model:value="newDrugInfo.number"
class="new-number-input w-1/5"
style="min-width: 100px"
@blur="newDrugBlur"
@keydown="selectDrugByNewDrugInfo($event, true)"
>
<template #addonAfter> g</template>
</InputNumber>
,
<span>
<Select
v-model:value="newDrugInfo.way_id"
placeholder="用法"
@keydown="selectDrugByNewDrugInfo($event, true)"
>
<SelectOption :value="0">煎服</SelectOption>
<SelectOption
v-for="(value, index) in drugUseWay"
:key="index"
:value="value.id"
>
{{ value.name }}
</SelectOption>
</Select>
</span>
<p class="card-price">
¥<span>{{
(
(newDrugInfo.price || 0) * (newDrugInfo.number || 1)
).toFixed(2)
}}元</span>
</p>
</div>
</div>
</Card>
</Col>
</Row>
</div>
<!-- 西(中成)药列表 -->
<div v-else class="selected-drugs">
<div class="drug-table">
<div class="table-header">
<span v-if="activeCategory !== 1">商品图片</span>
<span>商品名称</span>
<span>数量</span>
<span>单价</span>
<span>操作</span>
</div>
<div class="table-body">
<div
v-for="(drug, index) in currentDrugs"
:key="drug.id"
class="table-row"
>
<div
v-if="activeCategory !== 1"
style="width: 120px; margin: 0 auto"
>
<Image :src="drug.image"/>
</div>
<div>
<p>
<span>药品名称:{{ drug.drug_name }}</span>
</p>
<p>
<span>用法:{{
`${drug.use_type?.name}${drug.use_frequency?.name}${drug.use_num?.name},每次${drug.number}${drug.unit?.name}`
}}</span>
</p>
</div>
<div>
<div v-if="activeCategory === 1" class="quantity-control">
<InputNumber
v-model:value="drug.select_number"
class="w-full"
@blur="updateChineseNumber"
>
<template #addonAfter> g</template>
</InputNumber>
</div>
<div v-else class="quantity-control">
<Button type="primary" @click="decrement(index)">-</Button>
<span style="margin: 0 20px">{{ drug.select_number }}</span>
<Button type="primary" @click="increment(index)">+</Button>
</div>
</div>
<span>{{ drug.price }}</span>
<div>
<Button type="link" @click="removeDrug(index)">删除</Button>
<Button
v-if="drug.instruction !== ''"
type="link"
@click="setVisible(true, drug.instruction)"
>
查看说明书
</Button>
</div>
</div>
</div>
</div>
</div>
<!-- 诊断和医嘱 -->
<div class="diagnosis-area">
<div v-if="activeCategory === 1" class="mb-5">
<RadioGroup v-model:value="ruleType">
<RadioButton :value="1">自制剂</RadioButton>
<RadioButton :value="2">委托调剂</RadioButton>
</RadioGroup>
<div v-if="ruleType === 1">
<!-- 选择包法-->
<Select
:value="packageMethodId"
class="mt-3 w-1/5"
placeholder="请选择包法"
@change="selectPackageMethod"
>
<SelectOption
v-for="(value, index) in packageMethod"
:key="index"
:value="value.value"
>
{{ value.label }}
</SelectOption>
</Select>
</div>
<div v-else-if="ruleType === 2">
<!-- 选择制剂-->
<Select
v-model:value="processRuleId"
class="mt-3 w-1/5"
placeholder="请选择制剂"
@change="selectProcessRule"
>
<SelectOption
v-for="(value, index) in processRuleList"
:key="index"
:value="value.id"
>
{{ value.name }}
</SelectOption>
</Select>
<!-- 选择规格-->
<Select
v-model:value="childProcessRuleId"
class="ml-3 mt-3 w-1/5"
placeholder="请选择煎法"
@change="selectProcessRuleNot"
>
<SelectOption
v-for="(value, index) in childProcessRuleList"
:key="index"
:value="value.id"
>
{{ value.name }}
</SelectOption>
</Select>
<!-- 选择备注-->
<Select
v-model:value="processRuleNoteId"
class="ml-3 mt-3 w-1/5"
placeholder="请选择备注"
@change="selectProcessRuleNotCommit"
>
<SelectOption
v-for="(value, index) in processRuleNoteList"
:key="index"
:value="value.id"
>
{{ value.note }}
</SelectOption>
</Select>
</div>
<div class="mt-5">
用量:
<InputNumber v-model:value="dosage" class="w-1/5">
<template #addonBefore>
<MinusOutlined
key="prop"
@click="dosage = dosage > 1 ? dosage - 1 : 1"
/>
</template>
<template #addonAfter>
<PlusOutlined
key="add"
@click="dosage = dosage < 100 ? dosage + 1 : 100"
/>
</template>
</InputNumber>
天 / {{ dosage }} 剂
</div>
<div class="mt-5">
频次:
<InputNumber v-model:value="dayDosage" class="w-1/5">
<template #addonBefore>
<MinusOutlined
key="prop"
@click="dayDosage = dayDosage > 1 ? dayDosage - 1 : 1"
/>
</template>
<template #addonAfter>
<PlusOutlined
key="add"
@click="dayDosage = dayDosage < 100 ? dayDosage + 1 : 100"
/>
</template>
</InputNumber>
次/天
</div>
</div>
<Textarea v-model:value="diagnosis" placeholder="输入诊断结果..." class="mb-3"/>
<Textarea v-model:value="medicalAdvice" placeholder="输入医嘱..." class="mb-3"/>
诊疗费用:
<InputNumber
v-model:value="treatmentPrice"
placeholder="请输入诊疗价格"
class="mb-3"
>
<template #addonAfter> /元</template>
</InputNumber>
</div>
<!-- 费用总计 -->
<div class="price-box">
<div class="total-cost">加工费:¥{{ processingFee.toFixed(2) }}</div>
<div class="total-cost">
商品价格:¥{{ totalProductCost.toFixed(2) }}
</div>
<div class="total-cost">总计:¥{{ totalCost.toFixed(2) }}</div>
<Button class="mt-5 w-full" style="display: block" type="primary" @click="checkChineseMedicineConflict">
发送处方
</Button>
</div>
<Modal v-model:open="showNewDrugModal" @ok="newDrugModalOk">
要把【{{ newDrugInfo.name }}】添加到清单吗?
</Modal>
<Modal title="二次签名" v-model:open="doctorSecondSignModal" @ok="doctorSecondSignModalOk">
<div class="doctor-second-sign-title">有毒:</div>
<p v-for="item in checkData.message?.poisonous || []">【{{ item }}】</p>
<div class="doctor-second-sign-title">十八反:</div>
<p v-for="item in checkData.message?.opposition || []">【{{ item }}】</p>
<div class="doctor-second-sign-title">十九畏:</div>
<p v-for="item in checkData.message?.conflict || []">【{{ item }}】</p>
<p class="mt-5" style="color: red;">如需继续开方需要二次签名确认要二次签名吗</p>
</Modal>
</div>
</template>
<style scoped>
.doctor-second-sign-title {
font-size: 16px;
margin: 10px 0;
font-weight: bold;
border-left: 5px solid rgb(55, 71, 162);
padding-left: 10px;
}
.card-index {
font-size: 20px;
font-weight: bold;
position: absolute;
left: 10px;
top: 0;
}
.card-price {
position: absolute;
bottom: 10px;
right: 8px;
color: red;
}
.card-delete {
position: absolute;
top: 3px;
right: 0;
}
.prescription-card {
position: relative;
min-height: 120px;
}
.drug-categories {
display: flex;
gap: 1rem;
margin: 1rem 0;
}
.drug-categories button {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 10px;
cursor: pointer;
background: transparent;
color: inherit;
}
.dark .drug-categories button {
border: 1px solid #333;
}
.drug-categories button.active {
color: #fff;
border-color: transparent;
animation: pulse 0.5s /*infinite*/;
animation-fill-mode: forwards;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
background: #455cda;
transform: scale(1);
}
}
.selected-drugs {
margin: 1rem 0;
border: 1px solid #eee;
border-radius: 8px;
}
.dark .selected-drugs {
border: 1px solid #333;
}
.table-body {
max-height: 30vh;
overflow-y: auto;
}
.table-header {
display: grid;
grid-template-columns: 0.5fr 1fr 1fr 1fr 1fr;
padding: 12px;
font-weight: bold;
text-align: center;
}
.table-row {
display: grid;
grid-template-columns: 0.5fr 1fr 1fr 1fr 1fr;
padding: 12px;
border-bottom: 1px solid #eee;
text-align: center;
height: auto;
align-items: center;
}
.dark .table-row {
border-bottom: 1px solid #333;
}
.diagnosis-area textarea {
width: 100%;
height: 100px;
padding: 8px;
margin: 8px 0;
border: 1px solid #ddd;
border-radius: 4px;
background: transparent;
color: inherit;
}
.dark .diagnosis-area textarea {
border: 1px solid #333;
}
.price-box {
margin-top: 20px;
}
.total-cost {
text-align: right;
font-size: 1.2em;
font-weight: bold;
color: #f56c6c;
margin-top: 1rem;
}
.time-line-item-created {
color: #909399;
font-size: 0.8em;
}
/* 响应式设计 */
@media (max-width: 768px) {
.drug-categories {
flex-wrap: wrap;
}
.table-header,
.table-row {
grid-template-columns: 1fr 1fr 1fr;
}
.table-header span:nth-child(4),
.table-header span:nth-child(5),
.table-row > span:nth-child(4),
.table-row > div:nth-child(5) {
display: none;
}
}
</style>

View File

@@ -1,3 +1,4 @@
# 开发环境配置`n
# 应用标题
VITE_APP_TITLE=萧康云医
@@ -6,3 +7,6 @@ VITE_APP_NAMESPACE=xk-admin-
# 应用版本
XK_APP_VERSION=1.0.0
# WebSocket 连接地址
VITE_WS_URL=ws://localhost:12080/ws

View File

@@ -1,10 +1,11 @@
# 生产环境配置
VITE_BASE=/
# 接口地址
VITE_GLOB_API_URL=/api/admin
# 是否开启压缩,可以设置为 none, brotli, gzip
VITE_COMPRESS=none
VITE_COMPRESS=gzip
# 是否开启 PWA
VITE_PWA=false
@@ -17,3 +18,6 @@ VITE_INJECT_APP_LOADING=true
# 打包后是否生成dist.zip
VITE_ARCHIVER=true
# WebSocket 连接地址
VITE_WS_URL=wss://api.ws.g.xiaokang88.com/ws

View File

@@ -44,9 +44,15 @@
"@vueuse/core": "catalog:",
"ant-design-vue": "catalog:",
"dayjs": "catalog:",
"exceljs": "^4.4.0",
"markdown-it": "^14.1.0",
"pinia": "catalog:",
"sortablejs": "catalog:",
"vue": "catalog:",
"vue-router": "catalog:"
"vue-router": "catalog:",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/sortablejs": "catalog:"
}
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -13,7 +13,8 @@ import { $t } from '@vben/locales';
import {
AutoComplete,
Button, Cascader,
Button,
Cascader,
Checkbox,
CheckboxGroup,
DatePicker,
@@ -60,6 +61,7 @@ export type ComponentType =
| 'DatePicker'
| 'DefaultButton'
| 'Divider'
| 'GalleryPickLink' // OA 场景表单的图片/文件素材选择(透传 acceptTypes 过滤类型)
| 'IconPicker'
| 'Input'
| 'InputNumber'
@@ -77,6 +79,9 @@ export type ComponentType =
| 'TimePicker'
| 'TreeSelect'
| 'Upload'
| 'UploadImage'
| 'UploadImageSortable'
| 'UploadOssFile'
| BaseFormComponentType;
async function initComponentAdapter() {

View File

@@ -19,6 +19,8 @@ setupVbenForm<ComponentType>({
Radio: 'checked',
Switch: 'checked',
Upload: 'fileList',
UploadImage: 'modelValue',
UploadOssFile: 'modelValue',
},
},
defineRules: {

View File

@@ -3,39 +3,120 @@ import { baseRequestClient, requestClient } from '#/api/request';
export namespace AuthApi {
/** 登录接口参数 */
export interface LoginParams {
account: string;
password?: string;
username?: string;
code?: string;
admin_id?: number;
/** @deprecated 兼容旧客户端 */
login_method?: 'phone' | 'login_account' | 'job_number';
username?: string;
phone?: string;
login_account?: string;
job_number?: string;
}
/** 登录接口返回值 */
export interface AccountOption {
account_type?: string;
id: number;
nick_name: string;
role_name: string;
store_name: string;
login_account?: string;
is_current?: boolean;
password_matched?: boolean;
}
export interface LoginResult {
token: string;
token?: string;
need_select?: boolean;
accounts?: AccountOption[];
default_account_id?: number;
default_account_type?: string;
}
export interface RefreshTokenResult {
data: string;
status: number;
}
/** 发送验证码返回结果 */
export interface SendCodeResult {
code?: string;
need_select?: boolean;
accounts?: AccountOption[];
default_account_id?: number;
default_account_type?: string;
}
}
/**
* 登录
*/
export async function loginApi(data: AuthApi.LoginParams) {
return requestClient.post<AuthApi.LoginResult>('login', {
phone: data.username,
if (data.account) {
return requestClient.post<AuthApi.LoginResult>('login', {
account: data.account,
password: data.password,
code: data.code,
admin_id: data.admin_id,
});
}
const params: Record<string, unknown> = {
login_method: data.login_method || 'phone',
password: data.password,
code: data.code,
});
};
if (data.login_method === 'phone') {
params.phone = data.phone || data.username;
} else if (data.login_method === 'login_account') {
params.login_account = data.login_account;
} else if (data.login_method === 'job_number') {
params.job_number = data.job_number;
}
return requestClient.post<AuthApi.LoginResult>('login', params);
}
/**
* 发送验证码
*/
export async function sendVerificationCode(username: string) {
return requestClient.post('send-verification-code', {
username,
});
export async function sendVerificationCode(params: {
account?: string;
password?: string;
admin_id?: number;
login_method?: 'phone' | 'login_account' | 'job_number';
username?: string;
phone?: string;
login_account?: string;
job_number?: string;
}) {
if (params.account) {
return requestClient.post<AuthApi.SendCodeResult>('send-verification-code', {
account: params.account,
password: params.password,
admin_id: params.admin_id,
});
}
const requestParams: Record<string, unknown> = {
login_method: params.login_method || 'phone',
};
if (params.login_method === 'phone') {
requestParams.phone = params.phone || params.username;
} else if (params.login_method === 'login_account') {
requestParams.login_account = params.login_account;
} else if (params.login_method === 'job_number') {
requestParams.job_number = params.job_number;
}
return requestClient.post<AuthApi.SendCodeResult>(
'send-verification-code',
requestParams,
);
}
/**
@@ -62,3 +143,19 @@ export async function logoutApi() {
export async function getAccessCodesApi() {
return requestClient.get<string[]>('auth/codes');
}
/**
* 同手机号可切换的管理员账号
*/
export async function getSiblingAccountsApi() {
return requestClient.get<AuthApi.AccountOption[]>('auth/sibling-accounts');
}
/**
* 切换管理员账号
*/
export async function switchAccountApi(data: {
admin_id: number;
}) {
return requestClient.post<AuthApi.LoginResult>('auth/switch-account', data);
}

View File

@@ -0,0 +1,90 @@
import { requestClient } from '#/api/request';
const prefix = 'file-gallery/';
export interface FileGalleryItem {
id: number;
url: string;
file_name?: string;
file_size: number;
file_size_text: string;
type?: number;
type_text?: string;
type_icon?: string;
group_ids?: number[];
created_at?: string;
original_name?: string;
object_key?: string;
source?: number;
}
export interface FileTypeItem {
id: number;
name: string;
icon?: string;
value: number;
suffix: string;
sort: number;
}
export interface FileGalleryDetail extends FileGalleryItem {
source_text?: string;
updated_at?: string;
}
export async function getFileGalleryList(params?: {
page?: number;
page_size?: number;
pid?: number;
type?: number;
group_id?: number;
keyword?: string;
}) {
return requestClient.get<{
items: FileGalleryItem[];
total: number;
page: number;
page_size: number;
}>(`${prefix}list`, { params });
}
export async function getFileGalleryDetail(id: number) {
return requestClient.get<FileGalleryDetail>(`${prefix}detail`, { params: { id } });
}
export async function deleteFileGalleryItem(id: number) {
return requestClient.post<any>(`${prefix}delete`, { id });
}
export async function renameFileGalleryItem(id: number, file_name: string) {
return requestClient.post<any>(`${prefix}rename`, { id, file_name });
}
export async function moveFileGalleryType(id: number, type: number) {
return requestClient.post<any>(`${prefix}move-type`, { id, type });
}
export async function batchArchiveFiles() {
return requestClient.post<{
reclassified: number;
file_name_filled: number;
}>(`${prefix}batch-archive`);
}
export async function getFileTypes() {
return requestClient.get<FileTypeItem[]>(`${prefix}file-types`);
}
export async function syncFileGalleryFromOss() {
return requestClient.post<{ synced: number; skipped: number; scanned: number }>(`${prefix}sync-oss`);
}
/** 文件加入自定义分组 */
export async function addFileToGroup(file_id: number, group_id: number) {
return requestClient.post<any>(`${prefix}add-to-group`, { file_id, group_id });
}
/** 文件移出自定义分组 */
export async function removeFileFromGroup(file_id: number, group_id: number) {
return requestClient.post<any>(`${prefix}remove-from-group`, { file_id, group_id });
}

View File

@@ -0,0 +1,51 @@
import { requestClient } from '#/api/request';
const prefix = 'file-group/';
export interface FileGroupRecord {
id: number;
name: string;
sort: number;
created_at?: number;
updated_at?: number;
}
export interface FileGroupOptionItem {
id: number;
name: string;
sort: number;
}
/** 分组选项缓存结构 */
export interface FileGroupCachePayload {
hash: string;
items: FileGroupOptionItem[];
}
/**
* 管理端分组列表
*/
export async function getFileGroupList() {
return requestClient.get<FileGroupRecord[]>(`${prefix}list`);
}
/**
* 带 hash 的分组选项:无变化时 result 为 0
*/
export async function getFileGroupOptions(hash?: string) {
return requestClient.get<FileGroupOptionItem[] | 0>(`${prefix}options`, {
params: hash ? { hash } : undefined,
});
}
export async function createFileGroup(data: Partial<FileGroupRecord>) {
return requestClient.post<any>(`${prefix}create`, data);
}
export async function updateFileGroup(data: Partial<FileGroupRecord> & { id: number }) {
return requestClient.post<any>(`${prefix}update`, data);
}
export async function deleteFileGroup(id: number) {
return requestClient.post<any>(`${prefix}delete`, { id });
}

View File

@@ -0,0 +1,35 @@
import { requestClient } from '#/api/request';
const prefix = 'file-type/';
export interface FileTypeRecord {
id: number;
name: string;
icon: string;
value: number;
suffix: string;
sort: number;
status: number;
created_at?: number;
updated_at?: number;
}
export async function getFileTypeList() {
return requestClient.get<FileTypeRecord[]>(`${prefix}list`);
}
export async function getFileTypeDetail(id: number) {
return requestClient.get<FileTypeRecord>(`${prefix}detail`, { params: { id } });
}
export async function createFileType(data: Partial<FileTypeRecord>) {
return requestClient.post<any>(`${prefix}create`, data);
}
export async function updateFileType(data: Partial<FileTypeRecord> & { id: number }) {
return requestClient.post<any>(`${prefix}update`, data);
}
export async function deleteFileType(id: number) {
return requestClient.post<any>(`${prefix}delete`, { id });
}

View File

@@ -2,15 +2,80 @@ import { requestClient } from '#/api/request';
/**
* 文件上传
* @param data
* 通过后端服务器上传文件到OSS
*
* @param data 上传数据包含file字段文件对象
* @returns Promise 返回上传结果包含url字段文件URL
*/
export async function uploadFile(data: any) {
return requestClient.upload('upload/image', data);
}
/**
* 文件上传 - 聊天信息
* 用于聊天场景的文件上传
*
* @param data 上传数据包含file字段文件对象
* @returns Promise 返回上传结果包含url字段文件URL
*/
export async function uploadChatFile(data: any) {
return requestClient.upload('upload/chat-file', data);
}
/**
* 获取OSS上传签名信息
*
* 该接口用于获取OSS直传所需的签名信息包括
* - accessKeyId: 阿里云访问密钥ID
* - policy: Base64编码的上传策略
* - signature: 签名值
* - host: OSS服务端点URL
* - bucket: 存储桶名称(从后端环境变量获取,返回给前端)
* - key: 文件路径前缀
* - expire: 签名过期时间戳
*
* 使用场景:
* - 前端需要直接上传文件到OSS时先调用此接口获取签名
* - 签名具有时效性,需要在有效期内使用
* - 每次上传前建议重新获取签名,确保签名未过期
*
* 错误处理:
* - 如果后端返回错误会通过requestClient的统一错误处理机制处理
* - 调用方需要捕获异常并进行相应处理
*
* @returns Promise<OssSignature> 返回OSS签名信息对象
* @throws 如果API调用失败会抛出错误
*
* @example
* try {
* const signature = await getOssSignature();
* console.log('获取签名成功', signature);
* } catch (error) {
* console.error('获取签名失败', error);
* }
*/
export async function getOssSignature() {
return requestClient.get('/upload/oss-signature');
}
/**
* OSS 直传成功后登记 xk_file
*/
export async function registerOssFile(data: {
url: string;
type?: number;
source?: number;
file_size?: number;
file_name?: string;
}) {
return requestClient.post('/upload/register-oss-file', data);
}
/**
* 通过文件id集合获取文件信息
* @param data
*
* @param data 请求数据包含fileInfoIds字段文件ID数组或逗号分隔的字符串
* @returns Promise 返回文件信息数组
*/
export async function getFileInfoByIds(data: any) {
return requestClient.post('/sys/fileInfo/getFileInfoByIds', data);

View File

@@ -0,0 +1,13 @@
import { requestClient } from '#/api/request';
const orderPrefix = 'order/';
export async function adjustOrderPercent(data: Record<string, any>) {
return requestClient.post(`${orderPrefix}adjust-order-percent`, data);
}
export async function getOrderPriceAdjustConfig(storeId?: number) {
return requestClient.get(`${orderPrefix}price-adjust-config`, {
params: storeId ? { store_id: storeId } : {},
});
}

View File

@@ -16,27 +16,19 @@ import { message } from 'ant-design-vue';
// eslint-disable-next-line n/no-extraneous-import
import { Base64 } from 'js-base64';
import {
ENCRYPT_FIELDS,
isSensitiveField,
maskSensitiveField,
TM_TEXT_SUFFIX,
} from '#/constants/sensitive-fields';
import { useAuthStore } from '#/store';
import { refreshTokenApi } from './core';
import {downloadByData} from "#/util/tool";
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const arr = new Set([
'accept_tel',
'doctor',
'express_mobile',
'id_card',
'idcard',
'mobile',
'password',
'patient_mobile',
'phone',
]);
// 敏感数据
const sensitiveData = new Set(['id_card', 'idcard']);
function createRequestClient(baseURL: string) {
const client = new RequestClient({
baseURL,
@@ -131,6 +123,9 @@ function createRequestClient(baseURL: string) {
}
}
}
if (response.data instanceof Blob) {
return response;
}
throw Object.assign({}, response, { response });
},
@@ -173,60 +168,34 @@ export const baseRequestClient = new RequestClient({ baseURL: apiURL });
*/
function getRes(obj: any, isDecode = true) {
try {
if (obj == null || typeof obj !== 'object') {
return obj;
}
for (const key in obj) {
// 跳过脱敏展示字段,避免重复处理
if (key.endsWith(TM_TEXT_SUFFIX)) {
continue;
}
if (Array.isArray(obj[key])) {
obj[key] = getRes(obj[key]);
} else if (typeof obj[key] === 'object') {
obj[key] = getRes(obj[key]);
} else {
// 判断obj[key]是否在arr中
if (arr.has(key)) {
let aseFile = '';
aseFile = isDecode
? customBase64Decode(obj[key], key)
: customBase64Encode(obj[key]);
const isGarbled =
// eslint-disable-next-line no-control-regex
/[\u0000-\u0008\v\f\u000E-\u001F\u007F-\u00FF\u{E000}-\u{F8FF}]/u.test(
aseFile,
);
if (isGarbled) {
aseFile = obj[key];
}
if (isDecode && sensitiveData.has(key)) {
// 数据脱敏,自动匹配姓名、手机号、身份证
switch (key) {
// case 'accept_name':
// case 'express_name':
// case 'express_region':
// case 'patient': {
// // 保留前两个字符,其余用星号代替
// aseFile = aseFile.slice(0, 1).padEnd(aseFile.length, '*');
// break;
// }
// case 'express_mobile':
// case 'mobile': {
// // 保留前三位和后四位,中间用星号代替
// aseFile =
// aseFile.slice(0, 3).padEnd(aseFile.length - 4, '*') +
// aseFile.slice(-4);
// break;
// }
case 'id_card':
case 'idcard': {
// 保留前六位和后四位,中间用星号代替
aseFile =
aseFile.slice(0, 6).padEnd(aseFile.length - 4, '*') +
aseFile.slice(-4);
break;
}
default: {
// 默认情况下,全部用星号代替
break;
}
}
}
obj[key] = aseFile;
obj[key] = getRes(obj[key], isDecode);
} else if (obj[key] !== null && typeof obj[key] === 'object') {
obj[key] = getRes(obj[key], isDecode);
} else if (ENCRYPT_FIELDS.has(key)) {
let plainValue = isDecode
? customBase64Decode(obj[key], key)
: customBase64Encode(obj[key]);
const isGarbled =
// eslint-disable-next-line no-control-regex
/[\u0000-\u0008\v\f\u000E-\u001F\u007F-\u00FF\u{E000}-\u{F8FF}]/u.test(
plainValue,
);
if (isGarbled) {
plainValue = obj[key];
}
obj[key] = plainValue;
// 解密后写入脱敏展示字段,明文保留在原字段
if (isDecode && isSensitiveField(key)) {
obj[key + TM_TEXT_SUFFIX] = maskSensitiveField(key, plainValue);
}
}
}

View File

@@ -0,0 +1,81 @@
import { requestClient } from '#/api/request';
const prefix = 'spreadsheet-formula/';
export interface SpreadsheetFormulaRefSlot {
slot: string;
label: string;
}
export interface SpreadsheetFormulaItem {
id: number;
code: string;
name: string;
description: string;
expression_template: string;
ref_slots: SpreadsheetFormulaRefSlot[];
sort: number;
status: number;
created_at: number;
updated_at: number;
}
export async function listSpreadsheetFormula() {
return requestClient.get<SpreadsheetFormulaItem[]>(`${prefix}list`);
}
export async function createSpreadsheetFormula(data: {
code: string;
name: string;
description?: string;
expression_template: string;
ref_slots: SpreadsheetFormulaRefSlot[];
sort?: number;
status?: boolean;
}) {
return requestClient.post<SpreadsheetFormulaItem>(`${prefix}create`, data);
}
export async function updateSpreadsheetFormula(data: {
id: number;
code?: string;
name?: string;
description?: string;
expression_template?: string;
ref_slots?: SpreadsheetFormulaRefSlot[];
sort?: number;
status?: boolean;
}) {
return requestClient.post<SpreadsheetFormulaItem>(`${prefix}update`, data);
}
export async function deleteSpreadsheetFormula(id: number) {
return requestClient.post<boolean>(`${prefix}delete`, { id });
}
/** Replace REF0/REF1 with Excel-style column letters + row 1 */
export function buildFormulaFromTemplate(
template: string,
refColumns: number[],
): string {
let result = template;
refColumns.forEach((colIndex, i) => {
const letters = colIndexToLetters(colIndex);
const re = new RegExp(`REF${i}`, 'g');
result = result.replace(re, `${letters}1`);
});
return result;
}
function colIndexToLetters(index: number): string {
let n = index + 1;
let result = '';
while (n > 0) {
n -= 1;
result = String.fromCharCode(65 + (n % 26)) + result;
n = Math.floor(n / 26);
}
return result;
}
export { colIndexToLetters };

View File

@@ -0,0 +1,38 @@
import { requestClient } from '#/api/request';
import type { SpreadsheetHistoryEntry } from '#/components/canvas-spreadsheet/types';
const prefix = 'spreadsheet-history/';
export async function saveSpreadsheetHistoryBatch(
entries: SpreadsheetHistoryEntry[],
) {
return requestClient.post<{ saved_ids: string[] }>(`${prefix}save-batch`, {
entries: entries.map((e) => ({
client_entry_id: e.id,
table_name: e.tableName,
sheet_key: e.sheetKey,
row_key: String(e.rowKey),
field_id: e.fieldId,
action: e.action,
before_snapshot: e.beforeSnapshot,
after_snapshot: e.afterSnapshot,
created_at: e.createdAt,
})),
});
}
export async function listSpreadsheetHistory(params: {
table_name: string;
sheet_key?: string;
page?: number;
pageSize?: number;
}) {
return requestClient.get<{ items: any[]; total: number }>(`${prefix}list`, {
params,
});
}
export async function rollbackSpreadsheetHistory(id: number) {
return requestClient.post<{ success: boolean }>(`${prefix}rollback`, { id });
}

View File

@@ -0,0 +1,55 @@
import { requestClient } from '#/api/request';
import type { SpreadsheetConfigJson } from '#/views/business/warehouse-drug-management/admin/config/spreadsheetDefaults';
const prefix = 'spreadsheet-table-config/';
export interface SpreadsheetTableConfigItem {
id: number;
admin_id: number;
table_name: string;
sheet_key: string;
name: string;
is_default: number;
config: SpreadsheetConfigJson;
created_at: number;
updated_at: number;
}
export async function listSpreadsheetTableConfig(params: {
table_name: string;
sheet_key?: string;
}) {
return requestClient.get<SpreadsheetTableConfigItem[]>(`${prefix}list`, {
params,
});
}
export async function getSpreadsheetTableConfigDetail(id: number) {
return requestClient.get<SpreadsheetTableConfigItem>(`${prefix}detail`, {
params: { id },
});
}
export async function createSpreadsheetTableConfig(data: {
table_name: string;
sheet_key?: string;
name: string;
is_default?: boolean;
config: SpreadsheetConfigJson;
}) {
return requestClient.post<SpreadsheetTableConfigItem>(`${prefix}create`, data);
}
export async function updateSpreadsheetTableConfig(data: {
id: number;
name?: string;
is_default?: boolean;
config?: SpreadsheetConfigJson;
}) {
return requestClient.post<SpreadsheetTableConfigItem>(`${prefix}update`, data);
}
export async function deleteSpreadsheetTableConfig(id: number) {
return requestClient.post<boolean>(`${prefix}delete`, { id });
}

View File

@@ -1,40 +1,41 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, ref, watch } from 'vue';
import { useAntdDesignTokens } from '@vben/hooks';
import { preferences, usePreferences } from '@vben/preferences';
import { useUserStore as useVbenUserStore } from '@vben/stores';
import { App, ConfigProvider, theme } from 'ant-design-vue';
import { antdLocale } from '#/locales';
import { GlobalContextMenu } from '#/components/context-menu';
import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue';
import { useWebSocket } from '#/views/business/chat/composables/useWebSocket';
import { useChatStore } from '#/views/business/chat/stores/chat';
import { useUserStore } from '#/views/business/chat/stores/user';
defineOptions({ name: 'App' });
// 定义颜色代码
const redBoldYellowBg = '\u001B[1;31;43m'; // 1=加粗 31=红色文字 43=黄色背景
const reset = '\u001B[0m';
console.log(
`${redBoldYellowBg}......................阿弥陀佛...................... \n` +
` _oo0oo_ \n` +
` o8888888o \n` +
` 88" . "88 \n` +
` (| -_- |) \n` +
` 0\\ = /0 \n` +
` ___/---\\___ \n` +
` .' \\| |/ '. \n` +
` / \\\\||| : |||// \\ \n` +
` / _||||| -卍-|||||_ \\ \n` +
` | | \\\\\\ - /// | | \n` +
` | \\_| ''\\---/'' |_/ | \n` +
` \\ .-\\__ '-' ___/-. / \n` +
` ___'. .' /--.--\\ '. .'___ \n` +
` ."" < .___\\_<|>_/___.> "". \n` +
` | | : - \\.;\\ _ /;./ - : | | \n` +
` \\ \\ _. \\_ __\\ /__ _/ .- / / \n` +
` =====-.____.___ \\_____/___.-___.-===== \n` +
` =---= \n` +
` \n` +
`....................佛祖保佑 ,永无BUG...................${reset}`,
const { connectWebSocket } = useWebSocket();
const userStore = useUserStore();
const chatStore = useChatStore();
const vbenUserStore = useVbenUserStore();
// 用户信息加载状态跟踪
const userInfoLoaded = ref(false);
// 监听userInfo加载完成
watch(
() => vbenUserStore.userInfo,
(newUserInfo) => {
if (newUserInfo) {
console.log('用户信息已加载:', newUserInfo)
userStore.currentUser = newUserInfo;
userStore.login(newUserInfo);
userInfoLoaded.value = true;
}
},
{ immediate: true },
);
const { isDark } = usePreferences();
@@ -45,7 +46,6 @@ const tokenTheme = computed(() => {
? [theme.darkAlgorithm]
: [theme.defaultAlgorithm];
// antd 紧凑模式算法
if (preferences.app.compact) {
algorithm.push(theme.compactAlgorithm);
}
@@ -55,12 +55,42 @@ const tokenTheme = computed(() => {
token: tokens,
};
});
const initWebsocket = () => {
// 确保用户信息已加载
if (!userStore.currentUser) return;
// 初始化数据库
chatStore.initDB();
// 加载好友列表使用用户ID
chatStore.loadFriends(userStore.presetUsers, userStore.currentUser?.doctor_id);
// 创建WebSocket连接
connectWebSocket();
};
// 当userInfo加载完成后再执行初始化
watch(userInfoLoaded, (loaded) => {
if (loaded) {
initWebsocket();
}
});
/** 诊所医生账号显示传方悬浮窗(有 doctor_id 且绑定门店) */
const showDoctorTransferFloat = computed(() => {
const info = vbenUserStore.userInfo as Record<string, any> | null;
if (!info) return false;
return !!(info.doctor_id && info.store_id);
});
</script>
<template>
<ConfigProvider :locale="antdLocale" :theme="tokenTheme">
<App>
<RouterView />
<GlobalContextMenu />
<DoctorTransferFloat v-if="showDoctorTransferFloat" />
</App>
</ConfigProvider>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,950 @@
import type { SpreadsheetTheme } from './theme';
import { getSpreadsheetTheme, resolveHeaderColor } from './theme';
import type {
CellRange,
CellType,
SpreadsheetColumnConfig,
SpreadsheetHeaderConfig,
SpreadsheetTextAlign,
} from '../types';
import {
COLUMN_LETTER_HEIGHT,
DEFAULT_COL_WIDTH,
DEFAULT_ROW_HEIGHT,
HEADER_HEIGHT,
HEADER_TITLE_HEIGHT,
ROW_NUMBER_WIDTH,
SCROLL_BUFFER_ROWS,
} from '../types';
import { getRowTop } from '../composables/useAutoMeasure';
import { formatCellWithSuffix } from '../composables/cellDisplay';
import { resolveColumnTextAlign } from '../composables/columnTextAlign';
import { colIndexToLetters } from '../formula/evaluator';
import { iterateRanges, normalizeRange, rangesContainCell } from '../commands/FillRangeCommand';
import {
computeFixedWidth,
getDataColX,
getFixedColIndices,
isFixedCol,
} from './columnLayout';
export {
computeFixedWidth,
getDataColX,
getFixedColIndices,
} from './columnLayout';
export interface RenderOptions {
headers: SpreadsheetHeaderConfig[];
columns: SpreadsheetColumnConfig[];
rows: Record<string, unknown>[];
colWidths: number[];
rowHeight: number;
scrollTop: number;
scrollLeft: number;
viewportWidth: number;
viewportHeight: number;
displayRowCount: number;
colCount: number;
displayColCount: number;
defaultColWidth: number;
defaultTextAlign: SpreadsheetTextAlign;
isDark: boolean;
fixedCols: number[];
fixedWidth: number;
selectionRanges: CellRange[];
activeCell: { row: number; col: number } | null;
dirtyKeys: Set<string>;
filterValues: Record<number, string>;
filteredRowIndices: number[];
showFillHandle: boolean;
theme: SpreadsheetTheme;
findMatches?: Array<{ row: number; col: number }>;
findActiveIndex?: number;
resolveDisplayValue?: (
viewRow: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
) => unknown;
resolveCellStyle?: (
viewRow: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
) => { bgColor?: string; textColor?: string } | undefined;
referenceCell?: { row: number; col: number } | null;
}
function colByIndex(columns: SpreadsheetColumnConfig[], col: number) {
return columns.find((c) => c.col === col);
}
function isInSelection(
row: number,
col: number,
ranges: CellRange[],
): boolean {
return rangesContainCell(ranges, row, col);
}
function isFindMatch(
row: number,
col: number,
matches?: Array<{ row: number; col: number }>,
): boolean {
if (!matches?.length) return false;
return matches.some((m) => m.row === row && m.col === col);
}
function isActiveFindMatch(
row: number,
col: number,
matches?: Array<{ row: number; col: number }>,
activeIndex?: number,
): boolean {
if (activeIndex === undefined || activeIndex < 0 || !matches?.length) {
return false;
}
const m = matches[activeIndex];
return !!m && m.row === row && m.col === col;
}
function isRowInSelection(
row: number,
ranges: CellRange[],
maxCol: number,
): boolean {
for (const range of ranges) {
const r = normalizeRange(range);
if (r.startCol === 0 && r.endCol === maxCol && row >= r.startRow && row <= r.endRow) {
return true;
}
}
return false;
}
function isColInSelection(
col: number,
ranges: CellRange[],
maxRow: number,
): boolean {
for (const range of ranges) {
const r = normalizeRange(range);
if (r.startRow === 0 && r.endRow === maxRow && col >= r.startCol && col <= r.endCol) {
return true;
}
}
return false;
}
function isAllSelected(
ranges: CellRange[],
maxRow: number,
maxCol: number,
): boolean {
if (ranges.length !== 1) return false;
const r = normalizeRange(ranges[0]!);
return r.startRow === 0 && r.endRow === maxRow && r.startCol === 0 && r.endCol === maxCol;
}
function formatCellDisplay(value: unknown, cellType: CellType): string {
if (value === null || value === undefined) return '';
if (cellType === 'number') {
const n = Number(value);
return Number.isFinite(n) ? String(n) : String(value);
}
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
function drawAlignedText(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
w: number,
align: SpreadsheetTextAlign,
) {
ctx.textBaseline = 'middle';
if (align === 'center') {
ctx.textAlign = 'center';
ctx.fillText(text, x + w / 2, y);
} else if (align === 'right') {
ctx.textAlign = 'right';
ctx.fillText(text, x + w - 8, y);
} else {
ctx.textAlign = 'left';
ctx.fillText(text, x + 8, y, w - 16);
}
ctx.textAlign = 'left';
}
function drawCellContent(
ctx: CanvasRenderingContext2D,
value: unknown,
cellType: CellType,
colCfg: SpreadsheetColumnConfig | undefined,
x: number,
y: number,
w: number,
h: number,
theme: SpreadsheetTheme,
textColor: string | undefined,
defaultTextAlign: SpreadsheetTextAlign,
) {
ctx.save();
ctx.beginPath();
ctx.rect(x, y, w, h);
ctx.clip();
const align = resolveColumnTextAlign(colCfg, defaultTextAlign);
if (cellType === 'image' && value) {
ctx.fillStyle = theme.imagePlaceholder;
ctx.fillRect(x + 4, y + 4, h - 8, h - 8);
ctx.fillStyle = theme.textMuted;
ctx.font = '11px sans-serif';
drawAlignedText(ctx, 'IMG', x, y + h / 2, w, align);
} else {
ctx.fillStyle = textColor ?? theme.text;
ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
const text = colCfg
? formatCellWithSuffix(value, colCfg)
: formatCellDisplay(value, cellType);
drawAlignedText(ctx, text, x, y + h / 2, w, align);
}
ctx.restore();
}
function drawRowNumber(
ctx: CanvasRenderingContext2D,
y: number,
rowHeight: number,
label: string,
theme: SpreadsheetTheme,
selected = false,
) {
ctx.fillStyle = selected ? theme.selectionBg : theme.rowNumBg;
ctx.fillRect(0, y, ROW_NUMBER_WIDTH, rowHeight);
ctx.strokeStyle = theme.border;
ctx.strokeRect(0, y, ROW_NUMBER_WIDTH, rowHeight);
ctx.fillStyle = theme.textMuted;
ctx.font = '12px sans-serif';
ctx.textBaseline = 'middle';
ctx.fillText(label, ROW_NUMBER_WIDTH / 2 - 4, y + rowHeight / 2);
}
function drawHeaderCorner(
ctx: CanvasRenderingContext2D,
theme: SpreadsheetTheme,
allSelected = false,
) {
ctx.fillStyle = allSelected ? theme.selectionBg : theme.rowNumBg;
ctx.fillRect(0, 0, ROW_NUMBER_WIDTH, HEADER_HEIGHT);
ctx.strokeStyle = theme.border;
ctx.strokeRect(0, 0, ROW_NUMBER_WIDTH, HEADER_HEIGHT);
ctx.fillStyle = theme.textMuted;
ctx.font = '12px sans-serif';
ctx.textBaseline = 'middle';
ctx.fillText(
'#',
ROW_NUMBER_WIDTH / 2 - 4,
COLUMN_LETTER_HEIGHT + HEADER_TITLE_HEIGHT / 2,
);
}
function drawDataCell(
ctx: CanvasRenderingContext2D,
options: {
x: number;
y: number;
cw: number;
rowHeight: number;
bg: string;
theme: SpreadsheetTheme;
value?: unknown;
cellType?: CellType;
colCfg?: SpreadsheetColumnConfig;
textColor?: string;
empty?: boolean;
defaultTextAlign: SpreadsheetTextAlign;
},
) {
const {
x,
y,
cw,
rowHeight,
bg,
theme,
value,
cellType,
colCfg,
textColor,
empty,
defaultTextAlign,
} = options;
ctx.fillStyle = bg;
ctx.fillRect(x, y, cw, rowHeight);
ctx.strokeStyle = theme.border;
ctx.strokeRect(x, y, cw, rowHeight);
if (!empty && cellType && value !== undefined) {
drawCellContent(
ctx,
value,
cellType,
colCfg,
x,
y,
cw,
rowHeight,
theme,
textColor,
defaultTextAlign,
);
}
}
function resolveCellBg(
options: RenderOptions,
vi: number,
col: number,
colCfg: SpreadsheetColumnConfig | undefined,
row: Record<string, unknown> | undefined,
isEmpty: boolean,
dataRow: number | undefined,
): string {
const { theme } = options;
const selected = isInSelection(vi, col, options.selectionRanges);
const findActive = isActiveFindMatch(
vi,
col,
options.findMatches,
options.findActiveIndex,
);
const findMatch = isFindMatch(vi, col, options.findMatches);
if (isEmpty || !colCfg) {
if (findActive) return theme.findActiveMatchBg;
if (findMatch) return theme.findMatchBg;
return selected ? theme.selectionBg : theme.cellBg;
}
const dirtyKey =
dataRow !== undefined ? `${dataRow}:${colCfg.fieldId}` : '';
const cellStyle = options.resolveCellStyle?.(vi, colCfg, row);
if (options.dirtyKeys.has(dirtyKey)) return theme.dirtyBg;
if (findActive) return theme.findActiveMatchBg;
if (findMatch) return theme.findMatchBg;
if (cellStyle?.bgColor) return cellStyle.bgColor;
if (selected) return theme.selectionBg;
return theme.cellBg;
}
function drawHeaderCell(
ctx: CanvasRenderingContext2D,
options: RenderOptions,
col: number,
cw: number,
x: number,
title: string,
headerColor?: string,
) {
const { theme } = options;
const letterY = 0;
const titleY = COLUMN_LETTER_HEIGHT;
const maxRow = Math.max(0, options.filteredRowIndices.length - 1);
const colSelected = isColInSelection(col, options.selectionRanges, maxRow);
ctx.fillStyle = colSelected ? theme.selectionBg : theme.headerBg;
ctx.fillRect(x, letterY, cw, COLUMN_LETTER_HEIGHT);
ctx.strokeStyle = theme.border;
ctx.strokeRect(x, letterY, cw, COLUMN_LETTER_HEIGHT);
ctx.fillStyle = theme.textMuted;
ctx.font = '11px sans-serif';
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.fillText(colIndexToLetters(col), x + cw / 2, letterY + COLUMN_LETTER_HEIGHT / 2);
ctx.textAlign = 'left';
const bg = resolveHeaderColor(
headerColor,
options.isDark,
theme.headerBg,
);
ctx.fillStyle = bg;
ctx.fillRect(x, titleY, cw, HEADER_TITLE_HEIGHT);
ctx.strokeStyle = theme.border;
ctx.strokeRect(x, titleY, cw, HEADER_TITLE_HEIGHT);
ctx.fillStyle = theme.headerText;
ctx.font = '600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
const colCfg = colByIndex(options.columns, col);
const align = resolveColumnTextAlign(colCfg, options.defaultTextAlign);
drawAlignedText(
ctx,
title,
x,
titleY + HEADER_TITLE_HEIGHT / 2,
cw,
align,
);
}
function drawHeaderTitleOnly(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
cw: number,
title: string,
headerColor: string | undefined,
col: number,
columns: SpreadsheetColumnConfig[],
defaultTextAlign: SpreadsheetTextAlign,
isDark: boolean,
theme: SpreadsheetTheme,
) {
const bg = resolveHeaderColor(headerColor, isDark, theme.headerBg);
ctx.fillStyle = bg;
ctx.fillRect(x, y, cw, HEADER_TITLE_HEIGHT);
ctx.strokeStyle = theme.border;
ctx.strokeRect(x, y, cw, HEADER_TITLE_HEIGHT);
ctx.fillStyle = theme.headerText;
ctx.font = '600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
const colCfg = colByIndex(columns, col);
const align = resolveColumnTextAlign(colCfg, defaultTextAlign);
drawAlignedText(ctx, title, x, y + HEADER_TITLE_HEIGHT / 2, cw, align);
}
function resolveExportCellBg(
theme: SpreadsheetTheme,
viewRow: number,
col: number,
colCfg: SpreadsheetColumnConfig | undefined,
row: Record<string, unknown> | undefined,
dataRow: number | undefined,
dirtyKeys: Set<string>,
resolveCellStyle?: RenderOptions['resolveCellStyle'],
): string {
if (!colCfg) return theme.cellBg;
const dirtyKey =
dataRow !== undefined ? `${dataRow}:${colCfg.fieldId}` : '';
const cellStyle = resolveCellStyle?.(viewRow, colCfg, row);
if (dirtyKeys.has(dirtyKey)) return theme.dirtyBg;
if (cellStyle?.bgColor) return cellStyle.bgColor;
return theme.cellBg;
}
export interface SelectionImageParams {
selectionRanges: CellRange[];
headers: SpreadsheetHeaderConfig[];
columns: SpreadsheetColumnConfig[];
colWidths: number[];
rowHeight: number;
defaultColWidth: number;
defaultTextAlign: SpreadsheetTextAlign;
isDark: boolean;
filteredRowIndices: number[];
rows: Record<string, unknown>[];
dirtyKeys: Set<string>;
resolveDisplayValue?: RenderOptions['resolveDisplayValue'];
resolveCellStyle?: RenderOptions['resolveCellStyle'];
}
export async function renderSelectionImage(
params: SelectionImageParams,
): Promise<Blob> {
const selRanges = params.selectionRanges.map((r) => normalizeRange(r));
if (!selRanges.length) throw new Error('No selection');
const rowSet = new Set<number>();
const colSet = new Set<number>();
iterateRanges(selRanges, (viewRow, col) => {
rowSet.add(viewRow);
colSet.add(col);
});
const uniqueRows = [...rowSet].sort((a, b) => a - b);
const uniqueCols = [...colSet].sort((a, b) => a - b);
if (!uniqueRows.length || !uniqueCols.length) {
throw new Error('No selection');
}
const theme = getSpreadsheetTheme(params.isDark);
let width = 0;
for (const col of uniqueCols) {
width += params.colWidths[col] ?? params.defaultColWidth;
}
const height =
HEADER_TITLE_HEIGHT + uniqueRows.length * params.rowHeight;
const canvas = document.createElement('canvas');
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.ceil(width * dpr);
canvas.height = Math.ceil(height * dpr);
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas not supported');
ctx.scale(dpr, dpr);
ctx.fillStyle = theme.cellBg;
ctx.fillRect(0, 0, width, height);
let headerX = 0;
for (const col of uniqueCols) {
const cw = params.colWidths[col] ?? params.defaultColWidth;
const header = params.headers.find((h) => h.col === col);
const colCfg = colByIndex(params.columns, col);
const title = header?.title ?? colCfg?.fieldName ?? '';
drawHeaderTitleOnly(
ctx,
headerX,
0,
cw,
title,
header?.headerColor,
col,
params.columns,
params.defaultTextAlign,
params.isDark,
theme,
);
headerX += cw;
}
uniqueRows.forEach((viewRow, rowIndex) => {
const dataRow = params.filteredRowIndices[viewRow];
const row =
dataRow !== undefined ? params.rows[dataRow] : undefined;
const y = HEADER_TITLE_HEIGHT + rowIndex * params.rowHeight;
let cellX = 0;
for (const col of uniqueCols) {
const cw = params.colWidths[col] ?? params.defaultColWidth;
const colCfg = colByIndex(params.columns, col);
const inSel = rangesContainCell(selRanges, viewRow, col);
const isEmpty =
!inSel || dataRow === undefined || !row || !colCfg;
const bg = inSel
? resolveExportCellBg(
theme,
viewRow,
col,
colCfg,
row,
dataRow,
params.dirtyKeys,
params.resolveCellStyle,
)
: theme.cellBg;
const cellStyle =
inSel && !isEmpty && colCfg
? params.resolveCellStyle?.(viewRow, colCfg, row)
: undefined;
const displayCellType = colCfg?.cellType === 'formula'
? 'number'
: colCfg?.cellType;
drawDataCell(ctx, {
x: cellX,
y,
cw,
rowHeight: params.rowHeight,
bg,
theme,
empty: isEmpty,
value:
inSel && !isEmpty && colCfg
? params.resolveDisplayValue?.(viewRow, colCfg, row)
: undefined,
cellType: displayCellType,
colCfg: inSel ? colCfg : undefined,
textColor: cellStyle?.textColor,
defaultTextAlign: params.defaultTextAlign,
});
cellX += cw;
}
});
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error('Failed to create image'));
}, 'image/png');
});
}
function drawHeaderFillerCell(
ctx: CanvasRenderingContext2D,
options: RenderOptions,
col: number,
cw: number,
x: number,
) {
const { theme } = options;
const maxRow = Math.max(0, options.filteredRowIndices.length - 1);
const colSelected = isColInSelection(col, options.selectionRanges, maxRow);
const letterBg = colSelected ? theme.selectionBg : theme.headerBg;
ctx.fillStyle = letterBg;
ctx.fillRect(x, 0, cw, COLUMN_LETTER_HEIGHT);
ctx.strokeStyle = theme.border;
ctx.strokeRect(x, 0, cw, COLUMN_LETTER_HEIGHT);
ctx.fillStyle = theme.textMuted;
ctx.font = '11px sans-serif';
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.fillText(colIndexToLetters(col), x + cw / 2, COLUMN_LETTER_HEIGHT / 2);
ctx.textAlign = 'left';
ctx.fillRect(x, COLUMN_LETTER_HEIGHT, cw, HEADER_TITLE_HEIGHT);
ctx.strokeRect(x, COLUMN_LETTER_HEIGHT, cw, HEADER_TITLE_HEIGHT);
}
function drawScrollableHeaders(
ctx: CanvasRenderingContext2D,
options: RenderOptions,
w: number,
) {
const { fixedCols, fixedWidth } = options;
ctx.save();
ctx.beginPath();
ctx.rect(fixedWidth, 0, w - fixedWidth, HEADER_HEIGHT);
ctx.clip();
for (const header of options.headers) {
if (isFixedCol(header.col, fixedCols)) continue;
const x = getDataColX(
header.col,
options.colWidths,
options.scrollLeft,
fixedCols,
fixedWidth,
);
const cw = options.colWidths[header.col] ?? options.defaultColWidth;
if (x + cw <= fixedWidth || x > w) continue;
drawHeaderCell(ctx, options, header.col, cw, x, header.title, header.headerColor);
}
for (let col = options.colCount; col < options.displayColCount; col += 1) {
if (isFixedCol(col, fixedCols)) continue;
const x = getDataColX(
col,
options.colWidths,
options.scrollLeft,
fixedCols,
fixedWidth,
);
const cw = options.defaultColWidth;
if (x + cw <= fixedWidth || x > w) continue;
drawHeaderFillerCell(ctx, options, col, cw, x);
}
ctx.restore();
}
function drawFrozenHeaders(
ctx: CanvasRenderingContext2D,
options: RenderOptions,
w: number,
) {
const { fixedCols, fixedWidth, theme } = options;
const maxRow = Math.max(0, options.filteredRowIndices.length - 1);
const maxCol = options.colCount - 1;
drawHeaderCorner(
ctx,
theme,
isAllSelected(options.selectionRanges, maxRow, maxCol),
);
for (const header of options.headers) {
if (!isFixedCol(header.col, fixedCols)) continue;
const x = getDataColX(
header.col,
options.colWidths,
options.scrollLeft,
fixedCols,
fixedWidth,
);
const cw = options.colWidths[header.col] ?? options.defaultColWidth;
if (x + cw <= ROW_NUMBER_WIDTH) continue;
drawHeaderCell(ctx, options, header.col, cw, x, header.title, header.headerColor);
}
if (fixedCols.length > 0 && fixedWidth < w) {
ctx.fillStyle = theme.fixedShadow;
ctx.fillRect(fixedWidth, 0, 1, HEADER_HEIGHT);
}
}
export function renderHeader(canvas: HTMLCanvasElement, options: RenderOptions) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = HEADER_HEIGHT;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = options.theme.cellBg;
ctx.fillRect(0, 0, w, h);
drawScrollableHeaders(ctx, options, w);
drawFrozenHeaders(ctx, options, w);
}
function drawScrollableBodyRow(
ctx: CanvasRenderingContext2D,
options: RenderOptions,
vi: number,
y: number,
dataRow: number | undefined,
row: Record<string, unknown> | undefined,
w: number,
) {
const { theme, rowHeight, fixedCols, fixedWidth } = options;
const isEmpty = dataRow === undefined || !row;
ctx.save();
ctx.beginPath();
ctx.rect(fixedWidth, y, w - fixedWidth, rowHeight);
ctx.clip();
for (const colCfg of options.columns) {
const col = colCfg.col;
if (isFixedCol(col, fixedCols)) continue;
const x = getDataColX(
col,
options.colWidths,
options.scrollLeft,
fixedCols,
fixedWidth,
);
const cw = options.colWidths[col] ?? options.defaultColWidth;
if (x + cw <= fixedWidth || x > options.viewportWidth) continue;
const bg = resolveCellBg(options, vi, col, colCfg, row, isEmpty, dataRow);
const cellStyle = !isEmpty
? options.resolveCellStyle?.(vi, colCfg, row)
: undefined;
const displayCellType =
colCfg.cellType === 'formula' ? 'number' : colCfg.cellType;
drawDataCell(ctx, {
x,
y,
cw,
rowHeight,
bg,
theme,
empty: isEmpty,
value: options.resolveDisplayValue
? options.resolveDisplayValue(vi, colCfg, row)
: row?.[colCfg.fieldId],
cellType: displayCellType,
colCfg,
textColor: cellStyle?.textColor,
defaultTextAlign: options.defaultTextAlign,
});
}
for (let col = options.colCount; col < options.displayColCount; col += 1) {
if (isFixedCol(col, fixedCols)) continue;
const x = getDataColX(
col,
options.colWidths,
options.scrollLeft,
fixedCols,
fixedWidth,
);
const cw = options.defaultColWidth;
if (x + cw <= fixedWidth || x > options.viewportWidth) continue;
const bg = resolveCellBg(options, vi, col, undefined, undefined, true, dataRow);
drawDataCell(ctx, {
x,
y,
cw,
rowHeight,
bg,
theme,
empty: true,
defaultTextAlign: options.defaultTextAlign,
});
}
ctx.restore();
}
function drawFrozenBodyRow(
ctx: CanvasRenderingContext2D,
options: RenderOptions,
vi: number,
y: number,
dataRow: number | undefined,
row: Record<string, unknown> | undefined,
) {
const { theme, rowHeight, fixedCols, fixedWidth } = options;
const isEmpty = dataRow === undefined || !row;
const maxCol = options.colCount - 1;
const rowSelected = isRowInSelection(vi, options.selectionRanges, maxCol);
drawRowNumber(ctx, y, rowHeight, String(vi + 1), theme, rowSelected);
for (const colCfg of options.columns) {
const col = colCfg.col;
if (!isFixedCol(col, fixedCols)) continue;
const x = getDataColX(
col,
options.colWidths,
options.scrollLeft,
fixedCols,
fixedWidth,
);
const cw = options.colWidths[col] ?? options.defaultColWidth;
const bg = resolveCellBg(options, vi, col, colCfg, row, isEmpty, dataRow);
const cellStyle = !isEmpty
? options.resolveCellStyle?.(vi, colCfg, row)
: undefined;
const displayCellType =
colCfg.cellType === 'formula' ? 'number' : colCfg.cellType;
drawDataCell(ctx, {
x,
y,
cw,
rowHeight,
bg,
theme,
empty: isEmpty,
value: options.resolveDisplayValue
? options.resolveDisplayValue(vi, colCfg, row)
: row?.[colCfg.fieldId],
cellType: displayCellType,
colCfg,
textColor: cellStyle?.textColor,
defaultTextAlign: options.defaultTextAlign,
});
}
}
export function renderBody(canvas: HTMLCanvasElement, options: RenderOptions) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const { theme, rowHeight, scrollTop, fixedWidth, fixedCols } = options;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
const visibleCount = Math.ceil(h / rowHeight) + SCROLL_BUFFER_ROWS;
const startRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 2);
const endRow = Math.min(options.displayRowCount - 1, startRow + visibleCount);
for (let vi = startRow; vi <= endRow; vi += 1) {
const dataRow = options.filteredRowIndices[vi];
const row =
dataRow !== undefined ? options.rows[dataRow] : undefined;
const y = getRowTop(vi, rowHeight) - scrollTop;
drawScrollableBodyRow(ctx, options, vi, y, dataRow, row, w);
}
for (let vi = startRow; vi <= endRow; vi += 1) {
const dataRow = options.filteredRowIndices[vi];
const row =
dataRow !== undefined ? options.rows[dataRow] : undefined;
const y = getRowTop(vi, rowHeight) - scrollTop;
drawFrozenBodyRow(ctx, options, vi, y, dataRow, row);
}
if (fixedCols.length > 0 && fixedWidth < w) {
const bodyH = Math.min(h, options.displayRowCount * rowHeight - scrollTop);
ctx.fillStyle = theme.fixedShadow;
ctx.fillRect(fixedWidth, 0, 1, Math.max(0, bodyH));
}
if (options.activeCell) {
const { activeCell } = options;
const x = getDataColX(
activeCell.col,
options.colWidths,
options.scrollLeft,
fixedCols,
fixedWidth,
);
const y = getRowTop(activeCell.row, rowHeight) - scrollTop;
const cw = options.colWidths[activeCell.col] ?? options.defaultColWidth;
const findActive = isActiveFindMatch(
activeCell.row,
activeCell.col,
options.findMatches,
options.findActiveIndex,
);
ctx.strokeStyle = findActive ? theme.findActiveBorder : theme.selectionBorder;
ctx.lineWidth = 2;
ctx.setLineDash([]);
ctx.strokeRect(x + 1, y + 1, cw - 2, rowHeight - 2);
if (options.showFillHandle) {
const fh = 6;
ctx.fillStyle = theme.fillHandle;
ctx.fillRect(x + cw - fh - 2, y + rowHeight - fh - 2, fh, fh);
}
}
if (options.referenceCell) {
const { row, col } = options.referenceCell;
const x = getDataColX(
col,
options.colWidths,
options.scrollLeft,
fixedCols,
fixedWidth,
);
const y = getRowTop(row, rowHeight) - scrollTop;
const cw = options.colWidths[col] ?? options.defaultColWidth;
ctx.strokeStyle = theme.selectionBorder;
ctx.lineWidth = 2;
ctx.setLineDash([4, 4]);
ctx.strokeRect(x + 1, y + 1, cw - 2, rowHeight - 2);
ctx.setLineDash([]);
}
}
export function getVisibleRowIndices(
rows: Record<string, unknown>[],
columns: SpreadsheetColumnConfig[],
filterValues: Record<number, string>,
): number[] {
const activeFilters = Object.entries(filterValues).filter(([, v]) => v.trim());
if (!activeFilters.length) {
return rows.map((_, i) => i);
}
return rows
.map((row, index) => ({ row, index }))
.filter(({ row }) =>
activeFilters.every(([colStr, keyword]) => {
const col = Number(colStr);
const colCfg = colByIndex(columns, col);
if (!colCfg) return true;
const val = formatCellDisplay(row[colCfg.fieldId], colCfg.cellType);
return val.toLowerCase().includes(keyword.trim().toLowerCase());
}),
)
.map(({ index }) => index);
}
export function getContentSize(
colWidths: number[],
displayRowCount: number,
rowHeight: number,
displayContentWidth?: number,
): { width: number; height: number } {
const dataWidth = ROW_NUMBER_WIDTH + colWidths.reduce((s, w) => s + w, 0);
const width = displayContentWidth ?? dataWidth;
return { width, height: displayRowCount * rowHeight };
}
export { ROW_NUMBER_WIDTH, HEADER_HEIGHT, DEFAULT_ROW_HEIGHT };

View File

@@ -0,0 +1,83 @@
import type { SpreadsheetHeaderConfig } from '../types';
import { DEFAULT_COL_WIDTH, ROW_NUMBER_WIDTH } from '../types';
export function getFixedColOffset(
col: number,
colWidths: number[],
fixedCols: number[],
): number {
let w = 0;
for (const c of fixedCols) {
if (c >= col) break;
w += colWidths[c] ?? DEFAULT_COL_WIDTH;
}
return w;
}
export function getFixedDataWidth(
col: number,
colWidths: number[],
fixedCols: number[],
): number {
return getFixedColOffset(col, colWidths, fixedCols);
}
export function isFixedCol(col: number, fixedCols: number[]) {
return fixedCols.includes(col);
}
export function getDataColX(
col: number,
colWidths: number[],
scrollLeft: number,
fixedCols: number[],
fixedWidth: number,
): number {
if (isFixedCol(col, fixedCols)) {
return ROW_NUMBER_WIDTH + getFixedColOffset(col, colWidths, fixedCols);
}
const scrollColOffset =
getColLeft(col, colWidths) - getFixedDataWidth(col, colWidths, fixedCols);
return fixedWidth + scrollColOffset - scrollLeft;
}
export function getColLeft(colIndex: number, colWidths: number[]): number {
let left = 0;
for (let i = 0; i < colIndex; i += 1) {
left += colWidths[i] ?? DEFAULT_COL_WIDTH;
}
return left;
}
export function computeFixedWidth(
colWidths: number[],
fixedCols: number[],
): number {
let w = ROW_NUMBER_WIDTH;
for (const col of fixedCols) {
w += colWidths[col] ?? DEFAULT_COL_WIDTH;
}
return w;
}
/** Keep only contiguous frozen columns starting from col 0 (Excel freeze semantics). */
export function normalizeFixedCols(fixedCols: number[]): number[] {
if (fixedCols.length === 0) return [];
const sorted = [...fixedCols].sort((a, b) => a - b);
const result: number[] = [];
for (let i = 0; i < sorted.length; i += 1) {
if (sorted[i] === i) result.push(i);
else break;
}
return result;
}
export function getFixedColIndices(
headers: SpreadsheetHeaderConfig[],
): number[] {
const raw = headers
.filter((h) => h.fixed)
.map((h) => h.col)
.sort((a, b) => a - b);
return normalizeFixedCols(raw);
}

View File

@@ -0,0 +1,146 @@
export interface SpreadsheetTheme {
headerBg: string;
headerText: string;
cellBg: string;
border: string;
text: string;
textMuted: string;
selectionBg: string;
selectionBorder: string;
dirtyBg: string;
rowNumBg: string;
fillHandle: string;
fixedShadow: string;
imagePlaceholder: string;
findMatchBg: string;
findActiveMatchBg: string;
findActiveBorder: string;
}
export function getSpreadsheetTheme(isDark: boolean): SpreadsheetTheme {
if (isDark) {
return {
headerBg: '#1f1f1f',
headerText: 'rgba(255, 255, 255, 0.85)',
cellBg: '#141414',
border: '#424242',
text: 'rgba(255, 255, 255, 0.85)',
textMuted: 'rgba(255, 255, 255, 0.45)',
selectionBg: 'rgba(22, 93, 255, 0.25)',
selectionBorder: '#1668dc',
dirtyBg: 'rgba(255, 125, 0, 0.2)',
rowNumBg: '#1a1a1a',
fillHandle: '#1668dc',
fixedShadow: 'rgba(0, 0, 0, 0.45)',
imagePlaceholder: '#303030',
findMatchBg: 'rgba(255, 212, 0, 0.35)',
findActiveMatchBg: 'rgba(255, 212, 0, 0.55)',
findActiveBorder: '#faad14',
};
}
return {
headerBg: '#f5f7fa',
headerText: '#1d2129',
cellBg: '#ffffff',
border: '#e5e6eb',
text: '#1d2129',
textMuted: '#86909c',
selectionBg: 'rgba(22, 93, 255, 0.08)',
selectionBorder: '#165dff',
dirtyBg: 'rgba(255, 125, 0, 0.12)',
rowNumBg: '#fafafa',
fillHandle: '#165dff',
fixedShadow: 'rgba(0, 0, 0, 0.12)',
imagePlaceholder: '#f2f3f5',
findMatchBg: '#fff566',
findActiveMatchBg: '#ffe58f',
findActiveBorder: '#faad14',
};
}
export const HEADER_COLOR_PRESETS = [
'#f0f5ff',
'#fff7e6',
'#f6ffed',
'#fff1f0',
'#f9f0ff',
'#e6fffb',
];
function parseHexColor(hex: string): { r: number; g: number; b: number } | null {
const normalized = hex.trim().replace('#', '');
if (normalized.length === 3) {
return {
r: Number.parseInt(normalized[0]! + normalized[0], 16),
g: Number.parseInt(normalized[1]! + normalized[1], 16),
b: Number.parseInt(normalized[2]! + normalized[2], 16),
};
}
if (normalized.length === 6) {
return {
r: Number.parseInt(normalized.slice(0, 2), 16),
g: Number.parseInt(normalized.slice(2, 4), 16),
b: Number.parseInt(normalized.slice(4, 6), 16),
};
}
return null;
}
function rgbToHsl(r: number, g: number, b: number) {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
if (max === min) {
return { h: 0, s: 0, l };
}
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h = 0;
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
else h = ((rn - gn) / d + 4) / 6;
return { h, s, l };
}
function hslToHex(h: number, s: number, l: number): string {
if (s === 0) {
const v = Math.round(l * 255);
const hex = v.toString(16).padStart(2, '0');
return `#${hex}${hex}${hex}`;
}
const hue2rgb = (p: number, q: number, t: number) => {
let tt = t;
if (tt < 0) tt += 1;
if (tt > 1) tt -= 1;
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
if (tt < 1 / 2) return q;
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const r = Math.round(hue2rgb(p, q, h + 1 / 3) * 255);
const g = Math.round(hue2rgb(p, q, h) * 255);
const b = Math.round(hue2rgb(p, q, h - 1 / 3) * 255);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
export function resolveHeaderColor(
color: string | undefined,
isDark: boolean,
fallback: string,
): string {
if (!color) return fallback;
if (!isDark) return color;
const rgb = parseHexColor(color);
if (!rgb) return fallback;
const { h, s, l } = rgbToHsl(rgb.r, rgb.g, rgb.b);
const darkL = Math.min(0.28, Math.max(0.16, l * 0.35 + 0.12));
const darkS = Math.min(0.55, s * 0.75 + 0.08);
return hslToHex(h, darkS, darkL);
}

View File

@@ -0,0 +1,64 @@
import type { CellRange, SpreadsheetCommand } from '../types';
export function createFillRangeCommand(
persistBatch: (
changes: Array<{ row: number; col: number; value: unknown }>,
) => void | Promise<void>,
before: Array<{ row: number; col: number; value: unknown }>,
after: Array<{ row: number; col: number; value: unknown }>,
): SpreadsheetCommand {
return {
label: 'fillRange',
undo: () => persistBatch(before),
redo: () => persistBatch(after),
};
}
export function normalizeRange(range: CellRange): CellRange {
return {
startRow: Math.min(range.startRow, range.endRow),
startCol: Math.min(range.startCol, range.endCol),
endRow: Math.max(range.startRow, range.endRow),
endCol: Math.max(range.startCol, range.endCol),
};
}
export function iterateRange(
range: CellRange,
fn: (row: number, col: number) => void,
) {
const r = normalizeRange(range);
for (let row = r.startRow; row <= r.endRow; row += 1) {
for (let col = r.startCol; col <= r.endCol; col += 1) {
fn(row, col);
}
}
}
export function iterateRanges(
ranges: CellRange[],
fn: (row: number, col: number) => void,
) {
for (const range of ranges) {
iterateRange(range, fn);
}
}
export function rangesContainCell(
ranges: CellRange[],
row: number,
col: number,
): boolean {
for (const range of ranges) {
const r = normalizeRange(range);
if (
row >= r.startRow &&
row <= r.endRow &&
col >= r.startCol &&
col <= r.endCol
) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,15 @@
import type { SpreadsheetCommand } from '../types';
export function createPasteRangeCommand(
persistBatch: (
changes: Array<{ row: number; col: number; value: unknown }>,
) => void | Promise<void>,
before: Array<{ row: number; col: number; value: unknown }>,
after: Array<{ row: number; col: number; value: unknown }>,
): SpreadsheetCommand {
return {
label: 'pasteRange',
undo: () => persistBatch(before),
redo: () => persistBatch(after),
};
}

View File

@@ -0,0 +1,12 @@
import type { SpreadsheetCommand } from '../types';
export function createSetCellValueCommand(
undo: () => void | Promise<void>,
redo: () => void | Promise<void>,
): SpreadsheetCommand {

View File

@@ -0,0 +1,55 @@
import type {
ConditionalFormatRule,
SpreadsheetColumnConfig,
} from '../types';
import { formatCellDisplay } from './useAutoMeasure';
export function shouldShowSuffix(displayText: string): boolean {
if (!displayText) return false;
if (displayText === '#ERROR' || displayText === '-') return false;
return true;
}
export function formatCellWithSuffix(
value: unknown,
colCfg?: SpreadsheetColumnConfig,
): string {
const cellType =
colCfg?.cellType === 'formula' ? 'number' : (colCfg?.cellType ?? 'text');
const base = formatCellDisplay(value, cellType);
if (!shouldShowSuffix(base) || !colCfg?.suffix) return base;
return base + colCfg.suffix;
}
function resolveRuleStyle(
rule: ConditionalFormatRule,
): { bgColor?: string; textColor?: string } {
const mode = rule.colorMode ?? 'background';
if (mode === 'text') {
return rule.textColor ? { textColor: rule.textColor } : {};
}
if (mode === 'both') {
return {
bgColor: rule.bgColor,
textColor: rule.textColor,
};
}
return rule.bgColor ? { bgColor: rule.bgColor } : {};
}
export function resolveConditionalStyle(
colCfg: SpreadsheetColumnConfig,
numericValue: number | null,
): { bgColor?: string; textColor?: string } | undefined {
const rules = colCfg.conditionalFormat;
if (rules == null || numericValue == null || Number.isNaN(numericValue)) {
return undefined;
}
if (rules.gt && numericValue > rules.gt.threshold) {
return resolveRuleStyle(rules.gt);
}
if (rules.lt && numericValue < rules.lt.threshold) {
return resolveRuleStyle(rules.lt);
}
return undefined;
}

View File

@@ -0,0 +1,13 @@
import type {
SpreadsheetColumnConfig,
SpreadsheetTextAlign,
} from '../types';
export function resolveColumnTextAlign(
colCfg: SpreadsheetColumnConfig | undefined,
defaultAlign: SpreadsheetTextAlign = 'left',
): SpreadsheetTextAlign {
const a = colCfg?.textAlign;
if (!a || a === 'inherit') return defaultAlign;
return a;
}

View File

@@ -0,0 +1,155 @@
import { formatCellWithSuffix } from './cellDisplay';
import type {
SpreadsheetColumnConfig,
SpreadsheetColumnFilter,
SpreadsheetViewState,
} from '../types';
type ResolveDisplayValue = (
viewRow: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
) => unknown;
function formatDisplayText(value: unknown, colCfg: SpreadsheetColumnConfig): string {
if (value === null || value === undefined) return '';
const cellType = colCfg.cellType === 'formula' ? 'number' : colCfg.cellType;
if (colCfg.suffix) {
return formatCellWithSuffix(value, colCfg);
}
if (cellType === 'number') {
const n = Number(value);
return Number.isFinite(n) ? String(n) : String(value);
}
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
export function getCellDisplayText(
row: Record<string, unknown>,
dataRowIndex: number,
colCfg: SpreadsheetColumnConfig,
resolveDisplayValue: ResolveDisplayValue,
): string {
const raw = resolveDisplayValue(dataRowIndex, colCfg, row);
return formatDisplayText(raw, colCfg);
}
export function collectColumnUniqueValues(
rows: Record<string, unknown>[],
col: number,
columns: SpreadsheetColumnConfig[],
resolveDisplayValue: ResolveDisplayValue,
): string[] {
const colCfg = columns.find((c) => c.col === col);
if (!colCfg) return [];
const set = new Set<string>();
rows.forEach((row, dataRowIndex) => {
set.add(getCellDisplayText(row, dataRowIndex, colCfg, resolveDisplayValue));
});
return [...set].sort((a, b) => a.localeCompare(b, 'zh-CN'));
}
export function isColumnFilterActive(filter?: SpreadsheetColumnFilter): boolean {
return filter?.checkedValues !== undefined;
}
export function isColumnFilterMatching(
displayText: string,
filter?: SpreadsheetColumnFilter,
allValues?: string[],
): boolean {
if (!filter || filter.checkedValues === undefined) return true;
if (filter.checkedValues.length === 0) return false;
if (allValues && filter.checkedValues.length >= allValues.length) return true;
return filter.checkedValues.includes(displayText);
}
function compareRows(
a: number,
b: number,
sortCol: number,
rows: Record<string, unknown>[],
columns: SpreadsheetColumnConfig[],
resolveDisplayValue: ResolveDisplayValue,
): number {
const colCfg = columns.find((c) => c.col === sortCol);
if (!colCfg) return 0;
const rowA = rows[a];
const rowB = rows[b];
if (!rowA || !rowB) return 0;
const textA = getCellDisplayText(rowA, a, colCfg, resolveDisplayValue);
const textB = getCellDisplayText(rowB, b, colCfg, resolveDisplayValue);
if (colCfg.cellType === 'number' || colCfg.cellType === 'formula') {
const nA = Number(textA);
const nB = Number(textB);
const aValid = Number.isFinite(nA);
const bValid = Number.isFinite(nB);
if (!aValid && !bValid) return 0;
if (!aValid) return 1;
if (!bValid) return -1;
return nA - nB;
}
return textA.localeCompare(textB, 'zh-CN');
}
export function applyViewState(
rows: Record<string, unknown>[],
columns: SpreadsheetColumnConfig[],
viewState: SpreadsheetViewState,
resolveDisplayValue: ResolveDisplayValue,
): number[] {
const activeFilterCols = Object.entries(viewState.filters).filter(([, f]) =>
isColumnFilterActive(f),
);
let indices = rows.map((_, i) => i);
if (activeFilterCols.length > 0) {
const uniqueValuesMap = new Map<number, string[]>();
for (const [colStr] of activeFilterCols) {
const col = Number(colStr);
if (!uniqueValuesMap.has(col)) {
uniqueValuesMap.set(
col,
collectColumnUniqueValues(
rows,
col,
columns,
resolveDisplayValue,
),
);
}
}
indices = indices.filter((dataRowIndex) => {
const row = rows[dataRowIndex];
if (!row) return false;
return activeFilterCols.every(([colStr, filter]) => {
const col = Number(colStr);
const colCfg = columns.find((c) => c.col === col);
if (!colCfg) return true;
const allValues = uniqueValuesMap.get(col);
const text = getCellDisplayText(
row,
dataRowIndex,
colCfg,
resolveDisplayValue,
);
return isColumnFilterMatching(text, filter, allValues);
});
});
}
if (viewState.sort) {
const { col, direction } = viewState.sort;
indices = [...indices].sort((a, b) => {
const cmp = compareRows(a, b, col, rows, columns, resolveDisplayValue);
return direction === 'asc' ? cmp : -cmp;
});
}
return indices;
}

View File

@@ -0,0 +1,219 @@
import type { SpreadsheetColumnConfig } from '../types';
import {
COLUMN_LETTER_HEIGHT,
DEFAULT_COL_WIDTH,
DEFAULT_ROW_HEIGHT,
MIN_COL_WIDTH,
ROW_NUMBER_WIDTH,
} from '../types';
import { getDataColX } from '../canvas/columnLayout';
const CELL_PADDING = 16;
const SAMPLE_ROWS = 50;
function measureTextWidth(
ctx: CanvasRenderingContext2D,
text: string,
): number {
return ctx.measureText(text).width + CELL_PADDING;
}
function formatCellDisplay(value: unknown, cellType: import('../types').CellType): string { if (value === null || value === undefined) return '';
if (cellType === 'number') {
const n = Number(value);
return Number.isFinite(n) ? String(n) : String(value);
}
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
export function measureColumnWidths(
canvas: HTMLCanvasElement,
headers: Array<{ title: string; col: number }>,
columns: SpreadsheetColumnConfig[],
rows: Record<string, unknown>[],
defaultWidth: number,
resolveDisplayValue?: (
dataRowIndex: number,
colCfg: SpreadsheetColumnConfig,
row: Record<string, unknown>,
) => unknown,
formatWithSuffix?: (
value: unknown,
colCfg: SpreadsheetColumnConfig,
) => string,
): number[] { const ctx = canvas.getContext('2d');
if (!ctx) return headers.map(() => defaultWidth);
ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
const maxCol = Math.max(...headers.map((h) => h.col), 0);
const widths = new Array<number>(maxCol + 1).fill(defaultWidth);
for (const header of headers) {
widths[header.col] = Math.max(
widths[header.col] ?? defaultWidth,
measureTextWidth(ctx, header.title),
MIN_COL_WIDTH,
);
}
const sample = rows.slice(0, SAMPLE_ROWS);
for (const colCfg of columns) {
let maxW = widths[colCfg.col] ?? defaultWidth;
sample.forEach((row, dataRowIdx) => {
const raw = resolveDisplayValue
? resolveDisplayValue(dataRowIdx, colCfg, row)
: row[colCfg.fieldId];
const text = formatWithSuffix
? formatWithSuffix(raw, colCfg)
: formatCellDisplay(raw, colCfg.cellType === 'formula' ? 'number' : colCfg.cellType);
maxW = Math.max(maxW, measureTextWidth(ctx, text));
});
widths[colCfg.col] = Math.max(maxW, MIN_COL_WIDTH);
}
return widths;
}
export function getRowHeight(
cellType: import('../types').CellType, configured: number | 'auto' | undefined,
): number {
if (typeof configured === 'number') return configured;
switch (cellType) {
case 'image':
case 'video':
return 48;
default:
return DEFAULT_ROW_HEIGHT;
}
}
export function getTotalWidth(colWidths: number[]): number {
return colWidths.reduce((sum, w) => sum + w, 0);
}
export function getRowTop(rowIndex: number, rowHeight: number): number {
return rowIndex * rowHeight;
}
export function getColLeft(colIndex: number, colWidths: number[]): number {
let left = 0;
for (let i = 0; i < colIndex; i += 1) {
left += colWidths[i] ?? DEFAULT_COL_WIDTH;
}
return left;
}
function getDataColScreenX(
col: number,
colWidths: number[],
scrollLeft: number,
fixedCols: number[],
fixedWidth: number,
): number {
return getDataColX(col, colWidths, scrollLeft, fixedCols, fixedWidth);
}
export function hitTestCell(
x: number,
y: number,
scrollTop: number,
scrollLeft: number,
colWidths: number[],
rowHeight: number,
rowCount: number,
colCount: number,
fixedCols: number[] = [],
fixedWidth = ROW_NUMBER_WIDTH,
): { row: number; col: number } | null {
const bodyY = y + scrollTop;
if (bodyY < 0) return null;
const row = Math.floor(bodyY / rowHeight);
if (row < 0 || row >= rowCount) return null;
for (let col = 0; col < colCount; col += 1) {
const cellX = getDataColScreenX(
col,
colWidths,
scrollLeft,
fixedCols,
fixedWidth,
);
const w = colWidths[col] ?? DEFAULT_COL_WIDTH;
if (x >= cellX && x < cellX + w) {
return { row, col };
}
}
return null;
}
export function hitTestRowNumber(
x: number,
y: number,
scrollTop: number,
rowHeight: number,
rowCount: number,
): number | null {
if (x < 0 || x >= ROW_NUMBER_WIDTH) return null;
const bodyY = y + scrollTop;
if (bodyY < 0) return null;
const row = Math.floor(bodyY / rowHeight);
if (row < 0 || row >= rowCount) return null;
return row;
}
export type HeaderHitArea = 'corner' | 'columnLetter' | 'columnTitle';
export function hitTestHeaderArea(
x: number,
y: number,
scrollLeft: number,
colWidths: number[],
colCount: number,
fixedCols: number[] = [],
fixedWidth = ROW_NUMBER_WIDTH,
): { area: HeaderHitArea; col?: number } | null {
if (x >= 0 && x < ROW_NUMBER_WIDTH) {
return { area: 'corner' };
}
const col = hitTestHeaderCol(
x,
scrollLeft,
colWidths,
colCount,
fixedCols,
fixedWidth,
);
if (col === null) return null;
if (y < COLUMN_LETTER_HEIGHT) {
return { area: 'columnLetter', col };
}
return { area: 'columnTitle', col };
}
export function hitTestHeaderCol(
x: number,
scrollLeft: number,
colWidths: number[],
colCount: number,
fixedCols: number[] = [],
fixedWidth = ROW_NUMBER_WIDTH,
): number | null {
for (let col = 0; col < colCount; col += 1) {
const cellX = getDataColScreenX(
col,
colWidths,
scrollLeft,
fixedCols,
fixedWidth,
);
const w = colWidths[col] ?? DEFAULT_COL_WIDTH;
if (x >= cellX && x < cellX + w) return col;
}
return null;
}
export { getDataColScreenX };
export { formatCellDisplay, CELL_PADDING };

View File

@@ -0,0 +1,60 @@
import { ref } from 'vue';
import type { ClipboardMatrix } from '../types';
export function useClipboard() {
const internal = ref<ClipboardMatrix | null>(null);
function setMatrix(matrix: ClipboardMatrix) {
internal.value = matrix;
}
function getMatrix() {
return internal.value;
}
function hasContent() {
return !!internal.value?.rows?.length;
}
function matrixToTsv(matrix: ClipboardMatrix): string {
return matrix.rows.map((row) => row.map(String).join('\t')).join('\n');
}
function tsvToMatrix(text: string): unknown[][] {
return text
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.split('\n')
.filter((line, i, arr) => line.length > 0 || i < arr.length - 1)
.map((line) => line.split('\t'));
}
async function writeSystemClipboard(matrix: ClipboardMatrix) {
const text = matrixToTsv(matrix);
try {
await navigator.clipboard.writeText(text);
} catch {
// ignore
}
}
async function readSystemClipboard(): Promise<string | null> {
try {
return await navigator.clipboard.readText();
} catch {
return null;
}
}
return {
internal,
setMatrix,
getMatrix,
hasContent,
matrixToTsv,
tsvToMatrix,
writeSystemClipboard,
readSystemClipboard,
};
}

View File

@@ -0,0 +1,67 @@
import { useUserStore } from '@vben/stores';
import type { SpreadsheetHistoryEntry } from '../types';
import {
appendLocalHistory,
listUnsyncedHistory,
markHistorySynced,
} from '../history/localHistoryStore';
import { saveHistoryBatch } from '../history/remoteHistoryApi';
let syncTimer: ReturnType<typeof setTimeout> | null = null;
function uuid() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
export function useEditHistory(
tableName: string,
sheetKey?: string,
enabled = true,
) {
const userStore = useUserStore();
async function recordChange(
payload: Omit<
SpreadsheetHistoryEntry,
'id' | 'tableName' | 'sheetKey' | 'userId' | 'userName' | 'synced' | 'createdAt'
>,
) {
if (!enabled) return;
const entry: SpreadsheetHistoryEntry = {
id: uuid(),
tableName,
sheetKey,
userId: Number(userStore.userInfo?.userId ?? 0),
userName: String(
userStore.userInfo?.nick_name ??
userStore.userInfo?.username ??
'未知用户',
),
createdAt: new Date().toISOString(),
synced: false,
...payload,
};
await appendLocalHistory(entry);
scheduleSync();
return entry;
}
function scheduleSync() {
if (syncTimer) clearTimeout(syncTimer);
syncTimer = setTimeout(async () => {
try {
const pending = await listUnsyncedHistory();
if (!pending.length) return;
const res = await saveHistoryBatch(pending);
await markHistorySynced(res.saved_ids ?? pending.map((p) => p.id));
} catch {
// retry on next change
}
}, 2000);
}
return { recordChange, scheduleSync };
}

View File

@@ -0,0 +1,49 @@
import { ref } from 'vue';
export function useFormulaReferenceMode() {
const active = ref(false);
const pickedCell = ref<{ row: number; col: number } | null>(null);
const caret = ref(0);
function setCaret(pos: number) {
caret.value = Math.max(0, pos);
}
function enterIfNeeded(draft: string, focused: boolean, editable: boolean) {
if (!focused || !editable) {
active.value = false;
pickedCell.value = null;
return;
}
const on = draft.trimStart().startsWith('=');
active.value = on;
if (!on) pickedCell.value = null;
}
function exit() {
active.value = false;
pickedCell.value = null;
}
function insertAtCaret(draft: string, text: string): string {
const pos = caret.value;
const next = draft.slice(0, pos) + text + draft.slice(pos);
caret.value = pos + text.length;
return next;
}
function pickCell(row: number, col: number) {
pickedCell.value = { row, col };
}
return {
active,
pickedCell,
caret,
setCaret,
enterIfNeeded,
exit,
insertAtCaret,
pickCell,
};
}

View File

@@ -0,0 +1,268 @@
import { ref } from 'vue';
import type { CellRange } from '../types';
import {
normalizeRange,
rangesContainCell,
} from '../commands/FillRangeCommand';
export interface SelectionModifiers {
shift?: boolean;
ctrl?: boolean;
}
function fullRowRange(row: number, maxCol: number): CellRange {
return normalizeRange({
startRow: row,
endRow: row,
startCol: 0,
endCol: maxCol,
});
}
function fullColRange(col: number, maxRow: number): CellRange {
return normalizeRange({
startRow: 0,
endRow: maxRow,
startCol: col,
endCol: col,
});
}
function rowRange(r0: number, r1: number, maxCol: number): CellRange {
return normalizeRange({
startRow: r0,
endRow: r1,
startCol: 0,
endCol: maxCol,
});
}
function colRange(c0: number, c1: number, maxRow: number): CellRange {
return normalizeRange({
startRow: 0,
endRow: maxRow,
startCol: c0,
endCol: c1,
});
}
function isFullRowRange(range: CellRange, maxCol: number): boolean {
const r = normalizeRange(range);
return r.startCol === 0 && r.endCol === maxCol;
}
function isFullColRange(range: CellRange, maxRow: number): boolean {
const r = normalizeRange(range);
return r.startRow === 0 && r.endRow === maxRow;
}
function rangeContainsRow(range: CellRange, row: number, maxCol: number): boolean {
const r = normalizeRange(range);
return isFullRowRange(r, maxCol) && row >= r.startRow && row <= r.endRow;
}
function rangeContainsCol(range: CellRange, col: number, maxRow: number): boolean {
const r = normalizeRange(range);
return isFullColRange(r, maxRow) && col >= r.startCol && col <= r.endCol;
}
export function useSelection() {
const ranges = ref<CellRange[]>([]);
const anchor = ref<{ row: number; col: number } | null>(null);
const activeCell = ref<{ row: number; col: number } | null>(null);
const isFillDragging = ref(false);
/** @deprecated use ranges */
const selection = ranges;
function selectCell(row: number, col: number) {
activeCell.value = { row, col };
anchor.value = { row, col };
ranges.value = [
{ startRow: row, startCol: col, endRow: row, endCol: col },
];
}
function extendSelection(row: number, col: number) {
if (!anchor.value) {
selectCell(row, col);
return;
}
ranges.value = [
{
startRow: anchor.value.row,
startCol: anchor.value.col,
endRow: row,
endCol: col,
},
];
activeCell.value = { row, col };
}
function selectRange(range: CellRange) {
const normalized = normalizeRange(range);
ranges.value = [normalized];
activeCell.value = {
row: normalized.startRow,
col: normalized.startCol,
};
anchor.value = { ...activeCell.value };
}
function getSelectionRanges(): CellRange[] {
return ranges.value.map((r) => normalizeRange(r));
}
function getNormalizedSelection(): CellRange | null {
const primary = getPrimaryRange();
return primary;
}
function getPrimaryRange(): CellRange | null {
if (!ranges.value.length) return null;
if (!activeCell.value) return normalizeRange(ranges.value[0]!);
const { row, col } = activeCell.value;
for (const range of ranges.value) {
const r = normalizeRange(range);
if (
row >= r.startRow &&
row <= r.endRow &&
col >= r.startCol &&
col <= r.endCol
) {
return r;
}
}
return normalizeRange(ranges.value[ranges.value.length - 1]!);
}
function containsCell(row: number, col: number): boolean {
return rangesContainCell(ranges.value, row, col);
}
function selectAll(maxRow: number, maxCol: number) {
const range = normalizeRange({
startRow: 0,
endRow: maxRow,
startCol: 0,
endCol: maxCol,
});
ranges.value = [range];
activeCell.value = { row: 0, col: 0 };
anchor.value = { row: 0, col: 0 };
}
function selectEntireRow(
row: number,
maxCol: number,
opts: SelectionModifiers = {},
) {
const { shift, ctrl } = opts;
if (ctrl) {
const next = [...ranges.value];
const idx = next.findIndex((r) => rangeContainsRow(r, row, maxCol));
if (idx >= 0) {
next.splice(idx, 1);
} else {
next.push(fullRowRange(row, maxCol));
}
ranges.value = next.length ? next : [];
if (ranges.value.length) {
activeCell.value = { row, col: 0 };
if (!shift) anchor.value = { row, col: 0 };
} else {
activeCell.value = null;
}
return;
}
if (shift && anchor.value) {
const range = rowRange(anchor.value.row, row, maxCol);
ranges.value = [range];
activeCell.value = { row, col: 0 };
return;
}
ranges.value = [fullRowRange(row, maxCol)];
activeCell.value = { row, col: 0 };
anchor.value = { row, col: 0 };
}
function selectEntireColumn(
col: number,
maxRow: number,
opts: SelectionModifiers = {},
) {
const { shift, ctrl } = opts;
if (ctrl) {
const next = [...ranges.value];
const idx = next.findIndex((r) => rangeContainsCol(r, col, maxRow));
if (idx >= 0) {
next.splice(idx, 1);
} else {
next.push(fullColRange(col, maxRow));
}
ranges.value = next.length ? next : [];
if (ranges.value.length) {
activeCell.value = { row: 0, col };
if (!shift) anchor.value = { row: 0, col };
} else {
activeCell.value = null;
}
return;
}
if (shift && anchor.value) {
const range = colRange(anchor.value.col, col, maxRow);
ranges.value = [range];
activeCell.value = { row: 0, col };
return;
}
ranges.value = [fullColRange(col, maxRow)];
activeCell.value = { row: 0, col };
anchor.value = { row: 0, col };
}
function extendRowSelection(row: number, maxCol: number) {
if (!anchor.value) {
selectEntireRow(row, maxCol);
return;
}
ranges.value = [rowRange(anchor.value.row, row, maxCol)];
activeCell.value = { row, col: 0 };
}
function extendColumnSelection(col: number, maxRow: number) {
if (!anchor.value) {
selectEntireColumn(col, maxRow);
return;
}
ranges.value = [colRange(anchor.value.col, col, maxRow)];
activeCell.value = { row: 0, col };
}
function clearSelection() {
ranges.value = [];
anchor.value = null;
activeCell.value = null;
}
return {
ranges,
selection,
anchor,
activeCell,
isFillDragging,
selectCell,
extendSelection,
selectRange,
selectAll,
selectEntireRow,
selectEntireColumn,
extendRowSelection,
extendColumnSelection,
getSelectionRanges,
getNormalizedSelection,
getPrimaryRange,
containsCell,
clearSelection,
};
}

View File

@@ -0,0 +1,270 @@
import type { ContextMenuItem } from '#/components/context-menu';
import { menuDivider, showContextMenu } from '#/components/context-menu';
import { message } from 'ant-design-vue';
import type {
CellEditContext,
HeaderContextMenuContext,
SpreadsheetColumnConfig,
SpreadsheetEngineExpose,
SpreadsheetHeaderConfig,
SpreadsheetTableConfig,
} from '../types';
export interface HeaderLayoutActions {
toggleFixed: (col: number) => void;
setHeaderColor: (col: number, color?: string) => void;
isFixed: (col: number) => boolean;
getHeaderColor: (col: number) => string | undefined;
}
export interface SpreadsheetAdminMenuActions {
onOpenConfig: () => void;
onAddColumn: () => void;
onSetColumn: (col: number) => void;
}
export interface SpreadsheetMenuActions {
col: number;
layoutActions?: HeaderLayoutActions;
onOpenHeaderColorPicker: (col: number) => void;
admin?: SpreadsheetAdminMenuActions;
}
function resolveCustomItems<T>(
config:
| ContextMenuItem[]
| ((ctx: T) => ContextMenuItem[])
| undefined,
ctx: T,
): ContextMenuItem[] {
if (!config) return [];
return typeof config === 'function' ? config(ctx) : config;
}
function buildCellBuiltinItems(
ctx: CellEditContext,
engine: SpreadsheetEngineExpose,
onStartEdit: (row: number, col: number) => void,
): ContextMenuItem[] {
const canEditCell = engine.canEdit(ctx.rowIndex, ctx.colIndex);
const sel = ctx.selection;
return [
{
key: 'edit',
label: '编辑',
iconName: 'ant-design:edit-outlined',
disabled: !canEditCell,
handler: () => onStartEdit(ctx.rowIndex, ctx.colIndex),
},
{
key: 'copy',
label: '复制',
iconName: 'ant-design:copy-outlined',
disabled: !sel,
handler: () => {
if (sel) {
engine.copy();
message.success('复制成功');
}
},
},
{
key: 'copy-image',
label: '复制为图片',
iconName: 'ant-design:picture-outlined',
disabled: !sel,
handler: () => {
if (!sel) return;
void engine
.copyAsImage()
.then(() => {
message.success('已复制为图片');
})
.catch(() => {
message.warning('复制图片失败,请检查浏览器剪贴板权限');
});
},
},
{
key: 'paste',
label: '粘贴',
iconName: 'ant-design:snippets-outlined',
disabled: !engine.canPaste(),
handler: () => {
void engine.paste();
},
},
{
key: 'undo',
label: '撤回',
iconName: 'ant-design:undo-outlined',
disabled: !engine.canUndo(),
handler: () => {
void engine.undo();
},
},
];
}
function buildFixedItem(
col: number,
layoutActions?: HeaderLayoutActions,
): ContextMenuItem | null {
if (!layoutActions) return null;
const fixed = layoutActions.isFixed(col);
return {
key: 'toggle-fixed',
label: fixed ? '取消固定' : '固定此列',
iconName: 'ant-design:pushpin-outlined',
handler: () => layoutActions.toggleFixed(col),
};
}
function buildHeaderColorItem(
col: number,
onOpen: (col: number) => void,
): ContextMenuItem {
return {
key: 'header-color',
label: '表头颜色',
iconName: 'ant-design:bg-colors-outlined',
handler: () => onOpen(col),
};
}
function buildAdminFlatItemsForCol(
col: number,
admin: SpreadsheetAdminMenuActions,
): ContextMenuItem[] {
return [
{
key: 'admin-config',
label: 'Excel 配置',
iconName: 'mdi:table-cog',
handler: () => admin.onOpenConfig(),
},
{
key: 'admin-add-col',
label: '新增一列',
iconName: 'ant-design:plus-outlined',
handler: () => admin.onAddColumn(),
},
{
key: 'admin-set-col',
label: '设置列',
iconName: 'ant-design:setting-outlined',
handler: () => admin.onSetColumn(col),
},
];
}
function buildColumnSubmenu(menuActions: SpreadsheetMenuActions): ContextMenuItem {
const { col, layoutActions, onOpenHeaderColorPicker, admin } = menuActions;
const children: ContextMenuItem[] = [];
if (admin) {
children.push({
key: 'admin-add-col',
label: '新增一列',
iconName: 'ant-design:plus-outlined',
handler: () => admin.onAddColumn(),
});
children.push({
key: 'admin-set-col',
label: '设置列',
iconName: 'ant-design:setting-outlined',
handler: () => admin.onSetColumn(col),
});
}
const fixedItem = buildFixedItem(col, layoutActions);
if (fixedItem) children.push(fixedItem);
children.push(buildHeaderColorItem(col, onOpenHeaderColorPicker));
return {
key: 'column-submenu',
label: '列',
iconName: 'ant-design:table-outlined',
children,
};
}
function buildHeaderMenuItems(menuActions: SpreadsheetMenuActions): ContextMenuItem[] {
const { col, layoutActions, onOpenHeaderColorPicker, admin } = menuActions;
const items: ContextMenuItem[] = [];
const fixedItem = buildFixedItem(col, layoutActions);
if (fixedItem) items.push(fixedItem);
items.push(buildHeaderColorItem(col, onOpenHeaderColorPicker));
if (admin) {
items.push(...buildAdminFlatItemsForCol(col, admin));
}
return items;
}
function buildCellAdminItems(menuActions: SpreadsheetMenuActions): ContextMenuItem[] {
if (!menuActions.admin) return [];
return [
{
key: 'admin-config',
label: 'Excel 配置',
iconName: 'mdi:table-cog',
handler: () => menuActions.admin!.onOpenConfig(),
},
buildColumnSubmenu(menuActions),
];
}
function mergeMenus(
builtin: ContextMenuItem[],
custom: ContextMenuItem[],
): ContextMenuItem[] {
if (!custom.length) return builtin;
return [...builtin, menuDivider('__divider__'), ...custom];
}
function appendAdminBlock(
items: ContextMenuItem[],
adminItems: ContextMenuItem[],
): ContextMenuItem[] {
if (!adminItems.length) return items;
return [...items, menuDivider('__admin_divider__'), ...adminItems];
}
export function openCellContextMenu(
e: MouseEvent,
ctx: CellEditContext,
column: SpreadsheetColumnConfig | undefined,
engine: SpreadsheetEngineExpose,
onStartEdit: (row: number, col: number) => void,
menuActions?: SpreadsheetMenuActions,
) {
const builtin = buildCellBuiltinItems(ctx, engine, onStartEdit);
const custom = resolveCustomItems(column?.contextMenu, ctx);
const adminItems = menuActions ? buildCellAdminItems(menuActions) : [];
showContextMenu(
e,
appendAdminBlock(mergeMenus(builtin, custom), adminItems),
ctx,
);
}
export function openHeaderContextMenu(
e: MouseEvent,
ctx: HeaderContextMenuContext,
header: SpreadsheetHeaderConfig | undefined,
tableConfig: SpreadsheetTableConfig,
menuActions: SpreadsheetMenuActions,
) {
const builtin = buildHeaderMenuItems(menuActions);
let custom = resolveCustomItems(header?.contextMenu, ctx);
if (!custom.length) {
custom = resolveCustomItems(tableConfig.headerContextMenu, ctx);
}
showContextMenu(e, mergeMenus(builtin, custom), ctx);
}

View File

@@ -0,0 +1,784 @@
import { computed, ref, shallowRef, watch, type Ref } from 'vue';
import { createFillRangeCommand, iterateRange, iterateRanges, normalizeRange, rangesContainCell } from '../commands/FillRangeCommand';
import { createPasteRangeCommand } from '../commands/PasteRangeCommand';
import { createSetCellValueCommand } from '../commands/SetCellValueCommand';
import {
computeFixedWidth,
getContentSize,
getFixedColIndices,
renderSelectionImage,
} from '../canvas/SpreadsheetRenderer';
import { applyViewState, isColumnFilterActive } from './filterSort';
import {
getRowHeight,
hitTestCell,
hitTestHeaderCol,
hitTestRowNumber,
hitTestHeaderArea,
measureColumnWidths,
} from '../composables/useAutoMeasure';
import { useClipboard } from '../composables/useClipboard';
import { useEditHistory } from '../composables/useEditHistory';
import { useSelection } from '../composables/useSelection';
import { useUndoRedo } from '../composables/useUndoRedo';
import type {
CellEditContext,
CellRange,
SpreadsheetBatchCellChange,
SpreadsheetColumnConfig,
SpreadsheetEngineExpose,
SpreadsheetHeaderConfig,
SpreadsheetHistoryEntry,
SpreadsheetTableConfig,
SpreadsheetViewState,
SortDirection,
} from '../types';
import {
createDefaultViewState,
DEFAULT_COL_WIDTH,
DEFAULT_ROW_HEIGHT,
HEADER_HEIGHT,
ROW_NUMBER_WIDTH,
} from '../types';
import {
evaluateFormula,
formatFormulaResult,
type CellValueReader,
type FormulaResult,
} from '../formula/evaluator';
import {
formatCellWithSuffix,
resolveConditionalStyle,
} from '../composables/cellDisplay';
export function useSpreadsheetEngine(options: {
tableConfig: Ref<SpreadsheetTableConfig>;
mergedHeaders: Ref<SpreadsheetHeaderConfig[]>;
columns: Ref<SpreadsheetColumnConfig[]>;
data: Ref<Record<string, unknown>[]>;
rowKeyField: Ref<string>;
layoutColWidths: Ref<Record<number, number>>;
scrollViewportHeight: Ref<number>;
isDark: Ref<boolean>;
onChange: (data: Record<string, unknown>[]) => void;
}) {
const sortedColumns = computed(() =>
[...options.columns.value].sort((a, b) => a.col - b.col),
);
const colWidths = ref<number[]>([]);
const rowHeight = ref(DEFAULT_ROW_HEIGHT);
const scrollTop = ref(0);
const scrollLeft = ref(0);
const viewportWidth = ref(800);
const viewportHeight = ref(600);
const filterValues = ref<Record<number, string>>({});
const viewState = ref<SpreadsheetViewState>(createDefaultViewState());
const initialSnapshot = shallowRef<Map<string, unknown>>(new Map());
const dirtyKeys = ref<Set<string>>(new Set());
const editing = ref<{ row: number; col: number } | null>(null);
const measureCanvas = ref<HTMLCanvasElement | null>(null);
const selectionApi = useSelection();
const undoRedo = useUndoRedo();
const clipboard = useClipboard();
const history = useEditHistory(
options.tableConfig.value.tableName,
options.tableConfig.value.sheetKey,
options.tableConfig.value.enableHistory !== false,
);
const filteredRowIndices = computed(() =>
applyViewState(
options.data.value,
sortedColumns.value,
viewState.value,
(dataRowIndex, colCfg, row) =>
resolveDisplayValueForDataRow(dataRowIndex, colCfg, row),
),
);
function resolveDisplayValueForDataRow(
dataRowIndex: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
): unknown {
if (colCfg.cellType === 'formula' && colCfg.formula) {
const result = evaluateFormula(
colCfg.formula,
dataRowIndex,
(viewRow, col) => {
const cfg = colConfig(col);
if (!cfg || cfg.cellType === 'formula') return 0;
const dr = options.data.value[viewRow];
return dr ? getCellValue(viewRow, cfg.fieldId) : 0;
},
);
return formatFormulaResult(result);
}
const rowData = row ?? options.data.value[dataRowIndex];
if (!rowData) return undefined;
return rowData[colCfg.fieldId];
}
const fixedCols = computed(() =>
getFixedColIndices(options.mergedHeaders.value),
);
const fixedWidth = computed(() =>
computeFixedWidth(colWidths.value, fixedCols.value),
);
const displayRowCount = computed(() => {
const minRows = Math.max(
1,
Math.ceil(options.scrollViewportHeight.value / rowHeight.value),
);
return Math.max(filteredRowIndices.value.length, minRows);
});
const colCount = computed(
() => Math.max(...sortedColumns.value.map((c) => c.col), 0) + 1,
);
const defaultColWidth = computed(
() => options.tableConfig.value.defaultColWidth ?? DEFAULT_COL_WIDTH,
);
const dataContentWidth = computed(
() => ROW_NUMBER_WIDTH + colWidths.value.reduce((s, w) => s + w, 0),
);
const displayContentWidth = computed(() =>
Math.max(dataContentWidth.value, viewportWidth.value),
);
const fillerColCount = computed(() => {
const remaining = displayContentWidth.value - dataContentWidth.value;
if (remaining <= 0) return 0;
return Math.ceil(remaining / defaultColWidth.value);
});
const displayColCount = computed(
() => colCount.value + fillerColCount.value,
);
const paddedColWidths = computed(() => {
const defaultW = defaultColWidth.value;
const result: number[] = [];
for (let c = 0; c < displayColCount.value; c += 1) {
result.push(colWidths.value[c] ?? defaultW);
}
return result;
});
function colConfig(col: number) {
return sortedColumns.value.find((c) => c.col === col);
}
function dataRowFromViewRow(viewRow: number) {
return filteredRowIndices.value[viewRow] ?? viewRow;
}
function viewRowFromDataRow(dataRow: number) {
return filteredRowIndices.value.indexOf(dataRow);
}
function isEditable(colCfg?: SpreadsheetColumnConfig) {
if (!colCfg) return false;
if (colCfg.cellType === 'readonly' || colCfg.cellType === 'formula') {
return false;
}
return colCfg.editable !== false;
}
const readCellForFormula: CellValueReader = (viewRow, col) => {
const cfg = colConfig(col);
if (!cfg || cfg.cellType === 'formula') return 0;
const dataRow = dataRowFromViewRow(viewRow);
return getCellValue(dataRow, cfg.fieldId);
};
function resolveNumericValue(
viewRow: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
): number | null {
if (colCfg.cellType === 'formula' && colCfg.formula) {
const result: FormulaResult = evaluateFormula(
colCfg.formula,
viewRow,
readCellForFormula,
);
if (result === 'DIV_ZERO' || result == null) return null;
return result;
}
if (colCfg.cellType === 'number' && row) {
const n = Number(row[colCfg.fieldId]);
return Number.isFinite(n) ? n : null;
}
return null;
}
function resolveCellStyle(
viewRow: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
) {
return resolveConditionalStyle(
colCfg,
resolveNumericValue(viewRow, colCfg, row),
);
}
function resolveDisplayValue(
viewRow: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
): unknown {
if (colCfg.cellType === 'formula' && colCfg.formula) {
const result = evaluateFormula(
colCfg.formula,
viewRow,
readCellForFormula,
);
return formatFormulaResult(result);
}
if (!row) return undefined;
return row[colCfg.fieldId];
}
function getCellValue(dataRow: number, fieldId: string) {
return options.data.value[dataRow]?.[fieldId];
}
function buildCellContext(viewRow: number, col: number): CellEditContext | null {
const colCfg = colConfig(col);
if (!colCfg) return null;
const dataRow = dataRowFromViewRow(viewRow);
const rowData = options.data.value[dataRow];
if (!rowData) return null;
return {
fieldId: colCfg.fieldId,
fieldValue: rowData[colCfg.fieldId],
rowIndex: viewRow,
colIndex: col,
rowKey: rowData[options.rowKeyField.value] as string | number,
rowData,
editParams: colCfg.editParams ?? {},
selection: selectionApi.getPrimaryRange() ?? undefined,
};
}
function snapshotKey(dataRow: number, fieldId: string) {
const row = options.data.value[dataRow];
const rowKey = row?.[options.rowKeyField.value];
return `${rowKey}:${fieldId}`;
}
function markDirty(dataRow: number, fieldId: string, value: unknown) {
const key = snapshotKey(dataRow, fieldId);
const initial = initialSnapshot.value.get(key);
const next = new Set(dirtyKeys.value);
if (initial !== undefined && initial !== value) {
next.add(`${dataRow}:${fieldId}`);
} else {
next.delete(`${dataRow}:${fieldId}`);
}
dirtyKeys.value = next;
}
function applyLocalValue(dataRow: number, fieldId: string, value: unknown) {
const rows = options.data.value.map((r, i) =>
i === dataRow ? { ...r, [fieldId]: value } : r,
);
options.onChange(rows);
markDirty(dataRow, fieldId, value);
}
async function persistChanges(
items: Array<{ viewRow: number; col: number; value: unknown }>,
opts: {
recordHistory?: boolean;
action?: SpreadsheetHistoryEntry['action'];
} = {},
) {
const batchChanges: SpreadsheetBatchCellChange[] = [];
for (const item of items) {
const colCfg = colConfig(item.col);
if (!colCfg || !isEditable(colCfg)) continue;
const dataRow = dataRowFromViewRow(item.viewRow);
const ctx = buildCellContext(item.viewRow, item.col);
if (!ctx) continue;
const before = getCellValue(dataRow, colCfg.fieldId);
applyLocalValue(dataRow, colCfg.fieldId, item.value);
batchChanges.push({
ctx: { ...ctx, fieldValue: item.value },
before,
after: item.value,
col: item.col,
viewRow: item.viewRow,
});
}
if (batchChanges.length === 0) return;
const onBatchEdit = options.tableConfig.value.onBatchEdit;
if (onBatchEdit) {
await onBatchEdit(batchChanges);
} else {
for (const ch of batchChanges) {
const cfg = colConfig(ch.col);
if (cfg?.onEdit) {
await cfg.onEdit({ ...ch.ctx, fieldValue: ch.after });
}
}
}
if (
opts.recordHistory !== false &&
options.tableConfig.value.enableHistory !== false
) {
for (const ch of batchChanges) {
await history.recordChange({
action: opts.action ?? 'edit',
rowKey: ch.ctx.rowKey,
fieldId: ch.ctx.fieldId,
beforeSnapshot: ch.before,
afterSnapshot: ch.after,
});
}
}
}
function toPersistItems(
batch: Array<{ row: number; col: number; value: unknown }>,
) {
return batch.map((ch) => ({
viewRow: ch.row,
col: ch.col,
value: ch.value,
}));
}
async function commitCellChange(
viewRow: number,
col: number,
newValue: unknown,
recordHistory = true,
action: SpreadsheetHistoryEntry['action'] = 'edit',
) {
const colCfg = colConfig(col);
if (!colCfg || !isEditable(colCfg)) return;
const dataRow = dataRowFromViewRow(viewRow);
const before = getCellValue(dataRow, colCfg.fieldId);
if (before === newValue) return;
await persistChanges([{ viewRow, col, value: newValue }], {
recordHistory,
action,
});
undoRedo.push(
createSetCellValueCommand(
() =>
persistChanges([{ viewRow, col, value: before }], {
recordHistory: false,
}),
() =>
persistChanges([{ viewRow, col, value: newValue }], {
recordHistory: false,
}),
),
);
}
async function setCellValue(
viewRow: number,
col: number,
value: unknown,
recordHistory = true,
action: SpreadsheetHistoryEntry['action'] = 'edit',
) {
await commitCellChange(viewRow, col, value, recordHistory, action);
}
async function fillRange(source: CellRange, target: CellRange) {
const src = normalizeRange(source);
const tgt = normalizeRange(target);
const before: Array<{ row: number; col: number; value: unknown }> = [];
const after: Array<{ row: number; col: number; value: unknown }> = [];
const srcRows = src.endRow - src.startRow + 1;
const srcCols = src.endCol - src.startCol + 1;
iterateRange(tgt, (row, col) => {
const colCfg = colConfig(col);
if (!colCfg || !isEditable(colCfg)) return;
const relRow = (row - tgt.startRow) % srcRows;
const relCol = (col - tgt.startCol) % srcCols;
const srcRow = src.startRow + relRow;
const srcCol = src.startCol + relCol;
const srcColCfg = colConfig(srcCol);
if (!srcColCfg) return;
const dataRow = dataRowFromViewRow(row);
const beforeVal = getCellValue(dataRow, colCfg.fieldId);
const afterVal = getCellValue(
dataRowFromViewRow(srcRow),
srcColCfg.fieldId,
);
before.push({ row, col, value: beforeVal });
after.push({ row, col, value: afterVal });
});
await persistChanges(toPersistItems(after), {
recordHistory: true,
action: 'fill',
});
undoRedo.push(
createFillRangeCommand(
(batch) => persistChanges(toPersistItems(batch), { recordHistory: false }),
before,
after,
),
);
}
function copy(selectionOverride?: CellRange | CellRange[]) {
const selRanges = Array.isArray(selectionOverride)
? selectionOverride.map((r) => normalizeRange(r))
: selectionOverride
? [normalizeRange(selectionOverride)]
: selectionApi.getSelectionRanges();
if (!selRanges.length) return;
const rowSet = new Set<number>();
const colSet = new Set<number>();
iterateRanges(selRanges, (viewRow, col) => {
rowSet.add(viewRow);
colSet.add(col);
});
const uniqueRows = [...rowSet].sort((a, b) => a - b);
const uniqueCols = [...colSet].sort((a, b) => a - b);
if (!uniqueRows.length || !uniqueCols.length) return;
const rows: unknown[][] = uniqueRows.map((viewRow) =>
uniqueCols.map((col) => {
if (!rangesContainCell(selRanges, viewRow, col)) return '';
const colCfg = colConfig(col);
if (!colCfg) return '';
const dataRow = dataRowFromViewRow(viewRow);
const row = options.data.value[dataRow];
const raw = resolveDisplayValue(viewRow, colCfg, row);
return formatCellWithSuffix(raw, colCfg);
}),
);
const matrix = {
rows,
startRow: uniqueRows[0]!,
startCol: uniqueCols[0]!,
};
clipboard.setMatrix(matrix);
void clipboard.writeSystemClipboard(matrix);
}
async function copyAsImage(selectionOverride?: CellRange | CellRange[]) {
const selRanges = Array.isArray(selectionOverride)
? selectionOverride.map((r) => normalizeRange(r))
: selectionOverride
? [normalizeRange(selectionOverride)]
: selectionApi.getSelectionRanges();
if (!selRanges.length) return;
const blob = await renderSelectionImage({
selectionRanges: selRanges,
headers: options.mergedHeaders.value,
columns: sortedColumns.value,
colWidths: paddedColWidths.value,
rowHeight: rowHeight.value,
defaultColWidth: defaultColWidth.value,
defaultTextAlign: options.tableConfig.value.defaultTextAlign ?? 'left',
isDark: options.isDark.value,
filteredRowIndices: filteredRowIndices.value,
rows: options.data.value,
dirtyKeys: dirtyKeys.value,
resolveDisplayValue,
resolveCellStyle,
});
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
]);
}
async function paste() {
let matrix = clipboard.getMatrix();
if (!matrix) {
const text = await clipboard.readSystemClipboard();
if (!text) return;
matrix = {
rows: clipboard.tsvToMatrix(text),
startRow: 0,
startCol: 0,
};
}
const start = selectionApi.activeCell.value ?? {
row: 0,
col: sortedColumns.value[0]?.col ?? 0,
};
const before: Array<{ row: number; col: number; value: unknown }> = [];
const after: Array<{ row: number; col: number; value: unknown }> = [];
matrix.rows.forEach((rowVals, rOff) => {
rowVals.forEach((val, cOff) => {
const viewRow = start.row + rOff;
const col = start.col + cOff;
const colCfg = colConfig(col);
if (!colCfg || !isEditable(colCfg)) return;
const dataRow = dataRowFromViewRow(viewRow);
if (dataRow >= options.data.value.length) return;
before.push({
row: viewRow,
col,
value: getCellValue(dataRow, colCfg.fieldId),
});
after.push({ row: viewRow, col, value: val });
});
});
const applyBatch = (
changes: Array<{ row: number; col: number; value: unknown }>,
) => persistChanges(toPersistItems(changes), { recordHistory: false });
await persistChanges(toPersistItems(after), {
recordHistory: true,
action: 'paste',
});
undoRedo.push(createPasteRangeCommand(applyBatch, before, after));
}
function remeasure() {
if (!measureCanvas.value) return;
const defaultW =
options.tableConfig.value.defaultColWidth ?? DEFAULT_COL_WIDTH;
const measured = measureColumnWidths(
measureCanvas.value,
options.mergedHeaders.value,
sortedColumns.value,
options.data.value,
defaultW,
(dataRowIdx, colCfg, row) =>
resolveDisplayValue(dataRowIdx, colCfg, row),
formatCellWithSuffix,
);
const overrides = options.layoutColWidths.value;
colWidths.value = measured.map((w, i) => overrides[i] ?? w);
const firstType = sortedColumns.value[0]?.cellType ?? 'text';
rowHeight.value = getRowHeight(
firstType,
options.tableConfig.value.rowHeight,
);
}
function initSnapshot() {
const map = new Map<string, unknown>();
options.data.value.forEach((row, dataRow) => {
sortedColumns.value.forEach((col) => {
map.set(snapshotKey(dataRow, col.fieldId), row[col.fieldId]);
});
});
initialSnapshot.value = map;
dirtyKeys.value = new Set();
}
watch(
() => options.data.value,
() => {
if (initialSnapshot.value.size === 0) initSnapshot();
remeasure();
},
{ deep: true, immediate: true },
);
watch(
() => options.mergedHeaders.value,
() => remeasure(),
{ deep: true },
);
watch(
() => options.layoutColWidths.value,
() => remeasure(),
{ deep: true },
);
const contentSize = computed(() =>
getContentSize(
colWidths.value,
displayRowCount.value,
rowHeight.value,
displayContentWidth.value,
),
);
function setColumnFilter(col: number, checkedValues: string[] | undefined) {
const filters = { ...viewState.value.filters };
if (checkedValues === undefined) {
delete filters[col];
} else {
filters[col] = { checkedValues };
}
viewState.value = { ...viewState.value, filters };
}
function clearColumnFilter(col: number) {
const next = { ...viewState.value.filters };
delete next[col];
viewState.value = { ...viewState.value, filters: next };
}
function setSort(col: number, direction: SortDirection) {
viewState.value = { ...viewState.value, sort: { col, direction } };
}
function clearSort() {
viewState.value = { ...viewState.value, sort: null };
}
const engine: SpreadsheetEngineExpose = {
copy,
copyAsImage,
paste,
undo: undoRedo.undo,
redo: undoRedo.redo,
canUndo: undoRedo.canUndo,
canRedo: undoRedo.canRedo,
canCopy: () => selectionApi.getSelectionRanges().length > 0,
canCopyAsImage: () => selectionApi.getSelectionRanges().length > 0,
canPaste: () => clipboard.hasContent() || true,
canEdit: (_row, col) => {
const cfg = colConfig(col);
return !!cfg && isEditable(cfg);
},
getSelection: selectionApi.getSelectionRanges,
getCellEditContext: (row, col) => buildCellContext(row, col),
getHeaderContext: (col) => {
const header =
options.mergedHeaders.value.find((h) => h.col === col) ?? {
title: '',
col,
};
return {
colIndex: col,
fieldId: header.fieldId ?? colConfig(col)?.fieldId,
headerTitle: header.title,
filterValue: isColumnFilterActive(viewState.value.filters[col])
? 'active'
: '',
};
},
setCellValue,
fillRange,
getColumnConfig: colConfig,
getFilterValue: (col) =>
isColumnFilterActive(viewState.value.filters[col]) ? 'active' : '',
};
return {
engine,
sortedColumns,
colWidths,
rowHeight,
scrollTop,
scrollLeft,
viewportWidth,
viewportHeight,
filterValues,
viewState,
setColumnFilter,
clearColumnFilter,
setSort,
clearSort,
dirtyKeys,
editing,
measureCanvas,
filteredRowIndices,
contentSize,
displayRowCount,
displayColCount,
displayContentWidth,
defaultColWidth,
paddedColWidths,
fixedCols,
fixedWidth,
colCount,
selectionApi,
undoRedo,
dataRowFromViewRow,
viewRowFromDataRow,
colConfig,
isEditable,
resolveDisplayValue,
resolveCellStyle,
buildCellContext,
commitCellChange,
remeasure,
initSnapshot,
hitTestCell: (x: number, y: number) =>
hitTestCell(
x,
y,
scrollTop.value,
scrollLeft.value,
paddedColWidths.value,
rowHeight.value,
displayRowCount.value,
colCount.value,
fixedCols.value,
fixedWidth.value,
),
hitTestHeaderCol: (x: number) =>
hitTestHeaderCol(
x,
scrollLeft.value,
paddedColWidths.value,
displayColCount.value,
fixedCols.value,
fixedWidth.value,
),
hitTestRowNumber: (x: number, y: number) =>
hitTestRowNumber(
x,
y,
scrollTop.value,
rowHeight.value,
displayRowCount.value,
),
hitTestHeaderArea: (x: number, y: number) =>
hitTestHeaderArea(
x,
y,
scrollLeft.value,
paddedColWidths.value,
displayColCount.value,
fixedCols.value,
fixedWidth.value,
),
HEADER_HEIGHT,
ROW_NUMBER_WIDTH,
};
}
export type SpreadsheetEngine = ReturnType<typeof useSpreadsheetEngine>;

View File

@@ -0,0 +1,125 @@
import { computed, ref, watch, type Ref } from 'vue';
import { useDebounceFn } from '@vueuse/core';
import { formatCellWithSuffix } from './cellDisplay';
import type { SpreadsheetColumnConfig } from '../types';
export interface FindMatch {
row: number;
col: number;
}
export function useSpreadsheetFind(options: {
filteredRowIndices: Ref<number[]>;
columns: Ref<SpreadsheetColumnConfig[]>;
rows: Ref<Record<string, unknown>[]>;
resolveDisplayValue: (
viewRow: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
) => unknown;
}) {
const open = ref(false);
const query = ref('');
const matches = ref<FindMatch[]>([]);
const activeIndex = ref(-1);
function computeMatches(keyword: string): FindMatch[] {
const q = keyword.trim().toLowerCase();
if (!q) return [];
const result: FindMatch[] = [];
const cols = [...options.columns.value].sort((a, b) => a.col - b.col);
const rowCount = options.filteredRowIndices.value.length;
for (let viewRow = 0; viewRow < rowCount; viewRow += 1) {
const dataRow = options.filteredRowIndices.value[viewRow];
const row =
dataRow !== undefined ? options.rows.value[dataRow] : undefined;
if (!row) continue;
for (const colCfg of cols) {
const raw = options.resolveDisplayValue(viewRow, colCfg, row);
const text = formatCellWithSuffix(raw, colCfg).toLowerCase();
if (text.includes(q)) {
result.push({ row: viewRow, col: colCfg.col });
}
}
}
return result;
}
const debouncedSearch = useDebounceFn((keyword: string) => {
matches.value = computeMatches(keyword);
activeIndex.value = matches.value.length > 0 ? 0 : -1;
}, 150);
watch(query, (v) => {
if (!open.value) return;
void debouncedSearch(v);
});
const matchCount = computed(() => matches.value.length);
const activeMatch = computed(() => {
if (activeIndex.value < 0 || activeIndex.value >= matches.value.length) {
return null;
}
return matches.value[activeIndex.value] ?? null;
});
function openFind(initialQuery?: string) {
open.value = true;
if (initialQuery !== undefined) {
query.value = initialQuery;
}
matches.value = computeMatches(query.value);
activeIndex.value = matches.value.length > 0 ? 0 : -1;
}
function closeFind() {
open.value = false;
query.value = '';
matches.value = [];
activeIndex.value = -1;
}
function toggleFind() {
if (open.value) closeFind();
else openFind();
}
function goNext(): FindMatch | null {
if (!matches.value.length) return null;
activeIndex.value =
activeIndex.value < 0
? 0
: (activeIndex.value + 1) % matches.value.length;
return matches.value[activeIndex.value] ?? null;
}
function goPrev(): FindMatch | null {
if (!matches.value.length) return null;
activeIndex.value =
activeIndex.value < 0
? matches.value.length - 1
: (activeIndex.value - 1 + matches.value.length) %
matches.value.length;
return matches.value[activeIndex.value] ?? null;
}
return {
open,
query,
matches,
activeIndex,
matchCount,
activeMatch,
openFind,
closeFind,
toggleFind,
goNext,
goPrev,
};
}

View File

@@ -0,0 +1,47 @@
import { ref } from 'vue';
import type { SpreadsheetCommand } from '../types';
const MAX_STACK = 100;
export function useUndoRedo() {
const undoStack = ref<SpreadsheetCommand[]>([]);
const redoStack = ref<SpreadsheetCommand[]>([]);
function push(cmd: SpreadsheetCommand) {
undoStack.value.push(cmd);
if (undoStack.value.length > MAX_STACK) {
undoStack.value.shift();
}
redoStack.value = [];
}
async function undo() {
const cmd = undoStack.value.pop();
if (!cmd) return;
await cmd.undo();
redoStack.value.push(cmd);
}
async function redo() {
const cmd = redoStack.value.pop();
if (!cmd) return;
await cmd.redo();
undoStack.value.push(cmd);
}
function canUndo() {
return undoStack.value.length > 0;
}
function canRedo() {
return redoStack.value.length > 0;
}
function clear() {
undoStack.value = [];
redoStack.value = [];
}
return { push, undo, redo, canUndo, canRedo, clear };
}

View File

@@ -0,0 +1,238 @@
<script lang="ts" setup>
import { computed, nextTick, ref, watch } from 'vue';
import { Input, InputNumber } from 'ant-design-vue';
import { getDataColScreenX } from '../composables/useAutoMeasure';
import type { SpreadsheetColumnConfig } from '../types';
const BLUR_GUARD_MS = 150;
const props = defineProps<{
visible: boolean;
viewRow: number;
col: number;
colWidths: number[];
rowHeight: number;
scrollTop: number;
scrollLeft: number;
fixedCols: number[];
fixedWidth: number;
isDark?: boolean;
column?: SpreadsheetColumnConfig;
value: unknown;
draftSeed?: string | number | null;
selectAllOnFocus?: boolean;
}>();
const emit = defineEmits<{
commit: [value: unknown];
cancel: [];
}>();
const inputRef = ref<{ focus?: () => void; select?: () => void } | null>(null);
const draft = ref<string | number>('');
const ignoreBlurUntil = ref(0);
const suppressBlurCommit = ref(false);
const isCommitting = ref(false);
const style = computed(() => {
const left = getDataColScreenX(
props.col,
props.colWidths,
props.scrollLeft,
props.fixedCols,
props.fixedWidth,
);
const top = props.viewRow * props.rowHeight - props.scrollTop;
const width = props.colWidths[props.col] ?? 100;
return {
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${props.rowHeight}px`,
};
});
function getNativeInput(): HTMLInputElement | null {
const el = inputRef.value as
| { focus?: () => void; select?: () => void; $el?: HTMLElement }
| null;
if (!el) return null;
if (el.$el) {
return el.$el.querySelector('input') as HTMLInputElement | null;
}
return null;
}
function focusInput(options?: { selectAll?: boolean }) {
const el = inputRef.value as
| { focus?: () => void; select?: () => void; $el?: HTMLElement }
| null;
const native = getNativeInput();
if (native) {
native.focus({ preventScroll: true });
if (options?.selectAll) {
native.select();
} else {
const len = String(native.value ?? '').length;
native.setSelectionRange(len, len);
}
return;
}
if (!el) return;
if (typeof el.focus === 'function') {
el.focus();
if (options?.selectAll) {
el.select?.();
}
}
}
function blurInput() {
getNativeInput()?.blur();
}
function getDraftValue(): unknown {
return props.column?.cellType === 'number'
? Number(draft.value)
: draft.value;
}
watch(
() => props.visible,
async (v, _old, onCleanup) => {
let cancelled = false;
onCleanup(() => {
cancelled = true;
});
if (!v) {
suppressBlurCommit.value = false;
return;
}
ignoreBlurUntil.value = Date.now() + BLUR_GUARD_MS;
if (props.draftSeed != null) {
draft.value =
props.column?.cellType === 'number'
? Number(props.draftSeed)
: String(props.draftSeed);
} else {
draft.value =
props.column?.cellType === 'number'
? Number(props.value ?? 0)
: String(props.value ?? '');
}
await nextTick();
if (cancelled || !props.visible) return;
focusInput({ selectAll: props.selectAllOnFocus !== false });
},
{ immediate: true },
);
function commitFromEnter() {
if (isCommitting.value) return;
isCommitting.value = true;
suppressBlurCommit.value = true;
submit();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
commitFromEnter();
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
suppressBlurCommit.value = true;
blurInput();
emit('cancel');
}
}
function submit() {
emit('commit', getDraftValue());
}
function onBlur() {
if (Date.now() < ignoreBlurUntil.value) return;
if (suppressBlurCommit.value) return;
submit();
}
function stopPointer(e: MouseEvent) {
e.stopPropagation();
}
defineExpose({
getDraftValue,
blurInput,
});
</script>
<template>
<div
class="cell-editor-overlay"
:class="{ 'is-dark': isDark }"
:style="style"
@mousedown.stop="stopPointer"
@click.stop="stopPointer"
>
<InputNumber
v-if="column?.cellType === 'number'"
ref="inputRef"
v-model:value="draft as number"
class="cell-editor-input"
size="small"
:controls="false"
@keydown="onKeydown"
@blur="onBlur"
/>
<Input
v-else
ref="inputRef"
v-model:value="draft as string"
class="cell-editor-input"
size="small"
@keydown="onKeydown"
@blur="onBlur"
/>
</div>
</template>
<style scoped lang="scss">
.cell-editor-overlay {
position: absolute;
z-index: 20;
box-sizing: border-box;
border: 2px solid #165dff;
background: #fff;
&.is-dark {
border-color: #1668dc;
background: #141414;
}
}
.cell-editor-input {
width: 100%;
height: 100%;
:deep(.ant-input-number),
:deep(.ant-input) {
width: 100%;
height: 100%;
border: none;
border-radius: 0;
box-shadow: none;
background: transparent;
}
}
</style>

View File

@@ -0,0 +1,191 @@
export type CellValueReader = (viewRow: number, col: number) => unknown;
export type FormulaResult = number | 'DIV_ZERO' | null;
class DivZeroError extends Error {
constructor() {
super('Division by zero');
this.name = 'DivZeroError';
}
}
export function colLettersToIndex(letters: string): number {
let n = 0;
for (const ch of letters.toUpperCase()) {
n = n * 26 + (ch.charCodeAt(0) - 64);
}
return n - 1;
}
export function colIndexToLetters(index: number): string {
let n = index + 1;
let result = '';
while (n > 0) {
n -= 1;
result = String.fromCharCode(65 + (n % 26)) + result;
n = Math.floor(n / 26);
}
return result;
}
function toNumber(value: unknown): number {
if (value == null || value === '') return 0;
const n = Number(value);
return Number.isFinite(n) ? n : Number.NaN;
}
function tokenize(expr: string): string[] {
const tokens: string[] = [];
let i = 0;
while (i < expr.length) {
const ch = expr[i]!;
if (ch === ' ' || ch === '\t') {
i += 1;
continue;
}
if ('+-*/()'.includes(ch)) {
tokens.push(ch);
i += 1;
continue;
}
if (/[0-9.]/.test(ch)) {
let num = ch;
i += 1;
while (i < expr.length && /[0-9.]/.test(expr[i]!)) {
num += expr[i];
i += 1;
}
tokens.push(num);
continue;
}
if (/[A-Za-z]/.test(ch)) {
let ref = ch;
i += 1;
while (i < expr.length && /[A-Za-z0-9]/.test(expr[i]!)) {
ref += expr[i];
i += 1;
}
tokens.push(ref);
continue;
}
throw new Error(`Unexpected character: ${ch}`);
}
return tokens;
}
function parseFactor(
tokens: string[],
pos: { i: number },
readCell: CellValueReader,
viewRow: number,
): number {
const token = tokens[pos.i];
if (token === undefined) throw new Error('Unexpected end');
if (token === '(') {
pos.i += 1;
const value = parseExpression(tokens, pos, readCell, viewRow);
if (tokens[pos.i] !== ')') throw new Error('Missing )');
pos.i += 1;
return value;
}
if (token === '+') {
pos.i += 1;
return parseFactor(tokens, pos, readCell, viewRow);
}
if (token === '-') {
pos.i += 1;
return -parseFactor(tokens, pos, readCell, viewRow);
}
if (/^[0-9]+(\.[0-9]+)?$/.test(token)) {
pos.i += 1;
return Number(token);
}
const match = /^([A-Za-z]+)(\d+)$/.exec(token);
if (match) {
pos.i += 1;
const col = colLettersToIndex(match[1]!);
return toNumber(readCell(viewRow, col));
}
throw new Error(`Invalid token: ${token}`);
}
function parseTerm(
tokens: string[],
pos: { i: number },
readCell: CellValueReader,
viewRow: number,
): number {
let value = parseFactor(tokens, pos, readCell, viewRow);
while (pos.i < tokens.length) {
const op = tokens[pos.i];
if (op === '*' || op === '/') {
pos.i += 1;
const rhs = parseFactor(tokens, pos, readCell, viewRow);
if (op === '/') {
if (rhs === 0) throw new DivZeroError();
value /= rhs;
} else {
value *= rhs;
}
} else {
break;
}
}
return value;
}
function parseExpression(
tokens: string[],
pos: { i: number },
readCell: CellValueReader,
viewRow: number,
): number {
let value = parseTerm(tokens, pos, readCell, viewRow);
while (pos.i < tokens.length) {
const op = tokens[pos.i];
if (op === '+' || op === '-') {
pos.i += 1;
const rhs = parseTerm(tokens, pos, readCell, viewRow);
value = op === '+' ? value + rhs : value - rhs;
} else {
break;
}
}
return value;
}
export function evaluateFormula(
formula: string,
viewRow: number,
readCell: CellValueReader,
): FormulaResult {
if (!formula?.trim()) return null;
let expr = formula.trim();
if (expr.startsWith('=')) expr = expr.slice(1);
if (!expr) return null;
try {
const tokens = tokenize(expr);
const pos = { i: 0 };
const result = parseExpression(tokens, pos, readCell, viewRow);
if (pos.i < tokens.length) throw new Error('Trailing tokens');
if (!Number.isFinite(result)) return null;
return result;
} catch (err) {
if (err instanceof DivZeroError) return 'DIV_ZERO';
return null;
}
}
export function formatFormulaResult(value: FormulaResult): string {
if (value === 'DIV_ZERO') return '-';
if (value == null || Number.isNaN(value)) return '#ERROR';
if (Number.isInteger(value)) return String(value);
return String(Math.round(value * 100) / 100);
}

View File

@@ -0,0 +1,297 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import {
Button,
Checkbox,
Divider,
Dropdown,
Input,
Menu,
MenuItem,
SubMenu,
} from 'ant-design-vue';
import MIcon from '#/components/icon/icon.vue';
import type { SortDirection } from '../types';
const props = defineProps<{
col: number;
title: string;
left: number;
width: number;
top: number;
active: boolean;
sortDirection?: SortDirection | null;
uniqueValues: string[];
checkedValues?: string[];
disabled?: boolean;
isDark?: boolean;
loadingValues?: boolean;
}>();
const emit = defineEmits<{
sort: [direction: SortDirection];
clearSort: [];
clearFilter: [];
open: [];
'update:checkedValues': [values: string[] | undefined];
}>();
const open = ref(false);
const search = ref('');
const effectiveChecked = computed(() => {
if (props.checkedValues === undefined) {
return new Set(props.uniqueValues);
}
return new Set(props.checkedValues);
});
const filteredValues = computed(() => {
const q = search.value.trim().toLowerCase();
const list = props.uniqueValues;
if (!q) return list.slice(0, 200);
return list.filter((v) => v.toLowerCase().includes(q)).slice(0, 200);
});
const allChecked = computed(
() =>
props.uniqueValues.length > 0 &&
props.uniqueValues.every((v) => effectiveChecked.value.has(v)),
);
const indeterminate = computed(() => {
if (allChecked.value) return false;
return props.uniqueValues.some((v) => effectiveChecked.value.has(v));
});
function toggleValue(value: string, checked: boolean) {
const next = new Set(effectiveChecked.value);
if (checked) next.add(value);
else next.delete(value);
emit('update:checkedValues', [...next]);
}
function toggleAll(checked: boolean) {
if (checked) {
emit('update:checkedValues', undefined);
} else {
emit('update:checkedValues', []);
}
}
watch(open, (v) => {
if (v) {
emit('open');
} else {
search.value = '';
}
});
</script>
<template>
<div
class="header-column-filter"
:class="{ 'is-dark': isDark }"
:style="{ left: `${left}px`, top: `${top}px`, width: `${width}px` }"
>
<Dropdown
v-model:open="open"
:trigger="['click']"
placement="bottomRight"
:disabled="disabled"
:overlay-class-name="isDark ? 'sheet-header-filter-dropdown is-dark' : 'sheet-header-filter-dropdown'"
>
<Button
class="filter-btn"
:class="{
'is-active': active,
'is-sorted': !!sortDirection,
}"
size="small"
type="text"
@click.stop
>
<MIcon
:icon="
sortDirection === 'asc'
? 'ant-design:sort-ascending-outlined'
: sortDirection === 'desc'
? 'ant-design:sort-descending-outlined'
: 'ant-design:filter-outlined'
"
/>
</Button>
<template #overlay>
<div class="filter-panel" :class="{ 'is-dark': isDark }" @click.stop>
<Menu mode="vertical">
<SubMenu key="sort" title="排序">
<MenuItem @click="emit('sort', 'asc')">升序</MenuItem>
<MenuItem @click="emit('sort', 'desc')">降序</MenuItem>
<MenuItem @click="emit('clearSort')">清除排序</MenuItem>
</SubMenu>
<MenuItem @click="emit('clearFilter')">清除筛选</MenuItem>
</Menu>
<Divider style="margin: 8px 0" />
<Input
v-model:value="search"
allow-clear
placeholder="搜索"
size="small"
/>
<div v-if="loadingValues" class="value-loading">加载中...</div>
<div v-else class="value-list">
<Checkbox
:checked="allChecked"
:indeterminate="indeterminate"
@change="(e) => toggleAll(!!e.target.checked)"
>
全选
</Checkbox>
<Checkbox
v-for="val in filteredValues"
:key="val || '__empty__'"
:checked="effectiveChecked.has(val)"
@change="(e) => toggleValue(val, !!e.target.checked)"
>
{{ val || '(空白)' }}
</Checkbox>
<div
v-if="uniqueValues.length > 200 && !search.trim()"
class="value-hint"
>
值过多请搜索缩小范围
</div>
</div>
</div>
</template>
</Dropdown>
</div>
</template>
<style scoped lang="scss">
.header-column-filter {
position: absolute;
height: 36px;
pointer-events: none;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 2px;
}
.filter-btn {
pointer-events: auto;
width: 24px;
height: 24px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: #86909c;
&.is-active,
&.is-sorted {
color: #1668dc;
background: rgba(22, 104, 220, 0.08);
}
}
.header-column-filter.is-dark .filter-btn {
color: rgba(255, 255, 255, 0.45);
&:hover {
color: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.08);
}
&.is-active,
&.is-sorted {
color: #3c89e8;
background: rgba(22, 104, 220, 0.25);
}
}
.filter-panel {
width: 240px;
padding: 8px;
background: #fff;
border-radius: 8px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
&.is-dark {
background: #1f1f1f;
border: 1px solid #424242;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45);
:deep(.ant-menu) {
background: transparent;
color: rgba(255, 255, 255, 0.85);
}
:deep(.ant-menu-item),
:deep(.ant-menu-submenu-title) {
color: rgba(255, 255, 255, 0.85);
&:hover {
background: #303030 !important;
color: rgba(255, 255, 255, 0.95);
}
}
:deep(.ant-divider) {
border-color: #424242;
}
:deep(.ant-input) {
background: #141414;
border-color: #424242;
color: rgba(255, 255, 255, 0.85);
&::placeholder {
color: rgba(255, 255, 255, 0.35);
}
}
:deep(.ant-input-clear-icon) {
color: rgba(255, 255, 255, 0.45);
}
:deep(.ant-checkbox-wrapper) {
color: rgba(255, 255, 255, 0.85);
}
.value-hint,
.value-loading {
color: rgba(255, 255, 255, 0.45);
}
}
}
.value-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 240px;
overflow: auto;
margin-top: 8px;
}
.value-hint,
.value-loading {
font-size: 12px;
color: #86909c;
padding: 4px 0;
}
</style>
<style lang="scss">
.sheet-header-filter-dropdown.is-dark {
.ant-dropdown-menu {
background: #1f1f1f;
box-shadow: none;
}
}
</style>

View File

@@ -0,0 +1,150 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { getDataColScreenX } from '../composables/useAutoMeasure';
import {
collectColumnUniqueValues,
isColumnFilterActive,
} from '../composables/filterSort';
import type {
SortDirection,
SpreadsheetColumnConfig,
SpreadsheetHeaderConfig,
SpreadsheetViewState,
} from '../types';
import { COLUMN_LETTER_HEIGHT, HEADER_TITLE_HEIGHT } from '../types';
import HeaderColumnFilter from './HeaderColumnFilter.vue';
const props = defineProps<{
headers: SpreadsheetHeaderConfig[];
columns: SpreadsheetColumnConfig[];
rows: Record<string, unknown>[];
colWidths: number[];
scrollLeft: number;
fixedCols: number[];
fixedWidth: number;
viewportWidth: number;
viewState: SpreadsheetViewState;
enableFilter?: boolean;
isDark?: boolean;
resolveDisplayValue: (
viewRow: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
) => unknown;
}>();
const emit = defineEmits<{
sort: [col: number, direction: SortDirection];
clearSort: [];
clearFilter: [col: number];
'update:columnFilter': [col: number, checkedValues: string[] | undefined];
}>();
const uniqueCache = ref<Record<number, string[]>>({});
const loadingCols = ref<Set<number>>(new Set());
const rowsVersion = ref(0);
const filterableHeaders = computed(() =>
props.headers.filter((h) => h.filterable !== false),
);
function invalidateCache() {
uniqueCache.value = {};
rowsVersion.value += 1;
}
watch(
() => props.rows.length,
invalidateCache,
);
function ensureUniqueValues(col: number) {
if (uniqueCache.value[col]?.length !== undefined) return;
loadingCols.value = new Set([...loadingCols.value, col]);
uniqueCache.value[col] = collectColumnUniqueValues(
props.rows,
col,
props.columns,
props.resolveDisplayValue,
);
const next = new Set(loadingCols.value);
next.delete(col);
loadingCols.value = next;
}
function onFilterOpen(col: number) {
ensureUniqueValues(col);
}
function getUniqueValues(col: number): string[] {
return uniqueCache.value[col] ?? [];
}
function isLoadingCol(col: number): boolean {
return loadingCols.value.has(col);
}
function headerLeft(col: number): number {
return getDataColScreenX(
col,
props.colWidths,
props.scrollLeft,
props.fixedCols,
props.fixedWidth,
);
}
function isFilterActive(col: number): boolean {
return isColumnFilterActive(props.viewState.filters[col]);
}
function sortDirection(col: number): SortDirection | null {
if (props.viewState.sort?.col === col) {
return props.viewState.sort.direction;
}
return null;
}
</script>
<template>
<div
v-if="enableFilter !== false"
class="header-filter-layer"
:style="{ width: `${viewportWidth}px`, height: `${COLUMN_LETTER_HEIGHT + HEADER_TITLE_HEIGHT}px` }"
>
<HeaderColumnFilter
v-for="header in filterableHeaders"
:key="header.col"
:active="isFilterActive(header.col)"
:checked-values="viewState.filters[header.col]?.checkedValues"
:col="header.col"
:is-dark="isDark"
:left="headerLeft(header.col)"
:loading-values="isLoadingCol(header.col)"
:sort-direction="sortDirection(header.col)"
:title="header.title"
:top="COLUMN_LETTER_HEIGHT"
:unique-values="getUniqueValues(header.col)"
:width="colWidths[header.col] ?? 100"
@clear-filter="emit('clearFilter', header.col)"
@clear-sort="emit('clearSort')"
@open="onFilterOpen(header.col)"
@sort="(dir) => emit('sort', header.col, dir)"
@update:checked-values="
(v) => emit('update:columnFilter', header.col, v)
"
/>
</div>
</template>
<style scoped lang="scss">
.header-filter-layer {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
z-index: 3;
}
</style>

View File

@@ -0,0 +1,117 @@
<script lang="ts" setup>
import { onUnmounted, ref, watch } from 'vue';
import ColorPicker from '#/components/form/components/color-picker.vue';
const props = defineProps<{
open: boolean;
x: number;
y: number;
col: number;
color: string;
}>();
const emit = defineEmits<{
'update:color': [value: string];
apply: [col: number, color: string | undefined];
close: [];
}>();
const draftColor = ref(props.color);
watch(
() => props.color,
(v) => {
draftColor.value = v;
},
);
watch(
() => props.open,
(open) => {
if (open) {
draftColor.value = props.color;
document.addEventListener('keydown', onKeyDown);
document.addEventListener('pointerdown', onPointerDown, true);
} else {
removeListeners();
}
},
);
function removeListeners() {
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('pointerdown', onPointerDown, true);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
emit('close');
}
}
function onPointerDown(e: PointerEvent) {
const target = e.target as Node | null;
const panel = panelRef.value;
if (panel && target && !panel.contains(target)) {
emit('close');
}
}
const panelRef = ref<HTMLElement | null>(null);
function onColorUpdate(value: string) {
draftColor.value = value;
emit('update:color', value);
emit('apply', props.col, value || undefined);
}
onUnmounted(removeListeners);
</script>
<template>
<Teleport to="body">
<div
v-if="open"
ref="panelRef"
class="header-color-picker-popover"
:style="{ left: `${x}px`, top: `${y}px` }"
@contextmenu.prevent
>
<div class="popover-title">表头颜色</div>
<ColorPicker
:value="draftColor"
placeholder="选择颜色"
@update:value="onColorUpdate"
/>
</div>
</Teleport>
</template>
<style scoped lang="scss">
.header-color-picker-popover {
position: fixed;
z-index: 10000;
padding: 12px;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 8px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
.popover-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
color: #1d2129;
}
.is-dark .header-color-picker-popover {
background: #1f1f1f;
border-color: #424242;
.popover-title {
color: rgba(255, 255, 255, 0.85);
}
}
</style>

View File

@@ -0,0 +1,41 @@
import type { SpreadsheetLayoutState } from '../types';
function storageKey(tableName: string, sheetKey?: string) {
return `xk_spreadsheet_layout_${tableName}_${sheetKey ?? 'default'}`;
}
export function readSpreadsheetLayout(
tableName: string,
sheetKey?: string,
): SpreadsheetLayoutState | null {
try {
const raw = localStorage.getItem(storageKey(tableName, sheetKey));
if (!raw) return null;
return JSON.parse(raw) as SpreadsheetLayoutState;
} catch {
return null;
}
}
export function writeSpreadsheetLayout(
tableName: string,
sheetKey: string | undefined,
state: SpreadsheetLayoutState,
) {
localStorage.setItem(storageKey(tableName, sheetKey), JSON.stringify(state));
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
export function debouncedWriteSpreadsheetLayout(
tableName: string,
sheetKey: string | undefined,
state: SpreadsheetLayoutState,
delayMs = 400,
) {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
writeSpreadsheetLayout(tableName, sheetKey, state);
saveTimer = null;
}, delayMs);
}

View File

@@ -0,0 +1,94 @@
import type { SpreadsheetHistoryEntry } from '../types';
const DB_NAME = 'xk_spreadsheet_history';
const STORE_NAME = 'entries';
const DB_VERSION = 1;
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve(req.result);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
store.createIndex('tableName', 'tableName', { unique: false });
store.createIndex('tableSheet', ['tableName', 'sheetKey'], {
unique: false,
});
store.createIndex('createdAt', 'createdAt', { unique: false });
store.createIndex('synced', 'synced', { unique: false });
}
};
});
}
export async function appendLocalHistory(entry: SpreadsheetHistoryEntry) {
const db = await openDb();
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(entry);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function listLocalHistory(
tableName: string,
sheetKey?: string,
limit = 100,
): Promise<SpreadsheetHistoryEntry[]> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const index = store.index('tableName');
const req = index.getAll(tableName);
req.onsuccess = () => {
let items = (req.result as SpreadsheetHistoryEntry[]) ?? [];
if (sheetKey) {
items = items.filter((i) => i.sheetKey === sheetKey);
}
items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
resolve(items.slice(0, limit));
};
req.onerror = () => reject(req.error);
});
}
export async function listUnsyncedHistory(): Promise<SpreadsheetHistoryEntry[]> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const req = tx.objectStore(STORE_NAME).getAll();
req.onsuccess = () => {
const items = ((req.result as SpreadsheetHistoryEntry[]) ?? []).filter(
(i) => !i.synced,
);
resolve(items);
};
req.onerror = () => reject(req.error);
});
}
export async function markHistorySynced(ids: string[]) {
if (!ids.length) return;
const db = await openDb();
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
for (const id of ids) {
const getReq = store.get(id);
getReq.onsuccess = () => {
const entry = getReq.result as SpreadsheetHistoryEntry | undefined;
if (entry) {
entry.synced = true;
store.put(entry);
}
};
}
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}

View File

@@ -0,0 +1,54 @@
import { requestClient } from '#/api/request';
import type { SpreadsheetHistoryEntry } from '../types';
const prefix = 'spreadsheet-history/';
export interface RemoteHistoryItem {
id: number;
table_name: string;
sheet_key?: string;
admin_user_id: number;
admin_user_name: string;
row_key: string;
field_id: string;
action: string;
before_snapshot: unknown;
after_snapshot: unknown;
client_entry_id?: string;
created_at: string;
}
export async function saveHistoryBatch(
entries: SpreadsheetHistoryEntry[],
) {
return requestClient.post<{ saved_ids: string[] }>(`${prefix}save-batch`, {
entries: entries.map((e) => ({
client_entry_id: e.id,
table_name: e.tableName,
sheet_key: e.sheetKey,
row_key: String(e.rowKey),
field_id: e.fieldId,
action: e.action,
before_snapshot: e.beforeSnapshot,
after_snapshot: e.afterSnapshot,
created_at: e.createdAt,
})),
});
}
export async function listRemoteHistory(params: {
table_name: string;
sheet_key?: string;
page?: number;
pageSize?: number;
}) {
return requestClient.get<{
items: RemoteHistoryItem[];
total: number;
}>(`${prefix}list`, { params });
}
export async function rollbackRemoteHistory(id: number) {
return requestClient.post<{ success: boolean }>(`${prefix}rollback`, { id });
}

View File

@@ -0,0 +1,4 @@
export { default as CanvasSpreadsheet } from './CanvasSpreadsheet.vue';
export * from './types';
export { resolveColumnTextAlign } from './composables/columnTextAlign';
export { colIndexToLetters, colLettersToIndex } from './formula/evaluator';

View File

@@ -0,0 +1,161 @@
<script lang="ts" setup>
import { nextTick, ref, watch } from 'vue';
import { Button, Input } from 'ant-design-vue';
import MIcon from '#/components/icon/icon.vue';
const props = defineProps<{
open: boolean;
query: string;
matchCount: number;
activeIndex: number;
isDark?: boolean;
}>();
const emit = defineEmits<{
'update:query': [value: string];
close: [];
next: [];
prev: [];
}>();
const inputRef = ref<InstanceType<typeof Input> | null>(null);
watch(
() => props.open,
(v) => {
if (v) {
void nextTick(() => {
const el = inputRef.value?.$el?.querySelector?.('input') as
| HTMLInputElement
| undefined;
el?.focus();
el?.select();
});
}
},
);
function onInput(v: string) {
emit('update:query', v);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
emit('close');
return;
}
if (e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) emit('prev');
else emit('next');
}
}
const countLabel = () => {
if (!props.query.trim()) return '';
if (props.matchCount === 0) return '无匹配';
const current = props.activeIndex >= 0 ? props.activeIndex + 1 : 0;
return `${current} / ${props.matchCount}`;
};
</script>
<template>
<div
v-if="open"
class="spreadsheet-find-bar"
:class="{ 'is-dark': isDark }"
@mousedown.stop
>
<Input
ref="inputRef"
class="find-input"
:value="query"
placeholder="查找"
size="small"
allow-clear
@update:value="onInput"
@keydown="onKeydown"
/>
<span class="find-count">{{ countLabel() }}</span>
<Button
class="find-nav-btn"
size="small"
type="text"
title="上一个 (Shift+Enter)"
:disabled="matchCount === 0"
@click="emit('prev')"
>
<MIcon icon="mdi:chevron-up" />
</Button>
<Button
class="find-nav-btn"
size="small"
type="text"
title="下一个 (Enter)"
:disabled="matchCount === 0"
@click="emit('next')"
>
<MIcon icon="mdi:chevron-down" />
</Button>
<Button
class="find-close-btn"
size="small"
type="text"
title="关闭 (Esc)"
@click="emit('close')"
>
<MIcon icon="mdi:close" />
</Button>
</div>
</template>
<style scoped lang="scss">
.spreadsheet-find-bar {
position: absolute;
top: 8px;
right: 8px;
z-index: 20;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 6px;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
&.is-dark {
background: #1f1f1f;
border-color: #424242;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}
}
.find-input {
width: 180px;
}
.find-count {
min-width: 52px;
font-size: 12px;
color: #86909c;
text-align: center;
white-space: nowrap;
.is-dark & {
color: rgba(255, 255, 255, 0.45);
}
}
.find-nav-btn,
.find-close-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
</style>

View File

@@ -0,0 +1,292 @@
<script lang="ts" setup>
import { computed, nextTick, ref, watch } from 'vue';
import { Button, Input, Popover } from 'ant-design-vue';
import type { SpreadsheetFormulaItem } from '#/api/spreadsheet-formula';
const props = withDefaults(
defineProps<{
cellAddress: string;
sourceValue: string;
editable: boolean;
formulas: SpreadsheetFormulaItem[];
isDark?: boolean;
referenceMode?: boolean;
caretPosition?: number;
}>(),
{
isDark: false,
referenceMode: false,
caretPosition: 0,
},
);
const emit = defineEmits<{
commit: [value: string];
'draft-change': [value: string];
'caret-change': [position: number];
}>();
const draft = ref('');
const fxOpen = ref(false);
const focused = ref(false);
const inputRef = ref<{ input: HTMLInputElement } | null>(null);
function syncDraftFromProps() {
if (focused.value) return;
draft.value = props.sourceValue;
}
watch(
() => props.cellAddress,
() => syncDraftFromProps(),
{ immediate: true },
);
watch(
() => props.sourceValue,
() => syncDraftFromProps(),
);
watch(
() => props.caretPosition,
async (pos) => {
if (!focused.value || pos == null) return;
await nextTick();
const el = inputRef.value?.input;
if (el) {
el.setSelectionRange(pos, pos);
}
},
);
function syncCaret() {
const el = inputRef.value?.input;
if (!el) return;
emit('caret-change', el.selectionStart ?? draft.value.length);
}
function onFocus() {
focused.value = true;
syncCaret();
}
function onBlur() {
focused.value = false;
if (!props.editable || props.referenceMode) return;
emit('commit', draft.value);
}
function onEnter() {
focused.value = false;
if (!props.editable) return;
emit('commit', draft.value);
}
function onInput() {
emit('draft-change', draft.value);
syncCaret();
}
function insertFormula(item: SpreadsheetFormulaItem) {
draft.value = item.expression_template.replace(/REF(\d+)/g, (_, n) => {
return `${String.fromCharCode(65 + Number(n))}1`;
});
emit('draft-change', draft.value);
fxOpen.value = false;
}
function insertAtCaret(text: string) {
const pos = props.caretPosition ?? draft.value.length;
draft.value = draft.value.slice(0, pos) + text + draft.value.slice(pos);
const nextCaret = pos + text.length;
emit('draft-change', draft.value);
emit('caret-change', nextCaret);
void nextTick(() => {
const el = inputRef.value?.input;
if (el) {
el.focus();
el.setSelectionRange(nextCaret, nextCaret);
}
});
}
function focusInput() {
inputRef.value?.input?.focus();
}
const formulaList = computed(() => props.formulas);
defineExpose({
insertAtCaret,
focusInput,
getDraft: () => draft.value,
setDraft: (v: string) => {
draft.value = v;
emit('draft-change', v);
},
});
</script>
<template>
<div
class="spreadsheet-formula-bar"
:class="{ 'is-dark': isDark }"
>
<div class="cell-address">{{ cellAddress || ' ' }}</div>
<Popover
v-model:open="fxOpen"
placement="bottomLeft"
trigger="click"
:overlay-class-name="
isDark ? 'sheet-fx-popover is-dark' : 'sheet-fx-popover'
"
>
<template #content>
<div class="fx-list" :class="{ 'is-dark': isDark }">
<div
v-for="item in formulaList"
:key="item.id"
class="fx-item"
@click="insertFormula(item)"
>
<strong>{{ item.name }}</strong>
<span>{{ item.expression_template }}</span>
<small v-if="item.description">{{ item.description }}</small>
</div>
<div v-if="!formulaList.length" class="fx-empty">暂无公式</div>
</div>
</template>
<Button class="fx-btn" size="small" type="text">fx</Button>
</Popover>
<Input
ref="inputRef"
v-model:value="draft"
class="formula-input"
:readonly="!editable"
@blur="onBlur"
@click="syncCaret"
@focus="onFocus"
@input="onInput"
@keydown.enter.prevent="onEnter"
@keyup="syncCaret"
/>
</div>
</template>
<style scoped lang="scss">
.spreadsheet-formula-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-top: 1px solid #e5e6eb;
background: #fafafa;
&.is-dark {
background: #1f1f1f;
border-top-color: #424242;
.cell-address {
background: #141414;
border-color: #424242;
color: rgba(255, 255, 255, 0.85);
}
.fx-btn {
color: #3c89e8;
}
}
}
.cell-address {
min-width: 48px;
height: 28px;
line-height: 28px;
padding: 0 8px;
text-align: center;
font-size: 12px;
font-weight: 600;
border: 1px solid #e5e6eb;
border-radius: 4px;
background: #fff;
}
.fx-btn {
font-style: italic;
font-weight: 600;
color: #1668dc;
}
.formula-input {
flex: 1;
}
.fx-list {
width: 320px;
max-height: 280px;
overflow: auto;
}
.fx-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px;
cursor: pointer;
border-radius: 4px;
&:hover {
background: #f2f3f5;
}
span {
font-size: 12px;
color: #4e5969;
font-family: monospace;
}
small {
font-size: 11px;
color: #86909c;
}
}
.fx-list.is-dark {
.fx-item {
color: rgba(255, 255, 255, 0.85);
&:hover {
background: #303030;
}
span {
color: rgba(255, 255, 255, 0.65);
}
small {
color: rgba(255, 255, 255, 0.45);
}
}
.fx-empty {
color: rgba(255, 255, 255, 0.45);
}
}
.fx-empty {
padding: 12px;
color: #86909c;
text-align: center;
}
</style>
<style lang="scss">
.sheet-fx-popover.is-dark {
.ant-popover-inner {
background: #1f1f1f;
border: 1px solid #424242;
}
}
</style>

View File

@@ -0,0 +1,194 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
Button,
Input,
message,
Popconfirm,
Space,
Table,
} from 'ant-design-vue';
import {
createSpreadsheetFormula,
deleteSpreadsheetFormula,
listSpreadsheetFormula,
type SpreadsheetFormulaItem,
updateSpreadsheetFormula,
} from '#/api/spreadsheet-formula';
const emit = defineEmits<{
updated: [];
}>();
const items = ref<SpreadsheetFormulaItem[]>([]);
const loading = ref(false);
const editingId = ref<number | null>(null);
const formCode = ref('');
const formName = ref('');
const formDesc = ref('');
const formExpression = ref('');
const formRefSlots = ref('[{"slot":"REF0","label":"参数1"}]');
const [Modal, modalApi] = useVbenModal({
title: '全局公式库',
class: 'w-[720px]',
onOpenChange(isOpen) {
if (isOpen) void loadList();
},
});
async function loadList() {
loading.value = true;
try {
items.value = await listSpreadsheetFormula();
} finally {
loading.value = false;
}
}
function resetForm() {
editingId.value = null;
formCode.value = '';
formName.value = '';
formDesc.value = '';
formExpression.value = '';
formRefSlots.value = '[{"slot":"REF0","label":"参数1"}]';
}
function startEdit(item: SpreadsheetFormulaItem) {
editingId.value = item.id;
formCode.value = item.code;
formName.value = item.name;
formDesc.value = item.description;
formExpression.value = item.expression_template;
formRefSlots.value = JSON.stringify(item.ref_slots, null, 2);
}
async function saveForm() {
let refSlots: { slot: string; label: string }[];
try {
refSlots = JSON.parse(formRefSlots.value);
if (!Array.isArray(refSlots)) throw new Error('invalid');
} catch {
message.error('ref_slots 必须是 JSON 数组');
return;
}
try {
if (editingId.value) {
await updateSpreadsheetFormula({
id: editingId.value,
code: formCode.value,
name: formName.value,
description: formDesc.value,
expression_template: formExpression.value,
ref_slots: refSlots,
});
message.success('已更新');
} else {
await createSpreadsheetFormula({
code: formCode.value,
name: formName.value,
description: formDesc.value,
expression_template: formExpression.value,
ref_slots: refSlots,
});
message.success('已创建');
}
resetForm();
await loadList();
emit('updated');
} catch (err) {
message.error(String(err));
}
}
async function removeItem(id: number) {
await deleteSpreadsheetFormula(id);
message.success('已删除');
await loadList();
emit('updated');
}
const columns = computed(() => [
{ title: '编码', dataIndex: 'code', width: 120 },
{ title: '名称', dataIndex: 'name', width: 120 },
{ title: '表达式', dataIndex: 'expression_template' },
{
title: '操作',
key: 'action',
width: 120,
},
]);
defineExpose({
open: () => modalApi.open(),
});
</script>
<template>
<Modal>
<div class="formula-library">
<Table
:columns="columns"
:data-source="items"
:loading="loading"
:pagination="false"
row-key="id"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<Space>
<Button size="small" type="link" @click="startEdit(record)">
编辑
</Button>
<Popconfirm title="确认删除?" @confirm="removeItem(record.id)">
<Button danger size="small" type="link">删除</Button>
</Popconfirm>
</Space>
</template>
</template>
</Table>
<div class="formula-form">
<Space direction="vertical" style="width: 100%">
<Input v-model:value="formCode" placeholder="编码 code" />
<Input v-model:value="formName" placeholder="显示名称" />
<Input v-model:value="formDesc" placeholder="说明" />
<Input
v-model:value="formExpression"
placeholder="表达式模板,如 =(REF0-REF1)/REF0"
/>
<Input.TextArea
v-model:value="formRefSlots"
:rows="4"
placeholder='ref_slots JSON如 [{"slot":"REF0","label":"被减数"}]'
/>
<Space>
<Button type="primary" @click="saveForm">
{{ editingId ? '保存' : '新建' }}
</Button>
<Button @click="resetForm">清空</Button>
</Space>
</Space>
</div>
</div>
</Modal>
</template>
<style scoped lang="scss">
.formula-library {
display: flex;
flex-direction: column;
gap: 16px;
}
.formula-form {
padding-top: 12px;
border-top: 1px solid #e5e6eb;
}
</style>

View File

@@ -0,0 +1,304 @@
<script lang="ts" setup>
import { ref } from 'vue';
import {
Button,
Drawer,
Empty,
Space,
Tag,
Tooltip,
} from 'ant-design-vue';
import MIcon from '#/components/icon/icon.vue';
import { listLocalHistory } from '../history/localHistoryStore';
import { listRemoteHistory } from '../history/remoteHistoryApi';
import type { SpreadsheetHistoryEntry, SpreadsheetTableConfig } from '../types';
import SpreadsheetFormulaLibraryModal from './SpreadsheetFormulaLibraryModal.vue';
const props = defineProps<{
tableConfig: SpreadsheetTableConfig;
canUndo: boolean;
canRedo: boolean;
isDark?: boolean;
refreshing?: boolean;
showConfigButton?: boolean;
showFormulaLibrary?: boolean;
workbookName?: string;
}>();
const emit = defineEmits<{
undo: [];
redo: [];
refresh: [];
openConfig: [];
rollback: [entry: SpreadsheetHistoryEntry];
formulaLibraryUpdated: [];
}>();
const historyOpen = ref(false);
const historyItems = ref<SpreadsheetHistoryEntry[]>([]);
const loadingHistory = ref(false);
const formulaLibraryRef = ref<InstanceType<
typeof SpreadsheetFormulaLibraryModal
> | null>(null);
let historyLoadToken = 0;
function invalidateHistoryLoad() {
historyLoadToken += 1;
loadingHistory.value = false;
}
async function openHistory() {
historyOpen.value = true;
const token = ++historyLoadToken;
loadingHistory.value = true;
try {
const local = await listLocalHistory(
props.tableConfig.tableName,
props.tableConfig.sheetKey,
);
if (token !== historyLoadToken) return;
try {
const remote = await listRemoteHistory({
table_name: props.tableConfig.tableName,
sheet_key: props.tableConfig.sheetKey,
pageSize: 50,
});
if (token !== historyLoadToken) return;
const merged = [...local];
for (const r of remote.items ?? []) {
if (!merged.some((m) => m.id === r.client_entry_id)) {
merged.push({
id: r.client_entry_id ?? String(r.id),
tableName: r.table_name,
sheetKey: r.sheet_key,
userId: r.admin_user_id,
userName: r.admin_user_name,
action: r.action as SpreadsheetHistoryEntry['action'],
rowKey: r.row_key,
fieldId: r.field_id,
beforeSnapshot: r.before_snapshot,
afterSnapshot: r.after_snapshot,
createdAt: r.created_at,
synced: true,
});
}
}
merged.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
historyItems.value = merged.slice(0, 100);
} catch {
if (token !== historyLoadToken) return;
historyItems.value = local;
}
} finally {
if (token === historyLoadToken) {
loadingHistory.value = false;
}
}
}
async function handleRollback(entry: SpreadsheetHistoryEntry) {
emit('rollback', entry);
historyOpen.value = false;
}
function openFormulaLibrary() {
formulaLibraryRef.value?.open();
}
</script>
<template>
<div class="spreadsheet-toolbar" :class="{ 'is-dark': isDark }">
<div class="toolbar-row toolbar-main">
<Tag v-if="workbookName" class="workbook-tag" color="processing">
{{ workbookName }}
</Tag>
<span v-else class="workbook-tag plain">{{ tableConfig.tableName }}</span>
<span class="toolbar-divider" />
<Space :size="4">
<Tooltip title="撤回 (Ctrl+Z)">
<Button size="small" :disabled="!canUndo" @click="emit('undo')">
<MIcon icon="ant-design:undo-outlined" />
</Button>
</Tooltip>
<Tooltip title="重做 (Ctrl+Y)">
<Button size="small" :disabled="!canRedo" @click="emit('redo')">
<MIcon icon="ant-design:redo-outlined" />
</Button>
</Tooltip>
<Tooltip title="刷新">
<Button
size="small"
:disabled="refreshing"
:loading="refreshing"
@click="emit('refresh')"
>
<MIcon icon="ant-design:reload-outlined" />
</Button>
</Tooltip>
<Button
v-if="tableConfig.enableHistory !== false"
size="small"
@click="openHistory"
>
<MIcon icon="ant-design:history-outlined" />
历史
</Button>
</Space>
<span class="toolbar-divider" />
<Space :size="4">
<Button
v-if="showConfigButton"
size="small"
@click="emit('openConfig')"
>
<MIcon icon="mdi:table-cog" />
Excel 配置
</Button>
<Button
v-if="showFormulaLibrary"
size="small"
@click="openFormulaLibrary"
>
<MIcon icon="mdi:function-variant" />
公式库
</Button>
</Space>
</div>
<slot name="formula-bar" />
<SpreadsheetFormulaLibraryModal
ref="formulaLibraryRef"
@updated="emit('formulaLibraryUpdated')"
/>
<Drawer
v-model:open="historyOpen"
:title="`${workbookName || tableConfig.tableName} - 编辑历史`"
width="480"
@after-open-change="(open: boolean) => { if (!open) invalidateHistoryLoad(); }"
>
<div v-if="loadingHistory" class="history-loading">加载中...</div>
<Empty v-else-if="!historyItems.length" description="暂无历史记录" />
<div v-else class="history-list">
<div v-for="item in historyItems" :key="item.id" class="history-item">
<div class="history-meta">
<strong>{{ item.userName }}</strong>
<span>{{ item.createdAt }}</span>
<Tag size="small">{{ item.action }}</Tag>
</div>
<div class="history-diff">
行 {{ item.rowKey }} · {{ item.fieldId }}
<br />
{{ item.beforeSnapshot }} → {{ item.afterSnapshot }}
</div>
<Button size="small" type="link" @click="handleRollback(item)">
回滚到此版本
</Button>
</div>
</div>
</Drawer>
</div>
</template>
<style scoped lang="scss">
.spreadsheet-toolbar {
display: flex;
flex-direction: column;
background: #fff;
}
.toolbar-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 8px 10px;
}
.toolbar-main {
border-bottom: 1px solid #e5e6eb;
}
.workbook-tag {
font-weight: 600;
margin: 0;
&.plain {
font-size: 13px;
color: #1d2129;
}
}
.toolbar-divider {
width: 1px;
height: 20px;
background: #e5e6eb;
margin: 0 4px;
}
.history-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.history-item {
padding: 12px;
border: 1px solid #e5e6eb;
border-radius: 8px;
}
.history-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 12px;
color: #86909c;
}
.history-diff {
font-size: 13px;
margin-bottom: 4px;
}
.history-loading {
text-align: center;
padding: 24px;
color: #86909c;
}
.spreadsheet-toolbar.is-dark {
background: #141414;
.toolbar-main {
border-bottom-color: #424242;
}
.workbook-tag.plain {
color: rgba(255, 255, 255, 0.85);
}
.toolbar-divider {
background: #424242;
}
.history-item {
border-color: #424242;
}
.history-meta,
.history-loading {
color: rgba(255, 255, 255, 0.45);
}
}
</style>

View File

@@ -0,0 +1,206 @@
import type { ContextMenuItem } from '#/components/context-menu';
export type CellType =
| 'text'
| 'number'
| 'image'
| 'video'
| 'audio'
| 'file'
| 'readonly'
| 'formula';
export interface CellRange {
startRow: number;
startCol: number;
endRow: number;
endCol: number;
}
export interface CellEditContext {
fieldId: string;
fieldValue: unknown;
rowIndex: number;
colIndex: number;
rowKey: string | number;
rowData: Record<string, unknown>;
editParams: Record<string, unknown>;
selection?: CellRange;
}
export interface HeaderContextMenuContext {
colIndex: number;
fieldId?: string;
headerTitle: string;
filterValue?: string;
}
export type ConditionalColorMode = 'background' | 'text' | 'both';
export type SpreadsheetTextAlign = 'left' | 'center' | 'right';
/** undefined | 'inherit' 均表示跟随全局 defaultTextAlign */
export type ColumnTextAlign = SpreadsheetTextAlign | 'inherit';
export interface ConditionalFormatRule {
threshold: number;
bgColor?: string;
textColor?: string;
colorMode?: ConditionalColorMode;
}
export interface ColumnConditionalFormat {
gt?: ConditionalFormatRule;
lt?: ConditionalFormatRule;
}
export interface SpreadsheetColumnConfig {
fieldId: string;
fieldName: string;
cellType: CellType;
col: number;
editable?: boolean;
formula?: string;
suffix?: string;
textAlign?: ColumnTextAlign;
conditionalFormat?: ColumnConditionalFormat;
onEdit?: (ctx: CellEditContext) => void | Promise<void>;
onDoubleClick?: (ctx: CellEditContext) => void;
editParams?: Record<string, unknown>;
contextMenu?:
| ContextMenuItem[]
| ((ctx: CellEditContext) => ContextMenuItem[]);
}
export interface SpreadsheetHeaderConfig {
title: string;
col: number;
fieldId?: string;
filterable?: boolean;
width?: number | 'auto';
headerColor?: string;
fixed?: boolean;
contextMenu?:
| ContextMenuItem[]
| ((ctx: HeaderContextMenuContext) => ContextMenuItem[]);
}
export interface SpreadsheetLayoutState {
colWidths?: Record<number, number>;
headers?: Array<{
col: number;
headerColor?: string;
fixed?: boolean;
}>;
}
export interface SpreadsheetBatchCellChange {
ctx: CellEditContext;
before: unknown;
after: unknown;
col: number;
viewRow: number;
}
export interface SpreadsheetTableConfig {
tableName: string;
sheetKey?: string;
headers: SpreadsheetHeaderConfig[];
enableFilter?: boolean;
enableHistory?: boolean;
rowHeight?: number | 'auto';
defaultColWidth?: number;
defaultTextAlign?: SpreadsheetTextAlign;
onBatchEdit?: (changes: SpreadsheetBatchCellChange[]) => Promise<void>;
onRefresh?: () => void | Promise<void>;
headerContextMenu?:
| ContextMenuItem[]
| ((ctx: HeaderContextMenuContext) => ContextMenuItem[]);
}
export type SortDirection = 'asc' | 'desc';
export interface SpreadsheetColumnSort {
col: number;
direction: SortDirection;
}
/** checkedValues === undefined means all selected (no filter) */
export interface SpreadsheetColumnFilter {
checkedValues?: string[];
}
export interface SpreadsheetViewState {
filters: Record<number, SpreadsheetColumnFilter>;
sort: SpreadsheetColumnSort | null;
}
export function createDefaultViewState(): SpreadsheetViewState {
return { filters: {}, sort: null };
}
export interface SpreadsheetHistoryEntry {
id: string;
tableName: string;
sheetKey?: string;
userId: number;
userName: string;
action: 'edit' | 'fill' | 'paste' | 'rollback';
rowKey: string | number;
fieldId: string;
beforeSnapshot: unknown;
afterSnapshot: unknown;
createdAt: string;
synced: boolean;
}
export interface SpreadsheetCommand {
label: string;
undo: () => void | Promise<void>;
redo: () => void | Promise<void>;
}
export interface ClipboardMatrix {
rows: unknown[][];
startRow: number;
startCol: number;
}
export interface SpreadsheetEngineExpose {
copy: (selectionOverride?: CellRange | CellRange[]) => void;
copyAsImage: (selectionOverride?: CellRange | CellRange[]) => Promise<void>;
paste: () => Promise<void>;
undo: () => Promise<void>;
redo: () => Promise<void>;
canUndo: () => boolean;
canRedo: () => boolean;
canCopy: () => boolean;
canCopyAsImage: () => boolean;
canPaste: () => boolean;
canEdit: (row: number, col: number) => boolean;
getSelection: () => CellRange[];
getCellEditContext: (row: number, col: number) => CellEditContext | null;
getHeaderContext: (col: number) => HeaderContextMenuContext;
setCellValue: (
row: number,
col: number,
value: unknown,
recordHistory?: boolean,
action?: SpreadsheetHistoryEntry['action'],
) => Promise<void>;
fillRange: (
source: CellRange,
target: CellRange,
) => Promise<void>;
getColumnConfig: (col: number) => SpreadsheetColumnConfig | undefined;
getFilterValue: (col: number) => string;
}
export const COLUMN_LETTER_HEIGHT = 22;
export const HEADER_TITLE_HEIGHT = 36;
export const HEADER_HEIGHT = COLUMN_LETTER_HEIGHT + HEADER_TITLE_HEIGHT;
export const ROW_NUMBER_WIDTH = 48;
export const DEFAULT_ROW_HEIGHT = 32;
export const DEFAULT_COL_WIDTH = 100;
export const MIN_COL_WIDTH = 60;
export const SCROLL_BUFFER_ROWS = 8;

View File

@@ -0,0 +1,189 @@
<script lang="ts" setup>
import { nextTick } from 'vue';
import type { ContextMenuItem } from './types';
import MIcon from '#/components/icon/icon.vue';
import {
contextMenuState,
getSubmenuPosition,
hideContextMenu,
MENU_WIDTH,
showSubmenu,
submenuState,
} from './use-context-menu';
defineOptions({ name: 'GlobalContextMenu' });
function onItemEnter(item: ContextMenuItem, e: MouseEvent) {
if (item.type === 'divider') {
showSubmenu('', [], 0, 0);
return;
}
if (!item.children?.length || item.disabled) {
showSubmenu('', [], 0, 0);
return;
}
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const pos = getSubmenuPosition(rect, item.children.length);
showSubmenu(item.key, item.children, pos.left, pos.top);
}
function handleClick(item: ContextMenuItem) {
if (item.disabled || item.children?.length) {
return;
}
const payload = contextMenuState.payload;
hideContextMenu();
void nextTick(() => {
item.handler?.(payload);
});
}
function handleSubmenuClick(item: ContextMenuItem) {
if (item.disabled) {
return;
}
const payload = contextMenuState.payload;
hideContextMenu();
void nextTick(() => {
item.handler?.(payload);
});
}
</script>
<template>
<Teleport to="body">
<div
v-if="contextMenuState.visible"
class="global-context-menu"
:style="{ left: `${contextMenuState.x}px`, top: `${contextMenuState.y}px`, width: `${MENU_WIDTH}px` }"
@contextmenu.prevent
>
<template v-for="item in contextMenuState.items" :key="item.key">
<div v-if="item.type === 'divider'" class="context-menu-divider" />
<div
v-else
class="context-menu-item"
:class="{ disabled: item.disabled, 'has-children': !!item.children?.length }"
@click.stop="handleClick(item)"
@mouseenter="onItemEnter(item, $event)"
>
<span class="item-icon">
<component :is="item.icon" v-if="item.icon" />
<MIcon v-else-if="item.iconName" :icon="item.iconName" size="14" />
</span>
<span class="item-label">{{ item.label }}</span>
<span v-if="item.children?.length" class="item-arrow"></span>
</div>
</template>
</div>
<div
v-if="contextMenuState.visible && submenuState.activeKey && submenuState.items.length"
class="global-context-menu submenu"
:style="{ left: `${submenuState.left}px`, top: `${submenuState.top}px`, width: `${MENU_WIDTH}px` }"
@contextmenu.prevent
>
<div
v-for="child in submenuState.items"
:key="child.key"
class="context-menu-item"
:class="{ disabled: child.disabled }"
@click.stop="handleSubmenuClick(child)"
>
<span class="item-icon">
<component :is="child.icon" v-if="child.icon" />
<MIcon v-else-if="child.iconName" :icon="child.iconName" size="14" />
</span>
<span class="item-label">{{ child.label }}</span>
</div>
</div>
</Teleport>
</template>
<style scoped lang="scss">
.global-context-menu {
position: fixed;
z-index: 9999;
padding: 4px 0;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 8px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
.context-menu-item {
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 0 12px;
font-size: 13px;
color: #1d2129;
cursor: pointer;
user-select: none;
&:hover:not(.disabled) {
background: #f2f7ff;
color: #165dff;
}
&.disabled {
color: #c9cdd4;
cursor: not-allowed;
}
&.has-children {
padding-right: 8px;
}
}
.item-icon {
display: inline-flex;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.item-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-arrow {
color: #86909c;
font-size: 14px;
}
.context-menu-divider {
height: 1px;
margin: 4px 8px;
background: #e5e6eb;
}
.dark {
.global-context-menu {
background: #1f2937;
border-color: #374151;
}
.context-menu-item {
color: #e5e7eb;
&:hover:not(.disabled) {
background: #374151;
color: #93c5fd;
}
}
.context-menu-divider {
background: #374151;
}
}
</style>

View File

@@ -0,0 +1,9 @@
import type { ContextMenuItem } from './types';
export type { ContextMenuItem } from './types';
export { showContextMenu, hideContextMenu } from './use-context-menu';
export { default as GlobalContextMenu } from './global-context-menu.vue';
export function menuDivider(key = `divider-${Date.now()}`): ContextMenuItem {
return { key, type: 'divider' };
}

View File

@@ -0,0 +1,20 @@
import type { Component } from 'vue';
export interface ContextMenuItem {
key: string;
label?: string;
type?: 'item' | 'divider';
icon?: Component;
iconName?: string;
disabled?: boolean;
handler?: (payload?: any) => void;
children?: ContextMenuItem[];
}
export interface ContextMenuState {
visible: boolean;
x: number;
y: number;
items: ContextMenuItem[];
payload: any;
}

View File

@@ -0,0 +1,116 @@
import { reactive } from 'vue';
import type { ContextMenuItem, ContextMenuState } from './types';
const MENU_WIDTH = 180;
const MENU_ITEM_HEIGHT = 36;
const MENU_DIVIDER_HEIGHT = 9;
const SUBMENU_GAP = 4;
let listenersBound = false;
export const contextMenuState = reactive<ContextMenuState>({
visible: false,
x: 0,
y: 0,
items: [],
payload: null,
});
export const submenuState = reactive({
activeKey: null as string | null,
items: [] as ContextMenuItem[],
left: 0,
top: 0,
});
function bindGlobalListeners() {
if (listenersBound) {
return;
}
listenersBound = true;
const hide = () => hideContextMenu();
document.addEventListener('click', hide, true);
document.addEventListener('scroll', hide, true);
window.addEventListener('resize', hide);
}
export function resetSubmenu() {
submenuState.activeKey = null;
submenuState.items = [];
submenuState.left = 0;
submenuState.top = 0;
}
export function showSubmenu(key: string, items: ContextMenuItem[], left: number, top: number) {
submenuState.activeKey = key;
submenuState.items = items;
submenuState.left = left;
submenuState.top = top;
}
export function showContextMenu(
e: MouseEvent,
items: ContextMenuItem[],
payload?: any,
) {
e.preventDefault();
hideContextMenu();
const estimatedHeight = estimateMenuHeight(items);
let x = e.clientX;
let y = e.clientY;
if (x + MENU_WIDTH > window.innerWidth) {
x = Math.max(8, window.innerWidth - MENU_WIDTH - 8);
}
if (y + estimatedHeight > window.innerHeight) {
y = Math.max(8, window.innerHeight - estimatedHeight - 8);
}
contextMenuState.x = x;
contextMenuState.y = y;
contextMenuState.items = items;
contextMenuState.payload = payload ?? null;
contextMenuState.visible = true;
bindGlobalListeners();
}
export function hideContextMenu() {
contextMenuState.visible = false;
contextMenuState.items = [];
contextMenuState.payload = null;
resetSubmenu();
}
export function estimateMenuHeight(items: ContextMenuItem[]): number {
let height = 8;
for (const item of items) {
height +=
item.type === 'divider' ? MENU_DIVIDER_HEIGHT : MENU_ITEM_HEIGHT;
}
return height;
}
export function getSubmenuPosition(
parentRect: DOMRect,
childCount: number,
): { left: number; top: number } {
const submenuHeight = childCount * MENU_ITEM_HEIGHT + 8;
let left = parentRect.right + SUBMENU_GAP;
let top = parentRect.top;
if (left + MENU_WIDTH > window.innerWidth) {
left = parentRect.left - MENU_WIDTH - SUBMENU_GAP;
}
if (top + submenuHeight > window.innerHeight) {
top = Math.max(8, window.innerHeight - submenuHeight - 8);
}
return { left, top };
}
export { MENU_WIDTH, MENU_ITEM_HEIGHT };

View File

@@ -0,0 +1,851 @@
<script setup lang="ts">
/**
* 医生端 PC 传方悬浮窗:悬浮按钮 + 子窗口列表 + 状态操作
*/
import {
computed,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue';
import {
ReloadOutlined,
ZoomInOutlined,
ZoomOutOutlined,
} from '@ant-design/icons-vue';
import { usePreferences } from '@vben/preferences';
import { useUserStore } from '@vben/stores';
import {
Button,
Dropdown,
Image,
Input,
Menu,
Modal,
Select,
Spin,
Tag,
Textarea,
message,
} from 'ant-design-vue';
import FloatFab from '#/components/float-fab/FloatFab.vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import {
DEFAULT_SUB_WINDOW_Z_INDEX,
getSubWindowPopupZIndex,
} from '#/components/sub-window/constants';
import SubWindow from '#/components/sub-window/SubWindow.vue';
import {
getTransferPrescriptionListByStoreApi,
updateTransferPrescriptionHandleStatusApi,
} from '#/views/business/salesperson-transfer-prescription/api';
import { invokeDoctorTransferImport } from '#/views/doctor/doctor-reception/composables/useDoctorReceptionTransferImport';
const REFRESH_STORAGE_KEY = 'pc_doctor_transfer_refresh_sec';
const DEFAULT_AVATAR = '/img/user-default-avatar.png';
const IMAGE_SCALE_MIN = 0.5;
const IMAGE_SCALE_MAX = 3;
const IMAGE_SCALE_STEP = 0.25;
const userStore = useUserStore();
const { isDark } = usePreferences();
/** 与子窗口 z-index 对齐的弹出层层级 */
const popupZIndex = computed(() => getSubWindowPopupZIndex(DEFAULT_SUB_WINDOW_Z_INDEX));
const windowOpen = ref(false);
const loading = ref(false);
const list = ref<any[]>([]);
const pollTimer = ref<ReturnType<typeof setInterval> | null>(null);
const inlineImageUrl = ref('');
const imageScale = ref(1);
const imagePanX = ref(0);
const imagePanY = ref(0);
const imageDragging = ref(false);
const imageDragStartX = ref(0);
const imageDragStartY = ref(0);
const imagePanOriginX = ref(0);
const imagePanOriginY = ref(0);
const salespersonKeyword = ref('');
const patientName = ref('');
const refreshIntervalSec = ref(30);
const exceptionModalOpen = ref(false);
const exceptionMessage = ref('');
const pendingStatusItem = ref<any>(null);
const storeId = computed(() => Number(userStore.userInfo?.store_id || 0));
const pendingCount = computed(
() => list.value.filter((item) => Number(item.status) === 0).length,
);
const refreshOptions = [
{ value: 0, label: '不刷新' },
{ value: 10, label: '10秒' },
{ value: 30, label: '30秒' },
{ value: 60, label: '1分钟' },
];
const statusOptions = [
{ value: 0, label: '未处理' },
{ value: 1, label: '处理中' },
{ value: 2, label: '已完成' },
{ value: 3, label: '异常' },
];
const imageTransformStyle = computed(() => ({
transform: `translate(${imagePanX.value}px, ${imagePanY.value}px) scale(${imageScale.value})`,
transformOrigin: 'center top',
}));
/** Dropdown 挂载到 body避免被 overflow 裁剪 */
function getPopupContainer() {
return document.body;
}
function statusColor(status: number) {
const map: Record<number, string> = {
0: 'warning',
1: 'processing',
2: 'success',
3: 'error',
};
return map[Number(status)] || 'default';
}
/** 重置内嵌看图缩放与平移 */
function resetImageView() {
imageScale.value = 1;
imagePanX.value = 0;
imagePanY.value = 0;
}
/** 从 localStorage 读取刷新间隔配置 */
function loadRefreshSetting() {
const cached = localStorage.getItem(REFRESH_STORAGE_KEY);
if (cached !== null) {
const val = Number(cached);
if ([0, 10, 30, 60].includes(val)) {
refreshIntervalSec.value = val;
}
}
}
function saveRefreshSetting() {
localStorage.setItem(REFRESH_STORAGE_KEY, String(refreshIntervalSec.value));
}
/** 加载当前门店传方列表 */
async function loadList(silent = false) {
if (!storeId.value) return;
if (!silent) loading.value = true;
try {
const params: Record<string, any> = {
store_id: storeId.value,
page: 1,
pageSize: 50,
};
if (salespersonKeyword.value.trim()) {
params.salesperson_keyword = salespersonKeyword.value.trim();
}
if (patientName.value.trim()) {
params.patient_name = patientName.value.trim();
}
const res = await getTransferPrescriptionListByStoreApi(params);
list.value = res?.items ?? [];
} catch (e) {
console.error(e);
if (!silent) list.value = [];
} finally {
loading.value = false;
}
}
function handleSearch() {
loadList();
}
function handleManualRefresh() {
loadList();
}
/** 按配置间隔启动轮询0 表示不自动刷新) */
function restartPoll() {
stopPoll();
const sec = refreshIntervalSec.value;
if (sec <= 0) return;
pollTimer.value = setInterval(() => loadList(true), sec * 1000);
}
function stopPoll() {
if (pollTimer.value) {
clearInterval(pollTimer.value);
pollTimer.value = null;
}
}
function openWindow() {
windowOpen.value = true;
inlineImageUrl.value = '';
resetImageView();
loadList();
}
function openImageInWindow(url: string) {
inlineImageUrl.value = url;
resetImageView();
}
function closeInlineImage() {
inlineImageUrl.value = '';
resetImageView();
}
function zoomIn() {
imageScale.value = Math.min(
IMAGE_SCALE_MAX,
Math.round((imageScale.value + IMAGE_SCALE_STEP) * 100) / 100,
);
}
function zoomOut() {
imageScale.value = Math.max(
IMAGE_SCALE_MIN,
Math.round((imageScale.value - IMAGE_SCALE_STEP) * 100) / 100,
);
}
function resetImageZoom() {
resetImageView();
}
/** 滚轮缩放图片 */
function onImageWheel(e: WheelEvent) {
e.preventDefault();
if (e.deltaY < 0) zoomIn();
else zoomOut();
}
/** 内嵌看图:按住拖拽平移 */
function onImageMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
imageDragging.value = true;
imageDragStartX.value = e.clientX;
imageDragStartY.value = e.clientY;
imagePanOriginX.value = imagePanX.value;
imagePanOriginY.value = imagePanY.value;
document.addEventListener('mousemove', onImageMouseMove);
document.addEventListener('mouseup', onImageMouseUp);
}
function onImageMouseMove(e: MouseEvent) {
if (!imageDragging.value) return;
imagePanX.value = imagePanOriginX.value + (e.clientX - imageDragStartX.value);
imagePanY.value = imagePanOriginY.value + (e.clientY - imageDragStartY.value);
}
function onImageMouseUp() {
imageDragging.value = false;
document.removeEventListener('mousemove', onImageMouseMove);
document.removeEventListener('mouseup', onImageMouseUp);
}
/** 选择处理状态 */
async function handleStatusSelect(item: any, status: number) {
if (status === 3) {
pendingStatusItem.value = item;
exceptionMessage.value = item.exception_message || '';
exceptionModalOpen.value = true;
return;
}
await submitStatus(item, status, '');
}
async function submitStatus(item: any, status: number, exceptionMsg: string) {
if (!storeId.value) return;
try {
await updateTransferPrescriptionHandleStatusApi({
id: item.id,
store_id: storeId.value,
status,
exception_message: status === 3 ? exceptionMsg : '',
});
message.success('状态已更新');
exceptionModalOpen.value = false;
pendingStatusItem.value = null;
loadList(true);
} catch (e: any) {
message.error(e?.message || '更新失败');
}
}
async function confirmException() {
if (!exceptionMessage.value.trim()) {
message.warning('请填写异常说明');
return;
}
if (pendingStatusItem.value) {
await submitStatus(pendingStatusItem.value, 3, exceptionMessage.value.trim());
}
}
/** 是否可导入:手动输入且有药品 */
function canImportItem(item: any) {
if (Number(item.transfer_mode ?? 1) === 2) return false;
return Boolean(item.drug_list?.length || item.drug_text_lines?.length);
}
/** 当前接诊页选中的挂号 ID */
function getCurrentRegisterId() {
return Number.parseInt(localStorage.getItem('doctorReception-id') || '0', 10);
}
/** 导入传方药品到当前接诊患者 */
function handleImportToCurrentPatient(item: any) {
const registerId = getCurrentRegisterId();
if (!registerId) {
message.warning('请先在接诊页选择患者');
return;
}
if (!canImportItem(item)) {
message.warning('仅支持手动输入药品的传方');
return;
}
const ok = invokeDoctorTransferImport({ transferId: item.id, registerId });
if (!ok) {
message.warning('请先打开接诊页面');
}
}
watch(refreshIntervalSec, () => {
saveRefreshSetting();
restartPoll();
});
watch(windowOpen, (v) => {
if (!v) {
inlineImageUrl.value = '';
resetImageView();
}
});
watch(storeId, () => {
if (windowOpen.value) loadList(true);
});
onBeforeUnmount(() => {
stopPoll();
document.removeEventListener('mousemove', onImageMouseMove);
document.removeEventListener('mouseup', onImageMouseUp);
});
onMounted(() => {
loadRefreshSetting();
loadList(true);
restartPoll();
});
defineExpose({ loadList });
</script>
<template>
<FloatFab
storage-key="pc_doctor_transfer_fab"
label="传方"
:badge="pendingCount"
@click="openWindow"
/>
<SubWindow
v-model:open="windowOpen"
title="门店传方"
storage-key="pc_doctor_transfer_window"
:width="440"
:height="560"
:min-width="360"
:min-height="320"
:z-index="DEFAULT_SUB_WINDOW_Z_INDEX"
>
<div class="panel" :class="{ 'is-dark': isDark }">
<div v-if="inlineImageUrl" class="inline-image-layer">
<div class="inline-image-toolbar">
<Button type="link" size="small" @click="closeInlineImage">返回列表</Button>
<Button type="link" size="small" @click="zoomIn">
<ZoomInOutlined /> 放大
</Button>
<Button type="link" size="small" @click="zoomOut">
<ZoomOutOutlined /> 缩小
</Button>
<Button type="link" size="small" @click="resetImageZoom">还原</Button>
<span class="zoom-percent">{{ Math.round(imageScale * 100) }}%</span>
</div>
<div
class="inline-image-body"
:class="{ dragging: imageDragging }"
@wheel="onImageWheel"
@mousedown="onImageMouseDown"
>
<img
:src="inlineImageUrl"
class="inline-image"
:style="imageTransformStyle"
alt="处方图"
draggable="false"
/>
</div>
</div>
<div class="toolbar">
<div class="toolbar-row">
<Input.Search
v-model:value="salespersonKeyword"
allow-clear
placeholder="推广员姓名/手机"
size="small"
class="toolbar-input"
@search="handleSearch"
/>
<Input.Search
v-model:value="patientName"
allow-clear
placeholder="收货人姓名"
size="small"
class="toolbar-input"
@search="handleSearch"
/>
</div>
<div class="toolbar-row toolbar-row-bottom">
<span class="toolbar-label">刷新</span>
<Select
v-model:value="refreshIntervalSec"
size="small"
class="toolbar-select"
:options="refreshOptions"
:get-popup-container="getPopupContainer"
:dropdown-style="{ zIndex: popupZIndex }"
/>
<Button size="small" :loading="loading" @click="handleManualRefresh">
<ReloadOutlined />
</Button>
</div>
</div>
<div class="panel-body">
<div class="panel-scroll">
<Spin :spinning="loading">
<div v-if="!loading && !list.length" class="empty-tip">暂无传方记录</div>
<div v-for="item in list" :key="item.id" class="transfer-card">
<div class="card-head">
<span class="store-name">{{ item.store_name || '—' }}</span>
<Tag :color="statusColor(item.status)">
{{ item.status_text || '未处理' }}
</Tag>
</div>
<div class="salesperson-row">
<Image
:src="item.salesperson_avatar || DEFAULT_AVATAR"
:width="32"
:height="32"
class="salesperson-avatar"
:preview="false"
/>
<div class="salesperson-info">
<div class="salesperson-name">{{ item.salesperson_name || '—' }}</div>
<div v-if="item.salesperson_phone" class="salesperson-phone">
{{ item.salesperson_phone }}
</div>
</div>
</div>
<div class="card-row">
<span class="label">收货人</span>
<span class="value">
{{ item.patient_name }}
<SensitiveText :record="item" field="patient_mobile" :show-eye="false" />
</span>
</div>
<div v-if="item.patient_address" class="card-row">
<span class="label">地址</span>
<span class="value ellipsis">{{ item.patient_address }}</span>
</div>
<div v-if="item.remark" class="card-row">
<span class="label">备注</span>
<span class="value">{{ item.remark }}</span>
</div>
<div
v-if="item.drug_text_lines?.length"
class="drug-block drug-block--importable"
@click="canImportItem(item) && handleImportToCurrentPatient(item)"
>
<span class="label">药方</span>
<div class="drug-lines">
<div
v-for="(line, idx) in item.drug_text_lines"
:key="'d-' + idx"
class="drug-line"
>
{{ line }}
</div>
</div>
<span v-if="canImportItem(item)" class="import-hint">点击导入到当前患者</span>
</div>
<div v-if="item.prescription_images?.length" class="img-block">
<span class="label">处方图</span>
<div class="img-row">
<Image
v-for="(img, idx) in item.prescription_images"
:key="'img-' + idx"
:src="img"
:width="64"
:height="64"
class="thumb"
:preview="{ zIndex: popupZIndex }"
/>
</div>
<Button
type="link"
size="small"
class="link-btn"
@click="openImageInWindow(item.prescription_images[0])"
>
在窗口查看
</Button>
</div>
<div
v-if="item.status === 3 && item.exception_message"
class="exception-box"
>
异常{{ item.exception_message }}
</div>
<div class="card-foot">
<span class="time">{{ item.transfer_time_text }}</span>
<div class="card-foot-actions">
<Button
v-if="canImportItem(item)"
size="small"
type="primary"
@click="handleImportToCurrentPatient(item)"
>
导入到当前患者
</Button>
<Dropdown
:trigger="['click']"
:get-popup-container="getPopupContainer"
:overlay-style="{ zIndex: popupZIndex }"
>
<Button size="small" type="primary" ghost>操作</Button>
<template #overlay>
<Menu @click="({ key }) => handleStatusSelect(item, Number(key))">
<Menu.Item v-for="opt in statusOptions" :key="String(opt.value)">
{{ opt.label }}
</Menu.Item>
</Menu>
</template>
</Dropdown>
</div>
</div>
</div>
</Spin>
</div>
</div>
</div>
</SubWindow>
<Modal
v-model:open="exceptionModalOpen"
title="填写异常说明"
ok-text="确认"
cancel-text="取消"
:z-index="popupZIndex"
@ok="confirmException"
>
<Textarea
v-model:value="exceptionMessage"
:rows="4"
placeholder="请描述异常原因"
/>
</Modal>
</template>
<style scoped>
.panel {
flex: 1;
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
}
.toolbar {
flex-shrink: 0;
padding: 12px;
border-bottom: 1px solid #eee;
}
.panel.is-dark .toolbar {
border-bottom-color: #424242;
}
.toolbar-row {
display: flex;
gap: 8px;
}
.toolbar-row-bottom {
margin-top: 8px;
align-items: center;
}
.toolbar-input {
flex: 1;
}
.toolbar-select {
flex: 1;
}
.toolbar-label {
flex-shrink: 0;
font-size: 12px;
color: #86909c;
}
.panel.is-dark .toolbar-label {
color: rgba(255, 255, 255, 0.45);
}
/* 内部滚动panel-body 限定高度panel-scroll 为唯一滚动容器 */
.panel-body {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
}
.panel-scroll {
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
padding: 12px;
}
.empty-tip {
text-align: center;
color: #999;
padding: 40px 0;
}
.panel.is-dark .empty-tip {
color: rgba(255, 255, 255, 0.45);
}
.transfer-card {
background: #f9fafb;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
border: 1px solid #eee;
}
.panel.is-dark .transfer-card {
background: #262626;
border-color: #424242;
}
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.store-name {
font-weight: 600;
font-size: 14px;
color: #1d2129;
}
.panel.is-dark .store-name {
color: rgba(255, 255, 255, 0.85);
}
.salesperson-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.salesperson-avatar {
flex-shrink: 0;
border-radius: 50%;
object-fit: cover;
}
.salesperson-info {
min-width: 0;
flex: 1;
}
.salesperson-name {
font-size: 14px;
font-weight: 500;
color: #1d2129;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.panel.is-dark .salesperson-name {
color: rgba(255, 255, 255, 0.85);
}
.salesperson-phone {
font-size: 12px;
color: #86909c;
}
.panel.is-dark .salesperson-phone {
color: rgba(255, 255, 255, 0.45);
}
.card-row {
display: flex;
font-size: 13px;
margin-bottom: 6px;
line-height: 1.5;
}
.label {
color: #86909c;
width: 56px;
flex-shrink: 0;
}
.panel.is-dark .label {
color: rgba(255, 255, 255, 0.45);
}
.value {
color: #4e5969;
flex: 1;
}
.panel.is-dark .value {
color: rgba(255, 255, 255, 0.65);
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drug-block {
margin: 6px 0;
display: flex;
font-size: 13px;
}
.drug-block--importable {
flex-wrap: wrap;
cursor: pointer;
padding: 6px 8px;
border-radius: 4px;
transition: background 0.15s;
}
.drug-block--importable:hover {
background: rgba(0, 135, 113, 0.08);
}
.panel.is-dark .drug-block--importable:hover {
background: rgba(52, 211, 153, 0.12);
}
.import-hint {
flex-basis: 100%;
font-size: 12px;
color: #008771;
margin-top: 4px;
margin-left: 56px;
}
.panel.is-dark .import-hint {
color: #34d399;
}
.drug-lines {
flex: 1;
}
.drug-line {
color: #008771;
margin-bottom: 2px;
}
.panel.is-dark .drug-line {
color: #34d399;
}
.img-block {
margin-top: 6px;
}
.img-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 6px 0 6px 56px;
}
.thumb {
object-fit: cover;
border-radius: 4px;
}
.link-btn {
padding-left: 56px;
}
.exception-box {
background: #fef2f2;
color: #991b1b;
font-size: 12px;
padding: 8px;
border-radius: 4px;
margin-top: 6px;
}
.panel.is-dark .exception-box {
background: rgba(127, 29, 29, 0.35);
color: #fca5a5;
}
.card-foot {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e5e7eb;
}
.card-foot-actions {
display: flex;
align-items: center;
gap: 8px;
}
.panel.is-dark .card-foot {
border-top-color: #424242;
}
.time {
font-size: 12px;
color: #999;
}
.panel.is-dark .time {
color: rgba(255, 255, 255, 0.45);
}
.inline-image-layer {
position: absolute;
inset: 0;
background: #fff;
z-index: 10;
display: flex;
flex-direction: column;
}
.panel.is-dark .inline-image-layer {
background: #1f1f1f;
}
.inline-image-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid #eee;
flex-shrink: 0;
}
.panel.is-dark .inline-image-toolbar {
border-bottom-color: #424242;
}
.zoom-percent {
font-size: 12px;
color: #86909c;
}
.panel.is-dark .zoom-percent {
color: rgba(255, 255, 255, 0.45);
}
.inline-image-body {
flex: 1;
overflow: auto;
overscroll-behavior: contain;
padding: 12px;
cursor: grab;
}
.inline-image-body.dragging {
cursor: grabbing;
}
.inline-image {
width: 100%;
height: auto;
display: block;
margin: 0 auto;
user-select: none;
}
</style>

View File

@@ -0,0 +1,514 @@
<script lang="ts" setup>
/**
* 药品搜索选择组件(自定义实现)
*
* @description 自定义药品搜索下拉选择器不依赖antd的Select组件
* - 支持中药/西药类型切换
* - 下拉选项展示:药品图片、名称、供应商、规格、价格
* - 选择时返回完整药品数据(包含默认用法字段)
* @author 系统
* @date 2024
*/
import { ref, watch, onMounted, onUnmounted } from 'vue';
import { SearchOutlined, LoadingOutlined } from '@ant-design/icons-vue';
import { Input, Spin } from 'ant-design-vue';
import { debounce } from 'lodash-es';
import { getProductListDoctorReception } from '#/views/doctor/doctor-reception/api';
/** 中药无图时与仓库列表一致的占位图 */
const TCM_PLACEHOLDER_IMAGE =
'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250510/cd470ebf97e31c4048ba502ba71b66a3.jpg';
// ==================== Props 定义 ====================
interface Props {
/**
* 药品类型
* 1-中药2-西药
*/
type?: number;
/**
* 占位提示文本
*/
placeholder?: string;
/**
* 诊所ID
*/
storeId?: number;
/**
* 挂号ID用于取价门店与毛利率权限
*/
registerId?: number;
/**
* 是否禁用
*/
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
type: 1,
placeholder: '输入药品名称搜索',
storeId: 2,
registerId: undefined,
disabled: false,
});
// ==================== Emits 定义 ====================
const emit = defineEmits<{
/**
* 选中药品时触发
* @param drug 完整的药品数据
*/
(e: 'select', drug: any): void;
}>();
// ==================== 响应式数据 ====================
/**
* 搜索关键词
*/
const searchKeyword = ref('');
/**
* 是否正在搜索
*/
const isSearching = ref(false);
/**
* 搜索结果列表
*/
const searchResults = ref<any[]>([]);
/**
* 是否显示下拉列表
*/
const showDropdown = ref(false);
/**
* 组件容器引用
*/
const containerRef = ref<HTMLElement | null>(null);
/**
* 当前高亮的选项索引(用于键盘导航)
*/
const highlightIndex = ref(-1);
function resolveDropdownImage(item: any): string {
if (item._image) return item._image;
if (props.type === 1) return TCM_PLACEHOLDER_IMAGE;
return '';
}
// ==================== 方法定义 ====================
/**
* 搜索药品
* @param keyword 搜索关键词
* @description 调用后端API搜索药品支持按名称和拼音搜索
*/
const searchDrugs = debounce(async (keyword: string) => {
if (!keyword || keyword.length < 1) {
searchResults.value = [];
showDropdown.value = false;
return;
}
isSearching.value = true;
showDropdown.value = true;
try {
const res = await getProductListDoctorReception({
name: keyword,
type: props.type,
store_id: props.storeId,
...(props.registerId ? { register_id: props.registerId } : {}),
});
// 处理返回数据
if (Array.isArray(res)) {
searchResults.value = res.map((item: any) => ({
// 保留原始数据
...item,
// 提取常用字段便于访问
_id: item.id,
_drugId: item.drug_id || item.drug?.id,
_drugName: item.drug?.drug_name || item.drug_name || '',
_image: item.drug?.image || '',
_specification: item.drug?.specification || '',
_supplier: item.drug?.supplier?.name || '',
_price: item.price || 0,
// 默认用法字段
_timeId: item.drug?.time_id || 0,
_typeId: item.drug?.type_id || 0,
_frequencyId: item.drug?.frequency_id || 0,
_unitId: item.drug?.unit_id || 0,
_number: item.drug?.number || 1,
// 用法名称
_useNum: item.drug?.useNum,
_useType: item.drug?.useType,
_useFrequency: item.drug?.useFrequency,
}));
} else {
searchResults.value = [];
}
} catch (error) {
console.error('搜索药品失败:', error);
searchResults.value = [];
} finally {
isSearching.value = false;
}
}, 300);
/**
* 处理输入变化
* @param e 输入事件
*/
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
searchKeyword.value = target.value;
highlightIndex.value = -1;
searchDrugs(target.value);
}
/**
* 处理输入框获得焦点
*/
function handleFocus() {
if (searchResults.value.length > 0) {
showDropdown.value = true;
}
}
/**
* 处理选中药品
* @param drug 选中的药品数据
*/
function handleSelectDrug(drug: any) {
emit('select', drug);
// 清空搜索状态
searchKeyword.value = '';
searchResults.value = [];
showDropdown.value = false;
highlightIndex.value = -1;
}
/**
* 处理键盘事件
* @param e 键盘事件
*/
function handleKeydown(e: KeyboardEvent) {
if (!showDropdown.value || searchResults.value.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
highlightIndex.value = Math.min(
highlightIndex.value + 1,
searchResults.value.length - 1,
);
break;
case 'ArrowUp':
e.preventDefault();
highlightIndex.value = Math.max(highlightIndex.value - 1, 0);
break;
case 'Enter':
e.preventDefault();
if (highlightIndex.value >= 0) {
handleSelectDrug(searchResults.value[highlightIndex.value]);
}
break;
case 'Escape':
showDropdown.value = false;
highlightIndex.value = -1;
break;
}
}
/**
* 处理点击外部关闭下拉
* @param e 点击事件
*/
function handleClickOutside(e: MouseEvent) {
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
showDropdown.value = false;
highlightIndex.value = -1;
}
}
// ==================== 生命周期 ====================
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
// ==================== 监听 ====================
/**
* 监听类型变化,清空搜索结果
*/
watch(
() => props.type,
() => {
searchResults.value = [];
showDropdown.value = false;
searchKeyword.value = '';
},
);
</script>
<template>
<div ref="containerRef" class="drug-search-select">
<!-- 搜索输入框 -->
<Input
v-model:value="searchKeyword"
:placeholder="placeholder"
:disabled="disabled"
allow-clear
@input="handleInput"
@focus="handleFocus"
@keydown="handleKeydown"
>
<template #prefix>
<LoadingOutlined v-if="isSearching" class="text-gray-400" />
<SearchOutlined v-else class="text-gray-400" />
</template>
</Input>
<!-- 下拉列表 -->
<div v-if="showDropdown" class="drug-dropdown">
<!-- 加载中 -->
<div v-if="isSearching" class="drug-dropdown__loading">
<Spin size="small" />
<span>搜索中...</span>
</div>
<!-- 无结果 -->
<div
v-else-if="searchResults.length === 0"
class="drug-dropdown__empty"
>
暂无匹配药品
</div>
<!-- 结果列表 -->
<div v-else class="drug-dropdown__list">
<div
v-for="(item, index) in searchResults"
:key="item._id"
class="drug-item"
:class="{ 'drug-item--active': index === highlightIndex }"
@click="handleSelectDrug(item)"
@mouseenter="highlightIndex = index"
>
<!-- 药品图片 -->
<div class="drug-item__image">
<img
v-if="resolveDropdownImage(item)"
:src="resolveDropdownImage(item)"
alt=""
class="drug-item__img"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div v-else class="drug-item__img-placeholder">无图</div>
</div>
<!-- 药品信息 -->
<div class="drug-item__info">
<!-- 药品名称 -->
<div class="drug-item__name">{{ item._drugName }}</div>
<!-- 规格和供应商 -->
<div class="drug-item__meta">
<span v-if="item._specification" class="drug-item__spec">
规格{{ item._specification }}
</span>
<span v-if="item._supplier" class="drug-item__supplier">
供应商{{ item._supplier }}
</span>
</div>
</div>
<!-- 价格 -->
<div class="drug-item__price">
¥{{ Number(item._price).toFixed(2) }}
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.drug-search-select {
position: relative;
width: 100%;
}
.drug-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1050;
margin-top: 4px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
max-height: 400px;
overflow: hidden;
&__loading,
&__empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px;
color: #999;
font-size: 14px;
}
&__list {
max-height: 400px;
overflow-y: auto;
padding: 4px 0;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #d9d9d9;
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
}
.drug-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.2s;
&:hover,
&--active {
background-color: #f5f7fa;
}
&__image {
flex-shrink: 0;
width: 48px;
height: 48px;
}
&__img {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 6px;
border: 1px solid #f0f0f0;
}
&__img-placeholder {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 6px;
font-size: 12px;
color: #bbb;
}
&__info {
flex: 1;
min-width: 0;
overflow: hidden;
}
&__name {
font-size: 14px;
font-weight: 500;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: #999;
}
&__spec {
color: #666;
}
&__supplier {
color: #1890ff;
}
&__price {
flex-shrink: 0;
font-size: 16px;
font-weight: 600;
color: #ff4d4f;
}
}
/* 暗色模式适配 */
.dark {
.drug-dropdown {
background: #1f2937;
border-color: #374151;
}
.drug-item {
&:hover,
&--active {
background-color: #374151;
}
&__img-placeholder {
background: #374151;
color: #6b7280;
}
&__name {
color: #f3f4f6;
}
&__meta {
color: #9ca3af;
}
&__spec {
color: #d1d5db;
}
&__supplier {
color: #60a5fa;
}
}
}
</style>

View File

@@ -0,0 +1,9 @@
/**
* 药品搜索选择组件
*
* @description 封装药品搜索下拉选择器,展示药品详细信息
* @author 系统
* @date 2024
*/
export { default as DrugSearchSelect } from './drug-search-select.vue';

View File

@@ -0,0 +1,194 @@
<script setup lang="ts">
/**
* PC 可拖拽吸附悬浮按钮
*/
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { FileTextOutlined } from '@ant-design/icons-vue';
const props = withDefaults(
defineProps<{
/** localStorage 位置缓存 key */
storageKey?: string;
/** 显示文案 */
label?: string;
/** 角标数量 */
badge?: number;
/** 吸附阈值 px */
snapThreshold?: number;
zIndex?: number;
}>(),
{
storageKey: 'pc_float_fab_pos',
label: '传方',
badge: 0,
snapThreshold: 40,
zIndex: 9999,
},
);
const emit = defineEmits<{
click: [];
}>();
const posX = ref(0);
const posY = ref(0);
const fabSize = 56;
const dragging = ref(false);
const moved = ref(false);
const startX = ref(0);
const startY = ref(0);
const originX = ref(0);
const originY = ref(0);
const fabStyle = computed(() => ({
left: `${posX.value}px`,
top: `${posY.value}px`,
zIndex: props.zIndex,
}));
const badgeText = computed(() => {
if (props.badge <= 0) return '';
return props.badge > 99 ? '99+' : String(props.badge);
});
function initPosition() {
const cached = localStorage.getItem(props.storageKey);
if (cached) {
try {
const data = JSON.parse(cached);
posX.value = data.posX ?? window.innerWidth - fabSize - 24;
posY.value = data.posY ?? window.innerHeight * 0.6;
} catch {
posX.value = window.innerWidth - fabSize - 24;
posY.value = window.innerHeight * 0.6;
}
} else {
posX.value = window.innerWidth - fabSize - 24;
posY.value = window.innerHeight * 0.6;
}
clampPos();
}
function savePosition() {
localStorage.setItem(
props.storageKey,
JSON.stringify({ posX: posX.value, posY: posY.value }),
);
}
function clampPos() {
posX.value = Math.max(8, Math.min(posX.value, window.innerWidth - fabSize - 8));
posY.value = Math.max(60, Math.min(posY.value, window.innerHeight - fabSize - 24));
}
/** 松手吸附到最近左/右边缘 */
function snapToEdge() {
const centerX = posX.value + fabSize / 2;
if (centerX < window.innerWidth / 2) {
posX.value = props.snapThreshold;
} else {
posX.value = window.innerWidth - fabSize - props.snapThreshold;
}
clampPos();
savePosition();
}
function onMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
dragging.value = true;
moved.value = false;
startX.value = e.clientX;
startY.value = e.clientY;
originX.value = posX.value;
originY.value = posY.value;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
function onMouseMove(e: MouseEvent) {
if (!dragging.value) return;
const dx = e.clientX - startX.value;
const dy = e.clientY - startY.value;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved.value = true;
posX.value = originX.value + dx;
posY.value = originY.value + dy;
clampPos();
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
if (dragging.value && moved.value) {
snapToEdge();
} else if (!moved.value) {
emit('click');
}
dragging.value = false;
}
onMounted(initPosition);
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
});
</script>
<template>
<div class="float-fab" :style="fabStyle" @mousedown="onMouseDown">
<div class="fab-inner">
<FileTextOutlined class="fab-icon" />
<span v-if="label" class="fab-label">{{ label }}</span>
</div>
<span v-if="badgeText" class="fab-badge">{{ badgeText }}</span>
</div>
</template>
<style scoped>
.float-fab {
position: fixed;
width: 30px;
height: 30px;
cursor: grab;
user-select: none;
}
.float-fab:active {
cursor: grabbing;
}
.fab-inner {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #6acdbb, #00a88a);
box-shadow: 0 4px 16px rgba(0, 168, 138, 0.4);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff;
}
.fab-icon {
font-size: 12px;
}
.fab-label {
font-size: 10px;
margin-top: 2px;
}
.fab-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 12px;
height: 12px;
padding: 0 4px;
background: #ef4444;
color: #fff;
font-size: 11px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
</style>

View File

@@ -0,0 +1,346 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useDebounceFn, useVModel } from '@vueuse/core';
import { Avatar, Empty, Input, Popover, Spin, Tabs, Tag } from 'ant-design-vue';
import { searchApiLogUsers } from '#/views/log/api-log/api';
defineOptions({
name: 'ApiLogUserPicker',
inheritAttrs: false,
});
export type ApiLogUserFilter = {
id: number;
user_type: 'admin' | 'patient' | 'doctor';
platform_type: 0 | 1 | 2;
name: string;
role_label: string;
avatar?: string;
};
type ApiLogUserOption = ApiLogUserFilter;
type UserTab = 'admin' | 'patient' | 'doctor';
const props = defineProps<{
value?: ApiLogUserFilter;
disabled?: boolean;
onPlatformTypeChange?: (platformType: 0 | 1 | 2) => void;
}>();
const emits = defineEmits<{
'update:value': [value: ApiLogUserFilter | undefined];
}>();
const mValue = useVModel(props, 'value', emits, { passive: true });
const open = ref(false);
const loading = ref(false);
const searchKeyword = ref('');
const activeTab = ref<UserTab>('admin');
const options = ref<ApiLogUserOption[]>([]);
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
const TAB_ITEMS = [
{ key: 'admin', label: '后台用户' },
{ key: 'patient', label: '患者端用户' },
{ key: 'doctor', label: '医生端用户' },
] as const;
const searchPlaceholder = computed(() => {
if (activeTab.value === 'admin') return '搜索手机号或昵称';
if (activeTab.value === 'patient') return '搜索用户名字';
return '搜索名字或手机号';
});
async function fetchOptions() {
const keyword = searchKeyword.value.trim();
if (!keyword) {
options.value = [];
return;
}
loading.value = true;
try {
const result = await searchApiLogUsers({
user_type: activeTab.value,
keyword,
pageSize: 24,
});
options.value = result?.items ?? [];
} finally {
loading.value = false;
}
}
const debouncedFetch = useDebounceFn(fetchOptions, 300);
watch([searchKeyword, activeTab], () => {
debouncedFetch();
});
function selectOption(item: ApiLogUserOption) {
mValue.value = {
id: item.id,
user_type: item.user_type,
platform_type: item.platform_type,
name: item.name,
role_label: item.role_label,
avatar: item.avatar,
};
props.onPlatformTypeChange?.(item.platform_type);
open.value = false;
searchKeyword.value = '';
options.value = [];
}
function clearSelection(e: Event) {
e.stopPropagation();
mValue.value = undefined;
}
function onOpenChange(next: boolean) {
if (props.disabled) return;
open.value = next;
if (next) {
searchKeyword.value = '';
options.value = [];
nextTick(() => searchInputRef.value?.focus?.());
}
}
watch(
() => props.disabled,
(disabled) => {
if (disabled) open.value = false;
},
);
</script>
<template>
<Popover
:open="open"
trigger="click"
placement="bottomLeft"
overlay-class-name="api-log-user-picker-popover"
@open-change="onOpenChange"
>
<template #content>
<div class="api-log-user-panel">
<Tabs v-model:active-key="activeTab" size="small" class="api-log-user-tabs">
<Tabs.TabPane
v-for="tab in TAB_ITEMS"
:key="tab.key"
:tab="tab.label"
/>
</Tabs>
<Input
ref="searchInputRef"
v-model:value="searchKeyword"
allow-clear
:placeholder="searchPlaceholder"
class="api-log-user-search"
/>
<Spin :spinning="loading">
<div v-if="options.length" class="api-log-user-grid">
<button
v-for="item in options"
:key="`${item.user_type}-${item.id}`"
type="button"
class="api-log-user-card"
:class="{
active:
mValue?.id === item.id && mValue?.user_type === item.user_type,
}"
@click="selectOption(item)"
>
<Avatar :src="item.avatar" :size="30">
{{ (item.name || '?').charAt(0) }}
</Avatar>
<div class="api-log-user-card-id">#{{ item.id }}</div>
<div class="api-log-user-card-name" :title="item.name">
{{ item.name }}
</div>
<Tag class="api-log-user-card-role" :bordered="false">
{{ item.role_label }}
</Tag>
</button>
</div>
<Empty
v-else
:description="searchKeyword.trim() ? '无匹配用户' : '请输入关键词搜索'"

View File

@@ -2,7 +2,14 @@
import { useVModel } from '@vueuse/core';
import { Upload } from 'ant-design-vue';
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
import {
beforeImageUpload,
resolveUploadFile,
} from '#/utils/use-image-upload-pending';
import { preferences } from '@vben/preferences';
import { Icon } from '#/components/icon';
defineOptions({
@@ -19,43 +26,77 @@ const mValue = useVModel(props, 'value', emits, {
defaultValue: props.value,
passive: true,
});
const customRequest = (e: any) => {
uploadFile({
file: e.file,
}).then((data: any) => {
const customRequest = async (e: any) => {
try {
const actualFile = resolveUploadFile(e.file as File);
const uploadMethod = preferences.app.uploadMethod || 'direct';
let data: { url: string };
if (uploadMethod === 'direct') {
data = await uploadToOss({
file: actualFile,
});
} else {
data = await uploadFile({
file: actualFile,
});
}
mValue.value = data.url;
});
} catch (error) {
console.error('头像上传失败', error);
}
};
function onGallerySelect(urls: string[]) {
if (urls[0]) {
mValue.value = urls[0];
}
}
const handleRemove = (e: Event) => {
e.stopPropagation();
mValue.value = '';
};
</script>
<template>
<Upload
:custom-request="customRequest"
:show-upload-list="false"
list-type="picture-card"
>
<div v-if="mValue" class="m-avatar-wrap">
<Icon
class="m-avatar-icon-delete"
icon="ant-design:delete-outlined"
@click="handleRemove"
/>
<img :src="value" width="100%" />
</div>
<Icon v-else icon="ant-design:plus-outlined" />
</Upload>
<div class="avatar-upload-wrap">
<Upload
:before-upload="beforeImageUpload"
:custom-request="customRequest"
:show-upload-list="false"
list-type="picture-card"
>
<div v-if="mValue" class="m-avatar-wrap">
<Icon
class="m-avatar-icon-delete"
icon="ant-design:delete-outlined"
@click="handleRemove"
/>
<img :src="mValue" width="100%" />
</div>
<Icon v-else icon="ant-design:plus-outlined" />
</Upload>
<GalleryPickLink @select="onGallerySelect" />
</div>
</template>
<style lang="less" scoped>
.avatar-upload-wrap {
display: inline-block;
}
.m-avatar-wrap {
position: relative;
height: 102px;
overflow: hidden;
.m-avatar-icon-delete {
position: absolute;
top: 0;
right: 0;
z-index: 1;
cursor: pointer;
}
}

View File

@@ -0,0 +1,140 @@
<script setup lang="ts">
import { useVModel } from '@vueuse/core';
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
value: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '选择颜色',
},
});
const emits = defineEmits(['update:value']);
const mValue = useVModel(props, 'value', emits, {
defaultValue: props.value,
passive: true,
});
// 预设颜色列表(来自岗位颜色配置)
const presetColors = [
{ color: '#3b82f6', name: '蓝色' },
{ color: '#8b5cf6', name: '紫色' },
{ color: '#10b981', name: '绿色' },
{ color: '#f59e0b', name: '橙色' },
{ color: '#ec4899', name: '粉色' },
{ color: '#06b6d4', name: '青色' },
{ color: '#ef4444', name: '红色' },
{ color: '#6b7280', name: '灰色' },
];
const handleClear = (e: Event) => {
e.preventDefault();
mValue.value = '';
};
const selectPresetColor = (color: string) => {
mValue.value = color;
};
const isSelected = (color: string) => {
return mValue.value?.toLowerCase() === color.toLowerCase();
};
</script>
<template>
<div
class="color-picker-container rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-800"
>
<!-- 主选择器 -->
<div class="color-picker-main flex items-center gap-2.5">
<label class="color-picker-label relative inline-block cursor-pointer">
<input v-model="mValue" type="color" class="color-input" />
<span
class="color-preview flex h-10 w-10 items-center justify-center rounded-lg border-2 border-gray-300 shadow-sm transition-all hover:scale-105 hover:border-blue-500 dark:border-gray-500 dark:hover:border-blue-400"
:style="{ backgroundColor: mValue || 'var(--color-placeholder-bg)' }"
>
<span
v-if="!mValue"
class="text-base font-bold text-gray-400 dark:text-gray-500"
>?</span
>
</span>
</label>
<span
class="color-value flex-1 rounded border border-gray-200 bg-white px-2.5 py-1.5 font-mono text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
{{ mValue || placeholder }}
</span>
<a
v-if="mValue"
class="cursor-pointer rounded px-2 py-1 text-xs text-red-500 transition-all hover:bg-red-50 hover:no-underline dark:text-red-400 dark:hover:bg-red-900/20"
@click="handleClear"
>
清除
</a>
</div>
<!-- 预设颜色 -->
<div class="color-presets mt-3">
<span class="presets-label mb-2 block text-xs text-gray-500 dark:text-gray-400">
推荐颜色
</span>
<div class="presets-grid flex flex-wrap gap-2">
<div
v-for="preset in presetColors"
:key="preset.color"
class="preset-item flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border-2 border-transparent shadow-sm transition-all hover:scale-115 hover:shadow-md"
:class="{
'ring-2 ring-gray-800 ring-offset-2 dark:ring-gray-200 dark:ring-offset-gray-800':
isSelected(preset.color),
}"
:style="{ backgroundColor: preset.color }"
:title="preset.name"
@click="selectPresetColor(preset.color)"
>
<span
v-if="isSelected(preset.color)"
class="check-icon text-sm font-bold text-white drop-shadow"
>
</span>
</div>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.color-picker-container {
max-width: 320px;
--color-placeholder-bg: #e5e7eb;
.dark & {
--color-placeholder-bg: #374151;
}
}
.color-picker-label {
.color-input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
cursor: pointer;
}
}
.preset-item {
&:hover {
transform: scale(1.15);
}
}
</style>

View File

@@ -0,0 +1,344 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useVModel } from '@vueuse/core';
import { Avatar, Empty, Input, Popover, Spin } from 'ant-design-vue';
import { getDoctorOptionApi } from '#/views/system/store/api';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
defineOptions({
name: 'DoctorPicker',
inheritAttrs: false,
});
const props = defineProps<{
value?: number;
storeId?: number;
disabled?: boolean;
}>();
const emits = defineEmits<{
'update:value': [value: number | undefined];
}>();
const mValue = useVModel(props, 'value', emits, { passive: true });
type DoctorOption = {
id: number;
name: string;
avatar?: string;
mobile?: string;
depart_name?: string;
};
const open = ref(false);
const loading = ref(false);
const searchKeyword = ref('');
const options = ref<DoctorOption[]>([]);
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
const isDisabled = computed(() => props.disabled || !props.storeId);
/** 按关键词过滤医生列表 */
function filterDoctorOptions(keyword: string, list: DoctorOption[]) {
const q = keyword.trim().toLowerCase();
if (!q) return list;
return list.filter((item) => {
const name = (item.name || '').toLowerCase();
const mobile = (item.mobile || '').toLowerCase();
const depart = (item.depart_name || '').toLowerCase();
const idText = String(item.id);
return (
name.includes(q) ||
mobile.includes(q) ||
depart.includes(q) ||
idText.includes(q)
);
});
}
const filteredOptions = computed(() =>
filterDoctorOptions(searchKeyword.value, options.value),
);
const selectedOption = computed(() =>
options.value.find((item) => item.id === mValue.value),
);
/** 按诊所加载医生选项id 为 su_id */
async function loadOptions() {
if (!props.storeId) {
options.value = [];
return;
}
loading.value = true;
try {
options.value = (await getDoctorOptionApi(props.storeId)) || [];
} finally {
loading.value = false;
}
}
function selectOption(item: DoctorOption) {
mValue.value = item.id;
open.value = false;
searchKeyword.value = '';
}
function clearSelection() {
mValue.value = undefined;
}
function onOpenChange(next: boolean) {
if (isDisabled.value) return;
open.value = next;
if (next) {
searchKeyword.value = '';
loadOptions();
nextTick(() => searchInputRef.value?.focus?.());
}
}
/** 诊所变更时重载列表,并清除不在新诊所下的选中项 */
watch(
() => props.storeId,
async (storeId, prevStoreId) => {
if (storeId === prevStoreId) return;
await loadOptions();
if (!mValue.value) return;
const stillValid = options.value.some((item) => item.id === mValue.value);
if (!stillValid) {
mValue.value = undefined;
}
},
{ immediate: true },
);
watch(
() => props.disabled,
(disabled) => {
if (disabled) open.value = false;
},
);
</script>
<template>
<Popover
:open="open"
trigger="click"
placement="bottomLeft"
overlay-class-name="doctor-picker-popover"
@open-change="onOpenChange"
>
<template #content>
<div class="doctor-panel">
<Input
ref="searchInputRef"
v-model:value="searchKeyword"
allow-clear
placeholder="搜索医生姓名、手机号、科室"
class="doctor-search"
/>
<Spin :spinning="loading">
<div v-if="filteredOptions.length" class="doctor-grid">
<button
v-for="item in filteredOptions"
:key="item.id"
type="button"
class="doctor-card"
:class="{ active: item.id === mValue }"
@click="selectOption(item)"
>
<Avatar :src="resolveAvatarUrl(item.avatar)" :size="36">
{{ (item.name || '?').charAt(0) }}
</Avatar>
<div class="doctor-card-name" :title="item.name">
{{ item.name || '-' }}
</div>
<div v-if="item.mobile" class="doctor-card-sub">
<SensitiveText :record="item" field="mobile" :show-eye="false" />
</div>
<div v-if="item.depart_name" class="doctor-card-sub">
{{ item.depart_name }}
</div>
</button>
</div>
<Empty v-else description="该诊所下无匹配医生" class="doctor-empty" />
</Spin>
</div>
</template>
<div class="doctor-trigger" :class="{ disabled: isDisabled }">
<div v-if="selectedOption" class="doctor-selected-card">
<Avatar :src="resolveAvatarUrl(selectedOption.avatar)" :size="36">
{{ (selectedOption.name || '?').charAt(0) }}
</Avatar>
<div class="doctor-selected-info">
<div class="doctor-selected-name">{{ selectedOption.name }}</div>
<div class="doctor-selected-sub">
<SensitiveText
v-if="selectedOption.mobile"
:record="selectedOption"
field="mobile"
:show-eye="false"
/>
<span v-else>su_id: {{ selectedOption.id }}</span>
</div>
</div>
<span
v-if="!isDisabled"
class="doctor-clear-btn"
title="清除"
@click.stop="clearSelection"
>
×
</span>
</div>
<div v-else-if="mValue" class="doctor-selected-card">
<div class="doctor-selected-info">
<div class="doctor-selected-name">医生 su_id: {{ mValue }}</div>
</div>
<span
v-if="!isDisabled"
class="doctor-clear-btn"
title="清除"
@click.stop="clearSelection"
>
×
</span>
</div>
<div v-else class="doctor-trigger-placeholder">
{{ storeId ? '请选择关联医生(可选)' : '请先选择所属诊所' }}
</div>
</div>
</Popover>
</template>
<style scoped lang="scss">
@use './picker-card-theme.scss' as theme;
.doctor-trigger {
width: 100%;
}
.doctor-trigger.disabled {
cursor: not-allowed;
opacity: 0.65;
}
.doctor-trigger:not(.disabled) {
cursor: pointer;
}
.doctor-selected-card {
@include theme.picker-selected-card;
}
.doctor-selected-info {
flex: 1;
min-width: 0;
}
.doctor-selected-name {
font-size: 14px;
color: #1d2129;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doctor-selected-sub {
font-size: 12px;
color: #86909c;
margin-top: 2px;
}
.doctor-clear-btn {
@include theme.picker-clear-btn;
}
.doctor-trigger-placeholder {
@include theme.picker-trigger-placeholder;
}
.dark {
.doctor-selected-card {
@include theme.picker-selected-card-dark-props;
}
.doctor-selected-name {
@include theme.picker-text-primary-dark;
}
.doctor-selected-sub {
@include theme.picker-text-secondary-dark;
}
.doctor-clear-btn {
@include theme.picker-clear-dark-props;
}
.doctor-trigger-placeholder {
@include theme.picker-placeholder-dark-props;
}
}
</style>
<style lang="scss">
@use './picker-card-theme.scss' as theme;
.doctor-picker-popover {
.ant-popover-inner {
padding: 12px;
}
.doctor-panel {
width: 540px;
max-width: 86vw;
}
.doctor-search {
margin-bottom: 10px;
}
.doctor-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
gap: 8px;
max-height: 300px;
overflow-y: auto;
padding: 2px;
}
.doctor-card {
@include theme.picker-card-base;
}
.doctor-card-name {
@include theme.picker-card-name;
}
.doctor-card-sub {
@include theme.picker-card-sub;
}
.doctor-empty {
margin: 16px 0;
}
}
.dark .doctor-picker-popover {
.doctor-card {
@include theme.picker-card-dark-props;
}
.doctor-card-name {
@include theme.picker-text-primary-dark;
}
.doctor-card-sub {
@include theme.picker-text-secondary-dark;
}
}
</style>

View File

@@ -0,0 +1,283 @@
<script setup lang="ts">
import { ref } from 'vue';
import { QuillEditor } from '@vueup/vue-quill';
// eslint-disable-next-line n/no-extraneous-import
import '@vueup/vue-quill/dist/vue-quill.snow.css';
import { useVModel } from '@vueuse/core';
import { message } from 'ant-design-vue';
// 导入上传相关API和工具函数
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
import { prepareImageForUpload } from '#/utils/prepare-image-upload';
import { preferences } from '@vben/preferences';
const props = defineProps({
value: {
type: String,
default: '',
},
});
const emits = defineEmits(['update:value']);
// 创建对QuillEditor的引用用于后续获取Quill实例
const quillEditorRef = ref(null);
const isUpload = ref(false);
const mValue = useVModel(props, 'value', emits, {
defaultValue: props.value,
passive: true,
});
// 富文本编辑器配置选项
const editorOptions = {
theme: 'snow',
modules: {
toolbar: [
['bold', 'italic', 'underline', 'strike'],
['blockquote', 'code-block'],
[{ header: 1 }, { header: 2 }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ script: 'sub' }, { script: 'super' }],
[{ indent: '-1' }, { indent: '+1' }],
[{ direction: 'rtl' }],
[{ size: ['small', false, 'large', 'huge'] }],
[{ header: [1, 2, 3, 4, 5, 6, false] }],
[{ color: [] }, { background: [] }],
[{ font: [] }],
[{ align: [] }],
['clean'],
['link', 'image', 'video'],
],
},
placeholder: '请输入消息内容',
};
/**
* 直接处理编辑器的粘贴事件
* 这是一个备用方法通过Quill的clipboard模块直接处理粘贴事件
*/
const setupQuillPasteHandler = () => {
// 确保编辑器已经挂载
if (!quillEditorRef.value) return;
const quill = quillEditorRef.value.getQuill();
if (!quill) return;
// 获取Quill的clipboard模块
const clipboard = quill.getModule('clipboard');
// 保存原始的粘贴处理函数
const originalMatchers = clipboard.matchers;
// 重写粘贴处理函数
clipboard.addMatcher('img', (node: any, delta: any) => {
// 这里可以处理HTML中的img标签
// 但对于直接粘贴的图片文件,这个方法不会被触发
return delta;
});
// 监听编辑器的paste事件
quill.root.addEventListener('paste', async (e: ClipboardEvent) => {
const imageFile = getImageFromClipboard(e);
if (imageFile) {
e.preventDefault();
if (isUpload.value === true) {
return;
}
isUpload.value = true;
const imageUrl = await uploadImage(imageFile);
if (imageUrl) {
insertImageToEditor(quill, imageUrl);
}
}
});
};
/**
* 从剪贴板数据中提取图片文件
* @param {ClipboardEvent} event - 剪贴板事件
* @returns {File|null} - 返回图片文件或null
*/
const getImageFromClipboard = (event: ClipboardEvent): File | null => {
const clipboardData = event.clipboardData;
if (!clipboardData) return null;
// 遍历剪贴板中的所有项目
const items = clipboardData.items;
for (const item of items) {
// 检查是否是图片类型
if (item.type.includes('image')) {
return item.getAsFile();
}
}
return null;
};
/**
* 在编辑器中插入图片
* @param {any} quill - Quill编辑器实例
* @param {string} imageUrl - 图片URL
*/
const insertImageToEditor = (quill: any, imageUrl: string): void => {
if (!quill) return;
// 获取当前光标位置
const range = quill.getSelection();
if (range) {
// 在光标位置插入图片
quill.insertEmbed(range.index, 'image', imageUrl);
// 将光标移动到图片后面
quill.setSelection(range.index + 1);
} else {
// 如果没有选择范围,则在文档末尾插入
const length = quill.getLength();
quill.insertEmbed(length - 1, 'image', imageUrl);
quill.setSelection(length);
}
};
/**
* 图片上传函数
*
* 该函数根据preferences配置的上传方式选择使用OSS直传或后端上传
* - 'direct': OSS直传模式文件直接从浏览器上传到阿里云OSS
* - 'backend': 后端上传模式文件先上传到后端服务器再由后端上传到OSS
*
* 上传方式说明:
* 1. OSS直传direct
* - 调用uploadToOss函数直接上传到OSS
* - 减少服务器负载,上传速度更快
* - 支持上传进度回调(如果需要可以添加)
*
* 2. 后端上传backend
* - 调用uploadFile API通过后端服务器上传
* - 兼容现有功能,所有上传逻辑由后端统一处理
*
* 兼容性处理:
* - 两种上传方式返回的数据结构保持一致:{ url: string }
* - 上传成功或失败都会显示相应的提示信息
* - 上传完成后将isUpload标记设置为false允许下次上传
*
* @param {File} file - 要上传的图片文件
* @returns {Promise<string>} - 返回上传后的图片URL如果上传失败返回空字符串
*/
const uploadImage = async (file: File): Promise<string> => {
try {
const { file: preparedFile } = await prepareImageForUpload(file);
if (!preparedFile) {
isUpload.value = false;
return '';
}
// 显示上传中的提示
message.loading({ content: '图片上传中...', key: 'imageUpload' });
// 从preferences中读取上传方式配置
// uploadMethod可能的值'direct'OSS直传或 'backend'(后端上传)
const uploadMethod = preferences.app.uploadMethod || 'direct';
let data: { url: string };
// 根据配置选择上传方式
if (uploadMethod === 'direct') {
// OSS直传模式文件直接从浏览器上传到OSS
// uploadToOss函数会处理签名获取、文件上传等所有逻辑
data = await uploadToOss({
file: preparedFile,
// 如果需要显示上传进度可以添加onProgress回调
// onProgress: (percent) => {
// message.loading({ content: `图片上传中... ${percent}%`, key: 'imageUpload' });
// },
});
} else {
// 后端上传模式文件先上传到后端服务器再由后端上传到OSS
// uploadFile函数会将文件发送到后端API/upload/image
data = await uploadFile({
file: preparedFile,
});
}
// 上传成功,显示成功提示
message.success({
content: '图片上传成功',
key: 'imageUpload',
duration: 2,
});
// 重置上传标记,允许下次上传
isUpload.value = false;
// 返回上传后的图片URL
return data.url;
} catch (error) {
// 处理上传错误
console.error('图片上传错误:', error);
// 显示错误提示
message.error({ content: '图片上传失败', key: 'imageUpload', duration: 2 });
// 重置上传标记,允许重试
isUpload.value = false;
// 返回空字符串,表示上传失败
return '';
}
};
</script>
<template>
<div class="editor-container-box">
<QuillEditor
ref="quillEditorRef"
v-model:content="mValue"
:options="editorOptions"
class="editor-container"
content-type="html"
theme="snow"
@ready="setupQuillPasteHandler"
/>
</div>
</template>
<style scoped>
.editor-container-box {
width: 100%;
height: 100%;
}
/* 编辑器容器样式 */
.editor-container {
height: 250px;
margin-bottom: 40px;
/* 确保编辑器有足够的空间显示工具栏和内容区域 */
}
/* Markdown编辑器样式 */
.md-editor-container {
min-height: 400px;
margin-bottom: 40px;
}
/* 可以添加额外的样式来自定义编辑器外观 */
:deep(.ql-editor) {
min-height: 200px;
font-size: 14px;
line-height: 1.6;
}
/* 确保图片在编辑器中显示正常 */
:deep(.ql-editor img) {
max-width: 100%;
height: auto;
}
/* 自定义 Markdown 编辑器样式 */
:deep(.md-editor) {
border-radius: 4px;
}
:deep(.md-editor-dark) {
--md-bk-color: #1e1e1e;
--md-border-color: #333;
}
</style>

View File

@@ -0,0 +1,232 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useVModel } from '@vueuse/core';
import { Empty, Input, Popover, Spin } from 'ant-design-vue';
import { getExpressCompaniesOption } from '#/views/business/express/express-company/api';
defineOptions({
name: 'ExpressCompanySelect',
inheritAttrs: false,
});
const props = defineProps<{
value?: string;
disabled?: boolean;
placeholder?: string;
}>();
const emits = defineEmits<{
'update:value': [value: string | undefined];
}>();
const mValue = useVModel(props, 'value', emits, { passive: true });
type ExpressCompanyOption = {
id: number;
name: string;
code: string;
};
const open = ref(false);
const loading = ref(false);
const searchKeyword = ref('');
const options = ref<ExpressCompanyOption[]>([]);
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
function filterExpressCompanyOptions(keyword: string, list: ExpressCompanyOption[]) {
const q = keyword.trim().toLowerCase();
if (!q) return list;
return list.filter((item) => {
const name = (item.name || '').toLowerCase();
const code = (item.code || '').toLowerCase();
return name.includes(q) || code.includes(q);
});
}
const filteredOptions = computed(() =>
filterExpressCompanyOptions(searchKeyword.value, options.value),
);
const selectedOption = computed(() =>
options.value.find((item) => item.code === mValue.value),
);
const displayText = computed(() => {
const item = selectedOption.value;
if (!item) return '';
return item.code ? `${item.name || '-'}${item.code}` : (item.name || '-');
});
async function loadOptions() {
loading.value = true;
try {
const res = await getExpressCompaniesOption({});
options.value = Array.isArray(res) ? res : [];
} finally {
loading.value = false;
}
}
function selectOption(item: ExpressCompanyOption) {
mValue.value = item.code;
open.value = false;
searchKeyword.value = '';
}
function clearSelection(e: Event) {
e.stopPropagation();
mValue.value = undefined;
}
function onOpenChange(next: boolean) {
if (props.disabled) return;
open.value = next;
if (next) {
searchKeyword.value = '';
if (!options.value.length) {
loadOptions();
}
nextTick(() => searchInputRef.value?.focus?.());
}
}
watch(
() => props.disabled,
(disabled) => {
if (disabled) open.value = false;
},
);
onMounted(() => {
loadOptions();
});
</script>
<template>
<Popover
:open="open"
trigger="click"
placement="bottomLeft"
overlay-class-name="express-company-select-popover"
@open-change="onOpenChange"
>
<template #content>
<div class="express-panel">
<Input
ref="searchInputRef"
v-model:value="searchKeyword"
allow-clear
placeholder="搜索公司名称或编码"
class="express-search"
/>
<Spin :spinning="loading">
<div v-if="filteredOptions.length" class="express-list">
<button
v-for="item in filteredOptions"
:key="item.id"
type="button"
class="express-item"
:class="{ active: item.code === mValue }"
@click="selectOption(item)"
>
<div class="express-meta">
<div class="express-name">{{ item.name || '-' }}</div>
<div class="express-sub">{{ item.code }}</div>
</div>
</button>
</div>
<Empty v-else description="无匹配快递公司" class="express-empty" />
</Spin>
</div>
</template>
<div
class="express-trigger"
:class="{ disabled: disabled, placeholder: !displayText }"
>
<Input
:value="displayText"
readonly
:disabled="disabled"
:placeholder="placeholder || '请选择快递公司'"
class="express-trigger-input"
>
<template v-if="displayText && !disabled" #suffix>
<span class="clear-btn" @click="clearSelection">×</span>
</template>
</Input>
</div>
</Popover>
</template>
<style scoped>
.express-trigger {
width: 100%;
cursor: pointer;
}
.express-trigger.disabled {
cursor: not-allowed;
}
.express-trigger-input {
pointer-events: none;
}
.express-trigger:not(.disabled) :deep(.ant-input) {
cursor: pointer;
}
.clear-btn {
pointer-events: auto;
cursor: pointer;
color: #86909c;
font-size: 16px;
line-height: 1;
padding: 0 4px;
}
.express-panel {
width: 360px;
max-width: 80vw;
}
.express-search {
margin-bottom: 8px;
}
.express-list {
max-height: 280px;
overflow-y: auto;
}
.express-item {
display: flex;
align-items: center;
width: 100%;
padding: 8px 10px;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
border-radius: 6px;
}
.express-item:hover,
.express-item.active {
background: #f2f3f5;
}
.express-meta {
min-width: 0;
flex: 1;
}
.express-name {
font-size: 14px;
color: #1d2129;
}
.express-sub {
font-size: 12px;
color: #86909c;
}
.express-empty {
margin: 12px 0;
}
</style>
<style>
.express-company-select-popover .ant-popover-inner {
padding: 12px;
}
</style>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { ref } from 'vue';
import ImageGalleryPicker from '#/components/form/components/image-gallery-picker.vue';
const props = withDefaults(
defineProps<{
multiple?: boolean;
maxCount?: number;
disabled?: boolean;
/**
* 限制可选择的文件类型(透传给 image-gallery-picker按 xk_file_type.value 过滤)
* 例如图片:[1];文件:[6];语音:[3]
*/
acceptTypes?: number[];
}>(),
{
multiple: false,
maxCount: 1,
disabled: false,
acceptTypes: () => [] as number[],
},
);
const emit = defineEmits<{
select: [urls: string[]];
}>();
const galleryOpen = ref(false);
function openGallery() {
if (props.disabled) {
return;
}
galleryOpen.value = true;
}
function onSelect(urls: string[]) {
emit('select', urls);
}
</script>
<template>
<div class="gallery-pick-link-wrap">
<a class="gallery-pick-link" :class="{ disabled }" @click.prevent="openGallery">从文件库选择</a>
<ImageGalleryPicker
v-model:open="galleryOpen"
:multiple="multiple"
:max-count="maxCount"
:accept-types="acceptTypes"
@select="onSelect"
/>
</div>
</template>
<style scoped lang="scss">
.gallery-pick-link-wrap {
margin-top: 8px;
}
.gallery-pick-link {
font-size: 13px;
color: #165dff;
cursor: pointer;
user-select: none;
&:hover {
color: #4080ff;
}
&.disabled {
color: #c9cdd4;
cursor: not-allowed;
pointer-events: none;
}
}
.dark .gallery-pick-link {
color: #60a5fa;
&:hover {
color: #93c5fd;
}
&.disabled {
color: #6b7280;
}
}
</style>

View File

@@ -0,0 +1,415 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Empty, Input, Modal, Pagination, Spin, Tabs } from 'ant-design-vue';
import MIcon from '#/components/icon/icon.vue';
import { useFileGalleryFilter } from '#/composables/use-file-gallery-filter';
import { resolveTypeIcon } from '#/composables/use-file-type-icon';
import type { FileGalleryItem } from '#/api/core/file-gallery';
import { getFileGalleryList } from '#/api/core/file-gallery';
const props = withDefaults(
defineProps<{
open: boolean;
multiple?: boolean;
maxCount?: number;
/**
* 限制可选择的文件类型(按 xk_file_type.value 过滤)
* - 不传:显示全部类型(默认行为,保持向后兼容)
* - 传数组:只展示指定类型 tab且自动锁定到第一个匹配的类型
* 例如图片:[1];图片+视频:[1, 2];文件:[6]
*/
acceptTypes?: number[];
}>(),
{
multiple: false,
maxCount: 1,
acceptTypes: () => [] as number[],
},
);
const emit = defineEmits<{
'update:open': [value: boolean];
select: [urls: string[]];
}>();
const loading = ref(false);
const items = ref<FileGalleryItem[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(24);
const pageSizeOptions = ['12', '24', '48'];
const selected = ref<string[]>([]);
/** 按 acceptTypes 隔离 localStorage避免图片/视频选择器互相污染 */
const storageKey = computed(
() =>
`file-picker-type-${
props.acceptTypes?.length ? props.acceptTypes.join('-') : 'all'
}`,
);
const {
activeType,
keyword,
tabs,
setActiveType,
buildListParams,
fileTypes,
typesLoading,
} = useFileGalleryFilter(() => storageKey.value);
/**
* 实际渲染的 tabs按 acceptTypes 过滤)
*/
const visibleTabs = computed(() => {
if (!props.acceptTypes || props.acceptTypes.length === 0) {
return tabs.value;
}
return tabs.value.filter(
(tab) => tab.value !== -1 && props.acceptTypes.includes(Number(tab.value)),
);
});
/**
* 强制锁定到 acceptTypes 首项(打开弹窗 / 类型字典就绪时调用)
*/
function lockAcceptType() {
const arr = props.acceptTypes;
if (!arr || arr.length === 0) {
return;
}
if (activeType.value !== arr[0]) {
setActiveType(arr[0]!);
}
}
/**
* 是否允许拉列表:有 acceptTypes 时需等类型字典加载完且 visibleTabs 非空
*/
function canLoadList() {
if (typesLoading.value) {
return false;
}
if (props.acceptTypes && props.acceptTypes.length > 0) {
return visibleTabs.value.length > 0;
}
return true;
}
async function load() {
if (!canLoadList()) {
return;
}
loading.value = true;
try {
const res = await getFileGalleryList(
buildListParams(page.value, pageSize.value),
);
const data = (res as any)?.data ?? res;
items.value = data?.items ?? [];
total.value = data?.total ?? 0;
} finally {
loading.value = false;
}
}
watch(
() => props.acceptTypes,
(arr) => {
if (!arr || arr.length === 0) {
return;
}
lockAcceptType();
page.value = 1;
void load();
},
{ immediate: true },
);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
watch(
() => props.open,
(val) => {
if (val) {
selected.value = [];
page.value = 1;
// 打开时再次按 acceptTypes 重锁,避免沿用其它选择器的视频 tab
lockAcceptType();
void load();
}
},
);
watch(typesLoading, (val) => {
if (!val && props.open) {
lockAcceptType();
void load();
}
});
function toggleSelect(url: string) {
if (props.multiple) {
const idx = selected.value.indexOf(url);
if (idx >= 0) {
selected.value = selected.value.filter((u) => u !== url);
return;
}
if (selected.value.length >= props.maxCount) {
return;
}
selected.value = [...selected.value, url];
return;
}
selected.value = [url];
}
function isSelected(url: string) {
return selected.value.includes(url);
}
function handleOk() {
if (!selected.value.length) {
return;
}
emit('select', [...selected.value]);
emit('update:open', false);
}
function handleCancel() {
emit('update:open', false);
}
function onPageChange(p: number, size?: number) {
page.value = p;
if (size) {
pageSize.value = size;
}
void load();
}
function onShowSizeChange(_current: number, size: number) {
page.value = 1;
pageSize.value = size;
void load();
}
function onTabChange(key: string | number) {
setActiveType(Number(key));
page.value = 1;
void load();
}
function onSearch(value: string) {
keyword.value = value;
page.value = 1;
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(() => {
void load();
}, 300);
}
function itemIcon(item: FileGalleryItem) {
return item.type_icon || resolveTypeIcon(item.type, fileTypes.value);
}
/** 图片类型展示缩略图xk_file_type1=图片) */
function isImageItem(item: FileGalleryItem) {
return Number(item.type) === 1 || Number(item.type) === 0;
}
</script>
<template>
<Modal
:open="open"
title="从文件库选择"
width="820px"
:ok-button-props="{ disabled: !selected.length }"
@ok="handleOk"
@cancel="handleCancel"
>
<Tabs :active-key="String(activeType)" size="small" @change="onTabChange">
<Tabs.TabPane v-for="tab in visibleTabs" :key="String(tab.value)">
<template #tab>
<span class="tab-label">
<MIcon v-if="tab.icon" :icon="tab.icon" size="12" />
{{ tab.label }}
</span>
</template>
</Tabs.TabPane>
</Tabs>
<div class="picker-toolbar">
<Input.Search
v-model:value="keyword"
allow-clear
placeholder="按文件名搜索"
size="small"
@search="onSearch"
@change="(e) => onSearch(e.target.value ?? '')"
/>
</div>
<Spin :spinning="loading || typesLoading">
<div v-if="items.length" class="gallery-grid">
<div
v-for="item in items"
:key="item.id"
class="gallery-card"
:class="{ active: isSelected(item.url) }"
@click="toggleSelect(item.url)"
>
<img
v-if="isImageItem(item)"
:src="item.url"
alt=""
class="gallery-thumb"
/>
<div v-else class="gallery-file-icon">
<MIcon :icon="itemIcon(item)" size="28" />
<span class="file-name">{{
item.file_name || item.original_name || '未命名'
}}</span>
</div>
<div class="gallery-meta">
<div class="file-title">
<MIcon :icon="itemIcon(item)" size="11" class="meta-icon" />
{{ item.file_name || item.original_name || '-' }}
</div>
<div>{{ item.file_size_text || '-' }}</div>
</div>
</div>
</div>
<Empty v-else description="暂无文件" />
<div v-if="total > 0" class="gallery-pagination">
<Pagination
:current="page"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:total="total"
size="small"
show-less-items
show-size-changer
:show-total="(t) => `${t}`"
@change="onPageChange"
@show-size-change="onShowSizeChange"
/>
</div>
</Spin>
</Modal>
</template>
<style scoped lang="scss">
@use './picker-card-theme.scss' as picker;
.tab-label {
display: inline-flex;
align-items: center;
gap: 4px;
}
.picker-toolbar {
margin-bottom: 12px;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
gap: 12px;
max-height: 420px;
overflow-y: auto;
padding: 4px;
}
.gallery-card {
@include picker.picker-card-base;
padding: 6px;
&.active {
border-color: #165dff;
background: #f2f7ff;
box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.15);
}
}
.gallery-thumb {
width: 100%;
height: 88px;
object-fit: cover;
border-radius: 6px;
background: #f5f5f5;
}
.gallery-file-icon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
height: 88px;
border-radius: 6px;
background: #f5f5f5;
color: #4e5969;
padding: 6px;
.file-name {
font-size: 11px;
text-align: center;
word-break: break-all;
line-height: 1.2;
max-height: 28px;
overflow: hidden;
}
}
.gallery-meta {
font-size: 11px;
color: #86909c;
.file-title {
display: inline-flex;
align-items: center;
gap: 4px;
color: #1d2129;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.gallery-pagination {
margin-top: 16px;
display: flex;
justify-content: center;
}
.dark {
.gallery-card {
@include picker.picker-card-dark-props;
&.active {
border-color: #60a5fa;
background: #374151;
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
}
}
.gallery-thumb,
.gallery-file-icon {
background: #374151;
}
.gallery-meta {
@include picker.picker-text-secondary-dark;
.file-title {
color: #e5e7eb;
}
}
}
</style>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { useVModel } from '@vueuse/core';
import { Select } from 'ant-design-vue';
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
value: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '请输入',
},
});
const emits = defineEmits(['update:value']);
const mValue = useVModel(props, 'value', emits, {
defaultValue: props.value,
passive: true,
});
</script>
<template>
<Select
v-model:value="mValue"
:placeholder="placeholder"
list-type="picture-card"
mode="tags"
style="width: 100%"
/>
</template>
<style lang="less" scoped></style>

View File

@@ -0,0 +1,143 @@
// Shared picker layout mixins — property-only dark variants, no global class selectors.
@mixin picker-card-base {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 6px;
border: 1px solid #e5e6eb;
border-radius: 8px;
background: #fff;
cursor: pointer;
text-align: center;
min-width: 0;
transition:
border-color 0.2s,
background-color 0.2s;
&:hover,
&.active {
border-color: #165dff;
background: #f2f7ff;
}
}
@mixin picker-card-name {
width: 100%;
font-size: 12px;
color: #1d2129;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin picker-card-sub {
font-size: 11px;
color: #86909c;
line-height: 1.2;
}
@mixin picker-selected-card {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
border: 1px solid #e5e6eb;
border-radius: 8px;
background: #fafafa;
cursor: pointer;
transition:
border-color 0.2s,
background-color 0.2s;
&:hover {
border-color: #165dff;
background: #f2f7ff;
}
}
@mixin picker-trigger-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 40px;
padding: 8px 12px;
border: 1px dashed #d9d9d9;
border-radius: 8px;
color: #86909c;
font-size: 14px;
cursor: pointer;
transition:
border-color 0.2s,
color 0.2s;
&:hover {
border-color: #165dff;
color: #165dff;
}
}
@mixin picker-clear-btn {
pointer-events: auto;
cursor: pointer;
color: #86909c;
font-size: 16px;
line-height: 1;
padding: 0 4px;
flex-shrink: 0;
&:hover {
color: #1d2129;
}
}
@mixin picker-card-dark-props {
background: #1f2937;
border-color: #374151;
&:hover,
&.active {
background: #374151;
border-color: #60a5fa;
}
}
@mixin picker-selected-card-dark-props {
background: #1f2937;
border-color: #374151;
&:hover {
background: #374151;
border-color: #60a5fa;
}
}
@mixin picker-text-primary-dark {
color: #f3f4f6;
}
@mixin picker-text-secondary-dark {
color: #9ca3af;
}
@mixin picker-placeholder-dark-props {
border-color: #4b5563;
color: #9ca3af;
&:hover {
border-color: #60a5fa;
color: #60a5fa;
}
}
@mixin picker-clear-dark-props {
color: #9ca3af;
&:hover {
color: #f3f4f6;
}
}

View File

@@ -0,0 +1,270 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useVModel } from '@vueuse/core';
import { Avatar, Empty, Input, Popover, Spin, Tag } from 'ant-design-vue';
import PromoterInfoCard from '#/components/promoter/PromoterInfoCard.vue';
import { getPromoterOptions } from '#/views/system/store-input/api';
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
defineOptions({
name: 'PromoterPicker',
inheritAttrs: false,
});
const props = defineProps<{
value?: string;
disabled?: boolean;
}>();
const emits = defineEmits<{
'update:value': [value: string | undefined];
}>();
const mValue = useVModel(props, 'value', emits, { passive: true });
type PromoterOption = {
id: number;
nick_name: string;
avatar: string;
code: string;
phone: string;
role_id: number;
role_name: string;
};
const open = ref(false);
const loading = ref(false);
const searchKeyword = ref('');
const options = ref<PromoterOption[]>([]);
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
function filterPromoterOptions(keyword: string, list: PromoterOption[]) {
const q = keyword.trim().toLowerCase();
if (!q) return list;
return list.filter((item) => {
const name = (item.nick_name || '').toLowerCase();
const code = (item.code || '').toLowerCase();
const phone = (item.phone || '').toLowerCase();
const role = (item.role_name || '').toLowerCase();
return (
name.includes(q) ||
code.includes(q) ||
phone.includes(q) ||
role.includes(q)
);
});
}
const filteredOptions = computed(() =>
filterPromoterOptions(searchKeyword.value, options.value),
);
const selectedOption = computed(() =>
options.value.find((item) => item.code === mValue.value),
);
async function loadOptions() {
loading.value = true;
try {
options.value = (await getPromoterOptions()) || [];
if (options.value.length === 1 && !mValue.value) {
mValue.value = options.value[0].code;
}
} finally {
loading.value = false;
}
}
function selectOption(item: PromoterOption) {
mValue.value = item.code;
open.value = false;
searchKeyword.value = '';
}
function clearSelection() {
mValue.value = undefined;
}
function onOpenChange(next: boolean) {
if (props.disabled) return;
open.value = next;
if (next) {
searchKeyword.value = '';
if (!options.value.length) {
loadOptions();
}
nextTick(() => searchInputRef.value?.focus?.());
}
}
watch(
() => props.disabled,
(disabled) => {
if (disabled) open.value = false;
},
);
onMounted(() => {
loadOptions();
});
</script>
<template>
<Popover
:open="open"
trigger="click"
placement="bottomLeft"
overlay-class-name="promoter-picker-popover"
@open-change="onOpenChange"
>
<template #content>
<div class="promoter-panel">
<Input
ref="searchInputRef"
v-model:value="searchKeyword"
allow-clear
placeholder="搜索业务员姓名、业务码、手机号"
class="promoter-search"
/>
<Spin :spinning="loading">
<div v-if="filteredOptions.length" class="promoter-grid">
<button
v-for="item in filteredOptions"
:key="item.id"
type="button"
class="promoter-card"
:class="{ active: item.code === mValue }"
@click="selectOption(item)"
>
<Avatar :src="resolveAvatarUrl(item.avatar)" :size="30">
{{ (item.nick_name || '?').charAt(0) }}
</Avatar>
<div class="promoter-card-name" :title="item.nick_name">
{{ item.nick_name || '-' }}
</div>
<div class="promoter-card-sub">{{ item.code }}</div>
<div v-if="item.phone" class="promoter-card-phone">
<SensitiveText :record="item" field="phone" :show-eye="false" />
</div>
<Tag class="promoter-card-role" :bordered="false">
{{ item.role_name || '业务员' }}
</Tag>
</button>
</div>
<Empty v-else description="无匹配业务员" class="promoter-empty" />
</Spin>
</div>
</template>
<div class="promoter-trigger" :class="{ disabled: disabled }">
<PromoterInfoCard
v-if="selectedOption"
:admin="selectedOption"
size="trigger"
:show-clear="!disabled"
:disabled="disabled"
:clickable="false"
@clear="clearSelection"
/>
<div v-else class="promoter-trigger-placeholder">请选择业务员</div>
</div>
</Popover>
</template>
<style scoped lang="scss">
@use './picker-card-theme.scss' as theme;
.promoter-trigger {
width: 100%;
}
.promoter-trigger.disabled {
cursor: not-allowed;
opacity: 0.65;
}
.promoter-trigger:not(.disabled) {
cursor: pointer;
}
.promoter-trigger-placeholder {
@include theme.picker-trigger-placeholder;
}
.dark {
.promoter-trigger-placeholder {
@include theme.picker-placeholder-dark-props;
}
}
</style>
<style lang="scss">
@use './picker-card-theme.scss' as theme;
.promoter-picker-popover {
.ant-popover-inner {
padding: 12px;
}
.promoter-panel {
width: 540px;
max-width: 86vw;
}
.promoter-search {
margin-bottom: 10px;
}
.promoter-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
gap: 8px;
max-height: 300px;
overflow-y: auto;
padding: 2px;
}
.promoter-card {
@include theme.picker-card-base;
}
.promoter-card-name {
@include theme.picker-card-name;
}
.promoter-card-sub,
.promoter-card-phone {
@include theme.picker-card-sub;
}
.promoter-card-role {
margin: 0;
font-size: 11px;
line-height: 18px;
padding: 0 6px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.promoter-empty {
margin: 16px 0;
}
}
.dark .promoter-picker-popover {
.promoter-card {
@include theme.picker-card-dark-props;
}
.promoter-card-name {
@include theme.picker-text-primary-dark;
}
.promoter-card-sub,
.promoter-card-phone {
@include theme.picker-text-secondary-dark;
}
}
</style>

View File

@@ -0,0 +1,709 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useVModel } from '@vueuse/core';
import { Input, Popover } from 'ant-design-vue';
import type {
AddressListRow,
AddressNode,
AddressSearchHit,
AddressValue,
BreadcrumbItem,
RegionLevel,
SelectionContext,
} from '#/util/address-index';
import {
applySearchHit,
buildAddressOutput,
buildBreadcrumbFromContext,
canCompleteAfterSelect,
formatAddressDisplay,
getInitialPickerLevel,
getLevelSearchPlaceholder,
getNextLevel,
isMunicipalityProvince,
levelOptionsToRows,
resolveAddressValue,
rowHasNextLevel,
searchAddressHits,
searchHitsToRows,
truncateContextToBreadcrumb,
} from '#/util/address-index';
defineOptions({
name: 'RegionAddressPicker',
inheritAttrs: false,
});
const props = defineProps<{
value?: AddressValue | null;
disabled?: boolean;
}>();
const emits = defineEmits<{
'update:value': [value: AddressValue | undefined];
}>();
const mValue = useVModel(props, 'value', emits, {
passive: true,
});
const open = ref(false);
const searchKeyword = ref('');
const currentLevel = ref<RegionLevel>('province');
const activeIndex = ref(0);
const listRef = ref<HTMLElement | null>(null);
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
const triggerWrapRef = ref<HTMLElement | null>(null);
const ctx = ref<SelectionContext>({
provinceId: undefined,
cityId: undefined,
districtId: undefined,
});
const displayText = computed(() => formatAddressDisplay(mValue.value));
const breadcrumbItems = computed(() => buildBreadcrumbFromContext(ctx.value));
const isSearching = computed(() => !!searchKeyword.value.trim());
const listRows = computed((): AddressListRow[] => {
if (isSearching.value) {
return searchHitsToRows(
searchAddressHits(searchKeyword.value, ctx.value),
);
}
return levelOptionsToRows(currentLevel.value, ctx.value);
});
const searchPlaceholder = computed(() =>
getLevelSearchPlaceholder(currentLevel.value),
);
function syncContextFromValue(val?: AddressValue | null) {
const resolved = resolveAddressValue(val ?? undefined);
ctx.value = {
provinceId: resolved.provinceId,
cityId: resolved.cityId,
districtId: resolved.districtId,
};
}
function resetPickerToValue(val?: AddressValue | null) {
syncContextFromValue(val);
const resolved = resolveAddressValue(val ?? undefined);
currentLevel.value = getInitialPickerLevel(resolved);
searchKeyword.value = '';
activeIndex.value = 0;
}
function resetActiveIndex() {
activeIndex.value = listRows.value.length > 0 ? 0 : -1;
}
function closePanel(output?: AddressValue) {
if (output !== undefined) {
mValue.value = output;
}
open.value = false;
searchKeyword.value = '';
}
function completeSelection() {
closePanel(
buildAddressOutput(
ctx.value.provinceId,
ctx.value.cityId,
ctx.value.districtId,
),
);
}
function applySelectionAtLevel(level: RegionLevel, item: AddressNode) {
if (level === 'province') {
ctx.value = {
provinceId: item.value,
cityId: undefined,
districtId: undefined,
};
} else if (level === 'city') {
ctx.value = {
...ctx.value,
cityId: item.value,
districtId: undefined,
};
} else {
ctx.value = {
...ctx.value,
districtId: item.value,
};
}
}
function searchHitFromRow(row: AddressListRow): AddressSearchHit {
return {
level: row.level,
node: row.node,
pathLabel: row.label,
complete: row.complete,
};
}
function advanceAfterRowSelect(row: AddressListRow) {
searchKeyword.value = '';
if (row.complete) {
completeSelection();
return;
}
const next = getNextLevel(row.level, ctx.value);
if (next) {
currentLevel.value = next;
return;
}
completeSelection();
}
function onConfirmRow(row: AddressListRow) {
if (isSearching.value) {
const hit = searchHitFromRow(row);
if (
hit.level === 'province' &&
(ctx.value.cityId || ctx.value.districtId)
) {
ctx.value = applySearchHit(hit, ctx.value);
currentLevel.value = isMunicipalityProvince(hit.node.value)
? 'district'
: 'city';
searchKeyword.value = '';
resetActiveIndex();
return;
}
ctx.value = applySearchHit(hit, ctx.value);
advanceAfterRowSelect(row);
return;
}
onSelectItem(row.node, row.level);
}
function onSelectItem(item: AddressNode, level = currentLevel.value) {
if (level === 'province' && (ctx.value.cityId || ctx.value.districtId)) {
ctx.value = {
provinceId: item.value,
cityId: undefined,
districtId: undefined,
};
searchKeyword.value = '';
currentLevel.value = isMunicipalityProvince(item.value)
? 'district'
: 'city';
resetActiveIndex();
return;
}
applySelectionAtLevel(level, item);
const row: AddressListRow = {
level,
node: item,
label: item.label,
complete: canCompleteAfterSelect(level, ctx.value, item),
};
advanceAfterRowSelect(row);
}
function confirmActiveItem() {
const rows = listRows.value;
if (activeIndex.value < 0 || activeIndex.value >= rows.length) {
return;
}
onConfirmRow(rows[activeIndex.value]!);
}
function drillActiveRow() {
const rows = listRows.value;
const row = rows[activeIndex.value];
if (!row || !rowHasNextLevel(row, ctx.value)) {
return;
}
if (isSearching.value) {
const hit = searchHitFromRow(row);
if (
hit.level === 'province' &&
(ctx.value.cityId || ctx.value.districtId)
) {
ctx.value = applySearchHit(hit, ctx.value);
} else {
ctx.value = applySearchHit(hit, ctx.value);
}
searchKeyword.value = '';
} else {
applySelectionAtLevel(row.level, row.node);
}
const next = getNextLevel(row.level, ctx.value);
if (next) {
currentLevel.value = next;
resetActiveIndex();
}
}
function goBackOneLevel() {
if (currentLevel.value === 'province') {
return;
}
if (currentLevel.value === 'district') {
if (isMunicipalityProvince(ctx.value.provinceId)) {
ctx.value = { provinceId: ctx.value.provinceId };
currentLevel.value = 'province';
} else {
ctx.value = {
provinceId: ctx.value.provinceId,
cityId: ctx.value.cityId,
};
currentLevel.value = 'city';
}
} else if (currentLevel.value === 'city') {
ctx.value = {};
currentLevel.value = 'province';
}
searchKeyword.value = '';
resetActiveIndex();
}
function scrollActiveIntoView() {
nextTick(() => {
const el = listRef.value?.querySelector(
`[data-index="${activeIndex.value}"]`,
);
el?.scrollIntoView({ block: 'nearest' });
});
}
function getPopupContainer(triggerNode: HTMLElement): HTMLElement {
return (
(triggerNode.closest('[role="dialog"]') as HTMLElement | null) ??
document.body
);
}
function resolveSearchInputEl(): HTMLInputElement | null {
const inst = searchInputRef.value as
| (InstanceType<typeof Input> & { input?: HTMLInputElement })
| null;
if (inst?.input instanceof HTMLInputElement) {
return inst.input;
}
const scope =
(triggerWrapRef.value?.closest('[role="dialog"]') as HTMLElement | null) ??
document.body;
return scope.querySelector(
'.region-address-picker-popover .region-address-search input',
);
}
function focusSearchInput() {
const tryFocus = (): boolean => {
const inst = searchInputRef.value;
if (inst?.focus) {
inst.focus();
return true;
}
const input = resolveSearchInputEl();
if (input) {
input.focus();
return true;
}
return false;
};
const schedule = (attempt: number) => {
if (!open.value || attempt > 8) {
return;
}
if (tryFocus()) {
return;
}
requestAnimationFrame(() => {
if (tryFocus()) {
return;
}
const delay = attempt < 3 ? 0 : attempt < 6 ? 50 : 100;
setTimeout(() => schedule(attempt + 1), delay);
});
};
nextTick(() => schedule(0));
}
function handleKeydown(e: KeyboardEvent) {
if (!open.value) {
return;
}
const rows = listRows.value;
switch (e.key) {
case 'ArrowDown': {
if (rows.length === 0) {
return;
}
e.preventDefault();
if (activeIndex.value < 0) {
activeIndex.value = 0;
} else {
activeIndex.value = (activeIndex.value + 1) % rows.length;
}
scrollActiveIntoView();
break;
}
case 'ArrowUp': {
if (rows.length === 0) {
return;
}
e.preventDefault();
activeIndex.value =
activeIndex.value <= 0 ? rows.length - 1 : activeIndex.value - 1;
scrollActiveIntoView();
break;
}
case 'Enter': {
if (rows.length === 0) {
return;
}
e.preventDefault();
confirmActiveItem();
break;
}
case 'ArrowLeft': {
e.preventDefault();
goBackOneLevel();
break;
}
case 'ArrowRight': {
if (rows.length === 0 || activeIndex.value < 0) {
return;
}
e.preventDefault();
drillActiveRow();
break;
}
default:
break;
}
}
function onBreadcrumbClick(item: BreadcrumbItem) {
ctx.value = truncateContextToBreadcrumb(ctx.value, item);
if (item.level === 'province') {
currentLevel.value = isMunicipalityProvince(item.id) ? 'district' : 'city';
} else if (item.level === 'city') {
currentLevel.value = 'district';
} else {
currentLevel.value = 'district';
}
searchKeyword.value = '';
resetActiveIndex();
}
function goProvinceLevel() {
ctx.value = {};
currentLevel.value = 'province';
searchKeyword.value = '';
resetActiveIndex();
}
function onClear() {
ctx.value = {};
mValue.value = undefined;
currentLevel.value = 'province';
searchKeyword.value = '';
resetActiveIndex();
}
function onOpenChange(v: boolean) {
open.value = v;
if (v) {
resetPickerToValue(mValue.value);
}
}
watch(open, (v) => {
if (v) {
focusSearchInput();
}
}, { flush: 'post' });
watch(listRows, () => {
if (open.value) {
resetActiveIndex();
}
});
watch(
() => props.value,
(val) => {
if (!open.value) {
syncContextFromValue(val ?? undefined);
}
},
{ immediate: true, deep: true },
);
</script>
<template>
<Popover
v-model:open="open"
:trigger="['click']"
placement="bottomLeft"
overlay-class-name="region-address-picker-popover"
:get-popup-container="getPopupContainer"
@open-change="onOpenChange"
>
<template #content>
<div class="region-address-panel" @click.stop>
<div v-if="breadcrumbItems.length" class="region-address-breadcrumb-row">
<div class="region-address-breadcrumb">
<template
v-for="(item, idx) in breadcrumbItems"
:key="`${item.level}-${item.id}`"
>
<span v-if="idx > 0" class="region-address-breadcrumb-sep">/</span>
<button
type="button"
class="region-address-breadcrumb-item"
@click="onBreadcrumbClick(item)"
>
{{ item.label }}
</button>
</template>
</div>
<button
type="button"
class="region-address-repick-province"
@click="goProvinceLevel"
>
重选省份
</button>
</div>
<Input
ref="searchInputRef"
v-model:value="searchKeyword"
:placeholder="searchPlaceholder"
allow-clear
class="region-address-search"
@keydown="handleKeydown"
/>
<ul ref="listRef" class="region-address-list">
<li
v-for="(row, index) in listRows"
:key="`${row.level}-${row.node.value}`"
:class="[
'region-address-list-item',
{ 'region-address-list-item-active': index === activeIndex },
]"
:data-index="index"
@click="onConfirmRow(row)"
@mouseenter="activeIndex = index"
>
{{ row.label }}
</li>
<li v-if="listRows.length === 0" class="region-address-list-empty">
暂无数据
</li>
</ul>
</div>
</template>
<div
ref="triggerWrapRef"
class="region-address-trigger-wrap"
@click="!disabled && (open = true)"
>
<Input
:disabled="disabled"
:value="displayText"
class="region-address-trigger"
placeholder="请选择省市区"
readonly
>
<template v-if="displayText && !disabled" #suffix>
<span
class="region-address-clear"
@click.stop="onClear"
>×</span>
</template>
</Input>
</div>
</Popover>
</template>
<style scoped>
.region-address-trigger-wrap {
width: 100%;
}
.region-address-trigger {
width: 100%;
cursor: pointer;
}
.region-address-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: rgba(0, 0, 0, 0.35);
cursor: pointer;
font-size: 12px;
}
.region-address-clear:hover {
color: rgba(0, 0, 0, 0.65);
}
.region-address-panel {
width: 320px;
}
.region-address-breadcrumb-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.region-address-breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
flex: 1;
font-size: 13px;
}
.region-address-repick-province {
flex-shrink: 0;
padding: 0;
border: none;
background: none;
color: rgba(0, 0, 0, 0.45);
cursor: pointer;
font-size: 12px;
white-space: nowrap;
}
.region-address-repick-province:hover {
color: #1677ff;
}
.region-address-breadcrumb-sep {
color: rgba(0, 0, 0, 0.35);
margin: 0 4px;
}
.region-address-breadcrumb-item {
padding: 0;
border: none;
background: none;
color: #1677ff;
cursor: pointer;
font-size: 13px;
}
.region-address-breadcrumb-item:hover {
color: #4096ff;
}
.region-address-search {
margin-bottom: 8px;
}
.region-address-list {
margin: 0;
padding: 0;
max-height: 240px;
overflow-y: auto;
list-style: none;
}
.region-address-list-item {
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
transition: background 0.2s;
}
.region-address-list-item:hover {
background: rgba(0, 0, 0, 0.04);
}
.region-address-list-item-active {
background: #e6f4ff;
}
.region-address-list-item-active:hover {
background: #e6f4ff;
}
.region-address-list-empty {
padding: 16px 12px;
color: rgba(0, 0, 0, 0.45);
text-align: center;
}
/* 暗色模式适配(对齐 drug-search-select / menu-search-select */
.dark {
.region-address-clear {
color: rgba(255, 255, 255, 0.35);
}
.region-address-clear:hover {
color: rgba(255, 255, 255, 0.65);
}
.region-address-repick-province {
color: rgba(255, 255, 255, 0.45);
}
.region-address-repick-province:hover {
color: #60a5fa;
}
.region-address-breadcrumb-sep {
color: rgba(255, 255, 255, 0.35);
}
.region-address-breadcrumb-item {
color: #60a5fa;
}
.region-address-breadcrumb-item:hover {
color: #93c5fd;
}
.region-address-list-item {
color: #f3f4f6;
}
.region-address-list-item:hover {
background: #374151;
}
.region-address-list-item-active,
.region-address-list-item-active:hover {
background: #1e3a5f;
}
.region-address-list-empty {
color: #9ca3af;
}
}
</style>

View File

@@ -0,0 +1,317 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useVModel } from '@vueuse/core';
import { Avatar, Empty, Input, Popover, Spin } from 'ant-design-vue';
import { getStoreOption } from '#/views/system/store/api';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
defineOptions({
name: 'StorePicker',
inheritAttrs: false,
});
const props = defineProps<{
value?: number;
disabled?: boolean;
}>();
const emits = defineEmits<{
'update:value': [value: number | undefined];
}>();
const mValue = useVModel(props, 'value', emits, { passive: true });
type StoreOption = {
id: number;
name: string;
pic?: string;
mobile?: string;
};
const open = ref(false);
const loading = ref(false);
const searchKeyword = ref('');
const options = ref<StoreOption[]>([]);
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
/** 按关键词过滤诊所列表 */
function filterStoreOptions(keyword: string, list: StoreOption[]) {
const q = keyword.trim().toLowerCase();
if (!q) return list;
return list.filter((item) => {
const name = (item.name || '').toLowerCase();
const mobile = (item.mobile || '').toLowerCase();
const idText = String(item.id);
return name.includes(q) || mobile.includes(q) || idText.includes(q);
});
}
const filteredOptions = computed(() =>
filterStoreOptions(searchKeyword.value, options.value),
);
const selectedOption = computed(() =>
options.value.find((item) => item.id === mValue.value),
);
/** 加载诊所选项(含 pic、mobile 供卡片展示) */
async function loadOptions() {
loading.value = true;
try {
options.value = (await getStoreOption({})) || [];
} finally {
loading.value = false;
}
}
function selectOption(item: StoreOption) {
mValue.value = item.id;
open.value = false;
searchKeyword.value = '';
}
function clearSelection() {
mValue.value = undefined;
}
function onOpenChange(next: boolean) {
if (props.disabled) return;
open.value = next;
if (next) {
searchKeyword.value = '';
if (!options.value.length) {
loadOptions();
}
nextTick(() => searchInputRef.value?.focus?.());
}
}
watch(
() => props.disabled,
(disabled) => {
if (disabled) open.value = false;
},
);
onMounted(() => {
loadOptions();
});
</script>
<template>
<Popover
:open="open"
trigger="click"
placement="bottomLeft"
overlay-class-name="store-picker-popover"
@open-change="onOpenChange"
>
<template #content>
<div class="store-panel">
<Input
ref="searchInputRef"
v-model:value="searchKeyword"
allow-clear
placeholder="搜索诊所名称、电话、ID"
class="store-search"
/>
<Spin :spinning="loading">
<div v-if="filteredOptions.length" class="store-grid">
<button
v-for="item in filteredOptions"
:key="item.id"
type="button"
class="store-card"
:class="{ active: item.id === mValue }"
@click="selectOption(item)"
>
<Avatar :src="resolveAvatarUrl(item.pic)" :size="36" shape="square">
{{ (item.name || '?').charAt(0) }}
</Avatar>
<div class="store-card-name" :title="item.name">
{{ item.name || '-' }}
</div>
<div v-if="item.mobile" class="store-card-sub">
<SensitiveText :record="item" field="mobile" :show-eye="false" />
</div>
<div class="store-card-sub">ID: {{ item.id }}</div>
</button>
</div>
<Empty v-else description="无匹配诊所" class="store-empty" />
</Spin>
</div>
</template>
<div class="store-trigger" :class="{ disabled: disabled }">
<div v-if="selectedOption" class="store-selected-card">
<Avatar :src="resolveAvatarUrl(selectedOption.pic)" :size="36" shape="square">
{{ (selectedOption.name || '?').charAt(0) }}
</Avatar>
<div class="store-selected-info">
<div class="store-selected-name">{{ selectedOption.name }}</div>
<div class="store-selected-sub">
<SensitiveText
v-if="selectedOption.mobile"
:record="selectedOption"
field="mobile"
:show-eye="false"
/>
<span v-else>ID: {{ selectedOption.id }}</span>
</div>
</div>
<span
v-if="!disabled"
class="store-clear-btn"
title="清除"
@click.stop="clearSelection"
>
×
</span>
</div>
<div v-else-if="mValue" class="store-selected-card">
<div class="store-selected-info">
<div class="store-selected-name">诊所 #{{ mValue }}</div>
</div>
<span
v-if="!disabled"
class="store-clear-btn"
title="清除"
@click.stop="clearSelection"
>
×
</span>
</div>
<div v-else class="store-trigger-placeholder">请选择诊所</div>
</div>
</Popover>
</template>
<style scoped lang="scss">
@use './picker-card-theme.scss' as theme;
.store-trigger {
width: 100%;
}
.store-trigger.disabled {
cursor: not-allowed;
opacity: 0.65;
}
.store-trigger:not(.disabled) {
cursor: pointer;
}
.store-selected-card {
@include theme.picker-selected-card;
}
.store-selected-info {
flex: 1;
min-width: 0;
}
.store-selected-name {
font-size: 14px;
color: #1d2129;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-selected-sub {
font-size: 12px;
color: #86909c;
margin-top: 2px;
}
.store-clear-btn {
@include theme.picker-clear-btn;
}
.store-trigger-placeholder {
@include theme.picker-trigger-placeholder;
}
.dark {
.store-selected-card {
@include theme.picker-selected-card-dark-props;
}
.store-selected-name {
@include theme.picker-text-primary-dark;
}
.store-selected-sub {
@include theme.picker-text-secondary-dark;
}
.store-clear-btn {
@include theme.picker-clear-dark-props;
}
.store-trigger-placeholder {
@include theme.picker-placeholder-dark-props;
}
}
</style>
<style lang="scss">
@use './picker-card-theme.scss' as theme;
.store-picker-popover {
.ant-popover-inner {
padding: 12px;
}
.store-panel {
width: 540px;
max-width: 86vw;
}
.store-search {
margin-bottom: 10px;
}
.store-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
gap: 8px;
max-height: 300px;
overflow-y: auto;
padding: 2px;
}
.store-card {
@include theme.picker-card-base;
}
.store-card-name {
@include theme.picker-card-name;
}
.store-card-sub {
@include theme.picker-card-sub;
}
.store-empty {
margin: 16px 0;
}
}
.dark .store-picker-popover {
.store-card {
@include theme.picker-card-dark-props;
}
.store-card-name {
@include theme.picker-text-primary-dark;
}
.store-card-sub {
@include theme.picker-text-secondary-dark;
}
}
</style>

View File

@@ -0,0 +1,375 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { Modal, Upload } from 'ant-design-vue';
import type { UploadFile } from 'ant-design-vue';
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
import {
beforeImageUpload,
resolveUploadFile,
} from '#/utils/use-image-upload-pending';
import { preferences } from '@vben/preferences';
import { Icon } from '#/components/icon';
defineOptions({
name: 'UploadImageSortable',
inheritAttrs: false,
});
// 定义props接口
interface Props {
modelValue: string[];
multiple?: boolean;
maxCount?: number;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
multiple: true,
maxCount: 9,
});
const emit = defineEmits<{
'update:modelValue': [value: string[]];
}>();
// 定义文件项接口
interface FileItem {
uid: string;
name: string;
status: 'uploading' | 'done' | 'error';
url: string;
}
const fileList = ref<FileItem[]>([]);
// 初始化fileList
const initFileList = () => {
fileList.value = props.modelValue.map((url, index) => ({
uid: `img-${index}-${url}`,
name: url.split('/').pop() || 'file',
status: 'done' as const,
url,
}));
};
// 初始化
initFileList();
const uploadListRef = ref<HTMLElement>();
let sortableInstance: any = null;
// 监听外部modelValue变化同步到fileList与upload-image.vue保持一致
watch(() => props.modelValue, (newVal) => {
const currentUrls = fileList.value.filter(file => file.status === 'done').map(file => file.url);
if (JSON.stringify(newVal) !== JSON.stringify(currentUrls)) {
fileList.value = newVal.map((url, index) => ({
uid: `img-${index}-${url}`,
name: url.split('/').pop() || 'file',
status: 'done' as const,
url,
}));
}
nextTick(() => {
initSortable();
});
}, { deep: true, immediate: true });
// 统一通过updateModelValue函数更新modelValue与upload-image.vue保持一致
const updateModelValue = () => {
const urls = fileList.value
.filter((file) => file.status === 'done' && file.url)
.map((file) => file.url);
emit('update:modelValue', urls);
};
// 初始化拖拽排序使用原生HTML5拖拽API
const initSortable = () => {
if (!uploadListRef.value) return;
// 销毁旧的排序实例
if (sortableInstance) {
sortableInstance = null;
}
// 查找上传列表容器
const uploadList = uploadListRef.value.querySelector(
'.ant-upload-list-picture-card',
) as HTMLElement;
if (!uploadList) return;
const items = uploadList.querySelectorAll(
'.ant-upload-list-item',
) as NodeListOf<HTMLElement>;
items.forEach((item, index) => {
item.draggable = true;
item.style.cursor = 'move';
item.addEventListener('dragstart', (e) => {
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/html', index.toString());
item.style.opacity = '0.5';
});
item.addEventListener('dragend', () => {
item.style.opacity = '1';
});
item.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer!.dropEffect = 'move';
});
item.addEventListener('drop', (e) => {
e.preventDefault();
const dragIndex = Number.parseInt(e.dataTransfer!.getData('text/html'));
const dropIndex = index;
if (dragIndex !== dropIndex) {
const movedItem = fileList.value.splice(dragIndex, 1)[0];
fileList.value.splice(dropIndex, 0, movedItem);
// 拖拽排序后更新modelValue
updateModelValue();
}
});
});
};
/**
* 自定义上传请求处理函数
*
* 该函数根据preferences配置的上传方式选择使用OSS直传或后端上传
* - 'direct': OSS直传模式文件直接从浏览器上传到阿里云OSS
* - 'backend': 后端上传模式文件先上传到后端服务器再由后端上传到OSS
*
* 两种上传方式的区别:
* 1. OSS直传direct
* - 优点:减少服务器负载,上传速度更快,用户体验更好
* - 缺点:需要后端提供签名接口,配置相对复杂
* - 实现调用uploadToOss函数直接上传到OSS
*
* 2. 后端上传backend
* - 优点:兼容现有功能,所有上传逻辑由后端统一处理
* - 缺点:增加服务器负载,上传速度相对较慢
* - 实现调用uploadFile API通过后端服务器上传
*
* 兼容性处理:
* - 两种上传方式返回的数据结构保持一致:{ url: string }
* - 确保无论使用哪种方式,组件的行为都是一致的
* - 上传成功后,会重新初始化拖拽排序功能
* - 如果上传失败会更新文件状态为error并调用onError回调
*
* @param options 上传选项包含file、onProgress、onSuccess、onError等
*/
const customRequest = async (options: any) => {
const { file, onProgress, onSuccess, onError } = options;
const actualFile = resolveUploadFile(file as File);
// 创建上传中的文件项用于在UI中显示上传状态
const uploadingFile: FileItem = {
uid: file.uid,
name: file.name,
status: 'uploading',
url: '',
};
// 将上传中的文件添加到文件列表
fileList.value = [...fileList.value, uploadingFile];
try {
// 从preferences中读取上传方式配置
// uploadMethod可能的值'direct'OSS直传或 'backend'(后端上传)
const uploadMethod = preferences.app.uploadMethod || 'direct';
let res: { url: string };
// 根据配置选择上传方式
if (uploadMethod === 'direct') {
// OSS直传模式文件直接从浏览器上传到OSS
// uploadToOss函数会
// 1. 从后端获取OSS签名信息
// 2. 生成全局唯一的文件名UUID + 时间戳)
// 3. 使用FormData构造POST请求直接上传到OSS
// 4. 处理上传进度和错误
// 5. 返回上传后的文件URL
res = await uploadToOss({
file: actualFile,
onProgress: (percent) => {
// 将OSS上传进度传递给组件
// percent范围0-100表示上传百分比
if (onProgress) {
onProgress(percent);
}
},
});
} else {
// 后端上传模式文件先上传到后端服务器再由后端上传到OSS
// uploadFile函数会
// 1. 将文件发送到后端API/upload/image
// 2. 后端接收文件后上传到OSS
// 3. 返回上传后的文件URL
res = await uploadFile({
file: actualFile,
});
}
// 上传成功,更新文件状态为完成
// 将上传结果中的URL赋值给文件项
const updatedFileList = fileList.value.map(item =>
item.uid === file.uid
? { ...item, status: 'done' as const, url: res.url }
: item
);
fileList.value = updatedFileList;
// 更新组件的modelValue触发父组件的更新
updateModelValue();
// 重新初始化拖拽排序功能
// 因为文件列表发生了变化,需要重新绑定拖拽事件
nextTick(() => {
initSortable();
});
// 调用成功回调,通知上传组件上传已完成
onSuccess(res);
} catch (error) {
// 上传失败,记录错误信息
console.error('上传失败', error);
// 更新文件状态为错误
// 错误状态的文件会在UI中显示错误图标
const updatedFileList = fileList.value.map(item =>
item.uid === file.uid
? { ...item, status: 'error' as const }
: item
);
fileList.value = updatedFileList;
// 调用错误回调,通知上传组件上传失败
onError(error);
}
};
// 处理文件删除与upload-image.vue保持一致
const handleRemove = (file: UploadFile) => {
fileList.value = fileList.value.filter((item) => item.uid !== file.uid);
updateModelValue();
// 重新初始化排序
nextTick(() => {
initSortable();
});
};
// 预览功能与upload-image.vue保持一致
const previewVisible = ref(false);
const previewImage = ref('');
const previewTitle = ref('');
const handlePreview = async (file: UploadFile) => {
previewImage.value = file.url || '';
previewVisible.value = true;
previewTitle.value = file.name || file.url?.split('/').pop() || '';
};
function onGallerySelect(urls: string[]) {
const remain = props.maxCount - fileList.value.length;
const picked = props.multiple ? urls.slice(0, remain) : urls.slice(0, 1);
for (const url of picked) {
if (fileList.value.some((f) => f.url === url)) {
continue;
}
fileList.value = [
...fileList.value,
{
uid: `gallery-${url}`,
name: url.split('/').pop() || 'file',
status: 'done' as const,
url,
},
];
}
updateModelValue();
nextTick(() => {
initSortable();
});
}
// 计算是否显示上传按钮与upload-image.vue保持一致
const showUploadButton = computed(() =>
props.multiple
? fileList.value.length < props.maxCount
: fileList.value.length === 0,
);
const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.value.length, 0));
onMounted(() => {
nextTick(() => {
initSortable();
});
});
</script>
<template>
<div ref="uploadListRef" class="upload-image-wrap">
<Upload
:file-list="fileList"
:before-upload="beforeImageUpload"
:custom-request="customRequest"
:multiple="multiple"
:max-count="maxCount"
:show-upload-list="{
showPreviewIcon: true,
showRemoveIcon: true,
}"
list-type="picture-card"
@remove="handleRemove"
@preview="handlePreview"
accept="image/*"
>
<div v-if="showUploadButton" class="upload-button">
<Icon icon="ant-design:plus-outlined" />
<div class="ant-upload-text">上传图片</div>
</div>
</Upload>
<GalleryPickLink
v-if="showUploadButton"
:multiple="multiple"
:max-count="galleryRemainCount"
@select="onGallerySelect"
/>
<Modal
v-model:visible="previewVisible"
:title="previewTitle"
footer=""
width="60%"
>
<img alt="预览图片" style="width: 100%" :src="previewImage" />
</Modal>
</div>
</template>
<style lang="less" scoped>
.upload-button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
cursor: pointer;
}
:deep(.ant-upload-list-picture-card .ant-upload-list-item) {
cursor: move;
}
</style>

View File

@@ -0,0 +1,211 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Modal, Upload } from 'ant-design-vue';
import type { UploadFile } from 'ant-design-vue';
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
import {
beforeImageUpload,
resolveUploadFile,
} from '#/utils/use-image-upload-pending';
import { preferences } from '@vben/preferences';
import { Icon } from '#/components/icon';
interface FileItem {
uid: string;
name: string;
status: 'uploading' | 'done' | 'error';
url: string;
}
interface Props {
modelValue: string[];
multiple?: boolean;
maxCount?: number;
/**
* 素材库可选类型(透传 GalleryPickLink默认仅图片
*/
acceptTypes?: number[];
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
multiple: true,
maxCount: 9,
acceptTypes: () => [1],
});
const emit = defineEmits<{
'update:modelValue': [value: string[]];
}>();
const fileList = ref<FileItem[]>([]);
const initFileList = () => {
fileList.value = props.modelValue.map((url) => ({
uid: url,
name: url.split('/').pop() || 'file',
status: 'done' as const,
url,
}));
};
initFileList();
watch(
() => props.modelValue,
(newVal) => {
const currentUrls = fileList.value
.filter((file) => file.status === 'done')
.map((file) => file.url);
if (JSON.stringify(newVal) !== JSON.stringify(currentUrls)) {
fileList.value = newVal.map((url) => ({
uid: url,
name: url.split('/').pop() || 'file',
status: 'done' as const,
url,
}));
}
},
{ deep: true },
);
const updateModelValue = () => {
const urls = fileList.value
.filter((file) => file.status === 'done' && file.url)
.map((file) => file.url);
emit('update:modelValue', urls);
};
const customRequest = async (options: any) => {
const { file, onProgress, onSuccess, onError } = options;
const actualFile = resolveUploadFile(file as File);
const uploadingFile: FileItem = {
uid: file.uid,
name: file.name,
status: 'uploading',
url: '',
};
fileList.value = [...fileList.value, uploadingFile];
try {
const uploadMethod = preferences.app.uploadMethod || 'direct';
const res =
uploadMethod === 'direct'
? await uploadToOss({
file: actualFile,
onProgress: (percent) => {
if (onProgress) {
onProgress(percent);
}
},
})
: await uploadFile({ file: actualFile });
fileList.value = fileList.value.map((item) =>
item.uid === file.uid ? { ...item, status: 'done' as const, url: res.url } : item,
);
updateModelValue();
onSuccess(res);
} catch (error) {
console.error('上传失败', error);
fileList.value = fileList.value.map((item) =>
item.uid === file.uid ? { ...item, status: 'error' as const } : item,
);
onError(error);
}
};
const handleRemove = (file: UploadFile) => {
fileList.value = fileList.value.filter((item) => item.uid !== file.uid);
updateModelValue();
};
const previewVisible = ref(false);
const previewImage = ref('');
const previewTitle = ref('');
function onGallerySelect(urls: string[]) {
const remain = props.maxCount - fileList.value.length;
const picked = props.multiple ? urls.slice(0, remain) : urls.slice(0, 1);
for (const url of picked) {
if (fileList.value.some((f) => f.url === url)) {
continue;
}
fileList.value = [
...fileList.value,
{
uid: url,
name: url.split('/').pop() || 'file',
status: 'done' as const,
url,
},
];
}
updateModelValue();
}
const handlePreview = async (file: UploadFile) => {
previewImage.value = file.url || '';
previewVisible.value = true;
previewTitle.value = file.name || file.url?.split('/').pop() || '';
};
const showUploadButton = computed(() =>
props.multiple ? fileList.value.length < props.maxCount : fileList.value.length === 0,
);
const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.value.length, 0));
</script>
<template>
<div class="upload-image-wrap">
<Upload
:file-list="fileList"
:before-upload="beforeImageUpload"
:custom-request="customRequest"
:multiple="multiple"
:max-count="maxCount"
:show-upload-list="{
showPreviewIcon: true,
showRemoveIcon: true,
}"
list-type="picture-card"
accept="image/*"
@remove="handleRemove"
@preview="handlePreview"
>
<div v-if="showUploadButton" class="upload-button">
<Icon icon="ant-design:plus-outlined" />
<div class="ant-upload-text">上传图片</div>
</div>
</Upload>
<GalleryPickLink
v-if="showUploadButton"
:multiple="multiple"
:max-count="galleryRemainCount"
:accept-types="acceptTypes"
@select="onGallerySelect"
/>
<Modal v-model:visible="previewVisible" :title="previewTitle" footer="" width="60%">
<img alt="预览图片" style="width: 100%" :src="previewImage" />
</Modal>
</div>
</template>
<style lang="less" scoped>
.upload-button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
/**
* OSS 文件上传(单文件 URL 字符串)
* 可选从素材库选择acceptTypes 过滤,如文件=6、语音=3
*/
import { computed, ref, watch } from 'vue';
import { preferences } from '@vben/preferences';
import { Button, Upload } from 'ant-design-vue';
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
import { Icon } from '#/components/icon';
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
interface FileItem {
uid: string;
name: string;
status: 'uploading' | 'done' | 'error';
url: string;
}
interface Props {
modelValue?: string;
maxCount?: number;
/** 素材库类型过滤,如 [6] 文件、[3] 语音 */
acceptTypes?: number[];
/** input accept如 .pdf,.doc 或 audio/* */
accept?: string;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
maxCount: 1,
acceptTypes: () => [6],
accept: '.pdf,.doc,.docx,.zip,.rar,.txt,application/pdf,audio/*,*/*',
});
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const fileList = ref<FileItem[]>([]);
function syncFileListFromValue(url: string) {
if (!url) {
fileList.value = [];
return;
}
fileList.value = [
{
uid: url,
name: url.split('/').pop() || 'file',
status: 'done',
url,
},
];
}
syncFileListFromValue(props.modelValue);
watch(
() => props.modelValue,
(newVal) => {
const currentUrl =
fileList.value.find((file) => file.status === 'done')?.url ?? '';
if ((newVal ?? '') !== currentUrl) {
syncFileListFromValue(newVal ?? '');
}
},
);
function updateModelValue() {
const url =
fileList.value.find((file) => file.status === 'done' && file.url)?.url ??
'';
emit('update:modelValue', url);
}
function beforeUpload(file: File) {
const maxSize = 20 * 1024 * 1024;
if (file.size > maxSize) {
return Upload.LIST_IGNORE;
}
return true;
}
const customRequest = async (options: any) => {
const { file, onProgress, onSuccess, onError } = options;
fileList.value = [
{
uid: file.uid,
name: file.name,
status: 'uploading',
url: '',
},
];
try {
const uploadMethod = preferences.app.uploadMethod || 'direct';
let res: { url: string };
if (uploadMethod === 'direct') {
res = await uploadToOss({
file: file as File,
onProgress: (percent) => {
onProgress?.({ percent });
},
});
} else {
res = await uploadFile({
file: file as File,
});
}
fileList.value = [
{
uid: file.uid,
name: file.name,
status: 'done',
url: res.url,
},
];
updateModelValue();
onSuccess?.(res);
} catch (error) {
fileList.value = [
{
uid: file.uid,
name: file.name,
status: 'error',
url: '',
},
];
onError?.(error);
}
};
function handleRemove() {
fileList.value = [];
updateModelValue();
return true;
}
function onGallerySelect(urls: string[]) {
const url = urls[0] || '';
if (!url) return;
syncFileListFromValue(url);
updateModelValue();
}
const showUploadButton = computed(
() => fileList.value.filter((item) => item.status !== 'error').length === 0,
);
</script>
<template>
<div class="upload-oss-file-wrap">
<Upload
:file-list="fileList"
:before-upload="beforeUpload"
:custom-request="customRequest"
:max-count="maxCount"
list-type="text"
:accept="accept"
@remove="handleRemove"
>
<Button v-if="showUploadButton">
上传文件
<template #icon>
<Icon icon="ant-design:cloud-upload-outlined" />
</template>
</Button>
</Upload>
<GalleryPickLink
v-if="showUploadButton"
:max-count="1"
:accept-types="acceptTypes"
@select="onGallerySelect"
/>
</div>
</template>
<style scoped>
.upload-oss-file-wrap {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
</style>

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
/**
* 通用 Tag 选择器(带头像)
*
* - 基于 antd Selectmultiple 模式默认开启,单选传 multiple=false
* - 已选项以 Tag 形式展示头像+名字:
* - 有 avatar 用图片头像
* - 无 avatar 用文本头像(取 label 首字符)
* - 下拉选项同样展示头像+名字
* - 选项数据options: { value, label, avatar? }[]
*/
import { computed } from 'vue';
import { useVModel } from '@vueuse/core';
import { Avatar, Select, Tag } from 'ant-design-vue';
defineOptions({
name: 'UserTagSelect',
inheritAttrs: false,
});
/** 单个选项结构 */
export interface UserTagOption {
value: string | number;
label: string;
avatar?: string;
}
const props = withDefaults(
defineProps<{
/** 已选值数组(多选)或单值(单选时仍用数组包装) */
modelValue?: (string | number)[];
/** 选项列表 */
options: UserTagOption[];
/** 是否多选,默认 true */
multiple?: boolean;
/** 最大选择数,超出截断 */
maxCount?: number;
placeholder?: string;
allowClear?: boolean;
showSearch?: boolean;
disabled?: boolean;
}>(),
{
modelValue: () => [],
multiple: true,
maxCount: 99,
placeholder: '请选择',
allowClear: true,
showSearch: true,
disabled: false,
},
);
const emit = defineEmits<{
'update:modelValue': [v: (string | number)[]];
}>();
// 双向绑定useVModel 自动处理 modelValue 的读写
const innerValue = useVModel(props, 'modelValue', emit, {
defaultValue: [] as (string | number)[],
passive: true,
});
/** 用于 Select 的 mode单选时不传 mode */
const selectMode = computed(() => (props.multiple ? 'multiple' : undefined));
/** 按 value 索引选项,方便 tagRender/option 插槽快速查头像与名字 */
const optionMap = computed(() => {
const map = new Map<string | number, UserTagOption>();
for (const opt of props.options) {
map.set(opt.value, opt);
}
return map;
});
/** 取某 value 对应的展示名 */
function labelOf(value: string | number) {
return optionMap.value.get(value)?.label || String(value);
}
/** 取某 value 对应的头像 */
function avatarOf(value: string | number) {
return optionMap.value.get(value)?.avatar;
}
/** 取首字符(文本头像) */
function firstChar(label?: string) {
return (label || '?').trim().charAt(0) || '?';
}
/** 选项过滤:按 label 包含输入关键字 */
function filterOption(input: string, option: any) {
const v = String(option?.label || '').toLowerCase();
return v.includes((input || '').toLowerCase());
}
/**
* change 处理:
* - 单选:把单值包成数组
* - 多选:超 maxCount 截断
*/
function onChange(v: any) {
if (!props.multiple) {
innerValue.value = v == null ? [] : [v];
return;
}
if (Array.isArray(v) && v.length > props.maxCount) {
v = v.slice(0, props.maxCount);
}
innerValue.value = v;
}
</script>
<template>
<Select
:value="innerValue as any"
:mode="selectMode"
:options="options"
:placeholder="placeholder"
:allow-clear="allowClear"
:show-search="showSearch"
:filter-option="filterOption"
:disabled="disabled"
:max-tag-count="10"
option-filter-prop="label"
style="width: 100%"
@change="onChange"
>
<!-- 多选已选项展示头像+名字 -->
<template v-if="multiple" #tagRender="{ value, closable, onClose }">
<Tag :closable="closable" @close="onClose" class="user-tag-chip">
<Avatar :size="16" :src="avatarOf(value)">
{{ firstChar(labelOf(value)) }}
</Avatar>
<span class="user-tag-name">{{ labelOf(value) }}</span>
</Tag>
</template>
<!-- 下拉选项展示头像+名字 -->
<template #option="{ label, avatar }">
<div class="user-tag-option">
<Avatar :size="20" :src="avatar">{{ firstChar(label) }}</Avatar>
<span>{{ label }}</span>
</div>
</template>
</Select>
</template>
<style scoped>
.user-tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
}
.user-tag-name {
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-tag-option {
display: flex;
align-items: center;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,85 @@
<script lang="ts" setup>
/**
* 超级仓库 Admin选品字段搜索尚未加入总仓yii_drugstore_drug的药品
*/
import { computed, ref, watch } from 'vue';
import { Button } from 'ant-design-vue';
import {
WarehouseAvailableDrugSearch,
type WarehouseDrugItem,
} from '#/components/warehouse-drug-search';
const PRODUCT_TYPE_LABEL: Record<number, string> = {
1: '中药',
2: '西药',
3: '保健食品',
4: '中成药',
5: '产品服务包',
6: '非药品',
7: '医疗器械',
};
const props = withDefaults(
defineProps<{
/** vben-form药品 ID */
value?: number;
/** 列表筛选类型ProductTypeEnum */
productType?: number;
}>(),
{
productType: 1,
},
);
const emit = defineEmits<{
'update:value': [value: number | undefined];
}>();
const selectedLabel = ref('');
const placeholder = computed(() => {
const name = PRODUCT_TYPE_LABEL[props.productType] ?? '药品';
return `输入${name}名称或拼音搜索(显示图片、供应商、规格)`;
});
watch(
() => props.value,
(v) => {
if (v == null || v === undefined) {
selectedLabel.value = '';
}
},
{ immediate: true },
);
function onSelect(drug: WarehouseDrugItem) {
selectedLabel.value = drug.drug_name || '';
emit('update:value', drug.id);
}
function clearSelection() {
selectedLabel.value = '';
emit('update:value', undefined);
}
</script>
<template>
<div class="w-full space-y-2">
<WarehouseAvailableDrugSearch
:product-type="productType"
:placeholder="placeholder"
@select="onSelect"
/>
<div
v-if="value != null"
class="flex items-center gap-2 text-sm text-muted-foreground"
>
<span>已选{{ selectedLabel || `ID ${value}` }}</span>
<Button type="link" size="small" class="!p-0" @click="clearSelection">
清除
</Button>
</div>
</div>
</template>

View File

@@ -4,4 +4,5 @@ export type CustomComponentType =
| 'ApiRadioGroup'
| 'ApiSelect'
| 'ApiTreeSelect'
| 'IconPicker';
| 'IconPicker'
| 'WarehouseAdminDrugSearch';

View File

@@ -0,0 +1,354 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { Button, Image, Modal, Spin } from 'ant-design-vue';
import {
compressImageFile,
formatFileSize,
formatSavingsPercent,
} from '#/utils/image-compress';
export type ImageCompressChoice = 'compressed' | 'original' | 'cancel';
export interface ImageCompressModalResult {
choice: ImageCompressChoice;
file: File | null;
meta?: {
originalSize: number;
compressedSize?: number;
usedCompress: boolean;
};
}
const props = defineProps<{
file: File;
}>();
const emit = defineEmits<{
resolve: [result: ImageCompressModalResult];
}>();
type ModalStatus = 'compressing' | 'ready' | 'gifSkipped' | 'error';
const visible = ref(true);
const status = ref<ModalStatus>('compressing');
const progressText = ref('正在压缩,请稍候…');
const errorMessage = ref('');
const originalPreviewUrl = ref('');
const compressedPreviewUrl = ref('');
const compressedFile = ref<File | null>(null);
const originalSize = ref(0);
const compressedSize = ref(0);
const sizeText = computed(() => {
if (!originalSize.value) return '';
const originalText = formatFileSize(originalSize.value);
if (status.value === 'gifSkipped') {
return `原图 ${originalText}`;
}
if (compressedSize.value && status.value === 'ready') {
const savings = formatSavingsPercent(
originalSize.value,
compressedSize.value,
);
return `原图 ${originalText} → 压缩后 ${formatFileSize(compressedSize.value)}${savings}`;
}
if (compressedSize.value) {
return `原图 ${originalText} → 压缩后 ${formatFileSize(compressedSize.value)}`;
}
return `原图 ${originalText}`;
});
const originalButtonLabel = computed(() => {
if (!originalSize.value) return '使用原图';
return `使用原图 (${formatFileSize(originalSize.value)})`;
});
const compressedButtonLabel = computed(() => {
if (!compressedSize.value) return '使用压缩图';
return `使用压缩图 (${formatFileSize(compressedSize.value)})`;
});
function revokePreviewUrls() {
if (originalPreviewUrl.value) {
URL.revokeObjectURL(originalPreviewUrl.value);
originalPreviewUrl.value = '';
}
if (compressedPreviewUrl.value) {
URL.revokeObjectURL(compressedPreviewUrl.value);
compressedPreviewUrl.value = '';
}
}
function finish(choice: ImageCompressChoice) {
const useCompressed = choice === 'compressed' && status.value === 'ready';
const selectedFile =
choice === 'cancel'
? null
: useCompressed && compressedFile.value
? compressedFile.value
: props.file;
emit('resolve', {
choice,
file: selectedFile,
meta:
choice === 'cancel'
? undefined
: {
originalSize: originalSize.value,
compressedSize: compressedSize.value || undefined,
usedCompress: useCompressed,
},
});
visible.value = false;
}
async function startCompress() {
originalSize.value = props.file.size;
revokePreviewUrls();
originalPreviewUrl.value = URL.createObjectURL(props.file);
if (props.file.type === 'image/gif') {
status.value = 'gifSkipped';
progressText.value = '';
return;
}
status.value = 'compressing';
progressText.value = '正在压缩,请稍候…';
try {
const result = await compressImageFile(props.file, {
onProgress: (message) => {
progressText.value = message;
},
});
compressedFile.value = result.file;
compressedSize.value = result.file.size;
if (result.skipped) {
status.value = 'gifSkipped';
return;
}
compressedPreviewUrl.value = URL.createObjectURL(result.file);
status.value = 'ready';
progressText.value = '';
} catch (error) {
status.value = 'error';
errorMessage.value =
error instanceof Error ? error.message : '图片压缩失败,请使用原图或取消';
progressText.value = '';
}
}
onMounted(() => {
startCompress();
});
onBeforeUnmount(() => {
revokePreviewUrls();
});
</script>
<template>
<Modal
v-model:visible="visible"
title="图片较大"
:width="640"
:mask-closable="false"
:closable="false"
:footer="null"
@cancel="finish('cancel')"
>
<div class="image-compress-modal">
<p v-if="sizeText" class="size-text">{{ sizeText }}</p>
<div v-if="status === 'compressing'" class="compressing-panel">
<div class="preview-single">
<Image
v-if="originalPreviewUrl"
:src="originalPreviewUrl"
alt="原图预览"
class="preview-image"
/>
<span v-if="originalPreviewUrl" class="preview-hint">点击预览</span>
</div>
<div class="compressing-status">
<Spin />
<span class="progress-text">{{ progressText }}</span>
</div>
</div>
<div v-else-if="status === 'ready'" class="compare-panel">
<div class="preview-column">
<div class="preview-label">原图</div>
<Image
:src="originalPreviewUrl"
alt="原图预览"
class="preview-image"
/>
<div class="preview-size">{{ formatFileSize(originalSize) }}</div>
<span class="preview-hint">点击预览</span>
</div>
<div class="preview-column">
<div class="preview-label">压缩后</div>
<Image
:src="compressedPreviewUrl"
alt="压缩图预览"
class="preview-image"
/>
<div class="preview-size">{{ formatFileSize(compressedSize) }}</div>
<span class="preview-hint">点击预览</span>
</div>
</div>
<div v-else-if="status === 'gifSkipped'" class="gif-panel">
<div class="preview-single">
<Image
v-if="originalPreviewUrl"
:src="originalPreviewUrl"
alt="原图预览"
class="preview-image"
/>
<span v-if="originalPreviewUrl" class="preview-hint">点击预览</span>
</div>
<p class="hint-text">GIF 不支持压缩将上传原图</p>
</div>
<div v-else class="error-panel">
<div class="preview-single">
<Image
v-if="originalPreviewUrl"
:src="originalPreviewUrl"
alt="原图预览"
class="preview-image"
/>
<span v-if="originalPreviewUrl" class="preview-hint">点击预览</span>
</div>
<p class="error-text">{{ errorMessage }}</p>
</div>
<div class="footer-actions">
<Button @click="finish('cancel')">取消</Button>
<Button
v-if="status === 'ready' || status === 'error'"
@click="finish('original')"
>
{{ originalButtonLabel }}
</Button>
<Button
v-if="status === 'ready'"
type="primary"
@click="finish('compressed')"
>
{{ compressedButtonLabel }}
</Button>
<Button
v-if="status === 'gifSkipped'"
type="primary"
@click="finish('original')"
>
{{ originalButtonLabel }}
</Button>
</div>
</div>
</Modal>
</template>
<style lang="less" scoped>
.image-compress-modal {
.size-text {
margin-bottom: 16px;
color: rgba(0, 0, 0, 0.65);
text-align: center;
}
.preview-single,
.preview-column {
display: flex;
flex-direction: column;
align-items: center;
}
.preview-image {
max-width: 100%;
max-height: 220px;
cursor: pointer;
:deep(.ant-image-img) {
max-width: 100%;
max-height: 220px;
object-fit: contain;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fafafa;
}
}
.preview-label {
margin-bottom: 8px;
font-weight: 500;
}
.preview-size {
margin-top: 8px;
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
}
.preview-hint {
margin-top: 4px;
color: rgba(0, 0, 0, 0.35);
font-size: 12px;
}
.compressing-panel,
.gif-panel,
.error-panel {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
}
.compressing-status {
display: flex;
gap: 12px;
align-items: center;
justify-content: center;
}
.progress-text {
color: rgba(0, 0, 0, 0.65);
}
.compare-panel {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.hint-text,
.error-text {
margin: 0;
color: rgba(0, 0, 0, 0.65);
text-align: center;
}
.error-text {
color: #ff4d4f;
}
.footer-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
}
}
</style>

View File

@@ -0,0 +1,70 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { InfoCircleOutlined } from '@ant-design/icons-vue';
const content = ref('');
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: false,
confirmText: '我知道了',
onCancel() {
modalApi.close();
},
onConfirm() {
modalApi.close();
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<{
content: string;
}>();
console.log('data', data);
content.value = data.content || '';
}
},
});
</script>
<template>
<Modal class="w-[500px]">
<div class="info-modal-content">
<div class="info-icon">
<InfoCircleOutlined />
</div>
<div class="">{{ content }}</div>
</div>
</Modal>
</template>
<style scoped lang="less">
.info-modal-content {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 8px 0;
.info-icon {
font-size: 24px;
color: #1890ff;
flex-shrink: 0;
margin-top: 2px;
}
.info-text {
flex: 1;
line-height: 1.6;
color: rgba(0, 0, 0, 0.85);
word-break: break-word;
}
}
:deep(.dark) {
.info-text {
color: rgba(255, 255, 255, 0.85);
}
}
</style>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import type { BlockSchema } from './types';
import MIcon from '#/components/icon/icon.vue';
/**
* 左侧组件库:渲染所有可拖拽块类型
* 用户从这里拖动到中间画布SortableJS 用 pull:clone 模式,左侧库不会减少)
*/
defineProps<{
/** 所有可拖拽块定义 */
blocks: BlockSchema[];
}>();
</script>
<template>
<div class="block-library">
<div class="panel-header">
<span class="title">组件库</span>
<span class="hint">拖入画布</span>
</div>
<div class="block-list">
<div
v-for="block in blocks"
:key="block.type"
class="block-item"
:data-block-type="block.type"
>
<MIcon v-if="block.icon" :icon="block.icon" size="16" />
<span class="block-label">{{ block.label }}</span>
</div>
<div v-if="blocks.length === 0" class="empty-hint">暂无可拖入组件</div>
</div>
</div>
</template>
<style scoped lang="scss">
.block-library {
display: flex;
flex-direction: column;
width: 200px;
height: 100%;
border-right: 1px solid var(--ant-color-border-secondary, #e5e6eb);
background: var(--ant-color-fill-quaternary, #fafbfc);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
font-size: 13px;
.title {
font-weight: 600;
color: var(--ant-color-text, #1d2129);
}
.hint {
color: var(--ant-color-text-tertiary, #86909c);
font-size: 11px;
}
}
.block-list {
flex: 1;
overflow-y: auto;
padding: 12px 8px;
}
.block-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 6px;
border: 1px dashed var(--ant-color-border, #c9cdd4);
border-radius: 4px;
background: var(--ant-color-bg-container, #fff);
font-size: 13px;
color: var(--ant-color-text-secondary, #4e5969);
cursor: grab;
user-select: none;
transition: all 0.15s;
&:hover {
border-color: var(--ant-color-primary, #165dff);
color: var(--ant-color-primary, #165dff);
background: var(--ant-color-primary-bg, #f2f7ff);
}
&:active {
cursor: grabbing;
}
.block-label {
flex: 1;
}
}
.empty-hint {
padding: 24px 12px;
text-align: center;
color: var(--ant-color-text-quaternary, #c9cdd4);
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,300 @@
<script setup lang="ts">
import type { BlockInstance, BlockSchema } from './types';
import { computed } from 'vue';
import MIcon from '#/components/icon/icon.vue';
/**
* 中间画布:按 targetArrayPath 分组渲染块实例
* 每个分组是一个 SortableJS 接收区,可拖入、排序、删除
*/
const props = defineProps<{
/** 所有块定义(按 type 查找对应 schema */
blockSchemas: BlockSchema[];
/** 所有块实例(已拖入画布的) */
instances: BlockInstance[];
/** 当前选中的块实例 ID高亮显示 */
selectedInstanceId?: string;
}>();
const emit = defineEmits<{
/** 选中块实例(点击时触发) */
select: [instanceId: string];
/** 删除块实例 */
remove: [instanceId: string];
}>();
/**
* 按 targetArrayPath 分组的渲染结构
* 每个分组对应一个 SortableJS 接收区
*/
const groupedRender = computed(() => {
// 收集所有出现的 targetArrayPath按 blockSchemas 顺序排序
const orderedPaths: string[] = [];
const labelMap: Record<string, { label: string; maxCount?: number }> = {};
for (const schema of props.blockSchemas) {
if (!orderedPaths.includes(schema.targetArrayPath)) {
orderedPaths.push(schema.targetArrayPath);
labelMap[schema.targetArrayPath] = {
label: schema.targetArrayPath,
maxCount: schema.maxCount,
};
}
}
// 把 instances 按目标路径分组
const grouped: Record<string, BlockInstance[]> = {};
for (const inst of props.instances) {
const schema = props.blockSchemas.find((s) => s.type === inst.type);
if (!schema) continue;
const path = schema.targetArrayPath;
if (!grouped[path]) grouped[path] = [];
grouped[path].push(inst);
}
return orderedPaths.map((path) => ({
path,
label: labelMap[path]?.label ?? path,
maxCount: labelMap[path]?.maxCount,
items: grouped[path] ?? [],
}));
});
/**
* 获取块实例的 schema 定义
*/
function getSchema(type: string): BlockSchema | undefined {
return props.blockSchemas.find((s) => s.type === type);
}
/**
* 获取块实例的简要描述(属性面板编辑的字段值预览)
*/
function getInstanceSummary(inst: BlockInstance): string {
const schema = getSchema(inst.type);
if (!schema) return '';
const firstField = schema.fields[0];
if (!firstField) return '';
// 嵌套路径取最末级
const path = firstField.name.split('.');
let value: any = inst.data;
for (const key of path) {
value = value?.[key];
}
return value ? String(value) : '(未填写)';
}
function handleClick(instanceId: string) {
emit('select', instanceId);
}
function handleRemove(instanceId: string) {
emit('remove', instanceId);
}
</script>
<template>
<div class="canvas">
<div class="panel-header">
<span class="title">画布</span>
<span class="hint">拖入 / 排序 / 点击编辑属性</span>
</div>
<div class="canvas-body">
<div
v-for="group in groupedRender"
:key="group.path"
class="canvas-group"
>
<div class="group-header">
<span class="group-label">{{ group.label }}</span>
<span class="group-count">
{{ group.items.length }}{{ group.maxCount ? `/${group.maxCount}` : '' }}
</span>
</div>
<div
class="group-dropzone"
:data-target-array-path="group.path"
>
<div
v-for="inst in group.items"
:key="inst.id"
class="instance-item"
:class="{ selected: inst.id === selectedInstanceId }"
:data-instance-id="inst.id"
@click.stop="handleClick(inst.id)"
>
<div class="instance-info">
<MIcon
v-if="getSchema(inst.type)?.icon"
:icon="getSchema(inst.type)!.icon"
size="14"
/>
<div class="instance-text">
<div class="instance-label">{{ getSchema(inst.type)?.label }}</div>
<div class="instance-summary">{{ getInstanceSummary(inst) }}</div>
</div>
</div>
<button
type="button"
class="instance-remove"
title="删除"
@click.stop="handleRemove(inst.id)"
>
×
</button>
</div>
<div v-if="group.items.length === 0" class="empty-dropzone">
拖入组件
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.canvas {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
background: var(--ant-color-bg-container, #fff);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
font-size: 13px;
.title {
font-weight: 600;
color: var(--ant-color-text, #1d2129);
}
.hint {
color: var(--ant-color-text-tertiary, #86909c);
font-size: 11px;
}
}
.canvas-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.canvas-group {
margin-bottom: 20px;
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
.group-label {
color: var(--ant-color-text-secondary, #4e5969);
font-weight: 500;
font-family: 'JetBrains Mono', monospace;
}
.group-count {
color: var(--ant-color-text-tertiary, #86909c);
}
}
.group-dropzone {
min-height: 60px;
padding: 8px;
border: 2px dashed var(--ant-color-border, #d9d9d9);
border-radius: 6px;
background: var(--ant-color-fill-quaternary, #fafbfc);
}
.empty-dropzone {
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
color: var(--ant-color-text-quaternary, #c9cdd4);
font-size: 12px;
}
.instance-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
margin-bottom: 6px;
border: 1px solid var(--ant-color-border-secondary, #e5e6eb);
border-radius: 4px;
background: var(--ant-color-bg-container, #fff);
cursor: pointer;
transition: all 0.15s;
&:last-child {
margin-bottom: 0;
}
&:hover {
border-color: var(--ant-color-primary, #165dff);
}
&.selected {
border-color: var(--ant-color-primary, #165dff);
background: var(--ant-color-primary-bg, #f2f7ff);
box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.15);
}
}
.instance-info {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.instance-text {
flex: 1;
min-width: 0;
}
.instance-label {
font-size: 13px;
color: var(--ant-color-text, #1d2129);
font-weight: 500;
}
.instance-summary {
font-size: 11px;
color: var(--ant-color-text-tertiary, #86909c);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.instance-remove {
width: 18px;
height: 18px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--ant-color-text-tertiary, #86909c);
font-size: 16px;
line-height: 1;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--ant-color-error, #f53f3f);
color: #fff;
}
}
</style>

View File

@@ -0,0 +1,119 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Button, message } from 'ant-design-vue';
/**
* JSON 实时预览面板
* - 显示最终生成的 JSON 字符串(语法高亮,简单的 key/string 高亮)
* - 提供「复制 JSON」按钮一键拷贝到剪贴板
*/
const props = defineProps<{
/** 最终生成的 JSON 数据 */
value: Record<string, any>;
}>();
const formattedJson = computed(() => {
try {
return JSON.stringify(props.value, null, 2);
} catch {
return '// JSON 序列化失败';
}
});
/**
* 简单的 JSON 语法高亮
* 匹配 key / string / number / boolean 并套用不同颜色 span
*/
const highlightedHtml = computed(() => {
const escape = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const json = escape(formattedJson.value);
return json
.replace(
/("(?:\\.|[^"\\])*")(\s*:)/g,
'<span class="json-key">$1</span>$2',
)
.replace(
/:\s*("(?:\\.|[^"\\])*")/g,
': <span class="json-string">$1</span>',
)
.replace(/:\s*(-?\d+(?:\.\d+)?)/g, ': <span class="json-number">$1</span>')
.replace(/:\s*(true|false|null)/g, ': <span class="json-bool">$1</span>');
});
async function handleCopy() {
try {
await navigator.clipboard.writeText(formattedJson.value);
message.success('JSON 已复制');
} catch {
message.error('复制失败,请手动选择');
}
}
</script>
<template>
<div class="json-preview">
<div class="panel-header">
<span class="title">JSON 预览</span>
<Button size="small" type="link" @click="handleCopy">复制 JSON</Button>
</div>
<pre class="json-body" v-html="highlightedHtml" />
</div>
</template>
<style scoped lang="scss">
.json-preview {
display: flex;
flex-direction: column;
width: 280px;
height: 100%;
border-left: 1px solid var(--ant-color-border-secondary, #e5e6eb);
/* JSON 代码面板保留 VS Code Dark+ 风格暗底(无论浅色/暗色主题) */
background: #1e1e1e;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid #374151;
font-size: 13px;
.title {
font-weight: 600;
color: #e5e7eb;
}
}
.json-body {
flex: 1;
margin: 0;
padding: 12px;
overflow: auto;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px;
line-height: 1.6;
color: #d4d4d4;
white-space: pre-wrap;
word-break: break-all;
}
/* JSON 语法高亮颜色 */
:deep(.json-key) {
color: #9cdcfe;
}
:deep(.json-string) {
color: #ce9178;
}
:deep(.json-number) {
color: #b5cea8;
}
:deep(.json-bool) {
color: #569cd6;
}
</style>

View File

@@ -0,0 +1,348 @@
<script setup lang="ts">
import type { BlockInstance, BlockSchema, FixedField, FormFieldSchema } from './types';
import { computed, watch } from 'vue';
import { Empty, Input, InputNumber, Radio, RadioGroup, Select, Textarea } from 'ant-design-vue';
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
/**
* 右侧属性面板:根据选中块的字段 schema 渲染表单
* - 顶层固定字段fixedFields渲染在顶部不可删除
* - 选中块的 fields渲染在下方
* - 图片类型字段用 GalleryPickLink透传 acceptTypes
*/
const props = defineProps<{
/** 顶层固定字段定义 */
fixedFields?: FixedField[];
/** 顶层固定字段当前值 */
fixedValues?: Record<string, any>;
/** 当前选中的块定义(可能为空) */
selectedBlockSchema?: BlockSchema;
/** 当前选中的块实例(可能为空) */
selectedInstance?: BlockInstance;
}>();
const emit = defineEmits<{
/** 顶层固定字段值变化 */
'update:fixedValues': [value: Record<string, any>];
/** 选中块实例数据变化 */
'update:instance': [instance: BlockInstance];
}>();
/**
* 设置顶层固定字段的值
* 支持 a.b.c 嵌套路径
*/
function setFixedValue(path: string, value: any) {
const next = { ...(props.fixedValues || {}) };
setByPath(next, path, value);
emit('update:fixedValues', next);
}
/**
* 设置选中块实例的字段值
*/
function setInstanceValue(path: string, value: any) {
if (!props.selectedInstance) return;
const nextData = { ...props.selectedInstance.data };
setByPath(nextData, path, value);
emit('update:instance', {
...props.selectedInstance,
data: nextData,
});
}
/**
* 按 a.b.c 路径设置对象嵌套属性(不可变更新)
*/
function setByPath(obj: Record<string, any>, path: string, value: any) {
const keys = path.split('.');
let cursor: any = obj;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i]!;
if (!cursor[k] || typeof cursor[k] !== 'object') {
cursor[k] = {};
} else {
cursor[k] = { ...cursor[k] };
}
cursor = cursor[k];
}
cursor[keys[keys.length - 1]!] = value;
}
/**
* 按 a.b.c 路径读取对象嵌套属性
*/
function getByPath(obj: Record<string, any> | undefined, path: string): any {
if (!obj) return undefined;
const keys = path.split('.');
let cursor: any = obj;
for (const k of keys) {
if (cursor == null) return undefined;
cursor = cursor[k];
}
return cursor;
}
const hasAnyField = computed(() => {
return (
(props.fixedFields && props.fixedFields.length > 0) ||
(props.selectedBlockSchema && props.selectedBlockSchema.fields.length > 0)
);
});
// 当选中的块变化时,触发 watch 让父级感知(用于属性面板切换的动画/数据初始化)
watch(
() => props.selectedInstance?.id,
() => {
// 仅做依赖追踪,实际更新在用户交互时通过 emit 触发
},
);
</script>
<template>
<div class="property-panel">
<div class="panel-header">
<span class="title">属性</span>
<span v-if="selectedBlockSchema" class="hint">{{ selectedBlockSchema.label }}</span>
</div>
<div class="panel-body">
<!-- 顶层固定字段 -->
<div v-if="fixedFields && fixedFields.length > 0" class="field-section">
<div class="section-title">基础信息</div>
<div
v-for="field in fixedFields"
:key="field.name"
class="form-row"
>
<label class="form-label">
{{ field.label }}
<span v-if="field.required" class="required">*</span>
</label>
<!-- 文本 -->
<Input
v-if="!field.type || field.type === 'string' || field.type === 'url'"
:value="getByPath(fixedValues, field.name)"
:placeholder="field.placeholder"
allow-clear
size="small"
@update:value="setFixedValue(field.name, $event)"
/>
<!-- 数字 -->
<InputNumber
v-else-if="field.type === 'number'"
:value="getByPath(fixedValues, field.name)"
:placeholder="field.placeholder"
size="small"
style="width: 100%"
@update:value="setFixedValue(field.name, $event)"
/>
<!-- 多行文本 -->
<Textarea
v-else-if="field.type === 'textarea'"
:value="getByPath(fixedValues, field.name)"
:placeholder="field.placeholder"
:rows="3"
size="small"
@update:value="setFixedValue(field.name, $event)"
/>
<!-- 下拉选择 -->
<Select
v-else-if="field.type === 'select'"
:value="getByPath(fixedValues, field.name)"
:options="field.options"
:placeholder="field.placeholder"
size="small"
@update:value="setFixedValue(field.name, $event)"
/>
<!-- 单选 -->
<RadioGroup
v-else-if="field.type === 'radio'"
:value="getByPath(fixedValues, field.name)"
size="small"
@update:value="setFixedValue(field.name, $event)"
>
<Radio
v-for="opt in field.options"
:key="String(opt.value)"
:value="opt.value"
>
{{ opt.label }}
</Radio>
</RadioGroup>
<!-- 图片素材 -->
<div v-else-if="field.type === 'image'" class="image-picker-row">
<Input
:value="getByPath(fixedValues, field.name)"
:placeholder="field.placeholder || '从素材库选择图片'"
size="small"
read-only
/>
<GalleryPickLink
:accept-types="field.acceptTypes || [0]"
@select="(urls) => setFixedValue(field.name, urls[0] || '')"
/>
</div>
</div>
</div>
<!-- 选中块的属性字段 -->
<div v-if="selectedBlockSchema && selectedInstance" class="field-section">
<div class="section-title">{{ selectedBlockSchema.label }}属性</div>
<div
v-for="field in selectedBlockSchema.fields"
:key="field.name"
class="form-row"
>
<label class="form-label">
{{ field.label }}
<span v-if="field.required" class="required">*</span>
</label>
<Input
v-if="!field.type || field.type === 'string' || field.type === 'url'"
:value="getByPath(selectedInstance.data, field.name)"
:placeholder="field.placeholder"
allow-clear
size="small"
@update:value="setInstanceValue(field.name, $event)"
/>
<InputNumber
v-else-if="field.type === 'number'"
:value="getByPath(selectedInstance.data, field.name)"
:placeholder="field.placeholder"
size="small"
style="width: 100%"
@update:value="setInstanceValue(field.name, $event)"
/>
<Textarea
v-else-if="field.type === 'textarea'"
:value="getByPath(selectedInstance.data, field.name)"
:placeholder="field.placeholder"
:rows="3"
size="small"
@update:value="setInstanceValue(field.name, $event)"
/>
<Select
v-else-if="field.type === 'select'"
:value="getByPath(selectedInstance.data, field.name)"
:options="field.options"
:placeholder="field.placeholder"
size="small"
@update:value="setInstanceValue(field.name, $event)"
/>
<RadioGroup
v-else-if="field.type === 'radio'"
:value="getByPath(selectedInstance.data, field.name)"
size="small"
@update:value="setInstanceValue(field.name, $event)"
>
<Radio
v-for="opt in field.options"
:key="String(opt.value)"
:value="opt.value"
>
{{ opt.label }}
</Radio>
</RadioGroup>
<div v-else-if="field.type === 'image'" class="image-picker-row">
<Input
:value="getByPath(selectedInstance.data, field.name)"
:placeholder="field.placeholder || '从素材库选择图片'"
size="small"
read-only
/>
<GalleryPickLink
:accept-types="field.acceptTypes || [0]"
@select="(urls) => setInstanceValue(field.name, urls[0] || '')"
/>
</div>
</div>
</div>
<!-- 未选中任何块时的提示 -->
<Empty
v-if="!hasAnyField"
description="点击画布中的块以编辑属性"
:image-style="{ height: '60px' }"
style="margin-top: 60px"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.property-panel {
display: flex;
flex-direction: column;
width: 280px;
height: 100%;
border-left: 1px solid var(--ant-color-border-secondary, #e5e6eb);
background: var(--ant-color-bg-container, #fff);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
font-size: 13px;
.title {
font-weight: 600;
color: var(--ant-color-text, #1d2129);
}
.hint {
color: var(--ant-color-text-tertiary, #86909c);
font-size: 11px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.field-section {
margin-bottom: 24px;
}
.section-title {
margin-bottom: 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--ant-color-split, #f2f3f5);
font-size: 12px;
font-weight: 600;
color: var(--ant-color-text-secondary, #4e5969);
}
.form-row {
margin-bottom: 12px;
}
.form-label {
display: block;
margin-bottom: 4px;
font-size: 12px;
color: var(--ant-color-text-secondary, #4e5969);
.required {
margin-left: 2px;
color: var(--ant-color-error, #f53f3f);
}
}
.image-picker-row {
display: flex;
flex-direction: column;
gap: 4px;
}
</style>

View File

@@ -0,0 +1,522 @@
<script setup lang="ts">
import type { BlockInstance, BlockSchema, FixedField } from './types';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import type Sortable from 'sortablejs';
import type { SortableOptions } from 'sortablejs';
import { Button, message } from 'ant-design-vue';
import BlockLibrary from './BlockLibrary.vue';
import Canvas from './Canvas.vue';
import JsonPreview from './JsonPreview.vue';
import PropertyPanel from './PropertyPanel.vue';
/**
* 通用拖拽式可视化编辑器
*
* 用途:把「左侧组件库 → 中间画布拖拽组装 → 右侧属性面板 → 自动生成 JSON」
* 做成 schema 驱动的通用组件,未来 OA / 工单 / 通知等任何场景需要可视化拼装 JSON 都能复用。
*
* 核心机制:
* - 左侧库 + 画布各分组共用同一个 SortableJS grouppull:clone 模式:左侧库克隆到画布)
* - 拖入 / 排序 / 删除时同步内部 instances 状态
* - 通过 computed 把「fixedFields 值 + 各 targetArrayPath 数组」合并成最终 JSONemit 给父级
*
* SortableJS 用法参照项目内 views/system/role/components/quick-nav-transfer.vue
*/
const props = withDefaults(
defineProps<{
/** 双向绑定的 JSON 数据 */
modelValue: Record<string, any>;
/** 块定义 schema决定左侧能拖入哪些块、各自有哪些字段 */
schema: BlockSchema[];
/** 是否显示 JSON 预览面板 */
showJsonPreview?: boolean;
/** 顶层固定字段(不可拖拽,固定显示在属性面板顶部) */
fixedFields?: FixedField[];
}>(),
{
showJsonPreview: true,
fixedFields: () => [] as FixedField[],
},
);
const emit = defineEmits<{
'update:modelValue': [value: Record<string, any>];
}>();
// ====== 内部状态 ======
/** 所有块实例(按拖入顺序) */
const instances = ref<BlockInstance[]>([]);
/** 当前选中的块实例 ID */
const selectedInstanceId = ref<string>('');
/** 顶层固定字段值 */
const fixedValues = ref<Record<string, any>>({});
/** 顶层固定字段 + 块数组组装后的最终 JSON双向同步给父级 */
const assembledJson = computed<Record<string, any>>(() => {
const result: Record<string, any> = { ...fixedValues.value };
// 按 targetArrayPath 收集块实例数据
for (const schema of props.schema) {
const path = schema.targetArrayPath;
const items = instances.value
.filter((inst) => inst.type === schema.type)
.map((inst) => {
// 深拷贝避免引用问题
const data = JSON.parse(JSON.stringify(inst.data));
// 移除内部使用的 id 字段(如果有)
delete data._blockType;
return data;
});
if (items.length > 0) {
// 设置嵌套路径
setByPath(result, path, items);
}
}
return result;
});
/** 当前选中的块实例对象 */
const selectedInstance = computed<BlockInstance | undefined>(() => {
return instances.value.find((i) => i.id === selectedInstanceId.value);
});
/** 当前选中的块 schema */
const selectedBlockSchema = computed<BlockSchema | undefined>(() => {
if (!selectedInstance.value) return undefined;
return props.schema.find((s) => s.type === selectedInstance.value!.type);
});
// ====== SortableJS 实例管理 ======
const editorRootRef = ref<HTMLElement>();
const libraryRef = ref<HTMLElement>();
/** 画布中各分组的 dropzone DOM 引用key = targetArrayPath */
const dropzoneRefs = ref<Record<string, HTMLElement>>({});
const librarySortable = ref<Sortable | null>(null);
const dropzoneSortables = ref<Record<string, Sortable>>({});
/**
* 动态导入 SortableJS参照 quick-nav-transfer.vue 模式)
*/
async function createSortable(
el: HTMLElement,
options: SortableOptions = {},
): Promise<Sortable> {
const mod = await import(
// @ts-expect-error sortablejs modular esm path
'sortablejs/modular/sortable.complete.esm.js'
);
return mod.default.create(el, {
animation: 200,
...options,
}) as Sortable;
}
/**
* 初始化 SortableJS 实例
* 1. 左侧组件库pull:clone 模式(拖出后元素保留)
* 2. 画布各分组pull:clone/put跨组可移动
*/
async function initSortables() {
destroySortables();
await nextTick();
if (libraryRef.value) {
librarySortable.value = await createSortable(libraryRef.value, {
group: {
name: 'template-editor',
pull: 'clone',
put: false, // 左侧库不接受拖入
},
sort: false, // 左侧库不排序
onEnd: handleLibraryDragEnd,
});
}
// 通过 querySelector 直接查询画布中的所有 dropzone DOM避免父子组件 DOM 引用传递)
const editorRoot = editorRootRef.value;
const dropzoneEls = editorRoot?.querySelectorAll('.group-dropzone[data-target-array-path]') ?? [];
dropzoneEls.forEach((el) => {
const path = el.getAttribute('data-target-array-path');
if (!path) return;
dropzoneRefs.value[path] = el as HTMLElement;
void createSortable(el as HTMLElement, {
group: {
name: 'template-editor',
pull: true,
put: true,
},
onAdd: (evt) => handleDropzoneAdd(evt, path),
onRemove: (evt) => handleDropzoneRemove(evt, path),
onUpdate: (evt) => handleDropzoneUpdate(evt, path),
}).then((s) => {
dropzoneSortables.value[path] = s;
});
});
}
function destroySortables() {
librarySortable.value?.destroy();
librarySortable.value = null;
for (const sortable of Object.values(dropzoneSortables.value)) {
sortable.destroy();
}
dropzoneRefs.value = {};
dropzoneSortables.value = {};
}
/**
* 设置 dropzone DOM 引用Canvas 子组件通过回调注入)
*/
function setDropzoneRef(path: string, el: HTMLElement | null) {
if (el) {
dropzoneRefs.value[path] = el;
} else {
delete dropzoneRefs.value[path];
}
}
// ====== 拖拽事件处理(在 SortableJS 修改 DOM 后,同步到 Vue 状态) ======
/**
* 左侧库 → 画布的拖拽结束回调
* SortableJS 会把左侧库的元素 clone 一份移到画布 DOM我们要
* 1. 移除 DOM 中被克隆过去的元素(由 Vue 重新渲染)
* 2. 在 instances 中追加新块
*/
function handleLibraryDragEnd(evt: any) {
// 如果只是库内拖动(没拖到画布),不做处理
if (!evt.to || !evt.to.classList.contains('group-dropzone')) {
return;
}
const blockType = evt.item?.dataset?.blockType;
if (!blockType) return;
// 检查 maxCount 限制
const schema = props.schema.find((s) => s.type === blockType);
if (!schema) return;
const targetPath = schema.targetArrayPath;
const currentCount = instances.value.filter(
(i) => props.schema.find((s) => s.type === i.type)?.targetArrayPath === targetPath,
).length;
if (schema.maxCount && currentCount >= schema.maxCount) {
// 移除 SortableJS 已经克隆到画布 DOM 的元素
evt.item?.remove();
message.warning(`${schema.label}」最多 ${schema.maxCount}`);
return;
}
// 移除 SortableJS 已经克隆到画布 DOM 的元素Vue 会重新渲染)
evt.item?.remove();
// 根据 SortableJS 在画布 DOM 中的位置,计算插入位置
// 简化处理:直接追加到末尾(实现严格的位置同步太复杂,先保留追加行为)
const newInstance: BlockInstance = {
id: generateId(),
type: blockType,
data: schema.defaultValue ? JSON.parse(JSON.stringify(schema.defaultValue)) : {},
};
instances.value = [...instances.value, newInstance];
selectedInstanceId.value = newInstance.id;
}
/**
* 画布分组间或分组内的添加事件(块从其他分组拖入)
*/
function handleDropzoneAdd(_evt: any, _path: string) {
// SortableJS 自动处理 DOM 顺序,但因为 Vue 控制渲染,需要同步状态
syncInstancesFromDom();
}
function handleDropzoneRemove(_evt: any, _path: string) {
syncInstancesFromDom();
}
function handleDropzoneUpdate(_evt: any, _path: string) {
syncInstancesFromDom();
}
/**
* 从画布 DOM 同步块实例状态
* SortableJS 修改了 DOM 后,从 DOM 重新读取 instanceId 顺序
*/
function syncInstancesFromDom() {
const newInstances: BlockInstance[] = [];
for (const path of Object.keys(dropzoneRefs.value)) {
const el = dropzoneRefs.value[path];
if (!el) continue;
const items = el.querySelectorAll('[data-instance-id]');
items.forEach((item) => {
const id = item.getAttribute('data-instance-id');
if (!id) return;
const existing = instances.value.find((i) => i.id === id);
if (existing) {
newInstances.push(existing);
}
});
}
instances.value = newInstances;
}
/**
* 删除块实例
*/
function removeInstance(id: string) {
instances.value = instances.value.filter((i) => i.id !== id);
if (selectedInstanceId.value === id) {
selectedInstanceId.value = '';
}
}
/**
* 选中块实例
*/
function selectInstance(id: string) {
selectedInstanceId.value = id;
}
/**
* 更新顶层固定字段值PropertyPanel 触发)
*/
function updateFixedValues(values: Record<string, any>) {
fixedValues.value = values;
}
/**
* 更新块实例数据PropertyPanel 触发)
*/
function updateInstance(updated: BlockInstance) {
instances.value = instances.value.map((i) =>
i.id === updated.id ? updated : i,
);
}
// ====== 工具函数 ======
function generateId(): string {
return `inst_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
/**
* 按 a.b.c 路径设置对象嵌套属性(不可变更新)
*/
function setByPath(obj: Record<string, any>, path: string, value: any) {
const keys = path.split('.');
let cursor: any = obj;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i]!;
if (!cursor[k]) cursor[k] = {};
cursor = cursor[k];
}
cursor[keys[keys.length - 1]!] = value;
}
/**
* 按 a.b.c 路径读取对象嵌套属性
*/
function getByPath(obj: Record<string, any> | undefined, path: string): any {
if (!obj) return undefined;
const keys = path.split('.');
let cursor: any = obj;
for (const k of keys) {
if (cursor == null) return undefined;
cursor = cursor[k];
}
return cursor;
}
// ====== modelValue 双向同步 ======
/**
* 监听 modelValue 变化,反向初始化内部状态(用于编辑场景回显)
*/
watch(
() => props.modelValue,
(val) => {
if (!val || Object.keys(val).length === 0) {
// 重置
instances.value = [];
fixedValues.value = {};
selectedInstanceId.value = '';
return;
}
// 简化处理:只在 modelValue 引用变化时才反解析(避免无限循环)
// 从 modelValue 还原 fixedValues 和 instances
const newFixed: Record<string, any> = {};
for (const field of props.fixedFields || []) {
const v = getByPath(val, field.name);
if (v !== undefined) {
setByPath(newFixed, field.name, v);
}
}
fixedValues.value = newFixed;
// 从各 targetArrayPath 还原 instances
const newInstances: BlockInstance[] = [];
for (const schema of props.schema) {
const arr = getByPath(val, schema.targetArrayPath);
if (Array.isArray(arr)) {
for (const item of arr) {
newInstances.push({
id: generateId(),
type: schema.type,
data: JSON.parse(JSON.stringify(item)),
});
}
}
}
instances.value = newInstances;
},
{ immediate: false, deep: false },
);
/**
* 监听 assembledJson 变化emit 给父级
*/
watch(
assembledJson,
(val) => {
// 浅比较避免不必要的 emit
const current = JSON.stringify(props.modelValue || {});
const next = JSON.stringify(val);
if (current !== next) {
emit('update:modelValue', val);
}
},
{ deep: true },
);
// 监听 schema 变化时重置 SortableJS画布 DOM 可能重建)
watch(
() => props.schema,
() => {
void nextTick(() => initSortables());
},
);
onMounted(async () => {
// 初始化 fixedValues从 props.fixedFields 的 defaultValue
for (const field of props.fixedFields || []) {
if (field.defaultValue !== undefined) {
setByPath(fixedValues.value, field.name, field.defaultValue);
}
}
// 从 modelValue 回显数据
if (props.modelValue && Object.keys(props.modelValue).length > 0) {
for (const field of props.fixedFields || []) {
const v = getByPath(props.modelValue, field.name);
if (v !== undefined) {
setByPath(fixedValues.value, field.name, v);
}
}
for (const schema of props.schema) {
const arr = getByPath(props.modelValue, schema.targetArrayPath);
if (Array.isArray(arr)) {
for (const item of arr) {
instances.value.push({
id: generateId(),
type: schema.type,
data: JSON.parse(JSON.stringify(item)),
});
}
}
}
}
await nextTick();
await initSortables();
});
onUnmounted(() => {
destroySortables();
});
// 暴露给父组件
defineExpose({
initSortables,
});
</script>
<template>
<div ref="editorRootRef" class="template-editor">
<div class="editor-toolbar">
<span class="title">模板编辑器</span>
<Button
size="small"
type="link"
@click="initSortables"
>
重置拖拽
</Button>
</div>
<div class="editor-main">
<!-- 左侧组件库 ref 暴露给 SortableJS 初始化 -->
<div ref="libraryRef">
<BlockLibrary :blocks="schema" />
</div>
<!-- 中间画布 -->
<Canvas
:block-schemas="schema"
:instances="instances"
:selected-instance-id="selectedInstanceId"
@select="selectInstance"
@remove="removeInstance"
/>
<!-- 右侧属性面板 -->
<PropertyPanel
:fixed-fields="fixedFields"
:fixed-values="fixedValues"
:selected-block-schema="selectedBlockSchema"
:selected-instance="selectedInstance"
@update:fixed-values="updateFixedValues"
@update:instance="updateInstance"
/>
<!-- 最右侧JSON 预览可选 -->
<JsonPreview
v-if="showJsonPreview"
:value="assembledJson"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.template-editor {
display: flex;
flex-direction: column;
width: 100%;
height: 480px;
border: 1px solid var(--ant-color-border-secondary, #e5e6eb);
border-radius: 6px;
overflow: hidden;
background: var(--ant-color-bg-container, #fff);
}
.editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
background: var(--ant-color-fill-quaternary, #fafbfc);
font-size: 13px;
.title {
font-weight: 600;
color: var(--ant-color-text, #1d2129);
}
}
.editor-main {
display: flex;
flex: 1;
min-height: 0;
}
</style>

View File

@@ -0,0 +1,99 @@
/**
* 通用拖拽式可视化编辑器类型定义
*
* 设计目标:把「左侧组件库 → 中间画布拖拽 → 右侧属性面板 → 自动生成 JSON」
* 做成 schema 驱动的通用编辑器,未来任何需要拼装 JSON 结构的场景都能复用。
*
* 核心概念:
* - BlockSchema可拖拽块定义左侧组件库中展示决定属性面板渲染什么字段
* - FormFieldSchema单个字段定义驱动右侧属性面板表单渲染
* - 块的 instances 按 targetArrayPath 分组存到画布,每组数量不超过 maxCount
*/
/**
* 字段类型枚举
* - string单行文本
* - number数字
* - textarea多行文本
* - select下拉选择
* - radio单选按钮组
* - image图片素材选择用 GalleryPickLinkacceptTypes 限定图片)
* - urlURL 链接
*/
export type FormFieldType =
| 'image'
| 'number'
| 'radio'
| 'select'
| 'string'
| 'textarea'
| 'url';
/**
* 单个字段定义(驱动属性面板表单渲染)
*/
export interface FormFieldSchema {
/** 字段名(在 payload 对象中的 key支持 a.b.c 嵌套路径) */
name: string;
/** 中文标签 */
label: string;
/** 字段类型 */
type: FormFieldType;
/** 占位提示 */
placeholder?: string;
/** 是否必填 */
required?: boolean;
/** select / radio 类型的选项列表 */
options?: { label: string; value: any }[];
/** 图片字段:限定素材库文件类型(如 [1] 只显示图片) */
acceptTypes?: number[];
/** 最大长度 */
maxLength?: number;
/** 默认值 */
defaultValue?: any;
}
/**
* 可拖拽块定义(左侧组件库中展示,决定画布上能放什么块、各块有哪些字段)
*/
export interface BlockSchema {
/** 块类型标识(唯一) */
type: string;
/** 块显示名(如「二级标题+文本项」「跳转链接项」) */
label: string;
/** 块图标Iconify */
icon: string;
/** 该块的字段定义(驱动右侧属性面板表单渲染) */
fields: FormFieldSchema[];
/**
* 该块在最终 JSON 中放到的数组路径
* 例如 'horizontal_content_list' 表示拖入的块实例会被收集到 result.horizontal_content_list 数组
* 多个块共用同一个 targetArrayPath 即被收集到同一数组
*/
targetArrayPath: string;
/** 该数组路径下最多允许的块数量(如企微 jump_list 最多 3 项) */
maxCount?: number;
/** 块实例的默认值(拖入时初始化) */
defaultValue?: Record<string, any>;
}
/**
* 画布中的块实例(拖入后生成的实际数据)
*/
export interface BlockInstance {
/** 块实例唯一 ID用于 SortableJS 拖拽排序追踪) */
id: string;
/** 关联的块类型(对应 BlockSchema.type */
type: string;
/** 块实例的当前数据(属性面板编辑后会更新这里) */
data: Record<string, any>;
}
/**
* 顶层固定字段(不可拖拽删除,固定显示在属性面板顶部)
* 用于 card_type / main_title / source 等必填顶层字段
*/
export interface FixedField extends FormFieldSchema {
/** 字段在最终 JSON 中的路径(支持 a.b.c 嵌套,如 'main_title.title' */
name: string;
}

View File

@@ -0,0 +1,268 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useVbenModal } from '@vben/common-ui';
import { openRouteInNewWindow } from '@vben/utils';
import dayjs from 'dayjs';
import { Avatar, Descriptions, Empty, message, Spin, Table } from 'ant-design-vue';
import { getPromoterDetailApi } from '#/views/system/store-input/api';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
type PromoterStoreItem = {
store_id: number;
name: string;
type: number;
type_label: string;
input_at: number;
total_amount: string;
month_amount: string;
};
type PromoterDetail = {
id: number;
nick_name: string;
avatar: string;
code: string;
phone: string;
role_name: string;
clinic_store_count: number;
pharmacy_store_count: number;
store_total_count: number;
pending_clinic_count: number;
pending_pharmacy_count: number;
store_list: PromoterStoreItem[];
total_valid_amount: string;
month_valid_amount: string;
};
const router = useRouter();
const loading = ref(false);
const detail = ref<PromoterDetail | null>(null);
const storeColumns = [
{ title: '门店名称', dataIndex: 'name', key: 'name', ellipsis: true },
{ title: '类型', dataIndex: 'type_label', key: 'type_label', width: 72 },
{ title: '录入时间', dataIndex: 'input_at', key: 'input_at', width: 160 },
{ title: '总营业额', dataIndex: 'total_amount', key: 'total_amount', width: 120, align: 'right' as const },
{ title: '本月营业额', dataIndex: 'month_amount', key: 'month_amount', width: 120, align: 'right' as const },
];
/** 解析商品订单列表路由菜单动态注册path 含 product-order */
function findProductOrderListPath(): string | null {
const routes = router.getRoutes();
const hit = routes.find(
(r) =>
typeof r.path === 'string' &&
r.path.length > 0 &&
!r.redirect &&
/product-order/i.test(r.path),
);
return hit?.path ?? null;
}
/** 金额格式化为 ¥ + 2 位小数 */
function formatMoney(value: number | string | undefined) {
const num = Number(value || 0);
return `¥${num.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}
/** Unix 时间戳格式化为可读录入时间 */
function formatInputAt(timestamp?: number) {
if (!timestamp) return '-';
return dayjs.unix(timestamp).format('YYYY-MM-DD HH:mm');
}
/** 新标签页打开商品订单,按门店及时间范围筛选 */
function openStoreOrders(storeId: number, timeScope: 'all' | 'month') {
const path = findProductOrderListPath();
if (!path) {
message.warning('未找到商品订单菜单路由,请从左侧菜单进入商品订单');
return;
}
const resolved = router.resolve({
path,
query: {
store_id: String(storeId),
time_scope: timeScope,
},
});
openRouteInNewWindow(resolved.fullPath);
}
const [Modal, modalApi] = useVbenModal({
title: '业务员详情',
class: 'w-[860px]',
footer: false,
onOpenChange(isOpen) {
if (!isOpen) {
detail.value = null;
return;
}
const data = modalApi.getData<{ id?: number; code?: string }>();
loadDetail(data?.id, data?.code);
},
});
async function loadDetail(id?: number, code?: string) {
loading.value = true;
try {
detail.value = await getPromoterDetailApi({ id, code });
} finally {
loading.value = false;
}
}
</script>
<template>
<Modal>
<Spin :spinning="loading">
<div v-if="detail" class="promoter-detail">
<div class="promoter-detail__header">
<Avatar
v-if="resolveAvatarUrl(detail.avatar)"
:src="resolveAvatarUrl(detail.avatar)"
:size="64"
/>
<Avatar v-else :size="64">{{ (detail.nick_name || '?').charAt(0) }}</Avatar>
<div class="promoter-detail__title">{{ detail.nick_name || '-' }}</div>
<div class="promoter-detail__role">{{ detail.role_name || '业务员' }}</div>
</div>
<Descriptions bordered :column="2" size="small" class="promoter-detail__desc">
<Descriptions.Item label="推广码">{{ detail.code || '-' }}</Descriptions.Item>
<Descriptions.Item label="手机号">
<SensitiveText :record="detail" field="phone" />
</Descriptions.Item>
<Descriptions.Item label="绑定诊所(含待审核)">
{{ detail.clinic_store_count ?? 0 }}
<span v-if="detail.pending_clinic_count" class="promoter-detail__pending">
待审核 {{ detail.pending_clinic_count }}
</span>
</Descriptions.Item>
<Descriptions.Item label="绑定药店(含待审核)">
{{ detail.pharmacy_store_count ?? 0 }}
<span v-if="detail.pending_pharmacy_count" class="promoter-detail__pending">
待审核 {{ detail.pending_pharmacy_count }}
</span>
</Descriptions.Item>
<Descriptions.Item label="绑定门店总营业额">
{{ formatMoney(detail.total_valid_amount) }}
</Descriptions.Item>
<Descriptions.Item label="本月营业额">
{{ formatMoney(detail.month_valid_amount) }}
</Descriptions.Item>
</Descriptions>
<div class="promoter-detail__section-title">正式门店列表</div>
<Table
v-if="detail.store_list?.length"
:columns="storeColumns"
:data-source="detail.store_list"
:pagination="false"
:scroll="{ y: 320 }"
row-key="store_id"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'input_at'">
{{ formatInputAt(record.input_at) }}
</template>
<template v-else-if="column.key === 'total_amount'">
<button
type="button"
class="promoter-detail__amount-link"
@click="openStoreOrders(record.store_id, 'all')"
>
{{ formatMoney(record.total_amount) }}
</button>
</template>
<template v-else-if="column.key === 'month_amount'">
<button
type="button"
class="promoter-detail__amount-link"
@click="openStoreOrders(record.store_id, 'month')"
>
{{ formatMoney(record.month_amount) }}
</button>
</template>
</template>
</Table>
<Empty v-else class="promoter-detail__empty" description="暂无绑定正式门店" />
</div>
</Spin>
</Modal>
</template>
<style scoped>
.promoter-detail__header {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.promoter-detail__title {
font-size: 18px;
font-weight: 600;
color: #1d2129;
}
.promoter-detail__role {
font-size: 13px;
color: #86909c;
}
.promoter-detail__desc {
margin-top: 8px;
}
.promoter-detail__pending {
margin-left: 4px;
font-size: 12px;
color: #fa8c16;
}
.promoter-detail__section-title {
margin: 16px 0 8px;
font-size: 14px;
font-weight: 600;
color: #1d2129;
}
.promoter-detail__empty {
padding: 24px 0;
}
.promoter-detail__amount-link {
padding: 0;
font: inherit;
color: #1677ff;
cursor: pointer;
background: none;
border: none;
}
.promoter-detail__amount-link:hover {
color: #4096ff;
text-decoration: underline;
}
.dark .promoter-detail__title,
.dark .promoter-detail__section-title {
color: #f3f4f6;
}
.dark .promoter-detail__role {
color: #9ca3af;
}
.dark .promoter-detail__amount-link {
color: #60a5fa;
}
</style>

View File

@@ -0,0 +1,238 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Avatar } from 'ant-design-vue';
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import PromoterDetailModal from './PromoterDetailModal.vue';
export type PromoterAdminLike = {
id?: number;
nick_name?: string;
avatar?: string;
phone?: string;
code?: string;
role_name?: string;
};
const props = withDefaults(
defineProps<{
admin?: PromoterAdminLike | null;
size?: 'compact' | 'trigger';
clickable?: boolean;
showClear?: boolean;
disabled?: boolean;
}>(),
{
admin: null,
size: 'compact',
clickable: true,
showClear: false,
disabled: false,
},
);
const emits = defineEmits<{
clear: [];
}>();
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: PromoterDetailModal,
});
const displayName = computed(
() => props.admin?.nick_name?.trim() || props.admin?.phone?.trim() || '-',
);
const avatarUrl = computed(() => resolveAvatarUrl(props.admin?.avatar));
const canOpenDetail = computed(
() =>
props.clickable &&
!props.disabled &&
!!(props.admin?.id || props.admin?.code),
);
function openDetail() {
if (!canOpenDetail.value) return;
detailModalApi.setData({
id: props.admin?.id,
code: props.admin?.code,
});
detailModalApi.open();
}
function onClear(e: Event) {
e.stopPropagation();
emits('clear');
}
</script>
<template>
<div>
<DetailModal />
<div
v-if="admin"
class="promoter-info-card"
:class="[
`promoter-info-card--${size}`,
{
'promoter-info-card--clickable': canOpenDetail,
'promoter-info-card--disabled': disabled,
},
]"
@click="openDetail"
>
<Avatar v-if="avatarUrl" :src="avatarUrl" :size="size === 'trigger' ? 36 : 28" />
<Avatar v-else :size="size === 'trigger' ? 36 : 28">
{{ displayName.charAt(0) }}
</Avatar>
<div class="promoter-info-card__meta">
<div class="promoter-info-card__name">{{ displayName }}</div>
<div v-if="size === 'trigger' && admin.code" class="promoter-info-card__sub">
业务码{{ admin.code }}
</div>
<div v-else-if="admin.phone" class="promoter-info-card__sub">
<SensitiveText :record="admin" field="phone" :show-eye="false" />
</div>
</div>
<span
v-if="showClear && !disabled"
class="promoter-info-card__clear"
@click="onClear"
>
×
</span>
</div>
<span v-else class="promoter-info-card__empty">-</span>
</div>
</template>
<style scoped lang="scss">
.promoter-info-card {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.promoter-info-card--compact {
padding: 2px 0;
}
.promoter-info-card--trigger {
width: 100%;
padding: 8px 12px;
border: 1px solid #e5e6eb;
border-radius: 8px;
background: #fafafa;
transition:
border-color 0.2s,
background-color 0.2s;
}
.promoter-info-card--clickable.promoter-info-card--trigger {
cursor: pointer;
}
.promoter-info-card--clickable.promoter-info-card--trigger:hover {
border-color: #165dff;
background: #f2f7ff;
}
.promoter-info-card--clickable.promoter-info-card--compact {
cursor: pointer;
}
.promoter-info-card--clickable.promoter-info-card--compact:hover {
opacity: 0.85;
}
.promoter-info-card--disabled {
cursor: not-allowed;
opacity: 0.65;
}
.promoter-info-card__meta {
min-width: 0;
flex: 1;
}
.promoter-info-card__name {
font-size: 14px;
font-weight: 500;
color: #1d2129;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.promoter-info-card--compact .promoter-info-card__name {
font-size: 13px;
font-weight: 400;
}
.promoter-info-card__sub {
font-size: 12px;
color: #86909c;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.promoter-info-card--compact .promoter-info-card__sub {
font-size: 11px;
}
.promoter-info-card__clear {
flex-shrink: 0;
cursor: pointer;
color: #86909c;
font-size: 16px;
line-height: 1;
padding: 0 4px;
}
.promoter-info-card__clear:hover {
color: #1d2129;
}
.promoter-info-card__empty {
color: #86909c;
}
.dark {
.promoter-info-card--trigger {
background: #1f2937;
border-color: #374151;
}
.promoter-info-card--clickable.promoter-info-card--trigger:hover {
background: #374151;
border-color: #60a5fa;
}
.promoter-info-card__name {
color: #f3f4f6;
}
.promoter-info-card__sub,
.promoter-info-card__empty {
color: #9ca3af;
}
.promoter-info-card__clear {
color: #9ca3af;
}
.promoter-info-card__clear:hover {
color: #f3f4f6;
}
}
</style>

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Input, Modal, message } from 'ant-design-vue';
import UploadImage from '#/components/form/components/upload-image.vue';
export interface SettlementPreviewInfo {
record_count: number;
order_count?: number;
amount: string;
}
const props = defineProps<{
confirmFn: (data: Record<string, any>) => Promise<unknown>;
}>();
const emit = defineEmits<{
success: [];
}>();
const visible = ref(false);
const confirmLoading = ref(false);
const modalTitle = ref('确认结算');
const preview = ref<SettlementPreviewInfo | null>(null);
const settleParams = ref<Record<string, any>>({});
const proofImages = ref<string[]>([]);
const remark = ref('');
function open(
title: string,
previewInfo: SettlementPreviewInfo,
params: Record<string, any>,
) {
modalTitle.value = title;
preview.value = previewInfo;
settleParams.value = { ...params };
proofImages.value = [];
remark.value = '';
visible.value = true;
}
function close() {
visible.value = false;
}
async function handleOk() {
if (!proofImages.value.length) {
message.warning('请上传至少 1 张结算凭证(如转账截图)');
return Promise.reject();
}
confirmLoading.value = true;
try {
await props.confirmFn({
...settleParams.value,
proof_images: proofImages.value,
remark: remark.value.trim(),
});
message.success('结算成功');
close();
emit('success');
} finally {
confirmLoading.value = false;
}
}
defineExpose({ open });
</script>
<template>
<Modal
v-model:open="visible"
:confirm-loading="confirmLoading"
:title="modalTitle"
ok-text="确认结算"
width="560"
@ok="handleOk"
>
<div v-if="preview" class="mb-4 text-sm text-gray-600">
<p> {{ preview.record_count }} 条明细</p>
<p v-if="preview.order_count != null">{{ preview.order_count }} 个订单</p>
<p class="font-medium text-gray-900">合计 ¥{{ preview.amount }}</p>
</div>
<div class="mb-4">
<div class="mb-2 text-sm">
<span class="text-red-500">*</span> 结算凭证转账截图等最多 9
</div>
<UploadImage v-model="proofImages" :max-count="9" />
</div>
<div>
<div class="mb-2 text-sm text-gray-600">备注选填</div>
<Input.TextArea
v-model:value="remark"
:maxlength="255"
:rows="2"
placeholder="可填写转账说明等"
show-count
/>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,158 @@
<script lang="ts" setup>
import type { SensitiveFieldSource } from '#/constants/sensitive-fields';
import { computed, ref } from 'vue';
import { useClipboard } from '@vueuse/core';
import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import {
getSensitiveMaskText,
getSensitivePlainText,
} from '#/constants/sensitive-fields';
const props = withDefaults(
defineProps<{
/** 数据对象 */
record?: Record<string, unknown> | null;
/** 字段名 */
field: string;
/** 主字段为空时的备选来源 */
fallback?: SensitiveFieldSource;
/** 空值占位 */
emptyText?: string;
/** 是否显示眼睛切换 */
showEye?: boolean;
/** 点击文本是否复制明文 */
copyable?: boolean;
}>(),
{
record: null,
emptyText: '—',
showEye: true,
copyable: true,
},
);
const revealed = ref(false);
const { copy } = useClipboard({ legacy: true });
/** 明文值,复制与展示切换时使用 */
const plainText = computed(() =>
getSensitivePlainText(props.record, props.field, props.fallback),
);
/** 脱敏展示值 */
const maskText = computed(() =>
getSensitiveMaskText(props.record, props.field, props.fallback),
);
/** 当前界面展示文本 */
const displayText = computed(() => {
if (!plainText.value) {
return props.emptyText;
}
return revealed.value ? plainText.value : maskText.value;
});
/** 是否有有效值(非空占位) */
const hasValue = computed(() => plainText.value !== '');
/**
* 点击文本复制明文(脱敏前的原始值)
*/
async function handleCopyText() {
if (!props.copyable || !hasValue.value) {
return;
}
try {
await copy(plainText.value);
message.success('复制成功');
} catch {
message.error('复制失败');
}
}
/**
* 切换明文 / 脱敏展示
*/
function toggleReveal() {
if (!hasValue.value) {
return;
}
revealed.value = !revealed.value;
}
</script>
<template>
<span class="sensitive-text">
<span
class="sensitive-text__value"
:class="{ 'sensitive-text__value--copyable': copyable && hasValue }"
@click="handleCopyText"
>
{{ displayText }}
</span>
<EyeOutlined
v-if="showEye && hasValue && !revealed"
class="sensitive-text__eye"
@click.stop="toggleReveal"
/>
<EyeInvisibleOutlined
v-else-if="showEye && hasValue && revealed"
class="sensitive-text__eye"
@click.stop="toggleReveal"
/>
</span>
</template>
<style scoped>
.sensitive-text {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
}
.sensitive-text__value {
word-break: break-all;
}
.sensitive-text__value--copyable {
cursor: pointer;
}
.sensitive-text__value--copyable:hover {
color: var(--ant-color-primary, #1677ff);
}
.sensitive-text__eye {
flex-shrink: 0;
font-size: 14px;
color: rgba(0, 0, 0, 0.45);
cursor: pointer;
transition: color 0.2s;
}
.sensitive-text__eye :deep(svg) {
fill: currentColor;
}
.sensitive-text__eye:hover {
color: var(--ant-color-primary, #1677ff);
}
/* 暗色模式:避免 fallback 黑色导致图标不可见 */
.dark .sensitive-text__eye {
color: rgba(255, 255, 255, 0.45);
}
.dark .sensitive-text__eye:hover {
color: var(--ant-color-primary, #4096ff);
}
.dark .sensitive-text__value--copyable:hover {
color: var(--ant-color-primary, #4096ff);
}
</style>

View File

@@ -0,0 +1,435 @@
<script setup lang="ts">
/**
* 通用 PC 悬浮子窗口:可拖拽、四角缩放、可最小化,始终保持在可视区域内
*/
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
import { usePreferences } from '@vben/preferences';
import { CloseOutlined, MinusOutlined } from '@ant-design/icons-vue';
import { SUB_WINDOW_POPUP_Z_INDEX_KEY } from './constants';
const props = withDefaults(
defineProps<{
/** 是否显示窗口 */
open: boolean;
/** 标题 */
title?: string;
/** localStorage 缓存 key */
storageKey?: string;
/** 初始宽度 */
width?: number;
/** 初始高度 */
height?: number;
/** 最小宽度 */
minWidth?: number;
/** 最小高度 */
minHeight?: number;
/** 初始 X-1 表示靠右 */
defaultX?: number;
/** 初始 Y */
defaultY?: number;
/** 层级 */
zIndex?: number;
}>(),
{
title: '窗口',
storageKey: 'pc_sub_window_default',
width: 420,
height: 520,
minWidth: 320,
minHeight: 240,
defaultX: -1,
defaultY: 80,
zIndex: 10000,
},
);
const emit = defineEmits<{
'update:open': [boolean];
close: [];
}>();
const { isDark } = usePreferences();
const posX = ref(100);
const posY = ref(80);
const winWidth = ref(props.width);
const winHeight = ref(props.height);
const minimized = ref(false);
type DragMode =
| ''
| 'move'
| 'resize-se'
| 'resize-sw'
| 'resize-ne'
| 'resize-nw';
const dragMode = ref<DragMode>('');
const dragStartX = ref(0);
const dragStartY = ref(0);
const originX = ref(0);
const originY = ref(0);
const originW = ref(0);
const originH = ref(0);
/** 弹出层 z-index需高于子窗口本体 */
const popupZIndex = computed(() => props.zIndex + 100);
provide(SUB_WINDOW_POPUP_Z_INDEX_KEY, popupZIndex);
const windowStyle = computed(() => ({
left: `${posX.value}px`,
top: `${posY.value}px`,
width: `${winWidth.value}px`,
height: minimized.value ? 'auto' : `${winHeight.value}px`,
zIndex: props.zIndex,
}));
/** 从缓存或默认值初始化窗口位置与尺寸 */
function initLayout() {
const cached = localStorage.getItem(props.storageKey);
if (cached) {
try {
const data = JSON.parse(cached);
posX.value = data.posX ?? posX.value;
posY.value = data.posY ?? posY.value;
winWidth.value = data.width ?? winWidth.value;
winHeight.value = data.height ?? winHeight.value;
minimized.value = !!data.minimized;
} catch {
/* ignore */
}
} else {
winWidth.value = props.width;
winHeight.value = props.height;
posX.value =
props.defaultX >= 0
? props.defaultX
: Math.max(16, window.innerWidth - winWidth.value - 24);
posY.value = props.defaultY;
}
clampWindow();
}
/** 约束窗口在可视区域内 */
function clampWindow() {
const h = minimized.value ? 44 : winHeight.value;
winWidth.value = Math.max(
props.minWidth,
Math.min(winWidth.value, window.innerWidth - 16),
);
winHeight.value = Math.max(
props.minHeight,
Math.min(winHeight.value, window.innerHeight - 16),
);
posX.value = Math.max(0, Math.min(posX.value, window.innerWidth - winWidth.value));
posY.value = Math.max(0, Math.min(posY.value, window.innerHeight - h));
}
function saveLayout() {
localStorage.setItem(
props.storageKey,
JSON.stringify({
posX: posX.value,
posY: posY.value,
width: winWidth.value,
height: winHeight.value,
minimized: minimized.value,
}),
);
}
function bindDragListeners() {
document.addEventListener('mousemove', onDocumentMouseMove);
document.addEventListener('mouseup', onDocumentMouseUp);
}
function onTitleMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
dragMode.value = 'move';
dragStartX.value = e.clientX;
dragStartY.value = e.clientY;
originX.value = posX.value;
originY.value = posY.value;
bindDragListeners();
}
/** 四角缩放开始:记录初始位置与尺寸 */
function onResizeMouseDown(e: MouseEvent, mode: DragMode) {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
dragMode.value = mode;
dragStartX.value = e.clientX;
dragStartY.value = e.clientY;
originX.value = posX.value;
originY.value = posY.value;
originW.value = winWidth.value;
originH.value = winHeight.value;
bindDragListeners();
}
/** 左/上方向缩放时,宽高触达最小值需同步修正锚点位置 */
function applyHorizontalResizeFromLeft(dx: number) {
let newW = originW.value - dx;
let newX = originX.value + dx;
if (newW < props.minWidth) {
newX = originX.value + originW.value - props.minWidth;
newW = props.minWidth;
}
winWidth.value = newW;
posX.value = newX;
}
function applyVerticalResizeFromTop(dy: number) {
let newH = originH.value - dy;
let newY = originY.value + dy;
if (newH < props.minHeight) {
newY = originY.value + originH.value - props.minHeight;
newH = props.minHeight;
}
winHeight.value = newH;
posY.value = newY;
}
function onDocumentMouseMove(e: MouseEvent) {
const dx = e.clientX - dragStartX.value;
const dy = e.clientY - dragStartY.value;
if (dragMode.value === 'move') {
posX.value = originX.value + dx;
posY.value = originY.value + dy;
clampWindow();
return;
}
if (dragMode.value === 'resize-se') {
winWidth.value = originW.value + dx;
winHeight.value = originH.value + dy;
} else if (dragMode.value === 'resize-sw') {
applyHorizontalResizeFromLeft(dx);
winHeight.value = originH.value + dy;
} else if (dragMode.value === 'resize-ne') {
winWidth.value = originW.value + dx;
applyVerticalResizeFromTop(dy);
} else if (dragMode.value === 'resize-nw') {
applyHorizontalResizeFromLeft(dx);
applyVerticalResizeFromTop(dy);
}
clampWindow();
}
function onDocumentMouseUp() {
dragMode.value = '';
document.removeEventListener('mousemove', onDocumentMouseMove);
document.removeEventListener('mouseup', onDocumentMouseUp);
saveLayout();
}
function toggleMinimize() {
minimized.value = !minimized.value;
clampWindow();
saveLayout();
}
function handleClose() {
emit('update:open', false);
emit('close');
}
/** 阻止滚轮穿透到主页面:内部可滚动区域正常滚,其余区域拦截 */
function onWindowWheel(e: WheelEvent) {
e.stopPropagation();
let node = e.target as HTMLElement | null;
const root = e.currentTarget as HTMLElement;
while (node && node !== root) {
const style = getComputedStyle(node);
const canScrollY =
node.scrollHeight > node.clientHeight &&
(style.overflowY === 'auto' || style.overflowY === 'scroll');
if (canScrollY) {
const goingUp = e.deltaY < 0;
const goingDown = e.deltaY > 0;
const atTop = node.scrollTop <= 0;
const atBottom =
node.scrollTop + node.clientHeight >= node.scrollHeight - 1;
if ((goingUp && !atTop) || (goingDown && !atBottom)) return;
}
node = node.parentElement;
}
e.preventDefault();
}
watch(
() => props.open,
(v) => {
if (v) initLayout();
},
);
onMounted(() => {
if (props.open) initLayout();
});
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onDocumentMouseMove);
document.removeEventListener('mouseup', onDocumentMouseUp);
});
</script>
<template>
<Teleport to="body">
<div v-if="open" class="sub-window-root">
<div
class="sub-window"
:class="{ minimized, 'is-dark': isDark }"
:style="windowStyle"
@wheel="onWindowWheel"
>
<div class="sub-window-header" @mousedown="onTitleMouseDown">
<span class="sub-window-title">{{ title }}</span>
<div class="sub-window-actions" @mousedown.stop>
<button type="button" class="action-btn" @click="toggleMinimize">
<MinusOutlined />
</button>
<button type="button" class="action-btn" @click="handleClose">
<CloseOutlined />
</button>
</div>
</div>
<div v-show="!minimized" class="sub-window-body">
<slot />
</div>
<template v-if="!minimized">
<div
class="resize-handle resize-handle-nw"
@mousedown="onResizeMouseDown($event, 'resize-nw')"
/>
<div
class="resize-handle resize-handle-ne"
@mousedown="onResizeMouseDown($event, 'resize-ne')"
/>
<div
class="resize-handle resize-handle-sw"
@mousedown="onResizeMouseDown($event, 'resize-sw')"
/>
<div
class="resize-handle resize-handle-se"
@mousedown="onResizeMouseDown($event, 'resize-se')"
/>
</template>
</div>
</div>
</Teleport>
</template>
<style scoped>
.sub-window-root {
pointer-events: none;
}
.sub-window {
position: fixed;
pointer-events: auto;
background: #fff;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid #e5e7eb;
}
.sub-window.is-dark {
background: #1f1f1f;
border-color: #424242;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
}
.sub-window-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: linear-gradient(135deg, #f0fdf4, #ecfdf5);
border-bottom: 1px solid #e5e7eb;
cursor: move;
user-select: none;
flex-shrink: 0;
}
.sub-window.is-dark .sub-window-header {
background: linear-gradient(135deg, #1a2e22, #1f1f1f);
border-bottom-color: #424242;
}
.sub-window-title {
font-size: 14px;
font-weight: 600;
color: #1d2129;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub-window.is-dark .sub-window-title {
color: rgba(255, 255, 255, 0.85);
}
.sub-window-actions {
display: flex;
gap: 4px;
}
.action-btn {
width: 28px;
height: 28px;
border: none;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: #666;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.06);
}
.sub-window.is-dark .action-btn {
color: rgba(255, 255, 255, 0.65);
}
.sub-window.is-dark .action-btn:hover {
background: rgba(255, 255, 255, 0.08);
}
.sub-window-body {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
overscroll-behavior: contain;
}
/* 四角透明缩放热区,无视觉提示 */
.resize-handle {
position: absolute;
width: 16px;
height: 16px;
z-index: 20;
pointer-events: auto;
background: transparent;
}
.resize-handle-nw {
left: 0;
top: 0;
cursor: nwse-resize;
}
.resize-handle-ne {
right: 0;
top: 0;
cursor: nesw-resize;
}
.resize-handle-sw {
left: 0;
bottom: 0;
cursor: nesw-resize;
}
.resize-handle-se {
right: 0;
bottom: 0;
cursor: nwse-resize;
}
</style>

View File

@@ -0,0 +1,10 @@
/** 子窗口默认 z-index */
export const DEFAULT_SUB_WINDOW_Z_INDEX = 10000;
/** 子窗口内 Ant Design 弹出层 inject key */
export const SUB_WINDOW_POPUP_Z_INDEX_KEY = Symbol('subWindowPopupZIndex');
/** 根据子窗口 z-index 计算弹出层层级 */
export function getSubWindowPopupZIndex(baseZIndex = DEFAULT_SUB_WINDOW_Z_INDEX) {
return baseZIndex + 100;
}

View File

@@ -29,6 +29,10 @@ const props = defineProps({
type: Boolean,
default: true,
},
flex: {
type: Boolean,
default: true,
},
});
const MenuItem = Menu.Item;
@@ -128,22 +132,61 @@ const handleMenuClick = (e: any) => {
getActions?.some((item: ActionItem) => item.type === 'link') ? 0 : 8
"
>
<template v-for="(action, index) in getActions" :key="index">
<div
v-if="getActions.length > 2 && props.flex !== false"
class="grid grid-cols-2 gap-3 md:gap-4"
>
<template v-for="(action, index) in getActions" :key="index">
<Popconfirm
v-if="action.popConfirm"
v-bind="getPopConfirmProps(action.popConfirm)"
class="w-full"
>
<template v-if="action.popConfirm.icon" #icon>
<Icon :icon="action.popConfirm.icon" />
</template>
<Button v-bind="getButtonProps(action)" class="w-full">
<template v-if="action.icon" #icon>
<Icon :icon="action.icon" />
</template>
{{ action.label }}
</Button>
</Popconfirm>
<Button
v-else
v-bind="getButtonProps(action)"
@click="action.onClick"
class="w-full"
>
<template v-if="action.icon" #icon>
<Icon :icon="action.icon" />
</template>
{{ action.label }}
</Button>
</template>
</div>
<template v-for="(action, index) in getActions" v-else :key="index">
<Popconfirm
v-if="action.popConfirm"
v-bind="getPopConfirmProps(action.popConfirm)"
class="w-full"
>
<template v-if="action.popConfirm.icon" #icon>
<Icon :icon="action.popConfirm.icon" />
</template>
<Button v-bind="getButtonProps(action)">
<Button v-bind="getButtonProps(action)" class="w-full">
<template v-if="action.icon" #icon>
<Icon :icon="action.icon" />
</template>
{{ action.label }}
</Button>
</Popconfirm>
<Button v-else v-bind="getButtonProps(action)" @click="action.onClick">
<Button
v-else
v-bind="getButtonProps(action)"
@click="action.onClick"
class="w-full"
>
<template v-if="action.icon" #icon>
<Icon :icon="action.icon" />
</template>

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import Player from 'xgplayer';
import 'xgplayer/dist/index.min.css';
interface Props {
src: string;
autoplay?: boolean;
controls?: boolean;
thumbnail?: boolean;
width?: number | string;
height?: number | string;
poster?: string;
}
const props = withDefaults(defineProps<Props>(), {
autoplay: false,
controls: true,
thumbnail: false,
width: '100%',
height: '100%',
poster: undefined,
});
const emit = defineEmits<{
ready: [player: Player];
error: [error: any];
ended: [];
}>();
const containerRef = ref<HTMLDivElement>();
const player = shallowRef<Player | null>(null);
function createPlayer() {
if (!containerRef.value || !props.src) return;
player.value?.destroy();
player.value = null;
const ignores = props.thumbnail
? [
'cssfullscreen',
'screenshot',
'pip',
'miniscreen',
'keyboard',
'download',
'playbackrate',
'time',
'definition',
'fullscreen',
'loading',
'mobile',
'pc',
'play',
'poster',
'progress',
'replay',
'start',
'volume',
]
: [];
const p = new Player({
el: containerRef.value,
url: props.src,
autoplay: props.autoplay,
controls: props.thumbnail ? false : props.controls,
closeVideoClick: props.thumbnail,
closeVideoDblclick: props.thumbnail,
ignores,
fluid: true,
fitVideoSize: 'fixWidth',
videoInit: true,
poster: props.poster,
playsinline: true,
enableContextmenu: !props.thumbnail,
});
p.on('ready', () => {
emit('ready', p);
});
p.on('error', (e: any) => {
emit('error', e);
});
p.on('ended', () => {
emit('ended');
});
player.value = p;
}
onMounted(() => {
createPlayer();
});
onBeforeUnmount(() => {
player.value?.destroy();
player.value = null;
});
watch(
() => props.src,
() => {
createPlayer();
},
);
defineExpose({
getPlayer: () => player.value,
play: () => player.value?.play(),
pause: () => player.value?.pause(),
destroy: () => {
player.value?.destroy();
player.value = null;
},
});
</script>
<template>
<div ref="containerRef" class="xg-video-player"></div>
</template>
<style scoped>
.xg-video-player {
width: v-bind(width);
height: v-bind(height);
}
</style>

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