Compare commits
67 Commits
8ad4329632
...
feat/media
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c88f1573a5 | ||
|
|
15b6bec215 | ||
|
|
5d256fbf52 | ||
|
|
984fca627b | ||
|
|
288c73fd14 | ||
|
|
c814a4a8f3 | ||
|
|
a78673ef41 | ||
|
|
c87760f938 | ||
|
|
75f0dc4354 | ||
|
|
1945ef3f78 | ||
|
|
ffbe5a797f | ||
|
|
6457c53d9c | ||
|
|
3351c68d10 | ||
|
|
57192b2ec0 | ||
|
|
3d683dfe82 | ||
|
|
ee5ae9e1fb | ||
|
|
01a0fffc43 | ||
|
|
976b9d94a0 | ||
|
|
5d61f19966 | ||
|
|
1982698b79 | ||
|
|
76a89dc7de | ||
|
|
201a91580c | ||
|
|
a5c67d6bec | ||
|
|
958110cc73 | ||
|
|
13705a3eaf | ||
|
|
92ffd8cecb | ||
|
|
6d073840aa | ||
|
|
f96e88b17b | ||
|
|
dc5d689d11 | ||
|
|
695b61f850 | ||
|
|
8d3b3a0491 | ||
|
|
bc3c056c8d | ||
|
|
3e36e31cf6 | ||
|
|
ec404a3e25 | ||
|
|
7924768df3 | ||
|
|
ac9896d375 | ||
|
|
a86219c8a0 | ||
|
|
432c5d96f2 | ||
|
|
aa6d93129d | ||
|
|
9a67bf80c1 | ||
|
|
03ead44385 | ||
|
|
ddf5c196e4 | ||
|
|
23cd0b14a7 | ||
|
|
803a27fb84 | ||
|
|
a4d09269a4 | ||
|
|
b0323ec89c | ||
|
|
2324d770bc | ||
|
|
823d69a3c3 | ||
|
|
7d1b1f9c7c | ||
|
|
e94f5bc00c | ||
|
|
0a1f4cb9a9 | ||
|
|
23c5bbdb40 | ||
|
|
2ccf0801b7 | ||
|
|
86dbd74f3f | ||
|
|
0edb475638 | ||
|
|
a7526455b4 | ||
|
|
dda8be9079 | ||
|
|
af2484e63b | ||
|
|
10c28df152 | ||
|
|
3c7b48b6f6 | ||
|
|
3972db4f98 | ||
|
|
9d6a92e1d7 | ||
|
|
42299a6722 | ||
|
|
a2864713d6 | ||
|
|
ba93e6585c | ||
|
|
d7fb5da873 | ||
|
|
8027cdd1d9 |
@@ -46,6 +46,47 @@ jobs:
|
||||
ERP__JWT__SECRET: ci-test-secret
|
||||
ERP__AUTH__SUPER_ADMIN_PASSWORD: CI_Test_Pass_2026
|
||||
|
||||
# PP-10: 覆盖率 baseline(软门禁阶段)
|
||||
# 当前 continue-on-error=true,先让覆盖率可见、生成报告 artifact。
|
||||
# 后续根据 baseline 真实数据提高 fail-under 阈值(目标 service 层 ≥60%)并去掉
|
||||
# continue-on-error 硬化门禁。见 docs/discussions/2026-06-25-analysis/ PP-10。
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: erp_test
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
- name: Install cargo-tarpaulin
|
||||
run: cargo install cargo-tarpaulin --locked
|
||||
- name: Run coverage (fail-under 20% baseline)
|
||||
run: cargo tarpaulin --workspace --out Xml --output-dir coverage --fail-under 20 -- --test-threads=2
|
||||
env:
|
||||
ERP__DATABASE__URL: postgres://test:test@localhost:5432/erp_test
|
||||
ERP__JWT__SECRET: ci-test-secret
|
||||
ERP__AUTH__SUPER_ADMIN_PASSWORD: CI_Test_Pass_2026
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
if-no-files-found: warn
|
||||
|
||||
frontend-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: 123123
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
@@ -28,9 +28,9 @@ jobs:
|
||||
--health-retries 5
|
||||
|
||||
env:
|
||||
TEST_DB_URL: postgres://postgres:123123@localhost:5432/postgres
|
||||
TEST_DB_URL: postgres://postgres:postgres@localhost:5432/postgres
|
||||
JWT_SECRET: test-jwt-secret-for-ci
|
||||
DATABASE_URL: postgres://postgres:123123@localhost:5432/erp_ci
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/erp_ci
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Security audit (npm)
|
||||
run: npx npm-audit --audit-level=high || true
|
||||
run: npx npm-audit --audit-level=high
|
||||
|
||||
miniprogram-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -82,6 +82,28 @@ tmp/
|
||||
screenshots/
|
||||
server-log.txt
|
||||
snapshot_*.txt
|
||||
_*.txt
|
||||
_server_*.txt
|
||||
tmp_*.txt
|
||||
direct_*.txt
|
||||
server_*.txt
|
||||
server_combined.txt
|
||||
out.txt
|
||||
_wx_login.json
|
||||
.claude/settings.json
|
||||
|
||||
# Trace/debug JSON
|
||||
trace-*.json
|
||||
|
||||
# Graphify knowledge graph (regenerated locally)
|
||||
graphify-out/
|
||||
|
||||
# Native miniprogram (separate project)
|
||||
apps/mp-native/
|
||||
|
||||
# Misc untracked
|
||||
err.txt
|
||||
uploads/g:/hms/.superpowers/
|
||||
.claude/skills/design-handoff/node_modules/
|
||||
.design/config.yml
|
||||
.superpowers/
|
||||
|
||||
33
CLAUDE.md
33
CLAUDE.md
@@ -505,12 +505,31 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
| 开发进度、模块状态 | `wiki/index.md` 关键数字 |
|
||||
| 环境配置、连接信息、登录凭据 | `wiki/infrastructure.md` §2 |
|
||||
|
||||
## graphify
|
||||
## graphify — 代码知识图谱
|
||||
|
||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||
> 项目知识图谱位于 `graphify-out/`,当前规模:18,517 节点 / 22,666 边 / 1,841 社区(纯 AST 解析,无 API 成本)。
|
||||
> 工具:`python -m graphify`(已安装 graphifyy 0.8.18)。
|
||||
|
||||
Rules:
|
||||
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||
### 开发流程中的使用场景
|
||||
|
||||
| 时机 | 命令 | 目的 |
|
||||
|------|------|------|
|
||||
| **接手新任务,理解代码关系** | `graphify query "概念名"` | 搜索相关节点,比 Grep 更精准(按调用/引用/包含关系) |
|
||||
| **排查 bug,追踪调用链** | `graphify path "A" "B"` | 查找两个模块/函数间的最短路径 |
|
||||
| **理解某个模块的职责** | `graphify explain "模块名"` | 自然语言解释节点及其邻居 |
|
||||
| **代码改动后** | `graphify update .` | 增量更新图谱(AST-only,秒级完成) |
|
||||
| **宏观架构审查** | 读 `graphify-out/GRAPH_REPORT.md` | 全局社区结构、跨文件关系概览 |
|
||||
|
||||
### 使用优先级(融入 §2.5 闭环工作法)
|
||||
|
||||
在 §2.5 步骤 1「现状确认」中,**优先使用 graphify 替代盲目 Grep**:
|
||||
|
||||
1. **先 `graphify query`** — 精确定位相关节点和社区(比 Grep 返回更结构化的结果)
|
||||
2. **再 `graphify path`** — 确认模块间依赖路径(避免遗漏间接依赖)
|
||||
3. **最后 Grep/Glob/Read** — 确认 graphify 发现的具体文件内容
|
||||
|
||||
### 注意事项
|
||||
|
||||
- `graphify update .` 纯本地 AST 解析,不消耗 LLM token,每次代码改动后都可以运行
|
||||
- 查询结果比 GRAPH_REPORT.md 更精准,优先使用 query/path/explain,仅在需要全局视图时读报告
|
||||
- 首次生成需几分钟(1712 文件),后续增量更新秒级完成
|
||||
|
||||
@@ -120,6 +120,9 @@ handlebars = "6"
|
||||
# HTML sanitization
|
||||
ammonia = "4"
|
||||
|
||||
# Document parsing
|
||||
pdf-extract = "0.7"
|
||||
|
||||
# Metrics
|
||||
metrics = "0.24"
|
||||
metrics-exporter-prometheus = "0.16"
|
||||
|
||||
863
apps/miniprogram/native/pkg-veepoo/index.js
Normal file
863
apps/miniprogram/native/pkg-veepoo/index.js
Normal file
@@ -0,0 +1,863 @@
|
||||
/**
|
||||
* Veepoo M2 原生小程序页面 — 连接 + 测量
|
||||
*
|
||||
* 完全脱离 Taro 框架,直接使用微信原生 API + Veepoo SDK。
|
||||
* 流程严格对齐官方 Demo:
|
||||
* onLoad 注册全局监听器
|
||||
* → scan → stopScan → connect(等待 connection:true)
|
||||
* → delay 500ms → authenticate
|
||||
* → SDK 事件(type=1) / Storage 轮询 → ready
|
||||
*/
|
||||
// eslint-disable-next-line no-undef
|
||||
const { veepooBle, veepooFeature, veepooLogger } = require('./libs/veepoo-sdk');
|
||||
|
||||
// ── 常量 ──
|
||||
|
||||
var SDK_EVENT_AUTH = 1;
|
||||
var SDK_EVENT_BATTERY = 2;
|
||||
var SDK_EVENT_SLEEP = 4;
|
||||
var SDK_EVENT_DAILY = 5;
|
||||
var SDK_EVENT_TEMPERATURE = 6;
|
||||
var SDK_EVENT_BLOOD_PRESSURE = 18;
|
||||
var SDK_EVENT_BLOOD_OXYGEN = 31;
|
||||
var SDK_EVENT_HEART_RATE = 51;
|
||||
var SDK_EVENT_PRESSURE = 58;
|
||||
var SDK_EVENT_AUTO_TEST = 54;
|
||||
|
||||
var MEASURE_TYPES = [
|
||||
{ type: 'heart_rate', label: '心率', unit: 'bpm', icon: '♥', color: '#EF4444', sdkType: SDK_EVENT_HEART_RATE },
|
||||
{ type: 'blood_oxygen', label: '血氧', unit: '%', icon: 'O₂', color: '#3B82F6', sdkType: SDK_EVENT_BLOOD_OXYGEN },
|
||||
{ type: 'blood_pressure', label: '血压', unit: 'mmHg', icon: '↕', color: '#8B5CF6', sdkType: SDK_EVENT_BLOOD_PRESSURE },
|
||||
{ type: 'temperature', label: '体温', unit: '°C', icon: 'T', color: '#F59E0B', sdkType: SDK_EVENT_TEMPERATURE },
|
||||
{ type: 'pressure', label: '压力', unit: '', icon: '~', color: '#6366F1', sdkType: SDK_EVENT_PRESSURE },
|
||||
];
|
||||
|
||||
var MEASURE_TIMEOUTS = {
|
||||
heart_rate: 60000,
|
||||
blood_oxygen: 60000,
|
||||
blood_pressure: 120000,
|
||||
temperature: 60000,
|
||||
pressure: 90000,
|
||||
};
|
||||
|
||||
var MEASURE_SETTLE_DELAY = 1500;
|
||||
|
||||
function _findConfig(type) {
|
||||
for (var i = 0; i < MEASURE_TYPES.length; i++) {
|
||||
if (MEASURE_TYPES[i].type === type) return MEASURE_TYPES[i];
|
||||
}
|
||||
return MEASURE_TYPES[0];
|
||||
}
|
||||
|
||||
// ── Page ──
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
Page({
|
||||
data: {
|
||||
phase: 'idle',
|
||||
deviceId: '',
|
||||
deviceName: 'M2',
|
||||
batteryLevel: null,
|
||||
error: '',
|
||||
selectedType: 'heart_rate',
|
||||
selectedIcon: '♥',
|
||||
selectedColor: '#EF4444',
|
||||
selectedLabel: '心率',
|
||||
selectedUnit: 'bpm',
|
||||
measureTypes: MEASURE_TYPES,
|
||||
measurePhase: 'idle',
|
||||
measureProgress: 0,
|
||||
measureDisplayValue: '',
|
||||
measureError: '',
|
||||
results: {},
|
||||
hasResults: false,
|
||||
// 自动测量状态
|
||||
autoMeasuring: false,
|
||||
autoMeasureDone: false,
|
||||
autoMeasureStatus: {},
|
||||
autoMeasureValues: {},
|
||||
autoMeasureProgress: 0,
|
||||
},
|
||||
|
||||
_authTimer: null,
|
||||
_authTimeout: null,
|
||||
_scanTimer: null,
|
||||
_scanFound: null,
|
||||
_measureTimer: null,
|
||||
_settleTimer: null,
|
||||
_lastValues: null,
|
||||
_connected: false,
|
||||
_eventChannel: null,
|
||||
_connecting: false,
|
||||
_listenersRegistered: false,
|
||||
_autoQueue: null,
|
||||
_autoQueueIndex: 0,
|
||||
|
||||
// ── 生命周期 ──
|
||||
|
||||
onLoad: function () {
|
||||
// eslint-disable-next-line no-undef
|
||||
this._eventChannel = this.getOpenerEventChannel();
|
||||
// eslint-disable-next-line no-undef
|
||||
wx.setNavigationBarTitle({ title: 'M2 手环测量' });
|
||||
this._updateSelectedDisplay('heart_rate');
|
||||
|
||||
// 注意:不在 onLoad 注册 veepooWeiXinSDKNotifyMonitorValueChange!
|
||||
// 该函数内部会调用 wx.notifyBLECharacteristicValueChange,需要蓝牙适配器已初始化。
|
||||
// onLoad 时适配器未初始化 → 返回 "notifyBLECharacteristicValueChange:fail:not init"
|
||||
// 监听器改在 _doConnect 的 connection:true 回调中注册。
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 页面已加载');
|
||||
},
|
||||
|
||||
onUnload: function () {
|
||||
this._cleanup();
|
||||
},
|
||||
|
||||
_cleanup: function () {
|
||||
if (this._authTimer) { clearInterval(this._authTimer); this._authTimer = null; }
|
||||
if (this._authTimeout) { clearTimeout(this._authTimeout); this._authTimeout = null; }
|
||||
if (this._scanTimer) { clearTimeout(this._scanTimer); this._scanTimer = null; }
|
||||
if (this._measureTimer) { clearTimeout(this._measureTimer); this._measureTimer = null; }
|
||||
if (this._settleTimer) { clearTimeout(this._settleTimer); this._settleTimer = null; }
|
||||
this._connecting = false;
|
||||
if (this._connected) {
|
||||
try { veepooBle.veepooWeiXinSDKloseBluetoothAdapterManager(function () {}); } catch (e) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.warn('[veepoo-native] 断开异常:', e);
|
||||
}
|
||||
this._connected = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ── 全局监听器(onLoad 注册一次) ──
|
||||
|
||||
_registerGlobalListeners: function () {
|
||||
if (this._listenersRegistered) return;
|
||||
this._listenersRegistered = true;
|
||||
var self = this;
|
||||
|
||||
// SDK 数据监听 — 接收所有解析后的事件(auth/measure/battery 等)
|
||||
veepooBle.veepooWeiXinSDKNotifyMonitorValueChange(function (data) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] SDK 数据事件:', JSON.stringify(data).substring(0, 500));
|
||||
self._handleSdkEvent(data);
|
||||
});
|
||||
|
||||
// BLE 连接状态变化
|
||||
veepooBle.veepooWeiXinSDKBLEConnectionStateChangeManager(function (res) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 连接状态变化:', JSON.stringify(res));
|
||||
if (!res.connected) {
|
||||
self._connected = false;
|
||||
self._connecting = false;
|
||||
self._cancelPendingMeasure();
|
||||
self.setData({ phase: 'disconnected' });
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] SDK 函数类型:', {
|
||||
scanFn: typeof veepooBle.veepooWeiXinSDKStartScanDeviceAndReceiveScanningDevice,
|
||||
connectFn: typeof veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager,
|
||||
dataFn: typeof veepooBle.veepooWeiXinSDKNotifyMonitorValueChange,
|
||||
authFn: typeof veepooFeature.veepooBlePasswordCheckManager,
|
||||
});
|
||||
},
|
||||
|
||||
_updateSelectedDisplay: function (type) {
|
||||
var cfg = _findConfig(type);
|
||||
this.setData({
|
||||
selectedType: type,
|
||||
selectedIcon: cfg.icon,
|
||||
selectedColor: cfg.color,
|
||||
selectedLabel: cfg.label,
|
||||
selectedUnit: cfg.unit,
|
||||
});
|
||||
},
|
||||
|
||||
// ── 连接流程 ──
|
||||
|
||||
handleConnect: function () {
|
||||
if (this.data.phase !== 'idle' && this.data.phase !== 'error' && this.data.phase !== 'disconnected') return;
|
||||
if (this._connecting) return;
|
||||
this._connecting = true;
|
||||
|
||||
this.setData({ phase: 'scanning', error: '' });
|
||||
veepooLogger.setLevel(0);
|
||||
|
||||
var self = this;
|
||||
self._scanFound = null;
|
||||
|
||||
veepooBle.veepooWeiXinSDKStartScanDeviceAndReceiveScanningDevice(function (res) {
|
||||
var device = Array.isArray(res) ? res[0] : res;
|
||||
if (!device) return;
|
||||
var name = (device.localName || device.name || '').toUpperCase();
|
||||
var deviceId = device.deviceId || device.mac || '';
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 扫描到:', name, deviceId);
|
||||
if (!self._scanFound && (name.indexOf('M2') !== -1 || name.indexOf('VPM') !== -1 || name.indexOf('VEEPOO') !== -1)) {
|
||||
self._scanFound = device;
|
||||
veepooBle.veepooWeiXinSDKStopSearchBleManager(function () {
|
||||
self._doConnect(device);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this._scanTimer = setTimeout(function () {
|
||||
if (!self._scanFound) {
|
||||
veepooBle.veepooWeiXinSDKStopSearchBleManager(function () {});
|
||||
self._connecting = false;
|
||||
self.setData({ phase: 'error', error: '未找到 M2 设备,请确保手环已开机且蓝牙已开启' });
|
||||
}
|
||||
}, 15000);
|
||||
},
|
||||
|
||||
_doConnect: function (device) {
|
||||
this.setData({ phase: 'connecting' });
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 开始连接:', device.deviceId || device.mac);
|
||||
var self = this;
|
||||
|
||||
veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager(device, function (result) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 连接阶段回调:', JSON.stringify(result).substring(0, 300));
|
||||
|
||||
// 只响应最终就绪回调(connection:true)
|
||||
if (result.connection === true) {
|
||||
self._connected = true;
|
||||
self._connecting = false;
|
||||
self.setData({
|
||||
deviceId: device.deviceId || device.mac || '',
|
||||
});
|
||||
|
||||
// 关键:在连接就绪后注册数据监听器
|
||||
// veepooWeiXinSDKNotifyMonitorValueChange 内部会调用
|
||||
// wx.notifyBLECharacteristicValueChange,需要蓝牙适配器已初始化+已连接
|
||||
self._registerGlobalListeners();
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 连接就绪,监听器已注册,500ms 后发送认证');
|
||||
|
||||
setTimeout(function () {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 调用 veepooBlePasswordCheckManager');
|
||||
veepooFeature.veepooBlePasswordCheckManager();
|
||||
self.setData({ phase: 'authenticating' });
|
||||
}, 500);
|
||||
|
||||
// Storage 轮询兜底
|
||||
self._authTimer = setInterval(function () {
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
var status = wx.getStorageSync('deviceChipStatus');
|
||||
// SDK 可能写入字符串或布尔值 true
|
||||
if (status === 'successfulVerification' || status === 'passTheVerification' || status === true) {
|
||||
clearInterval(self._authTimer);
|
||||
self._authTimer = null;
|
||||
self._onReady();
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
}, 500);
|
||||
|
||||
self._authTimeout = setTimeout(function () {
|
||||
if (self._authTimer) {
|
||||
clearInterval(self._authTimer);
|
||||
self._authTimer = null;
|
||||
self._connecting = false;
|
||||
// eslint-disable-next-line no-undef
|
||||
console.error('[veepoo-native] 认证超时 deviceChipStatus=', wx.getStorageSync('deviceChipStatus'));
|
||||
self.setData({ phase: 'error', error: '设备认证超时,请重新连接' });
|
||||
}
|
||||
}, 8000);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_onReady: function () {
|
||||
if (this._authTimeout) { clearTimeout(this._authTimeout); this._authTimeout = null; }
|
||||
this._connecting = false;
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 认证成功,设备就绪');
|
||||
this.setData({ phase: 'ready' });
|
||||
veepooFeature.veepooReadElectricQuantityManager();
|
||||
|
||||
// 认证成功后自动读取 3 天睡眠数据 + 开启自动测量
|
||||
this._readSleepData();
|
||||
this._enableAutoMeasurement();
|
||||
|
||||
// 自动依次测量所有指标(面向中老年人,减少操作)
|
||||
this._startAutoMeasureQueue();
|
||||
},
|
||||
|
||||
// ── SDK 事件路由 ──
|
||||
|
||||
_handleSdkEvent: function (data) {
|
||||
if (!data || data.type === undefined) return;
|
||||
var type = data.type;
|
||||
|
||||
if (type === SDK_EVENT_AUTH) {
|
||||
var content = data.content || {};
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 认证事件: VPDeviceAck=' + content.VPDeviceAck + ' VPDevicepassword=' + content.VPDevicepassword);
|
||||
// VPDeviceAck 是认证结果(successfulVerification/passTheVerification)
|
||||
// VPDevicepassword 是设备密码原始值(如 "0000"),不是认证结果
|
||||
if (content.VPDeviceAck === 'successfulVerification' || content.VPDeviceAck === 'passTheVerification') {
|
||||
if (this._authTimer) { clearInterval(this._authTimer); this._authTimer = null; }
|
||||
if (this._authTimeout) { clearTimeout(this._authTimeout); this._authTimeout = null; }
|
||||
this._onReady();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === SDK_EVENT_BATTERY) {
|
||||
var pct = (data.content || {}).VPDeviceElectricPercent;
|
||||
if (pct !== undefined && pct !== null) {
|
||||
this.setData({ batteryLevel: Number(pct) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 睡眠数据回调(type=4)
|
||||
if (type === SDK_EVENT_SLEEP) {
|
||||
this._handleSleepEvent(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 日常数据回调(type=5)
|
||||
if (type === SDK_EVENT_DAILY) {
|
||||
// 日常数据用于历史同步,原生页面暂不处理
|
||||
return;
|
||||
}
|
||||
|
||||
// 自动测量配置回调(type=54)
|
||||
if (type === SDK_EVENT_AUTO_TEST) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 自动测量配置回调');
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < MEASURE_TYPES.length; i++) {
|
||||
if (MEASURE_TYPES[i].sdkType === type) {
|
||||
this._handleMeasureEvent(MEASURE_TYPES[i].type, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── 测量流程 ──
|
||||
|
||||
handleSelectType: function (e) {
|
||||
var type = e.currentTarget.dataset.type;
|
||||
if (this.data.measurePhase === 'measuring') return;
|
||||
this._updateSelectedDisplay(type);
|
||||
this.setData({ measureError: '' });
|
||||
},
|
||||
|
||||
handleStartMeasure: function () {
|
||||
var type = this.data.selectedType;
|
||||
if (this.data.measurePhase === 'measuring') return;
|
||||
if (!this._connected) {
|
||||
this.setData({ measureError: '设备未连接' });
|
||||
return;
|
||||
}
|
||||
|
||||
var self = this;
|
||||
self._lastValues = null;
|
||||
|
||||
self.setData({
|
||||
measurePhase: 'measuring',
|
||||
measureProgress: 0,
|
||||
measureDisplayValue: '',
|
||||
measureError: '',
|
||||
});
|
||||
|
||||
self._sendMeasureCommand(type, true);
|
||||
|
||||
self._measureTimer = setTimeout(function () {
|
||||
self._onMeasureError('测量超时,请重试');
|
||||
}, MEASURE_TIMEOUTS[type] || 60000);
|
||||
},
|
||||
|
||||
handleCancelMeasure: function () {
|
||||
this._cancelPendingMeasure();
|
||||
this.setData({
|
||||
measurePhase: 'idle',
|
||||
measureProgress: 0,
|
||||
measureDisplayValue: '',
|
||||
});
|
||||
},
|
||||
|
||||
handleDisconnect: function () {
|
||||
this._cleanup();
|
||||
this.setData({ phase: 'idle', deviceId: '', batteryLevel: null, error: '' });
|
||||
},
|
||||
|
||||
handleBack: function () {
|
||||
var results = this.data.results;
|
||||
if (Object.keys(results).length > 0) {
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
wx.setStorageSync('hms:veepoo_measure_results', JSON.stringify(results));
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
if (this._eventChannel) {
|
||||
this._eventChannel.emit('measureComplete', { results: results, count: Object.keys(results).length });
|
||||
}
|
||||
// eslint-disable-next-line no-undef
|
||||
wx.navigateBack({ delta: 1 });
|
||||
},
|
||||
|
||||
handleResetResult: function () {
|
||||
var type = this.data.selectedType;
|
||||
var newResults = Object.assign({}, this.data.results);
|
||||
delete newResults[type];
|
||||
this.setData({
|
||||
measurePhase: 'idle',
|
||||
measureProgress: 0,
|
||||
measureDisplayValue: '',
|
||||
measureError: '',
|
||||
results: newResults,
|
||||
hasResults: Object.keys(newResults).length > 0,
|
||||
});
|
||||
},
|
||||
|
||||
// ── 测量事件处理 ──
|
||||
|
||||
_handleMeasureEvent: function (type, data) {
|
||||
// 自动测量模式下,路由到自动测量处理器
|
||||
if (this.data.autoMeasuring) {
|
||||
this._handleAutoMeasureEvent(type, data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 手动测量模式
|
||||
if (this.data.selectedType !== type || this.data.measurePhase !== 'measuring') return;
|
||||
|
||||
var content = data.content || {};
|
||||
var self = this;
|
||||
|
||||
if (content.deviceBusy === true) { self._onMeasureError('设备正忙,请稍后重试'); return; }
|
||||
if (content.notWear === true || data.state === 6) { self._onMeasureError('请将手环佩戴到手腕上'); return; }
|
||||
if (data.state === 7) { self._onMeasureError('设备正在充电'); return; }
|
||||
if (data.state === 8) { self._onMeasureError('设备电量不足'); return; }
|
||||
if (type === 'pressure' && data.ack === 2) { self._onMeasureError('设备电量不足'); return; }
|
||||
if (type === 'pressure' && data.ack === 3) { self._onMeasureError('设备正在测量其他数据'); return; }
|
||||
if (type === 'pressure' && data.ack === 4) { self._onMeasureError('佩戴检测未通过'); return; }
|
||||
|
||||
var values = self._extractValues(type, content);
|
||||
if (!values) return;
|
||||
|
||||
self._lastValues = values;
|
||||
|
||||
var displayVal = self._formatValues(type, values);
|
||||
var progress = data.Progress !== undefined ? data.Progress : 0;
|
||||
self.setData({
|
||||
measureDisplayValue: displayVal,
|
||||
measureProgress: Math.max(progress, 0),
|
||||
});
|
||||
|
||||
if (progress >= 100) {
|
||||
self._onMeasureSuccess(type, values);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self._settleTimer) {
|
||||
self._settleTimer = setTimeout(function () {
|
||||
if (self._lastValues && self.data.measurePhase === 'measuring') {
|
||||
self._onMeasureSuccess(type, self._lastValues);
|
||||
}
|
||||
}, MEASURE_SETTLE_DELAY);
|
||||
}
|
||||
},
|
||||
|
||||
_extractValues: function (type, content) {
|
||||
switch (type) {
|
||||
case 'heart_rate': {
|
||||
var hr = Number(content.heartRate);
|
||||
return (hr >= 30 && hr <= 250) ? { heart_rate: hr } : null;
|
||||
}
|
||||
case 'blood_oxygen': {
|
||||
var bo = Number(content.bloodOxygen);
|
||||
return (bo >= 70 && bo <= 100) ? { blood_oxygen: bo } : null;
|
||||
}
|
||||
case 'blood_pressure': {
|
||||
var high = Number(content.bloodPressureHigh);
|
||||
var low = Number(content.bloodPressureLow);
|
||||
return (high > 0 && low > 0) ? { systolic: high, diastolic: low } : null;
|
||||
}
|
||||
case 'temperature': {
|
||||
var temp = Number(content.bodyTemperature);
|
||||
return (temp > 30 && temp < 45) ? { temperature: temp } : null;
|
||||
}
|
||||
case 'pressure': {
|
||||
var p = Number(content.pressure);
|
||||
return (p >= 0 && p <= 100) ? { pressure: p } : null;
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
},
|
||||
|
||||
_formatValues: function (type, values) {
|
||||
if (type === 'blood_pressure') {
|
||||
return (values.systolic != null ? values.systolic : '--') + '/' + (values.diastolic != null ? values.diastolic : '--');
|
||||
}
|
||||
var v = Object.values(values)[0];
|
||||
return (v !== undefined && v !== null) ? String(v) : '--';
|
||||
},
|
||||
|
||||
_onMeasureSuccess: function (type, values) {
|
||||
this._cancelPendingMeasure();
|
||||
|
||||
var result = { type: type, values: values, measuredAt: Date.now() };
|
||||
var newResults = Object.assign({}, this.data.results);
|
||||
newResults[type] = result;
|
||||
|
||||
this.setData({
|
||||
measurePhase: 'success',
|
||||
measureProgress: 100,
|
||||
measureDisplayValue: this._formatValues(type, values),
|
||||
results: newResults,
|
||||
hasResults: true,
|
||||
});
|
||||
|
||||
if (this._eventChannel) {
|
||||
this._eventChannel.emit('measureResult', result);
|
||||
}
|
||||
},
|
||||
|
||||
_onMeasureError: function (msg) {
|
||||
this._cancelPendingMeasure();
|
||||
this.setData({ measurePhase: 'error', measureError: msg });
|
||||
},
|
||||
|
||||
_cancelPendingMeasure: function () {
|
||||
if (this._measureTimer) { clearTimeout(this._measureTimer); this._measureTimer = null; }
|
||||
if (this._settleTimer) { clearTimeout(this._settleTimer); this._settleTimer = null; }
|
||||
this._lastValues = null;
|
||||
|
||||
var type = this.data.selectedType;
|
||||
if (type) this._sendMeasureCommand(type, false);
|
||||
},
|
||||
|
||||
_sendMeasureCommand: function (type, on) {
|
||||
switch (type) {
|
||||
case 'heart_rate':
|
||||
veepooFeature.veepooSendHeartRateTestSwitchManager({ switch: !!on });
|
||||
break;
|
||||
case 'blood_oxygen':
|
||||
veepooFeature.veepooSendBloodOxygenControlDataManager({ switch: on ? 'start' : 'stop' });
|
||||
break;
|
||||
case 'blood_pressure':
|
||||
veepooFeature.veepooSendReadUniversalBloodPressureDataManager({ switch: on ? 'start' : 'stop' });
|
||||
break;
|
||||
case 'temperature':
|
||||
veepooFeature.veepooSendTemperatureMeasurementSwitchManager({ switch: !!on });
|
||||
break;
|
||||
case 'pressure':
|
||||
veepooFeature.veepooSendPressureTestManager({ switch: !!on });
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
// ── 睡眠数据读取 ──
|
||||
|
||||
_sleepResults: null,
|
||||
_sleepDay: 0,
|
||||
|
||||
_readSleepData: function () {
|
||||
this._sleepResults = [];
|
||||
this._sleepDay = 0;
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 开始读取睡眠数据(3天)');
|
||||
|
||||
// 依次读取 3 天睡眠
|
||||
var self = this;
|
||||
veepooFeature.veepooSendReadPreciseSleepManager({ day: 0 });
|
||||
|
||||
// 延迟读取后续天(避免并发冲突)
|
||||
// eslint-disable-next-line no-undef
|
||||
setTimeout(function () { veepooFeature.veepooSendReadPreciseSleepManager({ day: 1 }); }, 3000);
|
||||
// eslint-disable-next-line no-undef
|
||||
setTimeout(function () { veepooFeature.veepooSendReadPreciseSleepManager({ day: 2 }); }, 6000);
|
||||
},
|
||||
|
||||
_handleSleepEvent: function (data) {
|
||||
var progress = data.Progress || 0;
|
||||
if (progress < 100) return;
|
||||
|
||||
var content = data.content || {};
|
||||
var readDay = data.readDay || 0;
|
||||
var totalTime = Number(content.sleepTotalTime || 0);
|
||||
|
||||
if (totalTime <= 0) return;
|
||||
|
||||
var sleepResult = {
|
||||
day: readDay,
|
||||
deepSleepMinutes: Number(content.deepSleepTime || 0),
|
||||
lightSleepMinutes: Number(content.lightSleepTime || 0),
|
||||
totalSleepMinutes: totalTime,
|
||||
qualityScore: Number(content.sleepQuality || 0),
|
||||
fallAsleepTime: String(content.fallAsleepTime || ''),
|
||||
exitSleepTime: String(content.exitSleepTime || ''),
|
||||
};
|
||||
|
||||
if (!this._sleepResults) this._sleepResults = [];
|
||||
this._sleepResults.push(sleepResult);
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 睡眠数据 day=' + readDay + ' 总时长=' + totalTime + '分钟 质量=' + sleepResult.qualityScore + '星');
|
||||
|
||||
// 保存到 Storage 供 Taro 页面读取
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
wx.setStorageSync('hms:veepoo_sleep_results', JSON.stringify(this._sleepResults));
|
||||
} catch (_) { /* ignore */ }
|
||||
},
|
||||
|
||||
// ── 自动测量 ──
|
||||
|
||||
_enableAutoMeasurement: function () {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 开启自动测量功能');
|
||||
|
||||
// 开启心率自动监测
|
||||
try {
|
||||
veepooFeature.veepooSendSwitchSettingDataManager({
|
||||
VPSettingAutomaticHRTest: 'open',
|
||||
});
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.warn('[veepoo-native] 开启心率自动监测失败:', e);
|
||||
}
|
||||
|
||||
// 开启血压自动监测
|
||||
try {
|
||||
veepooFeature.veepooSendSwitchSettingDataManager({
|
||||
VPSettingAutomaticBPTest: 'open',
|
||||
});
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.warn('[veepoo-native] 开启血压自动监测失败:', e);
|
||||
}
|
||||
|
||||
// 开启体温自动监测
|
||||
try {
|
||||
veepooFeature.veepooSendSwitchSettingDataManager({
|
||||
VPSettingAutomaticTemperatureTest: 'open',
|
||||
});
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.warn('[veepoo-native] 开启体温自动监测失败:', e);
|
||||
}
|
||||
},
|
||||
|
||||
// ── 自动测量队列 ──
|
||||
|
||||
_startAutoMeasureQueue: function () {
|
||||
var types = [];
|
||||
var status = {};
|
||||
for (var i = 0; i < MEASURE_TYPES.length; i++) {
|
||||
types.push(MEASURE_TYPES[i].type);
|
||||
status[MEASURE_TYPES[i].type] = 'pending';
|
||||
}
|
||||
|
||||
this._autoQueue = types;
|
||||
this._autoQueueIndex = 0;
|
||||
|
||||
this.setData({
|
||||
autoMeasuring: true,
|
||||
autoMeasureDone: false,
|
||||
autoMeasureStatus: status,
|
||||
autoMeasureValues: {},
|
||||
autoMeasureProgress: 0,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 启动自动测量队列,共 ' + types.length + ' 项');
|
||||
|
||||
var self = this;
|
||||
setTimeout(function () {
|
||||
self._startNextAutoMeasure();
|
||||
}, 800);
|
||||
},
|
||||
|
||||
_startNextAutoMeasure: function () {
|
||||
if (!this._autoQueue || this._autoQueueIndex >= this._autoQueue.length) {
|
||||
this._onAutoMeasureComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
var type = this._autoQueue[this._autoQueueIndex];
|
||||
this._updateSelectedDisplay(type);
|
||||
|
||||
var status = Object.assign({}, this.data.autoMeasureStatus);
|
||||
status[type] = 'measuring';
|
||||
this.setData({
|
||||
autoMeasureStatus: status,
|
||||
measurePhase: 'measuring',
|
||||
measureProgress: 0,
|
||||
measureDisplayValue: '',
|
||||
measureError: '',
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 自动测量 [' + (this._autoQueueIndex + 1) + '/' + this._autoQueue.length + ']: ' + type);
|
||||
|
||||
this._lastValues = null;
|
||||
this._sendMeasureCommand(type, true);
|
||||
|
||||
var self = this;
|
||||
var timeout = MEASURE_TIMEOUTS[type] || 60000;
|
||||
this._measureTimer = setTimeout(function () {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.warn('[veepoo-native] 自动测量超时: ' + type);
|
||||
self._onAutoMeasureError(type, '测量超时');
|
||||
}, timeout);
|
||||
},
|
||||
|
||||
_handleAutoMeasureEvent: function (type, data) {
|
||||
if (!this._autoQueue || this._autoQueueIndex >= this._autoQueue.length) return;
|
||||
if (type !== this._autoQueue[this._autoQueueIndex]) return;
|
||||
|
||||
var content = data.content || {};
|
||||
var self = this;
|
||||
|
||||
if (content.deviceBusy === true) { self._onAutoMeasureError(type, '设备正忙'); return; }
|
||||
if (content.notWear === true || data.state === 6) { self._onAutoMeasureError(type, '未佩戴'); return; }
|
||||
if (data.state === 7) { self._onAutoMeasureError(type, '设备充电中'); return; }
|
||||
if (data.state === 8) { self._onAutoMeasureError(type, '电量不足'); return; }
|
||||
if (type === 'pressure' && data.ack === 2) { self._onAutoMeasureError(type, '电量不足'); return; }
|
||||
if (type === 'pressure' && data.ack === 3) { self._onAutoMeasureError(type, '设备正忙'); return; }
|
||||
if (type === 'pressure' && data.ack === 4) { self._onAutoMeasureError(type, '未佩戴'); return; }
|
||||
|
||||
var values = self._extractValues(type, content);
|
||||
if (!values) return;
|
||||
|
||||
self._lastValues = values;
|
||||
|
||||
var progress = data.Progress !== undefined ? data.Progress : 0;
|
||||
self.setData({ measureProgress: Math.max(progress, 0) });
|
||||
|
||||
if (progress >= 100) {
|
||||
self._onAutoMeasureSuccess(type, values);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self._settleTimer) {
|
||||
self._settleTimer = setTimeout(function () {
|
||||
if (self._lastValues && self.data.autoMeasuring) {
|
||||
self._onAutoMeasureSuccess(type, self._lastValues);
|
||||
}
|
||||
}, MEASURE_SETTLE_DELAY);
|
||||
}
|
||||
},
|
||||
|
||||
_onAutoMeasureSuccess: function (type, values) {
|
||||
this._cancelPendingMeasure();
|
||||
|
||||
var result = { type: type, values: values, measuredAt: Date.now() };
|
||||
var newResults = Object.assign({}, this.data.results);
|
||||
newResults[type] = result;
|
||||
|
||||
var status = Object.assign({}, this.data.autoMeasureStatus);
|
||||
status[type] = 'done';
|
||||
|
||||
var newValues = Object.assign({}, this.data.autoMeasureValues);
|
||||
newValues[type] = this._formatValues(type, values);
|
||||
|
||||
var doneCount = 0;
|
||||
var keys = Object.keys(status);
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
if (status[keys[i]] === 'done' || status[keys[i]] === 'error') doneCount++;
|
||||
}
|
||||
var progress = Math.round((doneCount / this._autoQueue.length) * 100);
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 自动测量完成: ' + type + ' = ' + newValues[type] + ' (' + progress + '%)');
|
||||
|
||||
this.setData({
|
||||
results: newResults,
|
||||
hasResults: true,
|
||||
autoMeasureStatus: status,
|
||||
autoMeasureValues: newValues,
|
||||
autoMeasureProgress: progress,
|
||||
measurePhase: 'idle',
|
||||
});
|
||||
|
||||
this._autoQueueIndex++;
|
||||
var self = this;
|
||||
setTimeout(function () {
|
||||
self._startNextAutoMeasure();
|
||||
}, 800);
|
||||
},
|
||||
|
||||
_onAutoMeasureError: function (type, msg) {
|
||||
this._cancelPendingMeasure();
|
||||
|
||||
var status = Object.assign({}, this.data.autoMeasureStatus);
|
||||
status[type] = 'error';
|
||||
|
||||
var doneCount = 0;
|
||||
var keys = Object.keys(status);
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
if (status[keys[i]] === 'done' || status[keys[i]] === 'error') doneCount++;
|
||||
}
|
||||
var progress = Math.round((doneCount / this._autoQueue.length) * 100);
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
console.warn('[veepoo-native] 自动测量失败: ' + type + ' - ' + msg + ' (' + progress + '%)');
|
||||
|
||||
this.setData({
|
||||
autoMeasureStatus: status,
|
||||
autoMeasureProgress: progress,
|
||||
measurePhase: 'idle',
|
||||
});
|
||||
|
||||
this._autoQueueIndex++;
|
||||
var self = this;
|
||||
setTimeout(function () {
|
||||
self._startNextAutoMeasure();
|
||||
}, 500);
|
||||
},
|
||||
|
||||
_onAutoMeasureComplete: function () {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.log('[veepoo-native] 自动测量全部完成');
|
||||
|
||||
var results = this.data.results;
|
||||
if (Object.keys(results).length > 0) {
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
wx.setStorageSync('hms:veepoo_measure_results', JSON.stringify(results));
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
this.setData({
|
||||
autoMeasureDone: true,
|
||||
autoMeasuring: false,
|
||||
autoMeasureProgress: 100,
|
||||
measurePhase: 'idle',
|
||||
});
|
||||
},
|
||||
|
||||
handleCancelAutoMeasure: function () {
|
||||
this._cancelPendingMeasure();
|
||||
this._autoQueue = null;
|
||||
|
||||
var results = this.data.results;
|
||||
if (Object.keys(results).length > 0) {
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
wx.setStorageSync('hms:veepoo_measure_results', JSON.stringify(results));
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
this.setData({
|
||||
autoMeasuring: false,
|
||||
autoMeasureDone: false,
|
||||
measurePhase: 'idle',
|
||||
});
|
||||
},
|
||||
});
|
||||
6
apps/miniprogram/native/pkg-veepoo/index.json
Normal file
6
apps/miniprogram/native/pkg-veepoo/index.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"navigationBarTitleText": "M2 手环测量",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#F5F5F4"
|
||||
}
|
||||
215
apps/miniprogram/native/pkg-veepoo/index.wxml
Normal file
215
apps/miniprogram/native/pkg-veepoo/index.wxml
Normal file
@@ -0,0 +1,215 @@
|
||||
<!--
|
||||
Veepoo M2 原生小程序页面 — 连接 + 测量
|
||||
设计原型: docs/design/veepoo-measure-prototype.html
|
||||
完全匹配 SDK 官方 Demo 流程,不依赖 Taro
|
||||
-->
|
||||
|
||||
<!-- ═══ 未连接 / 错误 / 断开 ═══ -->
|
||||
<block wx:if="{{phase === 'idle' || phase === 'error' || phase === 'disconnected'}}">
|
||||
<view class="connect-screen">
|
||||
<view class="connect-anim">
|
||||
<view class="connect-ring"></view>
|
||||
<view class="connect-center">
|
||||
<text class="connect-bt">BT</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="connect-title">M2 手环健康测量</text>
|
||||
<text class="connect-hint">请确保手环已开机且蓝牙已开启</text>
|
||||
|
||||
<view wx:if="{{error}}" class="connect-error">
|
||||
<text class="connect-error-text">{{error}}</text>
|
||||
</view>
|
||||
|
||||
<view class="connect-btn-wrap">
|
||||
<view class="btn-primary" bindtap="handleConnect">
|
||||
{{phase === 'error' ? '重新连接' : phase === 'disconnected' ? '重新连接' : '连接 M2 手环'}}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{hasResults}}" class="connect-back">
|
||||
<view class="btn-text" bindtap="handleBack">查看测量结果并返回</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- ═══ 连接中(扫描/连接/认证) ═══ -->
|
||||
<block wx:elif="{{phase === 'scanning' || phase === 'connecting' || phase === 'authenticating'}}">
|
||||
<view class="connect-screen">
|
||||
<view class="connect-anim">
|
||||
<view class="connect-ring connect-ring--active"></view>
|
||||
<view class="connect-center">
|
||||
<text class="connect-bt">BT</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="connect-title">
|
||||
{{phase === 'scanning' ? '正在搜索 M2 手环...' : phase === 'connecting' ? '正在连接...' : '正在认证...'}}
|
||||
</text>
|
||||
<text class="connect-hint">请确保手环已开机且靠近手机</text>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- ═══ 自动测量中 / 自动测量完成 ═══ -->
|
||||
<block wx:elif="{{phase === 'ready' && (autoMeasuring || autoMeasureDone)}}">
|
||||
<view class="measure-page">
|
||||
<!-- 设备状态栏 -->
|
||||
<view class="device-bar">
|
||||
<view class="device-bar__left">
|
||||
<view class="device-bar__dot"></view>
|
||||
<text class="device-bar__name">{{deviceName}}</text>
|
||||
<text wx:if="{{batteryLevel !== null}}" class="device-bar__battery">{{batteryLevel}}%</text>
|
||||
</view>
|
||||
<view wx:if="{{autoMeasuring}}" class="device-bar__disconnect" bindtap="handleCancelAutoMeasure">取消</view>
|
||||
</view>
|
||||
|
||||
<view class="auto-measure">
|
||||
<!-- 标题 -->
|
||||
<view class="auto-measure__header">
|
||||
<text class="auto-measure__title">{{autoMeasureDone ? '✓ 测量完成!' : '正在自动测量...'}}</text>
|
||||
<text wx:if="{{!autoMeasureDone}}" class="auto-measure__subtitle">请保持手环佩戴,无需任何操作</text>
|
||||
</view>
|
||||
|
||||
<!-- 指标列表 -->
|
||||
<view class="auto-measure__list">
|
||||
<view
|
||||
wx:for="{{measureTypes}}"
|
||||
wx:key="type"
|
||||
class="auto-item {{autoMeasureStatus[item.type] === 'done' ? 'auto-item--done' : autoMeasureStatus[item.type] === 'measuring' ? 'auto-item--active' : autoMeasureStatus[item.type] === 'error' ? 'auto-item--error' : ''}}"
|
||||
>
|
||||
<view class="auto-item__left">
|
||||
<view class="auto-item__icon-wrap" style="background: {{autoMeasureStatus[item.type] === 'done' ? item.color : autoMeasureStatus[item.type] === 'error' ? '#ccc' : item.color}}">
|
||||
<text class="auto-item__icon">{{autoMeasureStatus[item.type] === 'done' ? '✓' : autoMeasureStatus[item.type] === 'error' ? '✕' : item.icon}}</text>
|
||||
</view>
|
||||
<text class="auto-item__label">{{item.label}}</text>
|
||||
</view>
|
||||
<view class="auto-item__right">
|
||||
<block wx:if="{{autoMeasureStatus[item.type] === 'done'}}">
|
||||
<text class="auto-item__value" style="color: {{item.color}}">{{autoMeasureValues[item.type]}}</text>
|
||||
<text wx:if="{{item.unit}}" class="auto-item__unit">{{item.unit}}</text>
|
||||
</block>
|
||||
<text wx:elif="{{autoMeasureStatus[item.type] === 'measuring'}}" class="auto-item__status auto-item__status--active">测量中...</text>
|
||||
<text wx:elif="{{autoMeasureStatus[item.type] === 'error'}}" class="auto-item__status auto-item__status--error">已跳过</text>
|
||||
<text wx:else class="auto-item__status">等待中</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<view class="auto-progress">
|
||||
<view class="auto-progress__bar">
|
||||
<view class="auto-progress__fill" style="width: {{autoMeasureProgress}}%"></view>
|
||||
</view>
|
||||
<text class="auto-progress__text">{{autoMeasureProgress}}%</text>
|
||||
</view>
|
||||
|
||||
<!-- 免责声明 -->
|
||||
<view class="disclaimer">
|
||||
<text class="disclaimer__text">测量数据仅供参考,不作为医学诊断依据。如有不适请及时就医。</text>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="actions">
|
||||
<block wx:if="{{autoMeasureDone}}">
|
||||
<view class="btn btn--primary" bindtap="handleBack">查看结果并返回</view>
|
||||
</block>
|
||||
<block wx:elif="{{autoMeasuring}}">
|
||||
<view class="btn btn--text" bindtap="handleCancelAutoMeasure">取消自动测量</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- ═══ 就绪 + 手动测量 ═══ -->
|
||||
<block wx:elif="{{phase === 'ready'}}">
|
||||
<view class="measure-page">
|
||||
<!-- 设备状态栏 -->
|
||||
<view class="device-bar">
|
||||
<view class="device-bar__left">
|
||||
<view class="device-bar__dot"></view>
|
||||
<text class="device-bar__name">{{deviceName}}</text>
|
||||
<text wx:if="{{batteryLevel !== null}}" class="device-bar__battery">{{batteryLevel}}%</text>
|
||||
</view>
|
||||
<view class="device-bar__disconnect" bindtap="handleDisconnect">断开</view>
|
||||
</view>
|
||||
|
||||
<!-- 指标选择器 — 药丸式 -->
|
||||
<scroll-view class="selector" scroll-x enhanced show-scrollbar="{{false}}">
|
||||
<view
|
||||
wx:for="{{measureTypes}}"
|
||||
wx:key="type"
|
||||
class="selector__pill {{selectedType === item.type ? 'selector__pill--active' : ''}} {{results[item.type] ? 'selector__pill--done' : ''}}"
|
||||
data-type="{{item.type}}"
|
||||
bindtap="handleSelectType"
|
||||
>
|
||||
<view class="selector__icon-wrap" style="background: {{item.color}}">
|
||||
<text class="selector__icon">{{item.icon}}</text>
|
||||
</view>
|
||||
<text class="selector__label">{{item.label}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 仪表盘区域 -->
|
||||
<view class="gauge-section">
|
||||
<view class="gauge {{measurePhase === 'measuring' ? 'gauge--measuring' : ''}}">
|
||||
<!-- SVG 圆环 -->
|
||||
<view class="gauge__ring-wrap">
|
||||
<view class="gauge__ring-bg"></view>
|
||||
<view class="gauge__ring-progress" style="background: conic-gradient({{selectedColor}} {{measureProgress * 3.6}}deg, #E8E2DC 0deg);"></view>
|
||||
<view class="gauge__center">
|
||||
<!-- 空闲 -->
|
||||
<block wx:if="{{measurePhase === 'idle'}}">
|
||||
<text class="gauge__icon-lg" style="color: {{selectedColor}}">{{selectedIcon}}</text>
|
||||
<text class="gauge__hint">点击下方按钮开始</text>
|
||||
</block>
|
||||
<!-- 测量中 -->
|
||||
<block wx:elif="{{measurePhase === 'measuring'}}">
|
||||
<text wx:if="{{measureDisplayValue}}" class="gauge__value" style="color: {{selectedColor}}">{{measureDisplayValue}}</text>
|
||||
<text wx:else class="gauge__loading">测量中...</text>
|
||||
<text wx:if="{{measureDisplayValue}}" class="gauge__unit">{{selectedUnit}}</text>
|
||||
</block>
|
||||
<!-- 成功 -->
|
||||
<block wx:elif="{{measurePhase === 'success'}}">
|
||||
<text class="gauge__value" style="color: {{selectedColor}}">{{measureDisplayValue}}</text>
|
||||
<text class="gauge__unit">{{selectedUnit}}</text>
|
||||
</block>
|
||||
<!-- 错误 -->
|
||||
<block wx:elif="{{measurePhase === 'error'}}">
|
||||
<text class="gauge__err">!</text>
|
||||
<text class="gauge__err-text">{{measureError}}</text>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<view wx:if="{{measurePhase === 'measuring' && measureProgress > 0}}" class="progress-bar">
|
||||
<view class="progress-bar__fill" style="width: {{measureProgress}}%; background: {{selectedColor}};"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 免责声明 -->
|
||||
<view class="disclaimer">
|
||||
<text class="disclaimer__text">测量数据仅供参考,不作为医学诊断依据。如有不适请及时就医。</text>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="actions">
|
||||
<block wx:if="{{measurePhase === 'idle'}}">
|
||||
<view class="btn btn--primary" style="background: {{selectedColor}}; box-shadow: 0 4px 16px rgba(0,0,0,0.15);" bindtap="handleStartMeasure">
|
||||
开始测量{{selectedLabel}}
|
||||
</view>
|
||||
</block>
|
||||
<block wx:elif="{{measurePhase === 'measuring'}}">
|
||||
<view class="btn btn--secondary" bindtap="handleCancelMeasure">停止测量</view>
|
||||
<view class="btn btn--text" bindtap="handleBack">完成并查看结果</view>
|
||||
</block>
|
||||
<block wx:elif="{{measurePhase === 'success'}}">
|
||||
<view class="btn btn--primary" style="background: {{selectedColor}}; box-shadow: 0 4px 16px rgba(0,0,0,0.15);" bindtap="handleResetResult">重新测量</view>
|
||||
<view class="btn btn--secondary" bindtap="handleBack">完成并查看结果</view>
|
||||
</block>
|
||||
<block wx:elif="{{measurePhase === 'error'}}">
|
||||
<view class="btn btn--primary" style="background: {{selectedColor}}; box-shadow: 0 4px 16px rgba(0,0,0,0.15);" bindtap="handleStartMeasure">重新测量</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
619
apps/miniprogram/native/pkg-veepoo/index.wxss
Normal file
619
apps/miniprogram/native/pkg-veepoo/index.wxss
Normal file
@@ -0,0 +1,619 @@
|
||||
/**
|
||||
* Veepoo M2 原生页面样式
|
||||
* 设计原型: docs/design/veepoo-measure-prototype.html
|
||||
* 复刻小程序 design token
|
||||
*/
|
||||
|
||||
page {
|
||||
--pri: #C4623A;
|
||||
--pri-l: #F0DDD4;
|
||||
--bg: #F5F0EB;
|
||||
--card: #FFFFFF;
|
||||
--tx: #2D2A26;
|
||||
--tx2: #5A554F;
|
||||
--tx3: #78716C;
|
||||
--bd: #E8E2DC;
|
||||
--acc: #5B7A5E;
|
||||
--acc-l: #E8F0E8;
|
||||
--dan: #B54A4A;
|
||||
--dan-l: #FDEAEA;
|
||||
background: var(--bg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
连接页面(未连接/连接中/错误)
|
||||
═══════════════════════════════════════ */
|
||||
.connect-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
.connect-anim {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.connect-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--pri);
|
||||
animation: pulse-ring 2s ease-out infinite;
|
||||
}
|
||||
|
||||
.connect-ring--active {
|
||||
border-color: var(--pri);
|
||||
animation: pulse-ring 1.5s ease-out infinite;
|
||||
}
|
||||
|
||||
.connect-center {
|
||||
position: absolute;
|
||||
inset: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--pri);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.connect-bt {
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.connect-title {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--tx);
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.connect-hint {
|
||||
font-size: 14px;
|
||||
color: var(--tx3);
|
||||
margin-bottom: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.connect-error {
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.connect-error-text {
|
||||
font-size: 14px;
|
||||
color: var(--dan);
|
||||
}
|
||||
|
||||
.connect-btn-wrap {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.connect-back {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
100% { transform: scale(1.4); opacity: 0; }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
测量页面(就绪态)
|
||||
═══════════════════════════════════════ */
|
||||
|
||||
/* ═══ 自动测量进度 ═══ */
|
||||
.auto-measure {
|
||||
padding: 24px 20px 40px;
|
||||
}
|
||||
|
||||
.auto-measure__header {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.auto-measure__title {
|
||||
display: block;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--tx);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.auto-measure__subtitle {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
.auto-measure__list {
|
||||
background: var(--card);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 12px rgba(45,42,38,0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auto-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--bd);
|
||||
transition: background 200ms ease;
|
||||
}
|
||||
|
||||
.auto-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.auto-item--active {
|
||||
background: rgba(196,98,58,0.04);
|
||||
}
|
||||
|
||||
.auto-item--done {
|
||||
background: rgba(91,122,94,0.04);
|
||||
}
|
||||
|
||||
.auto-item--error {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.auto-item__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.auto-item__icon-wrap {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auto-item--done .auto-item__icon-wrap {
|
||||
background: var(--acc) !important;
|
||||
}
|
||||
|
||||
.auto-item--error .auto-item__icon-wrap {
|
||||
background: var(--tx3) !important;
|
||||
}
|
||||
|
||||
.auto-item__icon {
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auto-item__label {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.auto-item__right {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.auto-item__value {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auto-item__unit {
|
||||
font-size: 13px;
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
.auto-item__status {
|
||||
font-size: 14px;
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
.auto-item__status--active {
|
||||
color: var(--pri);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auto-item__status--error {
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
/* ── 自动测量进度条 ── */
|
||||
.auto-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.auto-progress__bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--bd);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auto-progress__fill {
|
||||
height: 100%;
|
||||
background: var(--pri);
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease-out;
|
||||
}
|
||||
|
||||
.auto-progress__text {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--pri);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
.measure-page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
/* ── 设备状态栏 ── */
|
||||
.device-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 20px;
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.device-bar__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.device-bar__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--acc);
|
||||
}
|
||||
|
||||
.device-bar__name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.device-bar__battery {
|
||||
font-size: 13px;
|
||||
color: var(--tx3);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.device-bar__disconnect {
|
||||
font-size: 13px;
|
||||
color: var(--tx3);
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* ── 指标选择器(药丸式) ── */
|
||||
.selector {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
padding: 16px 20px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selector__pill {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
min-width: 64px;
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
.selector__pill--active {
|
||||
background: var(--card);
|
||||
box-shadow: 0 2px 12px rgba(45,42,38,0.10);
|
||||
}
|
||||
|
||||
.selector__pill--done::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
background: var(--acc);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.selector__icon-wrap {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
.selector__pill--active .selector__icon-wrap {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.selector__icon {
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.selector__label {
|
||||
font-size: 13px;
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
.selector__pill--active .selector__label {
|
||||
color: var(--tx);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── 仪表盘 ── */
|
||||
.gauge-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 0 24px;
|
||||
}
|
||||
|
||||
.gauge {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gauge--measuring {
|
||||
animation: gauge-breathe 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes gauge-breathe {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.03); }
|
||||
}
|
||||
|
||||
.gauge__ring-wrap {
|
||||
position: relative;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.gauge__ring-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 10px solid var(--bd);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gauge__ring-progress {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.gauge__center {
|
||||
position: absolute;
|
||||
inset: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.gauge__icon-lg {
|
||||
font-size: 40px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.gauge__hint {
|
||||
font-size: 13px;
|
||||
color: var(--tx3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gauge__value {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 52px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gauge__unit {
|
||||
font-size: 14px;
|
||||
color: var(--tx3);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.gauge__loading {
|
||||
font-size: 16px;
|
||||
color: var(--tx2);
|
||||
}
|
||||
|
||||
.gauge__err {
|
||||
font-size: 36px;
|
||||
color: var(--dan);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gauge__err-text {
|
||||
font-size: 13px;
|
||||
color: var(--tx2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── 进度条 ── */
|
||||
.progress-bar {
|
||||
width: 240px;
|
||||
height: 4px;
|
||||
background: var(--bd);
|
||||
border-radius: 2px;
|
||||
margin-top: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar__fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* ── 免责声明 ── */
|
||||
.disclaimer {
|
||||
text-align: center;
|
||||
padding: 0 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.disclaimer__text {
|
||||
font-size: 11px;
|
||||
color: var(--tx3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── 操作按钮 ── */
|
||||
.actions {
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--pri);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(196,98,58,0.3);
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--card);
|
||||
color: var(--tx);
|
||||
border: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.btn--text {
|
||||
background: transparent;
|
||||
color: var(--tx3);
|
||||
height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ═══ 旧版兼容样式 ═══ */
|
||||
.btn-primary {
|
||||
background: var(--pri);
|
||||
color: #fff;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 16px rgba(196,98,58,0.3);
|
||||
}
|
||||
.btn-primary:active { opacity: 0.85; }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--card);
|
||||
color: var(--tx);
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--bd);
|
||||
}
|
||||
.btn-secondary:active { opacity: 0.85; }
|
||||
|
||||
.btn-text {
|
||||
background: transparent;
|
||||
color: var(--tx3);
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-large { margin: 0; }
|
||||
|
||||
/* 旧版 header/selector/gauge 兼容 */
|
||||
.header { display: none; }
|
||||
.header-device { display: none; }
|
||||
.header-dot { display: none; }
|
||||
.header-name { display: none; }
|
||||
.header-battery { display: none; }
|
||||
.header-disconnect { display: none; }
|
||||
.selector-item { display: none; }
|
||||
.selector-icon { display: none; }
|
||||
.selector-label { display: none; }
|
||||
.selector-check { display: none; }
|
||||
.gauge-circle { display: none; }
|
||||
.gauge-icon { display: none; }
|
||||
.gauge-hint { display: none; }
|
||||
.gauge-value { display: none; }
|
||||
.gauge-loading { display: none; }
|
||||
.gauge-err { display: none; }
|
||||
.gauge-err-text { display: none; }
|
||||
.gauge-progress-bar { display: none; }
|
||||
.gauge-progress-fill { display: none; }
|
||||
.assessment { display: none; }
|
||||
.assessment-text { display: none; }
|
||||
.disclaimer-text { display: none; }
|
||||
.measure-error { display: none; }
|
||||
.measure-error-text { display: none; }
|
||||
@@ -7,6 +7,28 @@ interface RichArticleProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TAG_STYLE = JSON.stringify({
|
||||
h1: 'font-size:20px;font-weight:700;color:#2D2A26;margin:16px 0 8px',
|
||||
h2: 'font-size:18px;font-weight:700;color:#2D2A26;margin:16px 0 8px',
|
||||
h3: 'font-size:16px;font-weight:700;color:#2D2A26;margin:16px 0 8px',
|
||||
h4: 'font-size:15px;font-weight:600;color:#2D2A26;margin:12px 0 6px',
|
||||
p: 'font-size:16px;color:#2D2A26;line-height:1.85;margin-bottom:12px',
|
||||
ul: 'padding-left:20px;margin:8px 0;font-size:16px;line-height:1.9;color:#2D2A26',
|
||||
ol: 'padding-left:20px;margin:8px 0;font-size:16px;line-height:1.9;color:#2D2A26',
|
||||
li: 'margin-bottom:4px',
|
||||
blockquote: 'border-left:3px solid #C4623A;padding:6px 12px;color:#5A554F;margin:12px 0',
|
||||
strong: 'font-weight:700;color:#2D2A26',
|
||||
em: 'font-style:italic',
|
||||
code: 'background:#F5F0EB;padding:2px 6px;border-radius:4px;font-size:14px;color:#C4623A',
|
||||
pre: 'background:#F5F0EB;padding:12px;border-radius:8px;margin:14px 0;overflow-x:auto',
|
||||
table: 'width:100%;border-collapse:collapse;margin:8px 0;font-size:14px',
|
||||
th: 'border:1px solid #E8E2DC;padding:6px 8px;background:#FAF8F5;font-weight:600;text-align:left',
|
||||
td: 'border:1px solid #E8E2DC;padding:6px 8px',
|
||||
hr: 'border:none;border-top:1px dashed #D1D5DB;margin:14px 0',
|
||||
img: 'max-width:100%;border-radius:8px;margin:8px 0;display:block',
|
||||
a: 'color:#C4623A;text-decoration:none',
|
||||
});
|
||||
|
||||
function prepareHtml(raw: string): string {
|
||||
return sanitizeHtml(raw);
|
||||
}
|
||||
@@ -23,7 +45,7 @@ function RichArticle({ html, className }: RichArticleProps) {
|
||||
lazy-load
|
||||
selectable
|
||||
container-style="font-size:16px;color:#5A554F;line-height:1.8;word-break:break-word"
|
||||
tag-style='{"img":"max-width:100%;border-radius:8px;margin:12px auto;display:block","a":"color:#C4623A;text-decoration:none"}'
|
||||
tag-style={TAG_STYLE}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -2,515 +2,131 @@
|
||||
@import '../../styles/mixins.scss';
|
||||
|
||||
.health-page {
|
||||
padding-bottom: calc(var(--tk-tabbar-space) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* ─── 页头 ─── */
|
||||
.health-header {
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.health-title {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.health-date {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
/* ─── 今日体征 hero 卡片 ─── */
|
||||
.vitals-grid {
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
background: linear-gradient(135deg, $card 60%, $pri-l);
|
||||
border-radius: var(--tk-card-radius);
|
||||
box-shadow: $shadow-md;
|
||||
padding: var(--tk-card-padding);
|
||||
|
||||
/* 覆盖 ContentCard 默认 padding/margin */
|
||||
&.content-card {
|
||||
padding: var(--tk-card-padding);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
}
|
||||
}
|
||||
|
||||
.vitals-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.vitals-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx2;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.vitals-badge {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $acc;
|
||||
background: $acc-l;
|
||||
padding: 3px 10px;
|
||||
border-radius: $r-pill;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vitals-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.vital-cell {
|
||||
text-align: center;
|
||||
padding: var(--tk-gap-md) var(--tk-gap-sm);
|
||||
border-radius: $r-sm;
|
||||
background: $bg;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: block;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.vital-unit {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.vital-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.vital-cell.vital-warn {
|
||||
background: $wrn-l;
|
||||
|
||||
.vital-value {
|
||||
color: $wrn;
|
||||
}
|
||||
}
|
||||
|
||||
.vital-cell.vital-ok {
|
||||
.vital-value {
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 快捷入口 — 横排 4 格图标 ─── */
|
||||
.quick-entries {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--tk-gap-xs);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
}
|
||||
|
||||
.quick-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ─── 分类标签 ─── */
|
||||
.health-categories {
|
||||
white-space: nowrap;
|
||||
padding: var(--tk-gap-xs) var(--tk-page-padding);
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.health-cat-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-xs);
|
||||
min-height: var(--tk-touch-min);
|
||||
justify-content: center;
|
||||
padding: var(--tk-gap-sm) 0;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.quick-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: $r-sm;
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
.quick-icon-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quick-icon--input {
|
||||
background: $pri-l;
|
||||
|
||||
.quick-icon-text {
|
||||
color: $pri;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-icon--trend {
|
||||
background: $doc-pri-l;
|
||||
|
||||
.quick-icon-text {
|
||||
color: $doc-pri;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-icon--report {
|
||||
background: $acc-l;
|
||||
|
||||
.quick-icon-text {
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-icon--med {
|
||||
background: $wrn-l;
|
||||
|
||||
.quick-icon-text {
|
||||
color: $wrn;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── 告警横幅 ─── */
|
||||
.alert-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-sm);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
background: $dan-l;
|
||||
border-radius: $r-sm;
|
||||
|
||||
/* 覆盖 ContentCard 默认样式 */
|
||||
&.content-card {
|
||||
background: $dan-l;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.alert-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: $dan;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-text {
|
||||
flex: 1;
|
||||
padding: 8px 18px;
|
||||
margin-right: 8px;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 500;
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
.alert-arrow {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $dan;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ─── 趋势图 ─── */
|
||||
.trend-section {
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.trend-empty {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trend-empty-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 400;
|
||||
color: $tx2;
|
||||
background: $surface-alt;
|
||||
border-radius: 20px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&--active {
|
||||
background: var(--tk-pri);
|
||||
color: $white;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
padding: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.trend-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 120px;
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: var(--tk-gap-sm) var(--tk-gap-xs);
|
||||
gap: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trend-threshold-line {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
border-top: 1.5px dashed $wrn;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.trend-threshold-label {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -16px;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $wrn;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.trend-bar-col {
|
||||
/* ─── 可滚动内容区 ─── */
|
||||
.health-scroll {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
/* 微信小程序 ScrollView scrollY 需要显式高度 */
|
||||
height: 0; /* flex:1 + height:0 让 flex 布局正确分配剩余高度 */
|
||||
}
|
||||
|
||||
/* ─── 文章列表 ─── */
|
||||
.health-article-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.trend-bar {
|
||||
width: 24px;
|
||||
border-radius: $r-xs $r-xs 0 0;
|
||||
min-height: 6px;
|
||||
|
||||
&.trend-bar-normal {
|
||||
background: var(--tk-pri);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&.trend-bar-warn {
|
||||
background: $wrn;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.trend-bar-label {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: var(--tk-text-secondary);
|
||||
margin-top: var(--tk-gap-2xs);
|
||||
}
|
||||
|
||||
/* ─── BLE 设备卡片 ─── */
|
||||
.device-section {
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.device-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-sm);
|
||||
padding: 0 var(--tk-page-padding) var(--tk-gap-lg);
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
.content-card {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: $r-sm;
|
||||
background: var(--tk-pri-l);
|
||||
@include flex-center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-icon-text {
|
||||
font-size: var(--tk-font-body);
|
||||
}
|
||||
|
||||
.device-info {
|
||||
.health-article-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.device-desc {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $acc;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.device-arrow {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── 健康资讯入口 ─── */
|
||||
.article-entry {
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.article-entry-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── AI 建议卡片 ─── */
|
||||
.ai-suggestion-card {
|
||||
background: linear-gradient(135deg, #F0F7F0 0%, $acc-l 100%);
|
||||
border-radius: $r;
|
||||
padding: var(--tk-card-padding);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
box-shadow: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, $acc, $acc 60%, transparent);
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.ai-card-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
.ai-card-count {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $acc;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ai-suggestion-item {
|
||||
padding: var(--tk-gap-sm) 0;
|
||||
border-bottom: 1px solid rgba($acc, 0.12);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-suggestion-main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--tk-gap-xs);
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-risk-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 5px;
|
||||
|
||||
&.ai-risk-high {
|
||||
background: $dan;
|
||||
}
|
||||
|
||||
&.ai-risk-medium {
|
||||
background: $wrn;
|
||||
}
|
||||
|
||||
&.ai-risk-low {
|
||||
background: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-suggestion-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
line-height: 1.6;
|
||||
.health-article-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ─── AI 建议反馈按钮 ─── */
|
||||
.ai-feedback-row {
|
||||
display: flex;
|
||||
gap: var(--tk-gap-xs);
|
||||
margin-top: var(--tk-gap-xs);
|
||||
padding-left: 20px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-feedback-btn {
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
border-radius: $r-xs;
|
||||
@include flex-center;
|
||||
padding: 0 var(--tk-gap-sm);
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
|
||||
&.ai-feedback-adopt {
|
||||
background: rgba($acc, 0.15);
|
||||
}
|
||||
|
||||
&.ai-feedback-ignore {
|
||||
background: $surface-alt;
|
||||
}
|
||||
|
||||
&.ai-feedback-consult {
|
||||
background: var(--tk-pri-l);
|
||||
}
|
||||
.health-article-title {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
line-height: 1.35;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.ai-feedback-btn-text {
|
||||
font-size: var(--tk-font-micro);
|
||||
font-weight: 500;
|
||||
.health-article-summary {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
line-height: 1.4;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-2xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-feedback-adopt .ai-feedback-btn-text {
|
||||
color: $acc;
|
||||
.health-article-meta {
|
||||
display: flex;
|
||||
gap: var(--tk-gap-sm);
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ai-feedback-consult .ai-feedback-btn-text {
|
||||
.health-article-tag {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: var(--tk-pri);
|
||||
background: var(--tk-pri-l);
|
||||
padding: 2px 8px;
|
||||
border-radius: $r-xs;
|
||||
}
|
||||
|
||||
.health-article-date {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// 长者模式
|
||||
.elder-mode .health-page {
|
||||
.health-cat-tab {
|
||||
padding: 10px 20px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.health-article-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
.health-article-summary {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,244 +1,152 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import GuestGuard from '../../components/GuestGuard';
|
||||
import Loading from '../../components/Loading';
|
||||
import {
|
||||
listArticles,
|
||||
listCategories,
|
||||
listPublicArticles,
|
||||
listPublicCategories,
|
||||
type Article,
|
||||
type ArticleCategory,
|
||||
} from '../../services/article';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import SegmentTabs from '../../components/SegmentTabs';
|
||||
import { useHealthOverview, VITAL_TABS, type VitalType } from './useHealthOverview';
|
||||
import { submitSuggestionFeedback } from '../../services/ai-analysis';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
import ErrorState from '../../components/ErrorState';
|
||||
import Loading from '../../components/Loading';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const QUICK_ENTRIES = [
|
||||
{ label: '录入体征', icon: '✏', color: 'input', path: '/pages/pkg-health/input/index' },
|
||||
{ label: '健康趋势', icon: '📈', color: 'trend', path: '/pages/pkg-health/trend/index' },
|
||||
{ label: '我的报告', icon: '📋', color: 'report', path: '/pages/pkg-profile/reports/index' },
|
||||
{ label: '健康档案', icon: '健', color: 'med', path: '/pages/pkg-profile/health-records/index' },
|
||||
] as const;
|
||||
|
||||
function statusClass(status?: string): string {
|
||||
if (!status) return '';
|
||||
if (status === 'high' || status === 'abnormal') return 'vital-warn';
|
||||
if (status === 'low') return 'vital-warn';
|
||||
return 'vital-ok';
|
||||
}
|
||||
|
||||
function formatDate(): string {
|
||||
const d = new Date();
|
||||
const month = d.getMonth() + 1;
|
||||
const day = d.getDate();
|
||||
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
return `${month}月${day}日 周${weekDays[d.getDay()]}`;
|
||||
}
|
||||
|
||||
export default function Health() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const modeClass = useElderClass();
|
||||
const {
|
||||
todaySummary, loading, error, activeTab, trendData, trendLoading,
|
||||
aiSuggestions, thresholds, alertCount, handleTabChange, fetchData,
|
||||
} = useHealthOverview();
|
||||
const isLoggedIn = !!useAuthStore((s) => s.user);
|
||||
const [articles, setArticles] = useState<Article[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [categories, setCategories] = useState<ArticleCategory[]>([]);
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
|
||||
if (!user) {
|
||||
return <GuestGuard title='请先登录' desc='登录后即可查看健康数据' />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageShell padding="md" safeBottom={false} scroll={false} className={`health-page ${modeClass}`}>
|
||||
<View className='health-header'>
|
||||
<Text className='health-title'>健康总览</Text>
|
||||
</View>
|
||||
<Loading />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const maxTrendValue = trendData.reduce((max, d) => Math.max(max, d.value), 1);
|
||||
const dayLabels = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
const summary = todaySummary || {};
|
||||
const vitals = [
|
||||
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '—', unit: 'mmHg', status: summary.blood_pressure?.status },
|
||||
{ label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '—', unit: 'bpm', status: summary.heart_rate?.status },
|
||||
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status },
|
||||
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '—', unit: 'kg', status: summary.weight?.status },
|
||||
];
|
||||
const recordedCount = vitals.filter((v) => v.value !== '—').length;
|
||||
|
||||
const getThresholdValue = (type: VitalType): number | null => {
|
||||
if (!thresholds.length) return null;
|
||||
const th = thresholds;
|
||||
if (type === 'blood_pressure') {
|
||||
const v = th.find((t) => t.indicator === 'systolic_bp' && t.level === 'high');
|
||||
return v?.threshold_value ?? 140;
|
||||
const fetchData = useCallback(async (p: number, append = false, categoryId?: string | null) => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
try {
|
||||
const cid = categoryId !== undefined ? categoryId : activeCategory;
|
||||
const res = isLoggedIn
|
||||
? await listArticles({ page: p, category_id: cid || undefined })
|
||||
: await listPublicArticles({ page: p, category_id: cid || undefined });
|
||||
const list = res.data || [];
|
||||
setArticles(append ? (prev) => [...prev, ...list] : list);
|
||||
setTotal(res.total);
|
||||
setPage(p);
|
||||
} catch (err) {
|
||||
console.warn('[health] 加载文章列表失败:', err);
|
||||
setError(true);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
if (type === 'heart_rate') {
|
||||
const v = th.find((t) => t.indicator === 'heart_rate' && t.level === 'high');
|
||||
return v?.threshold_value ?? 100;
|
||||
}, [activeCategory, isLoggedIn]);
|
||||
|
||||
usePageData(
|
||||
useCallback(async () => {
|
||||
try {
|
||||
const cats = isLoggedIn
|
||||
? await listCategories()
|
||||
: await listPublicCategories();
|
||||
setCategories(cats || []);
|
||||
} catch (err) {
|
||||
console.warn('[health] 加载分类失败:', err);
|
||||
setCategories([]);
|
||||
}
|
||||
await fetchData(1);
|
||||
}, [fetchData, isLoggedIn]),
|
||||
{ throttleMs: 10000, enablePullDown: true },
|
||||
);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!loading && articles.length < total) {
|
||||
fetchData(page + 1, true);
|
||||
}
|
||||
if (type === 'blood_sugar') {
|
||||
const v = th.find((t) => t.indicator === 'blood_sugar_fasting' && t.level === 'high');
|
||||
return v?.threshold_value ?? 6.1;
|
||||
}
|
||||
return null;
|
||||
}, [loading, articles.length, total, page, fetchData]);
|
||||
|
||||
const handleCategoryChange = (categoryId: string | null) => {
|
||||
setActiveCategory(categoryId);
|
||||
fetchData(1, false, categoryId);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
const month = d.getMonth() + 1;
|
||||
const day = d.getDate();
|
||||
return `${month}月${day}日`;
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell padding="md" safeBottom={false} scroll className={`health-page ${modeClass}`}>
|
||||
<View className='health-header'>
|
||||
<Text className='health-title'>健康总览</Text>
|
||||
<Text className='health-date'>{formatDate()}</Text>
|
||||
</View>
|
||||
<PageShell safeBottom={false} padding="none" scroll={false} className={`health-page ${modeClass}`}>
|
||||
{/* 分类标签 */}
|
||||
{categories.length > 0 && (
|
||||
<ScrollView scrollX className='health-categories'>
|
||||
<View
|
||||
className={`health-cat-tab ${!activeCategory ? 'health-cat-tab--active' : ''}`}
|
||||
onClick={() => handleCategoryChange(null)}
|
||||
>
|
||||
<Text>推荐</Text>
|
||||
</View>
|
||||
{categories.map((cat) => (
|
||||
<View
|
||||
key={cat.id}
|
||||
className={`health-cat-tab ${activeCategory === cat.id ? 'health-cat-tab--active' : ''}`}
|
||||
onClick={() => handleCategoryChange(cat.id)}
|
||||
>
|
||||
<Text>{cat.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* 今日体征 hero 卡片 */}
|
||||
<View className='vitals-grid'>
|
||||
<View className='vitals-header'>
|
||||
<Text className='vitals-title'>今日体征</Text>
|
||||
{recordedCount > 0 && (
|
||||
<Text className='vitals-badge'>已记录 {recordedCount} 项</Text>
|
||||
)}
|
||||
</View>
|
||||
{loading ? <Loading /> : (
|
||||
<View className='vitals-row'>
|
||||
{vitals.map((v) => (
|
||||
<View className={`vital-cell ${statusClass(v.status)}`} key={v.label}>
|
||||
<Text className='vital-value'>{v.value}</Text>
|
||||
<Text className='vital-unit'>{v.unit}</Text>
|
||||
<Text className='vital-label'>{v.label}</Text>
|
||||
</View>
|
||||
{/* 文章列表 */}
|
||||
<ScrollView scrollY className='health-scroll' onScrollToLower={loadMore} lowerThreshold={200}>
|
||||
{error ? (
|
||||
<ErrorState onRetry={() => fetchData(1, false, null)} />
|
||||
) : articles.length === 0 && !loading ? (
|
||||
<EmptyState text='暂无健康资讯' />
|
||||
) : (
|
||||
<View className='health-article-list'>
|
||||
{articles.map((a) => (
|
||||
<ContentCard
|
||||
key={a.id}
|
||||
padding='sm'
|
||||
margin='none'
|
||||
onPress={() => safeNavigateTo(`/pages/article/detail/index?id=${a.id}`)}
|
||||
>
|
||||
<View className='health-article-body'>
|
||||
<View className='health-article-content'>
|
||||
<Text className='health-article-title'>{a.title}</Text>
|
||||
{a.summary && (
|
||||
<Text className='health-article-summary'>{a.summary}</Text>
|
||||
)}
|
||||
<View className='health-article-meta'>
|
||||
{(a.category_name || a.category) && (
|
||||
<Text className='health-article-tag'>{a.category_name || a.category}</Text>
|
||||
)}
|
||||
{a.published_at && (
|
||||
<Text className='health-article-date'>{formatDate(a.published_at)}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ContentCard>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 快捷入口 — 横排 4 格图标 */}
|
||||
<View className='quick-entries'>
|
||||
{QUICK_ENTRIES.map((e) => (
|
||||
<View
|
||||
key={e.label}
|
||||
className='quick-entry'
|
||||
onClick={() => safeNavigateTo(e.path)}
|
||||
>
|
||||
<View className={`quick-icon quick-icon--${e.color}`}>
|
||||
<Text className='quick-icon-text'>{e.icon}</Text>
|
||||
</View>
|
||||
<Text className='quick-label'>{e.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 告警横幅 */}
|
||||
{alertCount > 0 && (
|
||||
<ContentCard
|
||||
variant="default"
|
||||
padding="sm"
|
||||
margin="none"
|
||||
className='alert-hint'
|
||||
onPress={() => safeNavigateTo('/pages/pkg-health/alerts/index')}
|
||||
>
|
||||
<View className='alert-dot' />
|
||||
<Text className='alert-text'>{alertCount} 条待处理告警</Text>
|
||||
<Text className='alert-arrow'>›</Text>
|
||||
</ContentCard>
|
||||
)}
|
||||
|
||||
{/* AI 建议 */}
|
||||
{aiSuggestions.length > 0 && (
|
||||
<View className='ai-suggestion-card'>
|
||||
<View className='ai-card-header'>
|
||||
<Text className='ai-card-title'>AI 健康建议</Text>
|
||||
<Text className='ai-card-count'>{aiSuggestions.length} 条</Text>
|
||||
</View>
|
||||
{aiSuggestions.map((s) => {
|
||||
const riskCls = s.risk_level === 'high' ? 'ai-risk-high' : s.risk_level === 'medium' ? 'ai-risk-medium' : 'ai-risk-low';
|
||||
const params = s.params as Record<string, unknown> | null;
|
||||
const reason = (params?.reason as string) || (params?.message as string) || '健康建议';
|
||||
return (
|
||||
<View key={s.id} className='ai-suggestion-item'>
|
||||
<View className='ai-suggestion-main' onClick={() => {
|
||||
if (s.suggestion_type === 'appointment') safeNavigateTo('/pages/appointment/create/index');
|
||||
else if (s.suggestion_type === 'followup') safeNavigateTo('/pages/pkg-profile/followups/index');
|
||||
}}>
|
||||
<View className={`ai-risk-dot ${riskCls}`} />
|
||||
<Text className='ai-suggestion-text'>{reason.slice(0, 50)}</Text>
|
||||
</View>
|
||||
<View className='ai-feedback-row'>
|
||||
<View className='ai-feedback-btn ai-feedback-adopt' onClick={async () => {
|
||||
try { await submitSuggestionFeedback(s.id, 'adopt'); Taro.showToast({ title: '已采纳', icon: 'success' }); fetchData(); }
|
||||
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
|
||||
}}>
|
||||
<Text className='ai-feedback-btn-text'>采纳</Text>
|
||||
</View>
|
||||
<View className='ai-feedback-btn ai-feedback-ignore' onClick={async () => {
|
||||
try { await submitSuggestionFeedback(s.id, 'ignore'); Taro.showToast({ title: '已忽略', icon: 'success' }); fetchData(); }
|
||||
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
|
||||
}}>
|
||||
<Text className='ai-feedback-btn-text'>忽略</Text>
|
||||
</View>
|
||||
<View className='ai-feedback-btn ai-feedback-consult' onClick={async () => {
|
||||
try { await submitSuggestionFeedback(s.id, 'consult'); safeNavigateTo('/pages/consultation/index'); }
|
||||
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
|
||||
}}>
|
||||
<Text className='ai-feedback-btn-text'>咨询医生</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 7天趋势 */}
|
||||
<View className='trend-section'>
|
||||
<Text className='section-title'>近 7 天趋势</Text>
|
||||
<SegmentTabs tabs={VITAL_TABS} activeKey={activeTab} onChange={handleTabChange} variant="pill" />
|
||||
{trendLoading ? <Loading /> : trendData.length === 0 ? (
|
||||
<ContentCard padding="md">
|
||||
<Text className='trend-empty-text'>暂无趋势数据</Text>
|
||||
</ContentCard>
|
||||
) : (
|
||||
<ContentCard padding="md">
|
||||
<View className='trend-bars'>
|
||||
{(() => {
|
||||
const tv = getThresholdValue(activeTab);
|
||||
if (tv) {
|
||||
const pct = Math.min(95, (tv / maxTrendValue) * 100);
|
||||
return (
|
||||
<View className='trend-threshold-line' style={`bottom:${((12 + pct * 1.08) / 120 * 100).toFixed(1)}%;`}>
|
||||
<Text className='trend-threshold-label'>{tv}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
{trendData.map((point, i) => {
|
||||
const heightPct = Math.max(8, (point.value / maxTrendValue) * 100);
|
||||
const tv = getThresholdValue(activeTab);
|
||||
const isAbnormal = tv ? point.value >= tv : false;
|
||||
const dayOfWeek = new Date(point.date).getDay();
|
||||
return (
|
||||
<View className='trend-bar-col' key={i}>
|
||||
<View
|
||||
className={`trend-bar ${isAbnormal ? 'trend-bar-warn' : 'trend-bar-normal'}`}
|
||||
style={`height:${heightPct}%;`}
|
||||
/>
|
||||
<Text className='trend-bar-label'>{dayLabels[dayOfWeek]}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ContentCard>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 健康资讯入口 */}
|
||||
<ContentCard onPress={() => safeNavigateTo('/pages/article/index')}>
|
||||
<Text className='article-entry-text'>最新健康资讯 ›</Text>
|
||||
</ContentCard>
|
||||
{loading && <Loading />}
|
||||
</ScrollView>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '健康测量',
|
||||
});
|
||||
256
apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.scss
Normal file
256
apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.scss
Normal file
@@ -0,0 +1,256 @@
|
||||
// Veepoo 测量结果 + 上传页样式
|
||||
// 设计原型: docs/design/veepoo-measure-prototype.html
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
.vm-page {
|
||||
min-height: 100vh;
|
||||
background: var(--tk-bg-primary, $bg);
|
||||
}
|
||||
|
||||
// ── 连接中(等待跳转态) ──
|
||||
.vm-connect {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 0 32px;
|
||||
|
||||
&__anim {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
&__ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 3px solid $pri;
|
||||
animation: vm-pulse-ring 2s ease-out infinite;
|
||||
}
|
||||
|
||||
&__center {
|
||||
position: absolute;
|
||||
inset: 20px;
|
||||
border-radius: 50%;
|
||||
background: $pri;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__bt {
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-h2, 22px);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: var(--tk-font-body-sm, 14px);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes vm-pulse-ring {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
100% { transform: scale(1.4); opacity: 0; }
|
||||
}
|
||||
|
||||
// ── 上传页面 ──
|
||||
.vm-upload {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 40px;
|
||||
|
||||
&__header {
|
||||
padding: var(--tk-gap-lg, 24px) var(--tk-page-padding, 20px) var(--tk-gap-md, 16px);
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: block;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-h2, 22px);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
display: block;
|
||||
font-size: var(--tk-font-body-sm, 14px);
|
||||
color: $tx3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 结果卡片网格 ──
|
||||
.vm-results-grid {
|
||||
padding: 0 var(--tk-page-padding, 20px);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--tk-gap-sm, 12px);
|
||||
}
|
||||
|
||||
.vm-result-card {
|
||||
background: $card;
|
||||
border-radius: var(--tk-card-radius, 16px);
|
||||
padding: var(--tk-gap-md, 16px);
|
||||
box-shadow: $shadow-sm;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
&--empty {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap, 13px);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num-lg, 34px);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__unit {
|
||||
font-size: var(--tk-font-cap, 13px);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-top: 8px;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: var(--tk-font-micro, 11px);
|
||||
font-weight: 500;
|
||||
|
||||
&--normal {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: $dan-l;
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
padding-left: 8px;
|
||||
font-size: var(--tk-font-body-sm, 14px);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&--sleep {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 睡眠数据行 ──
|
||||
.vm-sleep-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 8px;
|
||||
margin-left: 8px;
|
||||
|
||||
&__day {
|
||||
font-size: var(--tk-font-body-sm, 14px);
|
||||
color: $tx2;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body, 16px);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__quality {
|
||||
font-size: 12px;
|
||||
color: $wrn;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 底部上传播区 ──
|
||||
.vm-upload-footer {
|
||||
padding: var(--tk-gap-lg, 24px) var(--tk-page-padding, 20px) var(--tk-gap-xl, 32px);
|
||||
|
||||
&__hint {
|
||||
display: block;
|
||||
font-size: var(--tk-font-cap, 13px);
|
||||
color: $tx3;
|
||||
text-align: center;
|
||||
margin-bottom: var(--tk-gap-sm, 12px);
|
||||
}
|
||||
|
||||
&__btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__time {
|
||||
display: block;
|
||||
font-size: var(--tk-font-micro, 11px);
|
||||
color: $tx3;
|
||||
text-align: center;
|
||||
margin-top: var(--tk-gap-sm, 12px);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 长者模式 ──
|
||||
.elder-mode {
|
||||
.vm-results-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.vm-result-card__value {
|
||||
font-size: var(--tk-font-num-lg, 40px);
|
||||
}
|
||||
}
|
||||
306
apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.tsx
Normal file
306
apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { uploadReadings } from '@/services/device-sync';
|
||||
import type { NormalizedReading } from '@/services/ble/types';
|
||||
import { useElderClass } from '@/hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import PrimaryButton from '@/components/ui/PrimaryButton';
|
||||
import './index.scss';
|
||||
|
||||
/** 原生页面返回的测量结果格式 */
|
||||
interface NativeMeasureResult {
|
||||
type: string;
|
||||
values: Record<string, number>;
|
||||
measuredAt: number;
|
||||
}
|
||||
|
||||
/** 原生页面返回的睡眠数据格式 */
|
||||
interface NativeSleepResult {
|
||||
day: number;
|
||||
deepSleepMinutes: number;
|
||||
lightSleepMinutes: number;
|
||||
totalSleepMinutes: number;
|
||||
qualityScore: number;
|
||||
fallAsleepTime: string;
|
||||
exitSleepTime: string;
|
||||
}
|
||||
|
||||
/** 指标配置 */
|
||||
const METRIC_CONFIG = [
|
||||
{ type: 'heart_rate', label: '心率', unit: 'bpm', color: '#EF4444', icon: '♥' },
|
||||
{ type: 'blood_oxygen', label: '血氧', unit: '%', color: '#3B82F6', icon: 'O₂' },
|
||||
{ type: 'blood_pressure', label: '血压', unit: 'mmHg', color: '#8B5CF6', icon: '↕' },
|
||||
{ type: 'temperature', label: '体温', unit: '°C', color: '#F59E0B', icon: 'T' },
|
||||
{ type: 'pressure', label: '压力', unit: '', color: '#6366F1', icon: '~' },
|
||||
] as const;
|
||||
|
||||
/** 健康评估 */
|
||||
function assessHealth(type: string, values: Record<string, number>): { level: 'normal' | 'warning' | 'danger'; text: string } {
|
||||
switch (type) {
|
||||
case 'heart_rate': {
|
||||
const v = values.heart_rate ?? 0;
|
||||
if (v >= 60 && v <= 100) return { level: 'normal', text: '心率正常' };
|
||||
if (v < 50 || v > 120) return { level: 'danger', text: '心率异常' };
|
||||
return { level: 'warning', text: '心率偏离正常范围' };
|
||||
}
|
||||
case 'blood_oxygen': {
|
||||
const v = values.blood_oxygen ?? 0;
|
||||
if (v >= 95) return { level: 'normal', text: '血氧正常' };
|
||||
if (v >= 90) return { level: 'warning', text: '血氧偏低' };
|
||||
return { level: 'danger', text: '血氧过低' };
|
||||
}
|
||||
case 'blood_pressure': {
|
||||
const sys = values.systolic ?? 0;
|
||||
const dia = values.diastolic ?? 0;
|
||||
if (sys >= 90 && sys <= 140 && dia >= 60 && dia <= 90) return { level: 'normal', text: '血压正常' };
|
||||
if (sys > 160 || dia > 100) return { level: 'danger', text: '血压过高' };
|
||||
return { level: 'warning', text: '血压偏高' };
|
||||
}
|
||||
case 'temperature': {
|
||||
const v = values.temperature ?? 0;
|
||||
if (v >= 36.0 && v <= 37.3) return { level: 'normal', text: '体温正常' };
|
||||
if (v > 38.0) return { level: 'danger', text: '发热' };
|
||||
return { level: 'warning', text: '体温偏离正常' };
|
||||
}
|
||||
case 'pressure': {
|
||||
const v = values.pressure ?? 0;
|
||||
if (v >= 1 && v <= 40) return { level: 'normal', text: '压力正常' };
|
||||
if (v > 60) return { level: 'danger', text: '压力过高' };
|
||||
return { level: 'warning', text: '压力偏高' };
|
||||
}
|
||||
default:
|
||||
return { level: 'normal', text: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化显示值 */
|
||||
function formatValue(type: string, values: Record<string, number>): string {
|
||||
if (type === 'blood_pressure') {
|
||||
return `${values.systolic ?? '--'}/${values.diastolic ?? '--'}`;
|
||||
}
|
||||
const v = Object.values(values)[0];
|
||||
return v !== undefined ? String(v) : '--';
|
||||
}
|
||||
|
||||
export default function VeepooMeasure() {
|
||||
const modeClass = useElderClass();
|
||||
const router = useRouter();
|
||||
const patient = useAuthStore((s) => s.currentPatient);
|
||||
const navigatedRef = useRef(false);
|
||||
const [results, setResults] = React.useState<Record<string, NativeMeasureResult>>({});
|
||||
const [sleepData, setSleepData] = React.useState<NativeSleepResult[]>([]);
|
||||
const [uploadStatus, setUploadStatus] = React.useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
|
||||
|
||||
// 从 URL 或 store 获取 patientId
|
||||
const patientId = patient?.id || router.params.patientId || '';
|
||||
|
||||
// C3 修复:用 ref 防重入,避免 React Strict Mode 双触发
|
||||
if (!navigatedRef.current) {
|
||||
navigatedRef.current = true;
|
||||
// 延迟到下一个微任务,确保页面渲染完成后再跳转
|
||||
setTimeout(() => {
|
||||
Taro.navigateTo({
|
||||
url: `/pkg-veepoo/index?patientId=${patientId}`,
|
||||
events: {
|
||||
measureResult: (data: NativeMeasureResult) => {
|
||||
setResults((prev) => ({ ...prev, [data.type]: data }));
|
||||
},
|
||||
measureComplete: (data: { results: Record<string, NativeMeasureResult>; count: number }) => {
|
||||
if (data.results) setResults(data.results);
|
||||
},
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// 页面恢复时读取原生页面返回的测量结果 + 睡眠数据
|
||||
useDidShow(() => {
|
||||
try {
|
||||
const raw = Taro.getStorageSync('hms:veepoo_measure_results');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Record<string, NativeMeasureResult>;
|
||||
setResults(parsed);
|
||||
Taro.removeStorageSync('hms:veepoo_measure_results');
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
const rawSleep = Taro.getStorageSync('hms:veepoo_sleep_results');
|
||||
if (rawSleep) {
|
||||
const parsedSleep = JSON.parse(rawSleep) as NativeSleepResult[];
|
||||
if (parsedSleep.length > 0) {
|
||||
setSleepData(parsedSleep);
|
||||
}
|
||||
Taro.removeStorageSync('hms:veepoo_sleep_results');
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
const handleUpload = async () => {
|
||||
// 修复:添加明确的错误提示,不再静默退出
|
||||
if (!patientId) {
|
||||
console.warn('[veepoo-measure] 上传失败:未获取到患者 ID');
|
||||
Taro.showToast({ title: '请先绑定患者档案', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const allResults = Object.values(results);
|
||||
const hasMeasureData = allResults.length > 0;
|
||||
const hasSleep = sleepData.length > 0;
|
||||
|
||||
if (!hasMeasureData && !hasSleep) {
|
||||
console.warn('[veepoo-measure] 上传失败:无数据');
|
||||
Taro.showToast({ title: '暂无测量数据', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadStatus('uploading');
|
||||
try {
|
||||
const allReadings: NormalizedReading[] = [];
|
||||
|
||||
// 测量结果
|
||||
if (hasMeasureData) {
|
||||
console.log('[veepoo-measure] 上传测量数据', allResults.length, '项');
|
||||
allReadings.push(...allResults.map((r) => ({
|
||||
device_type: r.type as NormalizedReading['device_type'],
|
||||
values: r.values,
|
||||
measured_at: new Date(r.measuredAt).toISOString(),
|
||||
})));
|
||||
}
|
||||
|
||||
// 睡眠数据
|
||||
if (hasSleep) {
|
||||
const now = new Date();
|
||||
console.log('[veepoo-measure] 上传睡眠数据', sleepData.length, '天');
|
||||
allReadings.push(...sleepData.map((s) => {
|
||||
const baseDate = new Date(now.getTime() - s.day * 86400000);
|
||||
return {
|
||||
device_type: 'sleep' as const,
|
||||
values: {
|
||||
deep_sleep_minutes: s.deepSleepMinutes,
|
||||
light_sleep_minutes: s.lightSleepMinutes,
|
||||
total_sleep_minutes: s.totalSleepMinutes,
|
||||
quality_score: s.qualityScore,
|
||||
},
|
||||
measured_at: baseDate.toISOString(),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
await uploadReadings(patientId, 'veepoo_m2', 'Veepoo M2', allReadings);
|
||||
setUploadStatus('success');
|
||||
Taro.showToast({ title: '数据已上传', icon: 'success' });
|
||||
} catch (err) {
|
||||
console.error('[veepoo-measure] 上传失败:', err);
|
||||
setUploadStatus('error');
|
||||
Taro.showToast({ title: '上传失败,请重试', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
const hasResults = Object.keys(results).length > 0;
|
||||
const measuredCount = Object.keys(results).length;
|
||||
const measuredAt = hasResults
|
||||
? new Date(Object.values(results)[0].measuredAt).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: '';
|
||||
|
||||
return (
|
||||
<PageShell padding="none" className={`vm-page ${modeClass}`}>
|
||||
{hasResults ? (
|
||||
<View className="vm-upload">
|
||||
{/* 页面标题 */}
|
||||
<View className="vm-upload__header">
|
||||
<Text className="vm-upload__title">测量结果</Text>
|
||||
<Text className="vm-upload__subtitle">Veepoo M2 · 刚刚完成测量</Text>
|
||||
</View>
|
||||
|
||||
{/* 结果卡片网格 */}
|
||||
<View className="vm-results-grid">
|
||||
{METRIC_CONFIG.map((metric) => {
|
||||
const result = results[metric.type];
|
||||
if (result) {
|
||||
const assessment = assessHealth(metric.type, result.values);
|
||||
return (
|
||||
<View
|
||||
key={metric.type}
|
||||
className={`vm-result-card ${metric.type === 'blood_pressure' ? 'vm-result-card--full' : ''}`}
|
||||
>
|
||||
<View className="vm-result-card__badge" style={{ background: metric.color }} />
|
||||
<Text className="vm-result-card__label">{metric.label}</Text>
|
||||
<View className="vm-result-card__row">
|
||||
<Text className="vm-result-card__value">{formatValue(metric.type, result.values)}</Text>
|
||||
<Text className="vm-result-card__unit">{metric.unit}</Text>
|
||||
</View>
|
||||
<View className={`vm-result-card__tag vm-result-card__tag--${assessment.level}`}>
|
||||
<Text>● {assessment.text}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// 未测量占位
|
||||
return (
|
||||
<View
|
||||
key={metric.type}
|
||||
className={`vm-result-card vm-result-card--empty ${metric.type === 'blood_pressure' ? 'vm-result-card--full' : ''}`}
|
||||
>
|
||||
<View className="vm-result-card__badge" style={{ background: metric.color, opacity: 0.3 }} />
|
||||
<Text className="vm-result-card__label">{metric.label}</Text>
|
||||
<Text className="vm-result-card__placeholder">未测量</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 睡眠数据卡片 */}
|
||||
{sleepData.length > 0 && (
|
||||
<View className="vm-result-card vm-result-card--full vm-result-card--sleep">
|
||||
<View className="vm-result-card__badge" style={{ background: '#5B7A5E' }} />
|
||||
<Text className="vm-result-card__label">睡眠数据({sleepData.length} 天)</Text>
|
||||
{sleepData.map((sleep, idx) => {
|
||||
const hours = Math.floor(sleep.totalSleepMinutes / 60);
|
||||
const mins = sleep.totalSleepMinutes % 60;
|
||||
const dayLabel = sleep.day === 0 ? '昨晚' : sleep.day === 1 ? '前晚' : '大前晚';
|
||||
return (
|
||||
<View key={idx} className="vm-sleep-row">
|
||||
<Text className="vm-sleep-row__day">{dayLabel}</Text>
|
||||
<Text className="vm-sleep-row__time">{hours}h{mins > 0 ? ` ${mins}min` : ''}</Text>
|
||||
<View className="vm-sleep-row__quality">
|
||||
{'★'.repeat(Math.min(sleep.qualityScore, 5))}{'☆'.repeat(Math.max(5 - sleep.qualityScore, 0))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<View className="vm-result-card__tag vm-result-card__tag--normal">
|
||||
<Text>● 自动同步</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 底部上传播区 */}
|
||||
<View className="vm-upload-footer">
|
||||
<Text className="vm-upload-footer__hint">测量数据将上传至您的健康档案</Text>
|
||||
<View className="vm-upload-footer__btn">
|
||||
<PrimaryButton onClick={handleUpload} disabled={uploadStatus === 'uploading'}>
|
||||
{uploadStatus === 'uploading'
|
||||
? '上传中...'
|
||||
: uploadStatus === 'success'
|
||||
? '✓ 已上传'
|
||||
: `上传数据(${measuredCount} 项测量${sleepData.length > 0 ? ' + ' + sleepData.length + ' 天睡眠' : ''})`}
|
||||
</PrimaryButton>
|
||||
</View>
|
||||
{measuredAt && <Text className="vm-upload-footer__time">测量时间:{measuredAt}</Text>}
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View className="vm-connect">
|
||||
<View className="vm-connect__anim">
|
||||
<View className="vm-connect__ring" />
|
||||
<View className="vm-connect__center"><Text className="vm-connect__bt">BT</Text></View>
|
||||
</View>
|
||||
<Text className="vm-connect__title">M2 手环健康测量</Text>
|
||||
<Text className="vm-connect__hint">即将跳转到设备测量页面...</Text>
|
||||
</View>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -68,7 +68,9 @@ export interface PatientSummary {
|
||||
}
|
||||
|
||||
/** 获取患者摘要列表(字段最小化,替代 getPatients) */
|
||||
export async function getPatientSummaries() {
|
||||
const res = await api.get<PaginatedData<PatientSummary>>('/health/patients/summary');
|
||||
export async function getPatientSummaries(userId?: string) {
|
||||
const params: Record<string, string> = {};
|
||||
if (userId) params.user_id = userId;
|
||||
const res = await api.get<PaginatedData<PatientSummary>>('/health/patients/summary', { params });
|
||||
return Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : []);
|
||||
}
|
||||
|
||||
305
apps/miniprogram/src/services/ble/VeepooBridge.ts
Normal file
305
apps/miniprogram/src/services/ble/VeepooBridge.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Veepoo SDK 桥接模块
|
||||
*
|
||||
* 调用顺序(基于 SDK Demo 验证):
|
||||
* 1. startScan() — 初始化蓝牙 + 扫描
|
||||
* 2. stopScan() — 找到设备后停止扫描
|
||||
* 3. connectDevice(deviceObj) — 传入完整设备对象(非 deviceId 字符串)
|
||||
* 4. registerDataListener() — 连接成功后注册数据监听
|
||||
* 5. authenticate() — 延迟 500ms 后调用秘钥认证
|
||||
* 6. 认证结果通过数据监听回调 type=1 返回
|
||||
*/
|
||||
|
||||
// @ts-ignore — SDK 类型声明为 any
|
||||
import { veepooBle, veepooFeature, veepooLogger } from './veepoo-sdk';
|
||||
|
||||
// ── SDK 事件类型常量 ──
|
||||
|
||||
/** 秘钥认证结果 */
|
||||
export const SDK_EVENT_AUTH = 1;
|
||||
/** 日常数据 */
|
||||
export const SDK_EVENT_DAILY = 5;
|
||||
/** 体温检测 */
|
||||
export const SDK_EVENT_TEMPERATURE = 6;
|
||||
/** 血压 */
|
||||
export const SDK_EVENT_BLOOD_PRESSURE = 18;
|
||||
/** 血氧手动测量 */
|
||||
export const SDK_EVENT_BLOOD_OXYGEN = 31;
|
||||
/** 心率测量 */
|
||||
export const SDK_EVENT_HEART_RATE = 51;
|
||||
/** 压力测量 */
|
||||
export const SDK_EVENT_PRESSURE = 58;
|
||||
|
||||
/** 设备正忙状态枚举(SDK state 字段) */
|
||||
export const DEVICE_STATE = {
|
||||
IDLE: 0,
|
||||
MEASURING_BP: 1,
|
||||
MEASURING_HR: 2,
|
||||
AUTO_TEST: 3,
|
||||
MEASURING_SPO2: 4,
|
||||
MEASURING_FATIGUE: 5,
|
||||
NOT_WORN: 6,
|
||||
CHARGING: 7,
|
||||
LOW_BATTERY: 8,
|
||||
BUSY: 9,
|
||||
} as const;
|
||||
|
||||
/** 连接回调中 connection 字段为 true 表示连接成功 */
|
||||
export interface VeepooConnectionResult {
|
||||
connection?: boolean;
|
||||
errno?: number;
|
||||
errCode?: number;
|
||||
errMsg?: string;
|
||||
}
|
||||
|
||||
/** SDK 事件回调数据(统一格式) */
|
||||
export interface SdkEventData {
|
||||
name: string;
|
||||
type: number;
|
||||
content: Record<string, unknown>;
|
||||
Progress?: number;
|
||||
state?: number;
|
||||
control?: number;
|
||||
ack?: number;
|
||||
}
|
||||
|
||||
// ── 蓝牙模块 ──
|
||||
|
||||
/** 初始化蓝牙 + 开始扫描 */
|
||||
export function startScan(onDeviceFound: (device: unknown) => void): void {
|
||||
veepooBle.veepooWeiXinSDKStartScanDeviceAndReceiveScanningDevice(
|
||||
(res: unknown) => {
|
||||
const device = Array.isArray(res) ? res[0] : res;
|
||||
if (device) onDeviceFound(device);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 停止扫描 */
|
||||
export function stopScan(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
veepooBle.veepooWeiXinSDKStopSearchBleManager(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
/** 连接设备 — 传入完整设备对象 */
|
||||
export function connectDevice(device: unknown): Promise<VeepooConnectionResult> {
|
||||
return new Promise((resolve) => {
|
||||
veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager(
|
||||
device,
|
||||
(res: VeepooConnectionResult) => resolve(res),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册数据监听(必须在连接成功后调用) */
|
||||
export function registerDataListener(callback: (data: SdkEventData) => void): void {
|
||||
veepooBle.veepooWeiXinSDKNotifyMonitorValueChange(callback);
|
||||
}
|
||||
|
||||
/** 监听蓝牙连接状态变化 */
|
||||
export function registerConnectionListener(callback: (res: { deviceId: string; connected: boolean }) => void): void {
|
||||
veepooBle.veepooWeiXinSDKBLEConnectionStateChangeManager(callback);
|
||||
}
|
||||
|
||||
/** 断开连接 */
|
||||
export function disconnect(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
veepooBle.veepooWeiXinSDKloseBluetoothAdapterManager(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// ── 功能模块:认证 ──
|
||||
|
||||
/** 秘钥认证(无参数无回调,结果通过数据监听 type=1 返回) */
|
||||
export function authenticate(): void {
|
||||
veepooFeature.veepooBlePasswordCheckManager();
|
||||
}
|
||||
|
||||
// ── 功能模块:测量指令 ──
|
||||
|
||||
/** 心率测量开关(true=开启,false=关闭) */
|
||||
export function setHeartRateMeasure(on: boolean): void {
|
||||
veepooFeature.veepooSendHeartRateTestSwitchManager({ switch: on });
|
||||
}
|
||||
|
||||
/** 血氧测量开关('start'=开启,'stop'=关闭) */
|
||||
export function setBloodOxygenMeasure(action: 'start' | 'stop'): void {
|
||||
veepooFeature.veepooSendBloodOxygenControlDataManager({ switch: action });
|
||||
}
|
||||
|
||||
/** 血压测量开关('start'=开启,'stop'=关闭) */
|
||||
export function setBloodPressureMeasure(action: 'start' | 'stop'): void {
|
||||
veepooFeature.veepooSendReadUniversalBloodPressureDataManager({ switch: action });
|
||||
}
|
||||
|
||||
/** 体温测量(单次触发) */
|
||||
export function startTemperatureMeasure(): void {
|
||||
veepooFeature.veepooSendTemperatureMeasurementSwitchManager();
|
||||
}
|
||||
|
||||
/** 压力测量开关(true=开启,false=关闭) */
|
||||
export function setPressureMeasure(on: boolean): void {
|
||||
veepooFeature.veepooSendPressureTestManager({ switch: on });
|
||||
}
|
||||
|
||||
// ── 功能模块:日常数据 ──
|
||||
|
||||
/** 读取日常数据(day: 0=今天, 1=昨天, 2=前天;package: 开始包序号,默认1) */
|
||||
export function readDailyData(day: number, pkg: number = 1): void {
|
||||
veepooFeature.veepooSendReadDailyDataManager({ day, package: pkg });
|
||||
}
|
||||
|
||||
// ── 功能模块:精准睡眠数据 ──
|
||||
|
||||
/** 精准睡眠事件类型 */
|
||||
export const SDK_EVENT_SLEEP = 4;
|
||||
|
||||
/** 精准睡眠数据(SDK 回调 type=4) */
|
||||
export interface SleepData {
|
||||
/** 入睡时间(时间戳字符串) */
|
||||
fallAsleepTime: string;
|
||||
/** 退出睡眠时间(时间戳字符串) */
|
||||
exitSleepTime: string;
|
||||
/** 起夜得分 */
|
||||
nightScore: number;
|
||||
/** 深睡得分 */
|
||||
deepSleepScore: number;
|
||||
/** 睡眠效率得分 */
|
||||
sleepEfficiencyScore: number;
|
||||
/** 入睡效率得分 */
|
||||
fallAsleepEfficiencyScore: number;
|
||||
/** 睡眠时长得分 */
|
||||
sleepTimeScore: number;
|
||||
/** 睡眠质量(1-5 星) */
|
||||
sleepQuality: number;
|
||||
/** 深睡时长(分钟) */
|
||||
deepSleepTime: number;
|
||||
/** 浅睡时长(分钟) */
|
||||
lightSleepTime: number;
|
||||
/** 其他睡眠时长(分钟) */
|
||||
otherSleepTime: number;
|
||||
/** 睡眠总时长(分钟) */
|
||||
sleepTotalTime: number;
|
||||
/** 首次深睡眠时长(分钟) */
|
||||
firstDeepSleepTime: number;
|
||||
/** 起夜总时长(分钟) */
|
||||
nightTotalTime: number;
|
||||
/** 起夜到深睡均值 */
|
||||
nightDeepSleepMeanValue: number;
|
||||
/** 失眠得分 */
|
||||
insomniaScore: number;
|
||||
/** 失眠次数 */
|
||||
insomniaCount: number;
|
||||
/** 睡眠曲线字符串(0=深睡, 1=浅睡, 2=REM, 3=失眠, 4=苏醒) */
|
||||
sleepCurve: string;
|
||||
}
|
||||
|
||||
/** 读取精准睡眠数据(day: 0=今天, 1=昨天, 2=前天) */
|
||||
export function readPreciseSleepData(day: number): void {
|
||||
veepooFeature.veepooSendReadPreciseSleepManager({ day });
|
||||
}
|
||||
|
||||
// ── 功能模块:自动测量(B3) ──
|
||||
|
||||
/** 自动测量事件类型 */
|
||||
export const SDK_EVENT_AUTO_TEST = 54;
|
||||
|
||||
/** B3 自动测量功能类型枚举 */
|
||||
export const AUTO_TEST_FUN_TYPES = {
|
||||
PULSE_RATE: 0, // 脉率
|
||||
BLOOD_PRESSURE: 1, // 血压
|
||||
BLOOD_GLUCOSE: 2, // 血糖
|
||||
PRESSURE: 3, // 压力
|
||||
BLOOD_OXYGEN: 4, // 血氧
|
||||
TEMPERATURE: 5, // 体温
|
||||
LORENTZ_SCATTER: 6, // 洛伦兹散点图
|
||||
HRV: 7, // HRV
|
||||
BLOOD_COMPONENT: 8, // 血液成分
|
||||
} as const;
|
||||
|
||||
export type AutoTestFunType = (typeof AUTO_TEST_FUN_TYPES)[keyof typeof AUTO_TEST_FUN_TYPES];
|
||||
|
||||
/** B3 自动测量配置项 */
|
||||
export interface AutoTestConfig {
|
||||
/** 协议类型(不可修改) */
|
||||
protocolType: number;
|
||||
/** 功能类型 0-8(可修改) */
|
||||
funTypeContent: AutoTestFunType;
|
||||
/** 开关:0=关闭, 1=开启 */
|
||||
funSwitch: number;
|
||||
/** 最小步进(分钟) */
|
||||
stepUnit: number;
|
||||
/** 是否支持时间段修改 */
|
||||
timeSlotModify: number;
|
||||
/** 是否支持时间间隔修改 */
|
||||
timeIntervalModify: number;
|
||||
/** 支持的测试时间段 */
|
||||
supportTimeSlot: { startTime: string; stopTime: string };
|
||||
/** 测量间隔(分钟,按 stepUnit 递增) */
|
||||
measInterval: number;
|
||||
/** 当前测试时间段 */
|
||||
currentTimeSlot: { startTime: string; stopTime: string };
|
||||
}
|
||||
|
||||
/** 读取自动测量功能配置 */
|
||||
export function readAutoTestConfig(): void {
|
||||
veepooFeature.veepooSendReadB3AutoTestFeatureDataManager();
|
||||
}
|
||||
|
||||
/** 设置自动测量功能 */
|
||||
export function setAutoTestConfig(config: AutoTestConfig): void {
|
||||
veepooFeature.veepooSendSetupB3AutoTestFeatureDataManager({
|
||||
p_protocol_type: config.protocolType,
|
||||
p_fun_type_content: config.funTypeContent,
|
||||
p_fun_switch: config.funSwitch,
|
||||
p_step_unit: config.stepUnit,
|
||||
p_time_slot_modify: config.timeSlotModify,
|
||||
p_time_interval_modify: config.timeIntervalModify,
|
||||
p_support_time_slot: config.supportTimeSlot,
|
||||
p_meas_inv: config.measInterval,
|
||||
p_cur_time_slot: config.currentTimeSlot,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 功能模块:开关设置 ──
|
||||
|
||||
/** 自动心率监测开关 */
|
||||
export function setAutoHeartRate(enabled: boolean): void {
|
||||
veepooFeature.veepooSendSwitchSettingDataManager({
|
||||
VPSettingAutomaticHRTest: enabled ? 'open' : 'close',
|
||||
});
|
||||
}
|
||||
|
||||
/** 自动血压监测开关 */
|
||||
export function setAutoBloodPressure(enabled: boolean): void {
|
||||
veepooFeature.veepooSendSwitchSettingDataManager({
|
||||
VPSettingAutomaticBPTest: enabled ? 'open' : 'close',
|
||||
});
|
||||
}
|
||||
|
||||
/** 体温自动监测 */
|
||||
export function setAutoTemperature(enabled: boolean): void {
|
||||
veepooFeature.veepooSendSwitchSettingDataManager({
|
||||
VPSettingAutomaticTemperatureTest: enabled ? 'open' : 'close',
|
||||
});
|
||||
}
|
||||
|
||||
/** 读取体温自动监测数据 */
|
||||
export function readAutoTemperatureData(): void {
|
||||
veepooFeature.veepooReadAutoTemperatureMeasurementDataManager({ day: 0 });
|
||||
}
|
||||
|
||||
// ── 功能模块:设备信息 ──
|
||||
|
||||
/** 读取设备电量 */
|
||||
export function readBatteryLevel(): void {
|
||||
veepooFeature.veepooReadElectricQuantityManager();
|
||||
}
|
||||
|
||||
// ── 日志模块 ──
|
||||
|
||||
/** 设置日志级别(0=DEBUG, 1=INFO, 2=WARN, 3=ERROR, 4=NONE) */
|
||||
export function setLogLevel(level: number): void {
|
||||
veepooLogger.setLevel(level);
|
||||
}
|
||||
245
apps/miniprogram/src/services/ble/veepoo/VeepooHistoryReader.ts
Normal file
245
apps/miniprogram/src/services/ble/veepoo/VeepooHistoryReader.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Veepoo 历史数据读取器 — 3天日常数据分批读取 + 上传
|
||||
*
|
||||
* SDK 日常数据格式(type=5):
|
||||
* - 包含计步、心率、血压、血氧、睡眠、压力、体温等
|
||||
* - Progress 字段 1-100% 表示读取进度
|
||||
* - 每次回调可能包含一包数据
|
||||
*/
|
||||
|
||||
import Taro from '@tarojs/taro';
|
||||
import { readDailyData } from '../VeepooBridge';
|
||||
import type { SdkEventData } from '../VeepooBridge';
|
||||
import type { NormalizedReading } from '../types';
|
||||
import type { SleepReading } from './types';
|
||||
import { uploadReadings } from '@/services/device-sync';
|
||||
|
||||
const CHECKPOINT_KEY = 'veepoo_history_checkpoint';
|
||||
const UPLOAD_BATCH_SIZE = 20;
|
||||
|
||||
interface Checkpoint {
|
||||
lastProgress: number;
|
||||
packagesRead: number;
|
||||
deviceId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type HistoryReadPhase = 'idle' | 'reading' | 'uploading' | 'done' | 'error';
|
||||
|
||||
export class VeepooHistoryReader {
|
||||
private phase: HistoryReadPhase = 'idle';
|
||||
private progress = 0;
|
||||
private packagesRead = 0;
|
||||
private buffer: NormalizedReading[] = [];
|
||||
private day = 0;
|
||||
private patientId = '';
|
||||
private deviceId = '';
|
||||
private onProgress?: (progress: number, phase: HistoryReadPhase) => void;
|
||||
private uploadedCount = 0;
|
||||
|
||||
setCallbacks(cbs: { onProgress?: (progress: number, phase: HistoryReadPhase) => void }): void {
|
||||
this.onProgress = cbs.onProgress;
|
||||
}
|
||||
|
||||
/** 开始读取3天数据 */
|
||||
async startRead(patientId: string, deviceId: string): Promise<number> {
|
||||
this.patientId = patientId;
|
||||
this.deviceId = deviceId;
|
||||
this.buffer = [];
|
||||
this.uploadedCount = 0;
|
||||
this.phase = 'reading';
|
||||
|
||||
// 依次读取 3 天数据
|
||||
for (let day = 0; day < 3; day++) {
|
||||
this.day = day;
|
||||
this.progress = 0;
|
||||
this.onProgress?.(0, 'reading');
|
||||
|
||||
await this.readDay(day);
|
||||
|
||||
// 刷新剩余 buffer
|
||||
if (this.buffer.length > 0) {
|
||||
await this.flushBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
this.phase = 'done';
|
||||
this.onProgress?.(100, 'done');
|
||||
this.clearCheckpoint();
|
||||
|
||||
return this.uploadedCount;
|
||||
}
|
||||
|
||||
/** 读取单天数据 */
|
||||
private readDay(day: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
// 发送读取指令
|
||||
readDailyData(day, 1);
|
||||
|
||||
// 进度通过 handleDailyEvent 更新
|
||||
// Progress=100 时 resolve
|
||||
this.dayResolve = resolve;
|
||||
|
||||
// 超时保护:30s
|
||||
this.dayTimeout = setTimeout(() => {
|
||||
this.dayResolve = null;
|
||||
resolve();
|
||||
}, 30_000);
|
||||
});
|
||||
}
|
||||
|
||||
private dayResolve: (() => void) | null = null;
|
||||
private dayTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** 处理 SDK 日常数据回调 */
|
||||
handleDailyEvent(data: SdkEventData): void {
|
||||
if (this.phase !== 'reading') return;
|
||||
|
||||
const progress = (data.Progress ?? 0) as number;
|
||||
this.progress = progress;
|
||||
this.onProgress?.(progress, 'reading');
|
||||
|
||||
// 解析数据
|
||||
const readings = this.parseDailyData(data);
|
||||
if (readings.length > 0) {
|
||||
this.buffer.push(...readings);
|
||||
this.packagesRead++;
|
||||
}
|
||||
|
||||
// 达到批量大小就上传
|
||||
if (this.buffer.length >= UPLOAD_BATCH_SIZE) {
|
||||
this.flushBuffer();
|
||||
}
|
||||
|
||||
// 进度 100% 表示当天数据读取完成
|
||||
if (progress >= 100) {
|
||||
if (this.dayTimeout) clearTimeout(this.dayTimeout);
|
||||
this.dayTimeout = null;
|
||||
const resolve = this.dayResolve;
|
||||
this.dayResolve = null;
|
||||
resolve?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 SDK 日常数据为 NormalizedReading */
|
||||
private parseDailyData(data: SdkEventData): NormalizedReading[] {
|
||||
const content = data.content ?? {};
|
||||
const readings: NormalizedReading[] = [];
|
||||
const now = new Date();
|
||||
// 偏移到对应天
|
||||
const baseDate = new Date(now.getTime() - this.day * 86400000);
|
||||
const timestamp = baseDate.toISOString();
|
||||
|
||||
// 心率
|
||||
const hr = content.heartReat ?? content.heartRate;
|
||||
if (typeof hr === 'number' && hr >= 30 && hr <= 250) {
|
||||
readings.push({ device_type: 'heart_rate', values: { heart_rate: hr }, measured_at: timestamp });
|
||||
}
|
||||
|
||||
// 血氧
|
||||
const bo = content.bloodOxygen;
|
||||
if (typeof bo === 'number' && bo >= 70 && bo <= 100) {
|
||||
readings.push({ device_type: 'blood_oxygen', values: { blood_oxygen: bo }, measured_at: timestamp });
|
||||
}
|
||||
|
||||
// 血压
|
||||
const bph = content.bloodPressureHigh;
|
||||
const bpl = content.bloodPressureLow;
|
||||
if (typeof bph === 'number' && typeof bpl === 'number' && bph > 0 && bpl > 0) {
|
||||
readings.push({ device_type: 'blood_pressure', values: { systolic: bph, diastolic: bpl }, measured_at: timestamp });
|
||||
}
|
||||
|
||||
// 体温
|
||||
const temp = content.bodyTemperature;
|
||||
if (typeof temp === 'number' && temp > 30 && temp < 45) {
|
||||
readings.push({ device_type: 'temperature', values: { temperature: temp }, measured_at: timestamp });
|
||||
}
|
||||
|
||||
// 压力
|
||||
const pressure = content.pressure;
|
||||
if (typeof pressure === 'number' && pressure >= 0 && pressure <= 100) {
|
||||
readings.push({ device_type: 'stress', values: { value: pressure }, measured_at: timestamp });
|
||||
}
|
||||
|
||||
// 步数
|
||||
const steps = content.stepCount ?? content.steps;
|
||||
if (typeof steps === 'number' && steps >= 0) {
|
||||
readings.push({ device_type: 'steps', values: { value: steps }, measured_at: timestamp });
|
||||
}
|
||||
|
||||
return readings;
|
||||
}
|
||||
|
||||
/** 上传 buffer 中的数据 */
|
||||
private async flushBuffer(): Promise<void> {
|
||||
if (this.buffer.length === 0) return;
|
||||
|
||||
const batch = this.buffer.splice(0, this.buffer.length);
|
||||
this.phase = 'uploading';
|
||||
this.onProgress?.(this.progress, 'uploading');
|
||||
|
||||
try {
|
||||
await uploadReadings(this.patientId, this.deviceId, 'Veepoo M2', batch);
|
||||
this.uploadedCount += batch.length;
|
||||
this.saveCheckpoint();
|
||||
} catch {
|
||||
// 上传失败,放回 buffer
|
||||
this.buffer.unshift(...batch);
|
||||
}
|
||||
|
||||
this.phase = 'reading';
|
||||
}
|
||||
|
||||
private saveCheckpoint(): void {
|
||||
try {
|
||||
const checkpoint: Checkpoint = {
|
||||
lastProgress: this.progress,
|
||||
packagesRead: this.packagesRead,
|
||||
deviceId: this.deviceId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
Taro.setStorageSync(CHECKPOINT_KEY, JSON.stringify(checkpoint));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
private clearCheckpoint(): void {
|
||||
try { Taro.removeStorageSync(CHECKPOINT_KEY); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
getPhase(): HistoryReadPhase { return this.phase; }
|
||||
getProgress(): number { return this.progress; }
|
||||
getUploadedCount(): number { return this.uploadedCount; }
|
||||
|
||||
// ── 睡眠数据上传 ──
|
||||
|
||||
/** 将睡眠数据转换为 NormalizedReading 并上传 */
|
||||
async uploadSleepReadings(patientId: string, deviceId: string, sleepData: SleepReading[]): Promise<number> {
|
||||
if (sleepData.length === 0) return 0;
|
||||
|
||||
const now = new Date();
|
||||
const readings: NormalizedReading[] = sleepData.map((sleep) => {
|
||||
// 根据天数偏移计算日期
|
||||
const baseDate = new Date(now.getTime() - sleep.day * 86400000);
|
||||
return {
|
||||
device_type: 'sleep',
|
||||
values: {
|
||||
deep_sleep_minutes: sleep.deepSleepMinutes,
|
||||
light_sleep_minutes: sleep.lightSleepMinutes,
|
||||
total_sleep_minutes: sleep.totalSleepMinutes,
|
||||
quality_score: sleep.qualityScore,
|
||||
},
|
||||
measured_at: baseDate.toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await uploadReadings(patientId, deviceId, 'Veepoo M2', readings);
|
||||
this.uploadedCount += readings.length;
|
||||
console.log('[veepoo-history] 睡眠数据上传成功:', readings.length, '条');
|
||||
return readings.length;
|
||||
} catch (err) {
|
||||
console.error('[veepoo-history] 睡眠数据上传失败:', err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
588
apps/miniprogram/src/services/ble/veepoo/VeepooPipeline.ts
Normal file
588
apps/miniprogram/src/services/ble/veepoo/VeepooPipeline.ts
Normal file
@@ -0,0 +1,588 @@
|
||||
/**
|
||||
* Veepoo 管线 — SDK 事件路由 + 连接编排 + 测量 Promise 封装
|
||||
*
|
||||
* 职责:
|
||||
* 1. 连接流程编排:扫描 → 连接 → 注册监听 → 认证 → 就绪
|
||||
* 2. SDK 事件路由:registerDataListener 按 type 分发
|
||||
* 3. 测量 Promise 化:startMeasure(type) → Promise<MeasureResult>
|
||||
*/
|
||||
|
||||
import Taro from '@tarojs/taro';
|
||||
import {
|
||||
startScan,
|
||||
stopScan,
|
||||
connectDevice,
|
||||
registerDataListener,
|
||||
registerConnectionListener,
|
||||
authenticate,
|
||||
disconnect as veepooDisconnect,
|
||||
setHeartRateMeasure,
|
||||
setBloodOxygenMeasure,
|
||||
setBloodPressureMeasure,
|
||||
startTemperatureMeasure,
|
||||
setPressureMeasure,
|
||||
readBatteryLevel,
|
||||
readPreciseSleepData,
|
||||
readAutoTestConfig,
|
||||
setAutoHeartRate,
|
||||
setAutoBloodPressure,
|
||||
setAutoTemperature,
|
||||
setLogLevel,
|
||||
SDK_EVENT_AUTH,
|
||||
SDK_EVENT_HEART_RATE,
|
||||
SDK_EVENT_BLOOD_OXYGEN,
|
||||
SDK_EVENT_BLOOD_PRESSURE,
|
||||
SDK_EVENT_TEMPERATURE,
|
||||
SDK_EVENT_PRESSURE,
|
||||
SDK_EVENT_DAILY,
|
||||
SDK_EVENT_SLEEP,
|
||||
SDK_EVENT_AUTO_TEST,
|
||||
DEVICE_STATE,
|
||||
} from '../VeepooBridge';
|
||||
import type { SdkEventData } from '../VeepooBridge';
|
||||
import type { MeasureType, MeasureResult, SleepReading } from './types';
|
||||
|
||||
const AUTH_TIMEOUT = 8_000;
|
||||
const AUTH_POLL_INTERVAL = 500;
|
||||
const MEASURE_SETTLE_DELAY = 1_500;
|
||||
|
||||
/** pending 测量的 resolve/reject 句柄 */
|
||||
interface PendingMeasure {
|
||||
type: MeasureType;
|
||||
resolve: (result: MeasureResult) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
lastValue: number | null;
|
||||
lastValues: Record<string, number>;
|
||||
settleTimer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
/** SDK type 到 MeasureType 的映射 */
|
||||
const SDK_TYPE_TO_MEASURE: Record<number, MeasureType> = {
|
||||
[SDK_EVENT_HEART_RATE]: 'heart_rate',
|
||||
[SDK_EVENT_BLOOD_OXYGEN]: 'blood_oxygen',
|
||||
[SDK_EVENT_BLOOD_PRESSURE]: 'blood_pressure',
|
||||
[SDK_EVENT_TEMPERATURE]: 'temperature',
|
||||
[SDK_EVENT_PRESSURE]: 'pressure',
|
||||
};
|
||||
|
||||
export type ConnectionChangeCallback = (connected: boolean, deviceId: string) => void;
|
||||
export type AuthResultCallback = (success: boolean) => void;
|
||||
export type MeasureEventCallback = (type: MeasureType, data: Record<string, unknown>) => void;
|
||||
export type DailyDataCallback = (data: SdkEventData) => void;
|
||||
export type SleepDataCallback = (day: number, sleep: SleepReading) => void;
|
||||
|
||||
export class VeepooPipeline {
|
||||
private pending: PendingMeasure | null = null;
|
||||
private isConnected = false;
|
||||
private deviceId = '';
|
||||
|
||||
/** 睡眠数据读取 Promise resolve 队列 */
|
||||
private sleepResolvers: Map<number, (sleep: SleepReading | null) => void> = new Map();
|
||||
private sleepTimeouts: Map<number, ReturnType<typeof setTimeout>> = new Map();
|
||||
|
||||
private onConnectionChange?: ConnectionChangeCallback;
|
||||
private onAuthResult?: AuthResultCallback;
|
||||
private onMeasureEvent?: MeasureEventCallback;
|
||||
private onDailyData?: DailyDataCallback;
|
||||
private onSleepData?: SleepDataCallback;
|
||||
|
||||
/** 注册回调 */
|
||||
setCallbacks(cbs: {
|
||||
onConnectionChange?: ConnectionChangeCallback;
|
||||
onAuthResult?: AuthResultCallback;
|
||||
onMeasureEvent?: MeasureEventCallback;
|
||||
onDailyData?: DailyDataCallback;
|
||||
onSleepData?: SleepDataCallback;
|
||||
}): void {
|
||||
this.onConnectionChange = cbs.onConnectionChange;
|
||||
this.onAuthResult = cbs.onAuthResult;
|
||||
this.onMeasureEvent = cbs.onMeasureEvent;
|
||||
this.onDailyData = cbs.onDailyData;
|
||||
this.onSleepData = cbs.onSleepData;
|
||||
}
|
||||
|
||||
/** 全流程:扫描 → 连接 → 注册监听 → 认证 */
|
||||
async connect(targetName: string, debug = false): Promise<string> {
|
||||
console.log('[veepoo-pipeline] connect() 开始, target:', targetName);
|
||||
if (debug) setLogLevel(0);
|
||||
|
||||
// 1. 扫描
|
||||
console.log('[veepoo-pipeline] Step 1: 扫描...');
|
||||
const device = await this.scanFor(targetName);
|
||||
if (!device) {
|
||||
console.error('[veepoo-pipeline] 扫描未找到设备');
|
||||
throw new Error(`未找到设备 ${targetName}`);
|
||||
}
|
||||
console.log('[veepoo-pipeline] 找到设备:', (device as Record<string, unknown>)?.deviceId);
|
||||
|
||||
// 2. 连接
|
||||
console.log('[veepoo-pipeline] Step 2: 连接...');
|
||||
const connRes = await connectDevice(device);
|
||||
console.log('[veepoo-pipeline] 连接结果:', JSON.stringify(connRes));
|
||||
// SDK 连接成功返回 errno=0 或 connection=true,两种都要兼容
|
||||
const ok = connRes?.connection === true || connRes?.errno === 0 || connRes?.errCode === 0;
|
||||
if (!ok) throw new Error('连接失败');
|
||||
|
||||
const id = (device as Record<string, unknown>).deviceId as string;
|
||||
this.deviceId = id;
|
||||
this.isConnected = true;
|
||||
|
||||
// 3. 注册数据监听(连接成功后)
|
||||
registerDataListener((data) => this.routeEvent(data));
|
||||
registerConnectionListener((res) => {
|
||||
this.isConnected = res.connected;
|
||||
this.onConnectionChange?.(res.connected, res.deviceId);
|
||||
});
|
||||
|
||||
// 4. 认证(延迟 500ms)
|
||||
await delay(500);
|
||||
authenticate();
|
||||
|
||||
// 5. 等待认证结果
|
||||
const authOk = await this.waitForAuth();
|
||||
if (!authOk) throw new Error('设备认证失败,请重新连接');
|
||||
|
||||
// 6. 读取电量
|
||||
readBatteryLevel();
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 扫描指定名称的设备 */
|
||||
private scanFor(targetName: string): Promise<unknown | null> {
|
||||
return new Promise((resolve) => {
|
||||
let found: unknown = null;
|
||||
const upper = targetName.toUpperCase();
|
||||
|
||||
startScan((device) => {
|
||||
const d = device as Record<string, unknown>;
|
||||
const name = String(d.localName ?? d.name ?? '').toUpperCase();
|
||||
if (name.includes(upper) && !found) {
|
||||
found = device;
|
||||
stopScan().then(() => resolve(found));
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (!found) {
|
||||
stopScan().then(() => resolve(null));
|
||||
}
|
||||
}, 10_000);
|
||||
});
|
||||
}
|
||||
|
||||
/** 等待认证结果(轮询 deviceChipStatus) */
|
||||
private waitForAuth(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
|
||||
const poll = () => {
|
||||
try {
|
||||
const status = Taro.getStorageSync('deviceChipStatus');
|
||||
if (status === 'successfulVerification' || status === 'passTheVerification') {
|
||||
this.onAuthResult?.(true);
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (Date.now() - start >= AUTH_TIMEOUT) {
|
||||
this.onAuthResult?.(false);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(poll, AUTH_POLL_INTERVAL);
|
||||
};
|
||||
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
/** SDK 事件路由 */
|
||||
private routeEvent(data: SdkEventData): void {
|
||||
const eventType = data.type;
|
||||
|
||||
// 认证回调
|
||||
if (eventType === SDK_EVENT_AUTH) {
|
||||
const content = data.content ?? {};
|
||||
const password = content.VPDevicepassword;
|
||||
if (password === 'passTheVerification' || password === 'successfulVerification') {
|
||||
this.onAuthResult?.(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 日常数据
|
||||
if (eventType === SDK_EVENT_DAILY) {
|
||||
this.onDailyData?.(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 精准睡眠数据
|
||||
if (eventType === SDK_EVENT_SLEEP) {
|
||||
this.handleSleepEvent(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 自动测量功能回调
|
||||
if (eventType === SDK_EVENT_AUTO_TEST) {
|
||||
console.log('[veepoo-pipeline] 自动测量配置回调:', JSON.stringify(data).substring(0, 300));
|
||||
return;
|
||||
}
|
||||
|
||||
// 测量数据
|
||||
const measureType = SDK_TYPE_TO_MEASURE[eventType];
|
||||
if (!measureType) return;
|
||||
|
||||
this.handleMeasureEvent(measureType, data);
|
||||
this.onMeasureEvent?.(measureType, data.content ?? {});
|
||||
}
|
||||
|
||||
/** 处理测量事件 */
|
||||
private handleMeasureEvent(type: MeasureType, data: SdkEventData): void {
|
||||
if (!this.pending || this.pending.type !== type) return;
|
||||
|
||||
const content = data.content ?? {};
|
||||
|
||||
// 检查设备状态错误
|
||||
const deviceBusy = content.deviceBusy === true;
|
||||
const notWear = content.notWear === true;
|
||||
const state = data.state;
|
||||
const ack = data.ack;
|
||||
|
||||
if (deviceBusy) {
|
||||
this.rejectPending(new Error('设备正忙,请稍后重试'));
|
||||
return;
|
||||
}
|
||||
if (notWear || state === DEVICE_STATE.NOT_WORN) {
|
||||
this.rejectPending(new Error('请将手环佩戴到手腕上'));
|
||||
return;
|
||||
}
|
||||
if (state === DEVICE_STATE.CHARGING) {
|
||||
this.rejectPending(new Error('设备正在充电,请取出后重试'));
|
||||
return;
|
||||
}
|
||||
if (state === DEVICE_STATE.LOW_BATTERY) {
|
||||
this.rejectPending(new Error('设备电量不足,请充电后重试'));
|
||||
return;
|
||||
}
|
||||
if (type === 'pressure' && ack === 2) {
|
||||
this.rejectPending(new Error('设备电量不足'));
|
||||
return;
|
||||
}
|
||||
if (type === 'pressure' && ack === 3) {
|
||||
this.rejectPending(new Error('设备正在测量其他数据'));
|
||||
return;
|
||||
}
|
||||
if (type === 'pressure' && ack === 4) {
|
||||
this.rejectPending(new Error('佩戴检测未通过'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 提取数值
|
||||
const values = this.extractValues(type, content);
|
||||
if (!values) return;
|
||||
|
||||
// 更新 pending 最新值
|
||||
this.pending.lastValues = values;
|
||||
|
||||
// 对于进度型指标,检查是否完成
|
||||
const progress = data.Progress;
|
||||
if (progress !== undefined && progress >= 100) {
|
||||
this.resolvePending(values);
|
||||
return;
|
||||
}
|
||||
|
||||
// 对于持续测量型/单次型,收到第一个有效值后延迟 settle
|
||||
if (this.pending.settleTimer === null) {
|
||||
this.pending.settleTimer = setTimeout(() => {
|
||||
if (this.pending && this.pending.lastValues && Object.keys(this.pending.lastValues).length > 0) {
|
||||
this.resolvePending(this.pending.lastValues);
|
||||
}
|
||||
}, MEASURE_SETTLE_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 SDK content 提取标准化数值 */
|
||||
private extractValues(type: MeasureType, content: Record<string, unknown>): Record<string, number> | null {
|
||||
switch (type) {
|
||||
case 'heart_rate': {
|
||||
const hr = Number(content.heartRate);
|
||||
if (hr >= 30 && hr <= 250) return { heart_rate: hr };
|
||||
return null;
|
||||
}
|
||||
case 'blood_oxygen': {
|
||||
const bo = Number(content.bloodOxygen);
|
||||
if (bo >= 70 && bo <= 100) return { blood_oxygen: bo };
|
||||
return null;
|
||||
}
|
||||
case 'blood_pressure': {
|
||||
const high = Number(content.bloodPressureHigh);
|
||||
const low = Number(content.bloodPressureLow);
|
||||
if (high > 0 && low > 0) return { systolic: high, diastolic: low };
|
||||
return null;
|
||||
}
|
||||
case 'temperature': {
|
||||
const temp = Number(content.bodyTemperature);
|
||||
if (temp > 30 && temp < 45) return { temperature: temp };
|
||||
return null;
|
||||
}
|
||||
case 'pressure': {
|
||||
const p = Number(content.pressure);
|
||||
if (p >= 0 && p <= 100) return { pressure: p };
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 发起测量 */
|
||||
startMeasure(type: MeasureType): Promise<MeasureResult> {
|
||||
if (this.pending) {
|
||||
throw new Error(`正在测量 ${this.pending.type},请等待完成`);
|
||||
}
|
||||
if (!this.isConnected) {
|
||||
throw new Error('设备未连接');
|
||||
}
|
||||
|
||||
return new Promise<MeasureResult>((resolve, reject) => {
|
||||
const timeout = getMeasureTimeout(type);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.rejectPending(new Error('测量超时,请重试'));
|
||||
}, timeout);
|
||||
|
||||
this.pending = {
|
||||
type,
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
lastValue: null,
|
||||
lastValues: {},
|
||||
settleTimer: null,
|
||||
};
|
||||
|
||||
// 发送 SDK 测量指令
|
||||
this.sendMeasureCommand(type);
|
||||
});
|
||||
}
|
||||
|
||||
/** 取消当前测量 */
|
||||
cancelMeasure(): void {
|
||||
if (!this.pending) return;
|
||||
this.stopMeasureCommand(this.pending.type);
|
||||
if (this.pending.lastValues && Object.keys(this.pending.lastValues).length > 0) {
|
||||
this.resolvePending(this.pending.lastValues);
|
||||
} else {
|
||||
this.rejectPending(new Error('测量已取消'));
|
||||
}
|
||||
}
|
||||
|
||||
/** 发送 SDK 测量指令 */
|
||||
private sendMeasureCommand(type: MeasureType): void {
|
||||
switch (type) {
|
||||
case 'heart_rate':
|
||||
setHeartRateMeasure(true);
|
||||
break;
|
||||
case 'blood_oxygen':
|
||||
setBloodOxygenMeasure('start');
|
||||
break;
|
||||
case 'blood_pressure':
|
||||
setBloodPressureMeasure('start');
|
||||
break;
|
||||
case 'temperature':
|
||||
startTemperatureMeasure();
|
||||
break;
|
||||
case 'pressure':
|
||||
setPressureMeasure(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** 发送 SDK 停止测量指令 */
|
||||
private stopMeasureCommand(type: MeasureType): void {
|
||||
switch (type) {
|
||||
case 'heart_rate':
|
||||
setHeartRateMeasure(false);
|
||||
break;
|
||||
case 'blood_oxygen':
|
||||
setBloodOxygenMeasure('stop');
|
||||
break;
|
||||
case 'blood_pressure':
|
||||
setBloodPressureMeasure('stop');
|
||||
break;
|
||||
case 'temperature':
|
||||
break; // 体温是单次触发,无法停止
|
||||
case 'pressure':
|
||||
setPressureMeasure(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** 成功 resolve pending 测量 */
|
||||
private resolvePending(values: Record<string, number>): void {
|
||||
if (!this.pending) return;
|
||||
const p = this.pending;
|
||||
this.pending = null;
|
||||
|
||||
clearTimeout(p.timer);
|
||||
if (p.settleTimer) clearTimeout(p.settleTimer);
|
||||
|
||||
// 停止持续测量型指标的 SDK 指令
|
||||
this.stopMeasureCommand(p.type);
|
||||
|
||||
p.resolve({
|
||||
type: p.type,
|
||||
values,
|
||||
measuredAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/** 失败 reject pending 测量 */
|
||||
private rejectPending(error: Error): void {
|
||||
if (!this.pending) return;
|
||||
const p = this.pending;
|
||||
this.pending = null;
|
||||
|
||||
clearTimeout(p.timer);
|
||||
if (p.settleTimer) clearTimeout(p.settleTimer);
|
||||
|
||||
// 停止 SDK 指令
|
||||
this.stopMeasureCommand(p.type);
|
||||
|
||||
p.reject(error);
|
||||
}
|
||||
|
||||
// ── 睡眠数据 ──
|
||||
|
||||
/** 读取单天精准睡眠数据,返回 Promise */
|
||||
readSleepData(day: number): Promise<SleepReading | null> {
|
||||
if (!this.isConnected) {
|
||||
return Promise.reject(new Error('设备未连接'));
|
||||
}
|
||||
|
||||
return new Promise<SleepReading | null>((resolve) => {
|
||||
this.sleepResolvers.set(day, resolve);
|
||||
|
||||
// 超时保护 30s
|
||||
const timer = setTimeout(() => {
|
||||
this.sleepResolvers.delete(day);
|
||||
this.sleepTimeouts.delete(day);
|
||||
resolve(null);
|
||||
}, 30_000);
|
||||
this.sleepTimeouts.set(day, timer);
|
||||
|
||||
// 发送 SDK 读取指令
|
||||
readPreciseSleepData(day);
|
||||
});
|
||||
}
|
||||
|
||||
/** 读取 3 天睡眠数据 */
|
||||
async readAllSleepData(): Promise<SleepReading[]> {
|
||||
const results: SleepReading[] = [];
|
||||
for (let day = 0; day < 3; day++) {
|
||||
const sleep = await this.readSleepData(day);
|
||||
if (sleep) {
|
||||
results.push(sleep);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** 处理 SDK 睡眠数据回调(type=4) */
|
||||
private handleSleepEvent(data: SdkEventData): void {
|
||||
const progress = data.Progress ?? 0;
|
||||
const readDay = (data as { readDay?: number }).readDay ?? 0;
|
||||
|
||||
// 进度未达 100% 忽略
|
||||
if (progress < 100) return;
|
||||
|
||||
const content = data.content ?? {};
|
||||
const sleep = this.parseSleepData(readDay, content as Record<string, unknown>);
|
||||
|
||||
// 通知回调
|
||||
if (sleep) {
|
||||
this.onSleepData?.(readDay, sleep);
|
||||
}
|
||||
|
||||
// resolve 等待中的 Promise
|
||||
const resolve = this.sleepResolvers.get(readDay);
|
||||
if (resolve) {
|
||||
const timer = this.sleepTimeouts.get(readDay);
|
||||
if (timer) clearTimeout(timer);
|
||||
this.sleepResolvers.delete(readDay);
|
||||
this.sleepTimeouts.delete(readDay);
|
||||
resolve(sleep);
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 SDK content 解析精准睡眠数据 */
|
||||
private parseSleepData(day: number, content: Record<string, unknown>): SleepReading | null {
|
||||
const total = Number(content.sleepTotalTime ?? 0);
|
||||
if (total <= 0) return null;
|
||||
|
||||
return {
|
||||
day,
|
||||
deepSleepMinutes: Number(content.deepSleepTime ?? 0),
|
||||
lightSleepMinutes: Number(content.lightSleepTime ?? 0),
|
||||
otherSleepMinutes: Number(content.otherSleepTime ?? 0),
|
||||
totalSleepMinutes: total,
|
||||
qualityScore: Number(content.sleepQuality ?? 0),
|
||||
fallAsleepTime: String(content.fallAsleepTime ?? ''),
|
||||
exitSleepTime: String(content.exitSleepTime ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 自动测量 ──
|
||||
|
||||
/** 开启自动测量(心率 + 血压 + 血氧 + 体温) */
|
||||
enableAutoMeasurement(): void {
|
||||
if (!this.isConnected) return;
|
||||
|
||||
console.log('[veepoo-pipeline] 开启自动测量功能');
|
||||
setAutoHeartRate(true);
|
||||
setAutoBloodPressure(true);
|
||||
setAutoTemperature(true);
|
||||
|
||||
// 读取当前自动测量配置
|
||||
readAutoTestConfig();
|
||||
}
|
||||
|
||||
/** 断开连接 */
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.pending) {
|
||||
this.rejectPending(new Error('设备已断开'));
|
||||
}
|
||||
this.isConnected = false;
|
||||
this.deviceId = '';
|
||||
await veepooDisconnect();
|
||||
}
|
||||
|
||||
/** 获取连接状态 */
|
||||
getConnected(): boolean {
|
||||
return this.isConnected;
|
||||
}
|
||||
|
||||
/** 获取设备 ID */
|
||||
getDeviceId(): string {
|
||||
return this.deviceId;
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function getMeasureTimeout(type: MeasureType): number {
|
||||
const timeouts: Record<MeasureType, number> = {
|
||||
heart_rate: 60_000,
|
||||
blood_oxygen: 60_000,
|
||||
blood_pressure: 120_000,
|
||||
temperature: 60_000,
|
||||
pressure: 90_000,
|
||||
};
|
||||
return timeouts[type];
|
||||
}
|
||||
21
apps/miniprogram/src/services/ble/veepoo/index.ts
Normal file
21
apps/miniprogram/src/services/ble/veepoo/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export { VeepooPipeline } from './VeepooPipeline';
|
||||
export { VeepooHistoryReader } from './VeepooHistoryReader';
|
||||
export type {
|
||||
ConnectionChangeCallback,
|
||||
AuthResultCallback,
|
||||
MeasureEventCallback,
|
||||
DailyDataCallback,
|
||||
} from './VeepooPipeline';
|
||||
export type {
|
||||
MeasureType,
|
||||
MeasurePhase,
|
||||
MeasureStatus,
|
||||
MeasureResult,
|
||||
MeasureConfig,
|
||||
ConnectionPhase,
|
||||
VeepooDeviceInfo,
|
||||
HistorySyncState,
|
||||
SleepReading,
|
||||
AutoTestSyncState,
|
||||
} from './types';
|
||||
export { MEASURE_TYPES, MEASURE_CONFIG } from './types';
|
||||
152
apps/miniprogram/src/services/ble/veepoo/types.ts
Normal file
152
apps/miniprogram/src/services/ble/veepoo/types.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/** Veepoo 管线专用类型定义 */
|
||||
|
||||
/** 测量指标类型 */
|
||||
export type MeasureType =
|
||||
| 'heart_rate'
|
||||
| 'blood_oxygen'
|
||||
| 'blood_pressure'
|
||||
| 'temperature'
|
||||
| 'pressure';
|
||||
|
||||
/** 所有支持的测量指标 */
|
||||
export const MEASURE_TYPES: readonly MeasureType[] = [
|
||||
'heart_rate',
|
||||
'blood_oxygen',
|
||||
'blood_pressure',
|
||||
'temperature',
|
||||
'pressure',
|
||||
] as const;
|
||||
|
||||
/** 测量指标配置 */
|
||||
export interface MeasureConfig {
|
||||
label: string;
|
||||
unit: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
/** 正常范围 [min, max] */
|
||||
normalRange: [number, number];
|
||||
/** 测量超时(毫秒) */
|
||||
timeout: number;
|
||||
/** 测量模式 */
|
||||
mode: 'continuous' | 'progress' | 'single';
|
||||
}
|
||||
|
||||
/** 各指标配置表 */
|
||||
export const MEASURE_CONFIG: Record<MeasureType, MeasureConfig> = {
|
||||
heart_rate: {
|
||||
label: '心率',
|
||||
unit: 'bpm',
|
||||
icon: '♥',
|
||||
color: '#EF4444',
|
||||
normalRange: [60, 100],
|
||||
timeout: 60_000,
|
||||
mode: 'continuous',
|
||||
},
|
||||
blood_oxygen: {
|
||||
label: '血氧',
|
||||
unit: '%',
|
||||
icon: 'O₂',
|
||||
color: '#3B82F6',
|
||||
normalRange: [95, 100],
|
||||
timeout: 60_000,
|
||||
mode: 'continuous',
|
||||
},
|
||||
blood_pressure: {
|
||||
label: '血压',
|
||||
unit: 'mmHg',
|
||||
icon: '↕',
|
||||
color: '#8B5CF6',
|
||||
normalRange: [90, 140],
|
||||
timeout: 120_000,
|
||||
mode: 'progress',
|
||||
},
|
||||
temperature: {
|
||||
label: '体温',
|
||||
unit: '°C',
|
||||
icon: 'T',
|
||||
color: '#F59E0B',
|
||||
normalRange: [36.0, 37.3],
|
||||
timeout: 60_000,
|
||||
mode: 'single',
|
||||
},
|
||||
pressure: {
|
||||
label: '压力',
|
||||
unit: '',
|
||||
icon: '~',
|
||||
color: '#6366F1',
|
||||
normalRange: [1, 40],
|
||||
timeout: 90_000,
|
||||
mode: 'progress',
|
||||
},
|
||||
};
|
||||
|
||||
/** 连接阶段 */
|
||||
export type ConnectionPhase =
|
||||
| 'idle'
|
||||
| 'scanning'
|
||||
| 'connecting'
|
||||
| 'authenticating'
|
||||
| 'ready'
|
||||
| 'disconnected'
|
||||
| 'error';
|
||||
|
||||
/** 测量阶段 */
|
||||
export type MeasurePhase = 'idle' | 'measuring' | 'success' | 'error';
|
||||
|
||||
/** 单个指标的测量状态 */
|
||||
export interface MeasureStatus {
|
||||
phase: MeasurePhase;
|
||||
progress: number;
|
||||
currentValue: number | null;
|
||||
result: MeasureResult | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** 测量结果 */
|
||||
export interface MeasureResult {
|
||||
type: MeasureType;
|
||||
values: Record<string, number>;
|
||||
measuredAt: number;
|
||||
}
|
||||
|
||||
/** 设备信息 */
|
||||
export interface VeepooDeviceInfo {
|
||||
deviceId: string;
|
||||
name: string;
|
||||
batteryLevel: number | null;
|
||||
}
|
||||
|
||||
/** 历史数据同步状态 */
|
||||
export interface HistorySyncState {
|
||||
phase: 'idle' | 'reading' | 'uploading' | 'done';
|
||||
progress: number;
|
||||
packagesRead: number;
|
||||
lastCheckpoint: number;
|
||||
}
|
||||
|
||||
/** 睡眠数据(从 SDK 精准睡眠解析) */
|
||||
export interface SleepReading {
|
||||
/** 读取天数(0=今天, 1=昨天, 2=前天) */
|
||||
day: number;
|
||||
/** 深睡时长(分钟) */
|
||||
deepSleepMinutes: number;
|
||||
/** 浅睡时长(分钟) */
|
||||
lightSleepMinutes: number;
|
||||
/** 其他睡眠时长(分钟) */
|
||||
otherSleepMinutes: number;
|
||||
/** 睡眠总时长(分钟) */
|
||||
totalSleepMinutes: number;
|
||||
/** 睡眠质量评分(1-5 星) */
|
||||
qualityScore: number;
|
||||
/** 入睡时间(时间戳字符串) */
|
||||
fallAsleepTime: string;
|
||||
/** 退出睡眠时间(时间戳字符串) */
|
||||
exitSleepTime: string;
|
||||
}
|
||||
|
||||
/** 自动测量同步状态 */
|
||||
export interface AutoTestSyncState {
|
||||
phase: 'idle' | 'reading_config' | 'configuring' | 'configured';
|
||||
enabledTypes: string[];
|
||||
intervalMinutes: number;
|
||||
}
|
||||
@@ -148,13 +148,19 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
get().loadPatients();
|
||||
return true;
|
||||
}
|
||||
// 未绑定:存储 openid 供后续绑定流程使用
|
||||
if (!resp.openid) {
|
||||
set({ loading: false });
|
||||
throw new Error('登录失败:服务器未返回用户标识');
|
||||
}
|
||||
secureSet('wechat_openid', resp.openid);
|
||||
set({ loading: false });
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.warn('[auth] 微信登录失败:', err);
|
||||
set({ loading: false });
|
||||
return false;
|
||||
// 不吞掉错误 — 让调用方区分"未绑定"和"真正的错误"
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -243,7 +249,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
loadPatients: async () => {
|
||||
try {
|
||||
const summaries = await authApi.getPatientSummaries();
|
||||
const userId = get().user?.id;
|
||||
const summaries = await authApi.getPatientSummaries(userId);
|
||||
const patients: authApi.PatientInfo[] = summaries.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
|
||||
335
apps/miniprogram/src/stores/veepoo.ts
Normal file
335
apps/miniprogram/src/stores/veepoo.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { create } from 'zustand';
|
||||
import { VeepooPipeline } from '@/services/ble/veepoo/VeepooPipeline';
|
||||
import { VeepooHistoryReader } from '@/services/ble/veepoo/VeepooHistoryReader';
|
||||
import type {
|
||||
MeasureType,
|
||||
MeasureStatus,
|
||||
MeasureResult,
|
||||
ConnectionPhase,
|
||||
VeepooDeviceInfo,
|
||||
HistorySyncState,
|
||||
SleepReading,
|
||||
} from '@/services/ble/veepoo/types';
|
||||
import { MEASURE_TYPES } from '@/services/ble/veepoo/types';
|
||||
import { useAuthStore } from './auth';
|
||||
|
||||
/** 初始化每个指标的默认状态 */
|
||||
function initialMeasureStates(): Record<MeasureType, MeasureStatus> {
|
||||
const states = {} as Record<MeasureType, MeasureStatus>;
|
||||
for (const t of MEASURE_TYPES) {
|
||||
states[t] = { phase: 'idle', progress: 0, currentValue: null, result: null, error: null };
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
interface VeepooState {
|
||||
// 连接
|
||||
connectionPhase: ConnectionPhase;
|
||||
device: VeepooDeviceInfo | null;
|
||||
error: string | null;
|
||||
|
||||
// 测量
|
||||
activeMeasure: MeasureType | null;
|
||||
measureStates: Record<MeasureType, MeasureStatus>;
|
||||
|
||||
// 历史
|
||||
historySync: HistorySyncState;
|
||||
|
||||
// 睡眠
|
||||
sleepData: SleepReading[];
|
||||
sleepLoading: boolean;
|
||||
|
||||
// Actions
|
||||
connect: (targetName?: string) => Promise<void>;
|
||||
disconnect: () => Promise<void>;
|
||||
startMeasure: (type: MeasureType) => Promise<MeasureResult>;
|
||||
cancelMeasure: () => void;
|
||||
syncHistory: (patientId: string) => Promise<void>;
|
||||
readSleepData: () => Promise<SleepReading[]>;
|
||||
enableAutoMeasurement: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
let pipelineInstance: VeepooPipeline | null = null;
|
||||
let historyReaderInstance: VeepooHistoryReader | null = null;
|
||||
|
||||
function getPipeline(): VeepooPipeline {
|
||||
if (!pipelineInstance) {
|
||||
pipelineInstance = new VeepooPipeline();
|
||||
}
|
||||
return pipelineInstance;
|
||||
}
|
||||
|
||||
function getHistoryReader(): VeepooHistoryReader {
|
||||
if (!historyReaderInstance) {
|
||||
historyReaderInstance = new VeepooHistoryReader();
|
||||
}
|
||||
return historyReaderInstance;
|
||||
}
|
||||
|
||||
export const useVeepooStore = create<VeepooState>((set, get) => ({
|
||||
connectionPhase: 'idle',
|
||||
device: null,
|
||||
error: null,
|
||||
activeMeasure: null,
|
||||
measureStates: initialMeasureStates(),
|
||||
historySync: { phase: 'idle', progress: 0, packagesRead: 0, lastCheckpoint: 0 },
|
||||
sleepData: [],
|
||||
sleepLoading: false,
|
||||
|
||||
connect: async (targetName = 'M2') => {
|
||||
console.log('[veepoo-store] connect() 开始, target:', targetName);
|
||||
set({ connectionPhase: 'scanning', error: null });
|
||||
const pipeline = getPipeline();
|
||||
const historyReader = getHistoryReader();
|
||||
|
||||
// 注册全部回调(包含新增的 onSleepData)
|
||||
pipeline.setCallbacks({
|
||||
onConnectionChange: (connected) => {
|
||||
if (!connected) {
|
||||
set({ connectionPhase: 'disconnected', device: null });
|
||||
}
|
||||
},
|
||||
onAuthResult: (success) => {
|
||||
if (success) {
|
||||
set({ connectionPhase: 'ready' });
|
||||
}
|
||||
},
|
||||
onMeasureEvent: (type, data) => {
|
||||
const state = get();
|
||||
if (state.activeMeasure !== type) return;
|
||||
|
||||
const value = extractDisplayValue(type, data);
|
||||
set({
|
||||
measureStates: {
|
||||
...state.measureStates,
|
||||
[type]: {
|
||||
...state.measureStates[type],
|
||||
phase: 'measuring',
|
||||
progress: (data.Progress ?? data.progress ?? 0) as number,
|
||||
currentValue: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
onDailyData: (data) => {
|
||||
// 转发给 HistoryReader 处理
|
||||
historyReader.handleDailyEvent(data);
|
||||
|
||||
const progress = data.Progress ?? 0;
|
||||
set((s) => ({
|
||||
historySync: { ...s.historySync, progress: progress as number },
|
||||
}));
|
||||
},
|
||||
onSleepData: (_day, sleep) => {
|
||||
// 收集睡眠数据到 store
|
||||
set((s) => ({
|
||||
sleepData: [...s.sleepData, sleep],
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// 注册 HistoryReader 进度回调
|
||||
historyReader.setCallbacks({
|
||||
onProgress: (progress, phase) => {
|
||||
set((s) => ({
|
||||
historySync: {
|
||||
...s.historySync,
|
||||
phase: phase === 'uploading' ? 'uploading' : 'reading',
|
||||
progress,
|
||||
},
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
set({ connectionPhase: 'connecting' });
|
||||
const deviceId = await pipeline.connect(targetName);
|
||||
set({
|
||||
connectionPhase: 'authenticating',
|
||||
device: { deviceId, name: targetName, batteryLevel: null },
|
||||
});
|
||||
|
||||
// 认证结果由 onAuthResult 回调设置
|
||||
// 等待 ready 状态(最多 10s)
|
||||
await waitForState(() => get().connectionPhase === 'ready', 10_000);
|
||||
|
||||
// 认证通过后:自动同步历史 + 读取睡眠 + 开启自动测量
|
||||
const patient = useAuthStore.getState().currentPatient;
|
||||
const readyState = get().connectionPhase === 'ready';
|
||||
if (patient && readyState) {
|
||||
const deviceIdForReader = get().device?.deviceId ?? 'veepoo_m2';
|
||||
|
||||
// 并行执行三件事:
|
||||
// 1. 同步日常历史数据(后台执行,进度通过回调更新)
|
||||
get().syncHistory(patient.id);
|
||||
|
||||
// 2. 读取睡眠数据 → 完成后自动上传
|
||||
get().readSleepData().then((sleepResults) => {
|
||||
if (sleepResults.length > 0) {
|
||||
historyReader.uploadSleepReadings(patient.id, deviceIdForReader, sleepResults);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 开启自动测量(心率+血压+体温)
|
||||
pipeline.enableAutoMeasurement();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[veepoo-store] connect 失败:', err);
|
||||
set({
|
||||
connectionPhase: 'error',
|
||||
error: err instanceof Error ? err.message : '连接失败',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
disconnect: async () => {
|
||||
const pipeline = getPipeline();
|
||||
await pipeline.disconnect();
|
||||
set({
|
||||
connectionPhase: 'idle',
|
||||
device: null,
|
||||
error: null,
|
||||
activeMeasure: null,
|
||||
measureStates: initialMeasureStates(),
|
||||
sleepData: [],
|
||||
sleepLoading: false,
|
||||
});
|
||||
},
|
||||
|
||||
startMeasure: async (type: MeasureType) => {
|
||||
const state = get();
|
||||
if (state.activeMeasure) {
|
||||
throw new Error(`正在测量 ${state.activeMeasure},请等待完成`);
|
||||
}
|
||||
if (state.connectionPhase !== 'ready') {
|
||||
throw new Error('设备未就绪');
|
||||
}
|
||||
|
||||
set({
|
||||
activeMeasure: type,
|
||||
measureStates: {
|
||||
...state.measureStates,
|
||||
[type]: { phase: 'measuring', progress: 0, currentValue: null, result: null, error: null },
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = getPipeline();
|
||||
try {
|
||||
const result = await pipeline.startMeasure(type);
|
||||
set((s) => ({
|
||||
activeMeasure: null,
|
||||
measureStates: {
|
||||
...s.measureStates,
|
||||
[type]: { phase: 'success', progress: 100, currentValue: null, result, error: null },
|
||||
},
|
||||
}));
|
||||
return result;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '测量失败';
|
||||
set((s) => ({
|
||||
activeMeasure: null,
|
||||
measureStates: {
|
||||
...s.measureStates,
|
||||
[type]: { phase: 'error', progress: 0, currentValue: null, result: null, error: msg },
|
||||
},
|
||||
}));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
cancelMeasure: () => {
|
||||
const pipeline = getPipeline();
|
||||
pipeline.cancelMeasure();
|
||||
},
|
||||
|
||||
syncHistory: async (patientId: string) => {
|
||||
const deviceId = get().device?.deviceId ?? 'veepoo_m2';
|
||||
set((s) => ({ historySync: { ...s.historySync, phase: 'reading', progress: 0 } }));
|
||||
|
||||
try {
|
||||
const historyReader = getHistoryReader();
|
||||
const count = await historyReader.startRead(patientId, deviceId);
|
||||
set((s) => ({
|
||||
historySync: { ...s.historySync, phase: 'done', progress: 100, packagesRead: count },
|
||||
}));
|
||||
console.log('[veepoo-store] 历史数据同步完成, 上传:', count, '条');
|
||||
} catch (err) {
|
||||
console.error('[veepoo-store] 历史数据同步失败:', err);
|
||||
set((s) => ({ historySync: { ...s.historySync, phase: 'done', progress: 100 } }));
|
||||
}
|
||||
},
|
||||
|
||||
readSleepData: async () => {
|
||||
const pipeline = getPipeline();
|
||||
if (!pipeline.getConnected()) {
|
||||
console.warn('[veepoo-store] 设备未连接,跳过睡眠数据读取');
|
||||
return [];
|
||||
}
|
||||
|
||||
set({ sleepLoading: true, sleepData: [] });
|
||||
try {
|
||||
const sleepResults = await pipeline.readAllSleepData();
|
||||
set({ sleepData: sleepResults, sleepLoading: false });
|
||||
console.log('[veepoo-store] 睡眠数据读取完成:', sleepResults.length, '天');
|
||||
return sleepResults;
|
||||
} catch (err) {
|
||||
console.error('[veepoo-store] 睡眠数据读取失败:', err);
|
||||
set({ sleepLoading: false });
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
enableAutoMeasurement: () => {
|
||||
const pipeline = getPipeline();
|
||||
pipeline.enableAutoMeasurement();
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
connectionPhase: 'idle',
|
||||
device: null,
|
||||
error: null,
|
||||
activeMeasure: null,
|
||||
measureStates: initialMeasureStates(),
|
||||
historySync: { phase: 'idle', progress: 0, packagesRead: 0, lastCheckpoint: 0 },
|
||||
sleepData: [],
|
||||
sleepLoading: false,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
/** 从 SDK 事件 content 提取显示值 */
|
||||
function extractDisplayValue(type: MeasureType, content: Record<string, unknown>): number | null {
|
||||
switch (type) {
|
||||
case 'heart_rate': {
|
||||
const v = Number(content.heartRate);
|
||||
return v >= 30 && v <= 250 ? v : null;
|
||||
}
|
||||
case 'blood_oxygen': {
|
||||
const v = Number(content.bloodOxygen);
|
||||
return v >= 70 && v <= 100 ? v : null;
|
||||
}
|
||||
case 'blood_pressure':
|
||||
return Number(content.bloodPressureHigh) || null;
|
||||
case 'temperature':
|
||||
return Number(content.bodyTemperature) || null;
|
||||
case 'pressure':
|
||||
return Number(content.pressure) || null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 轮询等待状态满足条件 */
|
||||
function waitForState(check: () => boolean, timeoutMs: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
const poll = () => {
|
||||
if (check()) { resolve(); return; }
|
||||
if (Date.now() - start >= timeoutMs) { reject(new Error('等待超时')); return; }
|
||||
setTimeout(poll, 200);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export function showToast(options: {
|
||||
const duration = options.duration ?? (mode === 'elder' ? 3000 : 1500);
|
||||
|
||||
if (mode === 'elder') {
|
||||
try { Taro.vibrateShort({ type: 'light' }); } catch { /* 不支持时静默 */ }
|
||||
Taro.vibrateShort({ type: 'light' }).catch(() => {});
|
||||
}
|
||||
|
||||
Taro.showToast({ ...options, duration, icon: options.icon ?? 'none' });
|
||||
|
||||
@@ -2,21 +2,15 @@ import Taro from '@tarojs/taro';
|
||||
|
||||
/** 轻触反馈(按钮点击) */
|
||||
export function hapticLight(): void {
|
||||
try {
|
||||
Taro.vibrateShort({ type: 'light' });
|
||||
} catch { /* 部分设备不支持 */ }
|
||||
Taro.vibrateShort({ type: 'light' }).catch(() => { /* DevTools 不支持 type 参数,真机正常 */ });
|
||||
}
|
||||
|
||||
/** 中等反馈(成功操作) */
|
||||
export function hapticMedium(): void {
|
||||
try {
|
||||
Taro.vibrateShort({ type: 'medium' });
|
||||
} catch { /* ignore */ }
|
||||
Taro.vibrateShort({ type: 'medium' }).catch(() => { /* ignore */ });
|
||||
}
|
||||
|
||||
/** 重度反馈(错误/警告) */
|
||||
export function hapticHeavy(): void {
|
||||
try {
|
||||
Taro.vibrateShort({ type: 'heavy' });
|
||||
} catch { /* ignore */ }
|
||||
Taro.vibrateShort({ type: 'heavy' }).catch(() => { /* ignore */ });
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ const AiPromptList = lazy(() => import('./pages/health/AiPromptList'));
|
||||
const AiAnalysisList = lazy(() => import('./pages/health/AiAnalysisList'));
|
||||
const AiUsageDashboard = lazy(() => import('./pages/health/AiUsageDashboard'));
|
||||
const AiConfigPage = lazy(() => import('./pages/health/AiConfigPage'));
|
||||
const AiKnowledgePage = lazy(() => import('./pages/health/AiKnowledgePage'));
|
||||
const KnowledgeV2Page = lazy(() => import('./pages/ai/KnowledgeV2Page'));
|
||||
const AiChatPage = lazy(() => import('./pages/ai/ChatPage'));
|
||||
const AlertList = lazy(() => import('./pages/health/AlertList'));
|
||||
const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard'));
|
||||
@@ -330,7 +330,7 @@ export default function App() {
|
||||
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
|
||||
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
|
||||
<Route path="/health/ai-config" element={<AiConfigPage />} />
|
||||
<Route path="/health/ai-knowledge" element={<AiKnowledgePage />} />
|
||||
<Route path="/health/ai-knowledge" element={<KnowledgeV2Page />} />
|
||||
<Route path="/ai/chat" element={<AiChatPage />} />
|
||||
<Route path="/health/alerts" element={<AlertList />} />
|
||||
<Route path="/health/alert-dashboard" element={<AlertDashboard />} />
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import client from '../client';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface KnowledgeReference {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
title: string;
|
||||
analysis_type: string;
|
||||
source_name: string;
|
||||
content_summary: string;
|
||||
tags: Record<string, unknown> | null;
|
||||
is_enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeGuide {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
title: string;
|
||||
analysis_type: string;
|
||||
content: string;
|
||||
category: string | null;
|
||||
is_enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateReferenceReq {
|
||||
title: string;
|
||||
analysis_type: string;
|
||||
source_name: string;
|
||||
content_summary: string;
|
||||
tags?: Record<string, unknown>;
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateReferenceReq {
|
||||
title?: string;
|
||||
analysis_type?: string;
|
||||
source_name?: string;
|
||||
content_summary?: string;
|
||||
tags?: Record<string, unknown>;
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateGuideReq {
|
||||
title: string;
|
||||
analysis_type: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateGuideReq {
|
||||
title?: string;
|
||||
analysis_type?: string;
|
||||
content?: string;
|
||||
category?: string;
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
// === API ===
|
||||
|
||||
export const knowledgeApi = {
|
||||
// References
|
||||
listReferences: async (params?: { analysis_type?: string }) => {
|
||||
const resp = await client.get('/ai/knowledge/references', { params });
|
||||
return resp.data.data as { data: KnowledgeReference[]; total: number };
|
||||
},
|
||||
createReference: async (data: CreateReferenceReq) => {
|
||||
const resp = await client.post('/ai/knowledge/references', data);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
updateReference: async (id: string, data: UpdateReferenceReq) => {
|
||||
const resp = await client.put(`/ai/knowledge/references/${id}`, data);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
deleteReference: async (id: string) => {
|
||||
const resp = await client.delete(`/ai/knowledge/references/${id}`);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
reEmbedReference: async (id: string) => {
|
||||
const resp = await client.post(`/ai/knowledge/references/${id}/re-embed`);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
// Guides
|
||||
listGuides: async (params?: { analysis_type?: string }) => {
|
||||
const resp = await client.get('/ai/knowledge/guides', { params });
|
||||
return resp.data.data as { data: KnowledgeGuide[]; total: number };
|
||||
},
|
||||
createGuide: async (data: CreateGuideReq) => {
|
||||
const resp = await client.post('/ai/knowledge/guides', data);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
updateGuide: async (id: string, data: UpdateGuideReq) => {
|
||||
const resp = await client.put(`/ai/knowledge/guides/${id}`, data);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
deleteGuide: async (id: string) => {
|
||||
const resp = await client.delete(`/ai/knowledge/guides/${id}`);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
reEmbedGuide: async (id: string) => {
|
||||
const resp = await client.post(`/ai/knowledge/guides/${id}/re-embed`);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
};
|
||||
188
apps/web/src/api/ai/knowledgeV2.ts
Normal file
188
apps/web/src/api/ai/knowledgeV2.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import client from '../client';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface KnowledgeBase {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
kb_type: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
chunk_strategy: Record<string, unknown>;
|
||||
intent_keywords: Record<string, unknown>;
|
||||
embedding_model: string | null;
|
||||
is_enabled: boolean;
|
||||
document_count: number;
|
||||
chunk_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeDocument {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
knowledge_base_id: string;
|
||||
title: string;
|
||||
doc_type: string;
|
||||
source_type: string;
|
||||
source_url: string | null;
|
||||
file_name: string | null;
|
||||
file_size: number | null;
|
||||
file_mime_type: string | null;
|
||||
content: string | null;
|
||||
status: string;
|
||||
chunk_count: number;
|
||||
embedded_count: number;
|
||||
error_message: string | null;
|
||||
processing_started_at: string | null;
|
||||
processing_completed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SearchHit {
|
||||
chunk_id: string;
|
||||
document_id: string;
|
||||
chunk_index: number;
|
||||
content: string;
|
||||
doc_title: string;
|
||||
similarity: number;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CreateKnowledgeBaseReq {
|
||||
name: string;
|
||||
kb_type: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
chunk_strategy?: Record<string, unknown>;
|
||||
intent_keywords?: Record<string, unknown>;
|
||||
embedding_model?: string;
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateKnowledgeBaseReq {
|
||||
name?: string;
|
||||
kb_type?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
chunk_strategy?: Record<string, unknown>;
|
||||
intent_keywords?: Record<string, unknown>;
|
||||
embedding_model?: string;
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateDocumentReq {
|
||||
kb_id: string;
|
||||
title: string;
|
||||
doc_type?: string;
|
||||
source_type?: string;
|
||||
source_url?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
// === API ===
|
||||
|
||||
export const knowledgeV2Api = {
|
||||
// Knowledge Bases
|
||||
listKnowledgeBases: async (params?: {
|
||||
kb_type?: string;
|
||||
is_enabled?: boolean;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) => {
|
||||
const resp = await client.get('/ai/knowledge-bases', { params });
|
||||
return resp.data.data as {
|
||||
data: KnowledgeBase[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
},
|
||||
|
||||
getKnowledgeBase: async (id: string) => {
|
||||
const resp = await client.get(`/ai/knowledge-bases/${id}`);
|
||||
return resp.data.data as KnowledgeBase;
|
||||
},
|
||||
|
||||
createKnowledgeBase: async (data: CreateKnowledgeBaseReq) => {
|
||||
const resp = await client.post('/ai/knowledge-bases', data);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
updateKnowledgeBase: async (id: string, data: UpdateKnowledgeBaseReq) => {
|
||||
const resp = await client.put(`/ai/knowledge-bases/${id}`, data);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
deleteKnowledgeBase: async (id: string) => {
|
||||
const resp = await client.delete(`/ai/knowledge-bases/${id}`);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
// Documents
|
||||
listDocuments: async (
|
||||
kbId: string,
|
||||
params?: { status?: string; page?: number; page_size?: number },
|
||||
) => {
|
||||
const resp = await client.get(
|
||||
`/ai/knowledge-bases/${kbId}/documents`,
|
||||
{ params },
|
||||
);
|
||||
return resp.data.data as {
|
||||
data: KnowledgeDocument[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
},
|
||||
|
||||
getDocument: async (id: string) => {
|
||||
const resp = await client.get(`/ai/documents/${id}`);
|
||||
return resp.data.data as KnowledgeDocument;
|
||||
},
|
||||
|
||||
createManualDocument: async (data: CreateDocumentReq) => {
|
||||
const resp = await client.post('/ai/documents/manual', data);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
uploadDocument: async (
|
||||
kbId: string,
|
||||
file: File,
|
||||
title?: string,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('kb_id', kbId);
|
||||
formData.append('file', file);
|
||||
if (title) {
|
||||
formData.append('title', title);
|
||||
}
|
||||
const resp = await client.post('/ai/documents/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
deleteDocument: async (kbId: string, id: string) => {
|
||||
const resp = await client.delete(
|
||||
`/ai/knowledge-bases/${kbId}/documents/${id}`,
|
||||
);
|
||||
return resp.data.data as { id: string };
|
||||
},
|
||||
|
||||
// Hit Test
|
||||
hitTest: async (kbId: string, query: string, topK?: number) => {
|
||||
const resp = await client.post('/ai/documents/hit-test', {
|
||||
kb_id: kbId,
|
||||
query,
|
||||
top_k: topK,
|
||||
});
|
||||
return resp.data.data as {
|
||||
query: string;
|
||||
total: number;
|
||||
hits: SearchHit[];
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -11,6 +11,7 @@ export interface PromptItem {
|
||||
version: number;
|
||||
is_active: boolean;
|
||||
category: string;
|
||||
analysis_type: string;
|
||||
tags: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -23,10 +24,11 @@ export interface CreatePromptReq {
|
||||
user_prompt_template: string;
|
||||
model_config: Record<string, unknown>;
|
||||
category: string;
|
||||
analysis_type: string;
|
||||
}
|
||||
|
||||
export const promptApi = {
|
||||
list: async (params?: { category?: string; page?: number; page_size?: number }) => {
|
||||
list: async (params?: { category?: string; analysis_type?: string; page?: number; page_size?: number }) => {
|
||||
const resp = await client.get('/ai/prompts', { params });
|
||||
return resp.data.data as PaginatedResponse<PromptItem>;
|
||||
},
|
||||
@@ -38,8 +40,15 @@ export const promptApi = {
|
||||
const resp = await client.post(`/ai/prompts/${id}/activate`);
|
||||
return resp.data.data as PromptItem;
|
||||
},
|
||||
deactivate: async (id: string) => {
|
||||
const resp = await client.post(`/ai/prompts/${id}/deactivate`);
|
||||
return resp.data.data as PromptItem;
|
||||
},
|
||||
rollback: async (id: string) => {
|
||||
const resp = await client.post(`/ai/prompts/${id}/rollback`);
|
||||
return resp.data.data as PromptItem;
|
||||
},
|
||||
delete: async (id: string) => {
|
||||
await client.delete(`/ai/prompts/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -82,6 +82,7 @@ export interface ArticleCategory {
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateCategoryReq {
|
||||
@@ -236,11 +237,11 @@ export const articleCategoryApi = {
|
||||
return data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string) => {
|
||||
delete: async (id: string, version: number) => {
|
||||
const { data } = await client.delete<{
|
||||
success: boolean;
|
||||
data: null;
|
||||
}>(`/health/article-categories/${id}`);
|
||||
}>(`/health/article-categories/${id}`, { data: { version } });
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -322,10 +322,11 @@ export const pointsApi = {
|
||||
},
|
||||
|
||||
updateProduct: async (id: string, req: Partial<CreatePointsProductReq> & { is_active?: boolean; version: number }) => {
|
||||
const { version, ...fields } = req;
|
||||
const { data } = await client.put<{
|
||||
success: boolean;
|
||||
data: PointsProduct;
|
||||
}>(`/health/admin/points/products/${id}`, req);
|
||||
}>(`/health/admin/points/products/${id}`, { data: fields, version });
|
||||
return data.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ export interface UpdateUserRequest {
|
||||
version: number;
|
||||
}
|
||||
|
||||
export async function listUsers(page = 1, pageSize = 20, search = '') {
|
||||
export async function listUsers(page = 1, pageSize = 20, search = '', excludeOnlyRoles?: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<UserInfo> }>(
|
||||
'/users',
|
||||
{ params: { page, page_size: pageSize, search: search || undefined } }
|
||||
{ params: { page, page_size: pageSize, search: search || undefined, exclude_only_roles: excludeOnlyRoles } }
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ interface DrawerFormProps {
|
||||
sections?: FormSection[];
|
||||
children?: React.ReactNode;
|
||||
columns?: 1 | 2;
|
||||
form?: ReturnType<typeof Form.useForm>[0];
|
||||
onValuesChange?: (changedValues: Record<string, unknown>, allValues: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function DrawerForm({
|
||||
@@ -32,8 +34,11 @@ export function DrawerForm({
|
||||
sections,
|
||||
children,
|
||||
columns = 2,
|
||||
form: externalForm,
|
||||
onValuesChange,
|
||||
}: DrawerFormProps) {
|
||||
const [form] = Form.useForm();
|
||||
const [internalForm] = Form.useForm();
|
||||
const form = externalForm ?? internalForm;
|
||||
const isDark = useThemeMode();
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -81,7 +86,7 @@ export function DrawerForm({
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical" initialValues={initialValues}>
|
||||
<Form form={form} layout="vertical" initialValues={initialValues} onValuesChange={onValuesChange}>
|
||||
{sections
|
||||
? sections.map((s, i) => (
|
||||
<div key={i}>
|
||||
|
||||
@@ -56,56 +56,62 @@ export function usePaginatedData<T, F = string>(
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filters, setFilters] = useState<F>(defaultFilters);
|
||||
|
||||
|
||||
const fetchFnRef = useRef(fetchFn);
|
||||
fetchFnRef.current = fetchFn;
|
||||
|
||||
const searchTextRef = useRef(searchText);
|
||||
searchTextRef.current = searchText;
|
||||
|
||||
const filtersRef = useRef(filters);
|
||||
filtersRef.current = filters;
|
||||
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
|
||||
|
||||
const refresh = useCallback(
|
||||
async (p?: number) => {
|
||||
const targetPage = p ?? stateRef.current.page;
|
||||
setState((s) => ({ ...s, loading: true }));
|
||||
try {
|
||||
const result = await fetchFnRef.current(
|
||||
targetPage,
|
||||
pageSize,
|
||||
filtersRef.current ?? searchTextRef.current,
|
||||
);
|
||||
setState({ data: result.data, total: result.total, page: targetPage, loading: false });
|
||||
} catch (err) {
|
||||
console.warn('[usePaginatedData] 加载数据失败:', err);
|
||||
message.error('加载数据失败');
|
||||
setState((s) => ({ ...s, loading: false }));
|
||||
}
|
||||
},
|
||||
[pageSize],
|
||||
);
|
||||
|
||||
// 合并初始 fetch 和 filters 变化时的 fetch,消除双重请求
|
||||
const isFirstRender = useRef(true);
|
||||
useEffect(() => {
|
||||
fetchFnRef.current = fetchFn;
|
||||
searchTextRef.current = searchText;
|
||||
filtersRef.current = filters;
|
||||
stateRef.current = state;
|
||||
});
|
||||
|
||||
// 所有 fetch 统一走 useEffect,通过 fetchTrigger 触发
|
||||
const [fetchTrigger, setFetchTrigger] = useState(0);
|
||||
const pendingPageRef = useRef<number | undefined>(undefined);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
// refresh 只负责设置目标页并递增 trigger,实际 fetch 在 useEffect 中执行
|
||||
const refresh = useCallback((p?: number) => {
|
||||
pendingPageRef.current = p;
|
||||
setFetchTrigger((t) => t + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const targetPage = pendingPageRef.current ?? stateRef.current.page;
|
||||
pendingPageRef.current = undefined;
|
||||
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
if (shouldAutoFetch) {
|
||||
refresh(1);
|
||||
}
|
||||
return;
|
||||
if (!shouldAutoFetch) return;
|
||||
}
|
||||
if (shouldAutoFetch) {
|
||||
refresh(1);
|
||||
}
|
||||
// refresh 每次渲染都稳定,不放入依赖数组;filters 变化触发重新 fetch
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shouldAutoFetch, filters]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- 数据获取 hook:loading → fetch → setState 是标准模式
|
||||
setState((s) => ({ ...s, loading: true }));
|
||||
|
||||
let cancelled = false;
|
||||
fetchFnRef.current(targetPage, pageSize, filtersRef.current ?? searchTextRef.current)
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setState({ data: result.data, total: result.total, page: targetPage, loading: false });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
console.warn('[usePaginatedData] 加载数据失败:', err);
|
||||
message.error('加载数据失败');
|
||||
setState((s) => ({ ...s, loading: false }));
|
||||
}
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
// fetchTrigger 变化 = 手动 refresh;filters 变化 = 筛选刷新
|
||||
}, [shouldAutoFetch, filters, fetchTrigger, pageSize]);
|
||||
|
||||
return { ...state, searchText, setSearchText, filters, setFilters, refresh };
|
||||
}
|
||||
@@ -115,5 +121,5 @@ interface PaginatedResult<T, F> extends PaginatedState<T> {
|
||||
setSearchText: (text: string) => void;
|
||||
filters: F;
|
||||
setFilters: (filters: F | ((prev: F) => F)) => void;
|
||||
refresh: (page?: number) => Promise<void>;
|
||||
refresh: (page?: number) => void;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function Users() {
|
||||
const {
|
||||
data: users, total, page, loading, refresh,
|
||||
} = usePaginatedData<UserInfo>(async (p, pageSize, search) => {
|
||||
const result = await listUsers(p, pageSize, search);
|
||||
const result = await listUsers(p, pageSize, search, 'patient');
|
||||
return { data: result.data, total: result.total };
|
||||
}, 20);
|
||||
|
||||
|
||||
566
apps/web/src/pages/ai/KnowledgeV2Page.tsx
Normal file
566
apps/web/src/pages/ai/KnowledgeV2Page.tsx
Normal file
@@ -0,0 +1,566 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Switch,
|
||||
message,
|
||||
Popconfirm,
|
||||
Tag,
|
||||
Upload,
|
||||
Progress,
|
||||
Drawer,
|
||||
List,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
UploadOutlined,
|
||||
SearchOutlined,
|
||||
FileTextOutlined,
|
||||
DatabaseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
import {
|
||||
knowledgeV2Api,
|
||||
type KnowledgeBase,
|
||||
type KnowledgeDocument,
|
||||
type SearchHit,
|
||||
type CreateKnowledgeBaseReq,
|
||||
} from '../../api/ai/knowledgeV2';
|
||||
|
||||
const KB_TYPES = [
|
||||
{ label: '临床指南', value: 'clinical_guide' },
|
||||
{ label: '操作规程', value: 'sop' },
|
||||
{ label: 'FAQ', value: 'faq' },
|
||||
{ label: '产品知识', value: 'product' },
|
||||
{ label: '通用', value: 'general' },
|
||||
];
|
||||
|
||||
export default function KnowledgeV2Page() {
|
||||
const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editKb, setEditKb] = useState<KnowledgeBase | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// Document drawer state
|
||||
const [docDrawerKb, setDocDrawerKb] = useState<KnowledgeBase | null>(null);
|
||||
const [docs, setDocs] = useState<KnowledgeDocument[]>([]);
|
||||
const [docsLoading, setDocsLoading] = useState(false);
|
||||
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
||||
const [uploadKbId, setUploadKbId] = useState<string>('');
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
|
||||
// Hit test state
|
||||
const [hitTestKb, setHitTestKb] = useState<KnowledgeBase | null>(null);
|
||||
const [hitTestQuery, setHitTestQuery] = useState('');
|
||||
const [hitResults, setHitResults] = useState<SearchHit[]>([]);
|
||||
const [hitTestLoading, setHitTestLoading] = useState(false);
|
||||
|
||||
const loadKbs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await knowledgeV2Api.listKnowledgeBases({
|
||||
page,
|
||||
page_size: 20,
|
||||
});
|
||||
setKbs(res.data);
|
||||
setTotal(res.total);
|
||||
} catch {
|
||||
message.error('加载知识库列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
loadKbs();
|
||||
}, [loadKbs]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const req: CreateKnowledgeBaseReq = {
|
||||
name: values.name,
|
||||
kb_type: values.kb_type,
|
||||
description: values.description,
|
||||
is_enabled: values.is_enabled ?? true,
|
||||
};
|
||||
await knowledgeV2Api.createKnowledgeBase(req);
|
||||
message.success('知识库创建成功');
|
||||
setCreateModalOpen(false);
|
||||
form.resetFields();
|
||||
loadKbs();
|
||||
} catch {
|
||||
// validation error
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!editKb) return;
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await knowledgeV2Api.updateKnowledgeBase(editKb.id, {
|
||||
name: values.name,
|
||||
kb_type: values.kb_type,
|
||||
description: values.description,
|
||||
is_enabled: values.is_enabled,
|
||||
});
|
||||
message.success('知识库更新成功');
|
||||
setEditKb(null);
|
||||
form.resetFields();
|
||||
loadKbs();
|
||||
} catch {
|
||||
// validation error
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await knowledgeV2Api.deleteKnowledgeBase(id);
|
||||
message.success('知识库已删除');
|
||||
loadKbs();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const loadDocuments = async (kb: KnowledgeBase) => {
|
||||
setDocDrawerKb(kb);
|
||||
setDocsLoading(true);
|
||||
try {
|
||||
const res = await knowledgeV2Api.listDocuments(kb.id, {
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
});
|
||||
setDocs(res.data);
|
||||
} catch {
|
||||
message.error('加载文档列表失败');
|
||||
} finally {
|
||||
setDocsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!uploadKbId || fileList.length === 0) return;
|
||||
try {
|
||||
const file = fileList[0].originFileObj;
|
||||
if (!file) return;
|
||||
await knowledgeV2Api.uploadDocument(uploadKbId, file);
|
||||
message.success('文档上传成功,正在处理...');
|
||||
setUploadModalOpen(false);
|
||||
setFileList([]);
|
||||
if (docDrawerKb) {
|
||||
loadDocuments(docDrawerKb);
|
||||
}
|
||||
} catch {
|
||||
message.error('上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDoc = async (kbId: string, docId: string) => {
|
||||
try {
|
||||
await knowledgeV2Api.deleteDocument(kbId, docId);
|
||||
message.success('文档已删除');
|
||||
if (docDrawerKb) {
|
||||
loadDocuments(docDrawerKb);
|
||||
}
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleHitTest = async () => {
|
||||
if (!hitTestKb || !hitTestQuery.trim()) return;
|
||||
setHitTestLoading(true);
|
||||
try {
|
||||
const res = await knowledgeV2Api.hitTest(hitTestKb.id, hitTestQuery, 5);
|
||||
setHitResults(res.hits);
|
||||
} catch {
|
||||
message.error('搜索失败');
|
||||
setHitResults([]);
|
||||
} finally {
|
||||
setHitTestLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statusTag = (status: string) => {
|
||||
const map: Record<string, { color: string; label: string }> = {
|
||||
pending: { color: 'default', label: '待处理' },
|
||||
processing: { color: 'processing', label: '处理中' },
|
||||
completed: { color: 'success', label: '已完成' },
|
||||
failed: { color: 'error', label: '失败' },
|
||||
};
|
||||
const info = map[status] || { color: 'default', label: status };
|
||||
return <Tag color={info.color}>{info.label}</Tag>;
|
||||
};
|
||||
|
||||
const kbColumns = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (name: string, record: KnowledgeBase) => (
|
||||
<Button type="link" onClick={() => loadDocuments(record)}>
|
||||
{name}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'kb_type',
|
||||
key: 'kb_type',
|
||||
render: (type: string) => {
|
||||
const found = KB_TYPES.find((t) => t.value === type);
|
||||
return found?.label || type;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '文档数',
|
||||
dataIndex: 'document_count',
|
||||
key: 'document_count',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: '切片数',
|
||||
dataIndex: 'chunk_count',
|
||||
key: 'chunk_count',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_enabled',
|
||||
key: 'is_enabled',
|
||||
width: 80,
|
||||
render: (v: boolean) => (
|
||||
<Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 170,
|
||||
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 240,
|
||||
render: (_: unknown, record: KnowledgeBase) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UploadOutlined />}
|
||||
onClick={() => {
|
||||
setUploadKbId(record.id);
|
||||
setUploadModalOpen(true);
|
||||
}}
|
||||
>
|
||||
上传
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={() => {
|
||||
setHitTestKb(record);
|
||||
setHitResults([]);
|
||||
setHitTestQuery('');
|
||||
}}
|
||||
>
|
||||
搜索测试
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditKb(record);
|
||||
form.setFieldsValue({
|
||||
name: record.name,
|
||||
kb_type: record.kb_type,
|
||||
description: record.description,
|
||||
is_enabled: record.is_enabled,
|
||||
});
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定删除此知识库?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const kbFormContent = (
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="知识库名称"
|
||||
rules={[{ required: true, message: '请输入知识库名称' }]}
|
||||
>
|
||||
<Input placeholder="例:高血压临床指南" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="kb_type"
|
||||
label="知识库类型"
|
||||
rules={[{ required: true, message: '请选择类型' }]}
|
||||
>
|
||||
<Select options={KB_TYPES} placeholder="选择类型" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={3} placeholder="知识库描述(可选)" />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
|
||||
<Switch defaultChecked />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
|
||||
const docColumns = [
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'doc_type',
|
||||
key: 'doc_type',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'source_type',
|
||||
key: 'source_type',
|
||||
width: 80,
|
||||
render: (v: string) => <Tag>{v === 'upload' ? '上传' : '手动'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
render: statusTag,
|
||||
},
|
||||
{
|
||||
title: '切片/嵌入',
|
||||
key: 'progress',
|
||||
width: 130,
|
||||
render: (_: unknown, record: KnowledgeDocument) => {
|
||||
if (record.chunk_count === 0) return '-';
|
||||
const pct = Math.round(
|
||||
(record.embedded_count / record.chunk_count) * 100,
|
||||
);
|
||||
return <Progress percent={pct} size="small" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 70,
|
||||
render: (_: unknown, record: KnowledgeDocument) => (
|
||||
<Popconfirm
|
||||
title="确定删除此文档?"
|
||||
onConfirm={() =>
|
||||
handleDeleteDoc(record.knowledge_base_id, record.id)
|
||||
}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<DatabaseOutlined />
|
||||
知识库管理 V2
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setCreateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新建知识库
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={kbColumns}
|
||||
dataSource={kbs}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: setPage,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 创建知识库 Modal */}
|
||||
<Modal
|
||||
title="新建知识库"
|
||||
open={createModalOpen}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => {
|
||||
setCreateModalOpen(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
okText="创建"
|
||||
>
|
||||
{kbFormContent}
|
||||
</Modal>
|
||||
|
||||
{/* 编辑知识库 Modal */}
|
||||
<Modal
|
||||
title="编辑知识库"
|
||||
open={!!editKb}
|
||||
onOk={handleUpdate}
|
||||
onCancel={() => {
|
||||
setEditKb(null);
|
||||
form.resetFields();
|
||||
}}
|
||||
okText="保存"
|
||||
>
|
||||
{kbFormContent}
|
||||
</Modal>
|
||||
|
||||
{/* 文档列表 Drawer */}
|
||||
<Drawer
|
||||
title={
|
||||
docDrawerKb
|
||||
? `${docDrawerKb.name} — 文档列表`
|
||||
: '文档列表'
|
||||
}
|
||||
open={!!docDrawerKb}
|
||||
onClose={() => setDocDrawerKb(null)}
|
||||
width={720}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={docColumns}
|
||||
dataSource={docs}
|
||||
loading={docsLoading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: <Empty description="暂无文档" /> }}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
{/* 上传文档 Modal */}
|
||||
<Modal
|
||||
title="上传文档"
|
||||
open={uploadModalOpen}
|
||||
onOk={handleUpload}
|
||||
onCancel={() => {
|
||||
setUploadModalOpen(false);
|
||||
setFileList([]);
|
||||
}}
|
||||
okText="上传"
|
||||
>
|
||||
<Upload
|
||||
beforeUpload={() => false}
|
||||
maxCount={1}
|
||||
fileList={fileList}
|
||||
onChange={({ fileList: fl }) => setFileList(fl)}
|
||||
accept=".pdf,.txt,.md,.docx,.xlsx"
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>选择文件</Button>
|
||||
</Upload>
|
||||
<div style={{ marginTop: 8, color: '#999', fontSize: 12 }}>
|
||||
支持 PDF、TXT、Markdown、DOCX、XLSX,最大 20MB
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Hit Test Drawer */}
|
||||
<Drawer
|
||||
title={
|
||||
hitTestKb ? `${hitTestKb.name} — 向量搜索测试` : '搜索测试'
|
||||
}
|
||||
open={!!hitTestKb}
|
||||
onClose={() => {
|
||||
setHitTestKb(null);
|
||||
setHitResults([]);
|
||||
}}
|
||||
width={600}
|
||||
>
|
||||
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Input
|
||||
placeholder="输入搜索文本..."
|
||||
value={hitTestQuery}
|
||||
onChange={(e) => setHitTestQuery(e.target.value)}
|
||||
onPressEnter={handleHitTest}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
loading={hitTestLoading}
|
||||
onClick={handleHitTest}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
<List
|
||||
dataSource={hitResults}
|
||||
locale={{ emptyText: <Empty description="输入查询后点击搜索" /> }}
|
||||
renderItem={(item) => (
|
||||
<List.Item key={item.chunk_id}>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<FileTextOutlined style={{ fontSize: 20, color: '#1890ff' }} />
|
||||
}
|
||||
title={
|
||||
<Space>
|
||||
<span>{item.doc_title}</span>
|
||||
<Tag color="blue">切片 #{item.chunk_index}</Tag>
|
||||
<Tag color="green">
|
||||
相似度 {(item.similarity * 100).toFixed(1)}%
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 120,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
{item.content}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,508 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Switch,
|
||||
message,
|
||||
Popconfirm,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
knowledgeApi,
|
||||
type KnowledgeReference,
|
||||
type KnowledgeGuide,
|
||||
type CreateReferenceReq,
|
||||
type UpdateReferenceReq,
|
||||
type CreateGuideReq,
|
||||
type UpdateGuideReq,
|
||||
} from '../../api/ai/knowledge';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
|
||||
const ANALYSIS_TYPES = [
|
||||
{ value: 'lab_report', label: '化验报告' },
|
||||
{ value: 'trend', label: '趋势分析' },
|
||||
{ value: 'report_summary', label: '报告摘要' },
|
||||
{ value: 'dialysis_risk', label: '透析风险' },
|
||||
{ value: 'checkup_plan', label: '体检计划' },
|
||||
{ value: 'follow_up', label: '随访总结' },
|
||||
];
|
||||
|
||||
export default function AiKnowledgePage() {
|
||||
return (
|
||||
<Card title="AI 知识库管理">
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: 'references', label: '参考资料', children: <ReferencesTab /> },
|
||||
{ key: 'guides', label: '临床指南', children: <GuidesTab /> },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// === References Tab ===
|
||||
|
||||
function ReferencesTab() {
|
||||
const [data, setData] = useState<KnowledgeReference[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<KnowledgeReference | null>(null);
|
||||
const [filterType, setFilterType] = useState<string | undefined>();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await knowledgeApi.listReferences(
|
||||
filterType ? { analysis_type: filterType } : undefined,
|
||||
);
|
||||
setData(result.data);
|
||||
} catch {
|
||||
message.error('加载参考资料失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterType]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ is_enabled: true });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (record: KnowledgeReference) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue({
|
||||
title: record.title,
|
||||
analysis_type: record.analysis_type,
|
||||
source_name: record.source_name,
|
||||
content_summary: record.content_summary,
|
||||
is_enabled: record.is_enabled,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const values = await form.validateFields();
|
||||
try {
|
||||
if (editing) {
|
||||
const req: UpdateReferenceReq = {
|
||||
title: values.title,
|
||||
analysis_type: values.analysis_type,
|
||||
source_name: values.source_name,
|
||||
content_summary: values.content_summary,
|
||||
is_enabled: values.is_enabled,
|
||||
};
|
||||
await knowledgeApi.updateReference(editing.id, req);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
const req: CreateReferenceReq = {
|
||||
title: values.title,
|
||||
analysis_type: values.analysis_type,
|
||||
source_name: values.source_name,
|
||||
content_summary: values.content_summary,
|
||||
is_enabled: values.is_enabled,
|
||||
};
|
||||
await knowledgeApi.createReference(req);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalOpen(false);
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error(editing ? '更新失败' : '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await knowledgeApi.deleteReference(id);
|
||||
message.success('删除成功');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReEmbed = async (id: string) => {
|
||||
try {
|
||||
await knowledgeApi.reEmbedReference(id);
|
||||
message.success('向量重新生成已触发');
|
||||
} catch {
|
||||
message.error('向量重新生成失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
|
||||
{
|
||||
title: '分析类型',
|
||||
dataIndex: 'analysis_type',
|
||||
key: 'analysis_type',
|
||||
width: 120,
|
||||
render: (v: string) => {
|
||||
const found = ANALYSIS_TYPES.find((t) => t.value === v);
|
||||
return <Tag>{found?.label ?? v}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '来源', dataIndex: 'source_name', key: 'source_name', width: 150, ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_enabled',
|
||||
key: 'is_enabled',
|
||||
width: 80,
|
||||
render: (v: boolean) => (
|
||||
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updated_at',
|
||||
key: 'updated_at',
|
||||
width: 170,
|
||||
render: (v: string) => (v ? new Date(v).toLocaleString() : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (_: unknown, record: KnowledgeReference) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="重新生成向量">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => handleReEmbed(record.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="确定删除此参考资料?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="按分析类型过滤"
|
||||
style={{ width: 180 }}
|
||||
options={ANALYSIS_TYPES}
|
||||
value={filterType}
|
||||
onChange={setFilterType}
|
||||
/>
|
||||
<AuthButton code="ai.knowledge.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新增参考资料
|
||||
</Button>
|
||||
</AuthButton>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchData}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 条` }}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑参考资料' : '新增参考资料'}
|
||||
open={modalOpen}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
width={600}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="analysis_type"
|
||||
label="分析类型"
|
||||
rules={[{ required: true, message: '请选择分析类型' }]}
|
||||
>
|
||||
<Select options={ANALYSIS_TYPES} />
|
||||
</Form.Item>
|
||||
<Form.Item name="source_name" label="来源名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="content_summary"
|
||||
label="内容摘要"
|
||||
rules={[{ required: true, message: '请输入内容摘要' }]}
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// === Guides Tab ===
|
||||
|
||||
function GuidesTab() {
|
||||
const [data, setData] = useState<KnowledgeGuide[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<KnowledgeGuide | null>(null);
|
||||
const [filterType, setFilterType] = useState<string | undefined>();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await knowledgeApi.listGuides(
|
||||
filterType ? { analysis_type: filterType } : undefined,
|
||||
);
|
||||
setData(result.data);
|
||||
} catch {
|
||||
message.error('加载临床指南失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterType]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ is_enabled: true });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (record: KnowledgeGuide) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue({
|
||||
title: record.title,
|
||||
analysis_type: record.analysis_type,
|
||||
content: record.content,
|
||||
category: record.category,
|
||||
is_enabled: record.is_enabled,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const values = await form.validateFields();
|
||||
try {
|
||||
if (editing) {
|
||||
const req: UpdateGuideReq = {
|
||||
title: values.title,
|
||||
analysis_type: values.analysis_type,
|
||||
content: values.content,
|
||||
category: values.category,
|
||||
is_enabled: values.is_enabled,
|
||||
};
|
||||
await knowledgeApi.updateGuide(editing.id, req);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
const req: CreateGuideReq = {
|
||||
title: values.title,
|
||||
analysis_type: values.analysis_type,
|
||||
content: values.content,
|
||||
category: values.category,
|
||||
is_enabled: values.is_enabled,
|
||||
};
|
||||
await knowledgeApi.createGuide(req);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalOpen(false);
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error(editing ? '更新失败' : '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await knowledgeApi.deleteGuide(id);
|
||||
message.success('删除成功');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReEmbed = async (id: string) => {
|
||||
try {
|
||||
await knowledgeApi.reEmbedGuide(id);
|
||||
message.success('向量重新生成已触发');
|
||||
} catch {
|
||||
message.error('向量重新生成失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
|
||||
{
|
||||
title: '分析类型',
|
||||
dataIndex: 'analysis_type',
|
||||
key: 'analysis_type',
|
||||
width: 120,
|
||||
render: (v: string) => {
|
||||
const found = ANALYSIS_TYPES.find((t) => t.value === v);
|
||||
return <Tag>{found?.label ?? v}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
width: 100,
|
||||
render: (v: string | null) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_enabled',
|
||||
key: 'is_enabled',
|
||||
width: 80,
|
||||
render: (v: boolean) => (
|
||||
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updated_at',
|
||||
key: 'updated_at',
|
||||
width: 170,
|
||||
render: (v: string) => (v ? new Date(v).toLocaleString() : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (_: unknown, record: KnowledgeGuide) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="重新生成向量">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => handleReEmbed(record.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="确定删除此临床指南?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="按分析类型过滤"
|
||||
style={{ width: 180 }}
|
||||
options={ANALYSIS_TYPES}
|
||||
value={filterType}
|
||||
onChange={setFilterType}
|
||||
/>
|
||||
<AuthButton code="ai.knowledge.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新增临床指南
|
||||
</Button>
|
||||
</AuthButton>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchData}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 条` }}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑临床指南' : '新增临床指南'}
|
||||
open={modalOpen}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
width={700}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="analysis_type"
|
||||
label="分析类型"
|
||||
rules={[{ required: true, message: '请选择分析类型' }]}
|
||||
>
|
||||
<Select options={ANALYSIS_TYPES} />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="分类">
|
||||
<Input placeholder="如:心血管、内分泌" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="指南内容"
|
||||
rules={[{ required: true, message: '请输入指南内容' }]}
|
||||
>
|
||||
<Input.TextArea rows={8} />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,36 +3,68 @@ import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
Badge,
|
||||
message,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
Typography,
|
||||
Slider,
|
||||
InputNumber,
|
||||
Modal,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, UndoOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import { promptApi, type PromptItem, type CreatePromptReq } from '../../api/ai/prompts';
|
||||
import {
|
||||
PlusOutlined,
|
||||
UndoOutlined,
|
||||
CheckOutlined,
|
||||
EyeOutlined,
|
||||
StopOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { promptApi, type PromptItem } from '../../api/ai/prompts';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { DrawerForm } from '../../components/DrawerForm';
|
||||
import type { FormSection } from '../../components/DrawerForm';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
import { formatDateTime } from '../../utils/format';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'lab_report_interpretation', label: '化验单解读' },
|
||||
{ value: 'health_trend_analysis', label: '趋势分析' },
|
||||
{ value: 'personalized_checkup_plan', label: '体检方案' },
|
||||
{ value: 'report_summary_generation', label: '报告摘要' },
|
||||
// --- 分析类型定义(与后端 AnalysisType::prompt_name() 一一对应) ---
|
||||
|
||||
const ANALYSIS_TYPES = [
|
||||
{ value: 'lab_report_interpretation', label: '化验单解读', api: '化验单解读 API' },
|
||||
{ value: 'health_trend_analysis', label: '趋势分析', api: '趋势分析 API' },
|
||||
{ value: 'personalized_checkup_plan', label: '体检方案', api: '体检方案 API' },
|
||||
{ value: 'report_summary_generation', label: '报告摘要', api: '报告摘要 API' },
|
||||
{ value: 'follow_up_summary_generation', label: '随访摘要', api: '随访摘要 API' },
|
||||
] as const;
|
||||
|
||||
const ANALYSIS_TYPE_MAP = Object.fromEntries(
|
||||
ANALYSIS_TYPES.map((t) => [t.value, t]),
|
||||
);
|
||||
|
||||
const MODEL_OPTIONS = [
|
||||
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
|
||||
{ value: 'deepseek-reasoner', label: 'DeepSeek Reasoner' },
|
||||
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
|
||||
{ value: 'gpt-4o', label: 'GPT-4o' },
|
||||
{ value: 'qwen-plus', label: 'Qwen Plus' },
|
||||
];
|
||||
|
||||
const CATEGORY_MAP: Record<string, string> = Object.fromEntries(
|
||||
CATEGORIES.map((c) => [c.value, c.label]),
|
||||
);
|
||||
const DEFAULT_MODEL_CONFIG = { model: 'deepseek-chat', temperature: 0.7, max_tokens: 4096 };
|
||||
|
||||
export default function AiPromptList() {
|
||||
const [data, setData] = useState<PromptItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [categoryFilter, setCategoryFilter] = useState<string | undefined>();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [analysisTypeFilter, setAnalysisTypeFilter] = useState<string | undefined>();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [viewing, setViewing] = useState<PromptItem | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const isDark = useThemeMode();
|
||||
|
||||
@@ -43,7 +75,7 @@ export default function AiPromptList() {
|
||||
const result = await promptApi.list({
|
||||
page: p,
|
||||
page_size: 20,
|
||||
category: categoryFilter,
|
||||
analysis_type: analysisTypeFilter,
|
||||
});
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
@@ -53,18 +85,29 @@ export default function AiPromptList() {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[page, categoryFilter],
|
||||
[page, analysisTypeFilter],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleCreate = async (values: CreatePromptReq) => {
|
||||
const handleCreate = async (values: Record<string, unknown>) => {
|
||||
const model = String(values.model ?? 'deepseek-chat');
|
||||
const temperature = Number(values.temperature ?? 0.7);
|
||||
const max_tokens = Number(values.max_tokens ?? 4096);
|
||||
try {
|
||||
await promptApi.create(values);
|
||||
await promptApi.create({
|
||||
name: String(values.name ?? ''),
|
||||
analysis_type: String(values.analysis_type ?? ''),
|
||||
category: String(values.analysis_type ?? ''),
|
||||
description: values.description ? String(values.description) : undefined,
|
||||
system_prompt: String(values.system_prompt ?? ''),
|
||||
user_prompt_template: String(values.user_prompt_template ?? ''),
|
||||
model_config: { model, temperature, max_tokens },
|
||||
});
|
||||
message.success('Prompt 创建成功');
|
||||
setModalOpen(false);
|
||||
setDrawerOpen(false);
|
||||
form.resetFields();
|
||||
fetchData();
|
||||
} catch {
|
||||
@@ -72,7 +115,7 @@ export default function AiPromptList() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async (id: string) => {
|
||||
const handleActivate = useCallback(async (id: string) => {
|
||||
try {
|
||||
await promptApi.activate(id);
|
||||
message.success('已激活');
|
||||
@@ -80,9 +123,19 @@ export default function AiPromptList() {
|
||||
} catch {
|
||||
message.error('激活失败');
|
||||
}
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
const handleRollback = async (id: string) => {
|
||||
const handleDeactivate = useCallback(async (id: string) => {
|
||||
try {
|
||||
await promptApi.deactivate(id);
|
||||
message.success('已停用');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('停用失败');
|
||||
}
|
||||
}, [fetchData]);
|
||||
|
||||
const handleRollback = useCallback(async (id: string) => {
|
||||
try {
|
||||
await promptApi.rollback(id);
|
||||
message.success('已回滚');
|
||||
@@ -90,31 +143,78 @@ export default function AiPromptList() {
|
||||
} catch {
|
||||
message.error('回滚失败');
|
||||
}
|
||||
}, [fetchData]);
|
||||
|
||||
const handleDelete = useCallback((record: PromptItem) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除 Prompt「${record.name}」(v${record.version}) 吗?`,
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await promptApi.delete(record.id);
|
||||
message.success('删除成功');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [fetchData]);
|
||||
|
||||
const openDetail = (record: PromptItem) => {
|
||||
setViewing(record);
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
// 按 analysis_type 汇总当前激活版本
|
||||
const activeVersionMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const item of data) {
|
||||
if (item.is_active) {
|
||||
map.set(item.analysis_type, item.version);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [data]);
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 180,
|
||||
width: 160,
|
||||
render: (name: string) => <span style={{ fontWeight: 500 }}>{name}</span>,
|
||||
},
|
||||
{
|
||||
title: '类别',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
width: 120,
|
||||
render: (v: string) => (
|
||||
<Tag color="blue">{CATEGORY_MAP[v] || v}</Tag>
|
||||
),
|
||||
title: '分析类型',
|
||||
dataIndex: 'analysis_type',
|
||||
key: 'analysis_type',
|
||||
width: 130,
|
||||
render: (v: string) => {
|
||||
const cfg = ANALYSIS_TYPE_MAP[v];
|
||||
return cfg ? <Tag color="blue">{cfg.label}</Tag> : <Tag>{v}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '调用链路',
|
||||
dataIndex: 'analysis_type',
|
||||
key: 'api_route',
|
||||
width: 160,
|
||||
render: (v: string) => {
|
||||
const cfg = ANALYSIS_TYPE_MAP[v];
|
||||
return <Typography.Text type="secondary" style={{ fontSize: 12 }}>{cfg?.api ?? v}</Typography.Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '版本',
|
||||
dataIndex: 'version',
|
||||
key: 'version',
|
||||
width: 70,
|
||||
render: (v: number) => `v${v}`,
|
||||
render: (v: number, record: PromptItem) => {
|
||||
const isActive = activeVersionMap.get(record.analysis_type) === v && record.is_active;
|
||||
return isActive ? <Tag color="green">v{v}</Tag> : <span>v{v}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
@@ -122,7 +222,7 @@ export default function AiPromptList() {
|
||||
key: 'is_active',
|
||||
width: 80,
|
||||
render: (v: boolean) => (
|
||||
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>
|
||||
<Badge status={v ? 'success' : 'default'} text={v ? '启用' : '停用'} />
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -130,145 +230,247 @@ export default function AiPromptList() {
|
||||
dataIndex: 'updated_at',
|
||||
key: 'updated_at',
|
||||
width: 170,
|
||||
render: (v: string) => (v ? new Date(v).toLocaleString('zh-CN') : '-'),
|
||||
render: (v: string) => formatDateTime(v),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
width: 220,
|
||||
render: (_: unknown, record: PromptItem) => (
|
||||
<AuthButton code="ai.prompt.manage">
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => openDetail(record)}>
|
||||
查看
|
||||
</Button>
|
||||
{!record.is_active && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => handleActivate(record.id)}
|
||||
>
|
||||
<Button type="link" size="small" icon={<CheckOutlined />} onClick={() => handleActivate(record.id)}>
|
||||
激活
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
onClick={() => handleRollback(record.id)}
|
||||
>
|
||||
{record.is_active && (
|
||||
<Button type="link" size="small" icon={<StopOutlined />} onClick={() => handleDeactivate(record.id)}>
|
||||
停用
|
||||
</Button>
|
||||
)}
|
||||
<Button type="link" size="small" icon={<UndoOutlined />} onClick={() => handleRollback(record.id)}>
|
||||
回滚
|
||||
</Button>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
</AuthButton>
|
||||
),
|
||||
},
|
||||
], [handleActivate, handleRollback]);
|
||||
], [handleActivate, handleDeactivate, handleRollback, handleDelete, activeVersionMap]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="erp-page-header">
|
||||
<div>
|
||||
<h4>AI Prompt 管理</h4>
|
||||
<div className="erp-page-subtitle">管理 AI 分析提示词模板和版本</div>
|
||||
</div>
|
||||
<Space size={8}>
|
||||
<Select
|
||||
placeholder="筛选类别"
|
||||
value={categoryFilter}
|
||||
onChange={(v) => {
|
||||
setCategoryFilter(v);
|
||||
setPage(1);
|
||||
}}
|
||||
options={CATEGORIES}
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<AuthButton code="ai.prompt.manage">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新建 Prompt
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => {
|
||||
setPage(p);
|
||||
fetchData(p);
|
||||
},
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
style: { padding: '12px 16px', margin: 0 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="新建 Prompt"
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
width={600}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} onFinish={handleCreate} layout="vertical" style={{ marginTop: 16 }}>
|
||||
const formSections: FormSection[] = [
|
||||
{
|
||||
title: '基本信息',
|
||||
fields: (
|
||||
<>
|
||||
<Form.Item
|
||||
name="analysis_type"
|
||||
label="分析类型"
|
||||
rules={[{ required: true, message: '请选择分析类型' }]}
|
||||
extra="选择后自动填充名称,决定该 Prompt 被哪条分析链路调用"
|
||||
>
|
||||
<Select
|
||||
options={ANALYSIS_TYPES.map((t) => ({ value: t.value, label: t.label }))}
|
||||
placeholder="选择分析类型"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="名称"
|
||||
rules={[{ required: true, message: '请输入 Prompt 名称' }]}
|
||||
label="标识符"
|
||||
rules={[
|
||||
{ required: true, message: '请输入标识符' },
|
||||
{ pattern: /^[a-z0-9_]{3,64}$/, message: '仅允许小写字母、数字、下划线,3-64位' },
|
||||
]}
|
||||
extra="后端按此标识符查找 Prompt,通常与分析类型一致,非必要勿改"
|
||||
>
|
||||
<Input placeholder="如:化验单解读 V2" />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="类别" rules={[{ required: true, message: '请选择类别' }]}>
|
||||
<Select options={CATEGORIES} placeholder="选择类别" />
|
||||
<Input placeholder="如 lab_report_interpretation" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={2} placeholder="Prompt 用途说明" />
|
||||
<Input.TextArea rows={2} placeholder="Prompt 用途说明(仅展示,不影响选择)" />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '模型配置',
|
||||
fields: (
|
||||
<>
|
||||
<Form.Item name="model" label="模型" rules={[{ required: true, message: '请选择模型' }]}>
|
||||
<Select options={MODEL_OPTIONS} placeholder="选择 AI 模型" />
|
||||
</Form.Item>
|
||||
<Form.Item name="temperature" label="Temperature" extra="越低越确定,越高越多样">
|
||||
<Slider min={0} max={2} step={0.1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="max_tokens" label="Max Tokens">
|
||||
<InputNumber min={256} max={8192} step={256} style={{ width: '100%' }} placeholder="4096" />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '提示词模板',
|
||||
fields: (
|
||||
<>
|
||||
<Form.Item
|
||||
name="system_prompt"
|
||||
label="System Prompt"
|
||||
rules={[{ required: true, message: '请输入 System Prompt' }]}
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="系统提示词" />
|
||||
<Input.TextArea rows={6} placeholder="系统提示词,定义 AI 的角色和行为规则" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="user_prompt_template"
|
||||
label="User Prompt 模板"
|
||||
rules={[{ required: true, message: '请输入 User Prompt 模板' }]}
|
||||
extra="支持 Handlebars {{变量}} 语法,如 {{patient_name}}、{{report_date}}"
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="用户提示词模板,可用 {{变量}} 占位" />
|
||||
<Input.TextArea rows={6} placeholder="用户提示词模板,可用 {{变量}} 占位" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="model_config"
|
||||
label="模型配置 (JSON)"
|
||||
initialValue={{ model: 'deepseek-chat', temperature: 0.7 }}
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="AI Prompt 管理"
|
||||
subtitle="管理 AI 分析提示词模板和版本"
|
||||
filters={
|
||||
<Select
|
||||
placeholder="筛选分析类型"
|
||||
value={analysisTypeFilter}
|
||||
onChange={(v) => {
|
||||
setAnalysisTypeFilter(v);
|
||||
setPage(1);
|
||||
}}
|
||||
options={ANALYSIS_TYPES.map((t) => ({ value: t.value, label: t.label }))}
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<AuthButton code="ai.prompt.manage">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
form.setFieldsValue(DEFAULT_MODEL_CONFIG);
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder='{"model": "deepseek-chat", "temperature": 0.7}' />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
新建 Prompt
|
||||
</Button>
|
||||
</AuthButton>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => {
|
||||
setPage(p);
|
||||
fetchData(p);
|
||||
},
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 新建 Prompt Drawer */}
|
||||
<DrawerForm
|
||||
title="新建 Prompt"
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onSubmit={handleCreate}
|
||||
form={form}
|
||||
onValuesChange={(changed) => {
|
||||
if ('analysis_type' in changed && changed.analysis_type) {
|
||||
form.setFieldValue('name', changed.analysis_type);
|
||||
}
|
||||
}}
|
||||
initialValues={{
|
||||
...DEFAULT_MODEL_CONFIG,
|
||||
category: '',
|
||||
analysis_type: undefined,
|
||||
name: '',
|
||||
description: '',
|
||||
}}
|
||||
width={720}
|
||||
columns={1}
|
||||
sections={formSections}
|
||||
/>
|
||||
|
||||
{/* 查看 Prompt 详情 */}
|
||||
<Drawer
|
||||
title={viewing ? `${viewing.name} (v${viewing.version})` : 'Prompt 详情'}
|
||||
open={detailOpen}
|
||||
onClose={() => { setDetailOpen(false); setViewing(null); }}
|
||||
width={640}
|
||||
styles={{ body: { background: isDark ? '#141414' : undefined } }}
|
||||
>
|
||||
{viewing && (
|
||||
<>
|
||||
<Descriptions column={2} size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="分析类型">
|
||||
<Tag color="blue">{ANALYSIS_TYPE_MAP[viewing.analysis_type]?.label ?? viewing.analysis_type}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="标识符">
|
||||
<Typography.Text code style={{ fontSize: 12 }}>{viewing.analysis_type}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Badge status={viewing.is_active ? 'success' : 'default'} text={viewing.is_active ? '启用' : '停用'} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="版本">v{viewing.version}</Descriptions.Item>
|
||||
<Descriptions.Item label="调用链路" span={2}>
|
||||
<Typography.Text type="secondary">{ANALYSIS_TYPE_MAP[viewing.analysis_type]?.api ?? viewing.analysis_type}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">{formatDateTime(viewing.updated_at)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{viewing.description && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>描述</Typography.Text>
|
||||
<div style={{ marginTop: 4 }}>{viewing.description}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{[
|
||||
{ label: 'System Prompt', content: viewing.system_prompt },
|
||||
{ label: 'User Prompt 模板', content: viewing.user_prompt_template },
|
||||
{ label: '模型配置', content: JSON.stringify(viewing.model_config, null, 2) },
|
||||
].map(({ label, content }) => (
|
||||
<div key={label} style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{label}</Typography.Text>
|
||||
<pre style={{
|
||||
marginTop: 4,
|
||||
padding: 12,
|
||||
background: isDark ? '#1e293b' : '#f8fafc',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
maxHeight: 300,
|
||||
overflow: 'auto',
|
||||
}}>
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function ArticleCategoryManage() {
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (record: ArticleCategory) => {
|
||||
const openEditModal = useCallback((record: ArticleCategory) => {
|
||||
setEditingCategory(record);
|
||||
form.setFieldsValue({
|
||||
name: record.name,
|
||||
@@ -67,7 +67,7 @@ export default function ArticleCategoryManage() {
|
||||
description: record.description,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
@@ -111,15 +111,15 @@ export default function ArticleCategoryManage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
const handleDelete = useCallback(async (record: ArticleCategory) => {
|
||||
try {
|
||||
await articleCategoryApi.delete(id);
|
||||
await articleCategoryApi.delete(record.id, record.version ?? 0);
|
||||
message.success('分类已删除');
|
||||
fetchCategories();
|
||||
} catch {
|
||||
message.error('删除失败,可能该分类下还有文章');
|
||||
}
|
||||
};
|
||||
}, [fetchCategories]);
|
||||
|
||||
// 构建父分类选项(排除自身)
|
||||
const parentOptions = categories
|
||||
@@ -184,7 +184,7 @@ export default function ArticleCategoryManage() {
|
||||
<Popconfirm
|
||||
title="确定删除此分类?"
|
||||
description="删除后不可恢复,关联文章将变为未分类"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
onConfirm={() => handleDelete(record)}
|
||||
>
|
||||
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
|
||||
</Popconfirm>
|
||||
|
||||
@@ -119,7 +119,7 @@ export default function ArticleManageList() {
|
||||
.catch((err) => console.warn('[ArticleManageList] 获取文章分类失败:', err));
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string, version: number) => {
|
||||
const handleDelete = useCallback(async (id: string, version: number) => {
|
||||
try {
|
||||
await articleApi.delete(id, version);
|
||||
message.success('文章已删除');
|
||||
@@ -127,9 +127,9 @@ export default function ArticleManageList() {
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const handleSubmit = async (record: ArticleListItem) => {
|
||||
const handleSubmit = useCallback(async (record: ArticleListItem) => {
|
||||
try {
|
||||
await articleApi.submit(record.id, record.version);
|
||||
message.success('已提交审核');
|
||||
@@ -137,9 +137,9 @@ export default function ArticleManageList() {
|
||||
} catch {
|
||||
message.error('提交审核失败');
|
||||
}
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const handleApprove = async (record: ArticleListItem) => {
|
||||
const handleApprove = useCallback(async (record: ArticleListItem) => {
|
||||
try {
|
||||
await articleApi.approve(record.id, record.version);
|
||||
message.success('审核通过,文章已发布');
|
||||
@@ -147,13 +147,13 @@ export default function ArticleManageList() {
|
||||
} catch {
|
||||
message.error('审核操作失败');
|
||||
}
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const openRejectModal = (record: ArticleListItem) => {
|
||||
const openRejectModal = useCallback((record: ArticleListItem) => {
|
||||
setRejectingArticle(record);
|
||||
rejectForm.resetFields();
|
||||
setRejectModalOpen(true);
|
||||
};
|
||||
}, [rejectForm]);
|
||||
|
||||
const handleReject = async (values: { review_note: string }) => {
|
||||
if (!rejectingArticle) return;
|
||||
@@ -167,7 +167,7 @@ export default function ArticleManageList() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpublish = async (record: ArticleListItem) => {
|
||||
const handleUnpublish = useCallback(async (record: ArticleListItem) => {
|
||||
try {
|
||||
await articleApi.unpublish(record.id, record.version);
|
||||
message.success('文章已撤回为草稿');
|
||||
@@ -175,9 +175,9 @@ export default function ArticleManageList() {
|
||||
} catch {
|
||||
message.error('撤回操作失败');
|
||||
}
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const renderActions = (record: ArticleListItem) => (
|
||||
const renderActions = useCallback((record: ArticleListItem) => (
|
||||
<Space size={4} wrap>
|
||||
{record.status === 'draft' && (
|
||||
<>
|
||||
@@ -252,7 +252,7 @@ export default function ArticleManageList() {
|
||||
</AuthButton>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
), [navigate, handleSubmit, handleApprove, openRejectModal, handleUnpublish, handleDelete]);
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
@@ -410,7 +410,7 @@ export default function ArticleManageList() {
|
||||
<Button size="small" type="text" icon={<EditOutlined />}
|
||||
onClick={() => { setCatEditing(record); catForm.setFieldsValue(record); setCatModalOpen(true); }} />
|
||||
<Popconfirm title="确定删除?" onConfirm={async () => {
|
||||
try { await articleCategoryApi.delete(record.id); message.success('已删除'); fetchCategories(); } catch { message.error('删除失败'); }
|
||||
try { await articleCategoryApi.delete(record.id, record.version ?? 0); message.success('已删除'); fetchCategories(); } catch { message.error('删除失败'); }
|
||||
}}>
|
||||
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
|
||||
</Popconfirm>
|
||||
@@ -522,7 +522,6 @@ export default function ArticleManageList() {
|
||||
</Button>
|
||||
</AuthButton>
|
||||
)}
|
||||
loading={loading}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activePageTab}
|
||||
|
||||
@@ -11,12 +11,15 @@ import {
|
||||
Tag,
|
||||
Badge,
|
||||
Switch,
|
||||
Upload,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
PictureOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
pointsApi,
|
||||
@@ -26,8 +29,11 @@ import {
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { DrawerForm } from '../../components/DrawerForm';
|
||||
import type { FormSection } from '../../components/DrawerForm';
|
||||
import MediaPicker from '../../components/MediaPicker';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
import { uploadFile } from '../../api/upload';
|
||||
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
import { formatDateTime } from '../../utils/format';
|
||||
|
||||
/** 商品类型映射 */
|
||||
@@ -59,6 +65,9 @@ interface ProductFilters {
|
||||
export default function PointsProductList() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<PointsProduct | null>(null);
|
||||
const [mediaPickerOpen, setMediaPickerOpen] = useState(false);
|
||||
const [imageUrl, setImageUrl] = useState<string>('');
|
||||
const isDark = useThemeMode();
|
||||
|
||||
const fetchProducts = useCallback(
|
||||
async (page: number, pageSize: number, filters: ProductFilters) => {
|
||||
@@ -94,11 +103,13 @@ export default function PointsProductList() {
|
||||
// ---- 新建 / 编辑 ----
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setImageUrl('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (record: PointsProduct) => {
|
||||
setEditing(record);
|
||||
setImageUrl(record.image_url || '');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -115,12 +126,14 @@ export default function PointsProductList() {
|
||||
points_cost: number;
|
||||
stock: number;
|
||||
description?: string;
|
||||
image_url?: string;
|
||||
sort_order?: number;
|
||||
};
|
||||
// 保存时去掉 URL 中的 ?token= 参数(token 是临时的,不应持久化)
|
||||
const cleanImageUrl = imageUrl ? imageUrl.replace(/\?token=.*$/, '') : undefined;
|
||||
if (editing) {
|
||||
await pointsApi.updateProduct(editing.id, {
|
||||
...typed,
|
||||
image_url: cleanImageUrl,
|
||||
version: editing.version,
|
||||
});
|
||||
} else {
|
||||
@@ -130,7 +143,7 @@ export default function PointsProductList() {
|
||||
points_cost: typed.points_cost,
|
||||
stock: typed.stock,
|
||||
description: typed.description,
|
||||
image_url: typed.image_url,
|
||||
image_url: cleanImageUrl,
|
||||
sort_order: typed.sort_order,
|
||||
};
|
||||
await pointsApi.createProduct(req);
|
||||
@@ -138,8 +151,9 @@ export default function PointsProductList() {
|
||||
message.success(editing ? '更新成功' : '创建成功');
|
||||
handleCloseDrawer();
|
||||
refresh(page);
|
||||
} catch {
|
||||
message.error(editing ? '更新失败' : '创建失败');
|
||||
} catch (err: unknown) {
|
||||
const apiMsg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
message.error(apiMsg || (editing ? '更新失败' : '创建失败'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -309,8 +323,51 @@ export default function PointsProductList() {
|
||||
title: '展示设置',
|
||||
fields: (
|
||||
<>
|
||||
<Form.Item name="image_url" label="图片链接">
|
||||
<Input placeholder="商品图片 URL" />
|
||||
<Form.Item label="商品图片">
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
placeholder="输入 URL 或从媒体库选择"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button icon={<PictureOutlined />} onClick={() => setMediaPickerOpen(true)}>
|
||||
媒体库
|
||||
</Button>
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
beforeUpload={async (file) => {
|
||||
try {
|
||||
const result = await uploadFile(file);
|
||||
setImageUrl(result.url);
|
||||
message.success('图片上传成功');
|
||||
} catch {
|
||||
message.error('图片上传失败');
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>上传</Button>
|
||||
</Upload>
|
||||
</Space.Compact>
|
||||
{imageUrl && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${isDark ? '#1e293b' : '#e2e8f0'}`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="商品图片预览"
|
||||
style={{ width: '100%', height: 120, objectFit: 'cover' }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序">
|
||||
<InputNumber min={0} max={9999} style={{ width: '100%' }} placeholder="0" />
|
||||
@@ -394,7 +451,6 @@ export default function PointsProductList() {
|
||||
points_cost: editing.points_cost,
|
||||
stock: editing.stock,
|
||||
description: editing.description,
|
||||
image_url: editing.image_url,
|
||||
sort_order: editing.sort_order,
|
||||
}
|
||||
: { stock: -1, sort_order: 0 }}
|
||||
@@ -403,6 +459,15 @@ export default function PointsProductList() {
|
||||
columns={2}
|
||||
sections={formSections}
|
||||
/>
|
||||
|
||||
<MediaPicker
|
||||
open={mediaPickerOpen}
|
||||
onClose={() => setMediaPickerOpen(false)}
|
||||
onSelect={(url) => {
|
||||
setImageUrl(url);
|
||||
message.success('已选择图片');
|
||||
}}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,8 +142,9 @@ export default function PointsRuleList() {
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error(editing ? '更新失败' : '创建失败');
|
||||
} catch (err: unknown) {
|
||||
const apiMsg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
message.error(apiMsg || (editing ? '更新失败' : '创建失败'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
MedicineBoxOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useStatsData } from './useStatsData';
|
||||
import { useCountUp } from '../../../hooks/useCountUp';
|
||||
import HealthDataCenter from './HealthDataCenter';
|
||||
|
||||
export function AdminDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { patientStats, followUpStats, healthDataStats, dialysisStats, doctorCount, loading } = useStatsData();
|
||||
const patientCount = useCountUp(patientStats?.total_patients ?? 0);
|
||||
const appointmentCount = useCountUp(healthDataStats?.appointments?.this_month ?? 0);
|
||||
@@ -18,39 +20,51 @@ export function AdminDashboard() {
|
||||
|
||||
if (loading && !patientStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
||||
|
||||
const newThisMonth = patientStats?.new_this_month ?? 0;
|
||||
const newThisWeek = patientStats?.new_this_week ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>管理中心</Typography.Title>
|
||||
<Typography.Text type="secondary">数据概览</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
患者增长 {newThisMonth > 0 ? `本月+${newThisMonth}` : ''} · 本周+{newThisWeek} · 活跃 {patientStats?.active_this_month ?? 0}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/patients')}>
|
||||
<Statistic title="患者总数" value={patientCount} prefix={<TeamOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/appointments')}>
|
||||
<Statistic title="本月预约" value={appointmentCount} prefix={<CalendarOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/follow-up-tasks')}>
|
||||
<Statistic
|
||||
title="随访完成"
|
||||
value={followUpStats?.completion_rate ?? 0}
|
||||
precision={0}
|
||||
suffix="%"
|
||||
prefix={<SafetyCertificateOutlined />}
|
||||
suffix={
|
||||
<span style={{ fontSize: 12 }}>
|
||||
% {followUpStats && followUpStats.overdue > 0 && (
|
||||
<Typography.Text type="danger" style={{ fontSize: 11 }}>({followUpStats.overdue}逾期)</Typography.Text>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/daily-monitoring')}>
|
||||
<Statistic
|
||||
title="体征上报"
|
||||
value={healthDataStats?.vital_signs_report_rate?.report_rate ?? 0}
|
||||
@@ -61,10 +75,20 @@ export function AdminDashboard() {
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/doctors')}>
|
||||
<Statistic title="医护人数" value={doctorCountDisplay} prefix={<UserOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/consultations')}>
|
||||
<Statistic
|
||||
title="咨询待回复"
|
||||
value={healthDataStats ? 0 : 0}
|
||||
prefix={<TeamOutlined />}
|
||||
valueStyle={{ color: '#d97706' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 健康数据中心 Tab */}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Row, Col, Card, Statistic, List, Tag, Spin, Typography, Flex, Space, Bu
|
||||
import {
|
||||
TeamOutlined,
|
||||
MessageOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
MedicineBoxOutlined,
|
||||
CalendarOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
AlertOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
@@ -137,7 +138,7 @@ export function DoctorDashboard() {
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/patients')}>
|
||||
<Statistic
|
||||
title="我的患者"
|
||||
value={myPatientsCount}
|
||||
@@ -151,19 +152,22 @@ export function DoctorDashboard() {
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/follow-up-tasks')}>
|
||||
<Statistic
|
||||
title="随访完成率"
|
||||
value={p?.follow_up_rate ?? 0}
|
||||
precision={0}
|
||||
suffix="%"
|
||||
prefix={<SafetyCertificateOutlined />}
|
||||
styles={{ content: { color: (p?.follow_up_rate ?? 0) >= 80 ? '#3f8600' : '#cf1322' } }}
|
||||
title="今日预约"
|
||||
value={p?.today_appointments ?? 0}
|
||||
prefix={<CalendarOutlined />}
|
||||
suffix={p?.yesterday_today_appointments != null ? (() => {
|
||||
const diff = (p.today_appointments ?? 0) - (p.yesterday_today_appointments ?? 0);
|
||||
if (diff > 0) return <Typography.Text type="success" style={{ fontSize: 12 }}><ArrowUpOutlined /> {diff}</Typography.Text>;
|
||||
if (diff < 0) return <Typography.Text type="danger" style={{ fontSize: 12 }}><ArrowDownOutlined /> {Math.abs(diff)}</Typography.Text>;
|
||||
return null;
|
||||
})() : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/consultations')}>
|
||||
<Statistic
|
||||
title="本月咨询"
|
||||
value={consultationsCount}
|
||||
@@ -177,7 +181,7 @@ export function DoctorDashboard() {
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/daily-monitoring')}>
|
||||
<Statistic
|
||||
title="体征上报率"
|
||||
value={p?.vital_signs_report_rate ?? 0}
|
||||
@@ -192,13 +196,18 @@ export function DoctorDashboard() {
|
||||
{/* 化验审核 */}
|
||||
{p && p.pending_lab_reviews > 0 && (
|
||||
<Col xs={24} md={12}>
|
||||
<Card title={`化验审核 (${p.pending_lab_reviews}待审)`} size="small">
|
||||
<List
|
||||
size="small"
|
||||
dataSource={[]}
|
||||
locale={{ emptyText: '暂无待审核化验' }}
|
||||
renderItem={() => <List.Item />}
|
||||
/>
|
||||
<Card
|
||||
title={`化验审核 (${p.pending_lab_reviews}待审)`}
|
||||
size="small"
|
||||
extra={
|
||||
<Button type="link" size="small" icon={<RightOutlined />} onClick={() => navigate('/health/patients')}>
|
||||
查看待审
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography.Text type="secondary">
|
||||
您有 {p.pending_lab_reviews} 份化验报告待审核,请及时处理。
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
@@ -211,7 +220,7 @@ export function DoctorDashboard() {
|
||||
dataSource={activeConsultations}
|
||||
locale={{ emptyText: '暂无未读消息' }}
|
||||
renderItem={(session) => (
|
||||
<List.Item style={{ padding: '6px 0' }}>
|
||||
<List.Item style={{ padding: '6px 0', cursor: 'pointer' }} onClick={() => navigate(`/health/consultations/${session.id}`)}>
|
||||
<Space>
|
||||
<Typography.Text strong style={{ fontSize: 13 }}>{session.patient_name ?? '患者'}</Typography.Text>
|
||||
<Tag color={session.status === 'active' ? 'green' : 'default'}>{session.status === 'active' ? '进行中' : session.status}</Tag>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Row, Col, Card, Statistic, Progress, List, Spin, Typography, Flex, Space } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
TeamOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
@@ -11,6 +12,7 @@ import { followUpApi, type FollowUpTask } from '../../../api/health/followUp';
|
||||
import { useCountUp } from '../../../hooks/useCountUp';
|
||||
|
||||
export function NurseDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [personal, setPersonal] = useState<PersonalStats | null>(null);
|
||||
const [followUpTasks, setFollowUpTasks] = useState<FollowUpTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -66,7 +68,7 @@ export function NurseDashboard() {
|
||||
{p.abnormal_vital_signs} 位患者体征异常
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Typography.Link>查看全部 →</Typography.Link>
|
||||
<Typography.Link onClick={() => navigate('/health/alert-dashboard')}>查看全部 →</Typography.Link>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { Row, Col, Card, Statistic, List, Spin, Typography, Flex } from 'antd';
|
||||
import { Row, Col, Card, Statistic, List, Spin, Typography, Flex, Button } from 'antd';
|
||||
import {
|
||||
TrophyOutlined,
|
||||
FileTextOutlined,
|
||||
CalendarOutlined,
|
||||
ShoppingOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useStatsData } from './useStatsData';
|
||||
import { articleApi, type ArticleListItem } from '../../../api/health/articles';
|
||||
import { useCountUp } from '../../../hooks/useCountUp';
|
||||
|
||||
export function OperatorDashboard() {
|
||||
const { pointsStats, loading } = useStatsData();
|
||||
const navigate = useNavigate();
|
||||
const { pointsStats, offlineEventCount, loading } = useStatsData();
|
||||
const [topArticles, setTopArticles] = useState<ArticleListItem[]>([]);
|
||||
const issuedCount = useCountUp(pointsStats?.total_issued ?? 0);
|
||||
const spentCount = useCountUp(pointsStats?.total_spent ?? 0);
|
||||
const activeCount = useCountUp(pointsStats?.active_accounts ?? 0);
|
||||
const offlineCount = useCountUp(offlineEventCount);
|
||||
|
||||
const fetchTopArticles = useCallback(async () => {
|
||||
try {
|
||||
@@ -26,6 +30,8 @@ export function OperatorDashboard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// fetchTopArticles 内部 async setState(外部数据获取),非同步派生 state,属合理 effect 用法
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { fetchTopArticles(); }, [fetchTopArticles]);
|
||||
|
||||
if (loading && !pointsStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
||||
@@ -41,12 +47,12 @@ export function OperatorDashboard() {
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/points-rules')}>
|
||||
<Statistic title="积分发放" value={issuedCount} prefix={<TrophyOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/points-rules')}>
|
||||
<Statistic title="积分消费" value={spentCount} prefix={<ShoppingOutlined />}
|
||||
suffix={pointsStats ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
@@ -57,26 +63,34 @@ export function OperatorDashboard() {
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/points-rules')}>
|
||||
<Statistic title="活跃账户" value={activeCount} prefix={<FileTextOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="线下活动" value={0} prefix={<CalendarOutlined />} />
|
||||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/offline-events')}>
|
||||
<Statistic title="线下活动" value={offlineCount} prefix={<CalendarOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 积分排行 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="积分消费排行" size="small">
|
||||
<Card
|
||||
title="积分消费排行"
|
||||
size="small"
|
||||
extra={
|
||||
<Button type="link" size="small" icon={<RightOutlined />} onClick={() => navigate('/health/points-rules')}>
|
||||
查看全部
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={pointsStats?.top_earners?.slice(0, 5) ?? []}
|
||||
locale={{ emptyText: '暂无数据' }}
|
||||
renderItem={(item, idx) => (
|
||||
<List.Item>
|
||||
<Typography.Text>{idx + 1}. {item.patient_id?.slice(0, 8) ?? '未知'}</Typography.Text>
|
||||
<List.Item style={{ cursor: 'pointer' }} onClick={() => navigate(`/health/patients/${item.patient_id}`)}>
|
||||
<Typography.Text>{idx + 1}. {item.patient_name}</Typography.Text>
|
||||
<Typography.Text type="secondary">{item.total_earned} 分</Typography.Text>
|
||||
</List.Item>
|
||||
)}
|
||||
@@ -86,13 +100,21 @@ export function OperatorDashboard() {
|
||||
|
||||
{/* 热门文章 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="热门文章" size="small">
|
||||
<Card
|
||||
title="热门文章"
|
||||
size="small"
|
||||
extra={
|
||||
<Button type="link" size="small" icon={<RightOutlined />} onClick={() => navigate('/health/articles')}>
|
||||
内容管理
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={topArticles}
|
||||
locale={{ emptyText: '暂无数据' }}
|
||||
renderItem={(article) => (
|
||||
<List.Item style={{ padding: '6px 0' }}>
|
||||
<List.Item style={{ padding: '6px 0', cursor: 'pointer' }} onClick={() => navigate(`/health/articles/${article.id}/edit`)}>
|
||||
<Typography.Text ellipsis style={{ flex: 1, fontSize: 13 }}>{article.title}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
|
||||
{article.view_count} 次阅读
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface StatsData {
|
||||
healthDataStats: HealthDataStats | null;
|
||||
dialysisStats: DialysisStatistics | null;
|
||||
doctorCount: number;
|
||||
offlineEventCount: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
@@ -40,6 +41,7 @@ export function useStatsData(): StatsData {
|
||||
const [healthDataStats, setHealthDataStats] = useState<HealthDataStats | null>(null);
|
||||
const [dialysisStats, setDialysisStats] = useState<DialysisStatistics | null>(null);
|
||||
const [doctorCount, setDoctorCount] = useState(0);
|
||||
const [offlineEventCount, setOfflineEventCount] = useState(0);
|
||||
|
||||
const fetchAllStats = useCallback(async () => {
|
||||
// 缓存未过期,直接使用
|
||||
@@ -52,6 +54,7 @@ export function useStatsData(): StatsData {
|
||||
setHealthDataStats(c.healthDataStats as HealthDataStats | null);
|
||||
setDialysisStats(c.dialysisStats as DialysisStatistics | null);
|
||||
setDoctorCount(c.doctorCount as number);
|
||||
setOfflineEventCount(c.offlineEventCount as number);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -66,6 +69,7 @@ export function useStatsData(): StatsData {
|
||||
setHealthDataStats(c.healthDataStats as HealthDataStats | null);
|
||||
setDialysisStats(c.dialysisStats as DialysisStatistics | null);
|
||||
setDoctorCount(c.doctorCount as number);
|
||||
setOfflineEventCount(c.offlineEventCount as number);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -86,6 +90,7 @@ export function useStatsData(): StatsData {
|
||||
healthDataStats: null,
|
||||
dialysisStats: null,
|
||||
doctorCount: 0,
|
||||
offlineEventCount: 0,
|
||||
};
|
||||
|
||||
const tryFetch = async <T,>(fn: () => Promise<T>, key: string, label: string) => {
|
||||
@@ -110,14 +115,19 @@ export function useStatsData(): StatsData {
|
||||
'doctorCount',
|
||||
'医护',
|
||||
),
|
||||
tryFetch(
|
||||
async () => { const r = await pointsApi.listOfflineEvents({ page: 1, page_size: 1 }); return r.total; },
|
||||
'offlineEventCount',
|
||||
'线下活动',
|
||||
),
|
||||
]);
|
||||
|
||||
if (!hasAnyError || errors.length < 7) {
|
||||
if (!hasAnyError || errors.length < 8) {
|
||||
cachedStats = results;
|
||||
cachedAt = Date.now();
|
||||
}
|
||||
|
||||
if (hasAnyError && errors.length === 7) {
|
||||
if (hasAnyError && errors.length === 8) {
|
||||
setError('加载统计数据失败');
|
||||
}
|
||||
|
||||
@@ -133,6 +143,7 @@ export function useStatsData(): StatsData {
|
||||
setHealthDataStats(c.healthDataStats as HealthDataStats | null);
|
||||
setDialysisStats(c.dialysisStats as DialysisStatistics | null);
|
||||
setDoctorCount(c.doctorCount as number);
|
||||
setOfflineEventCount(c.offlineEventCount as number);
|
||||
} finally {
|
||||
fetchPromise = null;
|
||||
setLoading(false);
|
||||
@@ -144,7 +155,7 @@ export function useStatsData(): StatsData {
|
||||
}, [fetchAllStats]);
|
||||
|
||||
return {
|
||||
patientStats, consultationStats, followUpStats, pointsStats, healthDataStats, dialysisStats, doctorCount,
|
||||
patientStats, consultationStats, followUpStats, pointsStats, healthDataStats, dialysisStats, doctorCount, offlineEventCount,
|
||||
loading, error, refresh: fetchAllStats,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function ArticleEditor() {
|
||||
setCoverImage(article.cover_image || '');
|
||||
setSlug(article.slug || '');
|
||||
setCategoryId(article.category_id);
|
||||
setSelectedTagIds(article.tags?.map((t) => t.id) || []);
|
||||
setSelectedTagIds(article.tags?.map((t) => t.id).filter(Boolean) || []);
|
||||
setSortOrder(article.sort_order);
|
||||
setIsPublic(article.is_public ?? true);
|
||||
setVersion(article.version);
|
||||
@@ -230,7 +230,7 @@ export default function ArticleEditor() {
|
||||
cover_image: coverImage || undefined,
|
||||
slug: slug || undefined,
|
||||
category_id: categoryId,
|
||||
tag_ids: selectedTagIds,
|
||||
tag_ids: selectedTagIds.filter(Boolean),
|
||||
sort_order: sortOrder,
|
||||
is_public: isPublic,
|
||||
version,
|
||||
@@ -246,7 +246,7 @@ export default function ArticleEditor() {
|
||||
cover_image: coverImage || undefined,
|
||||
slug: slug || undefined,
|
||||
category_id: categoryId,
|
||||
tag_ids: selectedTagIds,
|
||||
tag_ids: selectedTagIds.filter(Boolean),
|
||||
sort_order: sortOrder,
|
||||
is_public: isPublic,
|
||||
});
|
||||
@@ -279,7 +279,7 @@ export default function ArticleEditor() {
|
||||
cover_image: coverImage || undefined,
|
||||
slug: slug || undefined,
|
||||
category_id: categoryId,
|
||||
tag_ids: selectedTagIds,
|
||||
tag_ids: selectedTagIds.filter(Boolean),
|
||||
sort_order: sortOrder,
|
||||
is_public: isPublic,
|
||||
version,
|
||||
@@ -295,7 +295,7 @@ export default function ArticleEditor() {
|
||||
cover_image: coverImage || undefined,
|
||||
slug: slug || undefined,
|
||||
category_id: categoryId,
|
||||
tag_ids: selectedTagIds,
|
||||
tag_ids: selectedTagIds.filter(Boolean),
|
||||
sort_order: sortOrder,
|
||||
is_public: isPublic,
|
||||
});
|
||||
|
||||
@@ -26,3 +26,4 @@ sha2.workspace = true
|
||||
redis.workspace = true
|
||||
hex.workspace = true
|
||||
regex-lite.workspace = true
|
||||
pdf-extract.workspace = true
|
||||
|
||||
31
crates/erp-ai/src/entity/ai_knowledge_bases.rs
Normal file
31
crates/erp-ai/src/entity/ai_knowledge_bases.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "ai_knowledge_bases")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub name: String,
|
||||
pub kb_type: String,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub chunk_strategy: serde_json::Value,
|
||||
pub intent_keywords: serde_json::Value,
|
||||
pub embedding_model: Option<String>,
|
||||
pub is_enabled: bool,
|
||||
pub document_count: i32,
|
||||
pub chunk_count: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Option<Uuid>,
|
||||
pub updated_by: Option<Uuid>,
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version_lock: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
34
crates/erp-ai/src/entity/ai_knowledge_chunks.rs
Normal file
34
crates/erp-ai/src/entity/ai_knowledge_chunks.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "ai_knowledge_chunks")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub knowledge_base_id: Uuid,
|
||||
pub document_id: Uuid,
|
||||
pub chunk_index: i32,
|
||||
pub content: String,
|
||||
pub token_count: Option<i32>,
|
||||
// pgvector 字段 — SeaORM 不原生支持 vector 类型,查询时用 raw SQL
|
||||
#[sea_orm(ignore)]
|
||||
pub embedding: Option<Vec<f32>>,
|
||||
pub start_offset: Option<i32>,
|
||||
pub end_offset: Option<i32>,
|
||||
pub page_number: Option<i32>,
|
||||
pub metadata: serde_json::Value,
|
||||
pub hit_count: i32,
|
||||
pub last_hit_at: Option<DateTimeUtc>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Option<Uuid>,
|
||||
pub updated_by: Option<Uuid>,
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
36
crates/erp-ai/src/entity/ai_knowledge_documents.rs
Normal file
36
crates/erp-ai/src/entity/ai_knowledge_documents.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "ai_knowledge_documents")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub knowledge_base_id: Uuid,
|
||||
pub title: String,
|
||||
pub doc_type: String,
|
||||
pub source_type: String,
|
||||
pub source_url: Option<String>,
|
||||
pub file_name: Option<String>,
|
||||
pub file_size: Option<i64>,
|
||||
pub file_mime_type: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub status: String,
|
||||
pub chunk_count: i32,
|
||||
pub embedded_count: i32,
|
||||
pub error_message: Option<String>,
|
||||
pub processing_started_at: Option<DateTimeUtc>,
|
||||
pub processing_completed_at: Option<DateTimeUtc>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Option<Uuid>,
|
||||
pub updated_by: Option<Uuid>,
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version_lock: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -16,6 +16,8 @@ pub struct Model {
|
||||
pub version: i32,
|
||||
pub is_active: bool,
|
||||
pub category: String,
|
||||
/// 后端选择键:与 AnalysisType::prompt_name() 对应,handler 按此字段查找激活 Prompt
|
||||
pub analysis_type: String,
|
||||
pub tags: Option<serde_json::Value>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
|
||||
@@ -3,6 +3,9 @@ pub mod ai_analysis_queue;
|
||||
pub mod ai_chat_message;
|
||||
pub mod ai_chat_session;
|
||||
pub mod ai_feature_flags;
|
||||
pub mod ai_knowledge_bases;
|
||||
pub mod ai_knowledge_chunks;
|
||||
pub mod ai_knowledge_documents;
|
||||
pub mod ai_knowledge_guides;
|
||||
pub mod ai_knowledge_references;
|
||||
pub mod ai_knowledge_rules;
|
||||
|
||||
@@ -240,6 +240,14 @@ where
|
||||
let provider_name = provider_arc.name().to_string();
|
||||
let supports_fc = provider_name != "ollama"; // Ollama generate_with_tools 未实现
|
||||
|
||||
// 收集 token 和 display_hints
|
||||
#[allow(unused_assignments)]
|
||||
let mut input_tokens: u32 = 0;
|
||||
#[allow(unused_assignments)]
|
||||
let mut output_tokens: u32 = 0;
|
||||
let mut duration_ms: u64 = 0;
|
||||
let mut collected_hints: Option<Vec<crate::agent::tool::DisplayHint>> = None;
|
||||
|
||||
let result = if supports_fc {
|
||||
// FC provider:执行完整 Agent ReAct 循环
|
||||
let orchestrator = AgentOrchestrator::new(provider_arc, std::sync::Arc::new(registry));
|
||||
@@ -256,6 +264,11 @@ where
|
||||
tracing::error!(error = %e, "AI Agent run failed");
|
||||
erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into())
|
||||
})?;
|
||||
input_tokens = agent_result.total_input_tokens;
|
||||
output_tokens = agent_result.total_output_tokens;
|
||||
if !agent_result.display_hints.is_empty() {
|
||||
collected_hints = Some(agent_result.display_hints);
|
||||
}
|
||||
agent_result.reply
|
||||
} else {
|
||||
// 非 FC provider:降级为普通对话
|
||||
@@ -279,6 +292,9 @@ where
|
||||
tracing::error!(error = %e, "AI generate failed");
|
||||
erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into())
|
||||
})?;
|
||||
input_tokens = resp.input_tokens;
|
||||
output_tokens = resp.output_tokens;
|
||||
duration_ms = resp.duration_ms;
|
||||
resp.content
|
||||
};
|
||||
|
||||
@@ -297,7 +313,7 @@ where
|
||||
"AI chat response sent"
|
||||
);
|
||||
|
||||
// 记录用量的 token 消耗(简化模式下无法精确计量,记 0)
|
||||
// 记录用量的 token 消耗
|
||||
if let Err(e) = ai_state
|
||||
.usage
|
||||
.log_usage(
|
||||
@@ -305,9 +321,9 @@ where
|
||||
&provider_name,
|
||||
&run_params.model,
|
||||
"chat",
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
duration_ms,
|
||||
0,
|
||||
false,
|
||||
)
|
||||
@@ -362,7 +378,7 @@ where
|
||||
reply,
|
||||
message_id,
|
||||
iterations: if supports_fc { 1 } else { 0 },
|
||||
display_hints: None,
|
||||
display_hints: collected_hints,
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
307
crates/erp-ai/src/handler/document_handler.rs
Normal file
307
crates/erp-ai/src/handler/document_handler.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, FromRef, Multipart, Path, State};
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::service::document::{CreateDocumentReq, ListDocumentsQuery, UploadDocumentParams};
|
||||
use crate::state::AiState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListDocumentsParams {
|
||||
pub kb_id: uuid::Uuid,
|
||||
pub status: Option<String>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ai/knowledge-bases/{kb_id}/documents",
|
||||
responses((status = 200, description = "文档列表")),
|
||||
tag = "知识库文档",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn list_documents<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(kb_id): Path<uuid::Uuid>,
|
||||
axum::extract::Query(params): axum::extract::Query<ListDocumentsParamsNoKb>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.list")?;
|
||||
|
||||
let query = ListDocumentsQuery {
|
||||
status: params.status,
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
};
|
||||
let (items, total) = state
|
||||
.document
|
||||
.list_documents(ctx.tenant_id, kb_id, &query)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": items,
|
||||
"total": total,
|
||||
"page": query.page.unwrap_or(1),
|
||||
"page_size": query.page_size.unwrap_or(20),
|
||||
}))))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListDocumentsParamsNoKb {
|
||||
pub status: Option<String>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ai/documents/{id}",
|
||||
responses((status = 200, description = "文档详情")),
|
||||
tag = "知识库文档",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn get_document<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.list")?;
|
||||
let doc = state.document.get_document(ctx.tenant_id, id).await?;
|
||||
Ok(Json(ApiResponse::ok(
|
||||
serde_json::to_value(&doc).unwrap_or_default(),
|
||||
)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CreateDocumentBody {
|
||||
pub kb_id: uuid::Uuid,
|
||||
pub title: String,
|
||||
pub doc_type: Option<String>,
|
||||
pub source_type: Option<String>,
|
||||
pub source_url: Option<String>,
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/ai/documents/manual",
|
||||
request_body = CreateDocumentBody,
|
||||
responses((status = 200, description = "创建手动文档")),
|
||||
tag = "知识库文档",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn create_manual_document<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<CreateDocumentBody>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.manage")?;
|
||||
|
||||
if body.title.trim().is_empty() {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"文档标题不能为空".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let req = CreateDocumentReq {
|
||||
title: body.title,
|
||||
doc_type: body.doc_type,
|
||||
source_type: body.source_type,
|
||||
source_url: body.source_url,
|
||||
content: body.content,
|
||||
};
|
||||
|
||||
let id = state
|
||||
.document
|
||||
.create_manual_document(ctx.tenant_id, ctx.user_id, body.kb_id, req)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE: usize = 20 * 1024 * 1024; // 20MB
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/ai/documents/upload",
|
||||
responses((status = 200, description = "上传文档文件")),
|
||||
tag = "知识库文档",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn upload_document<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.manage")?;
|
||||
|
||||
let mut kb_id: Option<uuid::Uuid> = None;
|
||||
let mut title: Option<String> = None;
|
||||
let mut file_data: Option<(String, String, Vec<u8>)> = None; // (filename, mime, bytes)
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| erp_core::error::AppError::Validation(format!("读取上传字段失败: {}", e)))?
|
||||
{
|
||||
let field_name = field.name().unwrap_or("").to_string();
|
||||
|
||||
match field_name.as_str() {
|
||||
"kb_id" => {
|
||||
let text = field.text().await.map_err(|e| {
|
||||
erp_core::error::AppError::Validation(format!("读取 kb_id 失败: {}", e))
|
||||
})?;
|
||||
kb_id =
|
||||
Some(uuid::Uuid::parse_str(&text).map_err(|_| {
|
||||
erp_core::error::AppError::Validation("kb_id 格式错误".into())
|
||||
})?);
|
||||
}
|
||||
"title" => {
|
||||
let text = field.text().await.map_err(|e| {
|
||||
erp_core::error::AppError::Validation(format!("读取 title 失败: {}", e))
|
||||
})?;
|
||||
title = Some(text);
|
||||
}
|
||||
"file" => {
|
||||
let file_name = field.file_name().unwrap_or("unknown").to_string();
|
||||
let content_type = field
|
||||
.content_type()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let bytes = field.bytes().await.map_err(|e| {
|
||||
erp_core::error::AppError::Validation(format!("读取文件失败: {}", e))
|
||||
})?;
|
||||
|
||||
if bytes.len() > MAX_FILE_SIZE {
|
||||
return Err(erp_core::error::AppError::Validation(format!(
|
||||
"文件大小超过限制 (最大 {}MB)",
|
||||
MAX_FILE_SIZE / 1024 / 1024
|
||||
)));
|
||||
}
|
||||
|
||||
file_data = Some((file_name, content_type, bytes.to_vec()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let kb_id =
|
||||
kb_id.ok_or_else(|| erp_core::error::AppError::Validation("缺少 kb_id 字段".into()))?;
|
||||
let (file_name, mime_type, bytes) =
|
||||
file_data.ok_or_else(|| erp_core::error::AppError::Validation("缺少 file 字段".into()))?;
|
||||
|
||||
let doc_title = title.unwrap_or_else(|| file_name.clone());
|
||||
|
||||
// 解析文档内容
|
||||
let content = crate::service::document::parser::parse_document(&file_name, &mime_type, &bytes)
|
||||
.map_err(|e| erp_core::error::AppError::Validation(format!("文档解析失败: {}", e)))?;
|
||||
|
||||
let params = UploadDocumentParams {
|
||||
file_name,
|
||||
file_size: bytes.len() as i64,
|
||||
mime_type,
|
||||
content,
|
||||
};
|
||||
|
||||
let id = state
|
||||
.document
|
||||
.create_upload_document(ctx.tenant_id, ctx.user_id, kb_id, doc_title, params)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/ai/knowledge-bases/{kb_id}/documents/{id}",
|
||||
responses((status = 200, description = "删除文档")),
|
||||
tag = "知识库文档",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn delete_document<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((kb_id, id)): Path<(uuid::Uuid, uuid::Uuid)>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.manage")?;
|
||||
state
|
||||
.document
|
||||
.delete_document(ctx.tenant_id, kb_id, id)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct HitTestBody {
|
||||
pub kb_id: uuid::Uuid,
|
||||
pub query: String,
|
||||
pub top_k: Option<i64>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/ai/documents/hit-test",
|
||||
request_body = HitTestBody,
|
||||
responses((status = 200, description = "向量搜索 hit test")),
|
||||
tag = "知识库文档",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn hit_test<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<HitTestBody>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.list")?;
|
||||
|
||||
if body.query.trim().is_empty() {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"搜索查询不能为空".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// 生成 query embedding
|
||||
let embedding =
|
||||
state.embedding.embed(&body.query).await.map_err(|e| {
|
||||
erp_core::error::AppError::Internal(format!("Embedding 生成失败: {}", e))
|
||||
})?;
|
||||
|
||||
let top_k = body.top_k.unwrap_or(5).min(20);
|
||||
|
||||
let hits = state
|
||||
.knowledge_v2
|
||||
.vector_search(ctx.tenant_id, body.kb_id, &embedding, top_k)
|
||||
.await
|
||||
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"query": body.query,
|
||||
"total": hits.len(),
|
||||
"hits": hits,
|
||||
}))))
|
||||
}
|
||||
172
crates/erp-ai/src/handler/knowledge_v2_handler.rs
Normal file
172
crates/erp-ai/src/handler/knowledge_v2_handler.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, FromRef, Path, State};
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::service::knowledge_v2::{
|
||||
CreateKnowledgeBaseReq, ListKnowledgeBasesQuery, UpdateKnowledgeBaseReq,
|
||||
};
|
||||
use crate::state::AiState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListKnowledgeBasesParams {
|
||||
pub kb_type: Option<String>,
|
||||
pub is_enabled: Option<bool>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ai/knowledge-bases",
|
||||
responses((status = 200, description = "知识库列表")),
|
||||
tag = "知识库V2",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn list_knowledge_bases<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
axum::extract::Query(params): axum::extract::Query<ListKnowledgeBasesParams>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.list")?;
|
||||
|
||||
let query = ListKnowledgeBasesQuery {
|
||||
kb_type: params.kb_type,
|
||||
is_enabled: params.is_enabled,
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
};
|
||||
let (items, total) = state.knowledge_v2.list(ctx.tenant_id, &query).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": items,
|
||||
"total": total,
|
||||
"page": query.page.unwrap_or(1),
|
||||
"page_size": query.page_size.unwrap_or(20),
|
||||
}))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ai/knowledge-bases/{id}",
|
||||
responses((status = 200, description = "知识库详情")),
|
||||
tag = "知识库V2",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn get_knowledge_base<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.list")?;
|
||||
let kb = state.knowledge_v2.get_by_id(ctx.tenant_id, id).await?;
|
||||
Ok(Json(ApiResponse::ok(
|
||||
serde_json::to_value(&kb).unwrap_or_default(),
|
||||
)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/ai/knowledge-bases",
|
||||
request_body = CreateKnowledgeBaseReq,
|
||||
responses((status = 200, description = "创建知识库")),
|
||||
tag = "知识库V2",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn create_knowledge_base<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<CreateKnowledgeBaseReq>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.manage")?;
|
||||
|
||||
if body.name.trim().is_empty() {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"知识库名称不能为空".into(),
|
||||
));
|
||||
}
|
||||
if body.kb_type.trim().is_empty() {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"知识库类型不能为空".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let id = state
|
||||
.knowledge_v2
|
||||
.create(ctx.tenant_id, ctx.user_id, body)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/ai/knowledge-bases/{id}",
|
||||
request_body = UpdateKnowledgeBaseReq,
|
||||
responses((status = 200, description = "更新知识库")),
|
||||
tag = "知识库V2",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn update_knowledge_base<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
Json(body): Json<UpdateKnowledgeBaseReq>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.manage")?;
|
||||
|
||||
if let Some(ref name) = body.name
|
||||
&& name.trim().is_empty()
|
||||
{
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"知识库名称不能为空".into(),
|
||||
));
|
||||
}
|
||||
|
||||
state
|
||||
.knowledge_v2
|
||||
.update(ctx.tenant_id, ctx.user_id, id, body)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/ai/knowledge-bases/{id}",
|
||||
responses((status = 200, description = "删除知识库")),
|
||||
tag = "知识库V2",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn delete_knowledge_base<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.manage")?;
|
||||
|
||||
state.knowledge_v2.delete(ctx.tenant_id, id).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
|
||||
}
|
||||
@@ -14,8 +14,10 @@ use crate::state::AiState;
|
||||
|
||||
pub mod chat_handler;
|
||||
pub mod config_handler;
|
||||
pub mod document_handler;
|
||||
pub mod insight_handler;
|
||||
pub mod knowledge_handler;
|
||||
pub mod knowledge_v2_handler;
|
||||
pub mod risk_handler;
|
||||
pub mod rule_handler;
|
||||
pub mod suggestion_handler;
|
||||
@@ -95,7 +97,7 @@ where
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
.get_active_prompt(ctx.tenant_id, "lab_report_interpretation")
|
||||
.get_active_prompt(ctx.tenant_id, AnalysisType::LabReport.prompt_name())
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
@@ -190,7 +192,7 @@ where
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
.get_active_prompt(ctx.tenant_id, "health_trend_analysis")
|
||||
.get_active_prompt(ctx.tenant_id, AnalysisType::Trends.prompt_name())
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
@@ -262,7 +264,7 @@ where
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
.get_active_prompt(ctx.tenant_id, "personalized_checkup_plan")
|
||||
.get_active_prompt(ctx.tenant_id, AnalysisType::CheckupPlan.prompt_name())
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
@@ -341,7 +343,7 @@ where
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
.get_active_prompt(ctx.tenant_id, "report_summary_generation")
|
||||
.get_active_prompt(ctx.tenant_id, AnalysisType::ReportSummary.prompt_name())
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
@@ -417,7 +419,7 @@ where
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
.get_active_prompt(ctx.tenant_id, "follow_up_summary_generation")
|
||||
.get_active_prompt(ctx.tenant_id, AnalysisType::FollowUpSummary.prompt_name())
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
@@ -577,6 +579,7 @@ where
|
||||
#[derive(Debug, Deserialize, utoipa::IntoParams)]
|
||||
pub struct ListPromptsQuery {
|
||||
pub category: Option<String>,
|
||||
pub analysis_type: Option<String>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
@@ -605,7 +608,11 @@ where
|
||||
};
|
||||
let (items, total) = state
|
||||
.prompt
|
||||
.list_prompts(ctx.tenant_id, params.category, &pagination)
|
||||
.list_prompts(
|
||||
ctx.tenant_id,
|
||||
params.analysis_type.or(params.category),
|
||||
&pagination,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": items,
|
||||
@@ -623,6 +630,7 @@ pub struct CreatePromptBody {
|
||||
pub user_prompt_template: String,
|
||||
pub model_config: serde_json::Value,
|
||||
pub category: String,
|
||||
pub analysis_type: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -655,6 +663,7 @@ where
|
||||
body.user_prompt_template,
|
||||
body.model_config,
|
||||
body.category,
|
||||
body.analysis_type,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
@@ -702,6 +711,48 @@ where
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/ai/prompts/{id}/deactivate",
|
||||
responses((status = 200, description = "停用 Prompt 模板")),
|
||||
tag = "AI Prompt",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn deactivate_prompt<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<crate::entity::ai_prompt::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.manage")?;
|
||||
let prompt = state.prompt.deactivate_prompt(id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/ai/prompts/{id}",
|
||||
responses((status = 200, description = "删除 Prompt 模板")),
|
||||
tag = "AI Prompt",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn delete_prompt<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.manage")?;
|
||||
state.prompt.delete_prompt(id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
// === 用量统计 ===
|
||||
|
||||
#[utoipa::path(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod structured_source;
|
||||
pub mod v2_source;
|
||||
pub mod vector_search;
|
||||
pub mod vector_source;
|
||||
|
||||
|
||||
166
crates/erp-ai/src/knowledge/v2_source.rs
Normal file
166
crates/erp-ai/src/knowledge/v2_source.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use async_trait::async_trait;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{KnowledgeContext, KnowledgeQuery, KnowledgeSource, Reference};
|
||||
use crate::error::AiResult;
|
||||
use crate::service::embedding::EmbeddingService;
|
||||
use crate::service::knowledge_v2::KnowledgeV2Service;
|
||||
|
||||
/// 知识库 V2 向量检索源 — 基于 ai_knowledge_chunks + pgvector
|
||||
pub struct KnowledgeV2Source {
|
||||
db: DatabaseConnection,
|
||||
knowledge_v2: Arc<KnowledgeV2Service>,
|
||||
embedding: Arc<EmbeddingService>,
|
||||
}
|
||||
|
||||
impl KnowledgeV2Source {
|
||||
pub fn new(
|
||||
db: DatabaseConnection,
|
||||
knowledge_v2: Arc<KnowledgeV2Service>,
|
||||
embedding: Arc<EmbeddingService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
db,
|
||||
knowledge_v2,
|
||||
embedding,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl KnowledgeSource for KnowledgeV2Source {
|
||||
async fn get_context(&self, query: &KnowledgeQuery) -> AiResult<KnowledgeContext> {
|
||||
let query_text = match &query.query_text {
|
||||
Some(t) if !t.trim().is_empty() => t.clone(),
|
||||
_ => {
|
||||
return Ok(KnowledgeContext {
|
||||
source: "knowledge_v2".into(),
|
||||
context_text: String::new(),
|
||||
references: vec![],
|
||||
confidence: 0.0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if !self.embedding.is_configured() {
|
||||
return Ok(KnowledgeContext {
|
||||
source: "knowledge_v2".into(),
|
||||
context_text: String::new(),
|
||||
references: vec![],
|
||||
confidence: 0.0,
|
||||
});
|
||||
}
|
||||
|
||||
// 查找租户下所有启用的知识库
|
||||
let kb_ids = get_enabled_kb_ids(&self.db, query.tenant_id).await?;
|
||||
|
||||
if kb_ids.is_empty() {
|
||||
return Ok(KnowledgeContext {
|
||||
source: "knowledge_v2".into(),
|
||||
context_text: String::new(),
|
||||
references: vec![],
|
||||
confidence: 0.0,
|
||||
});
|
||||
}
|
||||
|
||||
let embedding = match self.embedding.embed(&query_text).await {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "KnowledgeV2 Source embedding 失败");
|
||||
return Ok(KnowledgeContext {
|
||||
source: "knowledge_v2".into(),
|
||||
context_text: String::new(),
|
||||
references: vec![],
|
||||
confidence: 0.0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 在所有知识库中搜索,取最佳结果
|
||||
let mut all_hits = Vec::new();
|
||||
for kb_id in &kb_ids {
|
||||
if let Ok(hits) = self
|
||||
.knowledge_v2
|
||||
.vector_search(query.tenant_id, *kb_id, &embedding, 5)
|
||||
.await
|
||||
{
|
||||
all_hits.extend(hits);
|
||||
}
|
||||
}
|
||||
|
||||
// 按相似度排序,取 top 10
|
||||
all_hits.sort_by(|a, b| {
|
||||
b.similarity
|
||||
.partial_cmp(&a.similarity)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
all_hits.truncate(10);
|
||||
|
||||
if all_hits.is_empty() {
|
||||
return Ok(KnowledgeContext {
|
||||
source: "knowledge_v2".into(),
|
||||
context_text: String::new(),
|
||||
references: vec![],
|
||||
confidence: 0.0,
|
||||
});
|
||||
}
|
||||
|
||||
let max_confidence = all_hits[0].similarity as f32;
|
||||
|
||||
let context_parts: Vec<String> = all_hits
|
||||
.iter()
|
||||
.map(|h| {
|
||||
format!(
|
||||
"[文档: {} | 相似度: {:.2}]\n{}",
|
||||
h.doc_title, h.similarity, h.content
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let references: Vec<Reference> = all_hits
|
||||
.iter()
|
||||
.map(|h| Reference {
|
||||
title: h.doc_title.clone(),
|
||||
source: format!("chunk_{}", h.chunk_index),
|
||||
relevance_score: h.similarity as f32,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(KnowledgeContext {
|
||||
source: "knowledge_v2".into(),
|
||||
context_text: context_parts.join("\n\n"),
|
||||
references,
|
||||
confidence: max_confidence,
|
||||
})
|
||||
}
|
||||
|
||||
fn source_type(&self) -> &str {
|
||||
"knowledge_v2"
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> AiResult<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_enabled_kb_ids(db: &DatabaseConnection, tenant_id: Uuid) -> AiResult<Vec<Uuid>> {
|
||||
#[derive(sea_orm::FromQueryResult)]
|
||||
struct KbIdRow {
|
||||
id: Uuid,
|
||||
}
|
||||
|
||||
let results: Vec<KbIdRow> = sea_orm::FromQueryResult::find_by_statement(
|
||||
sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"SELECT id FROM ai_knowledge_bases WHERE tenant_id = $1 AND is_enabled = true AND deleted_at IS NULL",
|
||||
[sea_orm::Value::from(tenant_id)],
|
||||
),
|
||||
)
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e: sea_orm::DbErr| crate::error::AiError::DbError(e.to_string()))?;
|
||||
|
||||
Ok(results.into_iter().map(|r| r.id).collect())
|
||||
}
|
||||
@@ -511,6 +511,14 @@ impl AiModule {
|
||||
"/ai/prompts/{id}/rollback",
|
||||
axum::routing::post(crate::handler::rollback_prompt),
|
||||
)
|
||||
.route(
|
||||
"/ai/prompts/{id}/deactivate",
|
||||
axum::routing::post(crate::handler::deactivate_prompt),
|
||||
)
|
||||
.route(
|
||||
"/ai/prompts/{id}",
|
||||
axum::routing::delete(crate::handler::delete_prompt),
|
||||
)
|
||||
.route(
|
||||
"/ai/usage/overview",
|
||||
axum::routing::get(crate::handler::usage_overview),
|
||||
@@ -580,6 +588,52 @@ impl AiModule {
|
||||
"/ai/knowledge/guides/{id}/re-embed",
|
||||
axum::routing::post(crate::handler::knowledge_handler::re_embed_guide),
|
||||
)
|
||||
// 知识库 V2 路由
|
||||
.route(
|
||||
"/ai/knowledge-bases",
|
||||
axum::routing::get(crate::handler::knowledge_v2_handler::list_knowledge_bases),
|
||||
)
|
||||
.route(
|
||||
"/ai/knowledge-bases",
|
||||
axum::routing::post(crate::handler::knowledge_v2_handler::create_knowledge_base),
|
||||
)
|
||||
.route(
|
||||
"/ai/knowledge-bases/{id}",
|
||||
axum::routing::get(crate::handler::knowledge_v2_handler::get_knowledge_base),
|
||||
)
|
||||
.route(
|
||||
"/ai/knowledge-bases/{id}",
|
||||
axum::routing::put(crate::handler::knowledge_v2_handler::update_knowledge_base),
|
||||
)
|
||||
.route(
|
||||
"/ai/knowledge-bases/{id}",
|
||||
axum::routing::delete(crate::handler::knowledge_v2_handler::delete_knowledge_base),
|
||||
)
|
||||
// 文档管理路由
|
||||
.route(
|
||||
"/ai/knowledge-bases/{kb_id}/documents",
|
||||
axum::routing::get(crate::handler::document_handler::list_documents),
|
||||
)
|
||||
.route(
|
||||
"/ai/documents/manual",
|
||||
axum::routing::post(crate::handler::document_handler::create_manual_document),
|
||||
)
|
||||
.route(
|
||||
"/ai/documents/upload",
|
||||
axum::routing::post(crate::handler::document_handler::upload_document),
|
||||
)
|
||||
.route(
|
||||
"/ai/documents/{id}",
|
||||
axum::routing::get(crate::handler::document_handler::get_document),
|
||||
)
|
||||
.route(
|
||||
"/ai/documents/hit-test",
|
||||
axum::routing::post(crate::handler::document_handler::hit_test),
|
||||
)
|
||||
.route(
|
||||
"/ai/knowledge-bases/{kb_id}/documents/{id}",
|
||||
axum::routing::delete(crate::handler::document_handler::delete_document),
|
||||
)
|
||||
.route(
|
||||
"/ai/dialysis/risk-assessment",
|
||||
axum::routing::post(crate::handler::assess_dialysis_risk),
|
||||
|
||||
@@ -21,7 +21,7 @@ pub struct AnalysisService {
|
||||
pub sanitizer: SanitizationService,
|
||||
pub renderer: PromptRenderer,
|
||||
pub db: sea_orm::DatabaseConnection,
|
||||
pub knowledge_source: Option<std::sync::Arc<dyn KnowledgeSource>>,
|
||||
pub knowledge_sources: Vec<std::sync::Arc<dyn KnowledgeSource>>,
|
||||
}
|
||||
|
||||
impl AnalysisService {
|
||||
@@ -34,12 +34,12 @@ impl AnalysisService {
|
||||
sanitizer: SanitizationService::new(),
|
||||
renderer: PromptRenderer::new(),
|
||||
db,
|
||||
knowledge_source: None,
|
||||
knowledge_sources: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_knowledge_source(mut self, source: std::sync::Arc<dyn KnowledgeSource>) -> Self {
|
||||
self.knowledge_source = Some(source);
|
||||
self.knowledge_sources.push(source);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -100,42 +100,47 @@ impl AnalysisService {
|
||||
例如:\"根据临床指南 [ref:uuid-of-guideline],建议...\"\n\
|
||||
每个引用的知识库条目必须在回答中标注。如果没有引用任何知识库条目,则无需标注。";
|
||||
|
||||
let system_prompt = if let Some(ref ks) = self.knowledge_source {
|
||||
let system_prompt = if !self.knowledge_sources.is_empty() {
|
||||
let query = crate::knowledge::KnowledgeQuery {
|
||||
tenant_id,
|
||||
analysis_type: analysis_type.as_str().to_string(),
|
||||
patient_context: None,
|
||||
query_text: None,
|
||||
};
|
||||
match ks.get_context(&query).await {
|
||||
Ok(ctx) if ctx.confidence > 0.0 => {
|
||||
tracing::info!(
|
||||
source = %ctx.source,
|
||||
confidence = ctx.confidence,
|
||||
"知识库上下文注入"
|
||||
);
|
||||
// 将引用的来源 ID 附加到上下文中
|
||||
let refs_info = if ctx.references.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let refs_list: Vec<String> = ctx
|
||||
.references
|
||||
.iter()
|
||||
.map(|r| format!("- {} (ID: {})", r.title, r.source))
|
||||
.collect();
|
||||
format!("\n\n可用引用源:\n{}", refs_list.join("\n"))
|
||||
};
|
||||
format!(
|
||||
"{}\n\n=== 知识库参考 ===\n{}{}{}",
|
||||
system_prompt, ctx.context_text, refs_info, citation_instruction
|
||||
)
|
||||
}
|
||||
Ok(_) => system_prompt,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "知识库查询失败,跳过注入");
|
||||
system_prompt
|
||||
let mut best_ctx: Option<crate::knowledge::KnowledgeContext> = None;
|
||||
for ks in &self.knowledge_sources {
|
||||
if let Ok(ctx) = ks.get_context(&query).await
|
||||
&& ctx.confidence > 0.0
|
||||
{
|
||||
match &best_ctx {
|
||||
Some(bc) if bc.confidence >= ctx.confidence => {}
|
||||
_ => best_ctx = Some(ctx),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ctx) = best_ctx {
|
||||
tracing::info!(
|
||||
source = %ctx.source,
|
||||
confidence = ctx.confidence,
|
||||
"知识库上下文注入"
|
||||
);
|
||||
let refs_info = if ctx.references.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let refs_list: Vec<String> = ctx
|
||||
.references
|
||||
.iter()
|
||||
.map(|r| format!("- {} (ID: {})", r.title, r.source))
|
||||
.collect();
|
||||
format!("\n\n可用引用源:\n{}", refs_list.join("\n"))
|
||||
};
|
||||
format!(
|
||||
"{}\n\n=== 知识库参考 ===\n{}{}{}",
|
||||
system_prompt, ctx.context_text, refs_info, citation_instruction
|
||||
)
|
||||
} else {
|
||||
system_prompt
|
||||
}
|
||||
} else {
|
||||
// 无知识库时也添加引用指令(供通用场景使用)
|
||||
format!("{}{}", system_prompt, citation_instruction)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, FromQueryResult, Set, Statement};
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, FromQueryResult, Set, Statement, TransactionTrait};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::ai_analysis_queue;
|
||||
@@ -93,43 +93,74 @@ impl AnalysisQueue {
|
||||
&self,
|
||||
tenant_id: Option<Uuid>,
|
||||
) -> AiResult<Option<ai_analysis_queue::Model>> {
|
||||
let sql = match tenant_id {
|
||||
Some(tid) => format!(
|
||||
"SELECT * FROM ai_analysis_queue WHERE tenant_id = '{}' AND status = 'pending' AND deleted_at IS NULL AND scheduled_at <= NOW() ORDER BY priority DESC, scheduled_at ASC LIMIT 1",
|
||||
tid
|
||||
),
|
||||
None => r#"
|
||||
SELECT * FROM ai_analysis_queue
|
||||
WHERE status = 'pending'
|
||||
AND deleted_at IS NULL
|
||||
AND scheduled_at <= NOW()
|
||||
ORDER BY priority DESC, scheduled_at ASC
|
||||
LIMIT 1
|
||||
"#
|
||||
.to_string(),
|
||||
};
|
||||
// 事务内 SELECT ... FOR UPDATE SKIP LOCKED + UPDATE:
|
||||
// - 参数化($1)消除原 format! 拼 tenant_id 的 SQL 注入风险
|
||||
// - FOR UPDATE SKIP LOCKED 在事务内持行锁到 UPDATE 完成,防多消费者并发重复 claim
|
||||
let claimed = self
|
||||
.db
|
||||
.transaction::<_, Option<ai_analysis_queue::Model>, AiError>(|txn| {
|
||||
Box::pin(async move {
|
||||
let row: Option<QueueRow> = match tenant_id {
|
||||
Some(tid) => {
|
||||
QueueRow::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"SELECT * FROM ai_analysis_queue
|
||||
WHERE tenant_id = $1
|
||||
AND status = 'pending'
|
||||
AND deleted_at IS NULL
|
||||
AND scheduled_at <= NOW()
|
||||
ORDER BY priority DESC, scheduled_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED"#,
|
||||
[tid.into()],
|
||||
))
|
||||
.one(txn)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
QueueRow::find_by_statement(Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"SELECT * FROM ai_analysis_queue
|
||||
WHERE status = 'pending'
|
||||
AND deleted_at IS NULL
|
||||
AND scheduled_at <= NOW()
|
||||
ORDER BY priority DESC, scheduled_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED"#
|
||||
.to_string(),
|
||||
))
|
||||
.one(txn)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let row: Option<QueueRow> = QueueRow::find_by_statement(Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql.to_string(),
|
||||
))
|
||||
.one(&self.db)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
let now = chrono::Utc::now();
|
||||
let mut active: ai_analysis_queue::ActiveModel =
|
||||
self.find_by_id(r.id).await?.into();
|
||||
active.status = Set("running".to_string());
|
||||
active.started_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
|
||||
let model = active.update(&self.db).await?;
|
||||
Ok(Some(model))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
match row {
|
||||
Some(r) => {
|
||||
let now = chrono::Utc::now();
|
||||
let model = ai_analysis_queue::Entity::find_by_id(r.id)
|
||||
.one(txn)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AiError::QueueError(format!("队列任务 {} 未找到", r.id))
|
||||
})?;
|
||||
let mut active: ai_analysis_queue::ActiveModel = model.into();
|
||||
active.status = Set("running".to_string());
|
||||
active.started_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
|
||||
let updated = active.update(txn).await?;
|
||||
Ok(Some(updated))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sea_orm::TransactionError::Connection(d) => d.into(),
|
||||
sea_orm::TransactionError::Transaction(a) => a,
|
||||
})?;
|
||||
Ok(claimed)
|
||||
}
|
||||
|
||||
pub async fn mark_completed(&self, id: Uuid, result_analysis_id: Uuid) -> AiResult<()> {
|
||||
|
||||
321
crates/erp-ai/src/service/analysis_worker.rs
Normal file
321
crates/erp-ai/src/service/analysis_worker.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
//! AI 分析队列消费者 — 把 pending 队列任务驱动到 completed/failed。
|
||||
//!
|
||||
//! `module.rs` 的事件入队 + `auto_analysis.rs` 的定时入队把任务写入 `ai_analysis_queue`,
|
||||
//! 但 `claim_next` 此前无人调用,所有任务永远 pending(违反「每个事件必须有消费者」铁律)。
|
||||
//!
|
||||
//! 本 worker 在后台循环 claim → 路由处理 → mark_completed / mark_failed,
|
||||
//! 把 erp-health 触发的分析链路真正打通(MVP 聚焦趋势分析 trend,其他类型暂 skip)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use erp_core::health_provider::TimeRange;
|
||||
use futures::StreamExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::AnalysisType;
|
||||
use crate::entity::ai_analysis_queue;
|
||||
use crate::error::{AiError, AiResult};
|
||||
use crate::service::analysis_queue::AnalysisQueue;
|
||||
use crate::state::AiState;
|
||||
|
||||
/// 轮询间隔:无任务时休眠 10 秒避免空转
|
||||
const IDLE_SLEEP: Duration = Duration::from_secs(10);
|
||||
|
||||
/// 启动 AI 分析队列消费者(后台 tokio 任务)。
|
||||
///
|
||||
/// 不阻塞调用方:`tokio::spawn` 后立即返回。
|
||||
/// 在 `erp-server/src/main.rs` 中与 `start_auto_analysis` 一起启动。
|
||||
pub fn start_analysis_worker(state: AiState) {
|
||||
tokio::spawn(async move {
|
||||
tracing::info!("AI 分析队列消费者已启动(轮询间隔 {:?})", IDLE_SLEEP);
|
||||
loop {
|
||||
match process_once(&state).await {
|
||||
Ok(Processed) => {
|
||||
// 立即尝试下一个任务,不等待
|
||||
}
|
||||
Ok(Idle) => {
|
||||
tokio::time::sleep(IDLE_SLEEP).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "分析队列消费循环异常,休眠后重试");
|
||||
tokio::time::sleep(IDLE_SLEEP).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
enum ProcessOutcome {
|
||||
/// 成功处理了一个任务(或已路由 skip),立刻尝试下一个
|
||||
Processed,
|
||||
/// 队列空,进入休眠
|
||||
Idle,
|
||||
}
|
||||
|
||||
use ProcessOutcome::{Idle, Processed};
|
||||
|
||||
async fn process_once(state: &AiState) -> AiResult<ProcessOutcome> {
|
||||
let queue = AnalysisQueue::new(state.db.clone());
|
||||
let job = match queue.claim_next(None).await? {
|
||||
Some(j) => j,
|
||||
None => return Ok(Idle),
|
||||
};
|
||||
|
||||
let job_id = job.id;
|
||||
tracing::info!(
|
||||
job_id = %job_id,
|
||||
tenant_id = %job.tenant_id,
|
||||
patient_id = %job.patient_id,
|
||||
analysis_type = %job.analysis_type,
|
||||
source_event = ?job.source_event,
|
||||
"已领取分析队列任务,开始处理"
|
||||
);
|
||||
|
||||
match job.analysis_type.as_str() {
|
||||
"trend" => handle_trend(state, &queue, job).await,
|
||||
other => {
|
||||
// MVP 阶段:非 trend 类型暂不支持自动消费。
|
||||
// 不写假数据,不标 completed(保留 pending 等未来扩展消费者),
|
||||
// 只记日志后回滚 running → pending 让任务可被未来的处理器接手。
|
||||
tracing::info!(
|
||||
job_id = %job_id,
|
||||
analysis_type = %other,
|
||||
"MVP 暂不支持的分析类型,跳过(保持 pending 供未来消费者处理)"
|
||||
);
|
||||
// 回滚事务:claim_next 是事务化的,这里只更新状态不置 completed
|
||||
rollback_running_to_pending(state, job_id).await?;
|
||||
Ok(Processed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 running 状态的任务回滚为 pending(用于 MVP 不支持的类型)。
|
||||
///
|
||||
/// 注意:retry_count 不递增(这是路由跳过而非处理失败),max_retries 不应被消耗。
|
||||
async fn rollback_running_to_pending(state: &AiState, job_id: Uuid) -> AiResult<()> {
|
||||
use sea_orm::ActiveModelTrait;
|
||||
use sea_orm::EntityTrait;
|
||||
use sea_orm::Set;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let entity = ai_analysis_queue::Entity::find_by_id(job_id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AiError::QueueError(format!("队列任务 {job_id} 未找到")))?;
|
||||
let mut active: ai_analysis_queue::ActiveModel = entity.into();
|
||||
active.status = Set("pending".to_string());
|
||||
active.started_at = Set(None);
|
||||
active.updated_at = Set(now);
|
||||
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
|
||||
active.update(&state.db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 趋势分析:复刻 handler 的 stream_trends 链路,drain 流式结果后 complete。
|
||||
async fn handle_trend(
|
||||
state: &AiState,
|
||||
queue: &AnalysisQueue,
|
||||
job: ai_analysis_queue::Model,
|
||||
) -> AiResult<ProcessOutcome> {
|
||||
let job_id = job.id;
|
||||
let tenant_id = job.tenant_id;
|
||||
let patient_id = job.patient_id;
|
||||
|
||||
// 失败统一走 mark_failed(自带 retry_count/max_retries 重试逻辑)
|
||||
match run_trend_analysis(state, tenant_id, patient_id).await {
|
||||
Ok(analysis_id) => match queue.mark_completed(job_id, analysis_id).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
job_id = %job_id,
|
||||
analysis_id = %analysis_id,
|
||||
"趋势分析任务完成"
|
||||
);
|
||||
Ok(Processed)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(job_id = %job_id, error = %e, "mark_completed 失败");
|
||||
Ok(Processed)
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let err_msg = e.to_string();
|
||||
tracing::warn!(
|
||||
job_id = %job_id,
|
||||
patient_id = %patient_id,
|
||||
error = %err_msg,
|
||||
"趋势分析处理失败"
|
||||
);
|
||||
match queue.mark_failed(job_id, err_msg).await {
|
||||
Ok(()) => {}
|
||||
Err(mfe) => {
|
||||
tracing::warn!(job_id = %job_id, error = %mfe, "mark_failed 本身失败");
|
||||
}
|
||||
}
|
||||
Ok(Processed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行一次趋势分析,返回新建的 analysis_id。
|
||||
///
|
||||
/// 流程对齐 `handler::stream_trends` + `build_sse_stream`:
|
||||
/// 取趋势数据 → sanitize → 加载 prompt → stream_analyze → drain 流 → complete_analysis。
|
||||
async fn run_trend_analysis(state: &AiState, tenant_id: Uuid, patient_id: Uuid) -> AiResult<Uuid> {
|
||||
let metrics = vec![
|
||||
"systolic_bp_morning".to_string(),
|
||||
"diastolic_bp_morning".to_string(),
|
||||
"heart_rate".to_string(),
|
||||
"weight".to_string(),
|
||||
"blood_sugar".to_string(),
|
||||
];
|
||||
let range = TimeRange {
|
||||
start: chrono::Utc::now() - chrono::Duration::days(90),
|
||||
end: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let trend_data = state
|
||||
.health_provider
|
||||
.get_trend_analysis_data(tenant_id, patient_id, &metrics, &range)
|
||||
.await
|
||||
.map_err(|e| AiError::ProviderError(format!("获取趋势数据失败: {e}")))?;
|
||||
|
||||
if trend_data.metrics.is_empty() {
|
||||
// 数据为空不是程序错误,但分析无法进行 → 返回失败让队列走重试/最终失败
|
||||
return Err(AiError::ProviderError(
|
||||
"患者在选定时间段内无体征监测数据".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let sanitized_data = state
|
||||
.analysis
|
||||
.sanitizer
|
||||
.sanitize_trend_analysis(&trend_data)?;
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
.get_active_prompt(tenant_id, AnalysisType::Trends.prompt_name())
|
||||
.await?;
|
||||
|
||||
let (model, temperature, max_tokens) =
|
||||
resolve_model_config(&prompt.model_config, tenant_id, &state.db).await;
|
||||
|
||||
// 队列任务无 HTTP 上下文,user_id 用 nil 占位(仅用于审计记录)
|
||||
let system_user = Uuid::nil();
|
||||
let (stream, analysis_id, _provider) = state
|
||||
.analysis
|
||||
.stream_analyze(
|
||||
tenant_id,
|
||||
system_user,
|
||||
patient_id,
|
||||
AnalysisType::Trends,
|
||||
patient_id.to_string(),
|
||||
prompt.system_prompt,
|
||||
prompt.user_prompt_template,
|
||||
sanitized_data,
|
||||
model,
|
||||
temperature,
|
||||
max_tokens,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// drain 流:累积全部输出,遇错 fail_analysis
|
||||
let mut stream = std::pin::pin!(stream);
|
||||
let mut full_content = String::new();
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(chunk) => full_content.push_str(&chunk),
|
||||
Err(e) => {
|
||||
let _ = state
|
||||
.analysis
|
||||
.fail_analysis(analysis_id, e.to_string())
|
||||
.await;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let metadata = serde_json::json!({ "analysis_type": "trend", "source": "queue_worker" });
|
||||
state
|
||||
.analysis
|
||||
.complete_analysis(analysis_id, full_content.clone(), metadata.clone())
|
||||
.await?;
|
||||
|
||||
// 用量记录(4 字符 ≈ 1 token 估算,对齐 SSE handler 逻辑)
|
||||
let est_output_tokens = (full_content.len() as u32) / 4;
|
||||
if let Err(e) = state
|
||||
.usage
|
||||
.log_usage(
|
||||
tenant_id,
|
||||
"queue_worker",
|
||||
"",
|
||||
"trend",
|
||||
0,
|
||||
est_output_tokens,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "队列消费者记录用量失败");
|
||||
}
|
||||
|
||||
// 后处理(解析建议、发布事件等)— 与 SSE handler 一致
|
||||
crate::service::post_process::post_process_analysis(
|
||||
state,
|
||||
analysis_id,
|
||||
&full_content,
|
||||
tenant_id,
|
||||
patient_id,
|
||||
system_user,
|
||||
"trend",
|
||||
metadata,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(analysis_id)
|
||||
}
|
||||
|
||||
/// 解析 prompt.model_config + 租户默认配置,返回 (model, temperature, max_tokens)。
|
||||
///
|
||||
/// 与 `handler::resolve_model_config` 实现等价(独立复制避免跨模块可见性问题)。
|
||||
async fn resolve_model_config(
|
||||
model_config: &serde_json::Value,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> (String, f32, u32) {
|
||||
let defaults = crate::config_resolver::load_ai_config(tenant_id, db).await;
|
||||
let analysis = &defaults.analysis_defaults;
|
||||
|
||||
let model = model_config
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&analysis.model)
|
||||
.to_string();
|
||||
let temperature = model_config
|
||||
.get("temperature")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(analysis.temperature as f64) as f32;
|
||||
let max_tokens = model_config
|
||||
.get("max_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(analysis.max_tokens as u64) as u32;
|
||||
|
||||
(model, temperature, max_tokens)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn idle_sleep_为10秒() {
|
||||
assert_eq!(IDLE_SLEEP.as_secs(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_outcome_枚举可用() {
|
||||
let _a = Processed;
|
||||
let _b = Idle;
|
||||
}
|
||||
}
|
||||
87
crates/erp-ai/src/service/document/chunker.rs
Normal file
87
crates/erp-ai/src/service/document/chunker.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
/// 文本切片:按固定大小 + 重叠切分
|
||||
pub fn chunk_text(text: &str, chunk_size: usize, overlap: usize) -> Vec<String> {
|
||||
if text.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let total = chars.len();
|
||||
|
||||
if total <= chunk_size {
|
||||
return vec![text.to_string()];
|
||||
}
|
||||
|
||||
let mut chunks = Vec::new();
|
||||
let mut start = 0;
|
||||
|
||||
while start < total {
|
||||
let end = (start + chunk_size).min(total);
|
||||
let chunk: String = chars[start..end].iter().collect();
|
||||
|
||||
let trimmed = chunk.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
chunks.push(trimmed);
|
||||
}
|
||||
|
||||
if end >= total {
|
||||
break;
|
||||
}
|
||||
start += chunk_size.saturating_sub(overlap);
|
||||
|
||||
// 防止无限循环
|
||||
if start <= end - chunk_size && start > 0 {
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
|
||||
chunks
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_chunk_empty() {
|
||||
assert_eq!(chunk_text("", 100, 20), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunk_small_text() {
|
||||
let text = "hello world";
|
||||
let chunks = chunk_text(text, 100, 20);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0], "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunk_long_text() {
|
||||
let text = "abcdefghij".repeat(100); // 1000 chars
|
||||
let chunks = chunk_text(&text, 200, 50);
|
||||
assert!(chunks.len() > 1);
|
||||
// First chunk should be 200 chars
|
||||
assert_eq!(chars_count(&chunks[0]), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunk_with_overlap() {
|
||||
let text = "abcdefghijklmnopqrstuvwxyz".repeat(20); // 520 chars
|
||||
let chunks = chunk_text(&text, 100, 20);
|
||||
assert!(chunks.len() > 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunk_chinese() {
|
||||
let text = "你好世界这是一段中文测试文本。".repeat(30);
|
||||
let chunks = chunk_text(&text, 100, 20);
|
||||
assert!(chunks.len() > 1);
|
||||
// 确保中文不被截断
|
||||
for chunk in &chunks {
|
||||
assert!(!chunk.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
fn chars_count(s: &str) -> usize {
|
||||
s.chars().count()
|
||||
}
|
||||
}
|
||||
459
crates/erp-ai/src/service/document/mod.rs
Normal file
459
crates/erp-ai/src/service/document/mod.rs
Normal file
@@ -0,0 +1,459 @@
|
||||
pub mod chunker;
|
||||
pub mod parser;
|
||||
|
||||
use sea_orm::{
|
||||
ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::ai_knowledge_documents;
|
||||
use crate::error::{AiError, AiResult};
|
||||
use crate::service::embedding::{EmbeddingService, format_vector};
|
||||
use crate::service::knowledge_v2::KnowledgeV2Service;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
// ─── DTO ───
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct CreateDocumentReq {
|
||||
pub title: String,
|
||||
pub doc_type: Option<String>,
|
||||
pub source_type: Option<String>,
|
||||
pub source_url: Option<String>,
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::IntoParams)]
|
||||
pub struct ListDocumentsQuery {
|
||||
pub status: Option<String>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UploadDocumentParams {
|
||||
pub file_name: String,
|
||||
pub file_size: i64,
|
||||
pub mime_type: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
// ─── Service ───
|
||||
|
||||
pub struct DocumentService {
|
||||
db: sea_orm::DatabaseConnection,
|
||||
knowledge_v2: Arc<KnowledgeV2Service>,
|
||||
embedding: Arc<EmbeddingService>,
|
||||
}
|
||||
|
||||
impl DocumentService {
|
||||
pub fn new(
|
||||
db: sea_orm::DatabaseConnection,
|
||||
knowledge_v2: Arc<KnowledgeV2Service>,
|
||||
embedding: Arc<EmbeddingService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
db,
|
||||
knowledge_v2,
|
||||
embedding,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_documents(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
kb_id: Uuid,
|
||||
query: &ListDocumentsQuery,
|
||||
) -> AiResult<(Vec<ai_knowledge_documents::Model>, u64)> {
|
||||
let page = query.page.unwrap_or(1);
|
||||
let page_size = query.page_size.unwrap_or(20);
|
||||
|
||||
let mut find = ai_knowledge_documents::Entity::find()
|
||||
.filter(ai_knowledge_documents::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_knowledge_documents::Column::KnowledgeBaseId.eq(kb_id))
|
||||
.filter(ai_knowledge_documents::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(ref status) = query.status {
|
||||
find = find.filter(ai_knowledge_documents::Column::Status.eq(status.as_str()));
|
||||
}
|
||||
|
||||
let paginator = find
|
||||
.order_by_desc(ai_knowledge_documents::Column::CreatedAt)
|
||||
.paginate(&self.db, page_size);
|
||||
|
||||
let total = paginator.num_items().await?;
|
||||
let items = paginator.fetch_page(page - 1).await?;
|
||||
|
||||
Ok((items, total))
|
||||
}
|
||||
|
||||
pub async fn get_document(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> AiResult<ai_knowledge_documents::Model> {
|
||||
ai_knowledge_documents::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| AiError::KnowledgeError("文档不存在".into()))
|
||||
}
|
||||
|
||||
/// 创建手动输入文档并立即处理
|
||||
pub async fn create_manual_document(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
kb_id: Uuid,
|
||||
req: CreateDocumentReq,
|
||||
) -> AiResult<Uuid> {
|
||||
// 验证知识库存在
|
||||
self.knowledge_v2.get_by_id(tenant_id, kb_id).await?;
|
||||
|
||||
let id = Uuid::now_v7();
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let active = ai_knowledge_documents::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
knowledge_base_id: Set(kb_id),
|
||||
title: Set(req.title),
|
||||
doc_type: Set(req.doc_type.unwrap_or_else(|| "manual".into())),
|
||||
source_type: Set(req.source_type.unwrap_or_else(|| "manual".into())),
|
||||
source_url: Set(req.source_url),
|
||||
file_name: Set(None),
|
||||
file_size: Set(None),
|
||||
file_mime_type: Set(None),
|
||||
content: Set(req.content),
|
||||
status: Set("pending".into()),
|
||||
chunk_count: Set(0),
|
||||
embedded_count: Set(0),
|
||||
error_message: Set(None),
|
||||
processing_started_at: Set(None),
|
||||
processing_completed_at: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(Some(user_id)),
|
||||
updated_by: Set(Some(user_id)),
|
||||
deleted_at: Set(None),
|
||||
version_lock: Set(1),
|
||||
};
|
||||
|
||||
ai_knowledge_documents::Entity::insert(active)
|
||||
.exec(&self.db)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
|
||||
// 异步处理文档(切片 + 嵌入)
|
||||
self.knowledge_v2.increment_document_count(kb_id, 1).await?;
|
||||
self.process_document(id).await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// 创建文件上传文档记录
|
||||
pub async fn create_upload_document(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
kb_id: Uuid,
|
||||
title: String,
|
||||
params: UploadDocumentParams,
|
||||
) -> AiResult<Uuid> {
|
||||
self.knowledge_v2.get_by_id(tenant_id, kb_id).await?;
|
||||
|
||||
let id = Uuid::now_v7();
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let doc_type = mime_to_doc_type(¶ms.mime_type);
|
||||
|
||||
let active = ai_knowledge_documents::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
knowledge_base_id: Set(kb_id),
|
||||
title: Set(title),
|
||||
doc_type: Set(doc_type),
|
||||
source_type: Set("upload".into()),
|
||||
source_url: Set(None),
|
||||
file_name: Set(Some(params.file_name)),
|
||||
file_size: Set(Some(params.file_size)),
|
||||
file_mime_type: Set(Some(params.mime_type)),
|
||||
content: Set(Some(params.content)),
|
||||
status: Set("pending".into()),
|
||||
chunk_count: Set(0),
|
||||
embedded_count: Set(0),
|
||||
error_message: Set(None),
|
||||
processing_started_at: Set(None),
|
||||
processing_completed_at: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(Some(user_id)),
|
||||
updated_by: Set(Some(user_id)),
|
||||
deleted_at: Set(None),
|
||||
version_lock: Set(1),
|
||||
};
|
||||
|
||||
ai_knowledge_documents::Entity::insert(active)
|
||||
.exec(&self.db)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
|
||||
self.knowledge_v2.increment_document_count(kb_id, 1).await?;
|
||||
self.process_document(id).await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn delete_document(&self, tenant_id: Uuid, kb_id: Uuid, id: Uuid) -> AiResult<()> {
|
||||
let existing = self.get_document(tenant_id, id).await?;
|
||||
if existing.knowledge_base_id != kb_id {
|
||||
return Err(AiError::KnowledgeError("文档不属于该知识库".into()));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let active = ai_knowledge_documents::ActiveModel {
|
||||
id: Set(existing.id),
|
||||
tenant_id: Set(existing.tenant_id),
|
||||
knowledge_base_id: Set(existing.knowledge_base_id),
|
||||
title: Set(existing.title),
|
||||
doc_type: Set(existing.doc_type),
|
||||
source_type: Set(existing.source_type),
|
||||
source_url: Set(existing.source_url),
|
||||
file_name: Set(existing.file_name),
|
||||
file_size: Set(existing.file_size),
|
||||
file_mime_type: Set(existing.file_mime_type),
|
||||
content: Set(existing.content),
|
||||
status: Set(existing.status),
|
||||
chunk_count: Set(existing.chunk_count),
|
||||
embedded_count: Set(existing.embedded_count),
|
||||
error_message: Set(existing.error_message),
|
||||
processing_started_at: Set(existing.processing_started_at),
|
||||
processing_completed_at: Set(existing.processing_completed_at),
|
||||
created_at: Set(existing.created_at),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(existing.created_by),
|
||||
updated_by: Set(existing.updated_by),
|
||||
deleted_at: Set(Some(now)),
|
||||
version_lock: Set(existing.version_lock + 1),
|
||||
};
|
||||
|
||||
ai_knowledge_documents::Entity::update(active)
|
||||
.exec(&self.db)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
|
||||
self.knowledge_v2
|
||||
.increment_document_count(kb_id, -1)
|
||||
.await?;
|
||||
self.knowledge_v2
|
||||
.increment_chunk_count(kb_id, -existing.chunk_count)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 处理文档:切片 → 嵌入 → 更新状态
|
||||
async fn process_document(&self, doc_id: Uuid) -> AiResult<()> {
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
// 标记处理中
|
||||
self.update_doc_status(doc_id, "processing", None, Some(now), None)
|
||||
.await?;
|
||||
|
||||
let doc = match ai_knowledge_documents::Entity::find_by_id(doc_id)
|
||||
.one(&self.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(d)) if d.deleted_at.is_none() => d,
|
||||
_ => {
|
||||
self.update_doc_status(
|
||||
doc_id,
|
||||
"failed",
|
||||
Some("文档未找到".into()),
|
||||
None,
|
||||
Some(now),
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let content = match &doc.content {
|
||||
Some(c) if !c.trim().is_empty() => c.clone(),
|
||||
_ => {
|
||||
self.update_doc_status(
|
||||
doc_id,
|
||||
"failed",
|
||||
Some("文档内容为空".into()),
|
||||
None,
|
||||
Some(now),
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// 切片
|
||||
let chunks = chunker::chunk_text(&content, 500, 50);
|
||||
if chunks.is_empty() {
|
||||
self.update_doc_status(
|
||||
doc_id,
|
||||
"failed",
|
||||
Some("切片结果为空".into()),
|
||||
None,
|
||||
Some(now),
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 嵌入 + 存储
|
||||
let mut embedded_count = 0u32;
|
||||
for (idx, chunk_content) in chunks.iter().enumerate() {
|
||||
let chunk_id = Uuid::now_v7();
|
||||
let embedding = self.try_embed(chunk_content).await;
|
||||
|
||||
let embedding_val = embedding
|
||||
.as_ref()
|
||||
.map(|e| sea_orm::Value::String(Some(Box::new(format_vector(e)))))
|
||||
.unwrap_or(sea_orm::Value::String(None));
|
||||
|
||||
let sql = r#"
|
||||
INSERT INTO ai_knowledge_chunks
|
||||
(id, tenant_id, knowledge_base_id, document_id, chunk_index, content,
|
||||
embedding, metadata, hit_count, created_at, updated_at, created_by, updated_by, deleted_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::vector, '{}', 0, $8, $8, $9, $9, NULL)
|
||||
"#;
|
||||
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[
|
||||
sea_orm::Value::from(chunk_id),
|
||||
sea_orm::Value::from(doc.tenant_id),
|
||||
sea_orm::Value::from(doc.knowledge_base_id),
|
||||
sea_orm::Value::from(doc_id),
|
||||
sea_orm::Value::from(idx as i32),
|
||||
sea_orm::Value::String(Some(Box::new(chunk_content.clone()))),
|
||||
embedding_val,
|
||||
sea_orm::Value::from(now),
|
||||
sea_orm::Value::from(doc.created_by),
|
||||
],
|
||||
);
|
||||
|
||||
match self.db.execute(stmt).await {
|
||||
Ok(_) => {
|
||||
if embedding.is_some() {
|
||||
embedded_count += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(chunk_index = idx, error = %e, "切片插入失败,跳过");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新文档状态
|
||||
let completed_now = chrono::Utc::now();
|
||||
let sql = r#"
|
||||
UPDATE ai_knowledge_documents
|
||||
SET status = 'completed', chunk_count = $2, embedded_count = $3,
|
||||
processing_completed_at = $4, updated_at = $4, version_lock = version_lock + 1
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
"#;
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[
|
||||
sea_orm::Value::from(doc_id),
|
||||
sea_orm::Value::from(chunks.len() as i32),
|
||||
sea_orm::Value::from(embedded_count as i32),
|
||||
sea_orm::Value::from(completed_now),
|
||||
],
|
||||
);
|
||||
self.db
|
||||
.execute(stmt)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
|
||||
// 原子递增知识库切片计数
|
||||
self.knowledge_v2
|
||||
.increment_chunk_count(doc.knowledge_base_id, chunks.len() as i32)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_doc_status(
|
||||
&self,
|
||||
doc_id: Uuid,
|
||||
status: &str,
|
||||
error: Option<String>,
|
||||
started_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
completed_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> AiResult<()> {
|
||||
let now = chrono::Utc::now();
|
||||
let mut values: Vec<sea_orm::Value> = vec![
|
||||
sea_orm::Value::from(doc_id),
|
||||
sea_orm::Value::String(Some(Box::new(status.to_string()))),
|
||||
error
|
||||
.map(|e| sea_orm::Value::String(Some(Box::new(e))))
|
||||
.unwrap_or(sea_orm::Value::String(None)),
|
||||
sea_orm::Value::from(now),
|
||||
];
|
||||
|
||||
let mut extra_sql = String::new();
|
||||
if let Some(sa) = started_at {
|
||||
values.push(sea_orm::Value::from(sa));
|
||||
extra_sql.push_str(", processing_started_at = $5");
|
||||
}
|
||||
if let Some(ca) = completed_at {
|
||||
values.push(sea_orm::Value::from(ca));
|
||||
let idx = values.len();
|
||||
extra_sql.push_str(&format!(", processing_completed_at = ${}", idx));
|
||||
}
|
||||
|
||||
let sql = format!(
|
||||
"UPDATE ai_knowledge_documents SET status = $2, error_message = $3, updated_at = $4, version_lock = version_lock + 1{} WHERE id = $1 AND deleted_at IS NULL",
|
||||
extra_sql
|
||||
);
|
||||
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
&sql,
|
||||
values,
|
||||
);
|
||||
self.db
|
||||
.execute(stmt)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn try_embed(&self, text: &str) -> Option<Vec<f32>> {
|
||||
if !self.embedding.is_configured() {
|
||||
return None;
|
||||
}
|
||||
match self.embedding.embed(text).await {
|
||||
Ok(e) => Some(e),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Embedding 生成失败");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mime_to_doc_type(mime: &str) -> String {
|
||||
match mime {
|
||||
"application/pdf" => "pdf".into(),
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx".into(),
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx".into(),
|
||||
"text/plain" => "txt".into(),
|
||||
"text/markdown" => "md".into(),
|
||||
_ => "other".into(),
|
||||
}
|
||||
}
|
||||
60
crates/erp-ai/src/service/document/parser.rs
Normal file
60
crates/erp-ai/src/service/document/parser.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use crate::error::{AiError, AiResult};
|
||||
|
||||
/// 从文件内容解析出纯文本
|
||||
pub fn parse_document(file_name: &str, mime_type: &str, data: &[u8]) -> AiResult<String> {
|
||||
match mime_type {
|
||||
"application/pdf" => parse_pdf(data),
|
||||
"text/plain" | "text/markdown" => parse_text(data),
|
||||
_ => {
|
||||
if file_name.ends_with(".pdf") {
|
||||
return parse_pdf(data);
|
||||
}
|
||||
// DOCX/XLSX 等二进制格式用 UTF-8 lossy 提取可读文本
|
||||
// 后续 Phase 可替换为专业解析器
|
||||
if file_name.ends_with(".txt") || file_name.ends_with(".md") {
|
||||
return parse_text(data);
|
||||
}
|
||||
// 二进制格式兜底:提取 UTF-8 可读片段
|
||||
parse_binary_text(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_pdf(data: &[u8]) -> AiResult<String> {
|
||||
pdf_extract::extract_text_from_mem(data)
|
||||
.map(|t| t.trim().to_string())
|
||||
.map_err(|e| AiError::KnowledgeError(format!("PDF 解析失败: {}", e)))
|
||||
}
|
||||
|
||||
fn parse_text(data: &[u8]) -> AiResult<String> {
|
||||
Ok(String::from_utf8_lossy(data).trim().to_string())
|
||||
}
|
||||
|
||||
/// 从二进制文件中提取可读文本片段(DOCX/XLSX 兜底方案)
|
||||
fn parse_binary_text(data: &[u8]) -> AiResult<String> {
|
||||
let text = String::from_utf8_lossy(data);
|
||||
let mut readable = String::new();
|
||||
let mut chunk = String::new();
|
||||
|
||||
for ch in text.chars() {
|
||||
let punctuation = ",。、;:\u{201c}\u{201d}\u{2018}\u{2019}!?()《》【】…—·\t\n\r";
|
||||
if ch.is_alphanumeric() || ch.is_whitespace() || punctuation.contains(ch) {
|
||||
chunk.push(ch);
|
||||
} else if !chunk.trim().is_empty() {
|
||||
readable.push_str(chunk.trim());
|
||||
readable.push(' ');
|
||||
chunk.clear();
|
||||
}
|
||||
}
|
||||
if !chunk.trim().is_empty() {
|
||||
readable.push_str(chunk.trim());
|
||||
}
|
||||
|
||||
let result = readable.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if result.len() < 20 {
|
||||
return Err(AiError::KnowledgeError(
|
||||
"无法从文件中提取有效文本内容".into(),
|
||||
));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
336
crates/erp-ai/src/service/knowledge_v2.rs
Normal file
336
crates/erp-ai/src/service/knowledge_v2.rs
Normal file
@@ -0,0 +1,336 @@
|
||||
use sea_orm::{
|
||||
ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::ai_knowledge_bases;
|
||||
use crate::error::{AiError, AiResult};
|
||||
|
||||
// ─── DTO ───
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct CreateKnowledgeBaseReq {
|
||||
pub name: String,
|
||||
pub kb_type: String,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub chunk_strategy: Option<serde_json::Value>,
|
||||
pub intent_keywords: Option<serde_json::Value>,
|
||||
pub embedding_model: Option<String>,
|
||||
pub is_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct UpdateKnowledgeBaseReq {
|
||||
pub name: Option<String>,
|
||||
pub kb_type: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub chunk_strategy: Option<serde_json::Value>,
|
||||
pub intent_keywords: Option<serde_json::Value>,
|
||||
pub embedding_model: Option<String>,
|
||||
pub is_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::IntoParams)]
|
||||
pub struct ListKnowledgeBasesQuery {
|
||||
pub kb_type: Option<String>,
|
||||
pub is_enabled: Option<bool>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
// ─── Service ───
|
||||
|
||||
pub struct KnowledgeV2Service {
|
||||
db: sea_orm::DatabaseConnection,
|
||||
}
|
||||
|
||||
impl KnowledgeV2Service {
|
||||
pub fn new(db: sea_orm::DatabaseConnection) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
query: &ListKnowledgeBasesQuery,
|
||||
) -> AiResult<(Vec<ai_knowledge_bases::Model>, u64)> {
|
||||
let page = query.page.unwrap_or(1);
|
||||
let page_size = query.page_size.unwrap_or(20);
|
||||
|
||||
let mut find = ai_knowledge_bases::Entity::find()
|
||||
.filter(ai_knowledge_bases::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_knowledge_bases::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(ref kb_type) = query.kb_type {
|
||||
find = find.filter(ai_knowledge_bases::Column::KbType.eq(kb_type.as_str()));
|
||||
}
|
||||
if let Some(is_enabled) = query.is_enabled {
|
||||
find = find.filter(ai_knowledge_bases::Column::IsEnabled.eq(is_enabled));
|
||||
}
|
||||
|
||||
let paginator = find
|
||||
.order_by_desc(ai_knowledge_bases::Column::CreatedAt)
|
||||
.paginate(&self.db, page_size);
|
||||
|
||||
let total = paginator.num_items().await?;
|
||||
let items = paginator.fetch_page(page - 1).await?;
|
||||
|
||||
Ok((items, total))
|
||||
}
|
||||
|
||||
pub async fn get_by_id(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> AiResult<ai_knowledge_bases::Model> {
|
||||
ai_knowledge_bases::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| AiError::KnowledgeError("知识库不存在".into()))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
req: CreateKnowledgeBaseReq,
|
||||
) -> AiResult<Uuid> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let active = ai_knowledge_bases::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
name: Set(req.name),
|
||||
kb_type: Set(req.kb_type),
|
||||
description: Set(req.description),
|
||||
icon: Set(req.icon),
|
||||
chunk_strategy: Set(req.chunk_strategy.unwrap_or(
|
||||
serde_json::json!({"strategy": "auto", "chunk_size": 500, "overlap": 50}),
|
||||
)),
|
||||
intent_keywords: Set(req.intent_keywords.unwrap_or(serde_json::json!([]))),
|
||||
embedding_model: Set(req.embedding_model),
|
||||
is_enabled: Set(req.is_enabled.unwrap_or(true)),
|
||||
document_count: Set(0),
|
||||
chunk_count: Set(0),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(Some(user_id)),
|
||||
updated_by: Set(Some(user_id)),
|
||||
deleted_at: Set(None),
|
||||
version_lock: Set(1),
|
||||
};
|
||||
|
||||
ai_knowledge_bases::Entity::insert(active)
|
||||
.exec(&self.db)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
id: Uuid,
|
||||
req: UpdateKnowledgeBaseReq,
|
||||
) -> AiResult<()> {
|
||||
let existing = self.get_by_id(tenant_id, id).await?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let active = ai_knowledge_bases::ActiveModel {
|
||||
id: Set(existing.id),
|
||||
tenant_id: Set(existing.tenant_id),
|
||||
name: Set(req.name.unwrap_or(existing.name)),
|
||||
kb_type: Set(req.kb_type.unwrap_or(existing.kb_type)),
|
||||
description: Set(req.description.or(existing.description)),
|
||||
icon: Set(req.icon.or(existing.icon)),
|
||||
chunk_strategy: Set(req.chunk_strategy.unwrap_or(existing.chunk_strategy)),
|
||||
intent_keywords: Set(req.intent_keywords.unwrap_or(existing.intent_keywords)),
|
||||
embedding_model: Set(req.embedding_model.or(existing.embedding_model)),
|
||||
is_enabled: Set(req.is_enabled.unwrap_or(existing.is_enabled)),
|
||||
document_count: Set(existing.document_count),
|
||||
chunk_count: Set(existing.chunk_count),
|
||||
created_at: Set(existing.created_at),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(existing.created_by),
|
||||
updated_by: Set(Some(user_id)),
|
||||
deleted_at: Set(existing.deleted_at),
|
||||
version_lock: Set(existing.version_lock + 1),
|
||||
};
|
||||
|
||||
ai_knowledge_bases::Entity::update(active)
|
||||
.exec(&self.db)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(&self, tenant_id: Uuid, id: Uuid) -> AiResult<()> {
|
||||
let existing = self.get_by_id(tenant_id, id).await?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let active = ai_knowledge_bases::ActiveModel {
|
||||
id: Set(existing.id),
|
||||
tenant_id: Set(existing.tenant_id),
|
||||
name: Set(existing.name),
|
||||
kb_type: Set(existing.kb_type),
|
||||
description: Set(existing.description),
|
||||
icon: Set(existing.icon),
|
||||
chunk_strategy: Set(existing.chunk_strategy),
|
||||
intent_keywords: Set(existing.intent_keywords),
|
||||
embedding_model: Set(existing.embedding_model),
|
||||
is_enabled: Set(existing.is_enabled),
|
||||
document_count: Set(existing.document_count),
|
||||
chunk_count: Set(existing.chunk_count),
|
||||
created_at: Set(existing.created_at),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(existing.created_by),
|
||||
updated_by: Set(existing.updated_by),
|
||||
deleted_at: Set(Some(now)),
|
||||
version_lock: Set(existing.version_lock + 1),
|
||||
};
|
||||
|
||||
ai_knowledge_bases::Entity::update(active)
|
||||
.exec(&self.db)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 原子递增文档计数(用于文档上传成功后)
|
||||
pub async fn increment_document_count(&self, kb_id: Uuid, delta: i32) -> AiResult<()> {
|
||||
let sql = r#"
|
||||
UPDATE ai_knowledge_bases
|
||||
SET document_count = document_count + $2,
|
||||
updated_at = $3,
|
||||
version_lock = version_lock + 1
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
"#;
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[
|
||||
sea_orm::Value::from(kb_id),
|
||||
sea_orm::Value::from(delta),
|
||||
sea_orm::Value::from(chrono::Utc::now()),
|
||||
],
|
||||
);
|
||||
self.db
|
||||
.execute(stmt)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 原子递增切片计数(用于切片生成后)
|
||||
pub async fn increment_chunk_count(&self, kb_id: Uuid, delta: i32) -> AiResult<()> {
|
||||
let sql = r#"
|
||||
UPDATE ai_knowledge_bases
|
||||
SET chunk_count = chunk_count + $2,
|
||||
updated_at = $3,
|
||||
version_lock = version_lock + 1
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
"#;
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[
|
||||
sea_orm::Value::from(kb_id),
|
||||
sea_orm::Value::from(delta),
|
||||
sea_orm::Value::from(chrono::Utc::now()),
|
||||
],
|
||||
);
|
||||
self.db
|
||||
.execute(stmt)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 向量相似度搜索:在指定知识库中搜索与 query_embedding 最相似的 top_k 个切片
|
||||
pub async fn vector_search(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
kb_id: Uuid,
|
||||
query_embedding: &[f32],
|
||||
top_k: i64,
|
||||
) -> AiResult<Vec<SearchHit>> {
|
||||
let vector_str = crate::service::embedding::format_vector(query_embedding);
|
||||
let sql = r#"
|
||||
SELECT c.id, c.document_id, c.chunk_index, c.content, c.metadata,
|
||||
d.title AS doc_title,
|
||||
1 - (c.embedding <=> $3::vector) AS similarity
|
||||
FROM ai_knowledge_chunks c
|
||||
JOIN ai_knowledge_documents d ON d.id = c.document_id
|
||||
WHERE c.tenant_id = $1
|
||||
AND c.knowledge_base_id = $2
|
||||
AND c.deleted_at IS NULL
|
||||
AND d.deleted_at IS NULL
|
||||
AND c.embedding IS NOT NULL
|
||||
ORDER BY c.embedding <=> $3::vector
|
||||
LIMIT $4
|
||||
"#;
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[
|
||||
sea_orm::Value::from(tenant_id),
|
||||
sea_orm::Value::from(kb_id),
|
||||
sea_orm::Value::String(Some(Box::new(vector_str))),
|
||||
sea_orm::Value::from(top_k),
|
||||
],
|
||||
);
|
||||
|
||||
let rows: Vec<SearchHitRow> = sea_orm::FromQueryResult::find_by_statement(stmt)
|
||||
.all(&self.db)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
|
||||
Ok(rows.into_iter().map(SearchHit::from).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, sea_orm::FromQueryResult)]
|
||||
struct SearchHitRow {
|
||||
id: Uuid,
|
||||
document_id: Uuid,
|
||||
chunk_index: i32,
|
||||
content: String,
|
||||
metadata: serde_json::Value,
|
||||
doc_title: String,
|
||||
similarity: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct SearchHit {
|
||||
pub chunk_id: Uuid,
|
||||
pub document_id: Uuid,
|
||||
pub chunk_index: i32,
|
||||
pub content: String,
|
||||
pub doc_title: String,
|
||||
pub similarity: f64,
|
||||
pub metadata: serde_json::Value,
|
||||
}
|
||||
|
||||
impl From<SearchHitRow> for SearchHit {
|
||||
fn from(row: SearchHitRow) -> Self {
|
||||
Self {
|
||||
chunk_id: row.id,
|
||||
document_id: row.document_id,
|
||||
chunk_index: row.chunk_index,
|
||||
content: row.content,
|
||||
doc_title: row.doc_title,
|
||||
similarity: row.similarity,
|
||||
metadata: row.metadata,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod analysis;
|
||||
pub mod analysis_queue;
|
||||
pub mod analysis_worker;
|
||||
pub mod auto_analysis;
|
||||
pub mod cache;
|
||||
pub mod chat_message;
|
||||
@@ -7,10 +8,12 @@ pub mod chat_session;
|
||||
pub mod comparison;
|
||||
pub mod cost;
|
||||
pub mod dialysis_risk_scorer;
|
||||
pub mod document;
|
||||
pub mod embedding;
|
||||
pub mod feature_flag_service;
|
||||
pub mod insight_service;
|
||||
pub mod knowledge;
|
||||
pub mod knowledge_v2;
|
||||
pub mod local_rules;
|
||||
pub mod output_parser;
|
||||
pub mod post_process;
|
||||
|
||||
@@ -17,20 +17,20 @@ impl PromptService {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// 获取当前激活的 Prompt 模板
|
||||
/// 获取当前激活的 Prompt 模板(按 analysis_type 查找)
|
||||
pub async fn get_active_prompt(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
name: &str,
|
||||
analysis_type: &str,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
ai_prompt::Entity::find()
|
||||
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_prompt::Column::Name.eq(name))
|
||||
.filter(ai_prompt::Column::AnalysisType.eq(analysis_type))
|
||||
.filter(ai_prompt::Column::IsActive.eq(true))
|
||||
.filter(ai_prompt::Column::DeletedAt.is_null())
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AiError::PromptNotFound(name.into()))
|
||||
.ok_or_else(|| AiError::PromptNotFound(analysis_type.into()))
|
||||
}
|
||||
|
||||
/// 新建 Prompt
|
||||
@@ -44,6 +44,7 @@ impl PromptService {
|
||||
user_prompt_template: String,
|
||||
model_config: serde_json::Value,
|
||||
category: String,
|
||||
analysis_type: String,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = chrono::Utc::now();
|
||||
@@ -59,6 +60,7 @@ impl PromptService {
|
||||
version: Set(1),
|
||||
is_active: Set(true),
|
||||
category: Set(category),
|
||||
analysis_type: Set(analysis_type),
|
||||
tags: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
@@ -74,15 +76,15 @@ impl PromptService {
|
||||
pub async fn list_prompts(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
category: Option<String>,
|
||||
analysis_type: Option<String>,
|
||||
pagination: &Pagination,
|
||||
) -> AiResult<(Vec<ai_prompt::Model>, u64)> {
|
||||
let mut query = ai_prompt::Entity::find()
|
||||
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_prompt::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(cat) = &category {
|
||||
query = query.filter(ai_prompt::Column::Category.eq(cat.as_str()));
|
||||
if let Some(at) = &analysis_type {
|
||||
query = query.filter(ai_prompt::Column::AnalysisType.eq(at.as_str()));
|
||||
}
|
||||
|
||||
let total = query.clone().count(&self.db).await?;
|
||||
@@ -132,6 +134,7 @@ impl PromptService {
|
||||
version: Set(entity.version + 1),
|
||||
is_active: Set(entity.is_active),
|
||||
category: Set(entity.category.clone()),
|
||||
analysis_type: Set(entity.analysis_type.clone()),
|
||||
tags: Set(entity.tags.clone()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
@@ -143,7 +146,7 @@ impl PromptService {
|
||||
Ok(active.insert(&self.db).await?)
|
||||
}
|
||||
|
||||
/// 激活指定 Prompt(停用同 name+category 的其他版本)
|
||||
/// 激活指定 Prompt(停用同 analysis_type 的其他版本,原子操作)
|
||||
pub async fn activate_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
|
||||
let entity = ai_prompt::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
@@ -154,25 +157,23 @@ impl PromptService {
|
||||
return Err(AiError::Validation("跨租户操作".into()));
|
||||
}
|
||||
|
||||
// 停用同 name + category 的其他激活版本
|
||||
let siblings = ai_prompt::Entity::find()
|
||||
// 原子操作:停用同 analysis_type 的其他版本
|
||||
ai_prompt::Entity::update_many()
|
||||
.col_expr(
|
||||
ai_prompt::Column::IsActive,
|
||||
sea_orm::sea_query::Expr::value(false),
|
||||
)
|
||||
.col_expr(
|
||||
ai_prompt::Column::UpdatedAt,
|
||||
sea_orm::sea_query::Expr::value(chrono::Utc::now()),
|
||||
)
|
||||
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_prompt::Column::Name.eq(&entity.name))
|
||||
.filter(ai_prompt::Column::Category.eq(&entity.category))
|
||||
.filter(ai_prompt::Column::IsActive.eq(true))
|
||||
.filter(ai_prompt::Column::DeletedAt.is_null())
|
||||
.filter(ai_prompt::Column::AnalysisType.eq(&entity.analysis_type))
|
||||
.filter(ai_prompt::Column::Id.ne(id))
|
||||
.all(&self.db)
|
||||
.filter(ai_prompt::Column::DeletedAt.is_null())
|
||||
.exec(&self.db)
|
||||
.await?;
|
||||
|
||||
for sibling in siblings {
|
||||
let mut active: ai_prompt::ActiveModel = sibling.into();
|
||||
active.is_active = Set(false);
|
||||
active.updated_at = Set(chrono::Utc::now());
|
||||
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
|
||||
active.update(&self.db).await?;
|
||||
}
|
||||
|
||||
// 激活目标
|
||||
let mut active: ai_prompt::ActiveModel = entity.into();
|
||||
active.is_active = Set(true);
|
||||
@@ -185,4 +186,41 @@ impl PromptService {
|
||||
pub async fn rollback_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
|
||||
self.activate_prompt(id, tenant_id).await
|
||||
}
|
||||
|
||||
/// 停用 Prompt
|
||||
pub async fn deactivate_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
|
||||
let entity = ai_prompt::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
|
||||
|
||||
if entity.tenant_id != tenant_id {
|
||||
return Err(AiError::Validation("跨租户操作".into()));
|
||||
}
|
||||
|
||||
let mut active: ai_prompt::ActiveModel = entity.into();
|
||||
active.is_active = Set(false);
|
||||
active.updated_at = Set(chrono::Utc::now());
|
||||
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
|
||||
Ok(active.update(&self.db).await?)
|
||||
}
|
||||
|
||||
/// 删除 Prompt(软删除)
|
||||
pub async fn delete_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<()> {
|
||||
let entity = ai_prompt::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
|
||||
|
||||
if entity.tenant_id != tenant_id {
|
||||
return Err(AiError::Validation("跨租户操作".into()));
|
||||
}
|
||||
|
||||
let mut active: ai_prompt::ActiveModel = entity.into();
|
||||
active.deleted_at = Set(Some(chrono::Utc::now()));
|
||||
active.updated_at = Set(chrono::Utc::now());
|
||||
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
|
||||
active.update(&self.db).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,12 @@ use crate::service::analysis::AnalysisService;
|
||||
use crate::service::cache::CacheService;
|
||||
use crate::service::chat_message::ChatMessageService;
|
||||
use crate::service::chat_session::ChatSessionService;
|
||||
use crate::service::document::DocumentService;
|
||||
use crate::service::embedding::EmbeddingService;
|
||||
use crate::service::feature_flag_service::FeatureFlagService;
|
||||
use crate::service::insight_service::InsightService;
|
||||
use crate::service::knowledge::KnowledgeService;
|
||||
use crate::service::knowledge_v2::KnowledgeV2Service;
|
||||
use crate::service::prompt::PromptService;
|
||||
use crate::service::quota::QuotaService;
|
||||
use crate::service::risk_service::RiskService;
|
||||
@@ -34,6 +37,9 @@ pub struct AiState {
|
||||
pub insight_service: Arc<InsightService>,
|
||||
pub feature_flags: Arc<FeatureFlagService>,
|
||||
pub knowledge: Arc<KnowledgeService>,
|
||||
pub knowledge_v2: Arc<KnowledgeV2Service>,
|
||||
pub document: Arc<DocumentService>,
|
||||
pub embedding: Arc<EmbeddingService>,
|
||||
pub chat_session: Arc<ChatSessionService>,
|
||||
pub chat_message: Arc<ChatMessageService>,
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ pub struct UserListParams {
|
||||
pub page_size: Option<u64>,
|
||||
/// Optional search term — filters by username (case-insensitive contains).
|
||||
pub search: Option<String>,
|
||||
/// Exclude users whose *only* role is one of these comma-separated role codes.
|
||||
/// Example: `exclude_only_roles=patient` hides users that have no role other than "patient".
|
||||
pub exclude_only_roles: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -54,10 +57,17 @@ where
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
};
|
||||
let exclude_only_roles: Option<Vec<String>> = params
|
||||
.exclude_only_roles
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.split(',').map(|c| c.trim().to_string()).collect());
|
||||
|
||||
let (users, total) = UserService::list(
|
||||
ctx.tenant_id,
|
||||
&pagination,
|
||||
params.search.as_deref(),
|
||||
exclude_only_roles.as_deref(),
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -19,8 +19,54 @@ type ScopeCacheEntry = (DeptIds, DataScopes, std::time::Instant);
|
||||
static USER_SCOPE_CACHE: std::sync::LazyLock<DashMap<uuid::Uuid, ScopeCacheEntry>> =
|
||||
std::sync::LazyLock::new(DashMap::new);
|
||||
|
||||
/// Access Token 吊销黑名单(token_hash -> 过期时间戳)
|
||||
/// key = SHA-256(token) 前 16 字符,value = token 的 exp 时间戳
|
||||
/// 惰性清理:检查时自动移除过期条目
|
||||
static TOKEN_BLACKLIST: std::sync::LazyLock<DashMap<String, i64>> =
|
||||
std::sync::LazyLock::new(DashMap::new);
|
||||
|
||||
const SCOPE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// 吊销单个 access token(直到其自然过期)
|
||||
pub fn revoke_access_token(token: &str, exp: i64) {
|
||||
let hash = token_hash(token);
|
||||
TOKEN_BLACKLIST.insert(hash, exp);
|
||||
}
|
||||
|
||||
/// 吊销用户所有 token(清除权限缓存,强制下次请求重新认证)
|
||||
pub fn revoke_all_user_tokens(user_id: uuid::Uuid) {
|
||||
USER_SCOPE_CACHE.remove(&user_id);
|
||||
}
|
||||
|
||||
/// 检查 token 是否已被吊销
|
||||
fn is_token_revoked(token: &str, _exp: i64) -> bool {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
// 惰性清理过期条目
|
||||
if TOKEN_BLACKLIST.len() > 10_000 {
|
||||
TOKEN_BLACKLIST.retain(|_, exp_ts| *exp_ts > now);
|
||||
}
|
||||
let hash = token_hash(token);
|
||||
match TOKEN_BLACKLIST.get(&hash) {
|
||||
Some(exp_ts) => {
|
||||
if *exp_ts <= now {
|
||||
drop(exp_ts);
|
||||
TOKEN_BLACKLIST.remove(&hash);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn token_hash(token: &str) -> String {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
token.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
/// JWT authentication middleware function.
|
||||
///
|
||||
/// Extracts the `Bearer` token from the `Authorization` header, validates it
|
||||
@@ -71,6 +117,11 @@ pub async fn jwt_auth_middleware_fn(
|
||||
let claims =
|
||||
TokenService::decode_token(&token, &jwt_secret).map_err(|_| AppError::Unauthorized)?;
|
||||
|
||||
// 检查 token 是否已被吊销(密码修改/管理员强制下线)
|
||||
if is_token_revoked(&token, claims.exp) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
// Verify this is an access token, not a refresh token
|
||||
if claims.token_type != "access" {
|
||||
return Err(AppError::Unauthorized);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pub mod jwt_auth;
|
||||
|
||||
pub use erp_core::rbac::{require_any_permission, require_permission, require_role};
|
||||
pub use jwt_auth::jwt_auth_middleware_fn;
|
||||
pub use jwt_auth::{jwt_auth_middleware_fn, revoke_access_token, revoke_all_user_tokens};
|
||||
|
||||
@@ -23,12 +23,23 @@ impl AuthModule {
|
||||
/// These routes do not require a valid JWT token.
|
||||
/// The caller wraps this into whatever state type the application uses.
|
||||
pub fn public_routes<S>() -> Router<S>
|
||||
where
|
||||
crate::auth_state::AuthState: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new().route("/auth/login", axum::routing::post(auth_handler::login))
|
||||
}
|
||||
|
||||
/// WeChat public routes — separate from login to allow higher rate limits.
|
||||
///
|
||||
/// Mobile users may retry more frequently, so these use 30 req/min
|
||||
/// instead of the strict 5 req/min for password login.
|
||||
pub fn wechat_routes<S>() -> Router<S>
|
||||
where
|
||||
crate::auth_state::AuthState: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/auth/login", axum::routing::post(auth_handler::login))
|
||||
.route(
|
||||
"/auth/wechat/login",
|
||||
axum::routing::post(wechat_handler::wechat_login),
|
||||
|
||||
@@ -5,6 +5,7 @@ use uuid::Uuid;
|
||||
use crate::dto::{LoginResp, RoleResp, UserResp};
|
||||
use crate::entity::{role, user, user_credential, user_role};
|
||||
use crate::error::AuthError;
|
||||
use crate::middleware::revoke_all_user_tokens as revoke_access_token_cache;
|
||||
use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
use erp_core::events::EventBus;
|
||||
@@ -284,6 +285,9 @@ impl AuthService {
|
||||
) -> AuthResult<()> {
|
||||
TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?;
|
||||
|
||||
// 清除 access token 权限缓存,强制重新认证
|
||||
revoke_access_token_cache(user_id);
|
||||
|
||||
// 审计:登出
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(user_id), "user.logout", "user")
|
||||
@@ -351,6 +355,9 @@ impl AuthService {
|
||||
// 4. Revoke all refresh tokens — force re-login on all devices
|
||||
TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?;
|
||||
|
||||
// 清除 access token 权限缓存,密码修改后所有已签发的 access token 强制失效
|
||||
revoke_access_token_cache(user_id);
|
||||
|
||||
// 审计:密码修改
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(user_id), "user.change_password", "user")
|
||||
|
||||
@@ -144,10 +144,15 @@ impl UserService {
|
||||
///
|
||||
/// Returns `(users, total_count)`. When `search` is provided, filters
|
||||
/// by username using case-insensitive substring match.
|
||||
///
|
||||
/// When `exclude_only_roles` is provided, users whose *only* role is one
|
||||
/// of the listed role codes are excluded (e.g. `["patient"]` hides
|
||||
/// patient-only users from the staff management page).
|
||||
pub async fn list(
|
||||
tenant_id: Uuid,
|
||||
pagination: &Pagination,
|
||||
search: Option<&str>,
|
||||
exclude_only_roles: Option<&[String]>,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AuthResult<(Vec<UserResp>, u64)> {
|
||||
let mut query = user::Entity::find()
|
||||
@@ -161,6 +166,56 @@ impl UserService {
|
||||
query = query.filter(Expr::col(user::Column::Username).like(format!("%{}%", term)));
|
||||
}
|
||||
|
||||
// Exclude users whose only role is one of the excluded role codes.
|
||||
// Two-step approach: first find user_ids that have ONLY excluded roles
|
||||
// via raw SQL, then exclude them from the main query.
|
||||
if let Some(roles) = exclude_only_roles
|
||||
&& !roles.is_empty()
|
||||
{
|
||||
use sea_orm::{ConnectionTrait, Statement};
|
||||
|
||||
let codes: Vec<String> = roles
|
||||
.iter()
|
||||
.map(|r| format!("'{}'", r.replace('\'', "''")))
|
||||
.collect();
|
||||
let codes_csv = codes.join(",");
|
||||
|
||||
// Find user_ids whose ONLY roles are in the excluded list.
|
||||
// A user qualifies if:
|
||||
// - they have at least one role in the excluded list
|
||||
// - they have ZERO roles outside the excluded list
|
||||
let excluded: Vec<Uuid> = db
|
||||
.query_all(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
format!(
|
||||
r#"SELECT u.id FROM users u
|
||||
WHERE u.tenant_id = $1 AND u.deleted_at IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id AND r.deleted_at IS NULL
|
||||
WHERE ur.user_id = u.id AND ur.tenant_id = $1
|
||||
AND r.code IN ({codes_csv})
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id AND r.deleted_at IS NULL
|
||||
WHERE ur.user_id = u.id AND ur.tenant_id = $1
|
||||
AND r.code NOT IN ({codes_csv})
|
||||
)"#
|
||||
),
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.await
|
||||
.map_err(|e| AuthError::DbError(e.to_string()))?
|
||||
.iter()
|
||||
.filter_map(|row| row.try_get("", "id").ok())
|
||||
.collect();
|
||||
|
||||
if !excluded.is_empty() {
|
||||
query = query.filter(user::Column::Id.is_not_in(excluded));
|
||||
}
|
||||
}
|
||||
|
||||
let paginator = query.paginate(db, pagination.limit());
|
||||
|
||||
let total = paginator
|
||||
|
||||
@@ -9,6 +9,66 @@ use sha2::{Digest, Sha256};
|
||||
use tracing;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 审计日志中需要脱敏的 PII 字段名(小写匹配)
|
||||
const PII_FIELDS: &[&str] = &[
|
||||
"id_number",
|
||||
"phone",
|
||||
"emergency_contact_phone",
|
||||
"emergency_contact_name",
|
||||
"allergy_history",
|
||||
"medical_history_summary",
|
||||
"name",
|
||||
"content",
|
||||
];
|
||||
|
||||
/// 审计日志中需要脱敏的 resource_type 前缀
|
||||
const PII_RESOURCE_TYPES: &[&str] = &[
|
||||
"patient",
|
||||
"consultation",
|
||||
"follow_up",
|
||||
"family_member",
|
||||
"doctor_profile",
|
||||
];
|
||||
|
||||
/// 对 JSON Value 中的 PII 字段进行脱敏
|
||||
fn sanitize_audit_value(
|
||||
value: &Option<serde_json::Value>,
|
||||
resource_type: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
let needs_sanitization = PII_RESOURCE_TYPES
|
||||
.iter()
|
||||
.any(|prefix| resource_type.starts_with(prefix));
|
||||
|
||||
if !needs_sanitization {
|
||||
return value.clone();
|
||||
}
|
||||
|
||||
value.as_ref().map(sanitize_json_value)
|
||||
}
|
||||
|
||||
fn sanitize_json_value(v: &serde_json::Value) -> serde_json::Value {
|
||||
match v {
|
||||
serde_json::Value::Object(map) => {
|
||||
let sanitized: serde_json::Map<String, serde_json::Value> = map
|
||||
.into_iter()
|
||||
.map(|(k, v)| {
|
||||
let key_lower = k.to_lowercase();
|
||||
if PII_FIELDS.iter().any(|f| key_lower.contains(f)) {
|
||||
(k.clone(), serde_json::Value::String("***".to_string()))
|
||||
} else {
|
||||
(k.clone(), sanitize_json_value(v))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
serde_json::Value::Object(sanitized)
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
serde_json::Value::Array(arr.iter().map(sanitize_json_value).collect())
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 持久化审计日志到 audit_logs 表。
|
||||
///
|
||||
/// 使用 fire-and-forget 模式:失败仅记录 warning 日志,不影响业务操作。
|
||||
@@ -43,6 +103,10 @@ pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
|
||||
// 计算当前记录的 record_hash
|
||||
let record_hash = compute_record_hash(&log, prev_hash.as_deref());
|
||||
|
||||
// 脱敏处理:对 patient/consultation/follow_up 等资源类型的变更值中 PII 字段进行 mask
|
||||
let sanitized_old = sanitize_audit_value(&log.old_value, &log.resource_type);
|
||||
let sanitized_new = sanitize_audit_value(&log.new_value, &log.resource_type);
|
||||
|
||||
// 保存日志字段用于错误日志(model 构建会 move String 字段)
|
||||
let err_tenant_id = log.tenant_id;
|
||||
let err_action = log.action.clone();
|
||||
@@ -56,8 +120,8 @@ pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
|
||||
action: Set(log.action),
|
||||
resource_type: Set(log.resource_type),
|
||||
resource_id: Set(log.resource_id),
|
||||
old_value: Set(log.old_value),
|
||||
new_value: Set(log.new_value),
|
||||
old_value: Set(sanitized_old),
|
||||
new_value: Set(sanitized_new),
|
||||
ip_address: Set(log.ip_address),
|
||||
user_agent: Set(log.user_agent),
|
||||
created_at: Set(log.created_at),
|
||||
|
||||
66
crates/erp-health/src/dto/export_dto.rs
Normal file
66
crates/erp-health/src/dto/export_dto.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
//! 患者数据导出 DTO(个保法 §45 数据可携权)
|
||||
//!
|
||||
//! 双格式分工:
|
||||
//! - `json` — 自定义 JSON,PII 明文(可携权本意,患者拿到完整数据)
|
||||
//! - `fhir` — FHIR R4 Bundle,复用现有 converter,PII 天然脱敏(标准化互操作)
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::IntoParams;
|
||||
|
||||
/// 导出格式
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, utoipa::ToSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ExportFormat {
|
||||
/// 自定义 JSON(明文 PII,可携权本意)
|
||||
#[default]
|
||||
Json,
|
||||
/// FHIR R4 Bundle(标准化互操作,PII 脱敏)
|
||||
Fhir,
|
||||
}
|
||||
|
||||
impl ExportFormat {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Json => "json",
|
||||
Self::Fhir => "fhir",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 导出查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct ExportQuery {
|
||||
/// 导出格式:json(默认,明文)/ fhir(标准化 Bundle)
|
||||
pub format: Option<String>,
|
||||
}
|
||||
|
||||
impl ExportQuery {
|
||||
/// 解析 format 参数,未知值/缺省回退到 json
|
||||
pub fn parse_format(&self) -> ExportFormat {
|
||||
match self
|
||||
.format
|
||||
.as_deref()
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref()
|
||||
{
|
||||
Some("fhir") => ExportFormat::Fhir,
|
||||
_ => ExportFormat::Json,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 导出响应
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct ExportResp {
|
||||
/// 导出格式
|
||||
pub format: ExportFormat,
|
||||
/// 导出时间
|
||||
pub exported_at: DateTime<Utc>,
|
||||
/// 各资源类型数量统计(如 `{"observations":12,"appointments":3}`)
|
||||
pub resource_counts: serde_json::Value,
|
||||
/// 是否因 limit 截断(MVP 同步导出,大数据量时为 true)
|
||||
pub truncated: bool,
|
||||
/// 导出载荷(JSON 明文结构 或 FHIR Bundle)
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod consultation_dto;
|
||||
pub mod daily_monitoring_dto;
|
||||
pub mod diagnosis_dto;
|
||||
pub mod doctor_dto;
|
||||
pub mod export_dto;
|
||||
pub mod follow_up_dto;
|
||||
pub mod follow_up_template_dto;
|
||||
pub mod health_data_dto;
|
||||
|
||||
@@ -64,6 +64,8 @@ pub const PATIENT_UPDATED: &str = "patient.updated";
|
||||
// TODO: 以下常量对应的患者认证和死亡记录流程尚未实现,待后续迭代
|
||||
pub const PATIENT_VERIFIED: &str = "patient.verified";
|
||||
pub const PATIENT_DECEASED: &str = "patient.deceased";
|
||||
/// 患者数据导出(个保法 §45 数据可携权)— 审计 + 后续可触发导出完成通知
|
||||
pub const PATIENT_EXPORTED: &str = "patient.exported";
|
||||
|
||||
// 积分
|
||||
pub const POINTS_EXPIRED: &str = "points.expired";
|
||||
|
||||
@@ -10,11 +10,13 @@ use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::dto::DeleteWithVersion;
|
||||
use crate::dto::export_dto::{ExportQuery, ExportResp};
|
||||
use crate::dto::patient_dto::{
|
||||
BatchImportPatientReq, BatchResultResp, BindByPhoneReq, BindResultResp, CreatePatientReq,
|
||||
FamilyMemberReq, FamilyMemberResp, ManageTagsReq, PatientResp, PatientSummary, ReferPatientReq,
|
||||
ReferResultResp, UpdatePatientReq,
|
||||
};
|
||||
use crate::handler::consent_check::check_consent_active;
|
||||
use crate::service::patient_service;
|
||||
use crate::state::HealthState;
|
||||
|
||||
@@ -24,6 +26,9 @@ pub struct PatientListParams {
|
||||
pub page_size: Option<u64>,
|
||||
pub search: Option<String>,
|
||||
pub tag_id: Option<Uuid>,
|
||||
/// Optional user_id filter — only return patients linked to this user.
|
||||
/// Used by the mini-program to fetch only the logged-in user's own patients.
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// 分配医生请求
|
||||
@@ -70,7 +75,9 @@ where
|
||||
require_permission(&ctx, "health.patient.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20).min(100);
|
||||
let result = patient_service::list_summaries(&state, ctx.tenant_id, page, page_size).await?;
|
||||
let result =
|
||||
patient_service::list_summaries(&state, ctx.tenant_id, page, page_size, params.user_id)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -577,3 +584,50 @@ where
|
||||
patient_service::refer_patient(&state, ctx.tenant_id, id, req, Some(ctx.user_id)).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
/// 患者数据导出(个保法 §45 数据可携权)
|
||||
///
|
||||
/// 双格式:`json`(明文 PII,可携本意)/ `fhir`(标准化 Bundle,脱敏)。
|
||||
/// 强制 consent 门控 + patient 角色 self-scope(仅导出自己 user_id 关联的档案)。
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health/patients/{id}/export",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "患者 ID"),
|
||||
ExportQuery,
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "导出成功", body = ExportResp),
|
||||
(status = 403, description = "无权限 / 仅能导出自己的数据 / 知情同意未授权"),
|
||||
(status = 404, description = "患者不存在"),
|
||||
),
|
||||
tag = "患者管理",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn export_patient<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Query(query): Query<ExportQuery>,
|
||||
) -> Result<Json<ApiResponse<ExportResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.patient.export")?;
|
||||
check_consent_active(&state.db, ctx.tenant_id, id, &ctx).await?;
|
||||
|
||||
// patient 角色 self-scope:只能导出自己 user_id 关联的档案。
|
||||
// 用 get_patient_user_id 轻量查询,避免校验阶段解密他人 PII。
|
||||
if ctx.roles.iter().any(|r| r == "patient") {
|
||||
let owner = patient_service::get_patient_user_id(&state, ctx.tenant_id, id).await?;
|
||||
if owner != Some(ctx.user_id) {
|
||||
return Err(AppError::Forbidden("只能导出自己的数据".into()));
|
||||
}
|
||||
}
|
||||
|
||||
let fmt = query.parse_format();
|
||||
let resp =
|
||||
patient_service::export_patient(&state, ctx.tenant_id, Some(ctx.user_id), id, fmt).await?;
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
@@ -72,6 +72,11 @@ where
|
||||
"/health/patients/{id}/refer",
|
||||
axum::routing::post(patient_handler::refer_patient),
|
||||
)
|
||||
// 患者数据导出(个保法 §45 数据可携权)
|
||||
.route(
|
||||
"/health/patients/{id}/export",
|
||||
axum::routing::get(patient_handler::export_patient),
|
||||
)
|
||||
// 家庭成员健康代理 — 管理端
|
||||
.route(
|
||||
"/health/patients/{patient_id}/family-members/{family_member_id}/grant-access",
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
//! 数据脱敏和状态转换验证
|
||||
//!
|
||||
//! 脱敏函数统一使用 erp_core::crypto 中的实现(Unicode 安全版本)。
|
||||
//! 此模块仅保留 health 业务特有的 validate_status_transition。
|
||||
|
||||
use crate::error::{HealthError, HealthResult};
|
||||
|
||||
/// 身份证号脱敏: 保留前 3 位和后 4 位,中间用 * 替代
|
||||
pub fn mask_id_number(s: &str) -> String {
|
||||
if s.len() >= 7 {
|
||||
format!("{}****{}", &s[..3], &s[s.len() - 4..])
|
||||
} else {
|
||||
"****".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 手机号脱敏: 保留前 3 位和后 4 位,中间用 * 替代
|
||||
pub fn mask_phone(s: Option<&str>) -> Option<String> {
|
||||
s.map(|p| {
|
||||
if p.len() >= 7 {
|
||||
format!("{}****{}", &p[..3], &p[p.len() - 4..])
|
||||
} else {
|
||||
"****".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
// 重导出 erp-core 的脱敏函数,供 health 模块内部统一引用
|
||||
pub use erp_core::crypto::{mask_id_number, mask_phone};
|
||||
|
||||
/// 状态机转换校验: 检查 (current → new) 是否在 allowed_transitions 中
|
||||
pub fn validate_status_transition(
|
||||
@@ -54,16 +40,6 @@ mod tests {
|
||||
assert_eq!("110****1234", mask_id_number("110101199001011234"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_id_15_digits() {
|
||||
assert_eq!("123****2345", mask_id_number("123456789012345"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_id_7_chars() {
|
||||
assert_eq!("123****4567", mask_id_number("1234567"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_id_short() {
|
||||
assert_eq!("****", mask_id_number("123456"));
|
||||
@@ -82,16 +58,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_phone_7_chars() {
|
||||
assert_eq!(Some("123****4567".to_string()), mask_phone(Some("1234567")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_phone_short() {
|
||||
assert_eq!(Some("****".to_string()), mask_phone(Some("123456")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_phone_none() {
|
||||
assert_eq!(None, mask_phone(None));
|
||||
|
||||
@@ -254,6 +254,19 @@ pub async fn get_patient(
|
||||
Ok(model_to_resp_decrypted(&state.crypto, model))
|
||||
}
|
||||
|
||||
/// 查询患者关联的 user_id(仅用于权限 self-scope 校验,不解密 PII)
|
||||
///
|
||||
/// 个保法 §45 导出场景:patient 角色只能导出自己 user_id 关联的档案。
|
||||
/// 用此轻量查询避免在校验阶段解密他人 PII(仅读 user_id 非敏感字段)。
|
||||
pub async fn get_patient_user_id(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> HealthResult<Option<Uuid>> {
|
||||
let model = find_patient(&state.db, tenant_id, id).await?;
|
||||
Ok(model.user_id)
|
||||
}
|
||||
|
||||
/// 更新患者信息(乐观锁)
|
||||
pub async fn update_patient(
|
||||
state: &HealthState,
|
||||
@@ -552,19 +565,27 @@ pub async fn bind_by_phone(
|
||||
}
|
||||
|
||||
/// 患者摘要列表 — 仅返回非敏感字段,供小程序切换/列表使用
|
||||
///
|
||||
/// When `user_id` is provided, only patients linked to that user are returned.
|
||||
/// This allows the mini-program to fetch only the logged-in user's own patients.
|
||||
pub async fn list_summaries(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
user_id: Option<Uuid>,
|
||||
) -> HealthResult<PaginatedResponse<PatientSummary>> {
|
||||
let limit = page_size.min(100);
|
||||
let offset = page.saturating_sub(1) * limit;
|
||||
|
||||
let query = patient::Entity::find()
|
||||
let mut query = patient::Entity::find()
|
||||
.filter(patient::Column::TenantId.eq(tenant_id))
|
||||
.filter(patient::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(uid) = user_id {
|
||||
query = query.filter(patient::Column::UserId.eq(uid));
|
||||
}
|
||||
|
||||
let total = query.clone().count(&state.db).await?;
|
||||
|
||||
let models = query
|
||||
|
||||
278
crates/erp-health/src/service/patient_service/export.rs
Normal file
278
crates/erp-health/src/service/patient_service/export.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
//! 患者数据导出 Service(个保法 §45 数据可携权)
|
||||
//!
|
||||
//! 双格式:
|
||||
//! - `json` — 自定义 JSON,PII 明文(可携权本意,患者拿到完整数据)
|
||||
//! - `fhir` — FHIR R4 Bundle,复用现有 converter,PII 天然脱敏
|
||||
//!
|
||||
//! 数据装配逻辑复刻 `fhir::patient_everything`(7 段查询 + 相同 limit),
|
||||
//! 但走 `/health/` JWT 体系(非 `/fhir` OAuth),并补明文 JSON 格式。
|
||||
//! 强制审计 `patient.exported`(new_value 只含 format/counts/标记位,绝不落明文 PII)。
|
||||
|
||||
use chrono::Utc;
|
||||
use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
use erp_core::events::DomainEvent;
|
||||
use sea_orm::QuerySelect;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::export_dto::{ExportFormat, ExportResp};
|
||||
use crate::entity::{
|
||||
appointment, consultation_session, device_readings, follow_up_task, lab_report, patient,
|
||||
patient_devices,
|
||||
};
|
||||
use crate::error::HealthResult;
|
||||
use crate::fhir::converter;
|
||||
use crate::state::HealthState;
|
||||
|
||||
use super::helper::{find_patient, patient_plaintext_pii};
|
||||
|
||||
/// 导出限制(与 fhir::patient_everything 对齐,避免大数据量阻塞同步响应)
|
||||
const OBSERVATIONS_LIMIT: u64 = 200;
|
||||
const TASKS_LIMIT: u64 = 50;
|
||||
const REPORTS_LIMIT: u64 = 50;
|
||||
|
||||
/// 装配的患者数据(原始 Model 向量,两格式共享同一批查询)
|
||||
struct AssembledData {
|
||||
patient: patient::Model,
|
||||
readings: Vec<device_readings::Model>,
|
||||
devices: Vec<patient_devices::Model>,
|
||||
consultations: Vec<consultation_session::Model>,
|
||||
appointments: Vec<appointment::Model>,
|
||||
tasks: Vec<follow_up_task::Model>,
|
||||
reports: Vec<lab_report::Model>,
|
||||
}
|
||||
|
||||
/// 导出患者数据(个保法 §45 数据可携权)
|
||||
///
|
||||
/// 强制审计 `patient.exported`(不含明文 PII),并发事件 `patient.exported`。
|
||||
/// 日志只记元数据(patient_id/format/counts),绝不记录 payload。
|
||||
pub async fn export_patient(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Option<Uuid>,
|
||||
patient_id: Uuid,
|
||||
format: ExportFormat,
|
||||
) -> HealthResult<ExportResp> {
|
||||
tracing::info!(
|
||||
action = "export_patient",
|
||||
%patient_id,
|
||||
%tenant_id,
|
||||
format = format.as_str(),
|
||||
"Exporting patient data (PIPL §45)"
|
||||
);
|
||||
|
||||
let data = assemble(state, tenant_id, patient_id).await?;
|
||||
let truncated = data.readings.len() as u64 >= OBSERVATIONS_LIMIT
|
||||
|| data.tasks.len() as u64 >= TASKS_LIMIT
|
||||
|| data.reports.len() as u64 >= REPORTS_LIMIT;
|
||||
|
||||
let (payload, counts) = match format {
|
||||
ExportFormat::Json => build_json_payload(state, &data),
|
||||
ExportFormat::Fhir => (build_fhir_bundle(&data), build_counts(&data)),
|
||||
};
|
||||
|
||||
let exported_at = Utc::now();
|
||||
|
||||
// 审计:只记录动作元数据,绝不落明文 PII。
|
||||
// json 格式 contains_plaintext_pii=true 标记响应含明文,便于事后追溯。
|
||||
let audit_value = serde_json::json!({
|
||||
"format": format.as_str(),
|
||||
"resource_counts": counts.clone(),
|
||||
"contains_plaintext_pii": format == ExportFormat::Json,
|
||||
});
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "patient.exported", "patient")
|
||||
.with_resource_id(patient_id)
|
||||
.with_changes(None, Some(audit_value)),
|
||||
&state.db,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 事件(现有 event/patient.rs 订阅器对 exported 是 no-op,无副作用;留作后续触发通知)
|
||||
let event = DomainEvent::new(
|
||||
crate::event::PATIENT_EXPORTED,
|
||||
tenant_id,
|
||||
erp_core::events::build_event_payload(serde_json::json!({
|
||||
"patient_id": patient_id,
|
||||
"format": format.as_str(),
|
||||
})),
|
||||
);
|
||||
state.event_bus.publish(event, &state.db).await;
|
||||
|
||||
tracing::info!(
|
||||
action = "export_patient",
|
||||
%patient_id,
|
||||
format = format.as_str(),
|
||||
truncated,
|
||||
"Patient export completed"
|
||||
);
|
||||
|
||||
Ok(ExportResp {
|
||||
format,
|
||||
exported_at,
|
||||
resource_counts: counts,
|
||||
truncated,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
/// 装配患者全量数据(7 段查询,复刻 fhir::patient_everything 装配逻辑)
|
||||
async fn assemble(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
) -> HealthResult<AssembledData> {
|
||||
let patient = find_patient(&state.db, tenant_id, patient_id).await?;
|
||||
|
||||
let readings = device_readings::Entity::find()
|
||||
.filter(device_readings::Column::PatientId.eq(patient_id))
|
||||
.filter(device_readings::Column::TenantId.eq(tenant_id))
|
||||
.filter(device_readings::Column::DeletedAt.is_null())
|
||||
.limit(OBSERVATIONS_LIMIT)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let devices = patient_devices::Entity::find()
|
||||
.filter(patient_devices::Column::PatientId.eq(patient_id))
|
||||
.filter(patient_devices::Column::TenantId.eq(tenant_id))
|
||||
.filter(patient_devices::Column::DeletedAt.is_null())
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let consultations = consultation_session::Entity::find()
|
||||
.filter(consultation_session::Column::PatientId.eq(patient_id))
|
||||
.filter(consultation_session::Column::TenantId.eq(tenant_id))
|
||||
.filter(consultation_session::Column::DeletedAt.is_null())
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let appointments = appointment::Entity::find()
|
||||
.filter(appointment::Column::PatientId.eq(patient_id))
|
||||
.filter(appointment::Column::TenantId.eq(tenant_id))
|
||||
.filter(appointment::Column::DeletedAt.is_null())
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let tasks = follow_up_task::Entity::find()
|
||||
.filter(follow_up_task::Column::PatientId.eq(patient_id))
|
||||
.filter(follow_up_task::Column::TenantId.eq(tenant_id))
|
||||
.filter(follow_up_task::Column::DeletedAt.is_null())
|
||||
.limit(TASKS_LIMIT)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let reports = lab_report::Entity::find()
|
||||
.filter(lab_report::Column::PatientId.eq(patient_id))
|
||||
.filter(lab_report::Column::TenantId.eq(tenant_id))
|
||||
.filter(lab_report::Column::DeletedAt.is_null())
|
||||
.limit(REPORTS_LIMIT)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(AssembledData {
|
||||
patient,
|
||||
readings,
|
||||
devices,
|
||||
consultations,
|
||||
appointments,
|
||||
tasks,
|
||||
reports,
|
||||
})
|
||||
}
|
||||
|
||||
/// 构建 JSON 明文载荷(解密 PII 不脱敏)+ counts
|
||||
fn build_json_payload(
|
||||
state: &HealthState,
|
||||
data: &AssembledData,
|
||||
) -> (serde_json::Value, serde_json::Value) {
|
||||
let pii = patient_plaintext_pii(&state.crypto, &data.patient);
|
||||
|
||||
// data 是引用,非 Copy 字段(String/Option<String>/Vec)须用 & 避免 move out of borrow
|
||||
let patient_json = serde_json::json!({
|
||||
"id": data.patient.id,
|
||||
"tenant_id": data.patient.tenant_id,
|
||||
"user_id": data.patient.user_id,
|
||||
"name": &data.patient.name,
|
||||
"gender": &data.patient.gender,
|
||||
"birth_date": data.patient.birth_date,
|
||||
"blood_type": &data.patient.blood_type,
|
||||
"id_number": &pii.id_number,
|
||||
"allergy_history": &pii.allergy_history,
|
||||
"medical_history_summary": &pii.medical_history_summary,
|
||||
"emergency_contact_name": &data.patient.emergency_contact_name,
|
||||
"emergency_contact_phone": &pii.emergency_contact_phone,
|
||||
"status": &data.patient.status,
|
||||
"verification_status": &data.patient.verification_status,
|
||||
"source": &data.patient.source,
|
||||
"notes": &data.patient.notes,
|
||||
"created_at": data.patient.created_at,
|
||||
"updated_at": data.patient.updated_at,
|
||||
});
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"export": {
|
||||
"legal_basis": "PIPL §45 数据可携权",
|
||||
"format": "json",
|
||||
"note": "包含明文个人身份信息(PII),请妥善保管",
|
||||
},
|
||||
"patient": patient_json,
|
||||
"device_readings": &data.readings,
|
||||
"patient_devices": &data.devices,
|
||||
"consultation_sessions": &data.consultations,
|
||||
"appointments": &data.appointments,
|
||||
"follow_up_tasks": &data.tasks,
|
||||
"lab_reports": &data.reports,
|
||||
});
|
||||
|
||||
(payload, build_counts(data))
|
||||
}
|
||||
|
||||
/// 构建 FHIR R4 Bundle(复用 converter,PII 天然脱敏)
|
||||
fn build_fhir_bundle(data: &AssembledData) -> serde_json::Value {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
entries.push(serde_json::json!({
|
||||
"resource": converter::patient_to_fhir(&data.patient),
|
||||
"fullUrl": format!("https://hms.local/fhir/R4/Patient/{}", data.patient.id),
|
||||
}));
|
||||
for r in &data.readings {
|
||||
for obs in converter::device_reading_to_fhir_observations(r) {
|
||||
entries.push(serde_json::json!({ "resource": obs }));
|
||||
}
|
||||
}
|
||||
for d in &data.devices {
|
||||
entries.push(serde_json::json!({ "resource": converter::patient_device_to_fhir(d) }));
|
||||
}
|
||||
for c in &data.consultations {
|
||||
entries.push(serde_json::json!({ "resource": converter::consultation_to_fhir(c) }));
|
||||
}
|
||||
for a in &data.appointments {
|
||||
entries.push(serde_json::json!({ "resource": converter::appointment_to_fhir(a) }));
|
||||
}
|
||||
for t in &data.tasks {
|
||||
entries.push(serde_json::json!({ "resource": converter::follow_up_to_fhir(t) }));
|
||||
}
|
||||
for r in &data.reports {
|
||||
entries.push(serde_json::json!({ "resource": converter::lab_report_to_fhir(r) }));
|
||||
}
|
||||
|
||||
serde_json::json!({
|
||||
"resourceType": "Bundle",
|
||||
"type": "collection",
|
||||
"total": entries.len(),
|
||||
"entry": entries,
|
||||
})
|
||||
}
|
||||
|
||||
/// 各资源数量统计
|
||||
fn build_counts(data: &AssembledData) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"observations": data.readings.len(),
|
||||
"devices": data.devices.len(),
|
||||
"encounters": data.consultations.len(),
|
||||
"appointments": data.appointments.len(),
|
||||
"tasks": data.tasks.len(),
|
||||
"diagnostic_reports": data.reports.len(),
|
||||
})
|
||||
}
|
||||
@@ -92,8 +92,40 @@ pub(crate) fn model_to_resp_decrypted(crypto: &PiiCrypto, m: patient::Model) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// 患者明文 PII(仅用于数据导出 §45 可携权,不脱敏)
|
||||
///
|
||||
/// 与 `model_to_resp_decrypted` 区别:后者对 id_number/phone 脱敏用于日常展示;
|
||||
/// 本结构返回原始明文,专供患者数据可携权导出(个保法 §45)。
|
||||
pub(crate) struct PatientPlaintextPii {
|
||||
pub id_number: Option<String>,
|
||||
pub allergy_history: Option<String>,
|
||||
pub medical_history_summary: Option<String>,
|
||||
pub emergency_contact_phone: Option<String>,
|
||||
}
|
||||
|
||||
/// 解密患者全部 PII 字段(不脱敏),供数据导出使用
|
||||
pub(crate) fn patient_plaintext_pii(crypto: &PiiCrypto, m: &patient::Model) -> PatientPlaintextPii {
|
||||
let kek = crypto.kek();
|
||||
PatientPlaintextPii {
|
||||
id_number: decrypt_field(kek, &m.id_number, "id_number", m.id),
|
||||
allergy_history: decrypt_field(kek, &m.allergy_history, "allergy_history", m.id),
|
||||
medical_history_summary: decrypt_field(
|
||||
kek,
|
||||
&m.medical_history_summary,
|
||||
"medical_history_summary",
|
||||
m.id,
|
||||
),
|
||||
emergency_contact_phone: decrypt_field(
|
||||
kek,
|
||||
&m.emergency_contact_phone,
|
||||
"emergency_contact_phone",
|
||||
m.id,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 解密单个 PII 字段,失败时输出 warn 日志并返回 None
|
||||
fn decrypt_field(
|
||||
pub(crate) fn decrypt_field(
|
||||
kek: &[u8; 32],
|
||||
field: &Option<String>,
|
||||
name: &str,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
//! 患者管理 Service — CRUD、家庭成员、标签、医生关联、健康摘要
|
||||
//! 患者管理 Service — CRUD、家庭成员、标签、医生关联、健康摘要、数据导出
|
||||
//!
|
||||
//! 按 4 个功能域组织:
|
||||
//! 按 5 个功能域组织:
|
||||
//! - `crud` — 患者基础 CRUD 操作
|
||||
//! - `export` — 患者数据导出(个保法 §45 数据可携权)
|
||||
//! - `relation` — 家庭成员、医生关联、标签管理(患者关联)、健康摘要
|
||||
//! - `tag` — 患者标签 CRUD
|
||||
//! - `helper` — 共享辅助函数
|
||||
|
||||
mod crud;
|
||||
mod export;
|
||||
mod helper;
|
||||
mod relation;
|
||||
mod tag;
|
||||
@@ -14,8 +16,9 @@ mod tag;
|
||||
// 从各子模块重新导出所有公开函数,保持 handler 层调用路径不变
|
||||
pub use crud::{
|
||||
batch_import_patients, bind_by_phone, create_patient, delete_patient, get_patient,
|
||||
list_patients, list_summaries, update_patient,
|
||||
get_patient_user_id, list_patients, list_summaries, update_patient,
|
||||
};
|
||||
pub use export::export_patient;
|
||||
pub use relation::{
|
||||
assign_doctor, create_family_member, delete_family_member, get_health_summary,
|
||||
list_family_members, manage_patient_tags, refer_patient, remove_doctor, update_family_member,
|
||||
|
||||
@@ -59,6 +59,20 @@ pub async fn create_rule(
|
||||
operator_id: Option<Uuid>,
|
||||
req: CreatePointsRuleReq,
|
||||
) -> HealthResult<PointsRuleResp> {
|
||||
// 查重:同一租户下不允许重复的 event_type
|
||||
let existing = points_rule::Entity::find()
|
||||
.filter(points_rule::Column::TenantId.eq(tenant_id))
|
||||
.filter(points_rule::Column::EventType.eq(&req.event_type))
|
||||
.filter(points_rule::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await?;
|
||||
if existing.is_some() {
|
||||
return Err(HealthError::Validation(format!(
|
||||
"事件类型 '{}' 已存在规则,不可重复创建",
|
||||
req.event_type
|
||||
)));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let active = points_rule::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
//! 统计 Service — 工作台管理统计
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use sea_orm::{ConnectionTrait, FromQueryResult};
|
||||
use tokio::try_join;
|
||||
|
||||
use erp_core::error::AppResult;
|
||||
|
||||
use crate::dto::stats_dto::*;
|
||||
use crate::state::HealthState;
|
||||
|
||||
/// 文章状态统计
|
||||
// ---------------------------------------------------------------------------
|
||||
// 健康检测结果缓存(30s TTL)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static HEALTH_CACHE: std::sync::OnceLock<Mutex<Option<(Instant, SystemHealthResp)>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
fn get_health_cache() -> &'static Mutex<Option<(Instant, SystemHealthResp)>> {
|
||||
HEALTH_CACHE.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
const HEALTH_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 文章统计
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_article_stats(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
@@ -61,7 +82,10 @@ pub async fn get_article_stats(
|
||||
})
|
||||
}
|
||||
|
||||
/// 积分最近动态
|
||||
// ---------------------------------------------------------------------------
|
||||
// 积分最近动态
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_points_recent_activity(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
@@ -113,7 +137,10 @@ pub async fn get_points_recent_activity(
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 模块状态
|
||||
// ---------------------------------------------------------------------------
|
||||
// 模块状态(entity_count 校正为实际值)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStatusResp>> {
|
||||
let modules = vec![
|
||||
ModuleStatusResp {
|
||||
@@ -121,7 +148,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
|
||||
display_name: "身份权限".into(),
|
||||
description: "用户/角色/权限/组织/部门".into(),
|
||||
active: true,
|
||||
entity_count: Some(9),
|
||||
entity_count: Some(13),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
@@ -129,7 +156,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
|
||||
display_name: "系统配置".into(),
|
||||
description: "字典/菜单/设置/编号规则".into(),
|
||||
active: true,
|
||||
entity_count: Some(6),
|
||||
entity_count: Some(7),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
@@ -137,7 +164,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
|
||||
display_name: "工作流引擎".into(),
|
||||
description: "BPMN 解析/任务分配".into(),
|
||||
active: true,
|
||||
entity_count: Some(5),
|
||||
entity_count: Some(6),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
@@ -145,7 +172,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
|
||||
display_name: "消息中心".into(),
|
||||
description: "消息/模板/订阅/通知".into(),
|
||||
active: true,
|
||||
entity_count: Some(3),
|
||||
entity_count: Some(4),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
@@ -153,7 +180,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
|
||||
display_name: "健康管理".into(),
|
||||
description: "患者/体征/预约/随访/咨询".into(),
|
||||
active: true,
|
||||
entity_count: Some(45),
|
||||
entity_count: Some(59),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
@@ -161,7 +188,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
|
||||
display_name: "AI 分析".into(),
|
||||
description: "智能分析/化验解读/趋势".into(),
|
||||
active: true,
|
||||
entity_count: Some(3),
|
||||
entity_count: Some(24),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
@@ -169,7 +196,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
|
||||
display_name: "透析管理".into(),
|
||||
description: "透析记录/处方/用药".into(),
|
||||
active: true,
|
||||
entity_count: Some(5),
|
||||
entity_count: Some(3),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
@@ -177,7 +204,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
|
||||
display_name: "插件系统".into(),
|
||||
description: "WASM 运行时/动态表".into(),
|
||||
active: true,
|
||||
entity_count: Some(4),
|
||||
entity_count: Some(6),
|
||||
route_count: None,
|
||||
},
|
||||
];
|
||||
@@ -185,16 +212,19 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
|
||||
Ok(modules)
|
||||
}
|
||||
|
||||
/// 用户活跃度统计
|
||||
// ---------------------------------------------------------------------------
|
||||
// 用户活跃度(基于 audit_log 真实操作记录)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_user_activity(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
) -> AppResult<UserActivityResp> {
|
||||
let sql = r#"
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND last_login_at >= NOW() - INTERVAL '1 day') AS daily_active,
|
||||
(SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND last_login_at >= NOW() - INTERVAL '7 days') AS weekly_active,
|
||||
(SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND last_login_at >= NOW() - INTERVAL '30 days') AS monthly_active,
|
||||
(SELECT COUNT(DISTINCT user_id) FROM audit_log WHERE tenant_id = $1 AND created_at >= NOW() - INTERVAL '1 day' AND user_id IS NOT NULL) AS daily_active,
|
||||
(SELECT COUNT(DISTINCT user_id) FROM audit_log WHERE tenant_id = $1 AND created_at >= NOW() - INTERVAL '7 days' AND user_id IS NOT NULL) AS weekly_active,
|
||||
(SELECT COUNT(DISTINCT user_id) FROM audit_log WHERE tenant_id = $1 AND created_at >= NOW() - INTERVAL '30 days' AND user_id IS NOT NULL) AS monthly_active,
|
||||
(SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL) AS total_registered
|
||||
"#;
|
||||
|
||||
@@ -263,79 +293,237 @@ pub async fn get_user_activity(
|
||||
})
|
||||
}
|
||||
|
||||
/// 系统健康检查
|
||||
// ---------------------------------------------------------------------------
|
||||
// 系统健康检查(全部真实检测,30s 缓存)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_system_health(state: &HealthState) -> AppResult<SystemHealthResp> {
|
||||
let mut services = Vec::new();
|
||||
// 检查缓存
|
||||
{
|
||||
let cache = get_health_cache().lock().unwrap();
|
||||
if let Some((ts, resp)) = cache.as_ref()
|
||||
&& ts.elapsed() < HEALTH_CACHE_TTL
|
||||
{
|
||||
return Ok(resp.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// 数据库检查
|
||||
let db_start = std::time::Instant::now();
|
||||
let db_status = match state
|
||||
.db
|
||||
// 并行执行所有检测
|
||||
let db_fut = check_database(&state.db);
|
||||
let queue_fut = check_eventbus_backlog(&state.db);
|
||||
let storage_fut = check_file_storage();
|
||||
let cron_fut = check_cron_heartbeat(&state.cron_heartbeat);
|
||||
|
||||
let (db_status, queue_status, storage_status, cron_status) =
|
||||
try_join!(db_fut, queue_fut, storage_fut, cron_fut)?;
|
||||
|
||||
let total_ms = start.elapsed().as_millis() as i64;
|
||||
|
||||
let mut services = Vec::new();
|
||||
|
||||
// PostgreSQL
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "PostgreSQL".into(),
|
||||
status: db_status.status.clone(),
|
||||
message: db_status.message.clone(),
|
||||
response_ms: db_status.response_ms,
|
||||
});
|
||||
|
||||
// 外部注入的组件检测(Redis 等,由 erp-server 提供)
|
||||
for (name, check_fn) in &state.external_health_checks {
|
||||
let result = check_fn().await;
|
||||
services.push(ServiceHealthStatus {
|
||||
name: (*name).into(),
|
||||
status: result.status,
|
||||
message: result.message,
|
||||
response_ms: result.response_ms,
|
||||
});
|
||||
}
|
||||
|
||||
// 消息队列
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "消息队列".into(),
|
||||
status: queue_status.status.clone(),
|
||||
message: queue_status.message.clone(),
|
||||
response_ms: queue_status.response_ms,
|
||||
});
|
||||
|
||||
// 文件存储
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "文件存储".into(),
|
||||
status: storage_status.status.clone(),
|
||||
message: storage_status.message.clone(),
|
||||
response_ms: storage_status.response_ms,
|
||||
});
|
||||
|
||||
// 定时任务
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "定时任务".into(),
|
||||
status: cron_status.status.clone(),
|
||||
message: cron_status.message.clone(),
|
||||
response_ms: None,
|
||||
});
|
||||
|
||||
// API 服务(自身响应时间 = 最可靠的 API 健康指标)
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "API 服务".into(),
|
||||
status: "healthy".into(),
|
||||
message: format!("运行中 (检测耗时 {total_ms}ms)"),
|
||||
response_ms: Some(total_ms),
|
||||
});
|
||||
|
||||
let resp = SystemHealthResp {
|
||||
services,
|
||||
checked_at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
|
||||
// 更新缓存
|
||||
{
|
||||
let mut cache = get_health_cache().lock().unwrap();
|
||||
*cache = Some((Instant::now(), resp.clone()));
|
||||
}
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 各组件真实检测
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct CheckResult {
|
||||
status: String,
|
||||
message: String,
|
||||
response_ms: Option<i64>,
|
||||
}
|
||||
|
||||
async fn check_database(db: &sea_orm::DatabaseConnection) -> AppResult<CheckResult> {
|
||||
let t = std::time::Instant::now();
|
||||
let result = db
|
||||
.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"SELECT 1".to_string(),
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(_) => "healthy".to_string(),
|
||||
Err(e) => format!("down: {e}"),
|
||||
};
|
||||
let db_ms = db_start.elapsed().as_millis() as i64;
|
||||
.await;
|
||||
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "PostgreSQL".into(),
|
||||
status: if db_status == "healthy" {
|
||||
"healthy".into()
|
||||
} else {
|
||||
"down".into()
|
||||
let ms = t.elapsed().as_millis() as i64;
|
||||
Ok(match result {
|
||||
Ok(_) => CheckResult {
|
||||
status: "healthy".into(),
|
||||
message: format!("正常 ({ms}ms)"),
|
||||
response_ms: Some(ms),
|
||||
},
|
||||
message: if db_status == "healthy" {
|
||||
"正常".into()
|
||||
} else {
|
||||
db_status
|
||||
Err(e) => CheckResult {
|
||||
status: "down".into(),
|
||||
message: format!("不可用: {e}"),
|
||||
response_ms: Some(ms),
|
||||
},
|
||||
response_ms: Some(db_ms),
|
||||
});
|
||||
|
||||
// 基础服务状态(简化版 — 无 Redis/SMTP 时标记 healthy)
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "API 服务".into(),
|
||||
status: "healthy".into(),
|
||||
message: "运行中".into(),
|
||||
response_ms: Some(start.elapsed().as_millis() as i64),
|
||||
});
|
||||
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "定时任务".into(),
|
||||
status: "healthy".into(),
|
||||
message: "正常运行".into(),
|
||||
response_ms: None,
|
||||
});
|
||||
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "文件存储".into(),
|
||||
status: "healthy".into(),
|
||||
message: "可用".into(),
|
||||
response_ms: None,
|
||||
});
|
||||
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "消息队列".into(),
|
||||
status: "healthy".into(),
|
||||
message: "无积压".into(),
|
||||
response_ms: None,
|
||||
});
|
||||
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "缓存服务".into(),
|
||||
status: "healthy".into(),
|
||||
message: "正常".into(),
|
||||
response_ms: None,
|
||||
});
|
||||
|
||||
Ok(SystemHealthResp {
|
||||
services,
|
||||
checked_at: chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn check_eventbus_backlog(db: &sea_orm::DatabaseConnection) -> AppResult<CheckResult> {
|
||||
let t = std::time::Instant::now();
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct CountRow {
|
||||
cnt: i64,
|
||||
}
|
||||
|
||||
let sql = "SELECT COUNT(*)::bigint AS cnt FROM domain_events WHERE status = 'pending'";
|
||||
let result: Result<Option<CountRow>, _> = FromQueryResult::find_by_statement(
|
||||
sea_orm::Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.to_string()),
|
||||
)
|
||||
.one(db)
|
||||
.await;
|
||||
|
||||
let ms = t.elapsed().as_millis() as i64;
|
||||
|
||||
Ok(match result {
|
||||
Ok(Some(row)) => match row.cnt {
|
||||
0 => CheckResult {
|
||||
status: "healthy".into(),
|
||||
message: "无积压".into(),
|
||||
response_ms: Some(ms),
|
||||
},
|
||||
n if n <= 100 => CheckResult {
|
||||
status: "degraded".into(),
|
||||
message: format!("{n} 条待处理"),
|
||||
response_ms: Some(ms),
|
||||
},
|
||||
n => CheckResult {
|
||||
status: "down".into(),
|
||||
message: format!("积压严重: {n} 条"),
|
||||
response_ms: Some(ms),
|
||||
},
|
||||
},
|
||||
Ok(None) => CheckResult {
|
||||
status: "healthy".into(),
|
||||
message: "无积压".into(),
|
||||
response_ms: Some(ms),
|
||||
},
|
||||
Err(e) => CheckResult {
|
||||
status: "down".into(),
|
||||
message: format!("查询失败: {e}"),
|
||||
response_ms: Some(ms),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn check_file_storage() -> AppResult<CheckResult> {
|
||||
let upload_dir = std::path::Path::new("uploads");
|
||||
if !upload_dir.exists() || !upload_dir.is_dir() {
|
||||
return Ok(CheckResult {
|
||||
status: "down".into(),
|
||||
message: "uploads/ 目录不存在".into(),
|
||||
response_ms: None,
|
||||
});
|
||||
}
|
||||
|
||||
let test_path = upload_dir.join(".health_check_tmp");
|
||||
let t = std::time::Instant::now();
|
||||
match std::fs::write(&test_path, b"check") {
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(&test_path);
|
||||
let ms = t.elapsed().as_millis() as i64;
|
||||
Ok(CheckResult {
|
||||
status: "healthy".into(),
|
||||
message: format!("可读写 ({ms}ms)"),
|
||||
response_ms: Some(ms),
|
||||
})
|
||||
}
|
||||
Err(e) => Ok(CheckResult {
|
||||
status: "down".into(),
|
||||
message: format!("不可写: {e}"),
|
||||
response_ms: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_cron_heartbeat(
|
||||
heartbeat: &std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
) -> AppResult<CheckResult> {
|
||||
let last_ts = heartbeat.load(Ordering::Relaxed);
|
||||
let now_ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let elapsed_secs = now_ts.saturating_sub(last_ts);
|
||||
|
||||
// 阈值:最频繁的定时任务是 30s 一次的指标采样,设 5 分钟为容忍上限
|
||||
Ok(if elapsed_secs < 300 {
|
||||
CheckResult {
|
||||
status: "healthy".into(),
|
||||
message: format!("正常 (上次心跳 {}s 前)", elapsed_secs),
|
||||
response_ms: None,
|
||||
}
|
||||
} else {
|
||||
let mins = elapsed_secs / 60;
|
||||
CheckResult {
|
||||
status: "degraded".into(),
|
||||
message: format!("超过 {mins} 分钟无心跳"),
|
||||
response_ms: None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,6 +169,15 @@ mod m20260521_000164_reorganize_menus_scheme_b;
|
||||
mod m20260522_000160_article_add_is_public;
|
||||
mod m20260522_000161_patient_points_manage_perm;
|
||||
mod m20260522_000162_seed_patient_miniprogram_permissions;
|
||||
mod m20260526_000163_points_rule_unique_event_type;
|
||||
mod m20260526_000164_ai_prompt_add_analysis_type;
|
||||
mod m20260526_000165_ai_prompt_fix_analysis_type;
|
||||
mod m20260526_000166_create_ai_knowledge_bases;
|
||||
mod m20260526_000167_create_ai_knowledge_documents;
|
||||
mod m20260527_000168_ai_knowledge_v2_menu;
|
||||
mod m20260529_000169_supplement_rls_for_new_tables;
|
||||
mod m20260626_000170_extend_device_readings_partitions;
|
||||
mod m20260626_000171_seed_patient_export_permission;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -345,6 +354,15 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260522_000160_article_add_is_public::Migration),
|
||||
Box::new(m20260522_000161_patient_points_manage_perm::Migration),
|
||||
Box::new(m20260522_000162_seed_patient_miniprogram_permissions::Migration),
|
||||
Box::new(m20260526_000163_points_rule_unique_event_type::Migration),
|
||||
Box::new(m20260526_000164_ai_prompt_add_analysis_type::Migration),
|
||||
Box::new(m20260526_000165_ai_prompt_fix_analysis_type::Migration),
|
||||
Box::new(m20260526_000166_create_ai_knowledge_bases::Migration),
|
||||
Box::new(m20260526_000167_create_ai_knowledge_documents::Migration),
|
||||
Box::new(m20260527_000168_ai_knowledge_v2_menu::Migration),
|
||||
Box::new(m20260529_000169_supplement_rls_for_new_tables::Migration),
|
||||
Box::new(m20260626_000170_extend_device_readings_partitions::Migration),
|
||||
Box::new(m20260626_000171_seed_patient_export_permission::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
/// 为 points_rule 添加 (tenant_id, event_type) 唯一索引(排除软删除行)
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
// 删除旧的非唯一索引
|
||||
db.execute_unprepared("DROP INDEX IF EXISTS idx_points_rule_event_type")
|
||||
.await?;
|
||||
|
||||
// 创建部分唯一索引(仅对未软删除的行生效)
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_points_rule_tenant_event
|
||||
ON points_rule (tenant_id, event_type)
|
||||
WHERE deleted_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
db.execute_unprepared("DROP INDEX IF EXISTS uq_points_rule_tenant_event")
|
||||
.await?;
|
||||
db.execute_unprepared(
|
||||
"CREATE INDEX IF NOT EXISTS idx_points_rule_event_type ON points_rule (event_type)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
/// ai_prompt 新增 analysis_type 列作为后端选择键,name 回归显示名
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
// 1. 新增 analysis_type 列(先允许 NULL 以便回填)
|
||||
db.execute_unprepared(
|
||||
"ALTER TABLE ai_prompt ADD COLUMN IF NOT EXISTS analysis_type VARCHAR(64)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 2. 用 name 值回填 analysis_type(name 在旧数据中就是后端选择键)
|
||||
db.execute_unprepared(
|
||||
"UPDATE ai_prompt SET analysis_type = name WHERE analysis_type IS NULL AND deleted_at IS NULL",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 3. 设置 NOT NULL 约束
|
||||
db.execute_unprepared("ALTER TABLE ai_prompt ALTER COLUMN analysis_type SET NOT NULL")
|
||||
.await?;
|
||||
|
||||
// 4. 为 analysis_type 创建索引(后端按此列查询激活 Prompt)
|
||||
db.execute_unprepared(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ai_prompt_analysis_type ON ai_prompt (tenant_id, analysis_type, is_active) WHERE deleted_at IS NULL",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
db.execute_unprepared("DROP INDEX IF EXISTS idx_ai_prompt_analysis_type")
|
||||
.await?;
|
||||
db.execute_unprepared("ALTER TABLE ai_prompt DROP COLUMN IF EXISTS analysis_type")
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
/// 修复 ai_prompt.analysis_type 回填数据:从 name(真正的后端选择键)而非 category(泛化标签)
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
db.execute_unprepared(
|
||||
"UPDATE ai_prompt SET analysis_type = name WHERE analysis_type != name AND deleted_at IS NULL",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[derive(Iden)]
|
||||
enum AiKnowledgeBases {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
Name,
|
||||
KbType,
|
||||
Description,
|
||||
Icon,
|
||||
ChunkStrategy,
|
||||
IntentKeywords,
|
||||
EmbeddingModel,
|
||||
IsEnabled,
|
||||
DocumentCount,
|
||||
ChunkCount,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
VersionLock,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(AiKnowledgeBases::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeBases::TenantId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::Name)
|
||||
.string_len(200)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::KbType)
|
||||
.string_len(50)
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeBases::Description).text())
|
||||
.col(ColumnDef::new(AiKnowledgeBases::Icon).string_len(50))
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::ChunkStrategy)
|
||||
.json_binary()
|
||||
.not_null()
|
||||
.default(Expr::cust("'{}'")),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::IntentKeywords)
|
||||
.json_binary()
|
||||
.not_null()
|
||||
.default(Expr::cust("'[]'")),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeBases::EmbeddingModel).string_len(100))
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::IsEnabled)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(true),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::DocumentCount)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::ChunkCount)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeBases::CreatedBy).uuid())
|
||||
.col(ColumnDef::new(AiKnowledgeBases::UpdatedBy).uuid())
|
||||
.col(ColumnDef::new(AiKnowledgeBases::DeletedAt).timestamp_with_time_zone())
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeBases::VersionLock)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.if_not_exists()
|
||||
.name("idx_kb_tenant")
|
||||
.table(AiKnowledgeBases::Table)
|
||||
.col(AiKnowledgeBases::TenantId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.if_not_exists()
|
||||
.name("idx_kb_type")
|
||||
.table(AiKnowledgeBases::Table)
|
||||
.col(AiKnowledgeBases::TenantId)
|
||||
.col(AiKnowledgeBases::KbType)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(AiKnowledgeBases::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[derive(Iden)]
|
||||
enum AiKnowledgeDocuments {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
KnowledgeBaseId,
|
||||
Title,
|
||||
DocType,
|
||||
SourceType,
|
||||
SourceUrl,
|
||||
FileName,
|
||||
FileSize,
|
||||
FileMimeType,
|
||||
Content,
|
||||
Status,
|
||||
ChunkCount,
|
||||
EmbeddedCount,
|
||||
ErrorMessage,
|
||||
ProcessingStartedAt,
|
||||
ProcessingCompletedAt,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
VersionLock,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
enum AiKnowledgeChunks {
|
||||
Table,
|
||||
Id,
|
||||
TenantId,
|
||||
KnowledgeBaseId,
|
||||
DocumentId,
|
||||
ChunkIndex,
|
||||
Content,
|
||||
TokenCount,
|
||||
StartOffset,
|
||||
EndOffset,
|
||||
PageNumber,
|
||||
Metadata,
|
||||
HitCount,
|
||||
LastHitAt,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
CreatedBy,
|
||||
UpdatedBy,
|
||||
DeletedAt,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// ai_knowledge_documents
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(AiKnowledgeDocuments::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::TenantId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::KnowledgeBaseId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::Title)
|
||||
.string_len(500)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::DocType)
|
||||
.string_len(30)
|
||||
.not_null()
|
||||
.default("manual"),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::SourceType)
|
||||
.string_len(30)
|
||||
.not_null()
|
||||
.default("manual"),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeDocuments::SourceUrl).text())
|
||||
.col(ColumnDef::new(AiKnowledgeDocuments::FileName).string_len(500))
|
||||
.col(ColumnDef::new(AiKnowledgeDocuments::FileSize).big_integer())
|
||||
.col(ColumnDef::new(AiKnowledgeDocuments::FileMimeType).string_len(100))
|
||||
.col(ColumnDef::new(AiKnowledgeDocuments::Content).text())
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::Status)
|
||||
.string_len(20)
|
||||
.not_null()
|
||||
.default("pending"),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::ChunkCount)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::EmbeddedCount)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeDocuments::ErrorMessage).text())
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::ProcessingStartedAt)
|
||||
.timestamp_with_time_zone(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::ProcessingCompletedAt)
|
||||
.timestamp_with_time_zone(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeDocuments::CreatedBy).uuid())
|
||||
.col(ColumnDef::new(AiKnowledgeDocuments::UpdatedBy).uuid())
|
||||
.col(ColumnDef::new(AiKnowledgeDocuments::DeletedAt).timestamp_with_time_zone())
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeDocuments::VersionLock)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(1),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.if_not_exists()
|
||||
.name("idx_doc_kb")
|
||||
.table(AiKnowledgeDocuments::Table)
|
||||
.col(AiKnowledgeDocuments::KnowledgeBaseId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.if_not_exists()
|
||||
.name("idx_doc_status")
|
||||
.table(AiKnowledgeDocuments::Table)
|
||||
.col(AiKnowledgeDocuments::KnowledgeBaseId)
|
||||
.col(AiKnowledgeDocuments::Status)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ai_knowledge_chunks
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(AiKnowledgeChunks::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeChunks::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeChunks::TenantId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeChunks::KnowledgeBaseId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeChunks::DocumentId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeChunks::ChunkIndex)
|
||||
.integer()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeChunks::Content).text().not_null())
|
||||
.col(ColumnDef::new(AiKnowledgeChunks::TokenCount).integer())
|
||||
// embedding is vector(1536) — added via raw SQL below
|
||||
.col(ColumnDef::new(AiKnowledgeChunks::StartOffset).integer())
|
||||
.col(ColumnDef::new(AiKnowledgeChunks::EndOffset).integer())
|
||||
.col(ColumnDef::new(AiKnowledgeChunks::PageNumber).integer())
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeChunks::Metadata)
|
||||
.json_binary()
|
||||
.not_null()
|
||||
.default(Expr::cust("'{}'")),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeChunks::HitCount)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeChunks::LastHitAt).timestamp_with_time_zone())
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeChunks::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AiKnowledgeChunks::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(AiKnowledgeChunks::CreatedBy).uuid())
|
||||
.col(ColumnDef::new(AiKnowledgeChunks::UpdatedBy).uuid())
|
||||
.col(ColumnDef::new(AiKnowledgeChunks::DeletedAt).timestamp_with_time_zone())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Add embedding column as vector type (SeaORM doesn't support this natively)
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(
|
||||
"ALTER TABLE ai_knowledge_chunks ADD COLUMN IF NOT EXISTS embedding vector(1536)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// HNSW index for vector similarity search
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(
|
||||
"CREATE INDEX IF NOT EXISTS idx_chunk_embedding ON ai_knowledge_chunks USING hnsw (embedding vector_cosine_ops)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.if_not_exists()
|
||||
.name("idx_chunk_document")
|
||||
.table(AiKnowledgeChunks::Table)
|
||||
.col(AiKnowledgeChunks::DocumentId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.if_not_exists()
|
||||
.name("idx_chunk_kb")
|
||||
.table(AiKnowledgeChunks::Table)
|
||||
.col(AiKnowledgeChunks::KnowledgeBaseId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(AiKnowledgeChunks::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_table(Table::drop().table(AiKnowledgeDocuments::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 将旧版 AI 知识库菜单更新为 V2 版本
|
||||
let sql = r#"
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'sys_menu') THEN
|
||||
UPDATE sys_menu
|
||||
SET name = '知识库管理',
|
||||
icon = 'DatabaseOutlined',
|
||||
component = 'ai/KnowledgeV2Page',
|
||||
updated_at = now()
|
||||
WHERE path = '/health/ai-knowledge' AND deleted_at IS NULL;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"#;
|
||||
|
||||
manager.get_connection().execute_unprepared(sql).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'sys_menu') THEN
|
||||
UPDATE sys_menu
|
||||
SET name = 'AI 知识库',
|
||||
icon = 'BookOutlined',
|
||||
component = 'health/AiKnowledgePage',
|
||||
updated_at = now()
|
||||
WHERE path = '/health/ai-knowledge' AND deleted_at IS NULL;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let conn = manager.get_connection();
|
||||
|
||||
// 为 m000088 之后创建的新表补充 RLS 策略。
|
||||
// 幂等操作:仅影响尚未启用 RLS 或缺少策略的表。
|
||||
conn.execute_unprepared(
|
||||
r#"
|
||||
DO $$
|
||||
DECLARE
|
||||
tbl TEXT;
|
||||
policy_exists BOOLEAN;
|
||||
BEGIN
|
||||
FOR tbl IN
|
||||
SELECT c.table_name FROM information_schema.columns c
|
||||
JOIN information_schema.tables t
|
||||
ON c.table_name = t.table_name AND c.table_schema = t.table_schema
|
||||
WHERE c.column_name = 'tenant_id'
|
||||
AND c.table_schema = 'public'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
ORDER BY c.table_name
|
||||
LOOP
|
||||
-- 启用 RLS(幂等)
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
|
||||
|
||||
-- 检查是否已有 tenant_isolation 策略
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = tbl
|
||||
AND policyname = 'tenant_isolation'
|
||||
) INTO policy_exists;
|
||||
|
||||
IF NOT policy_exists THEN
|
||||
EXECUTE format(
|
||||
'CREATE POLICY tenant_isolation ON %I USING (
|
||||
current_setting(''app.current_tenant_id'', true) != ''''
|
||||
AND tenant_id = current_setting(''app.current_tenant_id'', true)::uuid
|
||||
)',
|
||||
tbl
|
||||
);
|
||||
RAISE NOTICE 'Created RLS policy for table: %', tbl;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 回滚不需要移除 RLS,保持 m000088 的策略不变
|
||||
// 此迁移补充的 RLS 策略在 down() 中保留,因为 m000088 已处理回滚
|
||||
let _ = manager;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
/// 补建 device_readings 分区到 2027_06。
|
||||
///
|
||||
/// 背景:m000073 只静态建了 2026_05~2026_08 四个分区,2026-09-01 起 INSERT 将因
|
||||
/// 无目标分区抛错,导致小程序 Veepoo M2 BLE 数据上传全线中断(确定性硬截止)。
|
||||
/// 本迁移补建 2026_09~2027_06 共 10 个月分区解除截止;中期应引入 pg_partman
|
||||
/// 或定时任务自动维护未来分区(见系统分析 PP-02)。
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 分区范围字面量为受控常量(非用户输入),与 m000073 写法一致
|
||||
let partitions: [(&str, &str, &str); 10] = [
|
||||
("2026_09", "2026-09-01", "2026-10-01"),
|
||||
("2026_10", "2026-10-01", "2026-11-01"),
|
||||
("2026_11", "2026-11-01", "2026-12-01"),
|
||||
("2026_12", "2026-12-01", "2027-01-01"),
|
||||
("2027_01", "2027-01-01", "2027-02-01"),
|
||||
("2027_02", "2027-02-01", "2027-03-01"),
|
||||
("2027_03", "2027-03-01", "2027-04-01"),
|
||||
("2027_04", "2027-04-01", "2027-05-01"),
|
||||
("2027_05", "2027-05-01", "2027-06-01"),
|
||||
("2027_06", "2027-06-01", "2027-07-01"),
|
||||
];
|
||||
for (suffix, start, end) in partitions {
|
||||
let sql = format!(
|
||||
"CREATE TABLE IF NOT EXISTS device_readings_{suffix} PARTITION OF device_readings FOR VALUES FROM ('{start}') TO ('{end}');"
|
||||
);
|
||||
manager.get_connection().execute_unprepared(&sql).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let suffixes = [
|
||||
"2026_09", "2026_10", "2026_11", "2026_12", "2027_01", "2027_02", "2027_03", "2027_04",
|
||||
"2027_05", "2027_06",
|
||||
];
|
||||
for suffix in suffixes {
|
||||
let sql = format!("DROP TABLE IF EXISTS device_readings_{suffix};");
|
||||
manager.get_connection().execute_unprepared(&sql).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
/// 个保法 §45 数据可携权:注册 health.patient.export 权限并分配角色
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
let sys = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
// 1) 注册 health.patient.export 权限(跨租户幂等)
|
||||
db.execute_unprepared(&format!(
|
||||
"INSERT INTO permissions (id, tenant_id, name, code, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version) \
|
||||
SELECT gen_random_uuid(), t.id, '患者数据导出(数据可携权)', 'health.patient.export', 'health', 'export', '个保法 §45 数据可携权:导出患者全量健康数据', NOW(), NOW(), '{sys}', '{sys}', NULL, 1 \
|
||||
FROM tenant t \
|
||||
WHERE NOT EXISTS (SELECT 1 FROM permissions p WHERE p.tenant_id = t.id AND p.code = 'health.patient.export' AND p.deleted_at IS NULL)"
|
||||
)).await?;
|
||||
|
||||
// 2) 医护和管理角色(data_scope=all):可导出任意患者数据
|
||||
let staff_roles: &[&str] = &["admin", "doctor", "nurse", "health_manager"];
|
||||
for role in staff_roles {
|
||||
assign_single_perm(db, role, "health.patient.export").await?;
|
||||
}
|
||||
|
||||
// 3) patient 角色(data_scope=self):仅导出自己的数据
|
||||
// handler 层 enforce self-scope:patient.user_id == ctx.user_id
|
||||
assign_perms_by_codes(db, "patient", &["health.patient.export"]).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
// 移除所有角色的 health.patient.export 关联
|
||||
db.execute_unprepared(
|
||||
"DELETE FROM role_permissions \
|
||||
WHERE permission_id IN (SELECT id FROM permissions WHERE code = 'health.patient.export')",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 软删除权限
|
||||
db.execute_unprepared(
|
||||
"UPDATE permissions SET deleted_at = NOW() \
|
||||
WHERE code = 'health.patient.export' AND deleted_at IS NULL",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn assign_perms_by_codes(
|
||||
db: &sea_orm_migration::prelude::SchemaManagerConnection<'_>,
|
||||
role_code: &str,
|
||||
perm_codes: &[&str],
|
||||
) -> Result<(), DbErr> {
|
||||
let codes_csv: String = perm_codes
|
||||
.iter()
|
||||
.map(|c| format!("'{}'", c))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
|
||||
db.execute_unprepared(&format!(
|
||||
"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \
|
||||
SELECT r.id, p.id, r.tenant_id, 'self', NOW(), NOW(), r.id, r.id, NULL, 1 \
|
||||
FROM roles r \
|
||||
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code IN ({codes_csv}) AND p.deleted_at IS NULL \
|
||||
WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \
|
||||
ON CONFLICT (role_id, permission_id) WHERE deleted_at IS NULL \
|
||||
DO UPDATE SET deleted_at = NULL, version = role_permissions.version + 1, updated_at = NOW()"
|
||||
)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assign_single_perm(
|
||||
db: &sea_orm_migration::prelude::SchemaManagerConnection<'_>,
|
||||
role_code: &str,
|
||||
perm_code: &str,
|
||||
) -> Result<(), DbErr> {
|
||||
db.execute_unprepared(&format!(
|
||||
"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \
|
||||
SELECT r.id, p.id, r.tenant_id, 'all', NOW(), NOW(), r.id, r.id, NULL, 1 \
|
||||
FROM roles r \
|
||||
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code = '{perm_code}' AND p.deleted_at IS NULL \
|
||||
WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \
|
||||
ON CONFLICT (role_id, permission_id) WHERE deleted_at IS NULL \
|
||||
DO UPDATE SET deleted_at = NULL, version = role_permissions.version + 1, updated_at = NOW()"
|
||||
)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user