简单清理

This commit is contained in:
2025-11-28 14:03:09 +08:00
parent f80988279f
commit c413fe90f9
621 changed files with 0 additions and 86336 deletions
Submodule 93_release deleted from c4bf4fea72
-7
View File
@@ -1,7 +0,0 @@
node_modules
# dist # 注释掉dist,因为预构建模式需要dist目录
.git
.gitignore
.env
.env.*
*.log
-11
View File
@@ -1,11 +0,0 @@
# Editor configuration, see http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
-57
View File
@@ -1,57 +0,0 @@
# the base url of the application, the default is "/"
# if use a sub directory, it must be end with "/", like "/admin/" but not "/admin"
VITE_BASE_URL=./
VITE_APP_TITLE=穿戴健康管理平台
VITE_APP_DESC=穿戴健康管理平台
# the prefix of the icon name
VITE_ICON_PREFIX=icon
# the prefix of the local svg icon component, must include VITE_ICON_PREFIX
# format {VITE_ICON_PREFIX}-{local icon name}
VITE_ICON_LOCAL_PREFIX=icon-local
# auth route mode: static | dynamic
VITE_AUTH_ROUTE_MODE=dynamic
# static auth route home
VITE_ROUTE_HOME=home
# default menu icon
VITE_MENU_ICON=mdi:menu
# whether to enable http proxy when is dev mode
VITE_HTTP_PROXY=Y
# vue-router mode: hash | history | memory
VITE_ROUTER_HISTORY_MODE=history
# success code of backend service, when the code is received, the request is successful
VITE_SERVICE_SUCCESS_CODE=200
# logout codes of backend service, when the code is received, the user will be logged out and redirected to login page
VITE_SERVICE_LOGOUT_CODES=600,401
# modal logout codes of backend service, when the code is received, the user will be logged out by displaying a modal
VITE_SERVICE_MODAL_LOGOUT_CODES=401
# token expired codes of backend service, when the code is received, it will refresh the token and resend the request
VITE_SERVICE_EXPIRED_TOKEN_CODES=40101
# when the route mode is static, the defined super role
VITE_STATIC_SUPER_ROLE=R_SUPER
# permission button auth mode : user_info | route_meta
# user_info not implemented, need backend to cooperate and return data.
VITE_AUTH_BUTTON_MODE=route_meta
# sourcemap
VITE_SOURCE_MAP=N
# Used to differentiate storage across different domains
VITE_STORAGE_PREFIX=SOY_
# used to control whether the program automatically detects updates
VITE_AUTOMATICALLY_DETECT_UPDATE=Y
-7
View File
@@ -1,7 +0,0 @@
# backend service base url, test environment
VITE_SERVICE_BASE_URL=http://192.168.1.83:9998
# other backend service base url, test environment
VITE_OTHER_SERVICE_BASE_URL= `{
"demo": "http://localhost:9634"
}`
-10
View File
@@ -1,10 +0,0 @@
# backend service base url, production environment
VITE_SERVICE_BASE_URL=http://192.168.1.83:9998
# other backend service base url, production environment
VITE_OTHER_SERVICE_BASE_URL= `{
"demo": "http://192.168.1.83:9634"
}`
# 大屏服务地址 - 生产环境
VITE_BIGSCREEN_URL=http://192.168.1.83:5001
-10
View File
@@ -1,10 +0,0 @@
# backend service base url, test environment (修复:使用正确的API端口9998)
VITE_SERVICE_BASE_URL=http://127.0.0.1:9998
# other backend service base url, test environment
VITE_OTHER_SERVICE_BASE_URL= `{
"demo": "http://localhost:9634"
}`
# 大屏服务地址 - 本地开发环境
VITE_BIGSCREEN_URL=http://localhost:5001
-13
View File
@@ -1,13 +0,0 @@
"*.vue" eol=lf
"*.js" eol=lf
"*.ts" eol=lf
"*.jsx" eol=lf
"*.tsx" eol=lf
"*.mjs" eol=lf
"*.json" eol=lf
"*.html" eol=lf
"*.css" eol=lf
"*.scss" eol=lf
"*.md" eol=lf
"*.yaml" eol=lf
"*.yml" eol=lf
-46
View File
@@ -1,46 +0,0 @@
name: Build and Push ljwx-admin Docker Image
on:
push:
branches: [ main, master ]
workflow_dispatch:
env:
REGISTRY: crpi-yilnm6upy4pmbp67.cn-shenzhen.personal.cr.aliyuncs.com/ljwx
IMAGE_NAME: ljwx-admin
VERSION: 1.1.2
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout代码
uses: actions/checkout@v4
- name: 设置Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 登录阿里云镜像仓库
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.ALIYUN_USERNAME }}
password: ${{ secrets.ALIYUN_PASSWORD }}
- name: 构建并推送Docker镜像
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.prod
platforms: linux/amd64
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: 验证镜像
run: |
echo "✅ ljwx-admin镜像构建并推送成功!"
echo "🐳 镜像地址: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}"
echo "📦 拉取命令: docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}"
-36
View File
@@ -1,36 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
!.vscode/launch.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
package-lock.json
yarn.lock
.VSCodeCounter
.env.prod
-4
View File
@@ -1,4 +0,0 @@
registry=https://registry.npmmirror.com/
shamefully-hoist=true
ignore-workspace-root-check=true
link-workspace-packages=true
-8
View File
@@ -1,8 +0,0 @@
{
"semi": true,
"singleQuote": true,
"useTabs": false,
"tabWidth": 2,
"printWidth": 150,
"trailingComma": "none"
}
@@ -1,154 +0,0 @@
# 告警日志删除功能修复完整总结
## 🔍 **问题诊断**
### 1. 前端表格问题
- **全选异常**:点击单个复选框触发全选
- **删除失败**:传递错误的ID值 `[0]` 而不是实际的 `logId`
### 2. 后端删除问题
- **实体类主键配置错误**:MyBatis-Plus无法识别正确的主键字段
- **数据库操作失败**:删除操作实际没有执行
## ⚠️ **根本原因分析**
### 数据库表结构
```sql
-- t_alert_action_log 表结构
CREATE TABLE t_alert_action_log (
log_id BIGINT PRIMARY KEY AUTO_INCREMENT, -- 实际主键
id INTEGER, -- 普通字段(可为NULL)
alert_id BIGINT,
-- ... 其他字段
);
```
### 问题核心
1. **前端**: `:row-key="row => row.id"` 使用了错误的字段(值为NULL)
2. **后端**: TAlertActionLog实体类继承BaseEntity,MyBatis-Plus默认使用BaseEntity.id作为主键
3. **映射错误**: 实际主键`log_id`没有被正确标识为@TableId
## 🔧 **完整修复方案**
### 1. 前端修复 (ljwx-admin)
**文件**: `ljwx-admin/src/views/alert/log/index.vue`
```vue
<!-- 修复表格row-key -->
<NDataTable
:row-key="row => row.logId" <!-- ✅ 使用logId -->
v-model:checked-row-keys="checkedRowKeys"
<!-- 其他配置 -->
/>
<!-- 修复删除操作 -->
<NPopconfirm onPositiveClick={() => handleDelete(row.logId)}>
<!-- ✅ 使用logId而不是id -->
</NPopconfirm>
```
```typescript
// 修复函数参数类型
async function handleDelete(id: number) { // ✅ number类型
const { error, data: result } = await fetchDeleteAleractionLog(transDeleteParams([id]));
if (!error && result) {
await onDeleted();
}
}
```
### 2. 后端修复 (ljwx-boot)
**文件**: `ljwx-boot/ljwx-boot-modules/src/main/java/com/ljwx/modules/health/domain/entity/TAlertActionLog.java`
```java
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_alert_action_log")
public class TAlertActionLog extends BaseEntity {
// ✅ 排除BaseEntity的id字段,避免@TableId冲突
@TableField(exist = false)
private Long id;
@TableId // ✅ 指定logId为主键
private Long logId;
// ... 其他字段
}
```
### 3. 重新编译和部署
```bash
# 1. 重新编译modules项目
mvn clean install -DskipTests -pl ljwx-boot-modules
# 2. 使用正确的启动方式
cd ljwx-boot
./run-local.sh # ✅ 正确的启动脚本,而不是在ljwx-boot-admin目录下执行
```
## ✅ **验证方法**
### 1. 前端验证
- **单选测试**: 点击单个复选框,不应触发全选
- **删除测试**: 检查Network面板,确认传递正确的logId值
### 2. 后端验证
```bash
# 查看后端日志,应该看到正确的ID
curl -X DELETE "http://localhost:3333/proxy-default/t_alert_action_log/" \
-H "Authorization: Bearer TOKEN" \
-d '{"ids":[12]}'
# 后端日志应显示:
# setNonNullParameter called with parameter: ["{\"ids\":[12]}"]
```
### 3. 数据库验证
```sql
-- 删除前
SELECT log_id FROM t_alert_action_log WHERE log_id = 12; -- 应该存在
-- 删除后
SELECT log_id FROM t_alert_action_log WHERE log_id = 12; -- 应该为空
```
## 🎯 **技术要点总结**
### MyBatis-Plus主键配置规则
1. **默认主键**: 如果没有@TableId注解,使用BaseEntity.id
2. **自定义主键**: 使用@TableId明确指定主键字段
3. **冲突解决**: 当继承BaseEntity时,需要排除BaseEntity.id字段
### 前端表格配置
1. **row-key**: 必须使用实际的主键字段
2. **类型安全**: 确保ID类型匹配(number vs string)
3. **删除操作**: 使用正确的字段名称
## 📝 **相关文件清单**
### 前端修改
- `ljwx-admin/src/views/alert/log/index.vue`
- `ljwx-admin/ALERT_LOG_FIX_SUMMARY.md`
### 后端修改
- `ljwx-boot/ljwx-boot-modules/src/main/java/com/ljwx/modules/health/domain/entity/TAlertActionLog.java`
### 启动方式修正
- ❌ `cd ljwx-boot-admin && mvn spring-boot:run`
- ✅ `cd ljwx-boot && ./run-local.sh`
## 🎉 **修复状态**
- ✅ **前端全选问题**: 已修复
- ✅ **前端删除ID传递**: 已修复
- ✅ **后端实体类主键配置**: 已修复
- ✅ **启动方式**: 已修正
- 🔄 **整体测试**: 待用户验证
现在所有修复都已完成,用户可以正常使用告警日志的单选、删除等功能了!
-110
View File
@@ -1,110 +0,0 @@
# 告警日志表格修复总结
## 问题分析
### 1. 全选问题
**原因**:NDataTable的`:row-key`配置错误
- **错误配置**:`:row-key="row => row.id"`
- **正确配置**:`:row-key="row => row.logId"`
- **说明**:数据库主键是`log_id`,对应的实体字段是`logId`,而不是基类的`id`字段
### 2. 删除失败问题
**原因**:删除操作使用了错误的字段
- **错误**:`handleDelete(row.id)` - id字段为0或未定义
- **正确**:`handleDelete(row.logId)` - 使用实际的主键logId
- **影响**:导致传递ids=[0]给后端删除接口
## 技术细节
### 数据库表结构
```sql
-- t_alert_action_log 表
log_id BIGINT PRIMARY KEY AUTO_INCREMENT -- 主键
id INTEGER -- 普通字段(可为空)
alert_id BIGINT -- 告警ID
-- ... 其他字段
```
### Java实体映射
```java
@TableName("t_alert_action_log")
public class TAlertActionLog extends BaseEntity {
private Long logId; // 对应 log_id (主键)
// BaseEntity.id 对应普通的 id 字段
}
```
### MyBatis映射配置
```xml
<resultMap id="TAlertActionLogResultMap">
<id column="log_id" property="logId" /> <!-- 主键映射 -->
<!-- 其他字段映射 -->
</resultMap>
```
## 修复方案
### 1. 修复表格row-key配置
```vue
<NDataTable
:row-key="row => row.logId" <!-- 使用logId作为row-key -->
v-model:checked-row-keys="checkedRowKeys"
<!-- 其他配置 -->
/>
```
### 2. 修复删除操作
```vue
<!-- 删除按钮 -->
<NPopconfirm onPositiveClick={() => handleDelete(row.logId)}>
<!-- 使用logId而不是id -->
</NPopconfirm>
```
```typescript
async function handleDelete(id: number) { // 参数类型为number
const { error, data: result } = await fetchDeleteAleractionLog(transDeleteParams([id]));
if (!error && result) {
await onDeleted();
}
}
```
### 3. 修复批量删除
确保`checkedRowKeys`中收集的是`logId`值,而不是`id`值。
## 验证方法
### 1. 测试单选
- 点击单个复选框,不应该触发全选
- 检查控制台`checkedRowKeys`数组内容
### 2. 测试删除
- 单个删除:检查发送的ID是否为正确的logId
- 批量删除:检查发送的IDs数组内容
### 3. API调试
```bash
# 正确的删除请求应该是:
POST /t_alert_action_log/
{
"ids": [12, 11, 10] # 实际的logId值
}
# 而不是:
{
"ids": [0] # 错误的id值
}
```
## 注意事项
1. **字段命名一致性**:确保前端、后端、数据库字段映射一致
2. **类型安全**:logId是number类型,确保类型匹配
3. **测试覆盖**:测试单选、多选、全选、删除等完整流程
## 相关文件
- `ljwx-admin/src/views/alert/log/index.vue` - 主页面
- `ljwx-boot/ljwx-boot-modules/src/main/java/com/ljwx/modules/health/domain/entity/TAlertActionLog.java` - 实体类
- `ljwx-boot/ljwx-boot-modules/src/main/java/com/ljwx/modules/health/repository/mapper/TAlertActionLogMapper.xml` - 映射文件
-407
View File
@@ -1,407 +0,0 @@
# 业务表多租户 customer_id 字段分析报告
## 概述
本报告基于对 ljwx-admin 系统数据库的深入分析,为缺失 customer_id 字段的核心业务表提供完整的多租户迁移方案。系统已具备成熟的多租户架构基础,本次升级将实现业务数据的完整租户隔离。
## 当前多租户架构分析
### 已实现的多租户表
- ✅ `sys_user` - 用户表(customer_id 已实现)
- ✅ `sys_org_units` - 组织表(customer_id 已实现)
- ✅ `sys_role` - 角色表(customer_id 已实现)
- ✅ `sys_position` - 岗位表(customer_id 已实现)
- ✅ `sys_user_org` - 用户组织关联表(customer_id 已实现)
- ✅ `t_alert_rule` - 告警规则表(customer_id 已实现)
- ✅ `t_wechat_alarm_config` - 微信告警配置表(customer_id 已实现)
### 成熟的索引设计模式
系统已建立完善的多租户索引策略:
- `idx_[table]_customer_id` - 基础租户过滤索引
- `idx_[table]_customer_[field]` - 租户+业务字段复合索引
- `idx_[table]_customer_status` - 租户+状态复合索引
## 待迁移的核心业务表详细分析
### Priority 1: 核心业务表
#### 1. t_user_health_data (用户健康数据表)
**当前结构分析:**
```sql
CREATE TABLE t_user_health_data (
id int NOT NULL AUTO_INCREMENT,
phone_number varchar(20),
heart_rate int, pressure_high int, pressure_low int,
blood_oxygen int, stress int, temperature double(5,2),
step int, timestamp datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
user_name varchar(255) NOT NULL DEFAULT 'heguang',
latitude decimal(10,6), longitude decimal(10,6), altitude double,
device_sn varchar(255) NOT NULL,
distance double, calorie double,
user_id bigint, org_id bigint,
-- 缺少 customer_id 字段
);
```
**关系分析:**
- 通过 `user_id` 关联到 `sys_user.id`(已有 customer_id)
- 通过 `device_sn` 关联到设备信息
- 通过 `org_id` 关联到组织结构(已有 customer_id)
**customer_id 确定策略:**
1. **主要策略**:从 `user_id` → `sys_user.customer_id` 继承租户ID
2. **备选策略**:从 `org_id` → `sys_org_units.customer_id` 继承(当user_id为空时)
**推荐索引:**
```sql
-- 按租户+设备+时间查询(设备历史数据)
CREATE INDEX idx_health_customer_device_time ON t_user_health_data(customer_id, device_sn, timestamp);
-- 按租户+用户+时间查询(个人健康报告)
CREATE INDEX idx_health_customer_user_time ON t_user_health_data(customer_id, user_id, timestamp);
-- 按租户+组织+时间查询(组织健康统计)
CREATE INDEX idx_health_customer_org_time ON t_user_health_data(customer_id, org_id, timestamp);
```
**业务影响评估:**
- 🔥 **高影响**:健康数据是系统核心,必须严格按租户隔离
- 📊 **查询优化**:多租户索引将显著提升健康报告生成速度
- 🔒 **数据安全**:防止跨租户健康隐私数据泄露
---
#### 2. t_device_info (设备信息表)
**当前结构分析:**
```sql
CREATE TABLE t_device_info (
id int NOT NULL AUTO_INCREMENT,
system_software_version varchar(255) NOT NULL,
wifi_address varchar(255), bluetooth_address varchar(255),
ip_address varchar(255), serial_number varchar(255),
device_name varchar(255), imei varchar(255),
battery_level int, model varchar(50),
status enum('INACTIVE','ACTIVE'),
-- 缺少 customer_id, user_id, org_id 字段
);
```
**数据关系复杂性:**
- 设备信息表当前缺少直接的用户/组织关联
- 需要通过 `t_device_user` 表的绑定关系确定租户归属
- 一个设备可能在不同时期绑定给不同租户的用户
**customer_id 确定策略:**
1. **当前绑定策略**:从当前有效绑定 `t_device_user` → `sys_user.customer_id`
2. **历史绑定策略**:如果没有当前绑定,取最后一次绑定记录的租户
3. **默认策略**:未绑定设备设置为 customer_id = 0(全局设备池)
**建议增加字段:**
```sql
ALTER TABLE t_device_info
ADD COLUMN customer_id BIGINT NOT NULL DEFAULT '0' COMMENT '租户ID,继承自当前绑定用户',
ADD COLUMN user_id BIGINT DEFAULT NULL COMMENT '当前绑定用户ID',
ADD COLUMN org_id BIGINT DEFAULT NULL COMMENT '当前绑定组织ID';
```
**推荐索引:**
```sql
-- 租户设备管理核心索引
CREATE INDEX idx_device_customer_serial ON t_device_info(customer_id, serial_number);
CREATE INDEX idx_device_customer_user ON t_device_info(customer_id, user_id);
CREATE INDEX idx_device_customer_status ON t_device_info(customer_id, status);
```
**业务影响评估:**
- 🔥 **高影响**:设备是物联网系统的核心资源,必须严格管理归属
- 📱 **设备管理**:支持按租户查看和管理设备资产
- 🔄 **绑定历史**:保留设备在不同租户间流转的历史记录
---
#### 3. t_device_message & t_device_message_detail (设备消息表)
**当前结构分析:**
```sql
-- 主消息表
CREATE TABLE t_device_message (
id bigint NOT NULL AUTO_INCREMENT,
user_id bigint COMMENT '用户ID',
device_sn varchar(255),
message text NOT NULL,
message_type varchar(50) NOT NULL,
message_status varchar(50) NOT NULL DEFAULT 'pending',
-- 缺少 customer_id 字段
);
-- 消息详情表
CREATE TABLE t_device_message_detail (
id bigint NOT NULL AUTO_INCREMENT,
message_id varchar(255) NOT NULL,
device_sn varchar(255) NOT NULL,
message text NOT NULL,
-- 缺少 customer_id 字段
);
```
**关系分析:**
- 主消息表通过 `user_id` 直接关联用户租户
- 详情表通过 `message_id` 关联到主消息表
**customer_id 确定策略:**
1. **t_device_message**:从 `user_id` → `sys_user.customer_id` 直接继承
2. **t_device_message_detail**:从关联的 `t_device_message.customer_id` 继承
**推荐索引:**
```sql
-- 设备消息时序查询
CREATE INDEX idx_message_customer_device_time ON t_device_message(customer_id, device_sn, create_time);
-- 用户消息状态查询
CREATE INDEX idx_message_customer_user_status ON t_device_message(customer_id, user_id, message_status);
-- 消息详情快速定位
CREATE INDEX idx_message_detail_customer_device ON t_device_message_detail(customer_id, device_sn);
```
**业务影响评估:**
- 🔥 **高影响**:消息系统承载设备通讯,需要严格的租户隔离
- 💬 **通讯安全**:防止跨租户消息泄露和误发
- 📈 **性能优化**:按租户索引将显著提升消息查询性能
---
#### 4. t_alert_info & t_alert_action_log (告警信息及操作日志)
**当前结构分析:**
```sql
-- 告警信息表(已有 tenant_id,需重命名为 customer_id)
CREATE TABLE t_alert_info (
id bigint unsigned NOT NULL AUTO_INCREMENT,
rule_id bigint NOT NULL,
device_sn varchar(20) NOT NULL,
user_id bigint COMMENT '用户ID',
org_id bigint COMMENT '组织ID',
tenant_id bigint DEFAULT '1' COMMENT '租户ID', -- 需重命名
);
-- 告警操作日志表
CREATE TABLE t_alert_action_log (
log_id bigint unsigned NOT NULL AUTO_INCREMENT,
alert_id bigint unsigned NOT NULL,
action_user_id bigint DEFAULT NULL,
-- 缺少 customer_id 字段
);
```
**特殊情况:**
- `t_alert_info` 已有 `tenant_id` 字段,但名称不统一,需重命名为 `customer_id`
- `t_alert_action_log` 需要从关联的告警记录继承租户信息
**customer_id 确定策略:**
1. **t_alert_info**:重命名现有 `tenant_id` 为 `customer_id`,确保数据一致性
2. **t_alert_action_log**:从 `alert_id` → `t_alert_info.customer_id` 继承
**索引更新:**
```sql
-- 删除旧索引,创建新索引
DROP INDEX idx_alert_info_tenant_status ON t_alert_info;
CREATE INDEX idx_alert_info_customer_status ON t_alert_info(customer_id, alert_status);
CREATE INDEX idx_alert_log_customer_alert ON t_alert_action_log(customer_id, alert_id);
```
**业务影响评估:**
- 🔥 **高影响**:告警系统是安全监控核心,租户隔离至关重要
- 🚨 **安全告警**:防止跨租户告警信息泄露
- 📋 **操作审计**:操作日志按租户隔离,便于审计追踪
---
#### 5. t_device_bind_request & t_device_user (设备绑定管理)
**当前结构分析:**
```sql
-- 设备绑定申请表
CREATE TABLE t_device_bind_request (
id bigint NOT NULL AUTO_INCREMENT,
device_sn varchar(100) NOT NULL,
user_id bigint NOT NULL COMMENT '申请用户ID',
org_id bigint NOT NULL COMMENT '申请组织ID',
-- 缺少 customer_id 字段
);
-- 设备用户关联表
CREATE TABLE t_device_user (
id bigint unsigned NOT NULL AUTO_INCREMENT,
device_sn varchar(200) NOT NULL,
user_id bigint NOT NULL,
status enum('BIND','UNBIND') DEFAULT 'BIND',
-- 缺少 customer_id 字段
);
```
**关系分析:**
- 两表都直接关联用户,可从 `user_id` 继承租户信息
- 设备绑定流程:申请 → 审批 → 绑定,全流程需要租户一致性
**customer_id 确定策略:**
- 从 `user_id` → `sys_user.customer_id` 直接继承,确保设备绑定在正确租户范围内
**推荐索引:**
```sql
-- 设备绑定申请管理
CREATE INDEX idx_bind_request_customer_device ON t_device_bind_request(customer_id, device_sn);
CREATE INDEX idx_bind_request_customer_user ON t_device_bind_request(customer_id, user_id);
-- 设备用户关联查询
CREATE INDEX idx_device_user_customer_device ON t_device_user(customer_id, device_sn);
CREATE INDEX idx_device_user_customer_user ON t_device_user(customer_id, user_id);
```
**业务影响评估:**
- 🔥 **高影响**:设备绑定是权限管理基础,必须按租户严格隔离
- 🔐 **权限控制**:确保用户只能申请和绑定本租户设备
- 📊 **资产管理**:按租户统计设备绑定情况和使用率
---
#### 6. 健康分析表组 (t_health_baseline, t_org_health_baseline, t_health_score, t_org_health_score)
**当前结构分析:**
```sql
-- 个人健康基线表
CREATE TABLE t_health_baseline (
id bigint NOT NULL AUTO_INCREMENT,
device_sn varchar(50) NOT NULL,
user_id bigint DEFAULT '0',
org_id varchar(20) DEFAULT '1',
feature_name varchar(20) NOT NULL,
baseline_date date NOT NULL,
-- 缺少 customer_id 字段
);
-- 组织健康基线表
CREATE TABLE t_org_health_baseline (
id bigint NOT NULL AUTO_INCREMENT,
org_id varchar(20) NOT NULL,
feature_name varchar(20) NOT NULL,
baseline_date date NOT NULL,
-- 缺少 customer_id 字段
);
-- 健康评分表(个人和组织,结构类似)
```
**数据关系复杂性:**
- **个人表**:通过 `device_sn` → `t_device_user` → `sys_user` 间接获取租户
- **组织表**:通过 `org_id` → `sys_org_units.customer_id` 直接获取租户
- 数据链路:设备 → 用户 → 租户,需要多表关联确定归属
**customer_id 确定策略:**
1. **个人表**:`device_sn` → `t_device_user`(当前绑定) → `sys_user.customer_id`
2. **组织表**:`org_id` → `sys_org_units.customer_id` 直接映射
**推荐索引:**
```sql
-- 个人健康分析查询优化
CREATE INDEX idx_health_baseline_customer_device_feature ON t_health_baseline(customer_id, device_sn, feature_name, baseline_date);
CREATE INDEX idx_health_score_customer_device_date ON t_health_score(customer_id, device_sn, score_date);
-- 组织健康分析查询优化
CREATE INDEX idx_org_baseline_customer_org_feature ON t_org_health_baseline(customer_id, org_id, feature_name, baseline_date);
CREATE INDEX idx_org_score_customer_org_date ON t_org_health_score(customer_id, org_id, score_date);
```
**业务影响评估:**
- 🔥 **高影响**:健康分析是系统核心价值,数据准确性和安全性至关重要
- 📊 **分析报告**:按租户生成健康基线和评分报告
- 🏥 **医疗合规**:健康数据严格按租户隔离,符合医疗数据保护要求
---
## 数据迁移策略
### 迁移原则
1. **数据完整性**:确保所有现有数据正确分配到对应租户
2. **关系一致性**:维护表间关联关系的租户一致性
3. **性能优化**:创建适当索引,提升多租户查询性能
4. **回滚安全**:提供完整的备份和回滚方案
### 迁移优先级
1. **Phase 1**:基础关联表(t_device_user, t_device_bind_request)
2. **Phase 2**:核心业务表(t_user_health_data, t_alert_info)
3. **Phase 3**:分析统计表(健康基线和评分表组)
4. **Phase 4**:辅助功能表(消息、设备信息等)
### 数据验证检查点
1. **租户分布统计**:验证数据按租户正确分布
2. **关系一致性**:检查关联表间的租户ID一致性
3. **业务逻辑验证**:确保业务查询结果与迁移前一致
4. **性能基准测试**:对比迁移前后的查询性能
## 后端服务影响分析
### Java 实体类更新需求
基于现有的示例代码结构,需要更新的实体类包括:
```java
// 需要添加 customer_id 字段的实体类
- UserHealthDataEntity.java
- DeviceInfoEntity.java
- DeviceMessageEntity.java
- AlertInfoEntity.java (重命名 tenantId 为 customerId)
- HealthBaselineEntity.java
// ... 其他相关实体类
```
### API 接口影响
1. **查询接口**:所有查询需要自动添加 `customer_id` 条件
2. **新增接口**:创建数据时自动设置当前用户的 `customer_id`
3. **权限控制**:确保用户只能操作本租户数据
### Python 服务影响
根据现有架构,Python 服务(ljwx-bigscreen)需要:
1. **模型更新**:SQLAlchemy 模型添加 `customer_id` 字段
2. **查询修改**:所有数据库查询自动添加租户过滤
3. **上下文管理**:实现租户上下文自动注入
## 性能优化建议
### 索引策略
1. **复合索引优先**:`(customer_id, business_field)` 组合索引
2. **查询覆盖**:常用查询字段组合建立覆盖索引
3. **分区考虑**:大表可按 `customer_id` 进行分区优化
### 查询优化
1. **强制租户过滤**:所有业务查询必须包含 `customer_id` 条件
2. **索引提示**:复杂查询使用索引提示确保最优执行计划
3. **查询重写**:将跨表关联查询改写为租户范围内的高效查询
### 监控建议
1. **慢查询监控**:重点监控多租户查询的执行计划
2. **索引使用率**:定期检查新建索引的使用情况
3. **数据分布**:监控各租户的数据量分布,及时优化
## 风险评估与缓解
### 主要风险
1. **数据迁移风险**:复杂关联可能导致数据分配错误
2. **性能风险**:增加 `customer_id` 字段可能影响现有查询性能
3. **业务兼容性风险**:现有API和业务逻辑需要适配
### 缓解措施
1. **充分测试**:在测试环境完整验证迁移脚本
2. **分步实施**:按优先级分批迁移,降低单次变更风险
3. **监控告警**:实时监控迁移过程和系统性能指标
4. **快速回滚**:准备完整的数据恢复和回滚预案
## 总结建议
1. **立即执行**:核心业务表的多租户改造刻不容缓,建议优先处理 Priority 1 表
2. **统一标准**:严格按照现有的 `customer_id` 命名和索引规范执行
3. **性能优先**:重点关注健康数据和告警数据的查询性能优化
4. **安全第一**:确保健康隐私数据和告警信息的严格租户隔离
通过本次迁移,系统将实现完整的多租户数据隔离,为企业级应用部署奠定坚实基础。
---
**迁移脚本位置**:`/business-tables-customer-id-migration.sql`
**执行建议**:在生产环境执行前,请在测试环境完整验证所有业务功能
-171
View File
@@ -1,171 +0,0 @@
# Changelog
## [v1.0.5](https://github.com/paynezhuang/Ljwx-admin/compare/v1.0.4...v1.0.5) (2024-11-27)
### &nbsp;&nbsp;&nbsp;🚀 Features
- **projects**:
- merge remote project v1.3.8 &nbsp;-&nbsp; by @paynezhuang [<samp>(e4a4e)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/e4a4e59)
- all annotation permission & Organize search users &nbsp;-&nbsp; by @paynezhuang [<samp>(de9b5)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/de9b5b6)
- Time field formatted display &nbsp;-&nbsp; by @paynezhuang [<samp>(61e0a)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/61e0a02)
- Add notification announcement management &nbsp;-&nbsp; by @paynezhuang [<samp>(69b29)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/69b294a)
- role status default value &nbsp;-&nbsp; by @paynezhuang [<samp>(f5fc0)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/f5fc039)
- add file record module &nbsp;-&nbsp; by @paynezhuang [<samp>(c6bcb)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/c6bcb5e)
- add file manage &nbsp;-&nbsp; by @paynezhuang [<samp>(3896f)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/3896f31)
- merge remote project v1.3.9 &nbsp;-&nbsp; by @paynezhuang [<samp>(950af)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/950af4d)
### &nbsp;&nbsp;&nbsp;🐞 Bug Fixes
- **projects**:
- Fix spelling errors &nbsp;-&nbsp; by @paynezhuang [<samp>(5f440)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/5f440ba)
- Fix spelling errors &nbsp;-&nbsp; by @paynezhuang [<samp>(d34c0)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/d34c0be)
- fix code generate isEdit status &nbsp;-&nbsp; by @paynezhuang [<samp>(e3a15)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/e3a1558)
- fix dict reset search &nbsp;-&nbsp; by @paynezhuang [<samp>(8b95b)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/8b95b33)
- Repair return response &nbsp;-&nbsp; by @paynezhuang [<samp>(3744f)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/3744f86)
### &nbsp;&nbsp;&nbsp;🔥 Performance
- **projects**: update nsetting the name &nbsp;-&nbsp; by @paynezhuang [<samp>(64233)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/642335c)
### &nbsp;&nbsp;&nbsp;🛠 Optimizations
- **projects**: Optimization type &nbsp;-&nbsp; by @paynezhuang [<samp>(611db)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/611db7a)
### &nbsp;&nbsp;&nbsp;💅 Refactors
- **projects**: file record changed file &nbsp;-&nbsp; by @paynezhuang [<samp>(99d99)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/99d99f9)
### &nbsp;&nbsp;&nbsp;🎨 Styles
- **projects**:
- Rename &nbsp;-&nbsp; by @paynezhuang [<samp>(499e4)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/499e46a)
- delete console code &nbsp;-&nbsp; by @paynezhuang [<samp>(54cbb)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/54cbb6c)
- Table field display style &nbsp;-&nbsp; by @paynezhuang [<samp>(8529b)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/8529bcf)
- remove console log code &nbsp;-&nbsp; by @paynezhuang [<samp>(4fb71)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/4fb71f0)
### &nbsp;&nbsp;&nbsp;❤️ Contributors
[![paynezhuang](https://github.com/paynezhuang.png?size=48)](https://github.com/paynezhuang)&nbsp;&nbsp;
## [v1.0.4](https://github.com/paynezhuang/Ljwx-admin/compare/v1.0.3...v1.0.4) (2024-09-08)
### &nbsp;&nbsp;&nbsp;🚀 Features
- **projects**:
- add request language &nbsp;-&nbsp; by @paynezhuang [<samp>(b6f59)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/b6f597d)
- Using data dictionary rendering. &nbsp;-&nbsp; by @paynezhuang [<samp>(b1e22)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/b1e228e)
- Dict item add copy code &nbsp;-&nbsp; by @paynezhuang [<samp>(35d75)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/35d75a8)
- merge remote project v1.3.2 -> v.1.3.4 &nbsp;-&nbsp; by @paynezhuang [<samp>(11fc6)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/11fc61c)
- menu add iframeUrl field &nbsp;-&nbsp; by @paynezhuang [<samp>(e5fce)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/e5fce4a)
- add code generator &nbsp;-&nbsp; by @paynezhuang [<samp>(521f6)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/521f618)
- merge remote project v1.3.5 &nbsp;-&nbsp; by @paynezhuang [<samp>(d8579)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/d8579ed)
### &nbsp;&nbsp;&nbsp;🐞 Bug Fixes
- **projects**:
- fix vertical-mix menu selected &nbsp;-&nbsp; by @paynezhuang [<samp>(87310)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/873108e)
- fix org add drawer title &nbsp;-&nbsp; by @paynezhuang [<samp>(b6e6e)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/b6e6e3c)
- remove position i18nKey fields &nbsp;-&nbsp; by @paynezhuang [<samp>(24491)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/2449180)
- fix commonSearchParams value &nbsp;-&nbsp; by @paynezhuang [<samp>(f32ca)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/f32cafd)
- Dict add error &nbsp;-&nbsp; by @paynezhuang [<samp>(723c8)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/723c821)
### &nbsp;&nbsp;&nbsp;💅 Refactors
- **projects**:
- Split typings and services &nbsp;-&nbsp; by @paynezhuang [<samp>(eea35)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/eea3517)
- Adjust the position of the data dictionary copy button. &nbsp;-&nbsp; by @paynezhuang [<samp>(ae746)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/ae74605)
### &nbsp;&nbsp;&nbsp;🎨 Styles
- **projects**:
- tree Center the prompt when there is no data &nbsp;-&nbsp; by @paynezhuang [<samp>(d23a6)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/d23a6cc)
- fix mobile loss show &nbsp;-&nbsp; by @paynezhuang [<samp>(6087a)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/6087a1a)
### &nbsp;&nbsp;&nbsp;❤️ Contributors
[![paynezhuang](https://github.com/paynezhuang.png?size=48)](https://github.com/paynezhuang)&nbsp;&nbsp;
## [v1.0.3](https://github.com/paynezhuang/Ljwx-admin/compare/v1.0.2...v1.0.3) (2024-07-22)
### &nbsp;&nbsp;&nbsp;🚀 Features
- **projects**:
- add postion / orgUnits / dict &nbsp;-&nbsp; by @paynezhuang [<samp>(46787)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/46787e1)
- merge remote project v1.2.7 -> v1.3.1 &nbsp;-&nbsp; by @paynezhuang [<samp>(a99ef)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/a99ef2f)
### &nbsp;&nbsp;&nbsp;🐞 Bug Fixes
- **projects**:
- user realName rule &nbsp;-&nbsp; by @paynezhuang [<samp>(59b2b)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/59b2b8d)
- Disable username field in user edit &nbsp;-&nbsp; by @paynezhuang [<samp>(d6cb6)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/d6cb65b)
### &nbsp;&nbsp;&nbsp;📖 Documentation
- **projects**: add CHANGELOG &nbsp;-&nbsp; by @paynezhuang [<samp>(36810)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/3681040)
### &nbsp;&nbsp;&nbsp;❤️ Contributors
[![paynezhuang](https://github.com/paynezhuang.png?size=48)](https://github.com/paynezhuang)&nbsp;&nbsp;
## [v1.0.2](https://github.com/paynezhuang/Ljwx-admin/compare/v1.0.1...v1.0.2) (2024-06-23)
### &nbsp;&nbsp;&nbsp;🚀 Features
- **projects**:
- merge remote project v1.1.4 -> v1.2.2 &nbsp;-&nbsp; by @paynezhuang [<samp>(e1ca4)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/e1ca469)
- add button auth dorpdown hook &nbsp;-&nbsp; by @paynezhuang [<samp>(ac396)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/ac39628)
- merge remote project v1.2.3 -> v1.2.6 &nbsp;-&nbsp; by @paynezhuang [<samp>(7cc61)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/7cc6149)
### &nbsp;&nbsp;&nbsp;🐞 Bug Fixes
- **projects**: menu layout show &nbsp;-&nbsp; by @paynezhuang [<samp>(2f53d)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/2f53d15)
### &nbsp;&nbsp;&nbsp;🔥 Performance
- **components**: Delete text display &nbsp;-&nbsp; by @paynezhuang [<samp>(2437c)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/2437c1f)
- **projects**: update system title &nbsp;-&nbsp; by @paynezhuang [<samp>(94e9e)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/94e9e2d)
### &nbsp;&nbsp;&nbsp;🛠 Optimizations
- **projects**: Optimized code &nbsp;-&nbsp; by @paynezhuang [<samp>(55056)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/55056ce)
### &nbsp;&nbsp;&nbsp;🏡 Chore
- **projects**: Fix deprecated configuration config &nbsp;-&nbsp; by @paynezhuang [<samp>(7d8ba)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/7d8ba4c)
### &nbsp;&nbsp;&nbsp;🎨 Styles
- **projects**:
- style scheduler select size &nbsp;-&nbsp; by @paynezhuang [<samp>(ae5df)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/ae5df16)
- button type use quaternary &nbsp;-&nbsp; by @paynezhuang [<samp>(c96d5)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/c96d586)
### &nbsp;&nbsp;&nbsp;❤️ Contributors
[![paynezhuang](https://github.com/paynezhuang.png?size=48)](https://github.com/paynezhuang)&nbsp;&nbsp;
## [v1.0.1](https://github.com/paynezhuang/Ljwx-admin/compare/undefined...v1.0.1) (2024-06-02)
### &nbsp;&nbsp;&nbsp;🚀 Features
- **projects**:
- init &nbsp;-&nbsp; by @paynezhuang [<samp>(950af)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/950af67)
- merge remote project v1.1.1 &nbsp;-&nbsp; by @paynezhuang [<samp>(8f8a0)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/8f8a0ca)
- add quartz and optimize code &nbsp;-&nbsp; by @paynezhuang [<samp>(94e60)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/94e600c)
- merge remote project v1.1.3 &nbsp;-&nbsp; by @paynezhuang [<samp>(ba4da)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/ba4dae7)
### &nbsp;&nbsp;&nbsp;🐞 Bug Fixes
- **projects**:
- menu pahtParams and logs style width &nbsp;-&nbsp; by @paynezhuang [<samp>(f4c9a)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/f4c9a51)
- menu missing component field &nbsp;-&nbsp; by @paynezhuang [<samp>(0e1c2)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/0e1c2f6)
- fix menu transform data &nbsp;-&nbsp; by @paynezhuang [<samp>(5b3c2)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/5b3c20c)
### &nbsp;&nbsp;&nbsp;🏡 Chore
- **projects**: update deps &nbsp;-&nbsp; by @paynezhuang [<samp>(d6799)</samp>](https://github.com/paynezhuang/Ljwx-admin/commit/d679928)
### &nbsp;&nbsp;&nbsp;❤️ Contributors
[![paynezhuang](https://github.com/paynezhuang.png?size=48)](https://github.com/paynezhuang)&nbsp;&nbsp;
-388
View File
@@ -1,388 +0,0 @@
# LjwxAdmin Claude 开发文档
## 数据解析与表格渲染故障排查文档
### 概述
本文档详细记录了 LjwxAdmin 项目中 API 数据解析流程和表格渲染机制,以及一次耗时的微信告警配置表格显示故障的完整排查过程。
### 核心数据流架构
#### 1. API 调用层
```typescript
// src/service/api/health/alert-config-wechat.ts
export function fetchGetAlertConfigWechatList(params?: Api.Health.AlertConfigWechatSearchParams) {
return request<Api.Health.AlertConfigWechatList>({
url: '/t_wechat_alarm_config/page',
method: 'GET',
params
});
}
```
#### 2. useTable Hook 数据转换层
```typescript
// src/hooks/common/table.ts
const { loading, data, columns, getData, searchParams } = useTable({
apiFn: fetchGetAlertConfigWechatList,
apiParams: { page: 1, pageSize: 20, customerId, type: 'enterprise' },
transformer: res => {
const { records = [], page = 1, pageSize = 20, total = 0 } = res.data || {};
const recordsWithIndex = records.map((item, index) => ({
...item,
index: (page - 1) * pageSize + index + 1
}));
return { data: recordsWithIndex, pageNum: page, pageSize, total };
},
columns: () => { /* 动态列定义 */ }
});
```
#### 3. Vue 组件渲染层
```vue
<NDataTable
:data="data"
:columns="columns"
:row-key="(row) => row.id"
/>
```
### 数据流详细分析
#### API 响应格式
```json
{
"code": 200,
"message": "操作成功",
"data": {
"page": 1,
"pageSize": 20,
"total": 2,
"records": [
{
"id": 10,
"type": "official",
"appid": "wx10dcc9f0235e1d77",
"templateId": "xxx",
"enabled": true,
"createTime": 1750168835000
}
]
}
}
```
#### transformer 转换过程
1. **提取分页数据**:从 `res.data` 中解构 `records`, `page`, `pageSize`, `total`
2. **添加索引**:为每条记录添加 `index` 字段,用于表格序号显示
3. **返回标准格式**:返回 `{ data, pageNum, pageSize, total }` 供 useTable 使用
#### 列定义处理
useTable hook 通过 `getColumns` 函数处理列定义:
```typescript
getColumns: (cols, checks) => {
const columnMap = new Map();
cols.forEach(column => {
if (isTableColumnHasKey(column)) {
columnMap.set(column.key, column);
}
});
return checks.filter(item => item.checked).map(check => columnMap.get(check.key));
}
```
### 故障案例:微信告警配置表格显示空白
#### 问题现象
- API 正常返回数据
- data 数组长度正确
- columns 数组长度正确
- 但 NDataTable 表格完全空白
#### 排查过程
##### 第一阶段:参数传递问题
**怀疑**:API 参数传递错误
**排查**:
```typescript
// 修复搜索组件 type 参数传递
watch(() => props.type, (newType) => {
if (newType) {
model.value.type = newType;
}
}, { immediate: true });
```
**结果**:API 能正确返回数据,但表格仍空白
##### 第二阶段:数据过滤问题
**怀疑**:前端数据过滤导致显示为空
**排查**:
```typescript
// 添加 filteredData 计算属性
const filteredData = computed(() => {
if (!data.value) return [];
return data.value.filter(item => item.type === activeTab.value);
});
```
**结果**:数据过滤正确,但表格仍空白
##### 第三阶段:useTable hook 问题
**怀疑**:useTable 的 columns 处理丢失了 render 函数
**排查**:
```json
// 调试发现列定义中缺失 render 函数
[
{ "key": "corpId", "title": "企业ID", "align": "center", "width": 150 },
// render 函数在 JSON.stringify 时丢失了!
]
```
**发现**:useTable 的 getColumns 函数在处理列定义时,render 函数被丢失
##### 第四阶段:NDataTable 属性问题
**怀疑**:NDataTable 的某些属性导致渲染失败
**排查**:逐个移除属性测试
```typescript
// 问题属性组合
<NDataTable
remote // ❌ 导致问题的关键属性
:pagination="mobilePagination" // ❌ 分页配置问题
:flex-height="!appStore.isMobile" // ❌ 高度配置问题
class="sm:h-full" // ❌ CSS 类冲突
:data="data"
:columns="columns"
/>
// 修复后的简化版本
<NDataTable
:data="data"
:columns="columns"
:row-key="(row) => row.id"
size="small"
striped
:loading="loading"
/>
```
#### 根本原因
1. **`remote` 属性**:告诉 NDataTable 期待远程分页,但我们提供的是本地数据
2. **复杂的分页配置**:mobilePagination 与数据状态不匹配
3. **CSS 高度问题**:flex-height 和相关 CSS 类导致表格高度为 0
### 故障排查最佳实践
#### 1. 分层排查法
```
数据层 → 转换层 → 组件层 → 渲染层
↓ ↓ ↓ ↓
API useTable Vue组件 DOM
```
#### 2. 调试工具使用
```typescript
// 在关键节点添加调试输出
console.log('[API] 原始响应:', response);
console.log('[Transformer] 转换后数据:', transformedData);
console.log('[Component] 渲染数据:', data.value);
console.log('[Columns] 列定义:', columns.value);
```
#### 3. 对比测试法
```vue
<!-- 使用简化的测试表格对比 -->
<NDataTable
:data="data"
:columns="[
{ key: 'id', title: 'ID' },
{ key: 'type', title: '类型' }
]"
:row-key="(row) => row.id"
/>
```
### NDataTable 使用指南
#### ✅ 推荐配置
```vue
<NDataTable
:data="data"
:columns="columns"
:row-key="(row) => row.id"
size="small"
striped
:loading="loading"
v-model:checked-row-keys="checkedRowKeys"
/>
```
#### ❌ 避免的属性组合
```vue
<NDataTable
remote <!-- 除非真正需要远程分页 -->
:pagination="complexPagination" <!-- 避免复杂分页配置 -->
:flex-height="dynamicValue" <!-- 避免动态高度计算 -->
class="complex-css-classes" <!-- 避免可能冲突的CSS类 -->
/>
```
#### 列定义最佳实践
```typescript
// ✅ 使用 computed 属性,不依赖 useTable 的 columns
const tableColumns = computed(() => [
{ key: 'id', title: 'ID', width: 80 },
{
key: 'status',
title: '状态',
render: (row) => row.enabled ? '启用' : '禁用'
},
{
key: 'operate',
title: '操作',
render: (row) => h(NButton, { onClick: () => edit(row) }, () => '编辑')
}
]);
// ❌ 避免依赖 useTable 的动态列定义处理
```
### 类似问题快速诊断清单
#### 1. 数据检查 (30秒)
- [ ] API 是否返回正确数据?
- [ ] `data.length > 0` 是否为真?
- [ ] 数据结构是否与列定义匹配?
#### 2. 列定义检查 (1分钟)
- [ ] `columns.length > 0` 是否为真?
- [ ] render 函数是否丢失?
- [ ] key 字段是否与数据字段匹配?
#### 3. 组件属性检查 (2分钟)
- [ ] 移除 `remote` 属性测试
- [ ] 移除 `pagination` 属性测试
- [ ] 使用最简化的 NDataTable 配置测试
#### 4. 对比测试 (2分钟)
- [ ] 创建简化版本的测试表格
- [ ] 使用相同数据和简单列定义
- [ ] 确认是数据问题还是组件问题
### 总结
这次故障排查耗时的主要原因:
1. **问题隐蔽性**:表面现象(表格空白)与根本原因(NDataTable属性配置)没有直接关联
2. **多层抽象**:useTable hook 增加了调试复杂度
3. **属性依赖**:多个 NDataTable 属性的组合效应难以预测
通过建立这套分层排查方法和文档化的最佳实践,类似问题的排查时间可以从数小时缩短到数分钟。
## 多租户 CustomerId 自动注入功能
### 功能概述
为了更好地支持多租户架构,在前端 ljwx-admin 中实现了全局自动注入 `customerId` 的功能。该功能在请求拦截器层面统一处理,无需在每个 API 调用中手动添加 `customerId` 参数。
### 实现原理
在 `src/service/request/index.ts` 的 `onRequest` 拦截器中实现:
1. **自动检测**:检查当前用户的 `customerId` 是否存在
2. **白名单过滤**:排除不需要 `customerId` 的特殊 API(如认证、字典等)
3. **智能注入**:
- GET 请求:添加到 `params` 查询参数
- POST/PUT/DELETE 请求:添加到 `data` 请求体
- FormData 请求:作为表单字段添加
4. **防重复**:只有当请求中没有显式传入 `customerId` 时才自动添加
### 白名单配置
```typescript
const EXCLUDE_CUSTOMER_ID_URLS = [
'/auth/', // 认证相关
'/login', // 登录
'/refresh', // 刷新token
'/sys/dict', // 字典数据(全局)
'/captcha', // 验证码
'/logout' // 登出
];
```
### 使用示例
#### 原来需要手动添加
```typescript
// 旧方式:每个API调用都要手动添加 customerId
fetchGetUserList({
page: 1,
pageSize: 20,
customerId: authStore.userInfo.customerId
});
```
#### 现在自动注入
```typescript
// 新方式:系统自动注入,无需手动添加
fetchGetUserList({
page: 1,
pageSize: 20
// customerId 会被自动添加
});
```
### 特殊情况处理
#### 1. 显式传入 customerId
```typescript
// 显式传入的值会被保留,不会被自动注入覆盖
fetchGetUserList({
page: 1,
pageSize: 20,
customerId: 999 // 使用这个值,不会被自动注入覆盖
});
```
#### 2. 超级管理员(customerId = 0)
```typescript
// customerId = 0 的超级管理员也会正常注入参数
// 后端可以根据 customerId = 0 判断是否返回全局数据
```
#### 3. FormData 类型请求
```typescript
// 文件上传等 FormData 请求也会自动添加 customerId
const formData = new FormData();
formData.append('file', file);
// 系统自动添加: formData.append('customerId', userInfo.customerId);
```
### 技术优势
1. **零侵入**:不需要修改现有 70+ 个文件的 API 调用代码
2. **向后兼容**:已有的 `customerId` 参数不会被覆盖
3. **统一管理**:在一个地方控制所有 API 的多租户行为
4. **灵活配置**:通过白名单机制精确控制哪些 API 需要自动注入
5. **类型安全**:TypeScript 完全支持,不需要修改类型定义
### 调试方法
如需调试,可在 `onRequest` 拦截器中添加 console.log:
```typescript
if (shouldAddCustomerId) {
const customerId = authStore.userInfo.customerId;
console.log(`[CustomerId Auto-Injection] URL: ${config.url}, Method: ${config.method}, CustomerId: ${customerId}`);
// ... 注入逻辑
}
```
---
## 项目开发注意事项
### 1. 表格开发规范
- 避免使用 `remote` 属性,除非确实需要远程分页
- 列定义使用独立的 computed 属性,不依赖 useTable 的 columns 处理
- 优先使用简化的 NDataTable 配置,渐进式添加复杂功能
### 2. 调试技巧
- 数据问题时,先用简化测试表格验证数据正确性
- 使用分层排查法,从 API → useTable → 组件 → 渲染逐层检查
- 重要的调试信息要保留在代码注释中
### 3. 常见陷阱
- useTable hook 的 transformer 会自动添加 index 字段
- NDataTable 的 render 函数在 JSON.stringify 时会丢失
- type 参数需要在搜索组件中正确设置和传递
@@ -1,896 +0,0 @@
# 🚀 灵境万象健康管理系统 - 完整优化实施方案
基于 docs 目录中三个核心优化文档的分析,本方案将系统性地提升性能、可靠性和用户体验。
## 📋 方案概述
### 🎯 核心优化目标
1. **组织架构性能提升100倍** - 从500ms降低到5ms
2. **告警响应时间减少50%** - 从30分钟降低到15分钟
3. **消息处理吞吐量提升200%** - 支持10万+设备并发
4. **系统稳定性提升** - 告警丢失率<0.1%
### 🏗️ 三大优化支柱
- **支柱1**: 组织架构闭包表优化 (性能基础)
- **支柱2**: 智能告警通知系统 (业务核心)
- **支柱3**: 自适应批处理优化 (处理引擎)
---
## 📅 完整实施时间线
### Phase 1: 基础设施优化 (第1-3周)
**目标**: 建立高性能数据存储和查询基础
#### Week 1: 组织架构闭包表改造
```sql
-- 1. 创建闭包表结构
CREATE TABLE sys_org_closure (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
ancestor_id BIGINT NOT NULL COMMENT '祖先节点ID',
descendant_id BIGINT NOT NULL COMMENT '后代节点ID',
depth INT NOT NULL DEFAULT 0 COMMENT '层级深度(0表示自己)',
customer_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
UNIQUE KEY uk_ancestor_descendant (ancestor_id, descendant_id),
INDEX idx_ancestor (ancestor_id, customer_id),
INDEX idx_descendant (descendant_id, customer_id),
INDEX idx_depth (depth),
INDEX idx_customer_depth (customer_id, depth)
);
```
**关键实现**:
```java
@Service
@Transactional
public class ClosureTableService {
/**
* 查找所有子部门 - O(1)复杂度
*/
public List<SysOrgUnits> findAllChildren(Long parentId, Long customerId) {
String sql = """
SELECT o.* FROM sys_org_units o
INNER JOIN sys_org_closure c ON o.id = c.descendant_id
WHERE c.ancestor_id = ? AND c.customer_id = ? AND c.depth > 0
AND o.status = '1' AND o.is_deleted = 0
ORDER BY c.depth, o.sort
""";
return jdbcTemplate.query(sql, new BeanPropertyRowMapper<>(SysOrgUnits.class),
parentId, customerId);
}
/**
* 批量查找管理员 - 性能优化关键
*/
public Map<Long, List<Long>> batchFindManagers(List<Long> orgIds, Long customerId) {
String sql = """
SELECT uo.org_id, uo.user_id
FROM sys_user_org uo
INNER JOIN sys_org_closure c ON uo.org_id = c.descendant_id
WHERE c.ancestor_id IN (%s) AND c.customer_id = ?
AND uo.principal = '1' AND uo.is_deleted = 0
""".formatted(String.join(",", Collections.nCopies(orgIds.size(), "?")));
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql,
Stream.concat(orgIds.stream(), Stream.of(customerId)).toArray());
return rows.stream().collect(Collectors.groupingBy(
row -> (Long) row.get("org_id"),
Collectors.mapping(row -> (Long) row.get("user_id"), Collectors.toList())
));
}
}
```
#### Week 2-3: 自适应批处理系统
```java
@Component
public class AdaptiveBatchProcessor {
private final int cpuCores = Runtime.getRuntime().availableProcessors();
private volatile int currentBatchSize;
private volatile int workerCount;
@PostConstruct
public void initialize() {
// 根据CPU核心数动态配置
this.currentBatchSize = calculateOptimalBatchSize();
this.workerCount = calculateOptimalWorkers();
log.info("🚀 自适应批处理器初始化完成: CPU核心={}, 批次大小={}, 工作线程={}",
cpuCores, currentBatchSize, workerCount);
}
private int calculateOptimalBatchSize() {
// 基于CPU核心数和内存的动态计算
long availableMemory = Runtime.getRuntime().maxMemory() / (1024 * 1024); // MB
int baseBatchSize = cpuCores * 25;
// 内存调整系数
double memoryFactor = Math.min(2.0, availableMemory / 4096.0); // 4GB为基准
return Math.max(50, Math.min(1000, (int)(baseBatchSize * memoryFactor)));
}
private int calculateOptimalWorkers() {
// I/O密集型任务:CPU核心数 * 2-3
return Math.max(4, Math.min(32, cpuCores * 3));
}
/**
* 智能批处理执行
*/
public <T> CompletableFuture<BatchResult> processBatch(
List<T> data,
Function<List<T>, Boolean> processor,
BatchOptions options) {
return CompletableFuture.supplyAsync(() -> {
long startTime = System.currentTimeMillis();
// 分批处理
List<List<T>> batches = partitionList(data, currentBatchSize);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failureCount = new AtomicInteger(0);
// 并行处理批次
try (ThreadPoolExecutor executor = createDynamicExecutor()) {
List<CompletableFuture<Boolean>> futures = batches.stream()
.map(batch -> CompletableFuture.supplyAsync(() -> {
try {
boolean result = processor.apply(batch);
if (result) {
successCount.addAndGet(batch.size());
} else {
failureCount.addAndGet(batch.size());
}
return result;
} catch (Exception e) {
log.error("批处理执行失败", e);
failureCount.addAndGet(batch.size());
return false;
}
}, executor))
.collect(Collectors.toList());
// 等待所有批次完成
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
long processingTime = System.currentTimeMillis() - startTime;
double throughput = data.size() / (processingTime / 1000.0);
// 动态调整批次大小
adjustBatchSize(throughput, processingTime);
return BatchResult.builder()
.totalItems(data.size())
.successCount(successCount.get())
.failureCount(failureCount.get())
.processingTimeMs(processingTime)
.throughputPerSecond(throughput)
.batchSize(currentBatchSize)
.build();
});
}
private void adjustBatchSize(double throughput, long processingTime) {
if (throughput < 50) { // 吞吐量过低
currentBatchSize = Math.max(50, (int)(currentBatchSize * 0.8));
} else if (throughput > 200 && processingTime < 1000) { // 高吞吐量且响应快
currentBatchSize = Math.min(1000, (int)(currentBatchSize * 1.2));
}
}
}
```
### Phase 2: 智能告警系统 (第4-7周)
**目标**: 实现智能化、多层级的告警处理体系
#### Week 4-5: 智能优先级系统
```java
@Component
public class SmartAlertPriorityEngine {
/**
* 智能优先级计算
*/
public AlertPriorityResult calculatePriority(AlertInfo alert, UserProfile userProfile) {
PriorityCalculator calculator = new PriorityCalculator();
// 1. 基础优先级
int basePriority = getBasePriority(alert.getSeverityLevel());
// 2. 多维度调整
int timeAdjustment = calculateTimeAdjustment(alert.getAlertTimestamp());
int userAdjustment = calculateUserAdjustment(userProfile);
int frequencyAdjustment = calculateFrequencyAdjustment(alert);
int locationAdjustment = calculateLocationAdjustment(alert);
// 3. 综合计算
int finalPriority = Math.max(1, Math.min(10,
basePriority + timeAdjustment + userAdjustment + frequencyAdjustment + locationAdjustment));
// 4. 计算处理期限
LocalDateTime dueTime = calculateDueTime(finalPriority, alert.getAlertType());
return AlertPriorityResult.builder()
.priority(finalPriority)
.dueTime(dueTime)
.calculationDetails(Map.of(
"base", basePriority,
"time", timeAdjustment,
"user", userAdjustment,
"frequency", frequencyAdjustment,
"location", locationAdjustment
))
.build();
}
private int calculateTimeAdjustment(LocalDateTime alertTime) {
int hour = alertTime.getHour();
int dayOfWeek = alertTime.getDayOfWeek().getValue();
int adjustment = 0;
// 夜间时段优先级提升
if (hour >= 22 || hour <= 6) {
adjustment -= 1;
}
// 周末优先级提升
if (dayOfWeek >= 6) {
adjustment -= 1;
}
return adjustment;
}
private int calculateUserAdjustment(UserProfile profile) {
if (profile == null) return 0;
int adjustment = 0;
// 年龄因子
int age = profile.getAge();
if (age >= 70) {
adjustment -= 2; // 70岁以上高优先级
} else if (age >= 60) {
adjustment -= 1; // 60岁以上中优先级
}
// 健康风险等级
if ("high".equals(profile.getHealthRiskLevel())) {
adjustment -= 2;
} else if ("medium".equals(profile.getHealthRiskLevel())) {
adjustment -= 1;
}
// VIP用户
if (profile.isVip()) {
adjustment -= 1;
}
return adjustment;
}
}
```
#### Week 6-7: 多层级通知系统
```java
@Service
public class EnhancedNotificationService {
@Autowired
private ClosureTableService orgService;
/**
* 增强的多层级通知处理
*/
@Async
public CompletableFuture<NotificationResult> processAlertNotification(AlertInfo alert) {
NotificationContext context = NotificationContext.builder()
.alert(alert)
.timestamp(LocalDateTime.now())
.build();
try {
// 1. 获取设备绑定信息
DeviceBinding deviceBinding = getDeviceBinding(alert.getDeviceSn());
if (deviceBinding == null) {
return CompletableFuture.completedFuture(
NotificationResult.failed("设备绑定信息不存在"));
}
// 2. 构建通知层级链
List<NotificationTarget> targets = buildNotificationChain(deviceBinding, alert);
// 3. 执行分层通知
List<NotificationResult> results = new ArrayList<>();
for (NotificationTarget target : targets) {
NotificationResult result = executeNotification(target, context);
results.add(result);
// Critical级别告警立即通知所有层级
if (!"critical".equals(alert.getSeverityLevel())) {
// 非Critical告警,如果当前层级成功,可以延迟后续层级
if (result.isSuccess()) {
scheduleEscalation(targets.subList(targets.indexOf(target) + 1, targets.size()),
context, Duration.ofMinutes(5));
break;
}
}
}
// 4. WebSocket推送 (Critical告警)
if ("critical".equals(alert.getSeverityLevel())) {
sendWebSocketNotification(alert, deviceBinding);
}
return CompletableFuture.completedFuture(
NotificationResult.success(results));
} catch (Exception e) {
log.error("告警通知处理失败: alertId={}", alert.getId(), e);
return CompletableFuture.completedFuture(
NotificationResult.failed("通知处理异常: " + e.getMessage()));
}
}
/**
* 构建通知层级链
*/
private List<NotificationTarget> buildNotificationChain(DeviceBinding binding, AlertInfo alert) {
List<NotificationTarget> targets = new ArrayList<>();
// 1. 设备用户 (第一层级)
targets.add(NotificationTarget.builder()
.level(1)
.type(TargetType.DEVICE_USER)
.userId(binding.getUserId())
.message(generateUserMessage(alert, binding))
.channels(List.of("message", "wechat"))
.delay(Duration.ZERO)
.build());
// 2. 部门主管 (第二层级) - 使用闭包表快速查询
List<Long> managers = orgService.findDepartmentManagers(binding.getOrgId(), binding.getCustomerId());
if (!managers.isEmpty()) {
for (Long managerId : managers) {
if (!managerId.equals(binding.getUserId())) { // 避免重复通知
targets.add(NotificationTarget.builder()
.level(2)
.type(TargetType.DEPARTMENT_MANAGER)
.userId(managerId)
.message(generateManagerMessage(alert, binding))
.channels(List.of("message", "wechat"))
.delay(Duration.ofMinutes(5))
.build());
}
}
}
// 3. 租户管理员 (第三层级) - 仅Critical级别
if ("critical".equals(alert.getSeverityLevel())) {
List<Long> admins = orgService.findTenantAdmins(binding.getCustomerId());
for (Long adminId : admins) {
targets.add(NotificationTarget.builder()
.level(3)
.type(TargetType.TENANT_ADMIN)
.userId(adminId)
.message(generateAdminMessage(alert, binding))
.channels(List.of("message", "wechat", "email"))
.delay(Duration.ofMinutes(15))
.build());
}
}
return targets;
}
/**
* 智能消息去重
*/
private boolean shouldSendNotification(NotificationTarget target, NotificationContext context) {
String dedupeKey = generateDedupeKey(target, context.getAlert());
// 检查5分钟内是否有相同消息
String cacheKey = "notification:dedupe:" + dedupeKey;
Boolean exists = redisTemplate.hasKey(cacheKey);
if (Boolean.TRUE.equals(exists)) {
// 检查是否是状态变化或紧急升级
if (context.getAlert().getSeverityLevel().equals("critical") ||
isDeviceStateChanged(context.getAlert().getDeviceSn())) {
return true; // 允许发送
}
return false; // 去重
}
// 记录发送历史,5分钟过期
redisTemplate.opsForValue().set(cacheKey, "sent", Duration.ofMinutes(5));
return true;
}
}
```
### Phase 3: 性能监控与优化 (第8-10周)
**目标**: 建立完善的监控体系和持续优化机制
#### Week 8-9: 综合性能监控
```java
@Component
public class SystemPerformanceMonitor {
private final MeterRegistry meterRegistry;
private final AdaptiveBatchProcessor batchProcessor;
/**
* 实时性能监控
*/
@Scheduled(fixedRate = 30000) // 30秒
public void monitorSystemPerformance() {
// 1. 组织查询性能监控
Timer.Sample orgQuerySample = Timer.start(meterRegistry);
double avgOrgQueryTime = measureAverageOrgQueryTime();
orgQuerySample.stop(Timer.builder("org.query.performance")
.tag("type", "average")
.register(meterRegistry));
// 2. 告警处理性能监控
double alertProcessingRate = measureAlertProcessingRate();
Gauge.builder("alert.processing.rate")
.register(meterRegistry, () -> alertProcessingRate);
// 3. 批处理性能监控
BatchStats batchStats = batchProcessor.getCurrentStats();
Gauge.builder("batch.processing.throughput")
.register(meterRegistry, () -> batchStats.getThroughput());
// 4. 系统资源监控
double cpuUsage = getCpuUsage();
double memoryUsage = getMemoryUsage();
if (cpuUsage > 85) {
log.warn("⚠️ CPU使用率过高: {}%", cpuUsage);
triggerPerformanceAlert("CPU", cpuUsage);
}
if (memoryUsage > 85) {
log.warn("⚠️ 内存使用率过高: {}%", memoryUsage);
triggerPerformanceAlert("Memory", memoryUsage);
}
// 5. 生成性能报告
if (shouldGenerateReport()) {
generatePerformanceReport();
}
}
/**
* 自动性能调优
*/
@Scheduled(fixedRate = 300000) // 5分钟
public void autoTunePerformance() {
PerformanceMetrics metrics = collectPerformanceMetrics();
// 1. 自动调整批处理参数
if (metrics.getBatchProcessingLatency() > 2000) { // 超过2秒
batchProcessor.decreaseBatchSize(0.8);
log.info("🔧 自动调优: 降低批处理大小");
} else if (metrics.getBatchProcessingLatency() < 500) { // 小于500ms
batchProcessor.increaseBatchSize(1.2);
log.info("🔧 自动调优: 增加批处理大小");
}
// 2. 自动调整缓存策略
double cacheHitRate = metrics.getCacheHitRate();
if (cacheHitRate < 80) {
adjustCacheStrategy(metrics);
log.info("🔧 自动调优: 调整缓存策略,命中率: {}%", cacheHitRate);
}
// 3. 自动调整连接池
if (metrics.getDbConnectionPoolUsage() > 90) {
increaseConnectionPoolSize();
log.info("🔧 自动调优: 增加数据库连接池大小");
}
}
/**
* 预测性告警
*/
@Scheduled(fixedRate = 600000) // 10分钟
public void predictiveMonitoring() {
// 1. 预测系统负载
LoadPrediction prediction = predictSystemLoad(Duration.ofHours(2));
if (prediction.getPredictedLoad() > 0.9) {
sendPredictiveAlert("预计2小时内系统负载将超过90%", prediction);
}
// 2. 预测告警峰值
AlertVolumePrediction alertPrediction = predictAlertVolume(Duration.ofHours(1));
if (alertPrediction.getPredictedVolume() > getCurrentCapacity() * 0.8) {
sendCapacityAlert("预计1小时内告警量将达到系统容量的80%", alertPrediction);
}
// 3. 预测资源不足
ResourcePrediction resourcePrediction = predictResourceUsage(Duration.ofMinutes(30));
if (resourcePrediction.getMemoryPrediction() > 0.9) {
sendResourceAlert("预计30分钟内内存使用率将超过90%", resourcePrediction);
}
}
}
```
#### Week 10: 系统优化报告与建议
```java
@Component
public class SystemOptimizationReporter {
/**
* 生成综合优化报告
*/
public SystemOptimizationReport generateOptimizationReport() {
return SystemOptimizationReport.builder()
// 1. 性能提升统计
.performanceImprovement(PerformanceImprovement.builder()
.orgQuerySpeedUp("100x - 从500ms降低到5ms")
.alertResponseTimeReduction("50% - 从30分钟降低到15分钟")
.batchProcessingThroughput("200% - 支持10万+设备并发")
.systemStabilityImprovement("告警丢失率<0.1%")
.build())
// 2. 资源利用率优化
.resourceUtilization(ResourceUtilization.builder()
.cpuEfficiency("提升45% - 更好的多核利用")
.memoryOptimization("降低30% - 智能缓存策略")
.databasePerformance("查询性能提升100x - 闭包表优化")
.networkTraffic("降低25% - 批处理和去重优化")
.build())
// 3. 业务指标改善
.businessMetrics(BusinessMetrics.builder()
.falsePositiveReduction("从20%降低到8%")
.userSatisfaction("从80%提升到95%")
.alertProcessingAutomation("从20%提升到50%")
.systemScalability("支持10万+组织节点")
.build())
// 4. 技术债务清理
.technicalDebtReduction(TechnicalDebtReduction.builder()
.legacyCodeReplacement("ancestors字符串查询 → 闭包表")
.architectureModernization("单体批处理 → 自适应批处理")
.monitoringEnhancement("基础监控 → 预测性监控")
.scalabilityImprovement("单租户优化 → 多租户高并发")
.build())
// 5. 运维效率提升
.operationalEfficiency(OperationalEfficiency.builder()
.automaticTuning("自动调优减少70%人工干预")
.alertAccuracy("智能优先级减少误报50%")
.systemObservability("360度性能监控")
.troubleshootingSpeed("故障定位时间减少80%")
.build())
.build();
}
/**
* 生成持续优化建议
*/
public List<OptimizationRecommendation> generateContinuousOptimizationPlan() {
return Arrays.asList(
// 短期优化建议 (1-3个月)
OptimizationRecommendation.builder()
.priority(Priority.HIGH)
.timeframe("1-3个月")
.title("机器学习告警分析")
.description("引入ML模型进行告警模式识别和误报预测")
.expectedBenefit("误报率进一步降低到5%以下")
.complexity(Complexity.MEDIUM)
.build(),
OptimizationRecommendation.builder()
.priority(Priority.HIGH)
.timeframe("1-2个月")
.title("多渠道通知集成")
.description("集成钉钉、飞书、短信等更多通知渠道")
.expectedBenefit("通知覆盖率提升到99%+")
.complexity(Complexity.LOW)
.build(),
// 中期优化建议 (3-6个月)
OptimizationRecommendation.builder()
.priority(Priority.MEDIUM)
.timeframe("3-6个月")
.title("边缘计算告警预处理")
.description("在设备端进行初步告警过滤和预处理")
.expectedBenefit("网络流量降低50%,响应时间提升30%")
.complexity(Complexity.HIGH)
.build(),
OptimizationRecommendation.builder()
.priority(Priority.MEDIUM)
.timeframe("4-6个月")
.title("告警知识库和自动处理")
.description("建立告警处理知识库,实现常见告警自动处理")
.expectedBenefit("自动处理率从50%提升到80%")
.complexity(Complexity.HIGH)
.build(),
// 长期优化建议 (6-12个月)
OptimizationRecommendation.builder()
.priority(Priority.LOW)
.timeframe("6-12个月")
.title("微服务架构演进")
.description("将告警系统拆分为独立的微服务")
.expectedBenefit("系统可扩展性和维护性大幅提升")
.complexity(Complexity.VERY_HIGH)
.build(),
OptimizationRecommendation.builder()
.priority(Priority.LOW)
.timeframe("8-12个月")
.title("区块链告警溯源")
.description("使用区块链技术确保告警数据不可篡改")
.expectedBenefit("告警数据可信度100%,满足医疗合规要求")
.complexity(Complexity.VERY_HIGH)
.build()
);
}
}
```
---
## 🎯 实施成功标准
### 关键性能指标 (KPIs)
#### 1. 性能指标
- ✅ 组织查询响应时间: < 10ms (目标5ms)
- ✅ 告警处理响应时间: < 20分钟 (目标15分钟)
- ✅ 批处理吞吐量: > 5000条/秒
- ✅ 系统并发用户: > 10000
#### 2. 稳定性指标
- ✅ 系统可用性: > 99.9%
- ✅ 告警丢失率: < 0.1%
- ✅ 数据一致性: 100%
- ✅ 平均故障恢复时间: < 15分钟
#### 3. 业务指标
- ✅ 用户满意度: > 95%
- ✅ 告警误报率: < 8%
- ✅ 自动处理比例: > 50%
- ✅ 通知覆盖率: > 98%
### 质量保证措施
#### 1. 测试策略
```java
@TestConfiguration
public class SystemOptimizationTestSuite {
/**
* 性能回归测试
*/
@Test
public void performanceRegressionTest() {
// 组织查询性能测试
long startTime = System.currentTimeMillis();
List<SysOrgUnits> children = orgService.findAllChildren(parentId, customerId);
long queryTime = System.currentTimeMillis() - startTime;
assertThat(queryTime).isLessThan(10); // 10ms以内
assertThat(children).isNotEmpty();
}
/**
* 批处理性能测试
*/
@Test
public void batchProcessingPerformanceTest() {
List<TestData> largeDataSet = generateTestData(10000);
CompletableFuture<BatchResult> result = batchProcessor.processBatch(
largeDataSet, this::mockProcessor, BatchOptions.defaultOptions());
BatchResult batchResult = result.join();
assertThat(batchResult.getThroughputPerSecond()).isGreaterThan(5000);
assertThat(batchResult.getSuccessRate()).isGreaterThan(0.99);
}
/**
* 告警通知集成测试
*/
@Test
public void alertNotificationIntegrationTest() {
AlertInfo criticalAlert = createCriticalAlert();
CompletableFuture<NotificationResult> result =
notificationService.processAlertNotification(criticalAlert);
NotificationResult notificationResult = result.join();
assertThat(notificationResult.isSuccess()).isTrue();
assertThat(notificationResult.getNotifiedLevels()).contains(1, 2, 3); // 三级通知
assertThat(notificationResult.getResponseTime()).isLessThan(Duration.ofSeconds(5));
}
}
```
#### 2. 监控和告警
```yaml
# Prometheus监控配置
monitoring:
metrics:
- name: org_query_duration
help: "组织查询响应时间"
type: histogram
buckets: [1, 5, 10, 25, 50, 100, 250, 500, 1000]
- name: alert_processing_rate
help: "告警处理速率"
type: gauge
- name: batch_processing_throughput
help: "批处理吞吐量"
type: gauge
- name: system_resource_usage
help: "系统资源使用率"
type: gauge
labels: [resource_type]
# 告警规则配置
alerts:
- name: PerformanceDegradation
condition: org_query_duration_p95 > 50
message: "组织查询性能下降,95分位响应时间超过50ms"
severity: warning
- name: BatchProcessingBottleneck
condition: batch_processing_throughput < 1000
message: "批处理性能瓶颈,吞吐量低于1000条/秒"
severity: critical
- name: SystemResourceExhaustion
condition: system_resource_usage{resource_type="memory"} > 0.9
message: "系统内存使用率超过90%"
severity: critical
```
---
## 🚀 实施执行计划
### 1. 项目启动 (第1周)
**团队组建**:
- 项目经理 × 1
- 后端开发工程师 × 3
- 前端开发工程师 × 2
- 数据库优化专家 × 1
- 测试工程师 × 2
- 运维工程师 × 1
**环境准备**:
```bash
# 开发环境准备
git clone https://github.com/your-repo/ljwx-system.git
cd ljwx-system
# 创建优化分支
git checkout -b feature/system-optimization
# 数据库准备
mysql -u root -p test < database/optimization_schema.sql
# 依赖更新
mvn clean install
npm install
```
### 2. 风险控制与回滚策略
#### 风险评估矩阵
| 风险项 | 概率 | 影响 | 级别 | 缓解措施 |
|--------|------|------|------|----------|
| 数据迁移失败 | 低 | 高 | 中 | 完整备份+分批迁移+回滚脚本 |
| 性能不达预期 | 中 | 中 | 中 | 压力测试+灰度发布+配置调优 |
| 系统稳定性问题 | 低 | 高 | 中 | 充分测试+监控告警+快速响应 |
| 用户体验下降 | 低 | 中 | 低 | A/B测试+用户反馈+快速调整 |
#### 回滚预案
```bash
# 数据库回滚脚本
#!/bin/bash
BACKUP_DIR="/backup/$(date +%Y%m%d_%H%M%S)"
# 1. 停止应用服务
systemctl stop ljwx-admin ljwx-boot ljwx-bigscreen
# 2. 恢复数据库
mysql -u root -p test < $BACKUP_DIR/database_backup.sql
# 3. 恢复应用代码
git checkout release/stable
mvn clean install -DskipTests
# 4. 重启服务
systemctl start ljwx-admin ljwx-boot ljwx-bigscreen
# 5. 验证回滚结果
./scripts/health_check.sh
```
### 3. 成功验收标准
#### 技术验收
- [ ] 所有单元测试通过率 > 95%
- [ ] 集成测试覆盖率 > 90%
- [ ] 性能测试达到预期指标
- [ ] 安全扫描无高危漏洞
- [ ] 代码质量检查通过
#### 业务验收
- [ ] 用户操作响应时间满足要求
- [ ] 告警通知及时准确
- [ ] 系统稳定运行72小时
- [ ] 用户满意度调研 > 90%
- [ ] 业务指标达到预期
---
## 🎉 预期收益总结
### 量化收益
1. **性能提升**:
- 查询速度提升100倍 (500ms → 5ms)
- 告警响应时间减少50% (30min → 15min)
- 系统吞吐量提升200% (支持10万+设备)
2. **成本节约**:
- 服务器资源节约30% (优化算法和批处理)
- 运维成本降低40% (自动化监控和调优)
- 开发效率提升50% (标准化架构和工具)
3. **业务价值**:
- 用户满意度从80%提升到95%
- 误报率从20%降低到8%
- 自动化处理率从20%提升到50%
### 质性收益
1. **技术架构现代化**: 从传统单体架构向智能化、自适应架构演进
2. **运维能力提升**: 从被动响应向主动预测转变
3. **团队技能提升**: 掌握高性能系统设计和优化技能
4. **企业竞争力**: 建立技术壁垒,提升市场竞争力
---
这个完整的实施方案将系统性地解决当前系统的性能瓶颈,建立起一个高性能、高可靠、智能化的健康管理系统。通过分阶段实施,既保证了系统的稳定性,又实现了显著的性能提升和业务价值。
<function_calls>
<invoke name="TodoWrite">
<parameter name="todos">[{"content": "\u5206\u6790docs\u76ee\u5f55\u4e0b\u7684\u4e09\u4e2a\u6838\u5fc3\u6587\u6863\u5185\u5bb9", "status": "completed", "id": "1"}, {"content": "\u5236\u5b9a\u57fa\u4e8e\u4e09\u4e2a\u6587\u6863\u7684\u5b8c\u6574\u5b9e\u65bd\u65b9\u6848", "status": "completed", "id": "2"}]
-38
View File
@@ -1,38 +0,0 @@
# 使用 Node.js 官方镜像作为基础镜像
FROM node:18-alpine
# 设置北京时区
ENV TZ=Asia/Shanghai
RUN apk add --no-cache tzdata && \
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \
echo $TZ > /etc/timezone
# 安装 pnpm
RUN corepack enable && corepack prepare [email protected] --activate
# 设置工作目录
WORKDIR /app
# 安装构建依赖
RUN apk add --no-cache python3 make g++
# 复制 package.json、pnpm-lock.yaml 和其他配置文件
COPY package.json pnpm-lock.yaml .npmrc pnpm-workspace.yaml ./
COPY packages/ ./packages/
COPY tsconfig.json vite.config.ts ./
# 安装依赖
RUN pnpm install --frozen-lockfile
# 复制源代码
COPY . .
# 设置环境变量
ENV NODE_ENV=development
ENV VITE_USER_NODE_ENV=development
# 暴露端口
EXPOSE 5173
# 启动开发服务器
CMD ["pnpm", "run", "dev"]
-49
View File
@@ -1,49 +0,0 @@
# 多阶段构建:第一阶段在容器内构建前端应用
FROM node:20-alpine AS builder
LABEL maintainer="LJWX Team <[email protected]>"
# 安装 pnpm
RUN npm install -g pnpm
# 设置工作目录
WORKDIR /build
# 复制源代码
COPY . ./
# 安装依赖并构建
RUN pnpm install --frozen-lockfile && \
pnpm run build
# 第二阶段:运行时镜像
FROM nginx:alpine
LABEL maintainer="LJWX Team <[email protected]>"
# 设置时区
ENV TZ=Asia/Shanghai
RUN apk add --no-cache tzdata && \
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \
echo $TZ > /etc/timezone
# 安装必要工具
RUN apk add --no-cache findutils
# 从构建阶段复制前端资源
COPY --from=builder /build/dist /usr/share/nginx/html
# 复制配置文件
COPY nginx/nginx.conf /etc/nginx/nginx.conf
# 创建并复制入口脚本
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/health || exit 1
# 暴露端口
EXPOSE 80
# 启动脚本
ENTRYPOINT ["/docker-entrypoint.sh"]
-82
View File
@@ -1,82 +0,0 @@
# 多阶段构建优化版本
FROM node:18-alpine AS builder
# 设置北京时区
ENV TZ=Asia/Shanghai
RUN apk add --no-cache tzdata && \
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \
echo $TZ > /etc/timezone
# 启用pnpm
RUN corepack enable && corepack prepare [email protected] --activate
WORKDIR /app
# 优化:先复制package相关文件,利用Docker层缓存
COPY package.json pnpm-lock.yaml .npmrc pnpm-workspace.yaml ./
COPY packages/ ./packages/
# 只下载依赖,不执行构建
RUN pnpm install --frozen-lockfile
# 复制源代码和构建配置
COPY src/ ./src/
COPY public/ ./public/
COPY build/ ./build/
COPY index.html ./
COPY vite.config.ts ./
COPY tsconfig.json ./
COPY uno.config.ts ./
# 环境变量配置
ENV VITE_BASE_URL=./
ARG VITE_APP_TITLE=穿戴健康管理平台
ARG VITE_APP_DESC=穿戴健康管理平台
ENV VITE_APP_TITLE=${VITE_APP_TITLE}
ENV VITE_APP_DESC=${VITE_APP_DESC}
ENV VITE_ICON_PREFIX=icon
ENV VITE_ICON_LOCAL_PREFIX=icon-local
ENV VITE_AUTH_ROUTE_MODE=dynamic
ENV VITE_ROUTE_HOME=home
ENV VITE_MENU_ICON=mdi:menu
ENV VITE_HTTP_PROXY=Y
ENV VITE_ROUTER_HISTORY_MODE=hash
ENV VITE_SERVICE_SUCCESS_CODE=200
ENV VITE_SERVICE_LOGOUT_CODES=600,401
ENV VITE_SERVICE_MODAL_LOGOUT_CODES=401
ENV VITE_SERVICE_EXPIRED_TOKEN_CODES=40101
ENV VITE_STATIC_SUPER_ROLE=R_SUPER
ENV VITE_AUTH_BUTTON_MODE=route_meta
ENV VITE_SOURCE_MAP=N
ENV VITE_STORAGE_PREFIX=SOY_
ENV VITE_AUTOMATICALLY_DETECT_UPDATE=Y
ENV VITE_API_URL=/proxy-default
ENV VITE_SERVICE_BASE_URL=/proxy-default
ENV VITE_OTHER_SERVICE_BASE_URL='{"bigscreen": "/bigscreen/"}'
ARG VITE_BIGSCREEN_URL=http://localhost:8001
ARG VITE_CUSTOM_LOGO=false
ENV VITE_BIGSCREEN_URL=${VITE_BIGSCREEN_URL}
ENV VITE_CUSTOM_LOGO=${VITE_CUSTOM_LOGO}
# 构建前检查文件是否存在问题的Vue组件
RUN echo "检查Vue组件语法..." && \
find src -name "*.vue" -exec echo "检查文件: {}" \; || true
# 执行构建
RUN pnpm run build
# 生产环境nginx镜像
FROM nginx:alpine
# 设置北京时区
ENV TZ=Asia/Shanghai
RUN apk add --no-cache tzdata && \
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \
echo $TZ > /etc/timezone
RUN apk add --no-cache findutils # 添加find命令支持
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx/nginx.conf /etc/nginx/nginx.conf
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/docker-entrypoint.sh"]
-16
View File
@@ -1,16 +0,0 @@
# 简化版Dockerfile - 假设已经预构建了dist目录
FROM nginx:alpine
# 设置北京时区
ENV TZ=Asia/Shanghai
RUN apk add --no-cache tzdata && \
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \
echo $TZ > /etc/timezone
RUN apk add --no-cache findutils # 添加find命令支持
COPY dist /usr/share/nginx/html
COPY nginx/nginx.conf /etc/nginx/nginx.conf
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/docker-entrypoint.sh"]
-201
View File
@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2024 ljwx
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-661
View File
@@ -1,661 +0,0 @@
# LjwxAdmin 后台管理系统
![Node](https://img.shields.io/badge/Node-18-blue.svg)
![Version](https://img.shields.io/badge/Version-1.3.8-blue.svg)
[![License](https://img.shields.io/badge/License-Apache%20License%202.0-B9D6AF.svg)](./LICENSE)
<br/>
[![Author](https://img.shields.io/badge/Author-paynezhuang-green.svg)](https://github.com/paynezhuang)
[![Copyright](https://img.shields.io/badge/Copyright-2024%20Zhuang%20Pan%[email protected])](https://github.com/paynezhuang)
### 项目简介
[`LingjingAdmin`](https://github.com/BrunoGao/platform) 基于 [`SoybeanAdmin`](https://github.com/soybeanjs/soybean-admin) 二次修改而来。它是一个清新优雅、高颜值且功能强大的后台管理模板,采用`Naive UI`组件库,并最新的前端技术栈,包括 Vue3, Vite5, TypeScript, Pinia 和 UnoCSS。它内置了丰富的主题配置和组件,代码规范严谨,实现了自动化的文件路由系统。`LingjingAdmin` 为您提供了一站式的后台管理解决方案,无需额外配置,开箱即用。同样是一个快速学习前沿技术的最佳实践。
在此特别感谢开源作者:[Soybean](https://github.com/honghuangdc) 。
### 项目特性
- **前沿技术应用**:采用 Vue3, Vite5, TypeScript, Pinia 和 UnoCSS 等最新流行的技术栈。
- **清晰的项目架构**:采用 pnpm monorepo 架构,结构清晰,优雅易懂。
- **严格的代码规范**:遵循 [SoybeanJS 规范](https://docs.soybeanjs.cn/zh/standard),集成了eslint, prettier 和 simple-git-hooks,保证代码的规范性。
- **TypeScript**: 支持严格的类型检查,提高代码的可维护性。
- **丰富的主题配置**:内置多样的主题配置,与 UnoCSS 完美结合。
- **内置国际化方案**:轻松实现多语言支持。
- **自动化文件路由系统**:自动生成路由导入、声明和类型。更多细节请查看 [Elegant Router](https://github.com/soybeanjs/elegant-router)。
- **灵活的权限路由**:同时支持前端静态路由和后端动态路由。
- **丰富的页面组件**:内置多样页面和组件,包括403、404、500页面,以及布局组件、标签组件、主题配置组件等。
- **命令行工具**:内置高效的命令行工具,git提交、删除文件、发布等。
- **移动端适配**:完美支持移动端,实现自适应布局。
### 项目源码
| 名称 | 链接 |
|:--------|:------------------------------------------------------------------------|
| 前端 | [Lingjing-admin](https://github.com/BrunoGao/platform) |
| 后端 | [Lingjing-boot](https://github.com/BrunoGao/platform) |
| 后端扩展依赖库 | [Lingjing-boot-starter](https://github.com/BrunoGao/platform) |
### 项目启动
##### 前置环境
- **Git**: 你需要git来克隆和管理项目版本。
- **NodeJS**: >=18.12.0,推荐 18.19.0 或更高。
- **pnpm**: >= 8.7.0,推荐 8.14.0 或更高。
##### 克隆项目
```bash
git clone https://github.com/BrunoGao/platform.git
```
##### 项目启动
1. 安装依赖
```bash
pnpm i
```
> 由于本项目采用了 pnpm monorepo 的管理方式,因此请不要使用 npm 或 yarn 来安装依赖。
2. 启动项目,修改`.env.test`中`VITE_SERVICE_BASE_URL`对应的后端地址
```bash
pnpm dev
```
##### 构建项目
修改`.env.prod`中`VITE_SERVICE_BASE_URL`对应的后端地址
```bash
pnpm build
```
### Git 提交规范
本项目已内置 `commit` 命令,您可以通过执行 `pnpm commit` 来生成符合 [Conventional Commits]([conventionalcommits](https://www.conventionalcommits.org/)) 规范的提交信息。在提交PR时,请务必使用 `commit` 命令来创建提交信息,以确保信息的规范性。
### 示例图片
![](./doc/images/Login.png)
| ![Home](./doc/images/Home.png) | ![Home-2](./doc/images/Home-2.png) |
|--------------------------------|--------------------------------|
| ![User](./doc/images/User.png) | ![User-2](./doc/images/User-2.png) |
| ![Role](./doc/images/Role-Permission.png) | ![Role](./doc/images/Role-Menu.png) |
| ![Menu](./doc/images/Menu.png) | ![Menu-2](./doc/images/Menu-2.png) |
| ![Menu-3](./doc/images/Menu-3.png) |![User-Dark](./doc/images/User-Dark.png) |
| ![Mobile](./doc/images/Home-Mobile.png) | ![Mobile](./doc/images/User-Mobile.png) |
| ![Mobile](./doc/images/Menu-Mobile.png) | ![Mobile](./doc/images/Menu-Mobile-Dark.png)|
### 浏览器支持
推荐使用最新版的 Chrome 浏览器进行开发,以获得更好的体验。
| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/archive/internet-explorer_9-11/internet-explorer_9-11_48x48.png" alt="IE" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/) | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/edge/edge_48x48.png" alt=" Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/) | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/) | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/) | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png" alt="Safari" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/) |
| --- | --- | --- | --- | --- |
| not support | last 2 versions | last 2 versions | last 2 versions | last 2 versions |
### 特别鸣谢
- [SoybeanJS](https://github.com/soybeanjs)
- [Naive UI](https://www.naiveui.com/zh-CN/os-theme)
- [Vue](https://cn.vuejs.org/)
- 不一一列举,感谢所有开源项目的贡献者
### 开源协议
项目基于 [Apache License 2.0 © 2024 ljwx](./LICENSE) 协议,仅供学习参考,商业使用请遵循作者版权信息,作者不保证也不承担任何软件的使用风险。
## 功能说明
### 员工列表初始化
- 页面初始化时会根据部门自动拉取员工列表
- 部门变更时会自动更新员工列表
- 支持模糊搜索员工
### 表单验证优化 (最新)
#### 功能概述
- 优化员工新增/编辑表单的实时验证体验
- 员工名字和手机号格式错误时,修正后自动清除验证提示
- 无需关闭抽屉重新打开即可清除验证状态
#### 技术实现
- 修改表单验证规则触发器,支持 `input` 和 `change` 双重触发
- 优化 `userName` 和 `phone` 字段的验证响应机制
- 修复 `UserEdit` 类型定义,移除不必要的字段
#### 使用体验
1. **输入验证**:用户输入时立即显示格式错误提示
2. **实时清除**:修正输入后验证错误自动消失
3. **无缝操作**:提升表单操作的流畅性和用户体验
### 告警时序统计图
- 主页面饼图已替换为告警时序统计图
- 使用真实告警数据,自动根据数据实际时间范围生成时序图
- 支持中文显示:心率、血压、体温等告警类型,待处理、已响应、已解决等状态
- 按照alertType(折线图)、severityLevel(柱状图)维度分组展示
- 智能时间范围:根据数据跨度自动调整显示7-30天时间窗口
- 时间范围标注:标题显示数据总量和时间跨度(如:1/25-1/25)
- 支持交叉分析和多维度数据对比
- 无数据时显示友好提示信息
### 消息时序统计图
- 新增消息时序分析功能,自动根据数据实际时间范围生成图表
- 支持中文显示:系统消息、告警消息、通知消息等类型,已发送、已送达、已读等状态
- 按照messageType(折线图)、messageStatus(面积图)维度进行时序统计
- 智能时间范围:根据sentTime数据跨度自动调整显示窗口
- 时间范围标注:显示消息总量和时间跨度信息
- 支持多种消息类型的独立统计和状态追踪
### 🔄 用户管理 - 视图模式切换 (最新)
#### 功能概述
- 支持全部用户、员工、管理员三种视图模式切换
- 根据选择的模式动态过滤显示对应用户类型
- 在全部用户视图中显示用户类型标识(管理员/员工)
#### 使用方法
1. 在用户管理页面顶部选择视图模式:
- **全部用户**:显示所有用户,包含类型标识列
- **员工**:仅显示普通员工用户
- **管理员**:仅显示管理员用户
2. 视图模式参数说明:
```typescript
viewMode: 'all' | 'employee' | 'admin'
```
3. API调用示例:
```typescript
// 根据视图模式获取用户列表
fetchGetUserListByViewMode({
...params,
viewMode: 'employee' // 仅获取员工
})
```
#### 技术实现
- 前端组件:`UserViewModeSelector` - 视图模式选择器
- API函数:`fetchGetUserListByViewMode` - 支持viewMode参数
- 类型标识:使用 `NTag` 组件显示用户类型(管理员/员工)
### 👨‍💼 角色管理 - 管理员标识 (最新)
#### 功能概述
- 角色新增/编辑时支持设置是否为管理员角色
- 角色列表显示管理员类型标识
- 与后端数据库 `is_admin` 字段同步
#### 使用方法
1. **新增角色**:
- 在角色信息表单中选择"普通角色"或"管理员角色"
- 系统自动将 `isAdmin` 字段设置为 0 或 1
2. **编辑角色**:
- 可以修改现有角色的管理员属性
- 保存后立即生效
3. **角色列表**:
- 显示"是否管理员"列
- 管理员角色显示为红色标签
- 普通角色显示为绿色标签
#### 数据库字段
```sql
ALTER TABLE sys_role
ADD COLUMN is_admin TINYINT(1) NOT NULL DEFAULT 0
COMMENT '是否为管理员角色(0普通角色,1管理员角色)';
```
#### 技术实现
- 类型定义:`Role.isAdmin: number`
- 表单字段:单选按钮组选择角色类型
- 列表显示:使用 `NTag` 组件显示角色类型标识
- 国际化:支持中英文切换
## 🚀 部署说明
### 开发环境
```bash
# 安装依赖
npm install
# 启动开发服务器
npm run dev
```
### 生产环境
```bash
# 构建生产版本
npm run build
# 预览生产版本
npm run preview
```
## 📖 API文档
### 用户管理接口
#### 获取用户列表(支持视图模式)
```http
GET /sys_user/page?viewMode={mode}
```
参数:
- `viewMode`: 视图模式 (all/employee/admin)
- 其他分页和搜索参数...
响应:
```json
{
"code": 200,
"data": {
"records": [
{
"id": "1",
"userName": "admin",
"isAdmin": true,
"userType": "ADMIN"
}
]
}
}
```
### 角色管理接口
#### 新增/更新角色
```http
POST/PUT /sys_role/
```
请求体:
```json
{
"roleName": "系统管理员",
"roleCode": "ADMIN",
"isAdmin": 1,
"status": "1"
}
```
## 🔧 技术栈
- **框架**: Vue 3 + TypeScript
- **UI库**: Naive UI
- **状态管理**: Pinia
- **路由**: Vue Router
- **构建工具**: Vite
- **代码规范**: ESLint + Prettier
### 📥 用户批量导入功能 (最新)
#### 功能概述
- 支持Excel (.xlsx, .xls) 和CSV格式文件批量导入用户
- 智能模板下载,提供标准化的数据格式和字段说明
- 支持部门和岗位的ID或名称两种查询方式
- 详细的导入结果展示,包含成功/失败统计和错误详情
#### 使用方法
1. **下载模板**:
- 点击"下载导入模板"获取标准CSV模板
- 模板包含字段:姓名、性别、年龄、工龄、手机号码、部门、岗位、设备序列号、备注
- 提供完整的示例数据和字段说明
2. **准备数据**:
- 按模板格式填写用户信息
- 部门和岗位支持两种输入方式:
- **ID方式**:直接填写部门/岗位的数字ID(如:11, 1)
- **名称方式**:填写完整的部门/岗位名称(如:技术部、软件工程师)
3. **上传导入**:
- 拖拽或点击选择Excel/CSV文件
- 系统自动验证文件格式和数据完整性
- 显示详细的导入结果和错误信息
#### 数据验证规则
- **必填字段**:姓名、性别、年龄、工龄、手机号码、部门、岗位
- **格式验证**:
- 手机号:11位数字,1开头
- 年龄:0-120范围
- 工龄:非负数
- 性别:只能是"男"或"女"
- **唯一性检查**:手机号不能重复
- **权限验证**:只能在授权的组织范围内导入
#### 自动化处理
- **用户名生成**:基于手机号后6位自动生成(格式:user+6位数字)
- **默认密码**:统一设置为"123456"(MD5加密存储)
- **邮箱设置**:自动生成格式为"手机号@example.com"
- **组织关系**:自动建立用户与部门、岗位的关联关系
#### 技术实现
- **文件解析**:集成Apache POI支持Excel解析,自定义CSV解析器
- **数据验证**:多层级验证机制,确保数据完整性和格式正确性
- **权限控制**:基于Sa-Token的导入权限验证和组织数据隔离
- **错误处理**:详细的错误报告和事务回滚机制
### 🚨 告警管理 - 一键批量处理 (最新)
#### 功能概述
- 支持批量选择和处理多条告警记录
- 智能状态检查,自动识别已处理告警并提示用户
- 与 ljwx-bigscreen 服务集成,调用后端批量处理接口
- 详细的操作结果反馈,包含成功/失败数量统计
#### 使用方法
1. **选择告警记录**:
- 在告警管理页面选择多条(≥2条)告警记录
- "一键批量处理"按钮将从灰色禁用状态变为可点击的绿色按钮
2. **批量处理操作**:
- 点击"一键批量处理"按钮
- 系统自动检查选中告警的状态
- 如包含已响应告警,会弹出确认对话框供用户选择
3. **操作结果**:
- 显示详细处理结果:成功X条,失败X条
- 如有已处理过的告警,会特别标注数量
- 处理完成后自动刷新列表数据
#### 按钮状态说明
- **默认状态**:灰色禁用,始终显示在批量删除按钮右侧
- **激活条件**:选择2条或更多告警记录时激活
- **禁用条件**:选择少于2条记录或数据加载中时禁用
- **权限控制**:基于 `t:alert:info:update` 权限
#### 技术实现
- **前端组件**:使用 `#suffix` 插槽实现按钮布局
- **状态检查**:检查 `alertStatus === 'responded'` 状态
- **API接口**:调用 ljwx-bigscreen 的 `/batchDealAlert` 接口
- **用户体验**:包含确认对话框、详细反馈信息和权限控制
#### 接口说明
```http
POST http://localhost:5001/batchDealAlert
Content-Type: application/json
{
"alertIds": ["1", "2", "3"]
}
```
响应格式:
```json
{
"success": true,
"message": "批量处理完成:成功2条,失败1条",
"successCount": 2,
"failedCount": 1,
"failedAlerts": [
{
"alertId": "3",
"error": "处理失败"
}
]
}
```
### 🚀 多租户 CustomerId 全局自动注入系统 (最新)
#### 功能概述
- 实现请求拦截器层面的 customerId 全局自动注入功能
- 零侵入式架构升级,无需修改现有 API 调用代码
- 支持多租户数据隔离的统一管理和自动化处理
#### 技术特点
1. **智能请求处理**:
- **GET 请求**:自动添加到查询参数 `params`
- **POST/PUT/DELETE 请求**:自动添加到请求体 `data`
- **FormData 请求**:作为表单字段自动添加
- **防重复机制**:显式传入的 customerId 优先级更高
2. **白名单配置**:
- 精确控制认证、登录、字典等全局 API 的排除
- 灵活配置不需要租户隔离的特殊接口
- 支持动态调整白名单规则
3. **向后兼容**:
- 不影响现有 70+ 个文件的 API 调用逻辑
- TypeScript 类型安全完全支持
- 渐进式升级,可随时启用或禁用
#### 使用示例
```typescript
// 旧方式:需要手动添加 customerId
fetchGetUserList({
page: 1,
pageSize: 20,
customerId: authStore.userInfo.customerId
});
// 新方式:系统自动注入,简化开发
fetchGetUserList({
page: 1,
pageSize: 20
// customerId 自动添加,无需手动处理
});
```
#### 技术实现
- **核心文件**:`src/service/request/index.ts` 请求拦截器
- **配置管理**:白名单配置和排除规则
- **调试支持**:可添加日志跟踪自动注入过程
- **性能优化**:最小化运行时开销,高效参数处理
### 🔧 微信告警配置管理
#### 功能概述
- 支持企业微信和微信公众号两种类型的告警配置管理
- 动态表格列显示,根据配置类型自适应显示相应字段
- 完整的CRUD操作支持,包含新增、编辑、删除和批量删除功能
- 智能数据过滤和状态管理
#### 功能特点
1. **双类型支持**:
- **企业微信配置**:显示企业ID、应用ID、应用Secret等企业微信专属字段
- **微信公众号配置**:显示AppID、AppSecret、微信ID等公众号专属字段
2. **智能表格**:
- 标签页切换时动态调整表格列
- 自适应字段显示,避免冗余列
- 统一的通用字段:模板ID、启用状态、创建时间、操作等
3. **数据管理**:
- 实时数据同步和状态更新
- 完整的权限控制和操作验证
- 敏感信息脱敏显示(Secret字段只显示后4位)
#### 使用方法
1. **查看配置**:
- 点击"企业微信配置"标签查看企业微信告警配置
- 点击"微信公众号配置"标签查看公众号告警配置
- 支持按类型、启用状态等条件搜索过滤
2. **新增配置**:
- 点击"新增"按钮打开配置表单
- 根据当前标签页自动设置配置类型
- 填写对应类型的必要字段信息
3. **编辑配置**:
- 点击表格中的"编辑"按钮修改配置
- 支持修改除ID和类型外的所有字段
- 实时保存并更新表格显示
#### 技术实现
- **动态列定义**:基于 `activeTab` 值动态生成表格列配置
- **状态管理**:使用 Vue 3 的响应式系统实现状态同步
- **API集成**:与后端 `/t_wechat_alarm_config/page` 接口完整对接
- **类型安全**:完整的 TypeScript 类型定义和验证
#### 数据字段映射
```typescript
// 企业微信配置
{
corpId: string; // 企业ID
agentId: string; // 应用ID
secret: string; // 应用Secret
}
// 微信公众号配置
{
appid: string; // AppID
appsecret: string; // AppSecret
wechatId: string; // 微信ID (映射自appid)
}
// 通用字段
{
templateId: string; // 模板ID
enabled: boolean; // 启用状态
createTime: string; // 创建时间
}
```
## 📝 更新日志
### v1.3.8 - 多租户数据库表结构优化与查询性能提升 (2025-08-28)
- 🚀 **数据库多租户架构完善**
- 完成 sys_user、sys_org_units、sys_user_org 表的多租户字段添加
- 实现租户数据自动初始化,支持9个独立租户的完整数据隔离
- 建立租户层次结构:顶级组织作为租户根节点,子组织自动继承租户ID
- ⚡ **查询性能大幅优化**
- 创建17个专用多租户索引,包含customer_id复合索引优化
- 多租户查询性能提升60-80%,从全表扫描优化为高效索引查找
- 支持百万级用户数据的企业级查询性能,扫描行数从数千行减少到个位数
- 🏗️ **后端服务全面升级**
- ljwx-boot Java服务:更新实体类customer_id字段映射,扩展租户相关服务接口
- ljwx-bigscreen Python服务:创建tenant_context.py租户上下文管理,更新用户组织查询逻辑
- 实现零侵入式多租户支持,保持现有API接口完全兼容
- 🔧 **技术架构增强**
- 数据库备份与恢复机制,确保升级安全性
- 自动租户上下文检测与设置,支持全局租户(customer_id=0)向后兼容
- 完善的租户数据一致性保证,用户-组织关系按租户严格隔离
### v1.3.7 - 多租户 CustomerId 全局自动注入系统 (2025-08-28)
- 🚀 **全局自动注入 CustomerId 功能**
- 实现请求拦截器层面的 customerId 自动注入,零侵入式升级
- 支持 GET/POST/PUT/DELETE/FormData 等所有类型请求的智能参数添加
- 配置白名单机制,精确控制认证、字典等全局 API 的排除逻辑
- 防重复覆盖机制,显式传入的 customerId 参数优先级更高
- 🔧 **技术架构优化**
- 修改 `src/service/request/index.ts` 请求拦截器核心逻辑
- 无需修改现有 70+ 个文件的 API 调用代码,完全向后兼容
- TypeScript 类型安全支持,无需额外类型定义修改
- 统一的多租户数据隔离管理,简化开发维护成本
- 📚 **开发文档完善**
- CLAUDE.md 新增多租户 CustomerId 自动注入功能详细说明
- 提供使用示例、特殊情况处理和调试方法指南
- 建立最佳实践规范,提升团队开发效率
### v1.3.6 - 多租户角色岗位系统前端支持 (2025-08-28)
- 🏢 **完善多租户角色岗位管理前端支持**
- 后端多租户角色岗位系统已完整实现,支持租户级角色岗位隔离
- 自动化租户创建时的角色岗位配置,从全局模板继承标准配置
- 前端界面已准备好对接多租户角色岗位管理功能
- 为未来的前端多租户角色管理界面奠定技术基础
- 📚 **技术文档完善**
- 完善 CLAUDE.md 开发文档,新增多租户系统实现说明
- 记录角色岗位自动配置的技术架构和实现细节
- 建立数据隔离和权限控制的开发指南
### v1.3.5 (2025-08-27)
- 🐛 **修复微信告警配置表格显示空白问题**
- 解决 NDataTable 的 `remote` 属性与本地数据冲突导致的渲染失败
- 移除导致问题的属性组合:`remote`、复杂分页配置、动态高度设置
- 重构列定义为独立 computed 属性,避免 useTable 的 columns 处理丢失 render 函数
- 修复搜索组件中 `type` 参数传递问题,确保企业微信和微信公众号数据正确过滤
- 优化操作按钮使用 Vue 的 h 函数,避免 JSX 语法在 Vue 模板中的冲突
- 📚 **新增开发文档**
- 创建 CLAUDE.md 开发文档,记录数据解析流程和故障排查方法
- 建立表格渲染问题的快速诊断清单,提升后续开发效率
- 文档化 NDataTable 使用最佳实践和常见陷阱避免指南
### v1.3.5 (2025-08-27)
- ✨ **新增微信告警配置管理功能**
- 支持企业微信和微信公众号两种配置类型
- 动态表格列显示,根据类型自适应字段
- 完整的CRUD操作和批量管理
- 敏感信息安全脱敏显示
- 智能数据过滤和实时状态同步
- 🔧 **修复表格组件显示问题**
- 解决NDataTable组件数据绑定问题
- 修复TableHeaderOperation组件导入缺失
- 优化标签页切换时的数据加载逻辑
- 完善列定义动态更新机制
- 🐛 **修复数据过滤和状态管理**
- 修复标签切换时数据不更新的问题
- 优化searchParams参数传递机制
- 改进API参数验证和错误处理
- 增强前后端数据同步稳定性
### v1.3.4 (2025-08-25)
- ✨ **新增健康数据源过滤功能**
- 健康数据查看时自动过滤通用事件上传的数据
- 与后端 upload_method 字段联动,过滤掉 "common_event" 类型数据
- 确保健康信息页面仅显示正常健康监测数据,提高数据准确性
- 优化数据展示逻辑,提升用户查看健康报告的体验
### v1.3.3 (2025-08-20)
- ✨ **新增用户批量导入功能**
- 支持Excel (.xlsx, .xls) 和CSV格式文件导入
- 智能模板下载,包含完整字段示例和说明
- 支持部门和岗位的ID/名称双重查询方式
- 实时文件验证和格式检查
- 详细的导入结果展示和错误反馈
- 现代化的拖拽上传界面
- 完整的权限控制和数据隔离
- 🔧 **文件上传组件优化**
- 替换NUpload组件为原生HTML文件输入
- 修复FormData文件传输问题
- 添加直接fetch上传备用方案
- 优化文件选择和验证流程
- 🛡️ **数据安全增强**
- 严格的文件类型验证
- 多层级数据校验机制
- 按组织架构的权限隔离
- 完整的错误处理和事务回滚
### v1.2.17 (2025-01-14)
- ✨ **新增告警管理一键批量处理功能**
- 支持多选告警记录进行批量处理操作
- 智能状态检查,已响应告警会弹出确认提示
- 详细的处理结果反馈,包含成功/失败统计信息
- 按钮默认灰色禁用,多选时激活,位于批量删除右侧
- 集成 ljwx-bigscreen 后端服务,调用 /batchDealAlert 接口
- 完整的权限控制和用户体验优化
### v1.2.16 (2024-12-18)
- 🐛 **修复员工表单验证体验问题**
- 修复员工名字格式错误修正后需关闭抽屉才能清除验证提示的问题
- 优化表单验证触发机制,支持实时清除验证状态
- 改进用户名和手机号字段的验证响应,提升操作流畅性
- 修复UserEdit类型定义,确保类型安全
- 🔧 优化表单验证规则,支持input和change双重触发模式
### v1.2.15 (2024-12-18)
- ✨ 新增用户管理视图模式切换功能
- ✨ 新增角色管理员标识字段
- 🔧 优化用户类型显示和过滤
- 🌐 完善中英文国际化配置
- 🐛 修复viewMode参数传递问题
### v1.2.14
- 🔧 基础功能完善
- 📱 响应式界面优化
## 🤝 贡献指南
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 打开 Pull Request
## 📄 许可证
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情
@@ -1,130 +0,0 @@
// 示例后端代码:组织架构变更监听器
// 文件路径:src/main/java/com/example/listener/OrgUnitsChangeListener.java
package com.example.listener;
import com.example.entity.OrgUnits;
import com.example.entity.Customer;
import com.example.service.TenantRoleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
/**
* 组织架构变更监听器
* 监听租户/部门的创建、更新、删除事件
*/
@Component
public class OrgUnitsChangeListener {
@Autowired
private TenantRoleService tenantRoleService;
/**
* 监听租户创建事件
* 当创建新的顶级组织(租户)时触发
*/
@EventListener
public void handleTenantCreated(TenantCreatedEvent event) {
Customer customer = event.getCustomer();
try {
// 为新租户同步默认角色和岗位数据
tenantRoleService.syncDefaultRolesForNewTenant(customer);
// 记录日志
System.out.println("Successfully created default roles and positions for tenant: " + customer.getName());
} catch (Exception e) {
System.err.println("Failed to sync default data for tenant: " + customer.getName());
e.printStackTrace();
// 这里可以添加补偿机制或者告警通知
}
}
/**
* 监听部门创建事件
*/
@EventListener
public void handleDepartmentCreated(DepartmentCreatedEvent event) {
OrgUnits department = event.getDepartment();
// 部门创建时的处理逻辑
// 例如:创建部门特定的默认岗位
System.out.println("Department created: " + department.getName() +
" under tenant: " + department.getCustomerId());
}
/**
* 监听租户删除事件
*/
@EventListener
public void handleTenantDeleted(TenantDeletedEvent event) {
Long customerId = event.getCustomerId();
try {
// 清理租户相关的角色和岗位数据
cleanupTenantRolesAndPositions(customerId);
System.out.println("Successfully cleaned up roles and positions for deleted tenant: " + customerId);
} catch (Exception e) {
System.err.println("Failed to cleanup data for deleted tenant: " + customerId);
e.printStackTrace();
}
}
/**
* 清理被删除租户的角色和岗位数据
*/
private void cleanupTenantRolesAndPositions(Long customerId) {
// 这里实现清理逻辑
// 注意:需要考虑数据的完整性约束
// 可能需要先解除用户与角色、岗位的关联关系
}
}
/**
* 租户创建事件
*/
class TenantCreatedEvent {
private final Customer customer;
public TenantCreatedEvent(Customer customer) {
this.customer = customer;
}
public Customer getCustomer() {
return customer;
}
}
/**
* 部门创建事件
*/
class DepartmentCreatedEvent {
private final OrgUnits department;
public DepartmentCreatedEvent(OrgUnits department) {
this.department = department;
}
public OrgUnits getDepartment() {
return department;
}
}
/**
* 租户删除事件
*/
class TenantDeletedEvent {
private final Long customerId;
public TenantDeletedEvent(Long customerId) {
this.customerId = customerId;
}
public Long getCustomerId() {
return customerId;
}
}
-179
View File
@@ -1,179 +0,0 @@
# 多租户权限系统实现指南
## 概述
本目录包含了实现多租户权限系统的后端示例代码和数据库迁移脚本。
## 权限架构设计
### 1. 用户角色层级
- **admin(超级管理员)**:
- 可以管理所有租户
- 可以创建、修改、删除任何租户的角色和岗位
- 可以查看全局角色和所有租户角色
- **租户管理员(R_ADMIN角色)**:
- 只能管理自己租户内的用户、部门、角色、岗位
- 不能创建新租户
- 不能查看其他租户的数据
- **普通用户**:
- 只能访问分配给自己的功能
- 受角色和权限控制
### 2. 数据库设计
#### sys_role 表字段
```sql
- id: 主键
- role_name: 角色名称
- role_code: 角色编码
- description: 角色描述
- status: 状态(1启用,0禁用)
- sort: 排序
- is_admin: 是否管理员角色
- customer_id: 租户ID(NULL表示全局角色)
- create_time: 创建时间
- update_time: 更新时间
```
#### sys_position 表字段
```sql
- id: 主键
- name: 岗位名称
- code: 岗位编码
- abbr: 岗位简称
- description: 岗位描述
- sort: 排序
- status: 状态
- org_id: 组织ID
- weight: 权重
- customer_id: 租户ID(NULL表示全局岗位)
- create_time: 创建时间
- update_time: 更新时间
```
## 实现文件说明
### 1. TenantRoleService.java
租户角色管理服务,主要功能:
- 为新租户创建默认角色和岗位
- 根据用户权限查询角色/岗位列表
- 支持多租户数据隔离
### 2. OrgUnitsChangeListener.java
组织架构变更监听器,主要功能:
- 监听租户创建事件,自动同步默认数据
- 监听租户删除事件,清理相关数据
- 事件驱动的数据同步机制
### 3. DatabaseMigration.sql
数据库迁移脚本,主要内容:
- 为现有表添加 customer_id 字段
- 创建必要的索引优化查询性能
- 插入默认的全局角色数据
## 前端实现要点
### 1. 权限判断逻辑
```javascript
// 判断是否是超级管理员(admin用户,可以管理所有租户)
const isAdmin = computed(() => {
return authStore.userInfo?.userName === 'admin';
});
// 判断是否是租户管理员(只能管理自己租户)
const isTenantAdmin = computed(() => {
return authStore.userInfo?.roleIds?.includes('R_ADMIN') && !isAdmin.value;
});
```
### 2. API 参数处理
```javascript
// 角色查询参数
apiParams: {
page: 1,
pageSize: 20,
status: null,
roleName: null,
roleCode: null,
// 非admin用户只查看自己租户的角色
customerId: isAdmin.value ? null : currentCustomerId
}
```
### 3. UI 组件差异化显示
```vue
<!-- 只有admin用户才显示租户列 -->
...(isAdmin.value ? [{
key: 'customerId',
title: '租户ID',
align: 'center',
width: 100,
render: row => row.customerId || '全局'
}] : [])
<!-- 只有admin用户才能选择租户 -->
<NFormItem v-if="isAdmin" label="租户" path="customerId">
<NInputNumber
v-model:value="model.customerId"
placeholder="留空为全局角色"
:show-button="false"
clearable
/>
</NFormItem>
```
## 部署步骤
### 1. 数据库迁移
```bash
# 执行数据库迁移脚本
mysql -u username -p database_name < DatabaseMigration.sql
```
### 2. 后端代码部署
1. 将 `TenantRoleService.java` 复制到对应的 service 包
2. 将 `OrgUnitsChangeListener.java` 复制到对应的 listener 包
3. 根据实际项目结构调整包名和依赖
### 3. 前端代码部署
前端代码已经直接在现有文件中修改完成,包括:
- 权限判断逻辑更新
- 角色管理页面多租户支持
- 岗位管理页面多租户支持
- 组织架构管理权限控制
## 测试验证
### 1. 功能测试
- [ ] admin用户可以查看所有租户的角色和岗位
- [ ] admin用户可以创建全局角色和特定租户的角色
- [ ] 租户管理员只能查看和管理自己租户的数据
- [ ] 创建新租户时自动生成默认角色和岗位
### 2. 权限测试
- [ ] 租户A的管理员无法访问租户B的数据
- [ ] 普通用户无法访问管理功能
- [ ] admin用户可以跨租户操作
### 3. 性能测试
- [ ] 大量租户数据下的查询性能
- [ ] 索引是否正确创建和使用
- [ ] 并发操作的数据一致性
## 注意事项
1. **数据安全**:确保租户数据隔离,防止数据泄露
2. **性能优化**:合理使用索引,避免全表扫描
3. **事务处理**:租户创建和角色同步需要在事务中完成
4. **错误处理**:完善的异常处理和补偿机制
5. **审计日志**:记录重要操作的审计日志
## 扩展功能
1. **角色模板**:预定义不同行业的角色模板
2. **权限继承**:支持角色间的权限继承关系
3. **动态权限**:支持运行时动态调整权限
4. **权限审计**:完整的权限变更审计功能
@@ -1,136 +0,0 @@
// 示例后端代码:租户角色管理服务
// 文件路径:src/main/java/com/example/service/TenantRoleService.java
package com.example.service;
import com.example.entity.SysRole;
import com.example.entity.SysPosition;
import com.example.entity.Customer;
import com.example.mapper.SysRoleMapper;
import com.example.mapper.SysPositionMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Date;
@Service
public class TenantRoleService {
@Autowired
private SysRoleMapper roleMapper;
@Autowired
private SysPositionMapper positionMapper;
/**
* 创建新租户时同步默认角色和岗位数据
*
* @param customer 新创建的租户信息
*/
@Transactional
public void syncDefaultRolesForNewTenant(Customer customer) {
Long customerId = customer.getId();
// 1. 创建默认角色
createDefaultRoles(customerId);
// 2. 创建默认岗位
createDefaultPositions(customerId);
}
/**
* 为新租户创建默认角色
*/
private void createDefaultRoles(Long customerId) {
// 定义默认角色模板
String[] defaultRoles = {
"R_ADMIN:租户管理员:负责租户内部管理",
"R_MANAGER:部门经理:负责部门管理",
"R_USER:普通用户:系统基础用户"
};
for (String roleTemplate : defaultRoles) {
String[] parts = roleTemplate.split(":");
String roleCode = parts[0];
String roleName = parts[1];
String description = parts[2];
SysRole role = new SysRole();
role.setRoleCode(roleCode);
role.setRoleName(roleName);
role.setDescription(description);
role.setCustomerId(customerId); // 关联到具体租户
role.setStatus(1); // 启用状态
role.setSort(1);
role.setIsAdmin(roleCode.equals("R_ADMIN") ? 1 : 0);
role.setCreateTime(new Date());
role.setUpdateTime(new Date());
roleMapper.insert(role);
}
}
/**
* 为新租户创建默认岗位
*/
private void createDefaultPositions(Long customerId) {
// 定义默认岗位模板
String[] defaultPositions = {
"MANAGER:经理:MGR:负责部门管理工作:5.0",
"SUPERVISOR:主管:SUP:负责团队督导工作:3.0",
"STAFF:员工:STF:执行具体工作任务:1.0"
};
for (String posTemplate : defaultPositions) {
String[] parts = posTemplate.split(":");
String code = parts[0];
String name = parts[1];
String abbr = parts[2];
String description = parts[3];
Double weight = Double.parseDouble(parts[4]);
SysPosition position = new SysPosition();
position.setCode(code);
position.setName(name);
position.setAbbr(abbr);
position.setDescription(description);
position.setWeight(weight);
position.setCustomerId(customerId); // 关联到具体租户
position.setOrgId(customerId); // 默认关联到租户根组织
position.setStatus(1); // 启用状态
position.setSort(1);
position.setCreateTime(new Date());
position.setUpdateTime(new Date());
positionMapper.insert(position);
}
}
/**
* 根据租户ID查询角色列表(支持多租户隔离)
*/
public List<SysRole> getRolesByCustomerId(Long customerId, boolean isAdmin) {
if (isAdmin) {
// admin用户可以查看所有角色(包括全局角色和所有租户角色)
return roleMapper.selectAllRoles();
} else {
// 普通用户只能查看自己租户的角色
return roleMapper.selectByCustomerId(customerId);
}
}
/**
* 根据租户ID查询岗位列表(支持多租户隔离)
*/
public List<SysPosition> getPositionsByCustomerId(Long customerId, boolean isAdmin) {
if (isAdmin) {
// admin用户可以查看所有岗位(包括全局岗位和所有租户岗位)
return positionMapper.selectAllPositions();
} else {
// 普通用户只能查看自己租户的岗位
return positionMapper.selectByCustomerId(customerId);
}
}
}
-152
View File
@@ -1,152 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1120.000000pt" height="1038.000000pt" viewBox="0 0 1120.000000 1038.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.16, written by Peter Selinger 2001-2019
</metadata>
<g transform="translate(0.000000,1038.000000) scale(0.100000,-0.100000)"
fill="currentColor" stroke="none">
<path d="M3975 10341 c-859 -273 -1596 -727 -2231 -1372 -872 -887 -1386
-1992 -1520 -3265 -14 -128 -18 -252 -18 -514 0 -368 11 -528 59 -835 138
-881 490 -1712 1023 -2419 281 -371 672 -764 1040 -1044 500 -379 1079 -678
1666 -859 l109 -33 1497 0 1497 0 109 33 c1148 354 2165 1105 2856 2112 506
737 815 1593 914 2531 26 238 26 790 0 1028 -119 1127 -527 2101 -1239 2960
-217 260 -583 610 -865 824 -500 379 -1079 678 -1666 859 l-109 33 -1501 -1
-1501 0 -120 -38z m1805 -537 c194 -105 339 -448 395 -934 16 -135 20 -447 6
-468 -7 -12 -14 -11 -38 2 -17 8 -33 13 -35 11 -2 -3 -12 95 -22 216 -39 497
-123 818 -262 1005 -99 133 -197 143 -310 30 -137 -138 -255 -468 -308 -866
-24 -177 -25 -177 -21 0 13 527 195 974 419 1031 50 13 123 2 176 -27z m-1635
-704 c184 -66 403 -193 580 -335 121 -97 265 -228 320 -291 l54 -61 -38 -16
c-21 -10 -41 -17 -46 -17 -4 0 -60 46 -124 103 -405 360 -755 557 -991 557
-89 0 -129 -20 -164 -80 -27 -45 -28 -168 -3 -255 59 -203 214 -483 400 -725
l50 -65 -62 60 c-144 138 -300 357 -392 550 -64 135 -89 212 -96 304 -8 107
13 178 71 235 62 63 108 76 241 72 94 -3 123 -8 200 -36z m3378 -58 c90 -40
147 -141 147 -263 0 -196 -165 -538 -407 -839 -75 -94 -276 -301 -309 -318
-19 -10 -23 -7 -36 27 l-15 38 79 89 c289 322 483 617 560 849 31 96 33 240 2
281 -45 61 -66 69 -182 68 -92 -1 -120 -5 -199 -32 -176 -60 -413 -195 -638
-365 -116 -87 -119 -87 -30 -2 264 253 582 443 815 486 64 11 164 3 213 -19z
m-1748 -1227 c40 -8 115 -37 175 -66 90 -45 117 -65 191 -139 75 -74 94 -100
137 -191 65 -134 87 -234 79 -374 -14 -272 -170 -503 -422 -625 -130 -63 -184
-75 -330 -75 -97 0 -134 5 -195 24 -302 94 -508 337 -539 638 -38 362 171 679
519 791 36 12 76 23 90 25 60 10 231 5 295 -8z m-1518 -151 l51 -7 -12 -35
c-16 -44 -25 -47 -186 -56 -518 -29 -947 -149 -1094 -306 -89 -95 -85 -175 12
-270 64 -63 132 -103 259 -156 154 -64 254 -89 653 -163 93 -17 -220 -4 -330
14 -363 59 -632 186 -715 336 -39 71 -41 160 -6 229 70 136 285 259 595 339
215 56 611 94 773 75z m3406 -185 c150 -26 362 -94 467 -150 261 -139 304
-358 102 -525 -169 -139 -518 -252 -875 -284 -125 -11 -358 -13 -405 -4 -29 6
-30 7 -20 38 6 17 13 34 17 38 3 3 82 11 176 17 514 35 909 141 1065 287 135
126 99 250 -110 374 -146 87 -446 180 -702 217 -64 10 -115 20 -113 23 7 11
280 -10 398 -31z m-3358 -955 c19 -41 26 -30 -91 -159 -255 -284 -454 -588
-531 -812 -35 -103 -38 -230 -8 -283 35 -59 76 -75 185 -74 80 1 111 6 195 34
182 62 397 183 625 354 140 105 144 106 45 11 -172 -166 -364 -302 -550 -390
-177 -84 -286 -110 -396 -95 -352 49 -275 554 169 1110 81 102 312 340 329
340 6 0 18 -16 28 -36z m2992 -548 c137 -182 256 -417 284 -556 16 -83 8 -205
-18 -255 -130 -251 -589 -138 -1093 268 -121 98 -330 300 -330 320 0 10 46 36
74 41 2 1 53 -42 112 -94 440 -388 781 -580 1011 -568 85 4 117 22 151 89 21
39 23 54 19 130 -12 194 -149 479 -389 809 l-69 95 84 -87 c47 -48 120 -134
164 -192z m-2205 -210 l34 -15 12 -193 c35 -567 164 -979 343 -1100 192 -128
415 210 513 777 14 83 30 175 36 205 7 43 8 22 5 -100 -4 -168 -30 -348 -71
-488 -116 -399 -281 -566 -490 -495 -75 25 -177 133 -237 250 -88 171 -159
429 -188 678 -14 120 -18 449 -7 478 9 21 7 20 50 3z m-64 -2313 l27 -58 153
-3 152 -3 0 -44 0 -45 -269 0 -270 0 25 -62 c13 -34 24 -67 24 -74 0 -32 31
-44 110 -44 91 0 92 1 131 103 l22 58 41 -6 c61 -10 64 -18 32 -82 -21 -44
-25 -58 -14 -64 7 -5 57 -9 111 -9 l97 0 0 -45 0 -45 -415 0 -415 0 0 45 0 45
93 0 c50 0 98 4 104 8 10 6 8 22 -8 71 -23 69 -19 81 29 81 12 0 22 5 22 10 0
6 -38 10 -95 10 l-95 0 0 45 0 45 149 0 c83 0 152 4 155 9 3 5 -1 25 -9 45
-24 56 -24 56 68 64 12 2 26 -15 45 -55z m2942 46 c55 -12 59 -19 30 -56 -11
-14 -17 -30 -14 -35 4 -6 73 -9 168 -7 135 2 166 0 194 -15 17 -9 32 -22 32
-28 0 -6 -22 -42 -49 -79 -27 -37 -47 -70 -44 -73 4 -3 89 -6 190 -6 l183 0 0
-165 0 -165 -305 0 c-168 0 -305 -3 -305 -7 0 -5 16 -19 36 -32 19 -13 68 -60
109 -103 l73 -78 97 55 c53 30 124 73 157 95 33 22 65 40 72 40 6 0 28 -13 49
-29 l38 -29 -28 -19 c-15 -11 -61 -39 -100 -62 -40 -23 -73 -47 -73 -53 0 -25
84 -139 148 -200 37 -36 93 -80 125 -97 31 -18 57 -35 57 -38 0 -2 -15 -24
-33 -48 l-34 -44 -52 30 c-98 56 -184 154 -297 336 -14 22 -15 22 -49 6 -40
-19 -40 -18 -19 -84 12 -36 15 -72 12 -129 -6 -96 -33 -144 -105 -187 -45 -26
-56 -28 -157 -28 l-109 0 -13 45 c-8 25 -14 46 -14 48 0 1 43 2 95 2 135 0
187 32 200 124 6 39 -9 126 -21 126 -3 0 -31 -17 -62 -38 -121 -82 -293 -163
-469 -222 l-81 -28 -31 38 c-17 22 -31 41 -31 44 0 2 26 12 58 21 105 29 256
87 357 138 107 53 215 122 215 137 0 4 -12 22 -26 39 l-26 31 -41 -26 c-80
-49 -257 -131 -358 -165 l-100 -34 -29 35 c-34 41 -36 50 -13 50 56 0 397 138
458 186 20 15 19 16 -20 50 -22 18 -44 34 -49 34 -5 0 -59 -24 -120 -54 -61
-29 -149 -66 -195 -81 l-84 -28 -28 34 c-16 19 -27 38 -26 43 2 5 41 19 87 33
103 29 223 79 253 104 22 18 21 18 -90 19 l-113 0 0 109 c0 61 -4 112 -9 116
-5 3 -25 -4 -45 -16 l-35 -21 -29 34 c-17 18 -31 36 -33 40 -2 3 38 34 89 67
97 64 250 204 297 271 15 22 29 40 31 40 2 0 26 -5 54 -11z m-3598 -181 l3
-173 70 -5 70 -5 3 -47 3 -47 -73 -3 -73 -3 -3 -225 c-1 -123 0 -230 2 -237 4
-10 21 -7 72 12 36 14 70 25 74 25 5 0 11 -15 15 -32 4 -18 9 -40 11 -50 4
-13 -34 -32 -200 -97 -113 -45 -211 -81 -219 -81 -17 0 -52 89 -40 101 5 5 44
21 88 36 l80 28 3 263 2 262 -80 0 -80 0 0 50 0 50 80 0 80 0 0 159 c0 88 3
166 6 175 4 12 20 16 55 16 l49 0 2 -172z m-802 -148 l0 -270 -512 2 -513 3
-3 48 -3 47 450 0 c247 0 456 3 465 6 12 5 16 21 16 65 l0 59 -432 2 -433 3 0
40 0 40 433 3 432 2 0 60 0 60 -462 2 -463 3 -3 48 -3 47 515 0 516 0 0 -270z
m3538 168 l-3 -53 -387 -3 -386 -2 -6 -88 c-4 -48 -9 -115 -12 -149 l-6 -63
331 0 331 0 0 -51 c0 -159 -40 -546 -65 -635 -36 -125 -112 -156 -337 -140
l-94 8 -13 41 c-6 23 -14 47 -17 52 -3 7 45 9 141 7 173 -5 186 0 210 80 20
69 42 242 51 401 l7 127 -278 -2 -278 -3 -19 -92 c-35 -176 -105 -330 -202
-447 -42 -51 -143 -138 -193 -168 l-30 -17 -42 43 -42 43 57 38 c249 165 357
414 380 877 l7 138 -199 2 -199 3 -3 53 -3 52 651 0 651 0 -3 -52z m-1788
-598 l0 -210 -90 0 -90 0 0 -128 c0 -86 4 -132 12 -140 7 -7 41 -12 82 -12 82
0 105 15 107 69 1 20 4 48 8 63 l6 27 40 -22 c38 -22 39 -23 37 -72 -7 -128
-34 -148 -197 -149 -119 -1 -167 11 -188 48 -6 12 -13 82 -15 169 l-4 147 -62
0 -63 0 -16 -78 c-33 -150 -111 -234 -267 -287 -41 -14 -78 -25 -82 -25 -9 0
-63 79 -57 84 2 2 36 13 75 25 104 30 162 74 202 150 18 35 32 78 32 97 l0 34
-75 0 -75 0 0 210 0 210 340 0 340 0 0 -210z m-2185 73 c-4 -32 -11 -77 -16
-100 -14 -65 25 -138 125 -239 108 -108 238 -174 406 -204 123 -22 114 -13 78
-71 -31 -50 -32 -50 -72 -43 -225 37 -438 159 -541 308 -21 31 -42 56 -46 56
-5 0 -20 -23 -34 -51 -71 -140 -239 -249 -474 -309 -46 -11 -90 -20 -97 -18
-8 2 -26 22 -40 45 l-25 42 28 6 c99 22 220 61 291 95 181 86 271 230 297 473
l7 67 60 0 59 0 -6 -57z m-441 -43 l46 -19 -21 -53 c-38 -99 -114 -218 -138
-218 -15 0 -81 41 -81 51 0 5 18 32 40 61 22 29 54 86 72 126 17 39 33 72 34
72 1 0 23 -9 48 -20z m866 -5 c22 -14 40 -30 40 -36 0 -12 -109 -198 -139
-237 l-17 -24 -37 22 c-20 12 -37 26 -37 31 0 4 14 32 31 60 17 29 48 88 70
131 22 43 42 78 44 78 3 0 23 -11 45 -25z m1590 -1446 l0 -30 48 7 c58 8 62
10 62 35 0 27 18 24 22 -3 2 -18 11 -24 38 -27 l35 -4 -35 -6 c-27 -5 -36 -11
-38 -28 -3 -20 1 -23 27 -23 38 0 39 -15 6 -64 -14 -20 -25 -39 -25 -41 0 -3
16 -13 36 -23 21 -11 32 -22 27 -25 -6 -4 -27 2 -48 13 -37 19 -38 19 -76 0
-44 -23 -49 -24 -49 -10 0 5 15 17 33 26 17 9 33 18 35 19 1 1 -9 22 -23 46
-32 56 -31 59 6 59 26 0 30 3 27 23 -4 27 -37 34 -78 15 -37 -16 -41 -52 -7
-62 21 -7 21 -7 2 -15 -16 -6 -21 -20 -25 -62 -4 -47 -8 -54 -27 -57 -13 -2
-23 1 -23 7 0 6 7 11 15 11 11 0 15 11 15 40 0 33 -3 40 -20 40 -11 0 -20 5
-20 10 0 6 9 10 20 10 31 0 29 65 -2 75 l-23 8 23 4 c17 3 22 11 22 34 0 16 5
29 10 29 6 0 10 -14 10 -31z m1306 16 c-4 -12 3 -15 29 -15 19 0 35 -4 35 -10
0 -5 -18 -10 -40 -10 -29 0 -40 -4 -40 -15 0 -10 11 -15 35 -15 l35 0 0 -69
c0 -55 -3 -70 -16 -75 -9 -4 -20 -3 -23 0 -3 4 0 9 7 11 21 8 13 33 -11 33
-16 0 -27 -9 -34 -27 -10 -25 -11 -21 -12 35 -1 34 -3 62 -5 62 -26 0 -36 -16
-36 -61 0 -58 19 -73 93 -72 26 0 47 -3 47 -8 0 -16 -104 -10 -129 8 -22 15
-25 15 -37 -2 -8 -10 -17 -16 -21 -12 -3 4 1 15 10 25 19 20 24 102 7 102 -5
0 -10 5 -10 10 0 6 9 10 21 10 11 0 29 10 40 23 22 27 24 37 4 37 -8 0 -15 5
-15 10 0 6 9 10 20 10 11 0 23 7 26 15 4 8 11 15 16 15 6 0 8 -7 4 -15z m716
-12 c3 -26 7 -28 56 -31 66 -4 59 -22 -9 -22 -48 0 -49 -1 -49 -30 0 -29 2
-30 44 -30 61 0 68 -18 9 -22 -48 -3 -48 -3 -51 -40 l-3 -38 60 0 c34 0 61 -4
61 -10 0 -6 -50 -10 -130 -10 -80 0 -130 4 -130 10 0 6 27 10 60 10 l60 0 0
40 0 40 -40 0 c-22 0 -40 5 -40 10 0 6 18 10 40 10 38 0 40 2 40 30 0 28 -3
30 -34 30 -25 0 -39 -7 -50 -22 -26 -37 -29 -12 -4 41 24 53 41 67 36 29 -2
-19 2 -24 25 -26 24 -3 27 0 27 27 0 38 18 41 22 4z m-3012 -38 l0 -55 -45 0
c-43 0 -45 -1 -45 -29 0 -35 42 -80 82 -88 28 -5 45 -20 35 -29 -8 -8 -80 25
-105 48 l-22 22 -15 -21 c-18 -25 -94 -58 -107 -45 -5 5 8 14 30 20 48 14 71
39 82 86 l8 36 -49 0 c-27 0 -49 5 -49 10 0 6 37 10 90 10 73 0 90 3 90 15 0
12 -17 15 -87 17 l-88 2 88 3 c68 3 87 7 87 18 0 12 -18 15 -90 15 -53 0 -90
4 -90 10 0 6 40 10 100 10 l100 0 0 -55z m1950 -84 c0 -30 -4 -40 -20 -44 -12
-3 -20 -14 -20 -26 0 -29 28 -38 45 -15 14 18 14 18 15 -3 0 -26 -31 -39 -60
-23 -13 7 -20 21 -20 40 0 36 -17 39 -30 6 -12 -32 -57 -63 -71 -49 -7 7 -2
12 15 17 31 8 45 45 22 58 -16 9 -22 58 -9 71 3 4 35 7 70 7 l63 0 0 -39z"/>
<path d="M5524 7710 c-128 -18 -293 -104 -374 -197 -109 -124 -162 -260 -161
-418 0 -168 52 -307 157 -423 372 -412 1044 -182 1091 373 20 240 -129 499
-348 603 -126 60 -241 79 -365 62z m145 -381 c70 -16 127 -64 163 -136 27 -56
30 -70 25 -123 -19 -211 -269 -305 -418 -156 -130 130 -73 356 103 411 53 17
71 17 127 4z"/>
<path d="M7846 3273 c-30 -27 -55 -54 -55 -60 -1 -10 40 -13 173 -13 l174 0
41 51 c22 28 38 55 35 60 -3 5 -75 9 -159 9 l-155 0 -54 -47z"/>
<path d="M7757 3113 c-4 -3 -7 -39 -7 -80 l0 -73 133 0 133 0 31 33 c41 44 83
108 77 119 -6 9 -358 11 -367 1z"/>
<path d="M8254 3112 c-6 -4 -27 -33 -48 -65 -21 -31 -42 -60 -47 -63 -31 -19
9 -24 191 -24 l201 0 -3 78 -3 77 -140 2 c-77 2 -145 0 -151 -5z"/>
<path d="M4744 2866 c-3 -8 -4 -29 -2 -48 l3 -33 233 -3 233 -2 -3 47 -3 48
-228 3 c-189 2 -228 0 -233 -12z"/>
<path d="M4744 2696 c-3 -8 -4 -29 -2 -48 l3 -33 233 -3 233 -2 -3 47 -3 48
-228 3 c-189 2 -228 0 -233 -12z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 672 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

-43
View File
@@ -1,43 +0,0 @@
version: '3.8'
services:
# 前端服务
frontend:
build:
context: .
dockerfile: Dockerfile
container_name: ljwx-admin-dev
ports:
- "5173:5173"
volumes:
- .:/app
- /app/node_modules
- /app/packages
environment:
- NODE_ENV=development
- VITE_USER_NODE_ENV=development
- VITE_API_URL=http://localhost:9999 # 后端API地址
command: pnpm run dev
depends_on:
- backend
# 后端服务
backend:
build:
context: ../ljwx-boot/ljwx-boot-admin # 假设后端项目在同级目录
dockerfile: Dockerfile
container_name: ljwx-boot
ports:
- "9999:9999"
environment:
- SPRING_PROFILES_ACTIVE=dev
- SERVER_PORT=9999
# 添加其他需要的环境变量,比如数据库配置等
volumes:
- ../ljwx-boot/ljwx-boot-admin/logs:/app/logs # 日志持久化
networks:
- ljwx-network
networks:
ljwx-network:
driver: bridge
-36
View File
@@ -1,36 +0,0 @@
#!/bin/sh
# LJWX Admin Docker 入口脚本
echo "🚀 启动 LJWX Admin 前端服务..."
# 设置默认环境变量
export SERVER_NAME=${SERVER_NAME:-localhost}
export BACKEND_URL=${BACKEND_URL:-http://ljwx-boot:9998}
export BIGSCREEN_URL=${BIGSCREEN_URL:-http://ljwx-bigscreen:5000}
# 输出配置信息
echo "📋 服务配置:"
echo " SERVER_NAME: $SERVER_NAME"
echo " BACKEND_URL: $BACKEND_URL"
echo " BIGSCREEN_URL: $BIGSCREEN_URL"
# 检查 dist 目录
if [ ! -d "/usr/share/nginx/html" ] || [ -z "$(ls -A /usr/share/nginx/html)" ]; then
echo "❌ 错误: 前端资源目录为空或不存在"
echo " 请确保已正确构建前端应用"
exit 1
fi
echo "✅ 前端资源检查通过"
# 创建健康检查文件
echo "healthy" > /usr/share/nginx/html/health
# 显示前端资源文件(调试用)
echo "📁 前端资源文件:"
find /usr/share/nginx/html -type f -name "*.html" -o -name "*.js" -o -name "*.css" | head -5
# 启动 Nginx
echo "🌐 启动 Nginx 服务器..."
exec nginx -g 'daemon off;'
-12
View File
@@ -1,12 +0,0 @@
# 开发环境配置
VITE_APP_TITLE=LJWX管理系统
VITE_BASE_URL=/
VITE_HTTP_PROXY=Y
VITE_SOURCE_MAP=Y
# API配置 - 本地开发
VITE_SERVICE_BASE_URL=http://localhost:9998
VITE_BIGSCREEN_URL=http://localhost:5001
# 其他服务配置
VITE_OTHER_SERVICE_BASE_URL={}
-16
View File
@@ -1,16 +0,0 @@
# 生产环境配置
VITE_APP_TITLE=LJWX管理系统
VITE_BASE_URL=/
VITE_HTTP_PROXY=N
VITE_SOURCE_MAP=N
# API配置 - 直接使用相对路径
VITE_SERVICE_BASE_URL=
VITE_BIGSCREEN_URL=/bigscreen
# 其他服务配置
VITE_OTHER_SERVICE_BASE_URL={}
# 图标配置
VITE_ICON_PREFIX=icon
VITE_ICON_LOCAL_PREFIX=icon-local
-12
View File
@@ -1,12 +0,0 @@
# 测试环境配置
VITE_APP_TITLE=LJWX管理系统(测试)
VITE_BASE_URL=/
VITE_HTTP_PROXY=Y
VITE_SOURCE_MAP=Y
# API配置 - 测试环境
VITE_SERVICE_BASE_URL=http://localhost:9998
VITE_BIGSCREEN_URL=http://localhost:5001
# 其他服务配置
VITE_OTHER_SERVICE_BASE_URL=http://localhost:8080
-24
View File
@@ -1,24 +0,0 @@
import { defineConfig } from '@soybeanjs/eslint-config';
export default defineConfig(
{ vue: true, unocss: true },
{
rules: {
'vue/multi-word-component-names': [
'warn',
{
ignores: ['index', 'App', 'Register', '[id]', '[url]']
}
],
'vue/component-name-in-template-casing': [
'warn',
'PascalCase',
{
registeredComponentsOnly: false,
ignores: ['/^icon-/']
}
],
'unocss/order-attributify': 'off'
}
}
);
-36
View File
@@ -1,36 +0,0 @@
<!doctype html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>%VITE_APP_TITLE%</title>
<script>
// 全局Vue错误防护 - 最后防线
window.addEventListener('error', function (e) {
if (e.message && (e.message.includes('refs') || e.message.includes('push'))) {
console.warn('🔧 全局错误拦截:', e.message);
e.preventDefault();
return true;
}
});
window.addEventListener('unhandledrejection', function (e) {
const msg = e.reason?.message || String(e.reason);
if (msg.includes('refs') || msg.includes('push') || msg.includes('Cannot read properties')) {
console.warn('🔧 全局Promise拦截:', msg);
e.preventDefault();
}
});
// 超级安全模式
window.SAFE_MODE = true;
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-20
View File
@@ -1,20 +0,0 @@
#!/bin/bash
# Created Time: 2025-04-19 13:39:02
# Modified Time: 2025-04-19 13:39:14
#set -e # 这告诉bash一但有任何一个语句返回非真的值,则退出bash。
#!/bin/bash
echo "🔧 安装 Gitea Hook 到当前仓库..."
HOOK_SRC="$HOME/.git-templates/hooks/post-commit"
HOOK_DST="$(git rev-parse --show-toplevel)/.git/hooks/post-commit"
if [ ! -f "$HOOK_SRC" ]; then
echo "❌ 源 hook 文件不存在:$HOOK_SRC"
exit 1
fi
cp "$HOOK_SRC" "$HOOK_DST"
chmod +x "$HOOK_DST"
echo "✅ 安装完成: $HOOK_DST"
-78
View File
@@ -1,78 +0,0 @@
server {
listen 80;
server_name localhost;
index index.html;
root /usr/share/nginx/html;
# 后端API代理 - 优先级最高,必须在前面
location /proxy-default/ {
proxy_pass http://ljwx-boot:9998/; # 使用Docker容器名
client_max_body_size 1024m;
client_body_buffer_size 128k;
client_body_timeout 900s;
proxy_connect_timeout 120s;
proxy_read_timeout 900s;
proxy_send_timeout 900s;
proxy_pass_header Authorization;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-NginX-Proxy true;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_hide_header X-Powered-By;
proxy_redirect off;
proxy_buffers 32 32k;
}
# SPA路由支持 - 避免刷新页面出现 404 错误
location / {
try_files $uri $uri/ /index.html;
index index.html;
}
# 禁止访问的文件或目录
location ~ ^/(\.user.ini|\.htaccess|\.git|\.svn|\.project|LICENSE|README.md) {
return 404;
}
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
gzip on;
gzip_min_length 1k;
gzip_buffers 4 16k;
gzip_http_version 1.1;
gzip_comp_level 4;
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png image/x-icon font/ttf font/opentype font/x-woff image/svg+xml;
gzip_vary on;
gzip_proxied off;
# html js css 缓存设置
location ~* \.(html|htm|js|css)$ {
expires 2h;
}
# 图片缓存设置
location ~* \.(ico|gif|jpg|jpeg|png|ttf)$ {
access_log off;
add_header Cache-Control max-age=180000;
expires 2d;
}
location ~* \.(txt|xml|swf|wav)$ {
access_log off;
expires 24h;
}
location ~* \.(eot|ttf|otf|woff|svg)$ {
access_log off;
expires max;
}
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
-85
View File
@@ -1,85 +0,0 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_types text/plain application/javascript text/css application/json application/xml;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# SVG文件禁用缓存
location ~* \.svg$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# API代理配置 - 必须在通用路由前面
location /proxy-default/ {
proxy_pass http://ljwx-boot:9998/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 直接代理API路径 - 更简单的方式
location ~ ^/(auth|api)/ {
proxy_pass http://ljwx-boot:9998;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /bigscreen/ {
proxy_pass http://ljwx-bigscreen:8001/; # 修正端口从5001到8001
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 静态资源缓存配置
location ~* \.(js|css|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# SPA路由配置 - 支持hash和history模式
location / {
try_files $uri $uri/ @fallback;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# Nginx状态监控端点
location /nginx_status {
stub_status on;
access_log off;
allow all; # 生产环境建议限制IP访问
}
# Prometheus metrics端点
location /metrics {
return 200 "# Nginx基础指标\nnginx_up 1\nnginx_connections_active $connections_active\nnginx_connections_reading $connections_reading\nnginx_connections_writing $connections_writing\nnginx_connections_waiting $connections_waiting\n";
add_header Content-Type text/plain;
}
# Fallback for SPA routing
location @fallback {
rewrite ^.*$ /index.html last;
}
}
}
-109
View File
@@ -1,109 +0,0 @@
{
"name": "ljwx-admin",
"type": "module",
"version": "1.0.11",
"packageManager": "[email protected]",
"description": "灵境万象系统前端管理界面 - 基于 Vue3、Vite5、TypeScript、NaiveUI 和 UnoCSS 构建的现代化健康监测管理系统",
"author": {
"name": "paynezhuang",
"email": "[email protected]",
"url": "https://github.com/paynezhuang"
},
"license": "Apache-2.0",
"homepage": "https://github.com/BrunoGao/yunxiang.git",
"repository": {
"url": "https://github.com/BrunoGao/yunxiang.git"
},
"bugs": {
"url": "https://github.com/BrunoGao/yunxiang/issues"
},
"keywords": ["Vue3 admin ", "vue-admin-template", "Vite5", "TypeScript", "naive-ui", "naive-ui-admin", "ant-design-vue v4", "UnoCSS"],
"engines": {
"node": ">=18.12.0",
"pnpm": ">=8.7.0"
},
"scripts": {
"build": "vite build",
"build:test": "vite build --mode test",
"build:vercel": "cross-env VITE_VERCEL=Y vite build",
"cleanup": "sa cleanup",
"commit": "sa git-commit",
"dev": "vite --mode test --host 0.0.0.0 --port 3333",
"dev:local": "vite --mode test --host 0.0.0.0 --port 3333",
"dev:prod": "vite --mode production --host 0.0.0.0 --port 3333",
"gen-route": "sa gen-route",
"lint": "eslint . --fix",
"preview": "vite preview",
"release": "sa release",
"typecheck": "vue-tsc --noEmit --skipLibCheck",
"update-pkg": "sa update-pkg"
},
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@better-scroll/core": "2.5.1",
"@iconify/vue": "4.2.0",
"@sa/axios": "workspace:*",
"@sa/color": "workspace:*",
"@sa/hooks": "workspace:*",
"@sa/materials": "workspace:*",
"@sa/utils": "workspace:*",
"@types/amap-js-api": "^1.4.16",
"@types/xlsx": "^0.0.36",
"@vicons/ionicons5": "^0.13.0",
"@vueuse/core": "12.0.0",
"clipboard": "2.0.11",
"dayjs": "1.11.13",
"defu": "6.1.4",
"echarts": "5.5.1",
"json5": "2.2.3",
"naive-ui": "2.40.3",
"nprogress": "0.2.0",
"pinia": "2.3.0",
"tailwind-merge": "2.5.5",
"vue": "3.5.13",
"vue-draggable-plus": "0.6.0",
"vue-i18n": "10.0.5",
"vue-router": "4.5.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@elegant-router/vue": "0.3.8",
"@iconify/json": "2.2.283",
"@sa/scripts": "workspace:*",
"@sa/uno-preset": "workspace:*",
"@soybeanjs/eslint-config": "1.4.4",
"@types/node": "22.10.2",
"@types/nprogress": "0.2.3",
"@unocss/eslint-config": "0.65.1",
"@unocss/preset-icons": "0.65.1",
"@unocss/preset-uno": "0.65.1",
"@unocss/transformer-directives": "0.65.1",
"@unocss/transformer-variant-group": "0.65.1",
"@unocss/vite": "0.65.1",
"@vitejs/plugin-vue": "5.2.1",
"@vitejs/plugin-vue-jsx": "4.1.1",
"eslint": "9.17.0",
"eslint-plugin-vue": "9.32.0",
"lint-staged": "15.2.11",
"sass": "1.83.0",
"simple-git-hooks": "2.11.1",
"tsx": "4.19.2",
"typescript": "5.7.2",
"unplugin-icons": "0.21.0",
"unplugin-vue-components": "0.28.0",
"vite": "6.0.3",
"vite-plugin-progress": "0.0.7",
"vite-plugin-svg-icons": "2.0.1",
"vite-plugin-vue-devtools": "7.6.8",
"vue-eslint-parser": "9.4.3",
"vue-tsc": "2.1.10"
},
"simple-git-hooks": {
"commit-msg": "pnpm sa git-commit-verify",
"pre-commit": "pnpm typecheck && pnpm lint-staged"
},
"lint-staged": {
"*": "eslint --fix"
},
"website": "https://admin.soybeanjs.cn"
}
@@ -1,20 +0,0 @@
{
"name": "@sa/alova",
"version": "1.0.5",
"exports": {
".": "./src/index.ts",
"./fetch": "./src/fetch.ts",
"./client": "./src/client.ts",
"./mock": "./src/mock.ts"
},
"typesVersions": {
"*": {
"*": ["./src/*"]
}
},
"dependencies": {
"@alova/mock": "2.0.9",
"@sa/utils": "workspace:*",
"alova": "3.2.3"
}
}
@@ -1 +0,0 @@
export * from 'alova/client';
@@ -1,2 +0,0 @@
/** the backend error code key */
export const BACKEND_ERROR_CODE = 'BACKEND_ERROR';
@@ -1,2 +0,0 @@
import adapterFetch from 'alova/fetch';
export default adapterFetch;
@@ -1,75 +0,0 @@
import { createAlova } from 'alova';
import type { AlovaDefaultCacheAdapter, AlovaGenerics, AlovaGlobalCacheAdapter, AlovaRequestAdapter } from 'alova';
import VueHook from 'alova/vue';
import type { VueHookType } from 'alova/vue';
import adapterFetch from 'alova/fetch';
import { createServerTokenAuthentication } from 'alova/client';
import type { FetchRequestInit } from 'alova/fetch';
import { BACKEND_ERROR_CODE } from './constant';
import type { CustomAlovaConfig, RequestOptions } from './type';
export const createAlovaRequest = <
RequestConfig = FetchRequestInit,
ResponseType = Response,
ResponseHeader = Headers,
L1Cache extends AlovaGlobalCacheAdapter = AlovaDefaultCacheAdapter,
L2Cache extends AlovaGlobalCacheAdapter = AlovaDefaultCacheAdapter
>(
customConfig: CustomAlovaConfig<AlovaGenerics<any, any, RequestConfig, ResponseType, ResponseHeader, L1Cache, L2Cache, any>>,
options: RequestOptions<AlovaGenerics<any, any, RequestConfig, ResponseType, ResponseHeader, L1Cache, L2Cache, any>>
) => {
const { tokenRefresher } = options;
const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication<
VueHookType,
AlovaRequestAdapter<RequestConfig, ResponseType, ResponseHeader>
>({
refreshTokenOnSuccess: {
isExpired: (response, method) => tokenRefresher?.isExpired(response, method) || false,
handler: async (response, method) => tokenRefresher?.handler(response, method)
},
refreshTokenOnError: {
isExpired: (response, method) => tokenRefresher?.isExpired(response, method) || false,
handler: async (response, method) => tokenRefresher?.handler(response, method)
}
});
const instance = createAlova({
...customConfig,
timeout: customConfig.timeout ?? 10 * 1000,
requestAdapter: (customConfig.requestAdapter as any) ?? adapterFetch(),
statesHook: VueHook,
beforeRequest: onAuthRequired(options.onRequest as any),
responded: onResponseRefreshToken({
onSuccess: async (response, method) => {
// check if http status is success
let error: any = null;
let transformedData: any = null;
try {
if (await options.isBackendSuccess(response)) {
transformedData = await options.transformBackendResponse(response);
} else {
error = new Error('the backend request error');
error.code = BACKEND_ERROR_CODE;
}
} catch (err) {
error = err;
}
if (error) {
await options.onError?.(error, response, method);
throw error;
}
return transformedData;
},
onComplete: options.onComplete,
onError: (error, method) => options.onError?.(error, null, method)
})
});
return instance;
};
export { BACKEND_ERROR_CODE };
export type * from './type';
export type * from 'alova';
@@ -1 +0,0 @@
export * from '@alova/mock';
-49
View File
@@ -1,49 +0,0 @@
import type { AlovaGenerics, AlovaOptions, AlovaRequestAdapter, Method, ResponseCompleteHandler } from 'alova';
export type CustomAlovaConfig<AG extends AlovaGenerics> = Omit<AlovaOptions<AG>, 'statesHook' | 'beforeRequest' | 'responded' | 'requestAdapter'> & {
/** request adapter. all request of alova will be sent by it. */
requestAdapter?: AlovaRequestAdapter<AG['RequestConfig'], AG['Response'], AG['ResponseHeader']>;
};
export interface RequestOptions<AG extends AlovaGenerics> {
/**
* The hook before request
*
* For example: You can add header token in this hook
*
* @param method alova Method Instance
*/
onRequest?: AlovaOptions<AG>['beforeRequest'];
/**
* The hook to check backend response is success or not
*
* @param response alova response
*/
isBackendSuccess: (response: AG['Response']) => Promise<boolean>;
/** The config to refresh token */
tokenRefresher?: {
/** detect the token is expired */
isExpired(response: AG['Response'], Method: Method<AG>): Promise<boolean> | boolean;
/** refresh token handler */
handler(response: AG['Response'], Method: Method<AG>): Promise<void>;
};
/** The hook after backend request complete */
onComplete?: ResponseCompleteHandler<AG>;
/**
* The hook to handle error
*
* For example: You can show error message in this hook
*
* @param error
*/
onError?: (error: any, response: AG['Response'] | null, methodInstance: Method<AG>) => any | Promise<any>;
/**
* transform backend response when the responseType is json
*
* @param response alova response
*/
transformBackendResponse: (response: AG['Response']) => any;
}
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": ["DOM", "ESNext"],
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"types": ["node"],
"strict": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
@@ -1,21 +0,0 @@
{
"name": "@sa/axios",
"version": "1.0.5",
"exports": {
".": "./src/index.ts"
},
"typesVersions": {
"*": {
"*": ["./src/*"]
}
},
"dependencies": {
"@sa/utils": "workspace:*",
"axios": "1.7.7",
"axios-retry": "4.5.0",
"qs": "6.13.0"
},
"devDependencies": {
"@types/qs": "6.9.17"
}
}
@@ -1,8 +0,0 @@
/** request id key */
export const REQUEST_ID_KEY = 'P-Request-Id';
/** request language key */
export const REQUEST_LANGUAGE = 'P-Language';
/** the backend error code key */
export const BACKEND_ERROR_CODE = 'BACKEND_ERROR';
-164
View File
@@ -1,164 +0,0 @@
import axios, { AxiosError } from 'axios';
import type { AxiosResponse, CreateAxiosDefaults, InternalAxiosRequestConfig } from 'axios';
import axiosRetry from 'axios-retry';
import { nanoid } from '@sa/utils';
import { createAxiosConfig, createDefaultOptions, createRetryOptions } from './options';
import { BACKEND_ERROR_CODE, REQUEST_ID_KEY, REQUEST_LANGUAGE } from './constant';
import type { CustomAxiosRequestConfig, FlatRequestInstance, MappedType, RequestInstance, RequestOption, ResponseType } from './type';
function createCommonRequest<ResponseData = any>(axiosConfig?: CreateAxiosDefaults, options?: Partial<RequestOption<ResponseData>>) {
const opts = createDefaultOptions<ResponseData>(options);
const axiosConf = createAxiosConfig(axiosConfig);
const instance = axios.create(axiosConf);
const abortControllerMap = new Map<string, AbortController>();
// config axios retry
const retryOptions = createRetryOptions(axiosConf);
axiosRetry(instance, retryOptions);
instance.interceptors.request.use(conf => {
const config: InternalAxiosRequestConfig = { ...conf };
// set request id
const requestId = nanoid();
config.headers.set(REQUEST_ID_KEY, requestId);
// config abort controller
if (!config.signal) {
const abortController = new AbortController();
config.signal = abortController.signal;
abortControllerMap.set(requestId, abortController);
}
// handle config by hook
const handledConfig = opts.onRequest?.(config) || config;
return handledConfig;
});
instance.interceptors.response.use(
async response => {
const responseType: ResponseType = (response.config?.responseType as ResponseType) || 'json';
if (responseType !== 'json' || opts.isBackendSuccess(response)) {
return Promise.resolve(response);
}
const fail = await opts.onBackendFail(response, instance);
if (fail) {
return fail;
}
const backendError = new AxiosError<ResponseData>('the backend request error', BACKEND_ERROR_CODE, response.config, response.request, response);
await opts.onError(backendError);
return Promise.reject(backendError);
},
async (error: AxiosError<ResponseData>) => {
await opts.onError(error);
return Promise.reject(error);
}
);
function cancelRequest(requestId: string) {
const abortController = abortControllerMap.get(requestId);
if (abortController) {
abortController.abort();
abortControllerMap.delete(requestId);
}
}
function cancelAllRequest() {
abortControllerMap.forEach(abortController => {
abortController.abort();
});
abortControllerMap.clear();
}
return {
instance,
opts,
cancelRequest,
cancelAllRequest
};
}
/**
* create a request instance
*
* @param axiosConfig axios config
* @param options request options
*/
export function createRequest<ResponseData = any, State = Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData>>
) {
const { instance, opts, cancelRequest, cancelAllRequest } = createCommonRequest<ResponseData>(axiosConfig, options);
const request: RequestInstance<State> = async function request<T = any, R extends ResponseType = 'json'>(config: CustomAxiosRequestConfig) {
const response: AxiosResponse<ResponseData> = await instance(config);
const responseType = response.config?.responseType || 'json';
if (responseType === 'json') {
return opts.transformBackendResponse(response);
}
return response.data as MappedType<R, T>;
} as RequestInstance<State>;
request.cancelRequest = cancelRequest;
request.cancelAllRequest = cancelAllRequest;
request.state = {} as State;
return request;
}
/**
* create a flat request instance
*
* The response data is a flat object: { data: any, error: AxiosError }
*
* @param axiosConfig axios config
* @param options request options
*/
export function createFlatRequest<ResponseData = any, State = Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData>>
) {
const { instance, opts, cancelRequest, cancelAllRequest } = createCommonRequest<ResponseData>(axiosConfig, options);
const flatRequest: FlatRequestInstance<State, ResponseData> = async function flatRequest<T = any, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig
) {
try {
const response: AxiosResponse<ResponseData> = await instance(config);
const responseType = response.config?.responseType || 'json';
if (responseType === 'json') {
const data = opts.transformBackendResponse(response);
return { data, error: null, response };
}
return { data: response.data as MappedType<R, T>, error: null, response };
} catch (error) {
return { data: null, error, response: (error as AxiosError<ResponseData>).response };
}
} as FlatRequestInstance<State, ResponseData>;
flatRequest.cancelRequest = cancelRequest;
flatRequest.cancelAllRequest = cancelAllRequest;
flatRequest.state = {} as State;
return flatRequest;
}
export { BACKEND_ERROR_CODE, REQUEST_ID_KEY, REQUEST_LANGUAGE };
export type * from './type';
export type { CreateAxiosDefaults, AxiosError };
@@ -1,48 +0,0 @@
import type { CreateAxiosDefaults } from 'axios';
import type { IAxiosRetryConfig } from 'axios-retry';
import { stringify } from 'qs';
import { isHttpSuccess } from './shared';
import type { RequestOption } from './type';
export function createDefaultOptions<ResponseData = any>(options?: Partial<RequestOption<ResponseData>>) {
const opts: RequestOption<ResponseData> = {
onRequest: async config => config,
isBackendSuccess: _response => true,
onBackendFail: async () => {},
transformBackendResponse: async response => response.data,
onError: async () => {}
};
Object.assign(opts, options);
return opts;
}
export function createRetryOptions(config?: Partial<CreateAxiosDefaults>) {
const retryConfig: IAxiosRetryConfig = {
retries: 0
};
Object.assign(retryConfig, config);
return retryConfig;
}
export function createAxiosConfig(config?: Partial<CreateAxiosDefaults>) {
const TEN_SECONDS = 10 * 1000;
const axiosConfig: CreateAxiosDefaults = {
timeout: TEN_SECONDS,
headers: {
'Content-Type': 'application/json'
},
validateStatus: isHttpSuccess,
paramsSerializer: params => {
return stringify(params);
}
};
Object.assign(axiosConfig, config);
return axiosConfig;
}
@@ -1,28 +0,0 @@
import type { AxiosHeaderValue, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
export function getContentType(config: InternalAxiosRequestConfig) {
const contentType: AxiosHeaderValue = config.headers?.['Content-Type'] || 'application/json';
return contentType;
}
/**
* check if http status is success
*
* @param status
*/
export function isHttpSuccess(status: number) {
const isSuccessCode = status >= 200 && status < 300;
return isSuccessCode || status === 304;
}
/**
* is response json
*
* @param response axios response
*/
export function isResponseJson(response: AxiosResponse) {
const { responseType } = response.config;
return responseType === 'json' || responseType === undefined;
}
-106
View File
@@ -1,106 +0,0 @@
import type { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
export type ContentType =
| 'text/html'
| 'text/plain'
| 'multipart/form-data'
| 'application/json'
| 'application/x-www-form-urlencoded'
| 'application/octet-stream';
export interface RequestOption<ResponseData = any> {
/**
* The hook before request
*
* For example: You can add header token in this hook
*
* @param config Axios config
*/
onRequest: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>;
/**
* The hook to check backend response is success or not
*
* @param response Axios response
*/
isBackendSuccess: (response: AxiosResponse<ResponseData>) => boolean;
/**
* The hook after backend request fail
*
* For example: You can handle the expired token in this hook
*
* @param response Axios response
* @param instance Axios instance
*/
onBackendFail: (response: AxiosResponse<ResponseData>, instance: AxiosInstance) => Promise<AxiosResponse | null> | Promise<void>;
/**
* transform backend response when the responseType is json
*
* @param response Axios response
*/
transformBackendResponse(response: AxiosResponse<ResponseData>): any | Promise<any>;
/**
* The hook to handle error
*
* For example: You can show error message in this hook
*
* @param error
*/
onError: (error: AxiosError<ResponseData>) => void | Promise<void>;
}
interface ResponseMap {
blob: Blob;
text: string;
arrayBuffer: ArrayBuffer;
stream: ReadableStream<Uint8Array>;
document: Document;
}
export type ResponseType = keyof ResponseMap | 'json';
export type MappedType<R extends ResponseType, JsonType = any> = R extends keyof ResponseMap ? ResponseMap[R] : JsonType;
export type CustomAxiosRequestConfig<R extends ResponseType = 'json'> = Omit<AxiosRequestConfig, 'responseType'> & {
responseType?: R;
};
export interface RequestInstanceCommon<T> {
/**
* cancel the request by request id
*
* if the request provide abort controller sign from config, it will not collect in the abort controller map
*
* @param requestId
*/
cancelRequest: (requestId: string) => void;
/**
* cancel all request
*
* if the request provide abort controller sign from config, it will not collect in the abort controller map
*/
cancelAllRequest: () => void;
/** you can set custom state in the request instance */
state: T;
}
/** The request instance */
export interface RequestInstance<S = Record<string, unknown>> extends RequestInstanceCommon<S> {
<T = any, R extends ResponseType = 'json'>(config: CustomAxiosRequestConfig<R>): Promise<MappedType<R, T>>;
}
export type FlatResponseSuccessData<T = any, ResponseData = any> = {
data: T;
error: null;
response: AxiosResponse<ResponseData>;
};
export type FlatResponseFailData<ResponseData = any> = {
data: null;
error: AxiosError<ResponseData>;
response: AxiosResponse<ResponseData>;
};
export type FlatResponseData<T = any, ResponseData = any> = FlatResponseSuccessData<T, ResponseData> | FlatResponseFailData<ResponseData>;
export interface FlatRequestInstance<S = Record<string, unknown>, ResponseData = any> extends RequestInstanceCommon<S> {
<T = any, R extends ResponseType = 'json'>(config: CustomAxiosRequestConfig<R>): Promise<FlatResponseData<MappedType<R, T>, ResponseData>>;
}
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": ["DOM", "ESNext"],
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"types": ["node"],
"strict": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
@@ -1,16 +0,0 @@
{
"name": "@sa/color",
"version": "1.0.5",
"exports": {
".": "./src/index.ts"
},
"typesVersions": {
"*": {
"*": ["./src/*"]
}
},
"dependencies": {
"@sa/utils": "workspace:*",
"colord": "2.9.3"
}
}
@@ -1,2 +0,0 @@
export * from './name';
export * from './palette';
File diff suppressed because it is too large Load Diff
@@ -1,356 +0,0 @@
import type { ColorPaletteFamily } from '../types';
export const colorPalettes: ColorPaletteFamily[] = [
{
name: 'Slate',
palettes: [
{ hex: '#f8fafc', number: 50 },
{ hex: '#f1f5f9', number: 100 },
{ hex: '#e2e8f0', number: 200 },
{ hex: '#cbd5e1', number: 300 },
{ hex: '#94a3b8', number: 400 },
{ hex: '#64748b', number: 500 },
{ hex: '#475569', number: 600 },
{ hex: '#334155', number: 700 },
{ hex: '#1e293b', number: 800 },
{ hex: '#0f172a', number: 900 },
{ hex: '#020617', number: 950 }
]
},
{
name: 'Gray',
palettes: [
{ hex: '#f9fafb', number: 50 },
{ hex: '#f3f4f6', number: 100 },
{ hex: '#e5e7eb', number: 200 },
{ hex: '#d1d5db', number: 300 },
{ hex: '#9ca3af', number: 400 },
{ hex: '#6b7280', number: 500 },
{ hex: '#4b5563', number: 600 },
{ hex: '#374151', number: 700 },
{ hex: '#1f2937', number: 800 },
{ hex: '#111827', number: 900 },
{ hex: '#030712', number: 950 }
]
},
{
name: 'Zinc',
palettes: [
{ hex: '#fafafa', number: 50 },
{ hex: '#f4f4f5', number: 100 },
{ hex: '#e4e4e7', number: 200 },
{ hex: '#d4d4d8', number: 300 },
{ hex: '#a1a1aa', number: 400 },
{ hex: '#71717a', number: 500 },
{ hex: '#52525b', number: 600 },
{ hex: '#3f3f46', number: 700 },
{ hex: '#27272a', number: 800 },
{ hex: '#18181b', number: 900 },
{ hex: '#09090b', number: 950 }
]
},
{
name: 'Neutral',
palettes: [
{ hex: '#fafafa', number: 50 },
{ hex: '#f5f5f5', number: 100 },
{ hex: '#e5e5e5', number: 200 },
{ hex: '#d4d4d4', number: 300 },
{ hex: '#a3a3a3', number: 400 },
{ hex: '#737373', number: 500 },
{ hex: '#525252', number: 600 },
{ hex: '#404040', number: 700 },
{ hex: '#262626', number: 800 },
{ hex: '#171717', number: 900 },
{ hex: '#0a0a0a', number: 950 }
]
},
{
name: 'Stone',
palettes: [
{ hex: '#fafaf9', number: 50 },
{ hex: '#f5f5f4', number: 100 },
{ hex: '#e7e5e4', number: 200 },
{ hex: '#d6d3d1', number: 300 },
{ hex: '#a8a29e', number: 400 },
{ hex: '#78716c', number: 500 },
{ hex: '#57534e', number: 600 },
{ hex: '#44403c', number: 700 },
{ hex: '#292524', number: 800 },
{ hex: '#1c1917', number: 900 },
{ hex: '#0c0a09', number: 950 }
]
},
{
name: 'Red',
palettes: [
{ hex: '#fef2f2', number: 50 },
{ hex: '#fee2e2', number: 100 },
{ hex: '#fecaca', number: 200 },
{ hex: '#fca5a5', number: 300 },
{ hex: '#f87171', number: 400 },
{ hex: '#ef4444', number: 500 },
{ hex: '#dc2626', number: 600 },
{ hex: '#b91c1c', number: 700 },
{ hex: '#991b1b', number: 800 },
{ hex: '#7f1d1d', number: 900 },
{ hex: '#450a0a', number: 950 }
]
},
{
name: 'Orange',
palettes: [
{ hex: '#fff7ed', number: 50 },
{ hex: '#ffedd5', number: 100 },
{ hex: '#fed7aa', number: 200 },
{ hex: '#fdba74', number: 300 },
{ hex: '#fb923c', number: 400 },
{ hex: '#f97316', number: 500 },
{ hex: '#ea580c', number: 600 },
{ hex: '#c2410c', number: 700 },
{ hex: '#9a3412', number: 800 },
{ hex: '#7c2d12', number: 900 },
{ hex: '#431407', number: 950 }
]
},
{
name: 'Amber',
palettes: [
{ hex: '#fffbeb', number: 50 },
{ hex: '#fef3c7', number: 100 },
{ hex: '#fde68a', number: 200 },
{ hex: '#fcd34d', number: 300 },
{ hex: '#fbbf24', number: 400 },
{ hex: '#f59e0b', number: 500 },
{ hex: '#d97706', number: 600 },
{ hex: '#b45309', number: 700 },
{ hex: '#92400e', number: 800 },
{ hex: '#78350f', number: 900 },
{ hex: '#451a03', number: 950 }
]
},
{
name: 'Yellow',
palettes: [
{ hex: '#fefce8', number: 50 },
{ hex: '#fef9c3', number: 100 },
{ hex: '#fef08a', number: 200 },
{ hex: '#fde047', number: 300 },
{ hex: '#facc15', number: 400 },
{ hex: '#eab308', number: 500 },
{ hex: '#ca8a04', number: 600 },
{ hex: '#a16207', number: 700 },
{ hex: '#854d0e', number: 800 },
{ hex: '#713f12', number: 900 },
{ hex: '#422006', number: 950 }
]
},
{
name: 'Lime',
palettes: [
{ hex: '#f7fee7', number: 50 },
{ hex: '#ecfccb', number: 100 },
{ hex: '#d9f99d', number: 200 },
{ hex: '#bef264', number: 300 },
{ hex: '#a3e635', number: 400 },
{ hex: '#84cc16', number: 500 },
{ hex: '#65a30d', number: 600 },
{ hex: '#4d7c0f', number: 700 },
{ hex: '#3f6212', number: 800 },
{ hex: '#365314', number: 900 },
{ hex: '#1a2e05', number: 950 }
]
},
{
name: 'Green',
palettes: [
{ hex: '#f0fdf4', number: 50 },
{ hex: '#dcfce7', number: 100 },
{ hex: '#bbf7d0', number: 200 },
{ hex: '#86efac', number: 300 },
{ hex: '#4ade80', number: 400 },
{ hex: '#22c55e', number: 500 },
{ hex: '#16a34a', number: 600 },
{ hex: '#15803d', number: 700 },
{ hex: '#166534', number: 800 },
{ hex: '#14532d', number: 900 },
{ hex: '#052e16', number: 950 }
]
},
{
name: 'Emerald',
palettes: [
{ hex: '#ecfdf5', number: 50 },
{ hex: '#d1fae5', number: 100 },
{ hex: '#a7f3d0', number: 200 },
{ hex: '#6ee7b7', number: 300 },
{ hex: '#34d399', number: 400 },
{ hex: '#10b981', number: 500 },
{ hex: '#059669', number: 600 },
{ hex: '#047857', number: 700 },
{ hex: '#065f46', number: 800 },
{ hex: '#064e3b', number: 900 },
{ hex: '#022c22', number: 950 }
]
},
{
name: 'Teal',
palettes: [
{ hex: '#f0fdfa', number: 50 },
{ hex: '#ccfbf1', number: 100 },
{ hex: '#99f6e4', number: 200 },
{ hex: '#5eead4', number: 300 },
{ hex: '#2dd4bf', number: 400 },
{ hex: '#14b8a6', number: 500 },
{ hex: '#0d9488', number: 600 },
{ hex: '#0f766e', number: 700 },
{ hex: '#115e59', number: 800 },
{ hex: '#134e4a', number: 900 },
{ hex: '#042f2e', number: 950 }
]
},
{
name: 'Cyan',
palettes: [
{ hex: '#ecfeff', number: 50 },
{ hex: '#cffafe', number: 100 },
{ hex: '#a5f3fc', number: 200 },
{ hex: '#67e8f9', number: 300 },
{ hex: '#22d3ee', number: 400 },
{ hex: '#06b6d4', number: 500 },
{ hex: '#0891b2', number: 600 },
{ hex: '#0e7490', number: 700 },
{ hex: '#155e75', number: 800 },
{ hex: '#164e63', number: 900 },
{ hex: '#083344', number: 950 }
]
},
{
name: 'Sky',
palettes: [
{ hex: '#f0f9ff', number: 50 },
{ hex: '#e0f2fe', number: 100 },
{ hex: '#bae6fd', number: 200 },
{ hex: '#7dd3fc', number: 300 },
{ hex: '#38bdf8', number: 400 },
{ hex: '#0ea5e9', number: 500 },
{ hex: '#0284c7', number: 600 },
{ hex: '#0369a1', number: 700 },
{ hex: '#075985', number: 800 },
{ hex: '#0c4a6e', number: 900 },
{ hex: '#082f49', number: 950 }
]
},
{
name: 'Blue',
palettes: [
{ hex: '#eff6ff', number: 50 },
{ hex: '#dbeafe', number: 100 },
{ hex: '#bfdbfe', number: 200 },
{ hex: '#93c5fd', number: 300 },
{ hex: '#60a5fa', number: 400 },
{ hex: '#3b82f6', number: 500 },
{ hex: '#2563eb', number: 600 },
{ hex: '#1d4ed8', number: 700 },
{ hex: '#1e40af', number: 800 },
{ hex: '#1e3a8a', number: 900 },
{ hex: '#172554', number: 950 }
]
},
{
name: 'Indigo',
palettes: [
{ hex: '#eef2ff', number: 50 },
{ hex: '#e0e7ff', number: 100 },
{ hex: '#c7d2fe', number: 200 },
{ hex: '#a5b4fc', number: 300 },
{ hex: '#818cf8', number: 400 },
{ hex: '#6366f1', number: 500 },
{ hex: '#4f46e5', number: 600 },
{ hex: '#4338ca', number: 700 },
{ hex: '#3730a3', number: 800 },
{ hex: '#312e81', number: 900 },
{ hex: '#1e1b4b', number: 950 }
]
},
{
name: 'Violet',
palettes: [
{ hex: '#f5f3ff', number: 50 },
{ hex: '#ede9fe', number: 100 },
{ hex: '#ddd6fe', number: 200 },
{ hex: '#c4b5fd', number: 300 },
{ hex: '#a78bfa', number: 400 },
{ hex: '#8b5cf6', number: 500 },
{ hex: '#7c3aed', number: 600 },
{ hex: '#6d28d9', number: 700 },
{ hex: '#5b21b6', number: 800 },
{ hex: '#4c1d95', number: 900 },
{ hex: '#2e1065', number: 950 }
]
},
{
name: 'Purple',
palettes: [
{ hex: '#faf5ff', number: 50 },
{ hex: '#f3e8ff', number: 100 },
{ hex: '#e9d5ff', number: 200 },
{ hex: '#d8b4fe', number: 300 },
{ hex: '#c084fc', number: 400 },
{ hex: '#a855f7', number: 500 },
{ hex: '#9333ea', number: 600 },
{ hex: '#7e22ce', number: 700 },
{ hex: '#6b21a8', number: 800 },
{ hex: '#581c87', number: 900 },
{ hex: '#3b0764', number: 950 }
]
},
{
name: 'Fuchsia',
palettes: [
{ hex: '#fdf4ff', number: 50 },
{ hex: '#fae8ff', number: 100 },
{ hex: '#f5d0fe', number: 200 },
{ hex: '#f0abfc', number: 300 },
{ hex: '#e879f9', number: 400 },
{ hex: '#d946ef', number: 500 },
{ hex: '#c026d3', number: 600 },
{ hex: '#a21caf', number: 700 },
{ hex: '#86198f', number: 800 },
{ hex: '#701a75', number: 900 },
{ hex: '#4a044e', number: 950 }
]
},
{
name: 'Pink',
palettes: [
{ hex: '#fdf2f8', number: 50 },
{ hex: '#fce7f3', number: 100 },
{ hex: '#fbcfe8', number: 200 },
{ hex: '#f9a8d4', number: 300 },
{ hex: '#f472b6', number: 400 },
{ hex: '#ec4899', number: 500 },
{ hex: '#db2777', number: 600 },
{ hex: '#be185d', number: 700 },
{ hex: '#9d174d', number: 800 },
{ hex: '#831843', number: 900 },
{ hex: '#500724', number: 950 }
]
},
{
name: 'Rose',
palettes: [
{ hex: '#fff1f2', number: 50 },
{ hex: '#ffe4e6', number: 100 },
{ hex: '#fecdd3', number: 200 },
{ hex: '#fda4af', number: 300 },
{ hex: '#fb7185', number: 400 },
{ hex: '#f43f5e', number: 500 },
{ hex: '#e11d48', number: 600 },
{ hex: '#be123c', number: 700 },
{ hex: '#9f1239', number: 800 },
{ hex: '#881337', number: 900 },
{ hex: '#4c0519', number: 950 }
]
}
];
@@ -1,7 +0,0 @@
import { colorPalettes } from './constant';
export * from './palette';
export * from './shared';
export { colorPalettes };
export * from './types';
@@ -1,176 +0,0 @@
import type { AnyColor, HsvColor } from 'colord';
import { getHex, getHsv, isValidColor, mixColor } from '../shared';
import type { ColorIndex } from '../types';
/** Hue step */
const hueStep = 2;
/** Saturation step, light color part */
const saturationStep = 16;
/** Saturation step, dark color part */
const saturationStep2 = 5;
/** Brightness step, light color part */
const brightnessStep1 = 5;
/** Brightness step, dark color part */
const brightnessStep2 = 15;
/** Light color count, main color up */
const lightColorCount = 5;
/** Dark color count, main color down */
const darkColorCount = 4;
/**
* Get AntD palette color by index
*
* @param color - Color
* @param index - The color index of color palette (the main color index is 6)
* @returns Hex color
*/
export function getAntDPaletteColorByIndex(color: AnyColor, index: ColorIndex): string {
if (!isValidColor(color)) {
throw new Error('invalid input color value');
}
if (index === 6) {
return getHex(color);
}
const isLight = index < 6;
const hsv = getHsv(color);
const i = isLight ? lightColorCount + 1 - index : index - lightColorCount - 1;
const newHsv: HsvColor = {
h: getHue(hsv, i, isLight),
s: getSaturation(hsv, i, isLight),
v: getValue(hsv, i, isLight)
};
return getHex(newHsv);
}
/** Map of dark color index and opacity */
const darkColorMap = [
{ index: 7, opacity: 0.15 },
{ index: 6, opacity: 0.25 },
{ index: 5, opacity: 0.3 },
{ index: 5, opacity: 0.45 },
{ index: 5, opacity: 0.65 },
{ index: 5, opacity: 0.85 },
{ index: 5, opacity: 0.9 },
{ index: 4, opacity: 0.93 },
{ index: 3, opacity: 0.95 },
{ index: 2, opacity: 0.97 },
{ index: 1, opacity: 0.98 }
];
/**
* Get AntD color palette
*
* @param color - Color
* @param darkTheme - Dark theme
* @param darkThemeMixColor - Dark theme mix color (default: #141414)
*/
export function getAntDColorPalette(color: AnyColor, darkTheme = false, darkThemeMixColor = '#141414'): string[] {
const indexes: ColorIndex[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const patterns = indexes.map(index => getAntDPaletteColorByIndex(color, index));
if (darkTheme) {
const darkPatterns = darkColorMap.map(({ index, opacity }) => {
const darkColor = mixColor(darkThemeMixColor, patterns[index], opacity);
return darkColor;
});
return darkPatterns.map(item => getHex(item));
}
return patterns;
}
/**
* Get hue
*
* @param hsv - Hsv format color
* @param i - The relative distance from 6
* @param isLight - Is light color
*/
function getHue(hsv: HsvColor, i: number, isLight: boolean) {
let hue: number;
const hsvH = Math.round(hsv.h);
if (hsvH >= 60 && hsvH <= 240) {
hue = isLight ? hsvH - hueStep * i : hsvH + hueStep * i;
} else {
hue = isLight ? hsvH + hueStep * i : hsvH - hueStep * i;
}
if (hue < 0) {
hue += 360;
}
if (hue >= 360) {
hue -= 360;
}
return hue;
}
/**
* Get saturation
*
* @param hsv - Hsv format color
* @param i - The relative distance from 6
* @param isLight - Is light color
*/
function getSaturation(hsv: HsvColor, i: number, isLight: boolean) {
if (hsv.h === 0 && hsv.s === 0) {
return hsv.s;
}
let saturation: number;
if (isLight) {
saturation = hsv.s - saturationStep * i;
} else if (i === darkColorCount) {
saturation = hsv.s + saturationStep;
} else {
saturation = hsv.s + saturationStep2 * i;
}
if (saturation > 100) {
saturation = 100;
}
if (isLight && i === lightColorCount && saturation > 10) {
saturation = 10;
}
if (saturation < 6) {
saturation = 6;
}
return saturation;
}
/**
* Get value of hsv
*
* @param hsv - Hsv format color
* @param i - The relative distance from 6
* @param isLight - Is light color
*/
function getValue(hsv: HsvColor, i: number, isLight: boolean) {
let value: number;
if (isLight) {
value = hsv.v + brightnessStep1 * i;
} else {
value = hsv.v - brightnessStep2 * i;
}
if (value > 100) {
value = 100;
}
return value;
}
@@ -1,45 +0,0 @@
import type { AnyColor } from 'colord';
import { getHex } from '../shared';
import type { ColorPaletteNumber } from '../types';
import { getRecommendedColorPalette } from './recommend';
import { getAntDColorPalette } from './antd';
/**
* get color palette by provided color
*
* @param color
* @param recommended whether to get recommended color palette (the provided color may not be the main color)
*/
export function getColorPalette(color: AnyColor, recommended = false) {
const colorMap = new Map<ColorPaletteNumber, string>();
if (recommended) {
const colorPalette = getRecommendedColorPalette(getHex(color));
colorPalette.palettes.forEach(palette => {
colorMap.set(palette.number, palette.hex);
});
} else {
const colors = getAntDColorPalette(color);
const colorNumbers: ColorPaletteNumber[] = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
colorNumbers.forEach((number, index) => {
colorMap.set(number, colors[index]);
});
}
return colorMap;
}
/**
* get color palette color by number
*
* @param color the provided color
* @param number the color palette number
* @param recommended whether to get recommended color palette (the provided color may not be the main color)
*/
export function getPaletteColorByNumber(color: AnyColor, number: ColorPaletteNumber, recommended = false) {
const colorMap = getColorPalette(color, recommended);
return colorMap.get(number as ColorPaletteNumber)!;
}
@@ -1,144 +0,0 @@
import { getColorName, getDeltaE, getHsl, isValidColor, transformHslToHex } from '../shared';
import { colorPalettes } from '../constant';
import type { ColorPalette, ColorPaletteFamily, ColorPaletteFamilyWithNearestPalette, ColorPaletteMatch, ColorPaletteNumber } from '../types';
/**
* get recommended color palette by provided color
*
* @param color the provided color
*/
export function getRecommendedColorPalette(color: string) {
const colorPaletteFamily = getRecommendedColorPaletteFamily(color);
const colorMap = new Map<ColorPaletteNumber, ColorPalette>();
colorPaletteFamily.palettes.forEach(palette => {
colorMap.set(palette.number, palette);
});
const mainColor = colorMap.get(500)!;
const matchColor = colorPaletteFamily.palettes.find(palette => palette.hex === color)!;
const colorPalette: ColorPaletteMatch = {
...colorPaletteFamily,
colorMap,
main: mainColor,
match: matchColor
};
return colorPalette;
}
/**
* get recommended palette color by provided color
*
* @param color the provided color
* @param number the color palette number
*/
export function getRecommendedPaletteColorByNumber(color: string, number: ColorPaletteNumber) {
const colorPalette = getRecommendedColorPalette(color);
const { hex } = colorPalette.colorMap.get(number)!;
return hex;
}
/**
* get color palette family by provided color and color name
*
* @param color the provided color
*/
export function getRecommendedColorPaletteFamily(color: string) {
if (!isValidColor(color)) {
throw new Error('Invalid color, please check color value!');
}
let colorName = getColorName(color);
colorName = colorName.toLowerCase().replace(/\s/g, '-');
const { h: h1, s: s1 } = getHsl(color);
const { nearestLightnessPalette, palettes } = getNearestColorPaletteFamily(color, colorPalettes);
const { number, hex } = nearestLightnessPalette;
const { h: h2, s: s2 } = getHsl(hex);
const deltaH = h1 - h2;
const sRatio = s1 / s2;
const colorPaletteFamily: ColorPaletteFamily = {
name: colorName,
palettes: palettes.map(palette => {
let hexValue = color;
const isSame = number === palette.number;
if (!isSame) {
const { h: h3, s: s3, l } = getHsl(palette.hex);
const newH = deltaH < 0 ? h3 + deltaH : h3 - deltaH;
const newS = s3 * sRatio;
hexValue = transformHslToHex({
h: newH,
s: newS,
l
});
}
return {
hex: hexValue,
number: palette.number
};
})
};
return colorPaletteFamily;
}
/**
* get nearest color palette family
*
* @param color color
* @param families color palette families
*/
function getNearestColorPaletteFamily(color: string, families: ColorPaletteFamily[]) {
const familyWithConfig = families.map(family => {
const palettes = family.palettes.map(palette => {
return {
...palette,
delta: getDeltaE(color, palette.hex)
};
});
const nearestPalette = palettes.reduce((prev, curr) => (prev.delta < curr.delta ? prev : curr));
return {
...family,
palettes,
nearestPalette
};
});
const nearestPaletteFamily = familyWithConfig.reduce((prev, curr) => (prev.nearestPalette.delta < curr.nearestPalette.delta ? prev : curr));
const { l } = getHsl(color);
const paletteFamily: ColorPaletteFamilyWithNearestPalette = {
...nearestPaletteFamily,
nearestLightnessPalette: nearestPaletteFamily.palettes.reduce((prev, curr) => {
const { l: prevLightness } = getHsl(prev.hex);
const { l: currLightness } = getHsl(curr.hex);
const deltaPrev = Math.abs(prevLightness - l);
const deltaCurr = Math.abs(currLightness - l);
return deltaPrev < deltaCurr ? prev : curr;
})
};
return paletteFamily;
}
@@ -1,93 +0,0 @@
import { colord, extend } from 'colord';
import namesPlugin from 'colord/plugins/names';
import mixPlugin from 'colord/plugins/mix';
import labPlugin from 'colord/plugins/lab';
import type { AnyColor, HslColor, RgbColor } from 'colord';
extend([namesPlugin, mixPlugin, labPlugin]);
export function isValidColor(color: AnyColor) {
return colord(color).isValid();
}
export function getHex(color: AnyColor) {
return colord(color).toHex();
}
export function getRgb(color: AnyColor) {
return colord(color).toRgb();
}
export function getHsl(color: AnyColor) {
return colord(color).toHsl();
}
export function getHsv(color: AnyColor) {
return colord(color).toHsv();
}
export function getDeltaE(color1: AnyColor, color2: AnyColor) {
return colord(color1).delta(color2);
}
export function transformHslToHex(color: HslColor) {
return colord(color).toHex();
}
/**
* Add color alpha
*
* @param color - Color
* @param alpha - Alpha (0 - 1)
*/
export function addColorAlpha(color: AnyColor, alpha: number) {
return colord(color).alpha(alpha).toHex();
}
/**
* Mix color
*
* @param firstColor - First color
* @param secondColor - Second color
* @param ratio - The ratio of the second color (0 - 1)
*/
export function mixColor(firstColor: AnyColor, secondColor: AnyColor, ratio: number) {
return colord(firstColor).mix(secondColor, ratio).toHex();
}
/**
* Transform color with opacity to similar color without opacity
*
* @param color - Color
* @param alpha - Alpha (0 - 1)
* @param bgColor Background color (usually white or black)
*/
export function transformColorWithOpacity(color: AnyColor, alpha: number, bgColor = '#ffffff') {
const originColor = addColorAlpha(color, alpha);
const { r: oR, g: oG, b: oB } = colord(originColor).toRgb();
const { r: bgR, g: bgG, b: bgB } = colord(bgColor).toRgb();
function calRgb(or: number, bg: number, al: number) {
return bg + (or - bg) * al;
}
const resultRgb: RgbColor = {
r: calRgb(oR, bgR, alpha),
g: calRgb(oG, bgG, alpha),
b: calRgb(oB, bgB, alpha)
};
return colord(resultRgb).toHex();
}
/**
* Is white color
*
* @param color - Color
*/
export function isWhiteColor(color: AnyColor) {
return colord(color).isEqual('#ffffff');
}
export { colord };
@@ -1,2 +0,0 @@
export * from './colord';
export * from './name';
@@ -1,49 +0,0 @@
import { colorNames } from '../constant';
import { getHex, getHsl, getRgb } from './colord';
/**
* Get color name
*
* @param color
*/
export function getColorName(color: string) {
const hex = getHex(color);
const rgb = getRgb(color);
const hsl = getHsl(color);
let ndf = 0;
let ndf1 = 0;
let ndf2 = 0;
let cl = -1;
let df = -1;
let name = '';
colorNames.some((item, index) => {
const [hexValue, colorName] = item;
const match = hex === hexValue;
if (match) {
name = colorName;
} else {
const { r, g, b } = getRgb(hexValue);
const { h, s, l } = getHsl(hexValue);
ndf1 = (rgb.r - r) ** 2 + (rgb.g - g) ** 2 + (rgb.b - b) ** 2;
ndf2 = (hsl.h - h) ** 2 + (hsl.s - s) ** 2 + (hsl.l - l) ** 2;
ndf = ndf1 + ndf2 * 2;
if (df < 0 || df > ndf) {
df = ndf;
cl = index;
}
}
return match;
});
name = colorNames[cl][1];
return name;
}
@@ -1,58 +0,0 @@
/**
* the color palette number
*
* the main color number is 500
*/
export type ColorPaletteNumber = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950;
/** the color palette */
export type ColorPalette = {
/** the color hex value */
hex: string;
/**
* the color number
*
* - 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950
*/
number: ColorPaletteNumber;
};
/** the color palette family */
export type ColorPaletteFamily = {
/** the color palette family name */
name: string;
/** the color palettes */
palettes: ColorPalette[];
};
/** the color palette with delta */
export type ColorPaletteWithDelta = ColorPalette & {
delta: number;
};
/** the color palette family with nearest palette */
export type ColorPaletteFamilyWithNearestPalette = ColorPaletteFamily & {
nearestPalette: ColorPaletteWithDelta;
nearestLightnessPalette: ColorPaletteWithDelta;
};
/** the color palette match */
export type ColorPaletteMatch = ColorPaletteFamily & {
/** the color map of the palette */
colorMap: Map<ColorPaletteNumber, ColorPalette>;
/**
* the main color of the palette
*
* which number is 500
*/
main: ColorPalette;
/** the match color of the palette */
match: ColorPalette;
};
/**
* The color index of color palette
*
* From left to right, the color is from light to dark, 6 is main color
*/
export type ColorIndex = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11;
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": ["DOM", "ESNext"],
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"types": ["node"],
"strict": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
@@ -1,16 +0,0 @@
{
"name": "@sa/hooks",
"version": "1.0.5",
"exports": {
".": "./src/index.ts"
},
"typesVersions": {
"*": {
"*": ["./src/*"]
}
},
"dependencies": {
"@sa/axios": "workspace:*",
"@sa/utils": "workspace:*"
}
}
@@ -1,11 +0,0 @@
import useBoolean from './use-boolean';
import useLoading from './use-loading';
import useCountDown from './use-count-down';
import useContext from './use-context';
import useSvgIconRender from './use-svg-icon-render';
import useHookTable from './use-table';
export { useBoolean, useLoading, useCountDown, useContext, useSvgIconRender, useHookTable };
export * from './use-signal';
export * from './use-table';
@@ -1,31 +0,0 @@
import { ref } from 'vue';
/**
* Boolean
*
* @param initValue Init value
*/
export default function useBoolean(initValue = false) {
const bool = ref(initValue);
function setBool(value: boolean) {
bool.value = value;
}
function setTrue() {
setBool(true);
}
function setFalse() {
setBool(false);
}
function toggle() {
setBool(!bool.value);
}
return {
bool,
setBool,
setTrue,
setFalse,
toggle
};
}
@@ -1,96 +0,0 @@
import { inject, provide } from 'vue';
import type { InjectionKey } from 'vue';
/**
* Use context
*
* @example
* ```ts
* // there are three vue files: A.vue, B.vue, C.vue, and A.vue is the parent component of B.vue and C.vue
*
* // context.ts
* import { ref } from 'vue';
* import { useContext } from '@sa/hooks';
*
* export const { setupStore, useStore } = useContext('demo', () => {
* const count = ref(0);
*
* function increment() {
* count.value++;
* }
*
* function decrement() {
* count.value--;
* }
*
* return {
* count,
* increment,
* decrement
* };
* })
* ``` // A.vue
* ```vue
* <template>
* <div>A</div>
* </template>
* <script setup lang="ts">
* import { setupStore } from './context';
*
* setupStore();
* // const { increment } = setupStore(); // also can control the store in the parent component
* </script>
* ``` // B.vue
* ```vue
* <template>
* <div>B</div>
* </template>
* <script setup lang="ts">
* import { useStore } from './context';
*
* const { count, increment } = useStore();
* </script>
* ```;
*
* // C.vue is same as B.vue
*
* @param contextName Context name
* @param fn Context function
*/
export default function useContext<T extends (...args: any[]) => any>(contextName: string, fn: T) {
type Context = ReturnType<T>;
const { useProvide, useInject: useStore } = createContext<Context>(contextName);
function setupStore(...args: Parameters<T>) {
const context: Context = fn(...args);
return useProvide(context);
}
return {
/** Setup store in the parent component */
setupStore,
/** Use store in the child component */
useStore
};
}
/** Create context */
function createContext<T>(contextName: string) {
const injectKey: InjectionKey<T> = Symbol(contextName);
function useProvide(context: T) {
provide(injectKey, context);
return context;
}
function useInject() {
return inject(injectKey) as T;
}
return {
useProvide,
useInject
};
}
@@ -1,49 +0,0 @@
import { computed, onScopeDispose, ref } from 'vue';
import { useRafFn } from '@vueuse/core';
/**
* count down
*
* @param seconds - count down seconds
*/
export default function useCountDown(seconds: number) {
const FPS_PER_SECOND = 60;
const fps = ref(0);
const count = computed(() => Math.ceil(fps.value / FPS_PER_SECOND));
const isCounting = computed(() => fps.value > 0);
const { pause, resume } = useRafFn(
() => {
if (fps.value > 0) {
fps.value -= 1;
} else {
pause();
}
},
{ immediate: false }
);
function start(updateSeconds: number = seconds) {
fps.value = FPS_PER_SECOND * updateSeconds;
resume();
}
function stop() {
fps.value = 0;
pause();
}
onScopeDispose(() => {
pause();
});
return {
count,
isCounting,
start,
stop
};
}
@@ -1,16 +0,0 @@
import useBoolean from './use-boolean';
/**
* Loading
*
* @param initValue Init value
*/
export default function useLoading(initValue = false) {
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(initValue);
return {
loading,
startLoading,
endLoading
};
}
@@ -1,67 +0,0 @@
import { ref } from 'vue';
import type { Ref } from 'vue';
import { createFlatRequest } from '@sa/axios';
import type { AxiosError, CreateAxiosDefaults, CustomAxiosRequestConfig, MappedType, RequestOption, ResponseType } from '@sa/axios';
import useLoading from './use-loading';
export type HookRequestInstanceResponseSuccessData<T = any> = {
data: Ref<T>;
error: Ref<null>;
};
export type HookRequestInstanceResponseFailData<ResponseData = any> = {
data: Ref<null>;
error: Ref<AxiosError<ResponseData>>;
};
export type HookRequestInstanceResponseData<T = any, ResponseData = any> = {
loading: Ref<boolean>;
} & (HookRequestInstanceResponseSuccessData<T> | HookRequestInstanceResponseFailData<ResponseData>);
export interface HookRequestInstance<ResponseData = any> {
<T = any, R extends ResponseType = 'json'>(config: CustomAxiosRequestConfig): HookRequestInstanceResponseData<MappedType<R, T>, ResponseData>;
cancelRequest: (requestId: string) => void;
cancelAllRequest: () => void;
}
/**
* create a hook request instance
*
* @param axiosConfig
* @param options
*/
export default function createHookRequest<ResponseData = any>(axiosConfig?: CreateAxiosDefaults, options?: Partial<RequestOption<ResponseData>>) {
const request = createFlatRequest<ResponseData>(axiosConfig, options);
const hookRequest: HookRequestInstance<ResponseData> = function hookRequest<T = any, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig
) {
const { loading, startLoading, endLoading } = useLoading();
const data = ref<MappedType<R, T> | null>(null) as Ref<MappedType<R, T>>;
const error = ref<AxiosError<ResponseData> | null>(null) as Ref<AxiosError<ResponseData> | null>;
startLoading();
request(config).then(res => {
if (res.data) {
data.value = res.data;
} else {
error.value = res.error;
}
endLoading();
});
return {
loading,
data,
error
};
} as HookRequestInstance<ResponseData>;
hookRequest.cancelRequest = request.cancelRequest;
hookRequest.cancelAllRequest = request.cancelAllRequest;
return hookRequest;
}
@@ -1,134 +0,0 @@
import { computed, ref, shallowRef, triggerRef } from 'vue';
import type { ComputedGetter, DebuggerOptions, Ref, ShallowRef, WritableComputedOptions, WritableComputedRef } from 'vue';
type Updater<T> = (value: T) => T;
type Mutator<T> = (value: T) => void;
/**
* Signal is a reactive value that can be set, updated or mutated
*
* @example
* ```ts
* const count = useSignal(0);
*
* // `watchEffect`
* watchEffect(() => {
* console.log(count());
* });
*
* // watch
* watch(count, value => {
* console.log(value);
* });
*
* // useComputed
* const double = useComputed(() => count() * 2);
* const writeableDouble = useComputed({
* get: () => count() * 2,
* set: value => count.set(value / 2)
* });
* ```
*/
export interface Signal<T> {
(): Readonly<T>;
/**
* Set the value of the signal
*
* It recommend use `set` for primitive values
*
* @param value
*/
set(value: T): void;
/**
* Update the value of the signal using an updater function
*
* It recommend use `update` for non-primitive values, only the first level of the object will be reactive.
*
* @param updater
*/
update(updater: Updater<T>): void;
/**
* Mutate the value of the signal using a mutator function
*
* this action will call `triggerRef`, so the value will be tracked on `watchEffect`.
*
* It recommend use `mutate` for non-primitive values, all levels of the object will be reactive.
*
* @param mutator
*/
mutate(mutator: Mutator<T>): void;
/**
* Get the reference of the signal
*
* Sometimes it can be useful to make `v-model` work with the signal
*
* ```vue
* <template>
* <input v-model="model.count" />
* </template>;
*
* <script setup lang="ts">
* const state = useSignal({ count: 0 }, { useRef: true });
*
* const model = state.getRef();
* </script>
* ```
*/
getRef(): Readonly<ShallowRef<Readonly<T>>>;
}
export interface ReadonlySignal<T> {
(): Readonly<T>;
}
export interface SignalOptions {
/**
* Whether to use `ref` to store the value
*
* @default false use `sharedRef` to store the value
*/
useRef?: boolean;
}
export function useSignal<T>(initialValue: T, options?: SignalOptions): Signal<T> {
const { useRef } = options || {};
const state = useRef ? (ref(initialValue) as Ref<T>) : shallowRef(initialValue);
return createSignal(state);
}
export function useComputed<T>(getter: ComputedGetter<T>, debugOptions?: DebuggerOptions): ReadonlySignal<T>;
export function useComputed<T>(options: WritableComputedOptions<T>, debugOptions?: DebuggerOptions): Signal<T>;
export function useComputed<T>(getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>, debugOptions?: DebuggerOptions) {
const isGetter = typeof getterOrOptions === 'function';
const computedValue = computed(getterOrOptions as any, debugOptions);
if (isGetter) {
return () => computedValue.value as ReadonlySignal<T>;
}
return createSignal(computedValue);
}
function createSignal<T>(state: ShallowRef<T> | WritableComputedRef<T>): Signal<T> {
const signal = () => state.value;
signal.set = (value: T) => {
state.value = value;
};
signal.update = (updater: Updater<T>) => {
state.value = updater(state.value);
};
signal.mutate = (mutator: Mutator<T>) => {
mutator(state.value);
triggerRef(state);
};
signal.getRef = () => state as Readonly<ShallowRef<Readonly<T>>>;
return signal;
}
@@ -1,50 +0,0 @@
import { h } from 'vue';
import type { Component } from 'vue';
/**
* Svg icon render hook
*
* @param SvgIcon Svg icon component
*/
export default function useSvgIconRender(SvgIcon: Component) {
interface IconConfig {
/** Iconify icon name */
icon?: string;
/** Local icon name */
localIcon?: string;
/** Icon color */
color?: string;
/** Icon size */
fontSize?: number;
}
type IconStyle = Partial<Pick<CSSStyleDeclaration, 'color' | 'fontSize'>>;
/**
* Svg icon VNode
*
* @param config
*/
const SvgIconVNode = (config: IconConfig) => {
const { color, fontSize, icon, localIcon } = config;
const style: IconStyle = {};
if (color) {
style.color = color;
}
if (fontSize) {
style.fontSize = `${fontSize}px`;
}
if (!icon && !localIcon) {
return undefined;
}
return () => h(SvgIcon, { icon, localIcon, style });
};
return {
SvgIconVNode
};
}
@@ -1,153 +0,0 @@
import { computed, reactive, ref } from 'vue';
import type { Ref } from 'vue';
import { jsonClone } from '@sa/utils';
import useBoolean from './use-boolean';
import useLoading from './use-loading';
export type MaybePromise<T> = T | Promise<T>;
export type ApiFn = (args: any) => Promise<unknown>;
export type TableColumnCheck = {
key: string;
title: string;
checked: boolean;
};
export type TableDataWithIndex<T> = T & { index: number };
export type TransformedData<T> = {
data: TableDataWithIndex<T>[];
pageNum: number;
pageSize: number;
total: number;
};
export type Transformer<T, Response> = (response: Response) => TransformedData<T>;
export type TableConfig<A extends ApiFn, T, C> = {
/** api function to get table data */
apiFn: A;
/** api params */
apiParams?: Parameters<A>[0];
/** transform api response to table data */
transformer: Transformer<T, Awaited<ReturnType<A>>>;
/** columns factory */
columns: () => C[];
/**
* get column checks
*
* @param columns
*/
getColumnChecks: (columns: C[]) => TableColumnCheck[];
/**
* get columns
*
* @param columns
*/
getColumns: (columns: C[], checks: TableColumnCheck[]) => C[];
/**
* callback when response fetched
*
* @param transformed transformed data
*/
onFetched?: (transformed: TransformedData<T>) => MaybePromise<void>;
/**
* whether to get data immediately
*
* @default true
*/
immediate?: boolean;
};
export default function useHookTable<A extends ApiFn, T, C>(config: TableConfig<A, T, C>) {
const { loading, startLoading, endLoading } = useLoading();
const { bool: empty, setBool: setEmpty } = useBoolean();
const { apiFn, apiParams, transformer, immediate = true, getColumnChecks, getColumns } = config;
const searchParams: NonNullable<Parameters<A>[0]> = reactive(jsonClone({ ...apiParams }));
const allColumns = ref(config.columns()) as Ref<C[]>;
const data: Ref<TableDataWithIndex<T>[]> = ref([]);
const columnChecks: Ref<TableColumnCheck[]> = ref(getColumnChecks(config.columns()));
const columns = computed(() => getColumns(allColumns.value, columnChecks.value));
function reloadColumns() {
allColumns.value = config.columns();
const checkMap = new Map(columnChecks.value.map(col => [col.key, col.checked]));
const defaultChecks = getColumnChecks(allColumns.value);
columnChecks.value = defaultChecks.map(col => ({
...col,
checked: checkMap.get(col.key) ?? col.checked
}));
}
async function getData() {
startLoading();
const formattedParams = formatSearchParams(searchParams);
const response = await apiFn(formattedParams);
const transformed = transformer(response as Awaited<ReturnType<A>>);
data.value = transformed.data;
setEmpty(transformed.data.length === 0);
await config.onFetched?.(transformed);
endLoading();
}
function formatSearchParams(params: Record<string, unknown>) {
const formattedParams: Record<string, unknown> = {};
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formattedParams[key] = value;
}
});
return formattedParams;
}
/**
* update search params
*
* @param params
*/
function updateSearchParams(params: Partial<Parameters<A>[0]>) {
Object.assign(searchParams, params);
}
/** reset search params */
function resetSearchParams() {
Object.assign(searchParams, jsonClone(apiParams));
getData();
}
if (immediate) {
getData();
}
return {
loading,
empty,
data,
columns,
columnChecks,
reloadColumns,
getData,
searchParams,
updateSearchParams,
resetSearchParams
};
}
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": ["DOM", "ESNext"],
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"types": ["node"],
"strict": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
@@ -1,19 +0,0 @@
{
"name": "@sa/materials",
"version": "1.0.5",
"exports": {
".": "./src/index.ts"
},
"typesVersions": {
"*": {
"*": ["./src/*"]
}
},
"dependencies": {
"@sa/utils": "workspace:*",
"simplebar-vue": "2.3.5"
},
"devDependencies": {
"typed-css-modules": "0.9.1"
}
}
@@ -1,6 +0,0 @@
import AdminLayout, { LAYOUT_MAX_Z_INDEX, LAYOUT_SCROLL_EL_ID } from './libs/admin-layout';
import PageTab from './libs/page-tab';
import SimpleScrollbar from './libs/simple-scrollbar';
export { AdminLayout, LAYOUT_SCROLL_EL_ID, LAYOUT_MAX_Z_INDEX, PageTab, SimpleScrollbar };
export * from './types';
@@ -1,63 +0,0 @@
/* @type */
.layout-header,
.layout-header-placement {
height: var(--soy-header-height);
}
.layout-header {
z-index: var(--soy-header-z-index);
}
.layout-tab {
top: var(--soy-header-height);
height: var(--soy-tab-height);
z-index: var(--soy-tab-z-index);
}
.layout-tab-placement {
height: var(--soy-tab-height);
}
.layout-sider {
width: var(--soy-sider-width);
z-index: var(--soy-sider-z-index);
}
.layout-mobile-sider {
z-index: var(--soy-sider-z-index);
}
.layout-mobile-sider-mask {
z-index: var(--soy-mobile-sider-z-index);
}
.layout-sider_collapsed {
width: var(--soy-sider-collapsed-width);
z-index: var(--soy-sider-z-index);
}
.layout-footer,
.layout-footer-placement {
height: var(--soy-footer-height);
}
.layout-footer {
z-index: var(--soy-footer-z-index);
}
.left-gap {
padding-left: var(--soy-sider-width);
}
.left-gap_collapsed {
padding-left: var(--soy-sider-collapsed-width);
}
.sider-padding-top {
padding-top: var(--soy-header-height);
}
.sider-padding-bottom {
padding-bottom: var(--soy-footer-height);
}
@@ -1,18 +0,0 @@
declare const styles: {
readonly 'layout-header': string;
readonly 'layout-header-placement': string;
readonly 'layout-tab': string;
readonly 'layout-tab-placement': string;
readonly 'layout-sider': string;
readonly 'layout-mobile-sider': string;
readonly 'layout-mobile-sider-mask': string;
readonly 'layout-sider_collapsed': string;
readonly 'layout-footer': string;
readonly 'layout-footer-placement': string;
readonly 'left-gap': string;
readonly 'left-gap_collapsed': string;
readonly 'sider-padding-top': string;
readonly 'sider-padding-bottom': string;
};
export default styles;
@@ -1,5 +0,0 @@
import AdminLayout from './index.vue';
import { LAYOUT_MAX_Z_INDEX, LAYOUT_SCROLL_EL_ID } from './shared';
export default AdminLayout;
export { LAYOUT_SCROLL_EL_ID, LAYOUT_MAX_Z_INDEX };
@@ -1,203 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { AdminLayoutProps } from '../../types';
import { LAYOUT_MAX_Z_INDEX, LAYOUT_SCROLL_EL_ID, createLayoutCssVars } from './shared';
import style from './index.module.css';
defineOptions({
name: 'AdminLayout'
});
const props = withDefaults(defineProps<AdminLayoutProps>(), {
mode: 'vertical',
scrollMode: 'content',
scrollElId: LAYOUT_SCROLL_EL_ID,
commonClass: 'transition-all-300',
fixedTop: true,
maxZIndex: LAYOUT_MAX_Z_INDEX,
headerVisible: true,
headerHeight: 56,
tabVisible: true,
tabHeight: 48,
siderVisible: true,
siderCollapse: false,
siderWidth: 220,
siderCollapsedWidth: 64,
footerVisible: true,
footerHeight: 48,
rightFooter: false
});
interface Emits {
/** Update siderCollapse */
(e: 'update:siderCollapse', collapse: boolean): void;
}
const emit = defineEmits<Emits>();
type SlotFn = (props?: Record<string, unknown>) => any;
type Slots = {
/** Main */
default?: SlotFn;
/** Header */
header?: SlotFn;
/** Tab */
tab?: SlotFn;
/** Sider */
sider?: SlotFn;
/** Footer */
footer?: SlotFn;
};
const slots = defineSlots<Slots>();
const cssVars = computed(() => createLayoutCssVars(props));
// config visible
const showHeader = computed(() => Boolean(slots.header) && props.headerVisible);
const showTab = computed(() => Boolean(slots.tab) && props.tabVisible);
const showSider = computed(() => !props.isMobile && Boolean(slots.sider) && props.siderVisible);
const showMobileSider = computed(() => props.isMobile && Boolean(slots.sider) && props.siderVisible);
const showFooter = computed(() => Boolean(slots.footer) && props.footerVisible);
// scroll mode
const isWrapperScroll = computed(() => props.scrollMode === 'wrapper');
const isContentScroll = computed(() => props.scrollMode === 'content');
// layout direction
const isVertical = computed(() => props.mode === 'vertical');
const isHorizontal = computed(() => props.mode === 'horizontal');
const fixedHeaderAndTab = computed(() => props.fixedTop || (isHorizontal.value && isWrapperScroll.value));
// css
const leftGapClass = computed(() => {
if (!props.fullContent && showSider.value) {
return props.siderCollapse ? style['left-gap_collapsed'] : style['left-gap'];
}
return '';
});
const headerLeftGapClass = computed(() => (isVertical.value ? leftGapClass.value : ''));
const footerLeftGapClass = computed(() => {
const condition1 = isVertical.value;
const condition2 = isHorizontal.value && isWrapperScroll.value && !props.fixedFooter;
const condition3 = Boolean(isHorizontal.value && props.rightFooter);
if (condition1 || condition2 || condition3) {
return leftGapClass.value;
}
return '';
});
const siderPaddingClass = computed(() => {
let cls = '';
if (showHeader.value && !headerLeftGapClass.value) {
cls += style['sider-padding-top'];
}
if (showFooter.value && !footerLeftGapClass.value) {
cls += ` ${style['sider-padding-bottom']}`;
}
return cls;
});
function handleClickMask() {
emit('update:siderCollapse', true);
}
</script>
<template>
<div class="relative h-full" :class="[commonClass]" :style="cssVars">
<div
:id="isWrapperScroll ? scrollElId : undefined"
class="h-full flex flex-col"
:class="[commonClass, scrollWrapperClass, { 'overflow-y-auto': isWrapperScroll }]"
>
<!-- Header -->
<template v-if="showHeader">
<header
v-show="!fullContent"
class="flex-shrink-0"
:class="[style['layout-header'], commonClass, headerClass, headerLeftGapClass, { 'absolute top-0 left-0 w-full': fixedHeaderAndTab }]"
>
<slot name="header"></slot>
</header>
<div v-show="!fullContent && fixedHeaderAndTab" class="flex-shrink-0 overflow-hidden" :class="[style['layout-header-placement']]"></div>
</template>
<!-- Tab -->
<template v-if="showTab">
<div
class="flex-shrink-0"
:class="[
style['layout-tab'],
commonClass,
tabClass,
{ 'top-0!': fullContent || !showHeader },
leftGapClass,
{ 'absolute left-0 w-full': fixedHeaderAndTab }
]"
>
<slot name="tab"></slot>
</div>
<div v-show="fullContent || fixedHeaderAndTab" class="flex-shrink-0 overflow-hidden" :class="[style['layout-tab-placement']]"></div>
</template>
<!-- Sider -->
<template v-if="showSider">
<aside
v-show="!fullContent"
class="absolute left-0 top-0 h-full"
:class="[commonClass, siderClass, siderPaddingClass, siderCollapse ? style['layout-sider_collapsed'] : style['layout-sider']]"
>
<slot name="sider"></slot>
</aside>
</template>
<!-- Mobile Sider -->
<template v-if="showMobileSider">
<aside
class="absolute left-0 top-0 h-full w-0 bg-white"
:class="[commonClass, mobileSiderClass, style['layout-mobile-sider'], siderCollapse ? 'overflow-hidden' : style['layout-sider']]"
>
<slot name="sider"></slot>
</aside>
<div
v-show="!siderCollapse"
class="absolute left-0 top-0 h-full w-full bg-[rgba(0,0,0,0.2)]"
:class="[style['layout-mobile-sider-mask']]"
@click="handleClickMask"
></div>
</template>
<!-- Main Content -->
<main
:id="isContentScroll ? scrollElId : undefined"
class="flex flex-col flex-grow"
:class="[commonClass, contentClass, leftGapClass, { 'overflow-y-auto': isContentScroll }]"
>
<slot></slot>
</main>
<!-- Footer -->
<template v-if="showFooter">
<footer
v-show="!fullContent"
class="flex-shrink-0"
:class="[style['layout-footer'], commonClass, footerClass, footerLeftGapClass, { 'absolute left-0 bottom-0 w-full': fixedFooter }]"
>
<slot name="footer"></slot>
</footer>
<div v-show="!fullContent && fixedFooter" class="flex-shrink-0 overflow-hidden" :class="[style['layout-footer-placement']]"></div>
</template>
</div>
</div>
</template>
<style scoped></style>
@@ -1,59 +0,0 @@
import type { AdminLayoutProps, LayoutCssVars, LayoutCssVarsProps } from '../../types';
/** The id of the scroll element of the layout */
export const LAYOUT_SCROLL_EL_ID = '__SCROLL_EL_ID__';
/** The max z-index of the layout */
export const LAYOUT_MAX_Z_INDEX = 100;
/**
* Create layout css vars by css vars props
*
* @param props Css vars props
*/
function createLayoutCssVarsByCssVarsProps(props: LayoutCssVarsProps) {
const cssVars: LayoutCssVars = {
'--soy-header-height': `${props.headerHeight}px`,
'--soy-header-z-index': props.headerZIndex,
'--soy-tab-height': `${props.tabHeight}px`,
'--soy-tab-z-index': props.tabZIndex,
'--soy-sider-width': `${props.siderWidth}px`,
'--soy-sider-collapsed-width': `${props.siderCollapsedWidth}px`,
'--soy-sider-z-index': props.siderZIndex,
'--soy-mobile-sider-z-index': props.mobileSiderZIndex,
'--soy-footer-height': `${props.footerHeight}px`,
'--soy-footer-z-index': props.footerZIndex
};
return cssVars;
}
/**
* Create layout css vars
*
* @param props
*/
export function createLayoutCssVars(props: AdminLayoutProps) {
const { mode, isMobile, maxZIndex = LAYOUT_MAX_Z_INDEX, headerHeight, tabHeight, siderWidth, siderCollapsedWidth, footerHeight } = props;
const headerZIndex = maxZIndex - 3;
const tabZIndex = maxZIndex - 5;
const siderZIndex = mode === 'vertical' || isMobile ? maxZIndex - 1 : maxZIndex - 4;
const mobileSiderZIndex = isMobile ? maxZIndex - 2 : 0;
const footerZIndex = maxZIndex - 5;
const cssProps: LayoutCssVarsProps = {
headerHeight,
headerZIndex,
tabHeight,
tabZIndex,
siderWidth,
siderZIndex,
mobileSiderZIndex,
siderCollapsedWidth,
footerHeight,
footerZIndex
};
return createLayoutCssVarsByCssVarsProps(cssProps);
}

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