修复了一些问题,升级了 gvsdsdk

This commit is contained in:
TermiNexus committed 2026-07-04 21:50:35 +08:00
1 parent a77df5da0f
commit e5e4e67784
252 files changed
+13271 -2760

No files matched your search

+269
View File
@@ -0,0 +1,269 @@
# SAAS 架构概览
> 面向技术决策会议的架构说明文档。本文档聚焦 SAAS 多租户-公司-部门-用户-角色架构设计、当前实现状态与未来发展方向。
---
## 1. 项目定位
**GVS-DSDK** 是一套面向中小企业(SMB)的 SAAS 化企业管理平台,包含:
- **多租户 SAAS 框架**:支持租户入驻、审核、隔离
- **企业资源管理(GERP)**:组织、角色、权限、菜单、工单、资产、文档
- **三大业务引擎**:分佣引擎、支付网关、统计引擎
- **分布式通信底座(MSYNC)**:服务注册、发现、分布式事务
## 2. 技术栈
| 层 | 技术 |
|---|---|
| 后端框架 | Django 4.2+ / DRF / SimpleJWT |
| 异步任务 | Celery + Redis(可降级同步) |
| 实时通信 | Socket.IO(python-socketio) |
| 数据库 | MySQL 5.7+(7 库分库路由) |
| 缓存 | Redis(可降级 locmem) |
| 安全 | bcrypt / AES-256-GCM / JWT |
| 前端 | 原生 HTML/JS + 自研 NeoUI/GERPUI 组件 |
| 部署 | Gunicorn + Nginx 反向代理 |
## 3. 双包架构
```
┌─────────────────────────────────────────────────────┐
│ d:\SMANAGE(项目根) │
│ │
│ ┌──────────────────────┐ ┌──────────────────────┐│
│ │ gvsds │ │ gvsdsdk ││
│ │ 主服务器(业务层) │◄─┤ 分布式 SDK(薄封装)││
│ │ - Django 项目 │ │ - 协议契约层 ││
│ │ - 14 个业务 App │ │ - 惰性加载 ││
│ │ - 完整业务实现 │ │ - PascalCase 协议 ││
│ └──────────────────────┘ └──────────────────────┘│
│ ▲ ▲ │
│ │ │ │
│ ┌──────────────────────┐ ┌──────────────────────┐│
│ │ ATestCServer │ │ TempLate/ ││
│ │ 子服务器示例 │ │ 前端模板资源 ││
│ │ 通过 SDK 接入 │ │ GERP/AKS/MSOC 等 ││
│ └──────────────────────┘ └──────────────────────┘│
└─────────────────────────────────────────────────────┘
```
### 3.1 `gvsds` — 主服务器
- 14 个 Django App:`core / gerp / ugm / crb / invite / ccgo / sve / t4 / mc / doc / api / msync / payment / commission / stats`
- 7 库分库路由:`Users / GerpCore / Plans / Tasks / T4PC / Document / Minecraft`
- 6 个自定义中间件:IP 过滤、域名注入、当前角色注入、服务认证、攻击检测、错误日志
### 3.2 `gvsdsdk` — 分布式 SDK
- **薄封装层**:1:1 包装 `gvsds.apps.msync` 原生层,无运行时开销
- **惰性加载(PEP 562)**:90+ 符号按需 import
- **双类导出**:自有实现(无 Django 依赖,子服务器可用)+ 服务端转发(依赖 Django ORM)
- **三大引擎**:`CommissionEngine`(分佣)、`PaymentManager`(支付)、`TransactionManager`(分布式事务)
### 3.3 主从通信机制
- **版本校验**:启动时 `gvsdsdk.check_min_version('1.0.0')`,主从必须一致
- **Socket.IO 协议**:20+ 事件,全程 PascalCase 字段
- **支付网关自动注册**:`gvsdsdk/apps.py` 的 `ready()` 钩子
---
## 4. SAAS 多租户架构(核心)
### 4.1 组织四层架构
```
┌──────────────────────────────────────────────────────────┐
│ 第 4 层:鉴权规则层(运行时) │
│ 操作权限 (RBAC) 数据权限 (ABAC + RoleScope) │
├──────────────────────────────────────────────────────────┤
│ 第 3 层:角色权限载体层 │
│ Role + UserRole + RolePermission │
├──────────────────────────────────────────────────────────┤
│ 第 2 层:岗位模板层(初始化快照) │
│ PositionTemplate + PositionTemplatePermission │
│ CopyToRole 一次性复制,后续独立演进 │
├──────────────────────────────────────────────────────────┤
│ 第 1 层:组织人事层 │
│ Tenant → Company → Department │
│ UserDept(员工-部门多对多) │
│ Position(人事职务标签,与权限解耦) │
│ Workgroup(跨部门工作组) Shop(商铺,归公司) │
└──────────────────────────────────────────────────────────┘
```
### 4.2 数据模型核心表(共 17 张 SAAS 表)
| 分类 | 表名 | 职责 |
|---|---|---|
| **租户** | `Tenant` | 多租户根,含审核状态/到期时间 |
| **组织** | `Company` / `CompanyInfo` / `Department` / `Workgroup` | 公司树/工商信息/部门树/跨部门组 |
| **用户** | `User` / `UserAvatar` / `User2FA` / `UserPending2FA` / `UserLoginLog` / `AccountActivation` / `Password` / `UserIDCard` | 用户身份与认证 |
| **角色权限** | `Role` / `Permission` / `UserRole` / `RolePermission` | RBAC 核心 |
| **岗位模板** | `PositionTemplate` / `PositionTemplatePermission` | 权限初始化快照 |
| **人事标签** | `Position` / `UserDept` | 职务标签(与权限解耦) |
| **商铺** | `Shop` / `RoleShop` | 多商铺体系 |
| **ABAC** | `UserABAC` | 属性访问控制(补充) |
| **审计** | `AuditLog` / `AbnormalUserLog` | 操作审计 + 攻击溯源 |
### 4.3 关键设计决策
#### (1) Position 与权限**完全解耦**
- 人事职务标签(`Position`)仅用于人事管理,**不直接绑定权限**
- 调岗/升职只修改 `UserDept.PositionUUID` 与 `UserRole`,权限只通过 `UserRole → RolePermission → Permission` 走
- 规避了业界常见错误:把职务直接绑权限导致调岗时权限树重算
#### (2) PositionTemplate 的"快照复制"机制
- 模板 → 角色 = **一次性复制**(`CopyToRole`),复制后两者无运行时绑定
- 模板调整只影响**之后新建**的角色,已有角色不被动变更
- 避免了"模板改一个字段,全员权限突然变了"的运维灾难
#### (3) UserRole 作为**胖关联表**
`UserRole` 表同时承载三重语义:
- **权限分配**:User ↔ Role 多对多
- **组织归属**:携带 `CompanyUUID / DeptUUID / WorkgroupUUID`
- **数据范围**:`RoleType` 标识"普通成员/部门负责人"
**优势**:同一用户在不同公司/部门可绑不同角色,原生支持 SAAS 强需求
**代价**:UserRole 违反单一职责,未来扩展需谨慎
#### (4) 角色作用域四档数据权限(`RoleScope`)
| 类型 | 判定条件 | 可见范围 |
|---|---|---|
| `admin` | IsSuperuser/IsStaff 或 `admin:super` 权限 | 全公司 |
| `dept_manager` | `UserRole.RoleType=1` 且 `DeptUUID` 非空 | 本部门成员 |
| `workgroup_lead` | `UserRole.RoleType=1` 且 `WorkgroupUUID` 非空 | 本工作组成员 |
| `member` | 其他 | 仅自己 |
### 4.4 运行时鉴权流程
```
HTTP Request
│
├─► JWTAuthentication → User 实例
├─► CurrentRoleMiddleware → request.current_role (Role)
├─► DomainMiddleware → request.current_company_uuid
│
├──► 操作权限校验:PermissionVerifier(permission, attrs, user, request)
│ ├─ ABAC:UserABAC.HierarchicalPermissionIdentifiers ∋ attr?
│ └─ RBAC:RolePermission(角色)→Permission.PermCode ∋ attr?
│ (含请求级缓存,消除 N+1)
│
└──► 数据权限校验:RoleScope.GetUserScope(request)
└─ ApplyScopeToQueryset(qs, scope, table_name)
按 admin/dept_manager/workgroup_lead/member 差异化过滤
```
### 4.5 主键设计
- 全程使用 `BINARY(16)` UUID 主键
- 优势:分布式生成、无 ID 冲突、不暴露顺序信息
- 代价:InnoDB 聚簇索引无序,写入有页分裂开销(可考虑改 UUIDv7)
---
## 5. 三大业务引擎
### 5.1 分佣引擎(`CommissionEngine`)
| 策略 | Type | 说明 |
|---|---|---|
| `PercentageStrategy` | `percentage` | 比例分账,支持保底/封顶 |
| `FixedStrategy` | `fixed` | 固定金额 |
| `TieredStrategy` | `tiered` | 阶梯分账 |
| `RemainderStrategy` | `remainder` | 剩余归平台 |
| `WithdrawalFeeStrategy` | `withdrawal_fee` | 提现手续费 |
支持:规则配置 → 触发分佣 → 预结算 → 邀请链分佣 → 查询统计
### 5.2 支付网关(`PaymentManager`)
| 渠道 | 支付方式 | 转账 |
|---|---|---|
| 微信支付 | JSAPI/Native/H5/小程序 | 支持 |
| 支付宝 | APP/H5/Native | 支持 |
| 银行卡 | APP/H5/Native | 仅 bank 类型 |
支持:网关配置 → 支付/查询/退款/转账 → 回调处理
### 5.3 统计引擎(`stats`)
- Celery 定时任务:每日重置 / 每月重置 / 排行榜构建 / 过期清理
- 按角色差异化仪表盘
---
## 6. 当前实现状态
### 6.1 已完成
- ✅ 14 个业务 App 完整实现
- ✅ 多租户入驻审核工作流
- ✅ RBAC + ABAC 混合鉴权
- ✅ 分佣引擎 + 支付网关 + 统计引擎
- ✅ MSYNC 分布式通信(注册/发现/OGM/Saga 事务)
- ✅ 文件分片上传 + 资产管理
- ✅ 操作审计日志 + 攻击检测
### 6.2 已知技术债
| 优先级 | 问题 | 影响 |
|---|---|---|
| 高 | Role/Permission 模型在 gvsds 与 gvsdsdk 双份定义 | 字段漂移风险 |
| 高 | 部分表(User/Dept/Workgroup/Shop)无 TenantUUID | 跨租户查询需 JOIN,删除租户困难 |
| 中 | ABAC 实现本质是 RBAC(字符串集合) | 名实不符,扩展受限 |
| 中 | 数据权限硬编码 `if table_name == 'X'` | 违反开闭原则 |
| 中 | 缺乏软删除机制 | 误删不可恢复 |
| 低 | UUID 主键无序导致索引膨胀 | 大数据量写入性能下降 |
| 低 | 根 README.md 仍为 Flask 时代文档 | 新人易误读 |
---
## 7. 未来发展方向(会议讨论建议项)
### 7.1 短期(1-3 个月)
1. **统一 Role/Permission 模型**:让 gerp 直接复用 gvsdsdk 的模型定义,消除双份漂移
2. **补全 TenantUUID 字段**:所有租户语义表直接打 `TenantUUID`,简化跨租户查询与租户删除
3. **数据权限策略化**:用策略模式替代 `if table_name == 'X'`,每个模型注册自己的 `ScopeFilter`
4. **更新根 README.md**:反映当前 Django 架构
### 7.2 中期(3-6 个月)
1. **重构 ABAC 为真正的属性访问控制**:支持时间/IP/资源所有者/字段级权限
2. **引入软删除机制**:`DeletedAt` + `DeletedByUUID`,配合审计日志实现数据恢复
3. **支付网关扩展**:增加 PayPal/Stripe 适配(出海场景)
4. **租户配额管理**:用户数/数据量/API 调用次数配额
### 7.3 长期(6+ 个月)
1. **拆分协议层**:把 gvsdsdk 拆为 `gvsds-protocol`(纯协议)+ `gvsds-business`(业务能力),主从各自依赖
2. **改用 UUIDv7 或 Snowflake**:解决主键无序问题
3. **多区域部署**:基于 MSYNC 实现跨地域多活
4. **微服务化拆分**:gerp/payment/commission/stats 可独立部署
---
## 8. 关键架构原则
1. **PascalCase 全链路一致**:从数据库字段 → Python 类 → Socket.IO 事件 → 前端,无转换层
2. **优雅降级**:Redis/Celery/网关依赖缺失时自动降级,开发环境零依赖启动
3. **配置外置**:`app.ini` + `secrets.ini` + 环境变量三层配置,支持 12-factor 部署
4. **薄封装 SDK**:协议契约层与业务实现层分离,主从可独立演进
5. **Position 与权限解耦**:人事标签不绑权限,避免调岗重算
---
## 9. 联系与维护
- **主仓库**:`d:\SMANAGE`
- **SDK 包**:`gvsdsdk/`(pyproject.toml v1.0.0)
- **子服务器示例**:`ATestCServer/`
- **设计文档**:`docs/commission_engine.md` / `docs/payment_gateway.md` / `docs/stats_engine.md`
---
**本架构文档用于技术决策会议参考,详细 API 与字段定义见 [gvsdsdk/API.md](file:///d:/SMANAGE/gvsdsdk/API.md) 与 [gvsdsdk/README.md](file:///d:/SMANAGE/gvsdsdk/README.md)。**
+6 -8
View File
@@ -6,12 +6,9 @@
<title>GERP 管理系统</title>
<link rel="Shortcut Icon" href="{{ url_for('UGM.UGM_Query_GetELTUserAvatar') }}"/>
<meta name="viewport" id="viewport" content="width=device-width, user-scalable=yes, minimum-scale=0.4, initial-scale=0.8" />
<!-- ResourceShare -->
<!--script type="text/javascript" src="https://cdn.gvsds.com/ResourceShare/ResourceShare-1.3.7.5.min.js"></script-->
<!--script type="text/javascript" src="/assets/js/ResourceShare.js"></script-->
<!--[if lt IE 9]>
<resource-share type="script" src="https://cdn.staticfile.org/html5shiv/r29/html5.min.js"></resource-share>
<resource-share type="script" src="https://cdn.staticfile.org/respond.js/1.4.2/respond.min.js"></resource-share>
<script type="text/javascript" src="https://cdn.staticfile.org/html5shiv/r29/html5.min.js"></script>
<script type="text/javascript" src="https://cdn.staticfile.org/respond.js/1.4.2/respond.min.js"></script>
<![endif]-->
<!-- CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
@@ -22,16 +19,17 @@
<link rel="stylesheet" href="/assets/css/Debug.css">
<link rel="stylesheet" href="/assets/gerp/GERPUI/GERPLayui.css">
<link rel="stylesheet" href="/assets/js/core/NeoUI/NeoToast/style.css">
<!-- JS - 全部在模板内容之前加载,确保内联脚本可用 -->
<!-- JS - 主题同步需在所有资源加载前执行以避免 FOUC -->
<script src="/assets/gerp/GERPThemeSync.js"></script>
<script src="/assets/gerp/GERP.js?v=20260625b"></script>
<!-- 其余 JS 按序加载,依赖顺序由声明顺序保证 -->
<script src="/assets/gerp/GERP.js?v=20260628e"></script>
<script src="/assets/gerp/GERPUI/GERPContextMenu.js"></script>
<script src="/assets/gerp/GERPUI/GERPUI.js"></script>
<script src="/assets/gerp/GERPUI/GERPLayui.js"></script>
<script src="/assets/js/core/NeoUI/NeoToast/script.js"></script>
<script src="/assets/gerp/FormEngine.js"></script>
<script src="/assets/gerp/TableEngine.js"></script>
<script src="/assets/gerp/DbTableHelper.js"></script>
<script src="/assets/gerp/DbTableHelper.js?v=20260625e"></script>
</head>
<body>
{{ MAIN }}
-1
View File
@@ -1356,7 +1356,6 @@ body.GERPSidebarOpen .GERPSidebar {
/* 页面内容样式(用于 iframe 内的页面) */
.GERPPage {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
+42 -2
View File
@@ -10,7 +10,7 @@
const style = document.createElement('style');
style.id = 'CompanyCommonStyles';
style.textContent = `
.GERPPage { padding: 24px; max-width: 1400px; margin: 0 auto; height: 100%; display: flex; flex-direction: column; box-sizing: border-box; }
.GERPPage { padding: 24px; margin: 0 auto; height: 100%; display: flex; flex-direction: column; box-sizing: border-box; }
.cp-page { padding: 20px; flex: 1; min-height: 0; display: flex; flex-direction: column; box-sizing: border-box; }
.cp-header { display: flex; align-items: center; margin-bottom: 16px; gap: 12px; flex-shrink: 0; }
.cp-header h1 { margin: 0; flex: 1; }
@@ -344,6 +344,12 @@ const CompanyUtils = {
]
});
},
/** 判断当前 URL 是否显式指定 admin_verify=1 参数(特权模式按需启用) */
hasAdminVerifyArg() {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('admin_verify') === '1';
}
};
// ==================== TablePage 基类 ====================
@@ -361,6 +367,7 @@ class CompanyTablePage {
* @param {Function} [config.onView] - 查看按钮回调 (row) => void,设置后显示眼睛按钮
* @param {string} [config.pkField] - 主键字段名,用于 fetchDetail 按字段过滤
* @param {string} [config.container] - 容器选择器,默认 '#TableContainer'
* @param {boolean} [config.adminBadge] - 是否启用特权模式标记(管理员可关闭公司隔离,跨公司查看/操作数据)
*/
constructor(config) {
this.tableName = config.tableName;
@@ -372,24 +379,47 @@ class CompanyTablePage {
this.server = CompanyUtils.getServer();
this.tableInstance = null;
this.drawerClose = null;
// 特权模式:管理员可关闭公司隔离,跨公司查看/操作数据
this.adminBadge = config.adminBadge || false;
this.isAdminMode = false;
CompanyUtils.setPageTitle(this.title);
}
/** 构建带 server 的 FormAPI URL */
/** 构建带 server 与 admin_verify 的 FormAPI URL */
apiUrl(path, params = {}) {
const allParams = {};
if (this.server !== 'gvsds-master') allParams.server = this.server;
if (this.isAdminMode) allParams.admin_verify = '1';
Object.assign(allParams, params);
const qs = new URLSearchParams(allParams).toString();
return qs ? `${path}?${qs}` : path;
}
/** 检查当前用户是否具有管理员特权(超级用户或当前角色拥有 admin:super 权限) */
async checkAdmin() {
try {
const resp = await fetch(this.apiUrl('/API/FormAPI/CheckAdmin'), { credentials: 'include' });
const result = await resp.json();
return result.code === 0 && result.data && result.data.is_admin;
} catch (e) {
return false;
}
}
/** 初始化表格 */
async init() {
const containerEl = document.querySelector(this.container);
if (!containerEl) return;
// 特权模式:仅当 URL 显式指定 admin_verify=1 时才检查管理员特权并启用
// 管理员访问页面默认走普通公司隔离,需手动在 URL 加 ?admin_verify=1 才进入特权模式
if (this.adminBadge) {
this.isAdminMode = CompanyUtils.hasAdminVerifyArg() ? await this.checkAdmin() : false;
const badge = document.getElementById('admin-badge');
if (badge) badge.style.display = this.isAdminMode ? '' : 'none';
}
try {
let columns;
if (this.server === 'gvsds-master') {
@@ -405,8 +435,18 @@ class CompanyTablePage {
}
// 强公司隔离:CompanyUUID 由后端自动注入,前端隐藏输入框
// 同时用 defaultColumns 的 type/options 覆盖动态列(后端 SmallIntegerField 生成 number,需前端补充 select 选项)
const defaultMap = {};
if (this.defaultColumnsFn) {
this.defaultColumnsFn().forEach(c => { defaultMap[c.field] = c; });
}
columns = columns.map(c => {
if (c.field === 'CompanyUUID') { c.editable = false; c.hideInForm = true; }
const def = defaultMap[c.field];
if (def) {
if (def.type) c.type = def.type;
if (def.options) c.options = def.options;
}
return c;
});
+18 -11
View File
@@ -25,6 +25,7 @@ class DbTableHelper {
serverName,
backUrl,
backTitle,
adminVerify = false,
} = options;
if (!tableName) {
@@ -34,12 +35,18 @@ class DbTableHelper {
}
const helper = new DbTableHelper({
container, tableName, tableTitle, apiMode, subProxyUrl, serverName, backUrl, backTitle,
container, tableName, tableTitle, apiMode, subProxyUrl, serverName, backUrl, backTitle, adminVerify,
});
helper._init();
return helper;
}
/** 为 URL 追加 admin_verify 参数(管理员豁免,关闭公司隔离) */
_withAdmin(url) {
if (!this.adminVerify) return url;
return url + (url.includes('?') ? '&' : '?') + 'admin_verify=1';
}
constructor(options) {
Object.assign(this, options);
this._tableInstance = null;
@@ -52,7 +59,7 @@ class DbTableHelper {
try {
let columnsResult;
if (this.apiMode === 'formapi') {
const resp = await fetch(`/API/FormAPI/Columns/${this.tableName}`, { credentials: 'include' });
const resp = await fetch(this._withAdmin(`/API/FormAPI/Columns/${this.tableName}`), { credentials: 'include' });
columnsResult = await resp.json();
} else {
columnsResult = await this._subPost('columns');
@@ -108,18 +115,18 @@ class DbTableHelper {
if (this.apiMode === 'formapi') {
tableOpts.api = {
get: `/API/FormAPI/Get/${this.tableName}`,
add: `/API/FormAPI/Add/${this.tableName}`,
edit: `/API/FormAPI/Modify/${this.tableName}/<id>`,
modify: `/API/FormAPI/ModifyItem/${this.tableName}/<id>`,
delete: `/API/FormAPI/Delete/${this.tableName}/<id>`,
columns: `/API/FormAPI/Columns/${this.tableName}`,
get: this._withAdmin(`/API/FormAPI/Get/${this.tableName}`),
add: this._withAdmin(`/API/FormAPI/Add/${this.tableName}`),
edit: this._withAdmin(`/API/FormAPI/Modify/${this.tableName}/<id>`),
modify: this._withAdmin(`/API/FormAPI/ModifyItem/${this.tableName}/<id>`),
delete: this._withAdmin(`/API/FormAPI/Delete/${this.tableName}/<id>`),
columns: this._withAdmin(`/API/FormAPI/Columns/${this.tableName}`),
};
tableOpts.onRowClick = () => {};
tableOpts.onDelete = () => {};
} else {
tableOpts.api = {
get: `${this.subProxyUrl}?Action=list&Table=${encodeURIComponent(this.tableName)}`,
get: this._withAdmin(`${this.subProxyUrl}?Action=list&Table=${encodeURIComponent(this.tableName)}`),
};
tableOpts.onAdd = () => this._handleAdd();
tableOpts.onEdit = (row) => this._handleEdit(row);
@@ -132,7 +139,7 @@ class DbTableHelper {
// ─── Sub-proxy CRUD ───
async _subPost(action, extraParams) {
const resp = await fetch(this.subProxyUrl, {
const resp = await fetch(this._withAdmin(this.subProxyUrl), {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
@@ -162,7 +169,7 @@ class DbTableHelper {
if (rowElement) rowElement.classList.add('GERPTableRowLoading');
try {
const getUrl = `${this.subProxyUrl}?Action=list&Table=${encodeURIComponent(this.tableName)}&${pk}=${encodeURIComponent(id)}`;
const getUrl = this._withAdmin(`${this.subProxyUrl}?Action=list&Table=${encodeURIComponent(this.tableName)}&${pk}=${encodeURIComponent(id)}`);
const resp = await fetch(getUrl, { credentials: 'include' });
const data = await resp.json();
+143 -25
View File
@@ -15,7 +15,9 @@ class GERP {
this.State = {
CurrentUser: null,
Notifications: [],
Permissions: []
Permissions: [],
RoleScope: null, // {Type, TypeLabel, IsAdmin, IsDeptManager, IsWorkgroupLead, IsMember, DeptUUID, WorkgroupUUID}
IsAdmin: false
};
this.API = {
@@ -69,6 +71,24 @@ class GERP {
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const currentHost = window.location.hostname;
let _eltExpiredShown = false;
let _refreshing = null;
function _tryRefreshToken() {
if (_refreshing) return _refreshing;
_refreshing = originalFetch.call(window, 'https://ugm.gvsds.com/Auth/RefreshToken', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' }
}).then(res => res.json()).then(result => {
_refreshing = null;
return result.code === 0;
}).catch(() => {
_refreshing = null;
return false;
});
return _refreshing;
}
window.fetch = function(url, options = {}) {
if (typeof url === 'string') {
let isSameOrigin = url.startsWith('/API/') || url.startsWith('/');
@@ -90,6 +110,16 @@ class GERP {
}
}
return originalFetch.call(this, url, options).then(response => {
if (!_eltExpiredShown && response.status === 401) {
return _tryRefreshToken().then(ok => {
if (ok) {
return originalFetch.call(this, url, options);
}
_eltExpiredShown = true;
GERP.ShowELTExpiredDialog();
return response;
});
}
if (!_eltExpiredShown && response.redirected) {
try {
const redirectUrl = new URL(response.url);
@@ -99,10 +129,6 @@ class GERP {
}
} catch(e) {}
}
if (!_eltExpiredShown && response.status === 401) {
_eltExpiredShown = true;
GERP.ShowELTExpiredDialog();
}
return response;
});
};
@@ -677,7 +703,11 @@ class GERP {
}
// 初始化菜单数据
InitMenuData() {
async InitMenuData() {
// 先加载角色作用域(用于默认菜单差异化)
if (!this.State.RoleScope) {
await this.LoadRoleScope();
}
fetch('/API/MenuList', { credentials: 'include' })
.then(res => res.json())
.then(result => {
@@ -692,39 +722,127 @@ class GERP {
});
}
// 加载角色作用域(用于前端差异化显示)
async LoadRoleScope() {
try {
const res = await fetch('/API/FormAPI/CheckAdmin', { credentials: 'include' });
const result = await res.json();
if (result.code === 0 && result.data) {
this.State.RoleScope = result.data.role_scope || null;
this.State.IsAdmin = !!(result.data.is_admin || result.data.is_superuser);
}
} catch(e) {
this.State.RoleScope = null;
this.State.IsAdmin = false;
}
}
_getDefaultMenuData() {
return [
const scope = this.State.RoleScope;
const scopeType = scope?.Type || 'member';
const isAdmin = scopeType === 'admin';
const isDeptManager = scopeType === 'dept_manager';
const isWorkgroupLead = scopeType === 'workgroup_lead';
const isOrgManager = isAdmin || isDeptManager || isWorkgroupLead;
// 仪表盘:所有角色都可见
const menus = [
{
Id: 'dashboard',
Title: '仪表盘',
Icon: '<i class="fas fa-chart-line"></i>',
Page: 'pages/dashboard.html'
},
{
Id: 'system',
Title: '系统设置',
Icon: '<i class="fas fa-cog"></i>',
}
];
// 公司资产:所有角色都可见(原"系统设置"改名,资产管理挪入此)
// admin 还包含租户/审计/支付/系统配置等系统层面设置
const companyAssetsChildren = [
{ Id: 'system-assets', Title: '资产管理', Icon: '<i class="fas fa-images"></i>', Page: 'pages/assets/manager.html' }
];
if (isAdmin) {
companyAssetsChildren.push(
{ Id: 'system-tenant', Title: '租户管理', Icon: '<i class="fas fa-layer-group"></i>', Page: 'pages/Company/tenant.html' },
{ Id: 'system-tenant-apply', Title: '租户入驻申请', Icon: '<i class="fas fa-key"></i>', Page: 'pages/system/tenant-create.html' },
{ Id: 'system-tenant-review', Title: '租户审核工作台', Icon: '<i class="fas fa-clipboard-check"></i>', Page: 'pages/system/tenant-review.html' },
{ Id: 'system-audit-log', Title: '操作审计日志', Icon: '<i class="fas fa-history"></i>', Page: 'pages/system/audit-log.html' },
{ Id: 'system-payment-config', Title: '支付网关配置', Icon: '<i class="fas fa-credit-card"></i>', Page: 'pages/system/payment-config.html' },
{ Id: 'system-config', Title: '系统配置', Icon: '<i class="fas fa-tools"></i>', Page: 'pages/system/settings.html' }
);
} else if (isDeptManager || isWorkgroupLead) {
companyAssetsChildren.push(
{ Id: 'system-tenant-apply', Title: '租户入驻申请', Icon: '<i class="fas fa-key"></i>', Page: 'pages/system/tenant-create.html' }
);
}
menus.push({
Id: 'company-assets',
Title: '公司资产',
Icon: '<i class="fas fa-boxes"></i>',
Expanded: false,
Children: companyAssetsChildren
});
// 业务管理:所有角色都可见(销售订单 + 产品管理)
menus.push({
Id: 'business',
Title: '业务管理',
Icon: '<i class="fas fa-briefcase"></i>',
Expanded: false,
Children: [
{ Id: 'sales-orders', Title: '销售订单', Icon: '<i class="fas fa-shopping-cart"></i>', Page: 'pages/sales/orders.html' },
{ Id: 'inventory-products', Title: '产品管理', Icon: '<i class="fas fa-box"></i>', Page: 'pages/inventory/products.html' }
]
});
// 组织管理:admin/dept_manager/workgroup_lead 可见
// 归集公司/部门/工作组/角色/成员/职务等组织相关功能
if (isOrgManager) {
const orgChildren = [];
if (isAdmin) {
orgChildren.push({ Id: 'org-company', Title: '公司管理', Icon: '<i class="fas fa-building"></i>', Page: 'pages/Company/company.html' });
}
orgChildren.push(
{ Id: 'org-department', Title: '部门管理', Icon: '<i class="fas fa-sitemap"></i>', Page: 'pages/Company/department.html' },
{ Id: 'org-workgroup', Title: '工作组管理', Icon: '<i class="fas fa-users-cog"></i>', Page: 'pages/Company/workgroup.html' },
{ Id: 'org-role', Title: '角色管理', Icon: '<i class="fas fa-shield-alt"></i>', Page: 'pages/Company/role.html' }
);
if (isAdmin) {
orgChildren.push({ Id: 'org-role-permissions', Title: '角色权限分配', Icon: '<i class="fas fa-sitemap"></i>', Page: 'pages/system/role-permissions.html' });
}
// 成员管理(pages/system/users.html 内置 role_scope 差异化:admin=用户管理/dept_manager=部门成员/workgroup_lead=工作组成员)
const memberMenuTitle = isAdmin ? '成员管理' :
(isDeptManager ? '部门成员' : '工作组成员');
orgChildren.push(
{ Id: 'org-employee', Title: memberMenuTitle, Icon: '<i class="fas fa-user"></i>', Page: 'pages/system/users.html' },
{ Id: 'org-position', Title: '职务管理', Icon: '<i class="fas fa-id-badge"></i>', Page: 'pages/system/dbtable.html?table=Position&title=职务管理' }
);
menus.push({
Id: 'org',
Title: '组织管理',
Icon: '<i class="fas fa-project-diagram"></i>',
Expanded: false,
Children: [
{ Id: 'system-users', Title: '用户管理', Icon: '<i class="fas fa-user"></i>', Page: 'pages/system/users.html' },
{ Id: 'system-roles', Title: '角色权限', Icon: '<i class="fas fa-shield-alt"></i>', Page: 'pages/system/roles.html' },
{ Id: 'system-positions', Title: '职务管理', Icon: '<i class="fas fa-id-badge"></i>', Page: 'pages/system/dbtable.html?table=Position&title=职务管理' },
{ Id: 'system-config', Title: '系统配置', Icon: '<i class="fas fa-tools"></i>', Page: 'pages/system/config.html' },
{ Id: 'system-assets', Title: '资产管理', Icon: '<i class="fas fa-images"></i>', Page: 'pages/assets/manager.html' },
{ Id: 'system-menu', Title: '菜单管理', Icon: '<i class="fas fa-bars"></i>', Page: 'pages/system/menu.html' }
]
},
{
Children: orgChildren
});
}
// 服务器管理:仅 admin 可见(含菜单编辑器)
if (isAdmin) {
menus.push({
Id: 'server',
Title: '服务器管理',
Icon: '<i class="fas fa-server"></i>',
Expanded: false,
Children: [
{ Id: 'server-database', Title: '数据库管理', Icon: '<i class="fas fa-database"></i>', Page: 'pages/system/database.html' },
{ Id: 'server-msync', Title: 'MSYNC 服务器', Icon: '<i class="fas fa-network-wired"></i>', Page: 'pages/system/msync-servers.html' }
{ Id: 'server-msync', Title: 'MSYNC 服务器', Icon: '<i class="fas fa-network-wired"></i>', Page: 'pages/system/msync-servers.html' },
{ Id: 'server-menu', Title: '菜单编辑器', Icon: '<i class="fas fa-bars"></i>', Page: 'pages/system/menu.html' }
]
}
];
});
}
// 普通组员:仅看到 仪表盘 + 公司资产(个人资料通过顶部头像菜单访问)
return menus;
}
// 打开默认 Tab(仅在主页时)
@@ -1,4 +1,4 @@
<script src="/assets/gerp/CompanyCommon.js?v=20260625d"></script>
<script src="/assets/gerp/CompanyCommon.js?v=20260627c"></script>
<div class="GERPPage">
<div class="cp-page">
@@ -7,6 +7,7 @@
<h1 class="PageTitle">
<i class="fas fa-building icon-primary icon-left-lg"></i>
<span id="page-title">公司详情</span>
<span id="admin-badge" style="display:none;margin-left:8px;padding:2px 10px;border-radius:4px;background:#1e40af;color:#fff;font-size:12px;font-weight:600;"><i class="fas fa-shield-alt"></i> 特权模式</span>
</h1>
<button class="GERPBtn Primary" onclick="handleEdit()" style="margin-left:auto;"><i class="fas fa-edit"></i> 编辑</button>
</div>
@@ -19,6 +20,7 @@
<script>
const currentServer = CompanyUtils.getServer();
const companyUUID = new URLSearchParams(window.location.search).get('CompanyUUID');
let _isAdminMode = false;
CompanyUtils.setPageTitle('公司详情');
@@ -38,11 +40,22 @@
function buildApiUrl(path, params = {}) {
const allParams = {};
if (currentServer !== 'gvsds-master') allParams.server = currentServer;
if (_isAdminMode) allParams.admin_verify = '1';
Object.assign(allParams, params);
const qs = new URLSearchParams(allParams).toString();
return qs ? `${path}?${qs}` : path;
}
async function checkAdmin() {
try {
const resp = await fetch(buildApiUrl('/API/FormAPI/CheckAdmin'), { credentials: 'include' });
const result = await resp.json();
return result.code === 0 && result.data && result.data.is_admin;
} catch (e) {
return false;
}
}
async function init() {
const containerEl = document.getElementById('detailContainer');
@@ -51,6 +64,11 @@
return;
}
// 先检查管理员特权,决定是否关闭公司隔离(仅当 URL 带 admin_verify=1 时启用)
_isAdminMode = CompanyUtils.hasAdminVerifyArg() ? await checkAdmin() : false;
const badge = document.getElementById('admin-badge');
if (badge) badge.style.display = _isAdminMode ? '' : 'none';
try {
const dataResp = await fetch(buildApiUrl('/API/FormAPI/Get/Company', { CompanyUUID: companyUUID, PageSize: '1' }), { credentials: 'include' });
const dataResult = await dataResp.json();
+3 -1
View File
@@ -1,4 +1,4 @@
<script src="/assets/gerp/CompanyCommon.js"></script>
<script src="/assets/gerp/CompanyCommon.js?v=20260627c"></script>
<div class="GERPPage">
<div class="cp-page">
@@ -6,6 +6,7 @@
<h1 class="PageTitle">
<i class="fas fa-building icon-primary icon-left-lg"></i>
<span id="page-title">公司管理</span>
<span id="admin-badge" style="display:none;margin-left:8px;padding:2px 10px;border-radius:4px;background:#1e40af;color:#fff;font-size:12px;font-weight:600;"><i class="fas fa-shield-alt"></i> 特权模式</span>
</h1>
</div>
<div class="cp-container" id="TableContainer"></div>
@@ -17,6 +18,7 @@
tableName: 'Company',
title: '公司管理',
pkField: 'CompanyUUID',
adminBadge: true,
defaultColumns: () => [
{ field: 'CompanyUUID', title: '公司UUID', width: 140, primaryKey: true, editable: false, hideInForm: true },
{ field: 'CompanyName', title: '公司全称', width: 200, type: 'text' },
@@ -1,4 +1,4 @@
<script src="/assets/gerp/CompanyCommon.js?v=20260625d"></script>
<script src="/assets/gerp/CompanyCommon.js?v=20260627c"></script>
<div class="GERPPage">
<div class="cp-page">
@@ -120,7 +120,7 @@
bordered: true,
api: {
get: buildApiUrl('/API/FormAPI/Get/UserRole', { DeptUUID: deptUUID }),
add: buildApiUrl('/API/FormAPI/Add/UserRole'),
add: buildApiUrl('/API/FormAPI/Add/UserRole', { DeptUUID: deptUUID }),
edit: buildApiUrl('/API/FormAPI/Modify/UserRole/<id>'),
modify: buildApiUrl('/API/FormAPI/ModifyItem/UserRole/<id>'),
delete: buildApiUrl('/API/FormAPI/Delete/UserRole/<id>'),
@@ -1,4 +1,4 @@
<script src="/assets/gerp/CompanyCommon.js?v=20260625d"></script>
<script src="/assets/gerp/CompanyCommon.js?v=20260627c"></script>
<div class="GERPPage">
<div class="cp-page">
@@ -67,18 +67,53 @@
currentData = data;
CompanyUtils.setPageTitle(data.UserName || '员工详情');
// 渲染基本信息 + 表格容器
// 并行获取 2FA 信息
let twoFaData = null;
try {
const twoFaResp = await fetch(buildApiUrl('/API/SiteUserManagement/GetUser2FAInfo', { UserUUID: userUUID }), { credentials: 'include' });
const twoFaResult = await twoFaResp.json();
if (twoFaResult.code === 0) twoFaData = twoFaResult.data;
} catch (_) { /* 2FA 查询失败不阻塞页面 */ }
const avatarUrl = buildApiUrl('/Query/GetArgsUserAvatar', { UserUUID: userUUID });
const avatarFallback = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='96'%3E%3Crect fill='%23e5e7eb' width='96' height='96'/%3E%3Ctext x='50%25' y='50%25' font-size='13' fill='%239ca3af' text-anchor='middle' dy='.3em'%3E无头像%3C/text%3E%3C/svg%3E";
const twoFaStatusHtml = twoFaData
? (twoFaData.IsEnabled === 1
? '<span style="color:#166534;">已启用</span>'
: '<span style="color:#991b1b;">未启用</span>')
: '<span style="color:#6b7280;">未配置</span>';
const twoFaVerifiedHtml = twoFaData
? (twoFaData.Verified === 1
? '<span style="color:#166534;">已验证</span>'
: '<span style="color:#991b1b;">未验证</span>')
: '-';
const twoFaLastUsed = twoFaData && twoFaData.LastUsedAt ? CompanyUtils.formatTime(twoFaData.LastUsedAt) : '-';
const twoFaCreateTime = twoFaData && twoFaData.CreateTime ? CompanyUtils.formatTime(twoFaData.CreateTime) : '-';
// 渲染基本信息 + 安全信息 + 表格容器
containerEl.innerHTML = `
<div class="cp-detail-section">
<h3><i class="fas fa-user-circle"></i> 基本信息</h3>
<div style="display:flex;gap:24px;align-items:flex-start;">
<img src="${avatarUrl}" alt="头像" style="width:96px;height:96px;border-radius:50%;object-fit:cover;border:2px solid #e5e7eb;flex-shrink:0;" onerror="this.onerror=null;this.src='${avatarFallback}'" />
<div class="cp-info-grid" style="flex:1;">
<div class="cp-info-item"><div class="label">用户名</div><div class="value">${CompanyUtils.esc(data.UserName) || '-'}</div></div>
<div class="cp-info-item"><div class="label">真实姓名</div><div class="value">${CompanyUtils.esc(data.UserRealName) || '-'}</div></div>
<div class="cp-info-item"><div class="label">邮箱</div><div class="value">${CompanyUtils.esc(data.UserEmail) || '-'}</div></div>
<div class="cp-info-item"><div class="label">电话</div><div class="value">${CompanyUtils.esc(data.UserTele) || '-'}</div></div>
<div class="cp-info-item"><div class="label">账号状态</div><div class="value">${CompanyUtils.statusTag(data.UserAccountLicense)}</div></div>
<div class="cp-info-item"><div class="label">是否员工</div><div class="value">${data.IsStaff ? '<span style="color:#166534;">是</span>' : '<span style="color:#991b1b;">否</span>'}</div></div>
<div class="cp-info-item"><div class="label">创建时间</div><div class="value">${CompanyUtils.formatTime(data.UserCreateTime)}</div></div>
</div>
</div>
</div>
<div class="cp-detail-section">
<h3><i class="fas fa-shield-alt"></i> 安全信息</h3>
<div class="cp-info-grid">
<div class="cp-info-item"><div class="label">用户名</div><div class="value">${CompanyUtils.esc(data.UserName) || '-'}</div></div>
<div class="cp-info-item"><div class="label">真实姓名</div><div class="value">${CompanyUtils.esc(data.UserRealName) || '-'}</div></div>
<div class="cp-info-item"><div class="label">邮箱</div><div class="value">${CompanyUtils.esc(data.UserEmail) || '-'}</div></div>
<div class="cp-info-item"><div class="label">电话</div><div class="value">${CompanyUtils.esc(data.UserTele) || '-'}</div></div>
<div class="cp-info-item"><div class="label">账号状态</div><div class="value">${CompanyUtils.statusTag(data.UserAccountLicense)}</div></div>
<div class="cp-info-item"><div class="label">是否员工</div><div class="value">${data.IsStaff ? '<span style="color:#166534;">是</span>' : '<span style="color:#991b1b;">否</span>'}</div></div>
<div class="cp-info-item"><div class="label">创建时间</div><div class="value">${CompanyUtils.formatTime(data.UserCreateTime)}</div></div>
<div class="cp-info-item"><div class="label">2FA状态</div><div class="value">${twoFaStatusHtml}</div></div>
<div class="cp-info-item"><div class="label">验证状态</div><div class="value">${twoFaVerifiedHtml}</div></div>
<div class="cp-info-item"><div class="label">最后使用</div><div class="value">${twoFaLastUsed}</div></div>
<div class="cp-info-item"><div class="label">2FA创建时间</div><div class="value">${twoFaCreateTime}</div></div>
</div>
</div>
<div class="cp-detail-section">
@@ -125,7 +160,7 @@
bordered: true,
api: {
get: buildApiUrl('/API/FormAPI/Get/UserRole', { UserUUID: userUUID }),
add: buildApiUrl('/API/FormAPI/Add/UserRole'),
add: buildApiUrl('/API/FormAPI/Add/UserRole', { UserUUID: userUUID }),
edit: buildApiUrl('/API/FormAPI/Modify/UserRole/<id>'),
modify: buildApiUrl('/API/FormAPI/ModifyItem/UserRole/<id>'),
delete: buildApiUrl('/API/FormAPI/Delete/UserRole/<id>'),
@@ -155,7 +190,7 @@
bordered: true,
api: {
get: buildApiUrl('/API/FormAPI/Get/UserDept', { UserUUID: userUUID }),
add: buildApiUrl('/API/FormAPI/Add/UserDept'),
add: buildApiUrl('/API/FormAPI/Add/UserDept', { UserUUID: userUUID }),
edit: buildApiUrl('/API/FormAPI/Modify/UserDept/<id>'),
modify: buildApiUrl('/API/FormAPI/ModifyItem/UserDept/<id>'),
delete: buildApiUrl('/API/FormAPI/Delete/UserDept/<id>'),
@@ -0,0 +1,213 @@
<script src="/assets/gerp/CompanyCommon.js?v=20260627c"></script>
<div class="GERPPage">
<div class="cp-page">
<div class="cp-header">
<a href="javascript:void(0)" class="cp-back-btn" onclick="goBack()"><i class="fas fa-arrow-left"></i> 返回</a>
<h1 class="PageTitle">
<i class="fas fa-shield-alt icon-primary icon-left-lg"></i>
<span id="page-title">角色详情</span>
<span id="admin-badge" style="display:none;margin-left:8px;padding:2px 10px;border-radius:4px;background:#1e40af;color:#fff;font-size:12px;font-weight:600;"><i class="fas fa-shield-alt"></i> 特权模式</span>
</h1>
<button class="GERPBtn Primary" onclick="handleEdit()" style="margin-left:auto;"><i class="fas fa-edit"></i> 编辑</button>
</div>
<div class="cp-container" id="detailContainer" style="overflow:auto;">
<div class="cp-loading"><i class="fas fa-spinner fa-spin"></i> 加载中...</div>
</div>
</div>
</div>
<script>
const currentServer = CompanyUtils.getServer();
const roleUUID = new URLSearchParams(window.location.search).get('RoleUUID');
let _isAdminMode = false;
CompanyUtils.setPageTitle('角色详情');
function goBack() {
if (window.history.length > 1) {
window.history.back();
} else {
let url = '/pages/Company/role.html';
if (currentServer !== 'gvsds-master') url += `?server=${encodeURIComponent(currentServer)}`;
GERP.Navigate(url, '角色管理');
}
}
let currentData = null;
let permTable = null;
let userTable = null;
function buildApiUrl(path, params = {}) {
const allParams = {};
if (currentServer !== 'gvsds-master') allParams.server = currentServer;
if (_isAdminMode) allParams.admin_verify = '1';
Object.assign(allParams, params);
const qs = new URLSearchParams(allParams).toString();
return qs ? `${path}?${qs}` : path;
}
async function checkAdmin() {
try {
const resp = await fetch(buildApiUrl('/API/FormAPI/CheckAdmin'), { credentials: 'include' });
const result = await resp.json();
return result.code === 0 && result.data && result.data.is_admin;
} catch (e) {
return false;
}
}
function roleTypeText(t) {
const map = { 0: '组员', 1: '组长', 2: '其他' };
return map[t] ?? '-';
}
async function init() {
const containerEl = document.getElementById('detailContainer');
if (!roleUUID) {
containerEl.innerHTML = `<div class="cp-error"><i class="fas fa-exclamation-triangle" style="margin-right:6px;"></i> 缺少 RoleUUID 参数,无法加载角色详情。</div>`;
return;
}
// 先检查管理员特权,决定是否关闭公司隔离(仅当 URL 带 admin_verify=1 时启用)
_isAdminMode = CompanyUtils.hasAdminVerifyArg() ? await checkAdmin() : false;
const badge = document.getElementById('admin-badge');
if (badge) badge.style.display = _isAdminMode ? '' : 'none';
try {
const dataResp = await fetch(buildApiUrl('/API/FormAPI/Get/Role', { RoleUUID: roleUUID, PageSize: '1' }), { credentials: 'include' });
const dataResult = await dataResp.json();
if (dataResult.code !== 0) {
containerEl.innerHTML = `<div class="cp-error"><i class="fas fa-exclamation-circle" style="margin-right:6px;"></i> 加载失败: ${CompanyUtils.esc(dataResult.msg || '未知错误')}</div>`;
return;
}
const list = dataResult.data || [];
const data = Array.isArray(list) ? (list.find(r => r.RoleUUID === roleUUID) || list[0]) : list;
if (!data) {
containerEl.innerHTML = `<div class="cp-error">未找到角色数据</div>`;
return;
}
currentData = data;
CompanyUtils.setPageTitle(data.RoleName || '角色详情');
const statusHtml = CompanyUtils.statusTag(data.RoleStatus);
const assignScopeMap = { 0: '全租户', 1: '公司', 2: '部门' };
const assignScopeText = assignScopeMap[data.AssignScope] ?? '-';
const scopeHint = _isAdminMode
? `<div style="margin-bottom:12px;padding:8px 12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:4px;color:#1e40af;font-size:13px;"><i class="fas fa-info-circle"></i> 特权模式:显示该角色在所有公司的关联记录(无公司隔离)</div>`
: `<div style="margin-bottom:12px;padding:8px 12px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:4px;color:#6b7280;font-size:13px;"><i class="fas fa-info-circle"></i> 仅显示当前公司数据。如需查看所有公司记录,需管理员特权。</div>`;
containerEl.innerHTML = `
<div class="cp-detail-section">
<h3><i class="fas fa-shield-alt"></i> 基本信息</h3>
<div class="cp-info-grid">
<div class="cp-info-item"><div class="label">角色名称</div><div class="value">${CompanyUtils.esc(data.RoleName) || '-'}</div></div>
<div class="cp-info-item"><div class="label">角色类型</div><div class="value">${roleTypeText(data.RoleType)}</div></div>
<div class="cp-info-item"><div class="label">状态</div><div class="value">${statusHtml}</div></div>
<div class="cp-info-item"><div class="label">分配范围</div><div class="value">${assignScopeText}</div></div>
<div class="cp-info-item"><div class="label">角色描述</div><div class="value">${CompanyUtils.esc(data.RoleDesc) || '-'}</div></div>
<div class="cp-info-item"><div class="label">创建时间</div><div class="value">${CompanyUtils.formatTime(data.CreateTime)}</div></div>
</div>
</div>
${scopeHint}
<div class="cp-detail-section">
<h3><i class="fas fa-key"></i> 权限列表</h3>
<div id="perm-table-container" style="height:400px;"></div>
</div>
<div class="cp-detail-section">
<h3><i class="fas fa-users"></i> 关联用户</h3>
<div id="user-table-container" style="height:400px;"></div>
</div>`;
initPermTable();
initUserTable();
} catch (e) {
containerEl.innerHTML = `<div class="cp-error"><i class="fas fa-exclamation-circle" style="margin-right:6px;"></i> 网络错误: ${CompanyUtils.esc(e.message)}</div>`;
}
}
function initPermTable() {
permTable = new TableEngine({
container: '#perm-table-container',
height: '400px',
tableName: 'RolePermission',
columns: [
{ field: 'RolePermUUID', title: '关联UUID', primaryKey: true, editable: false, hideInForm: true },
CompanyUtils.epColumn('PermUUID', '权限名称', 'Permission'),
CompanyUtils.epColumn('CompanyUUID', '公司', 'Company', { editable: false, hideInForm: true }),
{ field: 'CreateTime', title: '创建时间', type: 'datetime', editable: false },
],
data: [],
pageSize: 20,
columnDistribution: 'equal',
showPagination: true,
showCheckbox: true,
editable: true,
sortable: true,
filterable: true,
striped: true,
hoverable: true,
bordered: true,
api: {
get: buildApiUrl('/API/FormAPI/Get/RolePermission', { RoleUUID: roleUUID }),
add: buildApiUrl('/API/FormAPI/Add/RolePermission', { RoleUUID: roleUUID }),
edit: buildApiUrl('/API/FormAPI/Modify/RolePermission/<id>'),
modify: buildApiUrl('/API/FormAPI/ModifyItem/RolePermission/<id>'),
delete: buildApiUrl('/API/FormAPI/Delete/RolePermission/<id>'),
},
});
}
function initUserTable() {
userTable = new TableEngine({
container: '#user-table-container',
height: '400px',
tableName: 'UserRole',
columns: [
{ field: 'UserRoleUUID', title: '关联UUID', primaryKey: true, editable: false, hideInForm: true },
CompanyUtils.epColumn('UserUUID', '用户', 'User'),
CompanyUtils.epColumn('CompanyUUID', '公司', 'Company', { editable: false, hideInForm: true }),
CompanyUtils.epColumn('DeptUUID', '部门', 'Department'),
CompanyUtils.epColumn('WorkgroupUUID', '工作组', 'Workgroup'),
{ field: 'RoleType', title: '角色类型', type: 'select', options: [{ value: 0, label: '组员' }, { value: 1, label: '组长' }] },
],
data: [],
pageSize: 20,
columnDistribution: 'equal',
showPagination: true,
showCheckbox: true,
editable: true,
sortable: true,
filterable: true,
striped: true,
hoverable: true,
bordered: true,
api: {
get: buildApiUrl('/API/FormAPI/Get/UserRole', { RoleUUID: roleUUID }),
add: buildApiUrl('/API/FormAPI/Add/UserRole', { RoleUUID: roleUUID }),
edit: buildApiUrl('/API/FormAPI/Modify/UserRole/<id>'),
modify: buildApiUrl('/API/FormAPI/ModifyItem/UserRole/<id>'),
delete: buildApiUrl('/API/FormAPI/Delete/UserRole/<id>'),
},
});
}
/** 编辑按钮:弹出编辑对话框 */
async function handleEdit() {
if (!currentData) return;
CompanyUtils.showEditDialog({
tableName: 'Role',
data: currentData,
onSave: () => {
if (window.GERPUI && typeof window.GERPUI.ShowToast === 'function') {
window.GERPUI.ShowToast({ message: '保存成功', type: 'success' });
}
init();
}
});
}
init();
</script>
+39
View File
@@ -0,0 +1,39 @@
<script src="/assets/gerp/CompanyCommon.js?v=20260627c"></script>
<div class="GERPPage">
<div class="cp-page">
<div class="cp-header">
<h1 class="PageTitle">
<i class="fas fa-shield-alt icon-primary icon-left-lg"></i>
<span id="page-title">角色管理</span>
<span id="admin-badge" style="display:none;margin-left:8px;padding:2px 10px;border-radius:4px;background:#1e40af;color:#fff;font-size:12px;font-weight:600;"><i class="fas fa-shield-alt"></i> 特权模式</span>
</h1>
</div>
<div class="cp-container" id="TableContainer"></div>
</div>
</div>
<script>
const page = new CompanyTablePage({
tableName: 'Role',
title: '角色管理',
pkField: 'RoleUUID',
adminBadge: true,
defaultColumns: () => [
{ field: 'RoleUUID', title: '角色UUID', width: 140, primaryKey: true, editable: false, hideInForm: true },
{ field: 'RoleName', title: '角色名称', width: 200, type: 'text' },
{ field: 'RoleType', title: '角色类型', width: 100, type: 'select', options: [{ value: 0, label: '组员' }, { value: 1, label: '组长' }, { value: 2, label: '其他' }] },
{ field: 'RoleStatus', title: '状态', width: 80, type: 'select', options: [{ value: 0, label: '禁用' }, { value: 1, label: '启用' }] },
{ field: 'AssignScope', title: '分配范围', width: 100, type: 'select', options: [{ value: 0, label: '全租户' }, { value: 1, label: '公司' }, { value: 2, label: '部门' }] },
{ field: 'RoleDesc', title: '角色描述', width: 240, type: 'text' },
{ field: 'CreateTime', title: '创建时间', width: 160, type: 'datetime', editable: false },
],
onView: (row) => {
const server = new URLSearchParams(window.location.search).get('server');
let url = `/pages/Company/role-detail.html?RoleUUID=${encodeURIComponent(row.RoleUUID || '')}`;
if (server && server !== 'gvsds-master') url += `&server=${encodeURIComponent(server)}`;
GERP.Navigate(url, row.RoleName || '角色详情');
},
});
page.init();
</script>
@@ -0,0 +1,188 @@
<script src="/assets/gerp/CompanyCommon.js?v=20260627c"></script>
<div class="GERPPage">
<div class="cp-page">
<div class="cp-header">
<a href="javascript:void(0)" class="cp-back-btn" onclick="goBack()"><i class="fas fa-arrow-left"></i> 返回</a>
<h1 class="PageTitle">
<i class="fas fa-layer-group icon-primary icon-left-lg"></i>
<span id="page-title">租户详情</span>
<span id="admin-badge" style="display:none;margin-left:8px;padding:2px 10px;border-radius:4px;background:#1e40af;color:#fff;font-size:12px;font-weight:600;"><i class="fas fa-shield-alt"></i> 特权模式</span>
</h1>
<button class="GERPBtn Primary" onclick="handleEdit()" style="margin-left:auto;"><i class="fas fa-edit"></i> 编辑</button>
</div>
<div class="cp-container" id="detailContainer" style="overflow:auto;">
<div class="cp-loading"><i class="fas fa-spinner fa-spin"></i> 加载中...</div>
</div>
</div>
</div>
<script>
const currentServer = CompanyUtils.getServer();
const tenantUUID = new URLSearchParams(window.location.search).get('TenantUUID');
let _isAdminMode = false;
CompanyUtils.setPageTitle('租户详情');
function goBack() {
if (window.history.length > 1) {
window.history.back();
} else {
let url = '/pages/Company/tenant.html';
if (currentServer !== 'gvsds-master') url += `?server=${encodeURIComponent(currentServer)}`;
GERP.Navigate(url, '租户管理');
}
}
let currentData = null;
let companyTable = null;
function buildApiUrl(path, params = {}) {
const allParams = {};
if (currentServer !== 'gvsds-master') allParams.server = currentServer;
if (_isAdminMode) allParams.admin_verify = '1';
Object.assign(allParams, params);
const qs = new URLSearchParams(allParams).toString();
return qs ? `${path}?${qs}` : path;
}
async function checkAdmin() {
try {
const resp = await fetch(buildApiUrl('/API/FormAPI/CheckAdmin'), { credentials: 'include' });
const result = await resp.json();
return result.code === 0 && result.data && result.data.is_admin;
} catch (e) {
return false;
}
}
function tenantStatusTag(status) {
if (status === 1) return '<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;background:#dcfce7;color:#166534;">启用</span>';
return '<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;background:#fee2e2;color:#991b1b;">禁用</span>';
}
function reviewStatusTag(status) {
const map = {
0: { label: '待审核', bg: '#fef9c3', color: '#854d0e' },
1: { label: '已通过', bg: '#dcfce7', color: '#166534' },
2: { label: '已拒绝', bg: '#fee2e2', color: '#991b1b' },
};
const s = map[status];
if (!s) return '-';
return `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;background:${s.bg};color:${s.color};">${s.label}</span>`;
}
async function init() {
const containerEl = document.getElementById('detailContainer');
if (!tenantUUID) {
containerEl.innerHTML = `<div class="cp-error"><i class="fas fa-exclamation-triangle" style="margin-right:6px;"></i> 缺少 TenantUUID 参数,无法加载租户详情。</div>`;
return;
}
// 先检查管理员特权(仅当 URL 带 admin_verify=1 时启用)
_isAdminMode = CompanyUtils.hasAdminVerifyArg() ? await checkAdmin() : false;
const badge = document.getElementById('admin-badge');
if (badge) badge.style.display = _isAdminMode ? '' : 'none';
try {
const dataResp = await fetch(buildApiUrl('/API/FormAPI/Get/Tenant', { TenantUUID: tenantUUID, PageSize: '1' }), { credentials: 'include' });
const dataResult = await dataResp.json();
if (dataResult.code !== 0) {
containerEl.innerHTML = `<div class="cp-error"><i class="fas fa-exclamation-circle" style="margin-right:6px;"></i> 加载失败: ${CompanyUtils.esc(dataResult.msg || '未知错误')}</div>`;
return;
}
const list = dataResult.data || [];
const data = Array.isArray(list) ? (list.find(t => t.TenantUUID === tenantUUID) || list[0]) : list;
if (!data) {
containerEl.innerHTML = `<div class="cp-error">未找到租户数据</div>`;
return;
}
currentData = data;
CompanyUtils.setPageTitle(data.TenantName || '租户详情');
containerEl.innerHTML = `
<div class="cp-detail-section">
<h3><i class="fas fa-info-circle"></i> 基本信息</h3>
<div class="cp-info-grid">
<div class="cp-info-item"><div class="label">租户名称</div><div class="value">${CompanyUtils.esc(data.TenantName) || '-'}</div></div>
<div class="cp-info-item"><div class="label">租户状态</div><div class="value">${tenantStatusTag(data.TenantStatus)}</div></div>
<div class="cp-info-item"><div class="label">到期时间</div><div class="value">${CompanyUtils.formatTime(data.TenantExpireTime)}</div></div>
<div class="cp-info-item"><div class="label">创建时间</div><div class="value">${CompanyUtils.formatTime(data.CreateTime)}</div></div>
</div>
</div>
<div class="cp-detail-section">
<h3><i class="fas fa-clipboard-check"></i> 审核信息</h3>
<div class="cp-info-grid">
<div class="cp-info-item"><div class="label">审核状态</div><div class="value">${reviewStatusTag(data.ReviewStatus)}</div></div>
<div class="cp-info-item"><div class="label">审核时间</div><div class="value">${CompanyUtils.formatTime(data.ReviewTime)}</div></div>
<div class="cp-info-item"><div class="label">审核备注</div><div class="value">${CompanyUtils.esc(data.ReviewRemark) || '-'}</div></div>
</div>
</div>
<div class="cp-detail-section">
<h3><i class="fas fa-building"></i> 关联公司</h3>
<div id="company-table-container" style="height:400px;"></div>
</div>`;
initCompanyTable();
} catch (e) {
containerEl.innerHTML = `<div class="cp-error"><i class="fas fa-exclamation-circle" style="margin-right:6px;"></i> 网络错误: ${CompanyUtils.esc(e.message)}</div>`;
}
}
function initCompanyTable() {
companyTable = new TableEngine({
container: '#company-table-container',
height: '400px',
tableName: 'Company',
columns: [
{ field: 'CompanyUUID', title: '公司UUID', primaryKey: true, editable: false, hideInForm: true },
{ field: 'CompanyName', title: '公司全称', type: 'text' },
{ field: 'CompanyShort', title: '公司简称', type: 'text' },
{ field: 'CompanyStatus', title: '状态', type: 'select', options: [{ value: 0, label: '禁用' }, { value: 1, label: '启用' }] },
{ field: 'CreateTime', title: '创建时间', type: 'datetime', editable: false },
],
data: [],
pageSize: 20,
columnDistribution: 'equal',
showPagination: true,
showCheckbox: true,
editable: true,
sortable: true,
filterable: true,
striped: true,
hoverable: true,
bordered: true,
api: {
get: buildApiUrl('/API/FormAPI/Get/Company', { TenantUUID: tenantUUID }),
add: buildApiUrl('/API/FormAPI/Add/Company', { TenantUUID: tenantUUID }),
edit: buildApiUrl('/API/FormAPI/Modify/Company/<id>'),
modify: buildApiUrl('/API/FormAPI/ModifyItem/Company/<id>'),
delete: buildApiUrl('/API/FormAPI/Delete/Company/<id>'),
},
onView: (row) => {
let url = `/pages/Company/company-detail.html?CompanyUUID=${encodeURIComponent(row.CompanyUUID || '')}`;
if (currentServer !== 'gvsds-master') url += `&server=${encodeURIComponent(currentServer)}`;
GERP.Navigate(url, row.CompanyName || '公司详情');
},
});
}
/** 编辑按钮:弹出编辑对话框 */
async function handleEdit() {
if (!currentData) return;
CompanyUtils.showEditDialog({
tableName: 'Tenant',
data: currentData,
onSave: () => {
if (window.GERPUI && typeof window.GERPUI.ShowToast === 'function') {
window.GERPUI.ShowToast({ message: '保存成功', type: 'success' });
}
init();
}
});
}
init();
</script>
+39
View File
@@ -0,0 +1,39 @@
<script src="/assets/gerp/CompanyCommon.js?v=20260627c"></script>
<div class="GERPPage">
<div class="cp-page">
<div class="cp-header">
<h1 class="PageTitle">
<i class="fas fa-layer-group icon-primary icon-left-lg"></i>
<span id="page-title">租户管理</span>
<span id="admin-badge" style="display:none;margin-left:8px;padding:2px 10px;border-radius:4px;background:#1e40af;color:#fff;font-size:12px;font-weight:600;"><i class="fas fa-shield-alt"></i> 特权模式</span>
</h1>
</div>
<div class="cp-container" id="TableContainer"></div>
</div>
</div>
<script>
const page = new CompanyTablePage({
tableName: 'Tenant',
title: '租户管理',
pkField: 'TenantUUID',
adminBadge: true,
defaultColumns: () => [
{ field: 'TenantUUID', title: '租户UUID', width: 140, primaryKey: true, editable: false, hideInForm: true },
{ field: 'TenantName', title: '租户名称', width: 200, type: 'text' },
{ field: 'TenantStatus', title: '租户状态', width: 90, type: 'select', options: [{ value: 0, label: '禁用' }, { value: 1, label: '启用' }] },
{ field: 'ReviewStatus', title: '审核状态', width: 90, type: 'select', options: [{ value: 0, label: '待审核' }, { value: 1, label: '已通过' }, { value: 2, label: '已拒绝' }] },
{ field: 'TenantExpireTime', title: '到期时间', width: 160, type: 'datetime' },
{ field: 'ReviewRemark', title: '审核备注', width: 200, type: 'text' },
{ field: 'CreateTime', title: '创建时间', width: 160, type: 'datetime', editable: false },
],
onView: (row) => {
const server = new URLSearchParams(window.location.search).get('server');
let url = `/pages/Company/tenant-detail.html?TenantUUID=${encodeURIComponent(row.TenantUUID || '')}`;
if (server && server !== 'gvsds-master') url += `&server=${encodeURIComponent(server)}`;
GERP.Navigate(url, row.TenantName || '租户详情');
},
});
page.init();
</script>
@@ -1,4 +1,4 @@
<script src="/assets/gerp/CompanyCommon.js?v=20260625d"></script>
<script src="/assets/gerp/CompanyCommon.js?v=20260627c"></script>
<div class="GERPPage">
<div class="cp-page">
@@ -113,7 +113,7 @@
bordered: true,
api: {
get: buildApiUrl('/API/FormAPI/Get/UserRole', { WorkgroupUUID: workgroupUUID }),
add: buildApiUrl('/API/FormAPI/Add/UserRole'),
add: buildApiUrl('/API/FormAPI/Add/UserRole', { WorkgroupUUID: workgroupUUID }),
edit: buildApiUrl('/API/FormAPI/Modify/UserRole/<id>'),
modify: buildApiUrl('/API/FormAPI/ModifyItem/UserRole/<id>'),
delete: buildApiUrl('/API/FormAPI/Delete/UserRole/<id>'),
+1 -1
View File
@@ -190,7 +190,7 @@
const pageSize = 24;
const CHUNK_SIZE = 5 * 1024 * 1024;
const SIMPLE_UPLOAD_THRESHOLD = 5 * 1024 * 1024;
const ASSET_DOWNLOAD_BASE = 'https://api.gvsds.com/Asset/Download';
const ASSET_DOWNLOAD_BASE = 'https://api.gvsds.com/API/Asset/Download';
const grid = document.getElementById('assetGrid');
const gridWrap = document.getElementById('assetGridWrap');
+47
View File
@@ -77,4 +77,51 @@ document.getElementById('btn-personal')?.addEventListener('click', () => {
window.GERPApp.HandleUserAction('profile');
}
});
// ========== 角色差异化统计 ==========
async function loadDashboardStats() {
const gridEl = document.getElementById('statsGrid');
// 欢迎页无统计卡片容器时静默跳过,避免 null 引用错误
if (!gridEl) return;
const badgeEl = document.getElementById('scopeBadge');
gridEl.innerHTML = '<div class="stat-loading"><i class="fas fa-spinner fa-spin"></i> 加载中...</div>';
try {
const resp = await fetch('/API/Dashboard/Stats', { credentials: 'include' });
const result = await resp.json();
if (result.code !== 0) {
gridEl.innerHTML = `<div class="stat-loading" style="color:var(--DangerColor);">${escapeHtml(result.msg || '加载失败')}</div>`;
return;
}
const data = result.data || {};
const stats = data.Stats || {};
const role = data.Role || {};
if (badgeEl) badgeEl.textContent = role.TypeLabel || '未知';
const cards = [
{ icon: 'users', cls: 'users', value: stats.TotalUsers || 0, label: '可见用户' },
{ icon: 'shopping-cart', cls: 'orders', value: stats.TotalOrders || 0, label: '订单总数' },
{ icon: 'calendar-day', cls: 'today', value: stats.TodayOrders || 0, label: '今日订单' },
{ icon: 'clock', cls: 'pending', value: stats.PendingOrders || 0, label: '待确认订单' },
{ icon: 'credit-card', cls: 'paid', value: stats.PaidOrders || 0, label: '已支付订单' },
{ icon: 'check-circle', cls: 'completed', value: stats.CompletedOrders || 0, label: '已完成订单' },
];
gridEl.innerHTML = cards.map(c => `
<div class="stat-card">
<div class="icon ${c.cls}"><i class="fas fa-${c.icon}"></i></div>
<div class="info">
<div class="value">${c.value}</div>
<div class="label">${c.label}</div>
</div>
</div>
`).join('');
} catch (err) {
gridEl.innerHTML = `<div class="stat-loading" style="color:var(--DangerColor);">网络错误: ${escapeHtml(err.message)}</div>`;
}
}
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
loadDashboardStats();
</script>
+418
View File
@@ -0,0 +1,418 @@
<style>
.GERPPage { padding: 0; height: 100%; display: flex; flex-direction: column; box-sizing: border-box; }
.doc-page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.doc-header {
display: flex; align-items: center; padding: 12px 20px;
background: var(--GERP-Bg-Card, #fff);
border-bottom: 1px solid var(--GERP-Border, #e5e7eb);
gap: 12px; flex-shrink: 0;
}
.doc-header h1 { margin: 0; font-size: 18px; flex-shrink: 0; }
.doc-header .spacer { flex: 1; }
.doc-toolbar { display: flex; gap: 8px; }
.doc-toolbar button {
padding: 6px 14px; border: 1px solid var(--GERP-Border, #d1d5db);
background: var(--GERP-Bg-Card, #fff); border-radius: 6px;
cursor: pointer; font-size: 13px; display: inline-flex; align-items: center; gap: 6px;
transition: all 0.15s;
}
.doc-toolbar button:hover { background: var(--GERP-Bg-Hover, #f3f4f6); }
.doc-toolbar button.primary { background: #1e40af; color: #fff; border-color: #1e40af; }
.doc-toolbar button.primary:hover { background: #1e3a8a; }
.doc-toolbar button.danger { color: #dc2626; border-color: #fecaca; }
.doc-toolbar button.danger:hover { background: #fef2f2; }
.doc-toolbar button:disabled { opacity: 0.5; cursor: not-allowed; }
.doc-body { flex: 1; min-height: 0; display: flex; overflow: hidden; }
.doc-sidebar {
width: 260px; flex-shrink: 0; border-right: 1px solid var(--GERP-Border, #e5e7eb);
background: var(--GERP-Bg-Card, #fff); display: flex; flex-direction: column;
}
.doc-sidebar .sidebar-header {
padding: 10px 12px; border-bottom: 1px solid var(--GERP-Border, #e5e7eb);
display: flex; gap: 8px; align-items: center;
}
.doc-sidebar .sidebar-header input {
flex: 1; padding: 6px 10px; border: 1px solid var(--GERP-Border, #d1d5db);
border-radius: 4px; font-size: 13px; background: var(--GERP-Bg-Input, #fff);
color: var(--GERP-Text, #1f2937);
}
.doc-sidebar .sidebar-header button {
padding: 6px 10px; border: 1px solid var(--GERP-Border, #d1d5db);
background: var(--GERP-Bg-Card, #fff); border-radius: 4px; cursor: pointer;
font-size: 13px; white-space: nowrap;
}
.doc-sidebar .sidebar-header button:hover { background: var(--GERP-Bg-Hover, #f3f4f6); }
.doc-list { flex: 1; overflow-y: auto; padding: 4px 0; }
.doc-item {
padding: 10px 14px; cursor: pointer; border-bottom: 1px solid var(--GERP-Border-Light, #f3f4f6);
transition: background 0.1s;
}
.doc-item:hover { background: var(--GERP-Bg-Hover, #f9fafb); }
.doc-item.active { background: rgba(30,64,175,0.08); border-left: 3px solid #1e40af; padding-left: 11px; }
.doc-item .doc-item-title {
font-size: 13px; font-weight: 500; color: var(--GERP-Text, #1f2937);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.doc-item .doc-item-time { font-size: 11px; color: var(--GERP-Text-Muted, #9ca3af); margin-top: 2px; }
.doc-empty { padding: 20px; text-align: center; color: var(--GERP-Text-Muted, #9ca3af); font-size: 13px; }
.doc-editor { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--GERP-Bg, #f9fafb); }
.doc-title-bar {
padding: 10px 16px; background: var(--GERP-Bg-Card, #fff);
border-bottom: 1px solid var(--GERP-Border, #e5e7eb); flex-shrink: 0;
display: flex; align-items: center; gap: 10px;
}
.doc-title-bar input {
flex: 1; padding: 8px 12px; border: 1px solid var(--GERP-Border, #d1d5db);
border-radius: 6px; font-size: 15px; font-weight: 600;
background: var(--GERP-Bg-Input, #fff); color: var(--GERP-Text, #1f2937);
}
.doc-title-bar input:focus { outline: none; border-color: #1e40af; box-shadow: 0 0 0 2px rgba(30,64,175,0.1); }
.doc-save-status { font-size: 12px; color: var(--GERP-Text-Muted, #9ca3af); white-space: nowrap; }
.editor-container { flex: 1; min-height: 0; overflow: hidden; }
.editor-container .tox-tinymce { border: none; border-radius: 0; }
.doc-loading { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--GERP-Text-Muted, #9ca3af); }
</style>
<div class="GERPPage">
<div class="doc-page">
<div class="doc-header">
<h1><i class="fas fa-file-word icon-primary icon-left-lg"></i>文档编辑器</h1>
<div class="spacer"></div>
<div class="doc-toolbar">
<button id="BtnNew" class="primary"><i class="fas fa-plus"></i> 新建</button>
<button id="BtnSave" disabled><i class="fas fa-save"></i> 保存</button>
<button id="BtnExport" disabled><i class="fas fa-file-export"></i> 导出 .docx</button>
<button id="BtnDelete" class="danger" disabled><i class="fas fa-trash"></i> 删除</button>
</div>
</div>
<div class="doc-body">
<div class="doc-sidebar">
<div class="sidebar-header">
<input id="DocSearch" placeholder="搜索文档..." />
<button id="BtnRefresh" title="刷新列表"><i class="fas fa-sync-alt"></i></button>
</div>
<div class="doc-list" id="DocList">
<div class="doc-empty">加载中...</div>
</div>
</div>
<div class="doc-editor">
<div class="doc-title-bar">
<input id="DocTitle" placeholder="请选择或新建文档" disabled />
<span class="doc-save-status" id="SaveStatus"></span>
</div>
<div class="editor-container" id="EditorContainer">
<div class="doc-loading">请从左侧选择或新建文档</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/tinymce@5.10.9/tinymce.min.js" referrerpolicy="origin"></script>
<script>
(function () {
'use strict';
let _editor = null;
let _currentDocUUID = null;
let _docList = [];
let _dirty = false;
const DocList = document.getElementById('DocList');
const DocTitle = document.getElementById('DocTitle');
const DocSearch = document.getElementById('DocSearch');
const EditorContainer = document.getElementById('EditorContainer');
const SaveStatus = document.getElementById('SaveStatus');
const BtnNew = document.getElementById('BtnNew');
const BtnSave = document.getElementById('BtnSave');
const BtnExport = document.getElementById('BtnExport');
const BtnDelete = document.getElementById('BtnDelete');
const BtnRefresh = document.getElementById('BtnRefresh');
/** 调用文档 API */
async function CallDocAPI(action, data = {}) {
const url = '/API/doc/doc/' + action;
const resp = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return resp.json();
}
/** 加载文档列表 */
async function LoadDocList(keyword = '') {
DocList.innerHTML = '<div class="doc-empty">加载中...</div>';
try {
const result = await CallDocAPI('GetDocs', keyword ? { keyword } : {});
if (result.code === 0 && result.data) {
_docList = result.data;
RenderDocList();
} else {
DocList.innerHTML = '<div class="doc-empty">' + (result.msg || '加载失败') + '</div>';
}
} catch (e) {
DocList.innerHTML = '<div class="doc-empty">加载失败: ' + e.message + '</div>';
}
}
/** 渲染文档列表 */
function RenderDocList() {
if (!_docList.length) {
DocList.innerHTML = '<div class="doc-empty">暂无文档,点击"新建"创建</div>';
return;
}
DocList.innerHTML = _docList.map(doc => {
const time = doc.DocCreateTime ? new Date(doc.DocCreateTime).toLocaleString('zh-CN') : '';
const active = doc.DocUUID === _currentDocUUID ? ' active' : '';
return '<div class="doc-item' + active + '" data-uuid="' + doc.DocUUID + '">' +
'<div class="doc-item-title">' + EscapeHtml(doc.DocTitle || '未命名') + '</div>' +
'<div class="doc-item-time">' + time + '</div>' +
'</div>';
}).join('');
DocList.querySelectorAll('.doc-item').forEach(item => {
item.addEventListener('click', () => SelectDoc(item.dataset.uuid));
});
}
/** 选择文档 */
async function SelectDoc(docUUID) {
if (_dirty && _currentDocUUID && !confirm('当前文档未保存,是否丢弃改动?')) return;
_currentDocUUID = docUUID;
_dirty = false;
RenderDocList();
try {
const result = await CallDocAPI('GetDoc', { DocUUID: docUUID });
if (result.code === 0) {
DocTitle.value = result.DocTitle || '';
DocTitle.disabled = false;
InitEditor(result.DocContent || '');
BtnSave.disabled = false;
BtnExport.disabled = false;
BtnDelete.disabled = false;
SaveStatus.textContent = '';
} else {
alert(result.msg || '加载文档失败');
}
} catch (e) {
alert('加载文档失败: ' + e.message);
}
}
/** 初始化 TinyMCE 编辑器 */
function InitEditor(content) {
if (_editor) {
_editor.setContent(content);
return;
}
EditorContainer.innerHTML = '<textarea id="TinyEditor"></textarea>';
tinymce.init({
selector: '#TinyEditor',
height: '100%',
menubar: 'file edit view insert format tools table help',
plugins: [
'advlist autolink lists link image charmap print preview hr anchor pagebreak',
'searchreplace wordcount visualblocks visualchars code fullscreen',
'insertdatetime media nonbreaking save table contextmenu directionality',
'emoticons template paste textcolor colorpicker textpattern imagetools',
],
toolbar1: 'undo redo | styleselect | bold italic underline strikethrough | ' +
'alignleft aligncenter alignright alignjustify | bullist numlist outdent indent',
toolbar2: 'forecolor backcolor | fontselect fontsizeselect | ' +
'link image media | table | preview fullscreen | code',
language: 'zh_CN',
language_url: 'https://cdn.jsdelivr.net/npm/tinymce-i18n@23.6.19/langs/zh_CN.js',
content_style: 'body { font-family: "Microsoft YaHei", "PingFang SC", sans-serif; font-size: 14px; line-height: 1.8; padding: 16px; }',
skin: 'oxide',
branding: false,
promotion: false,
setup: function (editor) {
_editor = editor;
editor.on('change', function () { MarkDirty(); });
editor.on('input', function () { MarkDirty(); });
},
init_instance_callback: function (editor) {
editor.setContent(content);
},
});
}
/** 标记文档已修改 */
function MarkDirty() {
_dirty = true;
SaveStatus.textContent = '未保存';
SaveStatus.style.color = '#f59e0b';
}
/** 新建文档 */
async function NewDoc() {
if (_dirty && _currentDocUUID && !confirm('当前文档未保存,是否丢弃改动?')) return;
const title = prompt('请输入文档标题:', '未命名文档');
if (!title) return;
try {
const result = await CallDocAPI('AddDoc', { DocTitle: title, DocContent: '' });
if (result.code === 0) {
await LoadDocList();
if (result.data && result.data.DocUUID) {
SelectDoc(result.data.DocUUID);
}
} else {
alert(result.msg || '新建失败');
}
} catch (e) {
alert('新建失败: ' + e.message);
}
}
/** 保存文档 */
async function SaveDoc() {
if (!_currentDocUUID || !_editor) return;
const title = DocTitle.value.trim();
if (!title) { alert('请输入文档标题'); return; }
const content = _editor.getContent();
SaveStatus.textContent = '保存中...';
SaveStatus.style.color = '#6b7280';
try {
const result = await CallDocAPI('ModifyDocSettings', {
DocUUID: _currentDocUUID,
DocTitle: title,
DocContent: content,
});
if (result.code === 0) {
_dirty = false;
SaveStatus.textContent = '已保存';
SaveStatus.style.color = '#10b981';
await LoadDocList(DocSearch.value);
} else {
alert(result.msg || '保存失败');
SaveStatus.textContent = '保存失败';
SaveStatus.style.color = '#ef4444';
}
} catch (e) {
alert('保存失败: ' + e.message);
SaveStatus.textContent = '保存失败';
SaveStatus.style.color = '#ef4444';
}
}
/** 导出 .docx */
async function ExportDocx() {
if (!_currentDocUUID) return;
SaveStatus.textContent = '导出中...';
SaveStatus.style.color = '#6b7280';
try {
const resp = await fetch('/API/doc/doc/ExportDocx', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ DocUUID: _currentDocUUID }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ msg: '导出失败' }));
throw new Error(err.msg || '导出失败');
}
const blob = await resp.blob();
const disposition = resp.headers.get('Content-Disposition') || '';
let filename = 'document.docx';
const match = disposition.match(/filename="?([^"]+)"?/);
if (match) filename = decodeURIComponent(match[1]);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
SaveStatus.textContent = '导出成功';
SaveStatus.style.color = '#10b981';
} catch (e) {
alert('导出失败: ' + e.message);
SaveStatus.textContent = '导出失败';
SaveStatus.style.color = '#ef4444';
}
}
/** 删除文档 */
async function DeleteDoc() {
if (!_currentDocUUID) return;
if (!confirm('确定删除此文档?此操作不可撤销。')) return;
try {
const result = await CallDocAPI('DeleteDoc', { DocUUID: _currentDocUUID });
if (result.code === 0) {
_currentDocUUID = null;
_dirty = false;
if (_editor) { _editor.remove(); _editor = null; }
DocTitle.value = '';
DocTitle.disabled = true;
EditorContainer.innerHTML = '<div class="doc-loading">请从左侧选择或新建文档</div>';
BtnSave.disabled = true;
BtnExport.disabled = true;
BtnDelete.disabled = true;
SaveStatus.textContent = '';
await LoadDocList(DocSearch.value);
} else {
alert(result.msg || '删除失败');
}
} catch (e) {
alert('删除失败: ' + e.message);
}
}
/** HTML 转义 */
function EscapeHtml(str) {
const div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
// 事件绑定
BtnNew.addEventListener('click', NewDoc);
BtnSave.addEventListener('click', SaveDoc);
BtnExport.addEventListener('click', ExportDocx);
BtnDelete.addEventListener('click', DeleteDoc);
BtnRefresh.addEventListener('click', () => LoadDocList(DocSearch.value));
DocTitle.addEventListener('input', MarkDirty);
DocSearch.addEventListener('input', function () {
clearTimeout(window._docSearchTimer);
window._docSearchTimer = setTimeout(() => LoadDocList(DocSearch.value), 300);
});
// 快捷键:Ctrl+S 保存
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (!BtnSave.disabled) SaveDoc();
}
});
// 初始加载
LoadDocList();
})();
</script>
+163 -52
View File
@@ -1,59 +1,170 @@
<div class="GERPPage">
<div class="GERPPageHeader">
<h1 class="GERPPageTitle">产品管理</h1>
<p class="GERPPageDesc">管理产品信息和库存</p>
</div>
<style>
.GERPPage { padding: 24px; max-width: 1400px; margin: 0 auto; height: 100%; display: flex; flex-direction: column; box-sizing: border-box; }
.product-page { padding: 20px; flex: 1; min-height: 0; display: flex; flex-direction: column; box-sizing: border-box; }
.product-header { display: flex; align-items: center; margin-bottom: 12px; gap: 12px; flex-shrink: 0; }
.product-header h1 { margin: 0; flex: 1; }
.product-container { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
.product-scope-banner { padding: 10px 14px; border-radius: 8px; font-size: 13px; margin-bottom: 12px; display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.product-scope-banner.admin { background: rgba(30,64,175,0.08); color: #1e40af; border: 1px solid rgba(30,64,175,0.2); }
.product-scope-banner.normal { background: rgba(100,116,139,0.08); color: #475569; border: 1px solid rgba(100,116,139,0.2); }
.product-scope-banner i { font-size: 16px; }
</style>
<div class="GERPCard">
<div class="GERPCardHeader">
<h3 class="GERPCardTitle">产品列表</h3>
<button class="GERPBtn Primary">添加产品</button>
<div class="GERPPage">
<div class="product-page">
<div class="product-header">
<h1 class="PageTitle">
<i class="fas fa-box icon-primary icon-left-lg"></i>
<span id="page-title">产品管理</span>
<span id="admin-badge" style="display:none;margin-left:8px;padding:2px 10px;border-radius:4px;background:#1e40af;color:#fff;font-size:12px;font-weight:600;"><i class="fas fa-shield-alt"></i> 特权模式</span>
</h1>
</div>
<table class="GERPTable">
<thead>
<tr>
<th>产品编号</th>
<th>产品名称</th>
<th>类别</th>
<th>单价</th>
<th>库存数量</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td>PRD-001</td>
<td>企业版软件许可</td>
<td>软件</td>
<td>¥25,000</td>
<td>无限</td>
<td><span class="GERPTag Success">在售</span></td>
<td><button class="GERPBtn Sm Secondary">编辑</button></td>
</tr>
<tr>
<td>PRD-002</td>
<td>硬件设备套装 A</td>
<td>硬件</td>
<td>¥18,500</td>
<td>156</td>
<td><span class="GERPTag Success">在售</span></td>
<td><button class="GERPBtn Sm Secondary">编辑</button></td>
</tr>
<tr>
<td>PRD-003</td>
<td>配件套装 B</td>
<td>配件</td>
<td>¥3,200</td>
<td>89</td>
<td><span class="GERPTag Warning">库存不足</span></td>
<td><button class="GERPBtn Sm Secondary">编辑</button></td>
</tr>
</tbody>
</table>
<div id="scopeBanner" class="product-scope-banner normal" style="display:none;"></div>
<div class="product-container" id="TableContainer"></div>
</div>
</div>
<script>
GERP.SetTitle('产品管理');
const urlParams = new URLSearchParams(window.location.search);
const currentServer = urlParams.get('server') || 'gvsds-master';
let _tableInstance = null;
let isAdminMode = false;
let _roleScope = null;
/** 检查当前用户是否具有管理员特权,并填充 _roleScope */
async function checkAdmin() {
try {
const resp = await fetch('/API/FormAPI/CheckAdmin', { credentials: 'include' });
const result = await resp.json();
if (result.code === 0 && result.data) {
_roleScope = result.data.role_scope || null;
return result.data.is_admin;
}
return false;
} catch (e) { return false; }
}
function buildApiUrl(path, params = {}) {
const allParams = {};
if (currentServer !== 'gvsds-master') allParams.server = currentServer;
if (isAdminMode) allParams.admin_verify = '1';
Object.assign(allParams, params);
const qs = new URLSearchParams(allParams).toString();
return qs ? `${path}?${qs}` : path;
}
/** 根据角色作用域显示提示横幅 */
function applyScopeUI() {
const scope = _roleScope;
const scopeType = scope ? scope.Type : 'member';
const titleEl = document.getElementById('page-title');
const bannerEl = document.getElementById('scopeBanner');
const isAdmin = scopeType === 'admin';
const titleMap = {
admin: '产品管理',
dept_manager: '部门产品',
workgroup_lead: '工作组产品',
member: '产品列表',
};
const hintMap = {
admin: '管理员视图:可查看和管理全公司产品模板,含跨公司特权模式。',
dept_manager: '部门负责人视图:可查看本部门可用产品模板。',
workgroup_lead: '工作组组长视图:可查看本工作组可用产品模板。',
member: '普通组员视图:可查看可用产品列表。',
};
const finalTitle = currentServer === 'gvsds-master' ? titleMap[scopeType] : `${currentServer} - ${titleMap[scopeType]}`;
titleEl.textContent = finalTitle;
GERP.SetTitle(finalTitle);
bannerEl.className = `product-scope-banner ${isAdmin ? 'admin' : 'normal'}`;
const icon = isAdmin ? 'fa-shield-alt' : 'fa-info-circle';
bannerEl.innerHTML = `<i class="fas ${icon}"></i><span>${hintMap[scopeType] || ''}</span>`;
bannerEl.style.display = 'flex';
}
/** 模板类型 / 状态渲染映射(OrderTemplate choices) */
const TPL_TYPE_MAP = {
'service': '服务', 'goods': '商品', 'membership': '会员',
'recharge': '充值', 'custom': '自定义'
};
const TPL_STATUS_MAP = { 0: '草稿', 1: '启用', 2: '归档' };
async function initTable() {
const containerEl = document.getElementById('TableContainer');
const isAdmin = await checkAdmin();
applyScopeUI();
if (isAdmin) {
isAdminMode = true;
const badge = document.getElementById('admin-badge');
if (badge) badge.style.display = 'inline-block';
}
try {
let columns;
if (currentServer === 'gvsds-master') {
const columnsResp = await fetch(buildApiUrl('/API/FormAPI/Columns/OrderTemplate'), { credentials: 'include' });
const columnsResult = await columnsResp.json();
if (columnsResult.code !== 0) {
containerEl.innerHTML = `<div style="color:var(--DangerColor);padding:40px;text-align:center;">加载列配置失败: ${columnsResult.msg || ''}</div>`;
return;
}
columns = columnsResult.data || [];
} else {
columns = getDefaultColumns();
}
_tableInstance = new TableEngine({
container: '#TableContainer',
tableName: 'OrderTemplate',
columns: columns,
data: [],
pageSize: 20,
showPagination: true,
showCheckbox: isAdmin,
editable: isAdmin,
sortable: true,
filterable: true,
striped: true,
hoverable: true,
bordered: true,
columnDistribution: 'equal',
api: {
get: buildApiUrl('/API/FormAPI/Get/OrderTemplate'),
add: buildApiUrl('/API/FormAPI/Add/OrderTemplate'),
edit: buildApiUrl('/API/FormAPI/Modify/OrderTemplate/<id>'),
modify: buildApiUrl('/API/FormAPI/ModifyItem/OrderTemplate/<id>'),
delete: buildApiUrl('/API/FormAPI/Delete/OrderTemplate/<id>'),
columns: buildApiUrl('/API/FormAPI/Columns/OrderTemplate')
},
onView: (row) => {
let url = `/pages/inventory/product-detail.html?TemplateUUID=${encodeURIComponent(row.TemplateUUID || '')}`;
if (currentServer !== 'gvsds-master') url += `&server=${encodeURIComponent(currentServer)}`;
GERP.Navigate(url, row.TemplateName || '产品详情');
},
});
} catch (error) {
console.error('initTable error:', error);
containerEl.innerHTML = `<div style="color:var(--DangerColor);padding:40px;text-align:center;">初始化失败: ${error.message}</div>`;
}
}
function getDefaultColumns() {
return [
{ field: 'TemplateUUID', title: '模板UUID', primaryKey: true, editable: false, hideInForm: true },
{ field: 'TemplateCode', title: '产品编号', type: 'text' },
{ field: 'TemplateName', title: '产品名称', type: 'text' },
{ field: 'TemplateDescription', title: '产品描述', type: 'textarea' },
{ field: 'TemplateType', title: '类别', type: 'select', options: Object.entries(TPL_TYPE_MAP).map(([v, l]) => ({ value: v, label: l })) },
{ field: 'TemplateStatus', title: '状态', type: 'select', options: Object.entries(TPL_STATUS_MAP).map(([v, l]) => ({ value: Number(v), label: l })) },
{ field: 'RequirePayment', title: '需要支付', type: 'select', options: [{ value: true, label: '是' }, { value: false, label: '否' }] },
{ field: 'AllowRefund', title: '允许退款', type: 'select', options: [{ value: true, label: '是' }, { value: false, label: '否' }] },
{ field: 'TemplateSort', title: '排序', type: 'number' },
{ field: 'CreateTime', title: '创建时间', type: 'datetime', editable: false },
];
}
initTable();
</script>
+165 -52
View File
@@ -1,59 +1,172 @@
<div class="GERPPage">
<div class="GERPPageHeader">
<h1 class="GERPPageTitle">销售订单</h1>
<p class="GERPPageDesc">管理和跟踪所有销售订单</p>
</div>
<style>
.GERPPage { padding: 24px; max-width: 1400px; margin: 0 auto; height: 100%; display: flex; flex-direction: column; box-sizing: border-box; }
.order-page { padding: 20px; flex: 1; min-height: 0; display: flex; flex-direction: column; box-sizing: border-box; }
.order-header { display: flex; align-items: center; margin-bottom: 12px; gap: 12px; flex-shrink: 0; }
.order-header h1 { margin: 0; flex: 1; }
.order-container { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
.order-scope-banner { padding: 10px 14px; border-radius: 8px; font-size: 13px; margin-bottom: 12px; display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.order-scope-banner.admin { background: rgba(30,64,175,0.08); color: #1e40af; border: 1px solid rgba(30,64,175,0.2); }
.order-scope-banner.normal { background: rgba(100,116,139,0.08); color: #475569; border: 1px solid rgba(100,116,139,0.2); }
.order-scope-banner i { font-size: 16px; }
</style>
<div class="GERPCard">
<div class="GERPCardHeader">
<h3 class="GERPCardTitle">订单列表</h3>
<button class="GERPBtn Primary">新建订单</button>
<div class="GERPPage">
<div class="order-page">
<div class="order-header">
<h1 class="PageTitle">
<i class="fas fa-shopping-cart icon-primary icon-left-lg"></i>
<span id="page-title">销售订单</span>
<span id="admin-badge" style="display:none;margin-left:8px;padding:2px 10px;border-radius:4px;background:#1e40af;color:#fff;font-size:12px;font-weight:600;"><i class="fas fa-shield-alt"></i> 特权模式</span>
</h1>
</div>
<table class="GERPTable">
<thead>
<tr>
<th>订单号</th>
<th>客户名称</th>
<th>订单金额</th>
<th>下单日期</th>
<th>交付日期</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td>SO-2024-001</td>
<td>张三科技有限公司</td>
<td>¥25,000</td>
<td>2024-03-26</td>
<td>2024-04-05</td>
<td><span class="GERPTag Success">已交付</span></td>
<td><button class="GERPBtn Sm Secondary">查看</button></td>
</tr>
<tr>
<td>SO-2024-002</td>
<td>李四制造厂</td>
<td>¥18,500</td>
<td>2024-03-25</td>
<td>2024-04-10</td>
<td><span class="GERPTag Warning">生产中</span></td>
<td><button class="GERPBtn Sm Secondary">查看</button></td>
</tr>
<tr>
<td>SO-2024-003</td>
<td>王五贸易公司</td>
<td>¥8,000</td>
<td>2024-03-25</td>
<td>2024-04-02</td>
<td><span class="GERPTag Info">待确认</span></td>
<td><button class="GERPBtn Sm Secondary">查看</button></td>
</tr>
</tbody>
</table>
<div id="scopeBanner" class="order-scope-banner normal" style="display:none;"></div>
<div class="order-container" id="TableContainer"></div>
</div>
</div>
<script>
GERP.SetTitle('销售订单');
const urlParams = new URLSearchParams(window.location.search);
const currentServer = urlParams.get('server') || 'gvsds-master';
let _tableInstance = null;
let isAdminMode = false;
let _roleScope = null;
/** 检查当前用户是否具有管理员特权,并填充 _roleScope */
async function checkAdmin() {
try {
const resp = await fetch('/API/FormAPI/CheckAdmin', { credentials: 'include' });
const result = await resp.json();
if (result.code === 0 && result.data) {
_roleScope = result.data.role_scope || null;
return result.data.is_admin;
}
return false;
} catch (e) { return false; }
}
function buildApiUrl(path, params = {}) {
const allParams = {};
if (currentServer !== 'gvsds-master') allParams.server = currentServer;
if (isAdminMode) allParams.admin_verify = '1';
Object.assign(allParams, params);
const qs = new URLSearchParams(allParams).toString();
return qs ? `${path}?${qs}` : path;
}
/** 根据角色作用域显示提示横幅 */
function applyScopeUI() {
const scope = _roleScope;
const scopeType = scope ? scope.Type : 'member';
const titleEl = document.getElementById('page-title');
const bannerEl = document.getElementById('scopeBanner');
const isAdmin = scopeType === 'admin';
const titleMap = {
admin: '销售订单',
dept_manager: '部门订单',
workgroup_lead: '工作组订单',
member: '我的订单',
};
const hintMap = {
admin: '管理员视图:可查看全公司订单,含跨公司特权模式。',
dept_manager: '部门负责人视图:仅可查看本部门相关订单。',
workgroup_lead: '工作组组长视图:仅可查看本工作组相关订单。',
member: '普通组员视图:仅可查看自己的订单。',
};
const finalTitle = currentServer === 'gvsds-master' ? titleMap[scopeType] : `${currentServer} - ${titleMap[scopeType]}`;
titleEl.textContent = finalTitle;
GERP.SetTitle(finalTitle);
bannerEl.className = `order-scope-banner ${isAdmin ? 'admin' : 'normal'}`;
const icon = isAdmin ? 'fa-shield-alt' : 'fa-info-circle';
bannerEl.innerHTML = `<i class="fas ${icon}"></i><span>${hintMap[scopeType] || ''}</span>`;
bannerEl.style.display = 'flex';
}
/** 订单状态渲染(SmallIntegerField choices) */
const ORDER_STATUS_MAP = {
0: '待确认', 1: '已确认', 2: '已支付', 3: '处理中',
4: '已完成', 5: '已取消', 6: '退款中', 7: '已退款', 8: '已关闭'
};
const ORDER_STATUS_TAG_CLASS = {
0: 'Info', 1: 'Info', 2: 'Success', 3: 'Warning',
4: 'Success', 5: 'Danger', 6: 'Warning', 7: 'Danger', 8: 'Secondary'
};
async function initTable() {
const containerEl = document.getElementById('TableContainer');
const isAdmin = await checkAdmin();
applyScopeUI();
if (isAdmin) {
isAdminMode = true;
const badge = document.getElementById('admin-badge');
if (badge) badge.style.display = 'inline-block';
}
try {
let columns;
if (currentServer === 'gvsds-master') {
const columnsResp = await fetch(buildApiUrl('/API/FormAPI/Columns/Order'), { credentials: 'include' });
const columnsResult = await columnsResp.json();
if (columnsResult.code !== 0) {
containerEl.innerHTML = `<div style="color:var(--DangerColor);padding:40px;text-align:center;">加载列配置失败: ${columnsResult.msg || ''}</div>`;
return;
}
columns = columnsResult.data || [];
} else {
columns = getDefaultColumns();
}
_tableInstance = new TableEngine({
container: '#TableContainer',
tableName: 'Order',
columns: columns,
data: [],
pageSize: 20,
showPagination: true,
showCheckbox: true,
editable: isAdmin,
sortable: true,
filterable: true,
striped: true,
hoverable: true,
bordered: true,
columnDistribution: 'equal',
api: {
get: buildApiUrl('/API/FormAPI/Get/Order'),
add: buildApiUrl('/API/FormAPI/Add/Order'),
edit: buildApiUrl('/API/FormAPI/Modify/Order/<id>'),
modify: buildApiUrl('/API/FormAPI/ModifyItem/Order/<id>'),
delete: buildApiUrl('/API/FormAPI/Delete/Order/<id>'),
columns: buildApiUrl('/API/FormAPI/Columns/Order')
},
onView: (row) => {
let url = `/pages/sales/order-detail.html?OrderUUID=${encodeURIComponent(row.OrderUUID || '')}`;
if (currentServer !== 'gvsds-master') url += `&server=${encodeURIComponent(currentServer)}`;
GERP.Navigate(url, row.OrderNO || '订单详情');
},
});
} catch (error) {
console.error('initTable error:', error);
containerEl.innerHTML = `<div style="color:var(--DangerColor);padding:40px;text-align:center;">初始化失败: ${error.message}</div>`;
}
}
function getDefaultColumns() {
return [
{ field: 'OrderUUID', title: '订单UUID', primaryKey: true, editable: false, hideInForm: true },
{ field: 'OrderNO', title: '订单编号', type: 'text', editable: false },
{ field: 'OrderTitle', title: '订单标题', type: 'text' },
{ field: 'OrderType', title: '订单类型', type: 'text' },
{ field: 'TotalAmount', title: '总金额', type: 'number', editable: false },
{ field: 'PayAmount', title: '实付金额', type: 'number', editable: false },
{ field: 'OrderStatus', title: '订单状态', type: 'select', options: Object.entries(ORDER_STATUS_MAP).map(([v, l]) => ({ value: Number(v), label: l })) },
{ field: 'CreateTime', title: '创建时间', type: 'datetime', editable: false },
{ field: 'CompleteTime', title: '完成时间', type: 'datetime', editable: false },
];
}
initTable();
</script>
+295
View File
@@ -0,0 +1,295 @@
<style>
.audit-log-page { padding: 24px; height: 100%; display: flex; flex-direction: column; box-sizing: border-box; }
.audit-log-header { display: flex; align-items: center; margin-bottom: 16px; gap: 12px; flex-shrink: 0; flex-wrap: wrap; }
.audit-log-header h1 { margin: 0; flex: 1; min-width: 200px; }
.audit-log-filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; flex-shrink: 0; align-items: center; }
.audit-log-filters label { font-size: 13px; color: var(--TextSecondary, #64748b); margin-right: 4px; }
.audit-log-filters select, .audit-log-filters input[type="text"], .audit-log-filters input[type="date"] {
padding: 6px 10px; border: 1px solid var(--BorderColor, #e2e8f0); border-radius: 6px; font-size: 13px; background: var(--CardBackground, #fff); color: var(--TextPrimary, #1e293b);
}
.audit-log-table-wrap { flex: 1; min-height: 0; overflow: auto; border: 1px solid var(--BorderColor, #e2e8f0); border-radius: 8px; background: var(--CardBackground, #fff); }
.audit-log-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.audit-log-table th { position: sticky; top: 0; background: var(--BgSecondary, #f8fafc); padding: 10px 12px; text-align: left; font-weight: 600; color: var(--TextSecondary, #64748b); border-bottom: 1px solid var(--BorderColor, #e2e8f0); z-index: 1; }
.audit-log-table td { padding: 10px 12px; border-bottom: 1px solid var(--BorderColor, #e2e8f0); vertical-align: top; color: var(--TextPrimary, #1e293b); }
.audit-log-table tr:hover td { background: var(--BgHover, #f1f5f9); }
.audit-log-action { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
.audit-log-action.add { background: #dcfce7; color: #166534; }
.audit-log-action.modify { background: #fef9c3; color: #854d0e; }
.audit-log-action.delete { background: #fee2e2; color: #991b1b; }
.audit-log-empty { text-align: center; padding: 60px 20px; color: var(--TextSecondary, #64748b); }
.audit-log-empty i { font-size: 48px; margin-bottom: 12px; display: block; color: var(--TextTertiary, #94a3b8); }
.audit-log-detail-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: none; align-items: center; justify-content: center; z-index: 1000; }
.audit-log-detail-modal.show { display: flex; }
.audit-log-detail-content { background: var(--CardBackground, #fff); border-radius: 12px; padding: 24px; max-width: 800px; width: 90%; max-height: 80vh; overflow: auto; }
.audit-log-detail-content h3 { margin: 0 0 16px; font-size: 16px; }
.audit-log-detail-section { margin-bottom: 16px; }
.audit-log-detail-section h4 { margin: 0 0 8px; font-size: 13px; color: var(--TextSecondary, #64748b); font-weight: 600; }
.audit-log-detail-json { background: var(--BgSecondary, #f8fafc); border: 1px solid var(--BorderColor, #e2e8f0); border-radius: 6px; padding: 12px; font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; white-space: pre-wrap; word-break: break-all; max-height: 240px; overflow: auto; }
.audit-log-pagination { display: flex; gap: 8px; justify-content: center; align-items: center; margin-top: 12px; flex-shrink: 0; }
.audit-log-pagination button { padding: 6px 12px; border: 1px solid var(--BorderColor, #e2e8f0); border-radius: 6px; background: var(--CardBackground, #fff); color: var(--TextPrimary, #1e293b); cursor: pointer; font-size: 13px; }
.audit-log-pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
.audit-log-pagination button:hover:not(:disabled) { background: var(--BgHover, #f1f5f9); }
.audit-log-pagination .page-info { font-size: 13px; color: var(--TextSecondary, #64748b); }
</style>
<div class="audit-log-page">
<div class="audit-log-header">
<h1 class="PageTitle">
<i class="fas fa-history icon-primary icon-left-lg"></i>
<span>操作审计日志</span>
</h1>
<button class="GERPBtn Secondary" onclick="loadAuditLogs()" style="display:flex;align-items:center;gap:6px;">
<i class="fas fa-sync"></i> 刷新
</button>
</div>
<div class="audit-log-filters">
<label>表名:</label>
<select id="filterTable">
<option value="">全部</option>
<option value="User">User</option>
<option value="Role">Role</option>
<option value="Permission">Permission</option>
<option value="Tenant">Tenant</option>
<option value="Company">Company</option>
</select>
<label>操作:</label>
<select id="filterAction">
<option value="">全部</option>
<option value="add">新增</option>
<option value="modify">修改</option>
<option value="delete">删除</option>
</select>
<label>开始:</label>
<input type="date" id="filterDateFrom" />
<label>结束:</label>
<input type="date" id="filterDateTo" />
<button class="GERPBtn Primary" onclick="onFilter()" style="display:flex;align-items:center;gap:6px;">
<i class="fas fa-search"></i> 查询
</button>
<button class="GERPBtn Secondary" onclick="onResetFilter()" style="display:flex;align-items:center;gap:6px;">
<i class="fas fa-undo"></i> 重置
</button>
</div>
<div class="audit-log-table-wrap">
<table class="audit-log-table">
<thead>
<tr>
<th style="width:160px;">时间</th>
<th style="width:110px;">表名</th>
<th style="width:80px;">操作</th>
<th style="width:180px;">目标UUID</th>
<th style="width:140px;">IP地址</th>
<th>操作人</th>
<th style="width:80px;">详情</th>
</tr>
</thead>
<tbody id="auditLogBody"></tbody>
</table>
<div class="audit-log-empty" id="emptyState" style="display:none;">
<i class="fas fa-inbox"></i>
<div>暂无审计日志</div>
</div>
<div id="errorState" style="display:none;color:var(--DangerColor);padding:40px;text-align:center;"></div>
<div id="loadingState" style="display:none;text-align:center;padding:40px;color:var(--TextSecondary);">
<i class="fas fa-spinner fa-spin"></i> 加载中...
</div>
</div>
<div class="audit-log-pagination">
<button id="prevPageBtn" onclick="changePage(-1)"><i class="fas fa-chevron-left"></i> 上一页</button>
<span class="page-info" id="pageInfo">第 1 页</span>
<button id="nextPageBtn" onclick="changePage(1)">下一页 <i class="fas fa-chevron-right"></i></button>
</div>
</div>
<div class="audit-log-detail-modal" id="detailModal">
<div class="audit-log-detail-content">
<h3>审计日志详情</h3>
<div id="detailBody"></div>
<div style="text-align:right;margin-top:16px;">
<button class="GERPBtn Secondary" onclick="closeDetailModal()">关闭</button>
</div>
</div>
</div>
<script>
let _auditLogs = [];
let _currentPage = 1;
const _pageSize = 20;
let _totalCount = 0;
GERP.SetTitle('操作审计日志');
async function checkAdmin() {
try {
const resp = await fetch('/API/FormAPI/CheckAdmin', { credentials: 'include' });
const result = await resp.json();
return result.code === 0 && result.data && result.data.is_admin;
} catch (e) { return false; }
}
function buildFilters() {
const params = new URLSearchParams();
const table = document.getElementById('filterTable').value;
const action = document.getElementById('filterAction').value;
const dateFrom = document.getElementById('filterDateFrom').value;
const dateTo = document.getElementById('filterDateTo').value;
if (table) params.set('TableName', table);
if (action) params.set('Action', action);
if (dateFrom) params.set('CreateTime__gte', dateFrom + ' 00:00:00');
if (dateTo) params.set('CreateTime__lte', dateTo + ' 23:59:59');
params.set('page', _currentPage);
params.set('page_size', _pageSize);
params.set('ordering', '-CreateTime');
return params.toString();
}
async function loadAuditLogs() {
const bodyEl = document.getElementById('auditLogBody');
const emptyEl = document.getElementById('emptyState');
const errorEl = document.getElementById('errorState');
const loadingEl = document.getElementById('loadingState');
bodyEl.innerHTML = '';
emptyEl.style.display = 'none';
errorEl.style.display = 'none';
loadingEl.style.display = 'block';
try {
// 仅管理员可见
const isAdmin = await checkAdmin();
if (!isAdmin) {
loadingEl.style.display = 'none';
errorEl.textContent = '无权访问审计日志,仅管理员可查看';
errorEl.style.display = 'block';
return;
}
const query = buildFilters();
const url = `/API/FormAPI/Get/AuditLog?${query}&admin_verify=1`;
const resp = await fetch(url, { credentials: 'include' });
const result = await resp.json();
loadingEl.style.display = 'none';
if (result.code !== 0) {
errorEl.textContent = result.msg || '加载失败';
errorEl.style.display = 'block';
return;
}
const data = result.data;
_auditLogs = Array.isArray(data) ? data : (data && Array.isArray(data.records) ? data.records : []);
_totalCount = (typeof result.total === 'number') ? result.total
: (data && typeof data.total === 'number' ? data.total : _auditLogs.length);
if (_auditLogs.length === 0) {
emptyEl.style.display = 'block';
} else {
renderTable();
}
renderPagination();
} catch (err) {
loadingEl.style.display = 'none';
errorEl.textContent = '网络错误: ' + err.message;
errorEl.style.display = 'block';
}
}
function renderTable() {
const bodyEl = document.getElementById('auditLogBody');
bodyEl.innerHTML = _auditLogs.map(log => `
<tr>
<td>${formatTime(log.CreateTime)}</td>
<td>${escapeHtml(log.TableName || '')}</td>
<td><span class="audit-log-action ${escapeHtml(log.Action || '')}">${actionLabel(log.Action)}</span></td>
<td style="font-family:monospace;font-size:12px;">${escapeHtml(log.TargetUUID || '-')}</td>
<td style="font-family:monospace;font-size:12px;">${escapeHtml(log.IPAddress || '-')}</td>
<td>${escapeHtml(log.UserUUID_label || log.UserUUID || '-')}</td>
<td><button class="GERPBtn Secondary" style="padding:2px 8px;font-size:12px;" onclick="showDetail('${escapeHtml(log.AuditLogUUID)}')"><i class="fas fa-eye"></i> 详情</button></td>
</tr>
`).join('');
}
function renderPagination() {
const totalPages = Math.max(1, Math.ceil(_totalCount / _pageSize));
document.getElementById('pageInfo').textContent = `第 ${_currentPage} / ${totalPages} 页(共 ${_totalCount} 条)`;
document.getElementById('prevPageBtn').disabled = _currentPage <= 1;
document.getElementById('nextPageBtn').disabled = _currentPage >= totalPages;
}
function changePage(delta) {
const totalPages = Math.max(1, Math.ceil(_totalCount / _pageSize));
const next = _currentPage + delta;
if (next < 1 || next > totalPages) return;
_currentPage = next;
loadAuditLogs();
}
function onFilter() {
_currentPage = 1;
loadAuditLogs();
}
function onResetFilter() {
document.getElementById('filterTable').value = '';
document.getElementById('filterAction').value = '';
document.getElementById('filterDateFrom').value = '';
document.getElementById('filterDateTo').value = '';
_currentPage = 1;
loadAuditLogs();
}
function showDetail(uuid) {
const log = _auditLogs.find(x => x.AuditLogUUID === uuid);
if (!log) return;
const bodyEl = document.getElementById('detailBody');
const oldData = log.OldData ? (typeof log.OldData === 'string' ? safeJsonParse(log.OldData) : log.OldData) : null;
const newData = log.NewData ? (typeof log.NewData === 'string' ? safeJsonParse(log.NewData) : log.NewData) : null;
bodyEl.innerHTML = `
<div class="audit-log-detail-section">
<h4>基本信息</h4>
<div style="font-size:13px;line-height:1.8;">
<div><strong>时间:</strong> ${formatTime(log.CreateTime)}</div>
<div><strong>表名:</strong> ${escapeHtml(log.TableName || '')}</div>
<div><strong>操作:</strong> <span class="audit-log-action ${escapeHtml(log.Action || '')}">${actionLabel(log.Action)}</span></div>
<div><strong>目标UUID:</strong> <code>${escapeHtml(log.TargetUUID || '-')}</code></div>
<div><strong>IP地址:</strong> <code>${escapeHtml(log.IPAddress || '-')}</code></div>
<div><strong>操作人:</strong> ${escapeHtml(log.UserUUID_label || log.UserUUID || '-')}</div>
</div>
</div>
${oldData ? `
<div class="audit-log-detail-section">
<h4>变更前数据 (OldData)</h4>
<div class="audit-log-detail-json">${escapeHtml(JSON.stringify(oldData, null, 2))}</div>
</div>` : ''}
${newData ? `
<div class="audit-log-detail-section">
<h4>变更后数据 (NewData)</h4>
<div class="audit-log-detail-json">${escapeHtml(JSON.stringify(newData, null, 2))}</div>
</div>` : ''}
`;
document.getElementById('detailModal').classList.add('show');
}
function closeDetailModal() {
document.getElementById('detailModal').classList.remove('show');
}
function safeJsonParse(str) {
try { return JSON.parse(str); } catch (e) { return str; }
}
function actionLabel(action) {
const map = { add: '新增', modify: '修改', delete: '删除' };
return map[action] || action || '-';
}
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function formatTime(t) {
if (!t) return '-';
return String(t).replace('T', ' ').substring(0, 19);
}
loadAuditLogs();
</script>
+55 -12
View File
@@ -34,6 +34,7 @@
<h1 class="PageTitle">
<i class="fas fa-database icon-primary icon-left-lg"></i>
<span id="page-title">数据库管理</span>
<span id="admin-badge" style="display:none;margin-left:8px;padding:2px 10px;border-radius:4px;background:#1e40af;color:#fff;font-size:12px;font-weight:600;"><i class="fas fa-shield-alt"></i> 特权模式</span>
</h1>
</div>
@@ -46,6 +47,12 @@
<h3>系统表</h3>
<div id="dynamic-tables"></div>
</div>
<div id="deniedState" style="display:none;text-align:center;padding:60px 20px;color:var(--TextSecondary,#64748b);">
<i class="fas fa-lock" style="font-size:48px;margin-bottom:12px;display:block;color:var(--DangerColor,#ef4444);"></i>
<div style="font-size:16px;font-weight:600;color:var(--TextPrimary,#1e293b);margin-bottom:6px;">无权访问</div>
<div>该页面仅管理员可查看,请切换至管理员角色或联系管理员。</div>
</div>
</div>
</div>
@@ -53,17 +60,49 @@
const urlParams = new URLSearchParams(window.location.search);
const serverName = urlParams.get('server');
const isSubServer = !!serverName;
let isAdminMode = false;
if (isSubServer) {
document.getElementById('back-nav').style.display = 'flex';
document.getElementById('section-static').style.display = 'none';
document.getElementById('section-dynamic').querySelector('h3').textContent = '数据表';
GERP.SetTitle(`${serverName} — 数据库管理`);
document.getElementById('page-title').textContent = `${serverName} — 数据库管理`;
loadSubTables();
} else {
GERP.SetTitle('数据库管理');
loadMainTables();
/** 检查当前用户是否具有管理员特权 */
async function checkAdmin() {
try {
const resp = await fetch('/API/FormAPI/CheckAdmin', { credentials: 'include' });
const result = await resp.json();
return result.code === 0 && result.data && result.data.is_admin;
} catch (e) { return false; }
}
function buildApiUrl(path, params = {}) {
const allParams = {};
if (isSubServer) allParams.server = serverName;
if (isAdminMode) allParams.admin_verify = '1';
Object.assign(allParams, params);
const qs = new URLSearchParams(allParams).toString();
return qs ? `${path}?${qs}` : path;
}
async function init() {
// 入口校验:仅管理员可访问数据库管理页
const isAdmin = await checkAdmin();
if (!isAdmin) {
document.getElementById('section-static').style.display = 'none';
document.getElementById('section-dynamic').style.display = 'none';
document.getElementById('deniedState').style.display = 'block';
return;
}
isAdminMode = true;
const badge = document.getElementById('admin-badge');
if (badge) badge.style.display = 'inline-block';
if (isSubServer) {
document.getElementById('back-nav').style.display = 'flex';
document.getElementById('section-static').style.display = 'none';
document.getElementById('section-dynamic').querySelector('h3').textContent = '数据表';
GERP.SetTitle(`${serverName} — 数据库管理`);
document.getElementById('page-title').textContent = `${serverName} — 数据库管理`;
loadSubTables();
} else {
GERP.SetTitle('数据库管理');
loadMainTables();
}
}
function goBack() {
@@ -72,7 +111,7 @@
async function loadMainTables() {
try {
const response = await fetch('/API/FormAPI/Tables', { credentials: 'include' });
const response = await fetch(buildApiUrl('/API/FormAPI/Tables'), { credentials: 'include' });
const result = await response.json();
if (result.code === 0) {
const tables = result.data || [];
@@ -88,7 +127,9 @@
const container = document.getElementById('dynamic-tables');
container.innerHTML = '<div style="color: var(--TextMuted);">加载中...</div>';
try {
const resp = await fetch(`/api/gerp/msync/servers/${encodeURIComponent(serverName)}/form/?Action=tables`, { credentials: 'include' });
let subUrl = `/api/gerp/msync/servers/${encodeURIComponent(serverName)}/form/?Action=tables`;
if (isAdminMode) subUrl += '&admin_verify=1';
const resp = await fetch(subUrl, { credentials: 'include' });
const json = await resp.json();
if (json.code !== 0) {
container.innerHTML = `<div style="color: var(--DangerColor);">${json.msg || '加载失败'}</div>`;
@@ -131,4 +172,6 @@
: `?table=${encodeURIComponent(tableName)}&title=${encodeURIComponent(tableTitle)}`;
GERP.Navigate(base + params, tableTitle);
}
init();
</script>
+44 -5
View File
@@ -19,12 +19,18 @@
<h1 class="PageTitle">
<i class="fas fa-table icon-primary icon-left-lg"></i>
<span id="table-title">未选择表</span>
<span id="admin-badge" style="display:none;margin-left:8px;padding:2px 10px;border-radius:4px;background:#1e40af;color:#fff;font-size:12px;font-weight:600;"><i class="fas fa-shield-alt"></i> 特权模式</span>
</h1>
</div>
<div class="dbtable-info" id="table-info"></div>
<div class="dbtable-container" id="TableContainer"></div>
<div id="deniedState" style="display:none;text-align:center;padding:60px 20px;color:var(--TextSecondary,#64748b);">
<i class="fas fa-lock" style="font-size:48px;margin-bottom:12px;display:block;color:var(--DangerColor,#ef4444);"></i>
<div style="font-size:16px;font-weight:600;color:var(--TextPrimary,#1e293b);margin-bottom:6px;">无权访问</div>
<div>该页面仅管理员可查看,请切换至管理员角色或联系管理员。</div>
</div>
</div>
</div>
@@ -36,12 +42,41 @@
const isSubServer = !!serverName;
let _helper = null;
let isAdminMode = false;
/** 检查当前用户是否具有管理员特权 */
async function checkAdmin() {
try {
const resp = await fetch('/API/FormAPI/CheckAdmin', { credentials: 'include' });
const result = await resp.json();
return result.code === 0 && result.data && result.data.is_admin;
} catch (e) { return false; }
}
function showDenied() {
document.getElementById('TableContainer').style.display = 'none';
document.getElementById('table-info').style.display = 'none';
document.getElementById('deniedState').style.display = 'block';
}
async function initPage() {
if (!tableName) {
document.getElementById('table-title').textContent = '未选择表';
document.getElementById('TableContainer').innerHTML =
'<div style="color:var(--TextSecondary);padding:40px;text-align:center;">未指定表名</div>';
return;
}
// 入口校验:仅管理员可访问数据库表浏览页
const isAdmin = await checkAdmin();
if (!isAdmin) {
showDenied();
return;
}
isAdminMode = true;
const badge = document.getElementById('admin-badge');
if (badge) badge.style.display = 'inline-block';
if (!tableName) {
document.getElementById('table-title').textContent = '未选择表';
document.getElementById('TableContainer').innerHTML =
'<div style="color:var(--TextSecondary);padding:40px;text-align:center;">未指定表名</div>';
} else {
if (isSubServer) {
GERP.SetTitle(`${serverName} / ${tableTitle}`);
document.getElementById('table-title').textContent = `${serverName} — ${tableTitle}`;
@@ -55,6 +90,7 @@
serverName: serverName,
backUrl: `/pages/system/database.html?server=${encodeURIComponent(serverName)}`,
backTitle: `${serverName} 数据库管理`,
adminVerify: isAdminMode,
});
} else {
GERP.SetTitle(tableTitle);
@@ -66,7 +102,10 @@
apiMode: 'formapi',
backUrl: '/pages/system/database.html',
backTitle: '数据库管理',
adminVerify: isAdminMode,
});
}
}
initPage();
</script>
+93 -18
View File
@@ -96,6 +96,7 @@
<h1 class="PageTitle">
<i class="fas fa-bars icon-primary icon-left-lg"></i>
<span>菜单编辑器</span>
<span id="admin-badge" style="display:none;margin-left:8px;padding:2px 10px;border-radius:4px;background:#1e40af;color:#fff;font-size:12px;font-weight:600;"><i class="fas fa-shield-alt"></i> 特权模式</span>
</h1>
<div class="company-search-wrap" id="companySearchWrap">
<input type="text" class="GERPInput company-search-input" id="companySearchInput" placeholder="搜索公司添加标签页...">
@@ -191,6 +192,11 @@
</div>
</div>
</div>
<div id="deniedState" style="display:none;text-align:center;padding:60px 20px;color:var(--TextSecondary,#64748b);">
<i class="fas fa-lock" style="font-size:48px;margin-bottom:12px;display:block;color:var(--DangerColor,#ef4444);"></i>
<div style="font-size:16px;font-weight:600;color:var(--TextPrimary,#1e293b);margin-bottom:6px;">无权访问</div>
<div>该页面仅管理员可查看,请切换至管理员角色或联系管理员。</div>
</div>
</div>
</div>
@@ -211,6 +217,53 @@
let clipboard = null;
let animatingUUID = null;
let ctxTargetUUID = null;
let isAdminMode = false;
let _accessVerified = false; // 入口校验是否已通过
const currentServer = new URLSearchParams(window.location.search).get('server') || 'gvsds-master';
/** 检查当前用户是否具有管理员特权 */
async function checkAdmin() {
try {
const resp = await fetch(buildApiUrl('/API/FormAPI/CheckAdmin'), { credentials: 'include' });
const result = await resp.json();
return result.code === 0 && result.data && result.data.is_admin;
} catch (e) { return false; }
}
function buildApiUrl(path, params = {}) {
const allParams = {};
if (currentServer !== 'gvsds-master') allParams.server = currentServer;
if (isAdminMode) allParams.admin_verify = '1';
Object.assign(allParams, params);
const qs = new URLSearchParams(allParams).toString();
return qs ? `${path}?${qs}` : path;
}
// 入口校验:仅管理员可访问菜单编辑器;非管理员显示无权访问
function showDenied() {
const editor = document.querySelector('.menu-editor');
if (editor) {
// 隐藏编辑器内的所有交互元素,但保留 deniedState
Array.from(editor.children).forEach(el => {
if (el.id !== 'deniedState') el.style.display = 'none';
});
}
const denied = document.getElementById('deniedState');
if (denied) denied.style.display = 'block';
}
// 同步先隐藏关键交互,待异步校验通过后再启用
const _pendingVerify = checkAdmin().then(r => {
if (!r) {
showDenied();
return false;
}
_accessVerified = true;
isAdminMode = true;
const badge = document.getElementById('admin-badge');
if (badge) badge.style.display = 'inline-block';
return true;
});
const menuTabs = document.getElementById('menuTabs');
const tabAddBtn = document.getElementById('tabAddBtn');
@@ -358,7 +411,7 @@
const kw = tabAddSearch.value.trim();
if (!kw) { tabAddResults.innerHTML = ''; return; }
tabSearchTimer = setTimeout(() => {
fetch(`/API/FormAPI/CompanySearch?keyword=${encodeURIComponent(kw)}&limit=10`, { credentials: 'include' })
fetch(buildApiUrl('/API/FormAPI/CompanySearch', { keyword: kw, limit: 10 }), { credentials: 'include' })
.then(r => r.json())
.then(res => {
if (res.code === 0 && res.data && res.data.length) {
@@ -389,7 +442,7 @@
companySearchClear.style.display = kw ? 'block' : 'none';
if (!kw) { companySearchDropdown.classList.remove('open'); return; }
companySearchTimer = setTimeout(() => {
fetch(`/API/FormAPI/CompanySearch?keyword=${encodeURIComponent(kw)}&limit=10`, { credentials: 'include' })
fetch(buildApiUrl('/API/FormAPI/CompanySearch', { keyword: kw, limit: 10 }), { credentials: 'include' })
.then(r => r.json())
.then(res => {
if (res.code === 0 && res.data && res.data.length) {
@@ -435,12 +488,15 @@
async function loadMenus() {
const tab = getActiveTab();
if (!tab) return;
// 入口校验:未通过管理员校验则不加载
if (!_accessVerified) { await _pendingVerify; }
if (!_accessVerified) return;
try {
const resp = await fetch('/API/FormAPI/Get/Menu?PageSize=200', { credentials: 'include' });
// 使用专门的菜单管理 API,后端按公司隔离返回(公有 + 当前公司私有)
const resp = await fetch(buildApiUrl('/API/Menu/AdminList', { CompanyUUID: tab.companyUUID }), { credentials: 'include' });
const result = await resp.json();
if (result.code === 0) {
const allMenus = result.data || [];
tab.menus = allMenus.filter(m => !m.CompanyUUID || m.CompanyUUID === tab.companyUUID);
tab.menus = result.data || [];
} else { tab.menus = []; }
} catch(e) { tab.menus = []; }
renderPreview();
@@ -697,7 +753,7 @@
};
let actualUUID = null;
try {
const resp = await fetch('/API/FormAPI/Add/Menu', {
const resp = await fetch(buildApiUrl('/API/Menu/Save'), {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(data).toString()
@@ -780,7 +836,7 @@
};
let actualUUID = null;
try {
const resp = await fetch('/API/FormAPI/Add/Menu', {
const resp = await fetch(buildApiUrl('/API/Menu/Save'), {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(data).toString()
@@ -843,12 +899,26 @@
};
}
async function saveMenuSort(u, newSort) {
async function saveMenuSort(menuObj, newSort) {
try {
const resp = await fetch(`/API/FormAPI/ModifyItem/Menu/${u}`, {
method: 'PATCH', credentials: 'include',
const data = {
MenuUUID: menuObj.MenuUUID,
CompanyUUID: menuObj.CompanyUUID || '',
ParentMenuUUID: menuObj.ParentMenuUUID || '',
MenuName: menuObj.MenuName || '',
MenuTitle: menuObj.MenuTitle || '',
MenuIcon: menuObj.MenuIcon || '',
MenuLink: menuObj.MenuLink || '',
MenuType: menuObj.MenuType != null ? menuObj.MenuType : 0,
MenuSort: newSort,
MenuStatus: menuObj.MenuStatus != null ? menuObj.MenuStatus : 1,
PermissionLevel: menuObj.PermissionLevel != null ? menuObj.PermissionLevel : 1.0,
PermissionAttr: menuObj.PermissionAttr || '',
};
const resp = await fetch(buildApiUrl('/API/Menu/Save'), {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ MenuSort: newSort }).toString()
body: new URLSearchParams(data).toString()
});
const result = await resp.json();
return result.code === 0;
@@ -867,8 +937,8 @@
const targetSort = target.MenuSort || 0;
current.MenuSort = targetSort;
target.MenuSort = currentSort;
const ok1 = await saveMenuSort(current.MenuUUID, current.MenuSort);
const ok2 = await saveMenuSort(target.MenuUUID, target.MenuSort);
const ok1 = await saveMenuSort(current, current.MenuSort);
const ok2 = await saveMenuSort(target, target.MenuSort);
if (ok1 && ok2) { renderPreview(); }
else { current.MenuSort = currentSort; target.MenuSort = targetSort; await loadMenus(); }
}
@@ -877,9 +947,10 @@
const tab = getActiveTab();
if (!tab || !tab.selectedUUID) return;
const data = getFormData();
data.MenuUUID = tab.selectedUUID;
try {
const resp = await fetch(`/API/FormAPI/ModifyItem/Menu/${tab.selectedUUID}`, {
method: 'PATCH', credentials: 'include',
const resp = await fetch(buildApiUrl('/API/Menu/Save'), {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(data).toString()
});
@@ -904,7 +975,7 @@
MenuType: 1, MenuSort: maxSort + 1, PermissionLevel: 1.0, PermissionAttr: '', MenuStatus: 1,
};
try {
const resp = await fetch('/API/FormAPI/Add/Menu', {
const resp = await fetch(buildApiUrl('/API/Menu/Save'), {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(data).toString()
@@ -924,7 +995,11 @@
const nodeEl = menuPreview.querySelector(`[data-uuid="${u}"]`);
if (nodeEl) { nodeEl.classList.add('anim-out'); await new Promise(r => setTimeout(r, 280)); }
try {
const resp = await fetch(`/API/FormAPI/Delete/Menu/${u}`, { method: 'DELETE', credentials: 'include' });
const resp = await fetch(buildApiUrl('/API/Menu/Delete'), {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ MenuUUID: u }).toString()
});
const result = await resp.json();
if (result.code === 0) {
if (tab.selectedUUID === u) { tab.selectedUUID = null; updateFormState(); }
@@ -946,7 +1021,7 @@
MenuLink: '', MenuType: 0, MenuSort: maxSort + 1, PermissionLevel: 1.0, PermissionAttr: '', MenuStatus: 1,
};
try {
const resp = await fetch('/API/FormAPI/Add/Menu', {
const resp = await fetch(buildApiUrl('/API/Menu/Save'), {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(data).toString()
+32 -3
View File
@@ -145,6 +145,7 @@
<h1 class="PageTitle">
<i class="fas fa-network-wired icon-primary icon-left-lg"></i>
MSYNC 服务器管理
<span id="admin-badge" style="display:none;margin-left:8px;padding:2px 10px;border-radius:4px;background:#1e40af;color:#fff;font-size:12px;font-weight:600;"><i class="fas fa-shield-alt"></i> 特权模式</span>
</h1>
<div style="display:flex;align-items:center;gap:12px;">
<span class="msync-timezone-badge" id="tz-badge">
@@ -165,6 +166,24 @@
GERP.SetTitle('MSYNC 服务器管理');
const API_BASE = '/api/gerp/msync/servers/';
let isAdminMode = false;
/** 检查当前用户是否具有管理员特权 */
async function checkAdmin() {
try {
const resp = await fetch('/API/FormAPI/CheckAdmin', { credentials: 'include' });
const result = await resp.json();
return result.code === 0 && result.data && result.data.is_admin;
} catch (e) { return false; }
}
function buildApiUrl(path, params = {}) {
const allParams = {};
if (isAdminMode) allParams.admin_verify = '1';
Object.assign(allParams, params);
const qs = new URLSearchParams(allParams).toString();
return qs ? `${path}?${qs}` : path;
}
// 时区工具
const userTZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -213,8 +232,18 @@
const statsEl = document.getElementById('stats');
listEl.innerHTML = '<div class="msync-empty"><i class="fas fa-spinner fa-spin"></i><p>加载中...</p></div>';
// 检查管理员特权(每次刷新时检查,仅当 URL 带 admin_verify=1 时启用)
if (!isAdminMode) {
const _hasAdminVerify = new URLSearchParams(window.location.search).get('admin_verify') === '1';
isAdminMode = _hasAdminVerify ? await checkAdmin() : false;
if (isAdminMode) {
const badge = document.getElementById('admin-badge');
if (badge) badge.style.display = 'inline-block';
}
}
try {
const resp = await fetch(API_BASE, { credentials: 'include' });
const resp = await fetch(buildApiUrl(API_BASE), { credentials: 'include' });
const json = await resp.json();
if (json.code === 403) {
@@ -348,7 +377,7 @@
async function viewServerDetail(name) {
try {
const resp = await fetch(`${API_BASE}${encodeURIComponent(name)}/`, { credentials: 'include' });
const resp = await fetch(buildApiUrl(`${API_BASE}${encodeURIComponent(name)}/`), { credentials: 'include' });
const json = await resp.json();
if (json.code !== 0) {
if (window.GERPUI) window.GERPUI.ShowToast({ message: json.msg || '获取详情失败', type: 'error' });
@@ -398,7 +427,7 @@
type: 'danger',
onConfirm: async () => {
try {
const resp = await fetch(`${API_BASE}${encodeURIComponent(name)}/deregister/`, {
const resp = await fetch(buildApiUrl(`${API_BASE}${encodeURIComponent(name)}/deregister/`), {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
@@ -0,0 +1,344 @@
<style>
.pc-page { padding: 24px; height: 100%; display: flex; flex-direction: column; box-sizing: border-box; }
.pc-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-shrink: 0; }
.pc-header h1 { margin: 0; flex: 1; }
.pc-grid { flex: 1; min-height: 0; overflow: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 16px; align-content: start; }
.pc-card { background: var(--CardBackground, #fff); border: 1px solid var(--BorderColor, #e2e8f0); border-radius: 10px; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
.pc-card-header { display: flex; align-items: center; gap: 8px; }
.pc-card-header .platform { font-size: 16px; font-weight: 600; color: var(--TextPrimary, #1e293b); flex: 1; }
.pc-card-header .badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
.pc-card-header .badge.active { background: #dcfce7; color: #166534; }
.pc-card-header .badge.inactive { background: #fee2e2; color: #991b1b; }
.pc-card-header .badge.default { background: #dbeafe; color: #1e40af; }
.pc-card-body { font-size: 13px; color: var(--TextSecondary, #64748b); display: flex; flex-direction: column; gap: 4px; }
.pc-card-body .row { display: flex; gap: 8px; }
.pc-card-body .row .label { min-width: 80px; color: var(--TextTertiary, #94a3b8); }
.pc-card-body .row .value { color: var(--TextPrimary, #1e293b); word-break: break-all; }
.pc-card-body .secret-set { color: #166534; font-weight: 500; }
.pc-card-body .secret-unset { color: var(--TextTertiary, #94a3b8); }
.pc-card-actions { display: flex; gap: 8px; justify-content: flex-end; padding-top: 8px; border-top: 1px solid var(--BorderColor, #e2e8f0); }
.pc-empty { text-align: center; padding: 60px 20px; color: var(--TextSecondary, #64748b); grid-column: 1 / -1; }
.pc-empty i { font-size: 48px; margin-bottom: 12px; display: block; color: var(--TextTertiary, #94a3b8); }
.pc-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: none; align-items: center; justify-content: center; z-index: 1000; }
.pc-modal.show { display: flex; }
.pc-modal-content { background: var(--CardBackground, #fff); border-radius: 12px; padding: 24px; max-width: 600px; width: 90%; max-height: 85vh; overflow: auto; }
.pc-modal-content h3 { margin: 0 0 16px; font-size: 16px; }
.pc-form-group { margin-bottom: 12px; }
.pc-form-group label { display: block; font-size: 13px; color: var(--TextSecondary, #64748b); margin-bottom: 4px; font-weight: 600; }
.pc-form-group label .hint { font-weight: normal; color: var(--TextTertiary, #94a3b8); font-size: 12px; margin-left: 4px; }
.pc-form-group input, .pc-form-group select, .pc-form-group textarea {
width: 100%; padding: 8px 10px; border: 1px solid var(--BorderColor, #e2e8f0); border-radius: 6px; font-size: 13px; background: var(--CardBackground, #fff); color: var(--TextPrimary, #1e293b); box-sizing: border-box;
}
.pc-form-group .secret-input { display: flex; gap: 6px; }
.pc-form-group .secret-input input { flex: 1; }
.pc-form-group .secret-input button { padding: 6px 10px; border: 1px solid var(--BorderColor, #e2e8f0); border-radius: 6px; background: var(--CardBackground, #fff); cursor: pointer; font-size: 12px; white-space: nowrap; }
.pc-form-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--BorderColor, #e2e8f0); }
</style>
<div class="pc-page">
<div class="pc-header">
<h1 class="PageTitle">
<i class="fas fa-credit-card icon-primary icon-left-lg"></i>
<span>支付网关配置</span>
</h1>
<button class="GERPBtn Primary" onclick="openCreateModal()" style="display:flex;align-items:center;gap:6px;">
<i class="fas fa-plus"></i> 新增配置
</button>
<button class="GERPBtn Secondary" onclick="loadConfigs()" style="display:flex;align-items:center;gap:6px;">
<i class="fas fa-sync"></i> 刷新
</button>
</div>
<div class="pc-grid" id="configGrid">
<div class="pc-empty" id="emptyState" style="display:none;">
<i class="fas fa-inbox"></i>
<div>暂无支付网关配置</div>
</div>
<div id="errorState" style="display:none;color:var(--DangerColor);padding:40px;text-align:center;grid-column:1 / -1;"></div>
<div id="loadingState" style="display:none;text-align:center;padding:40px;color:var(--TextSecondary);grid-column:1 / -1;">
<i class="fas fa-spinner fa-spin"></i> 加载中...
</div>
</div>
</div>
<div class="pc-modal" id="editModal">
<div class="pc-modal-content">
<h3 id="modalTitle">新增支付网关配置</h3>
<div class="pc-form-group">
<label>平台 <span class="hint">*</span></label>
<select id="f_Platform">
<option value="wechat">微信支付</option>
<option value="alipay">支付宝</option>
<option value="bank">银行直连</option>
<option value="other">其他</option>
</select>
</div>
<div class="pc-form-group">
<label>AppID</label>
<input type="text" id="f_AppID" placeholder="应用ID" />
</div>
<div class="pc-form-group">
<label>商户号 MchID</label>
<input type="text" id="f_MchID" placeholder="商户号" />
</div>
<div class="pc-form-group">
<label>APIKey <span class="hint" id="f_APIKeyHint"></span></label>
<div class="secret-input">
<input type="password" id="f_APIKey" placeholder="留空或 *** 表示保留原值" />
<button type="button" onclick="toggleSecret('f_APIKey', this)"><i class="fas fa-eye"></i></button>
</div>
</div>
<div class="pc-form-group">
<label>PrivateKey <span class="hint" id="f_PrivateKeyHint"></span></label>
<div class="secret-input">
<textarea id="f_PrivateKey" rows="3" style="flex:1;font-family:monospace;font-size:11px;" placeholder="留空或 *** 表示保留原值"></textarea>
</div>
</div>
<div class="pc-form-group">
<label>PublicKey <span class="hint" id="f_PublicKeyHint"></span></label>
<div class="secret-input">
<textarea id="f_PublicKey" rows="3" style="flex:1;font-family:monospace;font-size:11px;" placeholder="留空或 *** 表示保留原值"></textarea>
</div>
</div>
<div class="pc-form-group">
<label>NotifyURL</label>
<input type="text" id="f_NotifyURL" placeholder="支付回调通知地址" />
</div>
<div class="pc-form-group">
<label>ReturnURL</label>
<input type="text" id="f_ReturnURL" placeholder="支付完成跳转地址" />
</div>
<div class="pc-form-group">
<label>CertPath</label>
<input type="text" id="f_CertPath" placeholder="证书路径(可选)" />
</div>
<div class="pc-form-group">
<label>CertKeyPath</label>
<input type="text" id="f_CertKeyPath" placeholder="证书密钥路径(可选)" />
</div>
<div class="pc-form-group" style="display:flex;gap:24px;">
<label style="display:flex;align-items:center;gap:6px;font-weight:normal;">
<input type="checkbox" id="f_ConfigDefault" /> 设为默认网关
</label>
<label style="display:flex;align-items:center;gap:6px;font-weight:normal;">
<input type="checkbox" id="f_ConfigStatus" checked /> 启用
</label>
</div>
<div class="pc-form-actions">
<button class="GERPBtn Secondary" onclick="closeModal()">取消</button>
<button class="GERPBtn Primary" id="saveBtn" onclick="saveConfig()">保存</button>
</div>
</div>
</div>
<script>
let _configs = [];
let _editingUUID = null;
GERP.SetTitle('支付网关配置');
const PLATFORM_LABELS = {
'wechat': '微信支付', 'alipay': '支付宝', 'bank': '银行直连', 'other': '其他',
};
async function checkAdmin() {
try {
const resp = await fetch('/API/FormAPI/CheckAdmin', { credentials: 'include' });
const result = await resp.json();
return result.code === 0 && result.data && result.data.is_admin;
} catch (e) { return false; }
}
async function loadConfigs() {
const gridEl = document.getElementById('configGrid');
const emptyEl = document.getElementById('emptyState');
const errorEl = document.getElementById('errorState');
const loadingEl = document.getElementById('loadingState');
// 清空除空状态/错误/加载之外的元素
[...gridEl.children].forEach(el => {
if (el !== emptyEl && el !== errorEl && el !== loadingEl) el.remove();
});
emptyEl.style.display = 'none';
errorEl.style.display = 'none';
loadingEl.style.display = 'block';
const isAdmin = await checkAdmin();
if (!isAdmin) {
loadingEl.style.display = 'none';
errorEl.textContent = '无权访问:仅管理员可查看支付网关配置';
errorEl.style.display = 'block';
return;
}
try {
const resp = await fetch('/API/Payment/Configs', { credentials: 'include' });
const result = await resp.json();
loadingEl.style.display = 'none';
if (result.code !== 0) {
errorEl.textContent = result.msg || '加载失败';
errorEl.style.display = 'block';
return;
}
_configs = result.data || [];
if (_configs.length === 0) {
emptyEl.style.display = 'block';
return;
}
renderGrid();
} catch (err) {
loadingEl.style.display = 'none';
errorEl.textContent = '网络错误: ' + err.message;
errorEl.style.display = 'block';
}
}
function renderGrid() {
const gridEl = document.getElementById('configGrid');
const cardsHtml = _configs.map(c => `
<div class="pc-card">
<div class="pc-card-header">
<span class="platform">${escapeHtml(PLATFORM_LABELS[c.Platform] || c.Platform)}</span>
${c.ConfigDefault ? '<span class="badge default">默认</span>' : ''}
<span class="badge ${c.ConfigStatus === 1 ? 'active' : 'inactive'}">${c.ConfigStatus === 1 ? '启用' : '停用'}</span>
</div>
<div class="pc-card-body">
<div class="row"><span class="label">AppID</span><span class="value">${escapeHtml(c.AppID || '-')}</span></div>
<div class="row"><span class="label">商户号</span><span class="value">${escapeHtml(c.MchID || '-')}</span></div>
<div class="row"><span class="label">APIKey</span><span class="value ${c.APIKeySet ? 'secret-set' : 'secret-unset'}">${c.APIKeySet ? '已设置 ***' : '未设置'}</span></div>
<div class="row"><span class="label">PrivateKey</span><span class="value ${c.PrivateKeySet ? 'secret-set' : 'secret-unset'}">${c.PrivateKeySet ? '已设置 ***' : '未设置'}</span></div>
<div class="row"><span class="label">PublicKey</span><span class="value ${c.PublicKeySet ? 'secret-set' : 'secret-unset'}">${c.PublicKeySet ? '已设置 ***' : '未设置'}</span></div>
${c.NotifyURL ? `<div class="row"><span class="label">回调</span><span class="value">${escapeHtml(c.NotifyURL)}</span></div>` : ''}
</div>
<div class="pc-card-actions">
<button class="GERPBtn Secondary" onclick="openEditModal('${escapeHtml(c.ConfigUUID)}')"><i class="fas fa-edit"></i> 编辑</button>
<button class="GERPBtn Danger" onclick="deleteConfig('${escapeHtml(c.ConfigUUID)}', '${escapeHtml(c.Platform)}')"><i class="fas fa-trash"></i> 删除</button>
</div>
</div>
`).join('');
gridEl.insertAdjacentHTML('beforeend', cardsHtml);
}
function openCreateModal() {
_editingUUID = null;
document.getElementById('modalTitle').textContent = '新增支付网关配置';
['f_Platform','f_AppID','f_MchID','f_APIKey','f_PrivateKey','f_PublicKey','f_NotifyURL','f_ReturnURL','f_CertPath','f_CertKeyPath'].forEach(id => {
document.getElementById(id).value = '';
});
document.getElementById('f_Platform').value = 'wechat';
document.getElementById('f_ConfigDefault').checked = false;
document.getElementById('f_ConfigStatus').checked = true;
document.getElementById('f_APIKeyHint').textContent = '';
document.getElementById('f_PrivateKeyHint').textContent = '';
document.getElementById('f_PublicKeyHint').textContent = '';
document.getElementById('editModal').classList.add('show');
}
async function openEditModal(uuid) {
const config = _configs.find(c => c.ConfigUUID === uuid);
if (!config) return;
_editingUUID = uuid;
document.getElementById('modalTitle').textContent = `编辑 - ${PLATFORM_LABELS[config.Platform] || config.Platform}`;
document.getElementById('f_Platform').value = config.Platform;
document.getElementById('f_AppID').value = config.AppID || '';
document.getElementById('f_MchID').value = config.MchID || '';
// 敏感字段:显示 ***,提示保留原值
document.getElementById('f_APIKey').value = config.APIKeySet ? '***' : '';
document.getElementById('f_PrivateKey').value = config.PrivateKeySet ? '***' : '';
document.getElementById('f_PublicKey').value = config.PublicKeySet ? '***' : '';
document.getElementById('f_APIKeyHint').textContent = config.APIKeySet ? '(已设置,*** 保留原值,清空将删除)' : '';
document.getElementById('f_PrivateKeyHint').textContent = config.PrivateKeySet ? '(已设置,*** 保留原值,清空将删除)' : '';
document.getElementById('f_PublicKeyHint').textContent = config.PublicKeySet ? '(已设置,*** 保留原值,清空将删除)' : '';
document.getElementById('f_NotifyURL').value = config.NotifyURL || '';
document.getElementById('f_ReturnURL').value = config.ReturnURL || '';
document.getElementById('f_CertPath').value = config.CertPath || '';
document.getElementById('f_CertKeyPath').value = config.CertKeyPath || '';
document.getElementById('f_ConfigDefault').checked = !!config.ConfigDefault;
document.getElementById('f_ConfigStatus').checked = config.ConfigStatus === 1;
document.getElementById('editModal').classList.add('show');
}
function closeModal() {
document.getElementById('editModal').classList.remove('show');
_editingUUID = null;
}
async function saveConfig() {
const payload = {
ConfigUUID: _editingUUID || null,
Platform: document.getElementById('f_Platform').value,
AppID: document.getElementById('f_AppID').value,
MchID: document.getElementById('f_MchID').value,
APIKey: document.getElementById('f_APIKey').value,
PrivateKey: document.getElementById('f_PrivateKey').value,
PublicKey: document.getElementById('f_PublicKey').value,
NotifyURL: document.getElementById('f_NotifyURL').value,
ReturnURL: document.getElementById('f_ReturnURL').value,
CertPath: document.getElementById('f_CertPath').value,
CertKeyPath: document.getElementById('f_CertKeyPath').value,
ConfigDefault: document.getElementById('f_ConfigDefault').checked,
ConfigStatus: document.getElementById('f_ConfigStatus').checked ? 1 : 0,
};
const btn = document.getElementById('saveBtn');
btn.disabled = true;
btn.textContent = '保存中...';
try {
const resp = await fetch('/API/Payment/SaveConfig', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await resp.json();
btn.disabled = false;
btn.textContent = '保存';
if (result.code === 0) {
if (window.GERPLayui) GERPLayui.Toast(result.msg || '保存成功', 'success');
closeModal();
loadConfigs();
} else {
if (window.GERPLayui) GERPLayui.Toast(result.msg || '保存失败', 'error');
}
} catch (err) {
btn.disabled = false;
btn.textContent = '保存';
if (window.GERPLayui) GERPLayui.Toast('网络错误: ' + err.message, 'error');
}
}
async function deleteConfig(uuid, platform) {
if (!confirm(`确认删除 ${PLATFORM_LABELS[platform] || platform} 网关配置?`)) return;
try {
const resp = await fetch(`/API/Payment/DeleteConfig/${uuid}`, {
method: 'POST',
credentials: 'include',
});
const result = await resp.json();
if (result.code === 0) {
if (window.GERPLayui) GERPLayui.Toast(result.msg || '已删除', 'success');
loadConfigs();
} else {
if (window.GERPLayui) GERPLayui.Toast(result.msg || '删除失败', 'error');
}
} catch (err) {
if (window.GERPLayui) GERPLayui.Toast('网络错误: ' + err.message, 'error');
}
}
function toggleSecret(inputId, btn) {
const input = document.getElementById(inputId);
if (input.type === 'password') {
input.type = 'text';
btn.innerHTML = '<i class="fas fa-eye-slash"></i>';
} else {
input.type = 'password';
btn.innerHTML = '<i class="fas fa-eye"></i>';
}
}
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
loadConfigs();
</script>
+18 -41
View File
@@ -1,4 +1,4 @@
<script src="/assets/gerp/CompanyCommon.js?v=20260625d"></script>
<script src="/assets/gerp/CompanyCommon.js?v=20260627c"></script>
<div class="GERPPage">
<div class="cp-page">
@@ -10,6 +10,7 @@
</h1>
<span id="admin-badge" style="display:none;margin-left:8px;padding:2px 10px;border-radius:4px;background:#1e40af;color:#fff;font-size:12px;font-weight:600;"><i class="fas fa-shield-alt"></i> 特权模式</span>
<button class="GERPBtn Primary" onclick="handleEdit()" style="margin-left:auto;"><i class="fas fa-edit"></i> 编辑</button>
<button class="GERPBtn Secondary" onclick="goPermissionTree()" title="按权限组勾选分配权限"><i class="fas fa-sitemap"></i> 权限树分配</button>
</div>
<div class="cp-container" id="detailContainer" style="overflow:auto;">
<div class="cp-loading"><i class="fas fa-spinner fa-spin"></i> 加载中...</div>
@@ -34,7 +35,6 @@
let currentData = null;
let userRoleTable = null;
let permTable = null;
let adminTable = null;
let isAdminMode = false;
/** 检查当前用户是否具有管理员特权 */
@@ -78,8 +78,8 @@
return;
}
// 先检查管理员特权
isAdminMode = await checkAdmin();
// 先检查管理员特权(仅当 URL 带 admin_verify=1 时启用)
isAdminMode = CompanyUtils.hasAdminVerifyArg() ? await checkAdmin() : false;
const adminBadge = document.getElementById('admin-badge');
if (isAdminMode) {
adminBadge.style.display = 'inline-block';
@@ -131,7 +131,6 @@
initUserRoleTable();
initPermTable();
initAdminTable();
} catch (e) {
containerEl.innerHTML = `<div class="cp-error"><i class="fas fa-exclamation-circle" style="margin-right:6px;"></i> 网络错误: ${CompanyUtils.esc(e.message)}</div>`;
}
@@ -164,7 +163,7 @@
bordered: true,
api: {
get: buildApiUrl('/API/FormAPI/Get/UserRole', { RoleUUID: roleUUID }),
add: buildApiUrl('/API/FormAPI/Add/UserRole'),
add: buildApiUrl('/API/FormAPI/Add/UserRole', { RoleUUID: roleUUID }),
edit: buildApiUrl('/API/FormAPI/Modify/UserRole/<id>'),
modify: buildApiUrl('/API/FormAPI/ModifyItem/UserRole/<id>'),
delete: buildApiUrl('/API/FormAPI/Delete/UserRole/<id>'),
@@ -201,7 +200,7 @@
bordered: true,
api: {
get: buildApiUrl('/API/FormAPI/Get/RolePermission', { RoleUUID: roleUUID }),
add: buildApiUrl('/API/FormAPI/Add/RolePermission'),
add: buildApiUrl('/API/FormAPI/Add/RolePermission', { RoleUUID: roleUUID }),
edit: buildApiUrl('/API/FormAPI/Modify/RolePermission/<id>'),
modify: buildApiUrl('/API/FormAPI/ModifyItem/RolePermission/<id>'),
delete: buildApiUrl('/API/FormAPI/Delete/RolePermission/<id>'),
@@ -212,40 +211,6 @@
});
}
function initAdminTable() {
adminTable = new TableEngine({
container: '#admin-table-container',
height: '200px',
tableName: 'RoleAdmin',
columns: [
{ field: 'RoleAdminUUID', title: '主键UUID', primaryKey: true, editable: false, hideInForm: true },
{ field: 'RoleUUID', title: '角色UUID', editable: false, hideInForm: true },
{ field: 'CreateTime', title: '创建时间', type: 'datetime', editable: false },
],
data: [],
pageSize: 20,
columnDistribution: 'equal',
showPagination: true,
showCheckbox: true,
editable: true,
sortable: true,
filterable: true,
striped: true,
hoverable: true,
bordered: true,
api: {
get: buildApiUrl('/API/FormAPI/Get/RoleAdmin', { RoleUUID: roleUUID }),
add: buildApiUrl('/API/FormAPI/Add/RoleAdmin'),
edit: buildApiUrl('/API/FormAPI/Modify/RoleAdmin/<id>'),
modify: buildApiUrl('/API/FormAPI/ModifyItem/RoleAdmin/<id>'),
delete: buildApiUrl('/API/FormAPI/Delete/RoleAdmin/<id>'),
},
onView: (row) => {
// 无需跳转
},
});
}
/** 编辑按钮:弹出编辑对话框 */
async function handleEdit() {
if (!currentData) return;
@@ -257,5 +222,17 @@
});
}
/** 跳转到权限树分配页面 */
function goPermissionTree() {
if (!roleUUID) {
if (window.GERPLayui) GERPLayui.Toast('缺少 RoleUUID', 'warning');
return;
}
let url = `/pages/system/role-permissions.html?RoleUUID=${encodeURIComponent(roleUUID)}`;
if (currentServer !== 'gvsds-master') url += `&server=${encodeURIComponent(currentServer)}`;
if (isAdminMode) url += '&admin_verify=1';
GERP.Navigate(url, '角色权限分配');
}
init();
</script>
@@ -0,0 +1,406 @@
<script src="/assets/gerp/CompanyCommon.js?v=20260627c"></script>
<style>
.rp-page { padding: 24px; height: 100%; display: flex; flex-direction: column; box-sizing: border-box; }
.rp-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-shrink: 0; flex-wrap: wrap; }
.rp-header h1 { margin: 0; flex: 1; min-width: 200px; }
.rp-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; flex-shrink: 0; padding: 12px; background: var(--CardBackground, #fff); border: 1px solid var(--BorderColor, #e2e8f0); border-radius: 8px; }
.rp-toolbar label { font-size: 13px; color: var(--TextSecondary, #64748b); margin-right: 4px; font-weight: 600; }
.rp-toolbar select { padding: 6px 10px; border: 1px solid var(--BorderColor, #e2e8f0); border-radius: 6px; font-size: 13px; min-width: 200px; background: var(--CardBackground, #fff); color: var(--TextPrimary, #1e293b); }
.rp-toolbar .stat { margin-left: auto; font-size: 13px; color: var(--TextSecondary, #64748b); }
.rp-toolbar .stat strong { color: var(--PrimaryColor, #2563eb); }
.rp-actions { display: flex; gap: 8px; }
.rp-tree-wrap { flex: 1; min-height: 0; overflow: auto; padding: 12px; background: var(--CardBackground, #fff); border: 1px solid var(--BorderColor, #e2e8f0); border-radius: 8px; }
.rp-group { margin-bottom: 16px; border: 1px solid var(--BorderColor, #e2e8f0); border-radius: 6px; overflow: hidden; }
.rp-group-header { display: flex; align-items: center; gap: 8px; padding: 10px 12px; background: var(--BgSecondary, #f8fafc); cursor: pointer; user-select: none; font-weight: 600; font-size: 14px; color: var(--TextPrimary, #1e293b); }
.rp-group-header:hover { background: var(--BgHover, #f1f5f9); }
.rp-group-header .toggle { transition: transform 0.2s; }
.rp-group.collapsed .toggle { transform: rotate(-90deg); }
.rp-group.collapsed .rp-group-body { display: none; }
.rp-group-header .count { margin-left: auto; font-size: 12px; color: var(--TextSecondary, #64748b); font-weight: normal; }
.rp-group-body { padding: 4px 0; }
.rp-perm-row { display: flex; align-items: center; gap: 10px; padding: 8px 16px 8px 36px; cursor: pointer; font-size: 13px; }
.rp-perm-row:hover { background: var(--BgHover, #f1f5f9); }
.rp-perm-row input[type="checkbox"] { cursor: pointer; }
.rp-perm-row .name { font-weight: 500; color: var(--TextPrimary, #1e293b); }
.rp-perm-row .code { font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; color: var(--TextSecondary, #64748b); padding: 1px 6px; background: var(--BgSecondary, #f8fafc); border-radius: 3px; }
.rp-perm-row .desc { color: var(--TextSecondary, #64748b); font-size: 12px; margin-left: auto; }
.rp-empty { text-align: center; padding: 60px 20px; color: var(--TextSecondary, #64748b); }
.rp-empty i { font-size: 48px; margin-bottom: 12px; display: block; color: var(--TextTertiary, #94a3b8); }
.rp-footer { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; flex-shrink: 0; padding: 12px; background: var(--CardBackground, #fff); border: 1px solid var(--BorderColor, #e2e8f0); border-radius: 8px; }
</style>
<div class="GERPPage">
<div class="rp-page">
<div class="rp-header">
<a href="javascript:void(0)" class="cp-back-btn" onclick="goBack()" style="cursor:pointer;color:var(--PrimaryColor);display:flex;align-items:center;gap:4px;font-size:14px;">
<i class="fas fa-arrow-left"></i> 返回
</a>
<h1 class="PageTitle">
<i class="fas fa-shield-alt icon-primary icon-left-lg"></i>
<span>角色权限分配</span>
</h1>
<span id="admin-badge" style="display:none;padding:2px 10px;border-radius:4px;background:#1e40af;color:#fff;font-size:12px;font-weight:600;"><i class="fas fa-shield-alt"></i> 特权模式</span>
</div>
<div class="rp-toolbar">
<label>角色:</label>
<select id="roleSelect" onchange="onRoleChange()"></select>
<label>公司:</label>
<select id="companySelect" onchange="onCompanyChange()"></select>
<div class="stat" id="permStat">共 <strong>0</strong> 项权限,已勾选 <strong>0</strong> 项</div>
<div class="rp-actions">
<button class="GERPBtn Secondary" onclick="expandAll()"><i class="fas fa-expand"></i> 全部展开</button>
<button class="GERPBtn Secondary" onclick="collapseAll()"><i class="fas fa-compress"></i> 全部折叠</button>
<button class="GERPBtn Secondary" onclick="checkAll()"><i class="fas fa-check-double"></i> 全选</button>
<button class="GERPBtn Secondary" onclick="uncheckAll()"><i class="fas fa-times"></i> 清空</button>
</div>
</div>
<div class="rp-tree-wrap" id="treeWrap">
<div class="rp-empty" id="emptyState">
<i class="fas fa-shield-alt"></i>
<div>请先选择角色和公司</div>
</div>
<div id="errorState" style="display:none;color:var(--DangerColor);padding:40px;text-align:center;"></div>
<div id="loadingState" style="display:none;text-align:center;padding:40px;color:var(--TextSecondary);">
<i class="fas fa-spinner fa-spin"></i> 加载中...
</div>
<div id="treeBody"></div>
</div>
<div class="rp-footer">
<button class="GERPBtn Primary" id="saveBtn" onclick="savePermissions()" disabled style="display:flex;align-items:center;gap:6px;">
<i class="fas fa-save"></i> 保存权限
</button>
</div>
<div id="deniedState" style="display:none;text-align:center;padding:60px 20px;color:var(--TextSecondary,#64748b);">
<i class="fas fa-lock" style="font-size:48px;margin-bottom:12px;display:block;color:var(--DangerColor,#ef4444);"></i>
<div style="font-size:16px;font-weight:600;color:var(--TextPrimary,#1e293b);margin-bottom:6px;">无权访问</div>
<div>该页面仅管理员可查看,请切换至管理员角色或联系管理员。</div>
</div>
</div>
</div>
<script>
const _urlParams = new URLSearchParams(window.location.search);
const _initRoleUUID = _urlParams.get('RoleUUID') || '';
const _initCompanyUUID = _urlParams.get('CompanyUUID') || '';
let _isAdminMode = _urlParams.get('admin_verify') === '1';
let _permissionTree = [];
let _roles = [];
let _companies = [];
let _originalCheckedSet = new Set();
let _hasChanges = false;
GERP.SetTitle('角色权限分配');
function goBack() {
if (window.history.length > 1) {
window.history.back();
} else {
GERP.Navigate('/pages/system/roles.html', '角色管理');
}
}
async function checkAdmin() {
try {
const resp = await fetch('/API/FormAPI/CheckAdmin', { credentials: 'include' });
const result = await resp.json();
return result.code === 0 && result.data && result.data.is_admin;
} catch (e) { return false; }
}
function apiQs(extra = {}) {
const params = {};
if (_isAdminMode) params.admin_verify = '1';
Object.assign(params, extra);
const qs = new URLSearchParams(params).toString();
return qs ? `?${qs}` : '';
}
async function init() {
// 入口校验:仅管理员可访问角色权限分配页
const isAdmin = await checkAdmin();
if (!isAdmin) {
document.querySelector('.rp-toolbar').style.display = 'none';
document.querySelector('.rp-tree-wrap').style.display = 'none';
document.querySelector('.rp-footer').style.display = 'none';
document.getElementById('deniedState').style.display = 'block';
return;
}
_isAdminMode = true;
document.getElementById('admin-badge').style.display = 'inline-block';
await Promise.all([loadRoles(), loadCompanies()]);
// 自动选中初始角色/公司
if (_initRoleUUID) {
const roleSel = document.getElementById('roleSelect');
for (const opt of roleSel.options) {
if (opt.value === _initRoleUUID) { opt.selected = true; break; }
}
}
if (_initCompanyUUID) {
const compSel = document.getElementById('companySelect');
for (const opt of compSel.options) {
if (opt.value === _initCompanyUUID) { opt.selected = true; break; }
}
}
if (_initRoleUUID && _initCompanyUUID) {
loadTree();
}
}
async function loadRoles() {
try {
const resp = await fetch(`/API/FormAPI/Get/Role${apiQs({ PageSize: '500' })}`, { credentials: 'include' });
const result = await resp.json();
if (result.code !== 0) return;
_roles = Array.isArray(result.data) ? result.data : [];
const sel = document.getElementById('roleSelect');
sel.innerHTML = '<option value="">请选择角色</option>' + _roles.map(r =>
`<option value="${r.RoleUUID}">${escapeHtml(r.RoleName || '')}</option>`
).join('');
} catch (e) { console.error(e); }
}
async function loadCompanies() {
try {
const resp = await fetch(`/API/FormAPI/Get/Company${apiQs({ PageSize: '500' })}`, { credentials: 'include' });
const result = await resp.json();
if (result.code !== 0) return;
_companies = Array.isArray(result.data) ? result.data : [];
const sel = document.getElementById('companySelect');
sel.innerHTML = '<option value="">请选择公司</option>' + _companies.map(c =>
`<option value="${c.CompanyUUID}">${escapeHtml(c.CompanyName || '')}</option>`
).join('');
} catch (e) { console.error(e); }
}
function onRoleChange() { if (getSelectedCompany()) loadTree(); }
function onCompanyChange() { if (getSelectedRole()) loadTree(); }
function getSelectedRole() { return document.getElementById('roleSelect').value; }
function getSelectedCompany() { return document.getElementById('companySelect').value; }
async function loadTree() {
const roleUUID = getSelectedRole();
const companyUUID = getSelectedCompany();
const treeBody = document.getElementById('treeBody');
const emptyEl = document.getElementById('emptyState');
const errorEl = document.getElementById('errorState');
const loadingEl = document.getElementById('loadingState');
const saveBtn = document.getElementById('saveBtn');
treeBody.innerHTML = '';
emptyEl.style.display = 'none';
errorEl.style.display = 'none';
loadingEl.style.display = 'none';
saveBtn.disabled = true;
_hasChanges = false;
if (!roleUUID || !companyUUID) {
emptyEl.style.display = 'block';
return;
}
loadingEl.style.display = 'block';
try {
const url = `/API/Role/PermissionsTree${apiQs({ RoleUUID: roleUUID, CompanyUUID: companyUUID })}`;
const resp = await fetch(url, { credentials: 'include' });
const result = await resp.json();
loadingEl.style.display = 'none';
if (result.code === 403) {
errorEl.textContent = result.msg || '无权操作';
errorEl.style.display = 'block';
return;
}
if (result.code !== 0) {
errorEl.textContent = result.msg || '加载失败';
errorEl.style.display = 'block';
return;
}
_permissionTree = (result.data && result.data.Groups) || [];
_originalCheckedSet = new Set(
_permissionTree.flatMap(g => g.Permissions.filter(p => p.Checked).map(p => p.PermUUID))
);
renderTree();
updateStat();
saveBtn.disabled = false;
} catch (err) {
loadingEl.style.display = 'none';
errorEl.textContent = '网络错误: ' + err.message;
errorEl.style.display = 'block';
}
}
function renderTree() {
const treeBody = document.getElementById('treeBody');
if (_permissionTree.length === 0) {
treeBody.innerHTML = '<div class="rp-empty"><i class="fas fa-inbox"></i><div>暂无权限定义</div></div>';
return;
}
treeBody.innerHTML = _permissionTree.map((group, gi) => {
const checkedCount = group.Permissions.filter(p => p.Checked).length;
const allChecked = checkedCount === group.Permissions.length && group.Permissions.length > 0;
return `
<div class="rp-group" id="group-${gi}">
<div class="rp-group-header" onclick="toggleGroup(${gi})">
<i class="fas fa-chevron-down toggle"></i>
<input type="checkbox" ${allChecked ? 'checked' : ''} onclick="event.stopPropagation();toggleGroupCheck(${gi}, this.checked)" />
<span>${escapeHtml(group.Category)}</span>
<span class="count">${checkedCount} / ${group.Permissions.length}</span>
</div>
<div class="rp-group-body">
${group.Permissions.map((p, pi) => `
<div class="rp-perm-row" onclick="togglePerm(${gi}, ${pi})">
<input type="checkbox" data-group="${gi}" data-perm="${pi}" ${p.Checked ? 'checked' : ''} onclick="event.stopPropagation();onPermCheck(${gi}, ${pi}, this.checked)" />
<span class="name">${escapeHtml(p.PermName)}</span>
<span class="code">${escapeHtml(p.PermCode)}</span>
${p.PermDesc ? `<span class="desc">${escapeHtml(p.PermDesc)}</span>` : ''}
</div>
`).join('')}
</div>
</div>
`;
}).join('');
}
function toggleGroup(idx) {
const el = document.getElementById(`group-${idx}`);
if (el) el.classList.toggle('collapsed');
}
function expandAll() {
document.querySelectorAll('.rp-group').forEach(el => el.classList.remove('collapsed'));
}
function collapseAll() {
document.querySelectorAll('.rp-group').forEach(el => el.classList.add('collapsed'));
}
function toggleGroupCheck(groupIdx, checked) {
const group = _permissionTree[groupIdx];
if (!group) return;
group.Permissions.forEach(p => { p.Checked = checked; });
renderTree();
updateStat();
markChanged();
}
function togglePerm(groupIdx, permIdx) {
const perm = _permissionTree[groupIdx] && _permissionTree[groupIdx].Permissions[permIdx];
if (!perm) return;
perm.Checked = !perm.Checked;
renderTree();
updateStat();
markChanged();
}
function onPermCheck(groupIdx, permIdx, checked) {
const perm = _permissionTree[groupIdx] && _permissionTree[groupIdx].Permissions[permIdx];
if (!perm) return;
perm.Checked = checked;
// 仅更新当前组头部勾选状态,避免全量重渲染丢焦点
updateGroupHeader(groupIdx);
updateStat();
markChanged();
}
function updateGroupHeader(groupIdx) {
const group = _permissionTree[groupIdx];
if (!group) return;
const checkedCount = group.Permissions.filter(p => p.Checked).length;
const allChecked = checkedCount === group.Permissions.length && group.Permissions.length > 0;
const groupEl = document.getElementById(`group-${groupIdx}`);
if (!groupEl) return;
const headerCheckbox = groupEl.querySelector('.rp-group-header input[type="checkbox"]');
if (headerCheckbox) headerCheckbox.checked = allChecked;
const countEl = groupEl.querySelector('.count');
if (countEl) countEl.textContent = `${checkedCount} / ${group.Permissions.length}`;
}
function updateStat() {
const total = _permissionTree.reduce((s, g) => s + g.Permissions.length, 0);
const checked = _permissionTree.reduce((s, g) => s + g.Permissions.filter(p => p.Checked).length, 0);
document.getElementById('permStat').innerHTML = `共 <strong>${total}</strong> 项权限,已勾选 <strong>${checked}</strong> 项`;
}
function checkAll() {
_permissionTree.forEach(g => g.Permissions.forEach(p => p.Checked = true));
renderTree();
updateStat();
markChanged();
}
function uncheckAll() {
_permissionTree.forEach(g => g.Permissions.forEach(p => p.Checked = false));
renderTree();
updateStat();
markChanged();
}
function markChanged() {
const currentCheckedSet = new Set(
_permissionTree.flatMap(g => g.Permissions.filter(p => p.Checked).map(p => p.PermUUID))
);
_hasChanges = !(currentCheckedSet.size === _originalCheckedSet.size &&
[...currentCheckedSet].every(x => _originalCheckedSet.has(x)));
}
async function savePermissions() {
const roleUUID = getSelectedRole();
const companyUUID = getSelectedCompany();
if (!roleUUID || !companyUUID) {
if (window.GERPLayui) GERPLayui.Toast('请先选择角色和公司', 'warning');
return;
}
const permUUIDs = _permissionTree.flatMap(g => g.Permissions.filter(p => p.Checked).map(p => p.PermUUID));
const btn = document.getElementById('saveBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 保存中...';
try {
const resp = await fetch(`/API/Role/SavePermissions${apiQs()}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ RoleUUID: roleUUID, CompanyUUID: companyUUID, PermUUIDs: permUUIDs })
});
const result = await resp.json();
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-save"></i> 保存权限';
if (result.code === 0) {
const d = result.data || {};
if (window.GERPLayui) GERPLayui.Toast(`保存成功(新增 ${d.Added || 0},移除 ${d.Removed || 0},保留 ${d.Kept || 0})`, 'success');
_originalCheckedSet = new Set(permUUIDs);
_hasChanges = false;
} else {
if (window.GERPLayui) GERPLayui.Toast(result.msg || '保存失败', 'error');
}
} catch (err) {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-save"></i> 保存权限';
if (window.GERPLayui) GERPLayui.Toast('网络错误: ' + err.message, 'error');
}
}
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// 离开页面前提示未保存
window.addEventListener('beforeunload', (e) => {
if (_hasChanges) {
e.preventDefault();
e.returnValue = '';
}
});
init();
</script>
+3 -2
View File
@@ -50,8 +50,9 @@
async function initTable() {
const containerEl = document.getElementById('TableContainer');
// 先检查管理员特权
_isAdminMode = await checkAdmin();
// 先检查管理员特权(仅当 URL 带 admin_verify=1 时启用)
const _hasAdminVerify = new URLSearchParams(window.location.search).get('admin_verify') === '1';
_isAdminMode = _hasAdminVerify ? await checkAdmin() : false;
try {
let columns;
+19 -15
View File
@@ -17,9 +17,9 @@
<div id="formSection">
<div class="tenant-create-title">
<i class="fas fa-key icon-primary"></i>
<span>创建租户</span>
<span>租户入驻申请</span>
</div>
<div class="tenant-create-desc">创建新的租户主体。租户创建后,企业可申请入驻该租户下。</div>
<div class="tenant-create-desc">提交租户入驻申请,等待超管或审核员审核通过后激活。</div>
<div class="tenant-create-form" id="tenantForm">
<div class="GERPFormGroup">
<label class="GERPFormLabel Required">租户名称</label>
@@ -32,19 +32,23 @@
<div class="GERPDateTimePickerInput">请选择有效期</div>
</div>
</div>
<div class="GERPFormGroup">
<label class="GERPFormLabel">申请说明</label>
<textarea class="GERPInput" id="ReviewRemark" placeholder="可选,填写申请理由或备注" rows="3" style="resize:vertical;"></textarea>
</div>
</div>
<div class="tenant-create-actions">
<button class="GERPBtn Secondary" onclick="history.back()">取消</button>
<button class="GERPBtn Primary" id="submitBtn" onclick="submitTenant()">创建租户</button>
<button class="GERPBtn Primary" id="submitBtn" onclick="submitTenant()">提交申请</button>
</div>
</div>
<div id="successSection" style="display:none;">
<div class="tenant-create-success">
<i class="fas fa-check-circle"></i>
<h3>租户已创建</h3>
<p>租户主体已创建成功,企业现在可以申请入驻该租户。</p>
<h3>申请已提交</h3>
<p>租户入驻申请已提交,等待审核通过后激活。</p>
<div style="margin-top:24px;">
<button class="GERPBtn Primary" onclick="location.reload()">继续创建</button>
<button class="GERPBtn Primary" onclick="location.reload()">继续申请</button>
<button class="GERPBtn Secondary" onclick="history.back()" style="margin-left:8px;">返回</button>
</div>
</div>
@@ -58,31 +62,31 @@ function submitTenant() {
const TenantName = document.getElementById('TenantName').value.trim();
const expirePicker = document.getElementById('TenantExpireTime');
const TenantExpireTime = expirePicker ? (expirePicker._picker ? expirePicker._picker.getValue() : (expirePicker.dataset.value || '')) : '';
const ReviewRemark = document.getElementById('ReviewRemark').value.trim();
if (!TenantName) { if(window.GERPLayui) GERPLayui.Toast('请输入租户名称', 'warning'); return; }
if (!TenantExpireTime) { if(window.GERPLayui) GERPLayui.Toast('请选择有效期', 'warning'); return; }
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 创建中...';
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 提交中...';
const data = { TenantName, TenantExpireTime: TenantExpireTime || '2027-12-31 00:00:00', ReviewStatus: 0 };
fetch('/API/FormAPI/Add/Tenant', {
fetch('/API/Tenant/Apply', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(data).toString()
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ TenantName, TenantExpireTime, ReviewRemark })
}).then(res => res.json()).then(result => {
btn.disabled = false;
btn.textContent = '创建租户';
btn.textContent = '提交申请';
if (result.code === 0) {
document.getElementById('formSection').style.display = 'none';
document.getElementById('successSection').style.display = 'block';
} else {
if(window.GERPLayui) GERPLayui.Toast(result.msg || '创建失败', 'error');
if(window.GERPLayui) GERPLayui.Toast(result.msg || '提交失败', 'error');
}
}).catch(err => {
btn.disabled = false;
btn.textContent = '创建租户';
btn.textContent = '提交申请';
if(window.GERPLayui) GERPLayui.Toast('网络错误: ' + err.message, 'error');
});
}
@@ -0,0 +1,197 @@
<style>
.tenant-review-page { padding: 24px; height: 100%; display: flex; flex-direction: column; box-sizing: border-box; }
.tenant-review-header { display: flex; align-items: center; margin-bottom: 16px; gap: 12px; flex-shrink: 0; }
.tenant-review-header h1 { margin: 0; flex: 1; }
.tenant-review-container { flex: 1; min-height: 0; overflow: auto; }
.tenant-review-list { display: grid; gap: 12px; }
.tenant-review-card { background: var(--CardBackground, #fff); border: 1px solid var(--BorderColor, #e2e8f0); border-radius: 10px; padding: 20px; display: flex; align-items: center; gap: 16px; }
.tenant-review-card .info { flex: 1; min-width: 0; }
.tenant-review-card .name { font-size: 16px; font-weight: 600; color: var(--TextPrimary, #1e293b); margin-bottom: 4px; }
.tenant-review-card .meta { font-size: 13px; color: var(--TextSecondary, #64748b); display: flex; gap: 16px; flex-wrap: wrap; }
.tenant-review-card .remark { font-size: 13px; color: var(--TextSecondary, #64748b); margin-top: 6px; padding: 8px 12px; background: var(--BgSecondary, #f8fafc); border-radius: 6px; }
.tenant-review-card .actions { display: flex; gap: 8px; flex-shrink: 0; }
.tenant-review-empty { text-align: center; padding: 60px 20px; color: var(--TextSecondary, #64748b); }
.tenant-review-empty i { font-size: 48px; margin-bottom: 12px; display: block; color: var(--TextTertiary, #94a3b8); }
.reject-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: none; align-items: center; justify-content: center; z-index: 1000; }
.reject-modal.show { display: flex; }
.reject-modal-content { background: var(--CardBackground, #fff); border-radius: 12px; padding: 24px; max-width: 400px; width: 90%; }
.reject-modal-content h3 { margin: 0 0 16px; font-size: 16px; }
.reject-modal-content textarea { width: 100%; margin-bottom: 16px; resize: vertical; }
.reject-modal-content .actions { display: flex; gap: 8px; justify-content: flex-end; }
</style>
<div class="tenant-review-page">
<div class="tenant-review-header">
<h1 class="PageTitle">
<i class="fas fa-clipboard-check icon-primary icon-left-lg"></i>
<span>租户审核工作台</span>
</h1>
<button class="GERPBtn Secondary" onclick="loadPending()" style="display:flex;align-items:center;gap:6px;">
<i class="fas fa-sync"></i> 刷新
</button>
</div>
<div class="tenant-review-container">
<div class="tenant-review-list" id="pendingList"></div>
<div class="tenant-review-empty" id="emptyState" style="display:none;">
<i class="fas fa-inbox"></i>
<div>暂无待审核的租户申请</div>
</div>
<div id="errorState" style="display:none;color:var(--DangerColor);padding:40px;text-align:center;"></div>
</div>
</div>
<div class="reject-modal" id="rejectModal">
<div class="reject-modal-content">
<h3>拒绝入驻申请</h3>
<p style="font-size:13px;color:var(--TextSecondary);margin-bottom:12px;">租户:<span id="rejectTenantName"></span></p>
<textarea class="GERPInput" id="rejectReason" placeholder="请输入拒绝理由" rows="4"></textarea>
<div class="actions">
<button class="GERPBtn Secondary" onclick="closeRejectModal()">取消</button>
<button class="GERPBtn Danger" id="confirmRejectBtn" onclick="confirmReject()">确认拒绝</button>
</div>
</div>
</div>
<script>
let _pendingTenants = [];
let _rejectTargetUuid = null;
GERP.SetTitle('租户审核工作台');
async function loadPending() {
const listEl = document.getElementById('pendingList');
const emptyEl = document.getElementById('emptyState');
const errorEl = document.getElementById('errorState');
listEl.innerHTML = '';
emptyEl.style.display = 'none';
errorEl.style.display = 'none';
listEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--TextSecondary);"><i class="fas fa-spinner fa-spin"></i> 加载中...</div>';
try {
const resp = await fetch('/API/Tenant/Pending', { credentials: 'include' });
const result = await resp.json();
listEl.innerHTML = '';
if (result.code === 403) {
errorEl.textContent = '无权访问审核工作台';
errorEl.style.display = 'block';
return;
}
if (result.code !== 0) {
errorEl.textContent = result.msg || '加载失败';
errorEl.style.display = 'block';
return;
}
_pendingTenants = result.data || [];
if (_pendingTenants.length === 0) {
emptyEl.style.display = 'block';
return;
}
renderList();
} catch (err) {
listEl.innerHTML = '';
errorEl.textContent = '网络错误: ' + err.message;
errorEl.style.display = 'block';
}
}
function renderList() {
const listEl = document.getElementById('pendingList');
listEl.innerHTML = _pendingTenants.map(t => `
<div class="tenant-review-card">
<div class="info">
<div class="name">${escapeHtml(t.TenantName)}</div>
<div class="meta">
<span><i class="fas fa-calendar"></i> 有效期: ${formatTime(t.TenantExpireTime)}</span>
<span><i class="fas fa-clock"></i> 申请时间: ${formatTime(t.CreateTime)}</span>
</div>
${t.ReviewRemark ? `<div class="remark"><i class="fas fa-comment"></i> ${escapeHtml(t.ReviewRemark)}</div>` : ''}
</div>
<div class="actions">
<button class="GERPBtn Primary" onclick="approveTenant('${t.TenantUUID}')">
<i class="fas fa-check"></i> 批准
</button>
<button class="GERPBtn Danger" onclick="openRejectModal('${t.TenantUUID}', '${escapeHtml(t.TenantName)}')">
<i class="fas fa-times"></i> 拒绝
</button>
</div>
</div>
`).join('');
}
async function approveTenant(uuid) {
if (!confirm('确认批准该租户入驻申请?')) return;
try {
const resp = await fetch(`/API/Tenant/Approve/${uuid}`, {
method: 'POST',
credentials: 'include',
});
const result = await resp.json();
if (result.code === 0) {
if (window.GERPLayui) GERPLayui.Toast('审核通过', 'success');
loadPending();
} else {
if (window.GERPLayui) GERPLayui.Toast(result.msg || '操作失败', 'error');
}
} catch (err) {
if (window.GERPLayui) GERPLayui.Toast('网络错误: ' + err.message, 'error');
}
}
function openRejectModal(uuid, name) {
_rejectTargetUuid = uuid;
document.getElementById('rejectTenantName').textContent = name;
document.getElementById('rejectReason').value = '';
document.getElementById('rejectModal').classList.add('show');
}
function closeRejectModal() {
_rejectTargetUuid = null;
document.getElementById('rejectModal').classList.remove('show');
}
async function confirmReject() {
if (!_rejectTargetUuid) return;
const reason = document.getElementById('rejectReason').value.trim();
if (!reason) {
if (window.GERPLayui) GERPLayui.Toast('请输入拒绝理由', 'warning');
return;
}
const btn = document.getElementById('confirmRejectBtn');
btn.disabled = true;
btn.textContent = '处理中...';
try {
const resp = await fetch(`/API/Tenant/Reject/${_rejectTargetUuid}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ReviewRemark: reason })
});
const result = await resp.json();
btn.disabled = false;
btn.textContent = '确认拒绝';
if (result.code === 0) {
if (window.GERPLayui) GERPLayui.Toast('已拒绝申请', 'success');
closeRejectModal();
loadPending();
} else {
if (window.GERPLayui) GERPLayui.Toast(result.msg || '操作失败', 'error');
}
} catch (err) {
btn.disabled = false;
btn.textContent = '确认拒绝';
if (window.GERPLayui) GERPLayui.Toast('网络错误: ' + err.message, 'error');
}
}
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function formatTime(t) {
if (!t) return '-';
return t.replace('T', ' ').substring(0, 19);
}
loadPending();
</script>
+5 -5
View File
@@ -1,4 +1,4 @@
<script src="/assets/gerp/CompanyCommon.js?v=20260625d"></script>
<script src="/assets/gerp/CompanyCommon.js?v=20260627c"></script>
<div class="GERPPage">
<div class="cp-page">
@@ -74,8 +74,8 @@
return;
}
// 先检查管理员特权,决定是否启用无隔离模式
isAdminMode = await checkAdmin();
// 先检查管理员特权,决定是否启用无隔离模式(仅当 URL 带 admin_verify=1 时启用)
isAdminMode = CompanyUtils.hasAdminVerifyArg() ? await checkAdmin() : false;
const adminBadge = document.getElementById('admin-badge');
if (isAdminMode) {
adminBadge.style.display = 'inline-block';
@@ -163,7 +163,7 @@
bordered: true,
api: {
get: buildApiUrl('/API/FormAPI/Get/UserRole', { UserUUID: userUUID }),
add: buildApiUrl('/API/FormAPI/Add/UserRole'),
add: buildApiUrl('/API/FormAPI/Add/UserRole', { UserUUID: userUUID }),
edit: buildApiUrl('/API/FormAPI/Modify/UserRole/<id>'),
modify: buildApiUrl('/API/FormAPI/ModifyItem/UserRole/<id>'),
delete: buildApiUrl('/API/FormAPI/Delete/UserRole/<id>'),
@@ -199,7 +199,7 @@
bordered: true,
api: {
get: buildApiUrl('/API/FormAPI/Get/UserDept', { UserUUID: userUUID }),
add: buildApiUrl('/API/FormAPI/Add/UserDept'),
add: buildApiUrl('/API/FormAPI/Add/UserDept', { UserUUID: userUUID }),
edit: buildApiUrl('/API/FormAPI/Modify/UserDept/<id>'),
modify: buildApiUrl('/API/FormAPI/ModifyItem/UserDept/<id>'),
delete: buildApiUrl('/API/FormAPI/Delete/UserDept/<id>'),
@@ -0,0 +1,260 @@
<style>
.GERPPage { padding: 24px; margin: 0 auto; height: 100%; display: flex; flex-direction: column; box-sizing: border-box; }
.debug-page { padding: 20px; flex: 1; min-height: 0; display: flex; flex-direction: column; box-sizing: border-box; gap: 16px; overflow: auto; }
.debug-header { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.debug-header h1 { margin: 0; flex: 1; }
.ctx-bar { padding: 10px 14px; background: var(--CardBackground, #fff); border: 1px solid var(--BorderColor, #e5e7eb); border-radius: 6px; font-size: 13px; display: flex; gap: 16px; flex-wrap: wrap; flex-shrink: 0; }
.ctx-bar .ctx-item { display: flex; gap: 6px; align-items: center; }
.ctx-bar .ctx-label { color: var(--TextSecondary, #6b7280); }
.ctx-bar .ctx-value { font-weight: 600; color: var(--TextPrimary, #111827); font-family: monospace; font-size: 12px; }
.section { background: var(--CardBackground, #fff); border: 1px solid var(--BorderColor, #e5e7eb); border-radius: 8px; padding: 16px; flex-shrink: 0; }
.section h3 { margin: 0 0 12px 0; font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.section h3 i { color: var(--PrimaryColor, #3b82f6); }
.section .hint { font-size: 12px; color: var(--TextSecondary, #6b7280); margin-bottom: 10px; }
.table-wrap { height: 420px; }
.public-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; align-items: end; }
.public-form .field { display: flex; flex-direction: column; gap: 4px; }
.public-form label { font-size: 12px; color: var(--TextSecondary, #6b7280); font-weight: 500; }
.public-form input, .public-form select { padding: 6px 10px; border: 1px solid var(--BorderColor, #d1d5db); border-radius: 4px; font-size: 13px; background: var(--InputBackground, #fff); color: var(--TextPrimary, #111827); }
.public-form button { padding: 7px 16px; background: var(--PrimaryColor, #3b82f6); color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 500; }
.public-form button:hover { opacity: 0.9; }
.badge-private { display:inline-block; padding:2px 8px; border-radius:4px; background:#dcfce7; color:#166534; font-size:11px; font-weight:600; }
.badge-public { display:inline-block; padding:2px 8px; border-radius:4px; background:#dbeafe; color:#1e40af; font-size:11px; font-weight:600; }
</style>
<div class="GERPPage">
<div class="debug-page">
<div class="debug-header">
<h1 class="PageTitle">
<i class="fas fa-bug icon-primary icon-left-lg"></i>
<span id="page-title">UserRole 调试</span>
<span class="badge-private" title="私有修改按 UserRoleUUID 定位单条记录">私有修改</span>
<span class="badge-public" title="公有修改按 RoleUUID 批量修改该角色下所有记录">公有修改</span>
</h1>
</div>
<div class="ctx-bar" id="ctxBar">
<div class="ctx-item"><span class="ctx-label">当前角色:</span><span class="ctx-value" id="ctx-role">-</span></div>
<div class="ctx-item"><span class="ctx-label">当前公司:</span><span class="ctx-value" id="ctx-company">-</span></div>
<div class="ctx-item"><span class="ctx-label">当前租户:</span><span class="ctx-value" id="ctx-tenant">-</span></div>
<div class="ctx-item"><span class="ctx-label">隔离:</span><span class="ctx-value" id="ctx-scope">-</span></div>
</div>
<div class="section">
<h3><i class="fas fa-table"></i> UserRole 列表 <span class="badge-private">私有修改:双击行编辑单条记录(按 UserRoleUUID)</span></h3>
<div class="hint">表格受公司隔离约束。仅管理员可访问本页。单条编辑仅影响该条 UserRole 记录。</div>
<div class="table-wrap" id="TableContainer"></div>
</div>
<div class="section">
<h3><i class="fas fa-layer-group"></i> 公有修改 <span class="badge-public">按 RoleUUID 批量修改该角色下所有 UserRole 记录</span></h3>
<div class="hint">输入 RoleUUID,选择要批量修改的字段与值,提交后将更新该角色下所有 UserRole 记录(受当前隔离范围限制)。留空字段不修改。</div>
<div class="public-form">
<div class="field">
<label>RoleUUID(角色)</label>
<input type="text" id="publicRoleUUID" placeholder="输入角色 UUID">
</div>
<div class="field">
<label>RoleType(角色类型)</label>
<select id="publicRoleType">
<option value="">不修改</option>
<option value="0">0 - 组员</option>
<option value="1">1 - 组长</option>
</select>
</div>
<div class="field">
<label>CompanyUUID(公司)</label>
<input type="text" id="publicCompanyUUID" placeholder="留空不修改">
</div>
<div class="field">
<label>DeptUUID(部门)</label>
<input type="text" id="publicDeptUUID" placeholder="留空不修改,填 0 清空">
</div>
<div class="field">
<label>WorkgroupUUID(工作组)</label>
<input type="text" id="publicWorkgroupUUID" placeholder="留空不修改,填 0 清空">
</div>
<div class="field">
<label>&nbsp;</label>
<button onclick="submitPublicModify()"><i class="fas fa-bolt"></i> 批量修改</button>
</div>
</div>
<div id="publicResult" style="margin-top:10px;font-size:13px;"></div>
</div>
<div id="deniedState" style="display:none;text-align:center;padding:60px 20px;color:var(--TextSecondary,#64748b);">
<i class="fas fa-lock" style="font-size:48px;margin-bottom:12px;display:block;color:var(--DangerColor,#ef4444);"></i>
<div style="font-size:16px;font-weight:600;color:var(--TextPrimary,#1e293b);margin-bottom:6px;">无权访问</div>
<div>该页面仅管理员可查看,请切换至管理员角色或联系管理员。</div>
</div>
</div>
</div>
<script>
const urlParams = new URLSearchParams(window.location.search);
const currentServer = urlParams.get('server') || 'gvsds-master';
const hasAdminVerify = urlParams.get('admin_verify') === '1';
let _tableInstance = null;
let isAdminMode = false;
GERP.SetTitle(currentServer === 'gvsds-master' ? 'UserRole 调试' : `${currentServer} UserRole 调试`);
document.getElementById('page-title').textContent = currentServer === 'gvsds-master' ? 'UserRole 调试' : `${currentServer} - UserRole 调试`;
function buildApiUrl(path, params = {}) {
const allParams = {};
if (currentServer !== 'gvsds-master') allParams.server = currentServer;
if (isAdminMode) allParams.admin_verify = '1';
Object.assign(allParams, params);
const qs = new URLSearchParams(allParams).toString();
return qs ? `${path}?${qs}` : path;
}
async function checkAdmin() {
try {
const resp = await fetch(buildApiUrl('/API/FormAPI/CheckAdmin'), { credentials: 'include' });
const result = await resp.json();
return result.code === 0 && result.data && result.data.is_admin;
} catch (e) { return false; }
}
async function loadContext() {
try {
const resp = await fetch(buildApiUrl('/API/FormAPI/CheckAdmin'), { credentials: 'include' });
const result = await resp.json();
if (result.code === 0 && result.data) {
const d = result.data;
document.getElementById('ctx-role').textContent = d.current_role_name || d.current_role_uuid || '-';
document.getElementById('ctx-company').textContent = d.current_company_uuid || '-';
document.getElementById('ctx-tenant').textContent = d.current_tenant_uuid || '-';
document.getElementById('ctx-scope').textContent = d.company_scope === false ? '已关闭(特权模式)' : '已开启(公司隔离)';
}
} catch (e) {}
}
async function initTable() {
// 入口校验:仅管理员可访问 UserRole 调试页
const isAdmin = await checkAdmin();
if (!isAdmin) {
document.querySelectorAll('.section').forEach(el => el.style.display = 'none');
document.getElementById('ctxBar').style.display = 'none';
document.getElementById('deniedState').style.display = 'block';
return;
}
isAdminMode = true;
await loadContext();
const columns = [
{ field: 'UserRoleUUID', title: '关联UUID', primaryKey: true, editable: false, hideInForm: true, width: 280 },
{ field: 'UserUUID', title: '用户', type: 'text', width: 280 },
{ field: 'RoleUUID', title: '角色', type: 'text', width: 280 },
{ field: 'CompanyUUID', title: '公司', type: 'text', width: 280 },
{ field: 'DeptUUID', title: '部门', type: 'text', width: 280 },
{ field: 'WorkgroupUUID', title: '工作组', type: 'text', width: 280 },
{ field: 'RoleType', title: '角色类型', type: 'select', options: [{ value: 0, label: '组员' }, { value: 1, label: '组长' }], width: 100 },
{ field: 'CreateTime', title: '创建时间', type: 'datetime', editable: false, width: 160 },
];
_tableInstance = new TableEngine({
container: '#TableContainer',
height: '420px',
tableName: 'UserRole',
columns: columns,
data: [],
pageSize: 50,
columnDistribution: 'equal',
showPagination: true,
showCheckbox: true,
editable: true,
sortable: true,
filterable: true,
striped: true,
hoverable: true,
bordered: true,
api: {
get: buildApiUrl('/API/FormAPI/Get/UserRole'),
add: buildApiUrl('/API/FormAPI/Add/UserRole'),
edit: buildApiUrl('/API/FormAPI/Modify/UserRole/<id>'),
modify: buildApiUrl('/API/FormAPI/ModifyItem/UserRole/<id>'),
delete: buildApiUrl('/API/FormAPI/Delete/UserRole/<id>'),
},
});
}
/** 公有修改:按 RoleUUID 批量修改该角色下所有 UserRole 记录 */
async function submitPublicModify() {
const roleUUID = document.getElementById('publicRoleUUID').value.trim();
if (!roleUUID) {
document.getElementById('publicResult').innerHTML = '<span style="color:#dc2626;"><i class="fas fa-exclamation-circle"></i> 请输入 RoleUUID</span>';
return;
}
const updates = {};
const roleType = document.getElementById('publicRoleType').value;
const companyUUID = document.getElementById('publicCompanyUUID').value.trim();
const deptUUID = document.getElementById('publicDeptUUID').value.trim();
const workgroupUUID = document.getElementById('publicWorkgroupUUID').value.trim();
if (roleType !== '') updates.RoleType = parseInt(roleType);
if (companyUUID) updates.CompanyUUID = companyUUID;
if (deptUUID) updates.DeptUUID = deptUUID === '0' ? null : deptUUID;
if (workgroupUUID) updates.WorkgroupUUID = workgroupUUID === '0' ? null : workgroupUUID;
if (Object.keys(updates).length === 0) {
document.getElementById('publicResult').innerHTML = '<span style="color:#dc2626;"><i class="fas fa-exclamation-circle"></i> 至少填写一个要修改的字段</span>';
return;
}
const resultEl = document.getElementById('publicResult');
resultEl.innerHTML = '<span style="color:#6b7280;"><i class="fas fa-spinner fa-spin"></i> 正在批量修改...</span>';
try {
// 先查询该角色下所有 UserRole 记录
const listResp = await fetch(buildApiUrl('/API/FormAPI/Get/UserRole', { RoleUUID: roleUUID, PageSize: '500' }), { credentials: 'include' });
const listResult = await listResp.json();
if (listResult.code !== 0) {
resultEl.innerHTML = `<span style="color:#dc2626;"><i class="fas fa-exclamation-circle"></i> 查询失败: ${listResult.msg}</span>`;
return;
}
const records = listResult.data || [];
if (records.length === 0) {
resultEl.innerHTML = `<span style="color:#dc2626;"><i class="fas fa-exclamation-circle"></i> 未找到 RoleUUID=${roleUUID} 的 UserRole 记录</span>`;
return;
}
// 逐条修改(ModifyItem 按 PK)
let success = 0, failed = 0;
for (const row of records) {
const pk = row.UserRoleUUID;
const body = new URLSearchParams();
body.append('Table', 'UserRole');
body.append('PK', pk);
for (const [k, v] of Object.entries(updates)) {
body.append(k, v === null ? '' : v);
}
try {
const resp = await fetch(buildApiUrl(`/API/FormAPI/ModifyItem/UserRole/${pk}`), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
credentials: 'include',
});
const r = await resp.json();
if (r.code === 0) success++; else failed++;
} catch (e) {
failed++;
}
}
resultEl.innerHTML = `<span style="color:${failed > 0 ? '#d97706' : '#16a34a'};"><i class="fas fa-${failed > 0 ? 'exclamation-triangle' : 'check-circle'}"></i> 批量修改完成:成功 ${success} 条,失败 ${failed} 条(共 ${records.length} 条)</span>`;
// 刷新表格
if (_tableInstance && _tableInstance.refresh) {
_tableInstance.refresh();
}
} catch (e) {
resultEl.innerHTML = `<span style="color:#dc2626;"><i class="fas fa-exclamation-circle"></i> 网络错误: ${e.message}</span>`;
}
}
initTable();
</script>
+83 -12
View File
@@ -1,9 +1,15 @@
<style>
.GERPPage { padding: 24px; max-width: 1400px; margin: 0 auto; height: 100%; display: flex; flex-direction: column; box-sizing: border-box; }
.user-page { padding: 20px; flex: 1; min-height: 0; display: flex; flex-direction: column; box-sizing: border-box; }
.user-header { display: flex; align-items: center; margin-bottom: 16px; gap: 12px; flex-shrink: 0; }
.user-header { display: flex; align-items: center; margin-bottom: 12px; gap: 12px; flex-shrink: 0; }
.user-header h1 { margin: 0; flex: 1; }
.user-container { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
.user-scope-banner { padding: 10px 14px; border-radius: 8px; font-size: 13px; margin-bottom: 12px; display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.user-scope-banner.admin { background: rgba(30,64,175,0.08); color: #1e40af; border: 1px solid rgba(30,64,175,0.2); }
.user-scope-banner.dept_manager { background: rgba(16,185,129,0.08); color: #047857; border: 1px solid rgba(16,185,129,0.2); }
.user-scope-banner.workgroup_lead { background: rgba(245,158,11,0.08); color: #b45309; border: 1px solid rgba(245,158,11,0.2); }
.user-scope-banner.member { background: rgba(100,116,139,0.08); color: #475569; border: 1px solid rgba(100,116,139,0.2); }
.user-scope-banner i { font-size: 16px; }
</style>
<div class="GERPPage">
@@ -12,8 +18,10 @@
<h1 class="PageTitle">
<i class="fas fa-users icon-primary icon-left-lg"></i>
<span id="page-title">用户管理</span>
<span id="admin-badge" style="display:none;margin-left:8px;padding:2px 10px;border-radius:4px;background:#1e40af;color:#fff;font-size:12px;font-weight:600;"><i class="fas fa-shield-alt"></i> 特权模式</span>
</h1>
</div>
<div id="scopeBanner" class="user-scope-banner admin" style="display:none;"></div>
<div class="user-container" id="TableContainer"></div>
</div>
@@ -23,17 +31,80 @@
const urlParams = new URLSearchParams(window.location.search);
const currentServer = urlParams.get('server') || 'gvsds-master';
let _tableInstance = null;
let isAdminMode = false;
let _roleScope = null; // {Type, TypeLabel, IsAdmin, IsDeptManager, IsWorkgroupLead, IsMember, DeptUUID, WorkgroupUUID}
GERP.SetTitle(currentServer === 'gvsds-master' ? '用户管理' : `${currentServer} 用户管理`);
document.getElementById('page-title').textContent = currentServer === 'gvsds-master' ? '用户管理' : `${currentServer} - 用户管理`;
/** 检查当前用户是否具有管理员特权,并填充 _roleScope */
async function checkAdmin() {
try {
const resp = await fetch('/API/FormAPI/CheckAdmin', { credentials: 'include' });
const result = await resp.json();
if (result.code === 0 && result.data) {
_roleScope = result.data.role_scope || null;
return result.data.is_admin;
}
return false;
} catch (e) { return false; }
}
function buildApiUrl(path, params = {}) {
const allParams = {};
if (currentServer !== 'gvsds-master') allParams.server = currentServer;
if (isAdminMode) allParams.admin_verify = '1';
Object.assign(allParams, params);
const qs = new URLSearchParams(allParams).toString();
return qs ? `${path}?${qs}` : path;
}
/** 根据 _roleScope 差异化显示页面标题与作用域提示横幅 */
function applyScopeUI() {
const scope = _roleScope;
const scopeType = scope ? scope.Type : 'member';
const titleEl = document.getElementById('page-title');
const bannerEl = document.getElementById('scopeBanner');
const iconMap = {
admin: 'fa-shield-alt',
dept_manager: 'fa-sitemap',
workgroup_lead: 'fa-users-cog',
member: 'fa-user',
};
const titleMap = {
admin: '用户管理',
dept_manager: '部门成员',
workgroup_lead: '工作组成员',
member: '我的资料',
};
const hintMap = {
admin: '管理员视图:可查看和管理全公司用户,含跨公司特权模式。',
dept_manager: '部门负责人视图:仅可查看和管理本部门成员,新增/编辑/删除受部门隔离约束。',
workgroup_lead: '工作组组长视图:仅可查看和管理本工作组成员,新增/编辑/删除受工作组隔离约束。',
member: '普通组员视图:仅可查看和编辑自己的资料,无法查看他人数据。',
};
const finalTitle = currentServer === 'gvsds-master' ? titleMap[scopeType] : `${currentServer} - ${titleMap[scopeType]}`;
titleEl.textContent = finalTitle;
GERP.SetTitle(finalTitle);
bannerEl.className = `user-scope-banner ${scopeType}`;
bannerEl.innerHTML = `<i class="fas ${iconMap[scopeType] || 'fa-info-circle'}"></i><span>${hintMap[scopeType] || ''}</span>`;
bannerEl.style.display = 'flex';
}
async function initTable() {
const containerEl = document.getElementById('TableContainer');
// 入口校验:获取 role_scope 并差异化显示
const isAdmin = await checkAdmin();
applyScopeUI();
if (isAdmin) {
isAdminMode = true;
const badge = document.getElementById('admin-badge');
if (badge) badge.style.display = 'inline-block';
}
try {
let columns;
if (currentServer === 'gvsds-master') {
const columnsResp = await fetch('/API/FormAPI/Columns/User', { credentials: 'include' });
const columnsResp = await fetch(buildApiUrl('/API/FormAPI/Columns/User'), { credentials: 'include' });
const columnsResult = await columnsResp.json();
if (columnsResult.code !== 0) {
containerEl.innerHTML = `<div style="color:var(--DangerColor);padding:40px;text-align:center;">加载列配置失败: ${columnsResult.msg || ''}</div>`;
@@ -51,8 +122,8 @@
data: [],
pageSize: 20,
showPagination: true,
showCheckbox: true,
editable: true,
showCheckbox: _roleScope && _roleScope.IsMember ? false : true,
editable: _roleScope && _roleScope.IsMember ? false : true,
sortable: true,
filterable: true,
striped: true,
@@ -60,12 +131,12 @@
bordered: true,
columnDistribution: 'equal',
api: {
get: '/API/FormAPI/Get/User',
add: '/API/FormAPI/Add/User',
edit: '/API/FormAPI/Modify/User/<id>',
modify: '/API/FormAPI/ModifyItem/User/<id>',
delete: '/API/FormAPI/Delete/User/<id>',
columns: '/API/FormAPI/Columns/User'
get: buildApiUrl('/API/FormAPI/Get/User'),
add: buildApiUrl('/API/FormAPI/Add/User'),
edit: buildApiUrl('/API/FormAPI/Modify/User/<id>'),
modify: buildApiUrl('/API/FormAPI/ModifyItem/User/<id>'),
delete: buildApiUrl('/API/FormAPI/Delete/User/<id>'),
columns: buildApiUrl('/API/FormAPI/Columns/User')
},
onView: (row) => {
let url = `/pages/system/user-detail.html?UserUUID=${encodeURIComponent(row.UserUUID || '')}`;
@@ -86,6 +86,8 @@ window.RS_CONFIG = deepMerge(window.RS_CONFIG || {}, {
// 功能开关
ENABLE_UI: true,
// 是否显示右下角日志面板(overlay-panel)。设为 false 只保留启动 loading + 进度条
ENABLE_OVERLAY_PANEL: false,
// 阈值配置
POST_MESSAGE_LIMIT: 1 * 1024 * 1024,
@@ -349,23 +351,25 @@ class ResourceShareUI {
document.documentElement.appendChild(progress);
this.progressBarElement = progress;
// 3. 主面板
const panel = document.createElement('div');
panel.id = 'rs-overlay-panel';
panel.innerHTML = `
<div class="rs-panel-header">
<div class="rs-panel-title"><div class="rs-status-indicator"></div><span>ResourceShare Log</span></div>
<div class="rs-controls">
<button class="rs-btn active" id="rs-toggle-console">日志</button>
<button class="rs-btn active" id="rs-toggle-overlay">隐藏</button>
<button class="rs-btn rs-btn-close" id="rs-close-btn">×</button>
// 3. 主面板(仅当 ENABLE_OVERLAY_PANEL 开启时创建)
if (RS_CONFIG.ENABLE_OVERLAY_PANEL) {
const panel = document.createElement('div');
panel.id = 'rs-overlay-panel';
panel.innerHTML = `
<div class="rs-panel-header">
<div class="rs-panel-title"><div class="rs-status-indicator"></div><span>ResourceShare Log</span></div>
<div class="rs-controls">
<button class="rs-btn active" id="rs-toggle-console">日志</button>
<button class="rs-btn active" id="rs-toggle-overlay">隐藏</button>
<button class="rs-btn rs-btn-close" id="rs-close-btn">×</button>
</div>
</div>
</div>
<div id="rs-console-output"></div>
`;
document.documentElement.appendChild(panel);
this.overlayPanel = panel;
this.consoleOutput = panel.querySelector('#rs-console-output');
<div id="rs-console-output"></div>
`;
document.documentElement.appendChild(panel);
this.overlayPanel = panel;
this.consoleOutput = panel.querySelector('#rs-console-output');
}
// 4. 错误屏幕
const errorScreen = document.createElement('div');
@@ -401,30 +405,32 @@ class ResourceShareUI {
const originalText = btn.textContent; btn.textContent = '已复制!'; setTimeout(() => btn.textContent = originalText, 2000);
});
});
document.getElementById('rs-close-btn').addEventListener('click', () => this.setOverlayVisible(false));
const overlayBtn = document.getElementById('rs-toggle-overlay');
overlayBtn.addEventListener('click', () => {
this.setOverlayVisible(!this.isVisible);
overlayBtn.classList.toggle('active', this.isVisible);
overlayBtn.textContent = this.isVisible ? '隐藏' : '显示';
});
const consoleBtn = document.getElementById('rs-toggle-console');
consoleBtn.addEventListener('click', () => {
this.consoleVisible = !this.consoleVisible;
consoleBtn.classList.toggle('active', this.consoleVisible);
this.consoleOutput.style.display = this.consoleVisible ? 'block' : 'none';
});
// 面板按钮绑定(仅在面板启用时执行,避免找不到 DOM 元素)
if (RS_CONFIG.ENABLE_OVERLAY_PANEL) {
document.getElementById('rs-close-btn').addEventListener('click', () => this.setOverlayVisible(false));
const overlayBtn = document.getElementById('rs-toggle-overlay');
overlayBtn.addEventListener('click', () => {
this.setOverlayVisible(!this.isVisible);
overlayBtn.classList.toggle('active', this.isVisible);
overlayBtn.textContent = this.isVisible ? '隐藏' : '显示';
});
const consoleBtn = document.getElementById('rs-toggle-console');
consoleBtn.addEventListener('click', () => {
this.consoleVisible = !this.consoleVisible;
consoleBtn.classList.toggle('active', this.consoleVisible);
this.consoleOutput.style.display = this.consoleVisible ? 'block' : 'none';
});
}
}
startSplashScreen() {
setTimeout(() => {
this.splashElement.classList.add('hidden');
setTimeout(() => { if (this.splashElement.parentNode) this.splashElement.parentNode.removeChild(this.splashElement); }, RS_CONFIG.ANIMATION.SPLASH_HIDE);
}, 1500);
// splash-screen 不在此处自动隐藏,改为在 finishLoading(所有资源加载完成)时隐藏
// 避免 loading 过早消失导致用户看到未就绪的页面
}
setOverlayVisible(visible) {
this.isVisible = visible;
if (!this.overlayPanel) return;
if (visible) this.overlayPanel.classList.remove('rs-closed');
else this.overlayPanel.classList.add('rs-closed');
}
@@ -455,6 +461,14 @@ class ResourceShareUI {
if (status) status.style.backgroundColor = '#107c10';
const title = document.querySelector('.rs-panel-title span');
if (title) title.textContent = '系统就绪';
// 隐藏 splash-screen(页面就绪)—— 用 transition 平滑消失
if (this.splashElement) {
this.splashElement.classList.add('hidden');
setTimeout(() => {
if (this.splashElement.parentNode) this.splashElement.parentNode.removeChild(this.splashElement);
}, RS_CONFIG.ANIMATION.SPLASH_HIDE || 800);
}
// 延迟移除面板/错误屏/样式
setTimeout(() => {
this.setOverlayVisible(false);
setTimeout(() => {
@@ -463,7 +477,7 @@ class ResourceShareUI {
const style = document.getElementById('rs-ui-styles');
if (style && style.parentNode) style.parentNode.removeChild(style);
}, RS_CONFIG.ANIMATION.PANEL_HIDE);
}, 2000);
}, 800);
}
showFatalError(error, diagnosticsHTML) {
@@ -573,6 +587,7 @@ if (typeof window.ResourceShareManager === 'undefined') {
this.isBlockingEnabled = true;
this.allResourcesLoaded = false;
this.scriptBlockingInitialized = false;
this.scanInitialized = false;
this.domContentLoadedFired = false;
this.domReadyCallbacks = [];
this.scriptTimeout = null;
@@ -606,8 +621,11 @@ if (typeof window.ResourceShareManager === 'undefined') {
this.logger.log(`系统初始化 - ${this.isTopLevel ? '顶级页面' : '子页面'}`, 'info');
this.logger.log(`传输阈值: PM=${this.formatBytes(RS_CONFIG.POST_MESSAGE_LIMIT)}, Blob=${this.formatBytes(RS_CONFIG.BLOB_LIMIT)}`, 'info');
// 修复:interceptDOMReadyEvents 对所有页面(顶级/子页面)执行
// 子页面(iframe)也需要拦截 DOMContentLoaded,否则被阻塞脚本注册的监听器永不会执行
this.interceptDOMReadyEvents();
if (this.isTopLevel) {
this.interceptDOMReadyEvents();
window.addEventListener('message', (event) => this.handleChildRequest(event));
this.logger.log('顶级页面:监听同源子页面请求', 'success');
} else {
@@ -669,7 +687,8 @@ if (typeof window.ResourceShareManager === 'undefined') {
}
interceptDOMReadyEvents() {
if (!this.isTopLevel) return;
// 修复:移除 isTopLevel 限制,子页面(iframe)也需要拦截 DOMContentLoaded
// 否则子页面被阻塞脚本注册的 DOMContentLoaded 监听器在事件触发后才注册,永不执行
const originalAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function(type, listener, options) {
if (type === 'DOMContentLoaded' && window.resourceShareManager && window.resourceShareManager.isBlockingEnabled) {
@@ -692,16 +711,21 @@ if (typeof window.ResourceShareManager === 'undefined') {
}
fireDelayedDOMReadyEvents() {
// 修复:防止重复触发(多个调用路径可能调用此方法)
if (this.domContentLoadedFired) return;
this.log('触发延迟的DOM就绪事件', 'info');
this.domContentLoadedFired = true;
document.dispatchEvent(new Event('DOMContentLoaded'));
// 先触发已被拦截的 DOMContentLoaded 监听器(直接调用 listener,而非重新 addEventListener)
// 修复:原实现重新 addEventListener,但 DOMContentLoaded 只触发一次,新注册的监听器永远不会执行
this.domReadyCallbacks.forEach(callback => {
try {
if (callback.isLayui) callback.listener.call(callback.target);
else callback.target.addEventListener('DOMContentLoaded', callback.listener, callback.options);
else callback.listener.call(callback.target, new Event('DOMContentLoaded'));
} catch (error) { this.log(`执行DOM就绪回调出错: ${error.message}`, 'danger'); }
});
this.domReadyCallbacks = [];
// 最后 dispatchEvent,通知其他真实注册的 DOMContentLoaded 监听器
document.dispatchEvent(new Event('DOMContentLoaded'));
}
initScriptBlocking() {
@@ -731,14 +755,22 @@ if (typeof window.ResourceShareManager === 'undefined') {
return true;
}
scanResourceShareElements() {
async scanResourceShareElements() {
// 修复:防止重复扫描/初始化
// fireDelayedDOMReadyEvents 会 dispatch DOMContentLoaded 事件,
// 这会再次触发 initScriptBlocking 中注册的 scanResourceShareElements 监听器,
// 导致 initializeResourceQueue 被调用两次,脚本重复执行(SyntaxError: Identifier has already been declared)
if (this.scanInitialized) return;
this.scanInitialized = true;
this.resourceShareElements = Array.from(document.querySelectorAll('resource-share'));
this.log(`发现 ${this.resourceShareElements.length} 个 resource-share 标签`, 'info');
if (this.resourceShareElements.length === 0) {
this.log('未发现 resource-share 标签,禁用脚本阻塞', 'warning');
this.isBlockingEnabled = false;
this.releaseBlockedScripts();
this.uiManager.finishLoading();
await this.releaseBlockedScripts();
if (this.uiManager) this.uiManager.finishLoading();
this.fireDelayedDOMReadyEvents();
return;
}
this.initializeResourceQueue();
@@ -892,7 +924,7 @@ if (typeof window.ResourceShareManager === 'undefined') {
};
}
markResourceLoaded(url) {
async markResourceLoaded(url) {
this.loadedResources.add(url);
this.log(`资源加载完成: ${url} (${this.loadedResources.size}/${this.resourceShareElements.length})`, 'success');
@@ -905,21 +937,31 @@ if (typeof window.ResourceShareManager === 'undefined') {
this.log('所有 resource-share 资源加载完成', 'success');
if (this.scriptTimeout) { clearTimeout(this.scriptTimeout); this.scriptTimeout = null; }
this.releaseBlockedScripts();
// 修复:await 等待所有被阻塞脚本串行执行完成,再隐藏 loading 和触发 DOMContentLoaded
// 否则 finishLoading() 和 fireDelayedDOMReadyEvents() 会在脚本执行前运行
await this.releaseBlockedScripts();
if (this.uiManager) {
this.uiManager.finishLoading();
}
if (this.isTopLevel) this.fireDelayedDOMReadyEvents();
// 修复:移除 isTopLevel 限制,子页面也需要触发延迟的 DOMContentLoaded
// 子页面被阻塞的脚本释放后,需要触发已拦截的 DOMContentLoaded 监听器
this.fireDelayedDOMReadyEvents();
}
}
releaseBlockedScripts() {
async releaseBlockedScripts() {
this.isBlockingEnabled = false;
this.log(`释放 ${this.blockedScripts.length} 个被阻塞的脚本`, 'info');
this.blockedScripts.forEach(scriptInfo => this.executeScriptInfo(scriptInfo));
// 修复:按原始顺序串行执行,外部脚本需等待加载完成后再执行下一个
// 否则内联脚本会在外部脚本(如 CompanyCommon.js)加载完成前执行,
// 导致 ReferenceError: XXX is not defined
const scripts = this.blockedScripts;
this.blockedScripts = [];
for (const scriptInfo of scripts) {
await this.executeScriptInfo(scriptInfo);
}
}
executeScriptInfo(scriptInfo) {
@@ -927,7 +969,7 @@ if (typeof window.ResourceShareManager === 'undefined') {
if (scriptInfo.src) {
if (getFileNameFromPath(scriptInfo.src) == ResourceShareFileName) {
this.log(`绕过自指: ${scriptInfo.src}`, 'success');
return;
return Promise.resolve();
}
const script = document.createElement('script');
script.src = scriptInfo.src; script.type = scriptInfo.type;
@@ -937,21 +979,36 @@ if (typeof window.ResourceShareManager === 'undefined') {
if (scriptInfo.nonce) script.nonce = scriptInfo.nonce;
if (scriptInfo.referrerPolicy) script.referrerPolicy = scriptInfo.referrerPolicy;
script.onerror = (e) => {
const loadError = new Error(`Script Load Error: ${scriptInfo.src}`);
console.error(loadError);
this.log(`外部脚本加载失败 (致命): [${scriptInfo.src}]`, 'danger');
this.handleCriticalError(loadError, scriptInfo.src);
};
document.head.appendChild(script);
this.log(`执行外部脚本: ${scriptInfo.src}`, 'success');
// 修复:返回 Promise,等待外部脚本加载/执行完成后再 resolve
// 这样 releaseBlockedScripts 可以串行执行,确保依赖顺序
return new Promise((resolve) => {
script.onload = () => {
this.log(`执行外部脚本: ${scriptInfo.src}`, 'success');
resolve();
};
script.onerror = (e) => {
const loadError = new Error(`Script Load Error: ${scriptInfo.src}`);
console.error(loadError);
this.log(`外部脚本加载失败 (致命): [${scriptInfo.src}]`, 'danger');
this.handleCriticalError(loadError, scriptInfo.src);
// 即使失败也 resolve,避免阻塞后续脚本执行
resolve();
};
document.head.appendChild(script);
});
} else if (scriptInfo.text) {
try {
// V1.3.7.1: 生成内联脚本的 SourceURL (独立逻辑,不受 resource-share 逻辑影响)
const sourceURL = this.generateInlineScriptSourceURL();
const codeWithSourceMap = `${scriptInfo.text}\n//# sourceURL=${sourceURL}`;
const executeFunction = new Function(codeWithSourceMap);
executeFunction();
// 使用 <script> 标签执行内联脚本,确保 function 声明在全局作用域
// 修复:new Function(code)() 会将 function 声明变成局部变量,
// 导致 HTML onclick="fn()" 等内联事件处理器找不到函数
const inlineScript = document.createElement('script');
inlineScript.textContent = codeWithSourceMap;
inlineScript.setAttribute('data-resource-share', 'executing');
document.head.appendChild(inlineScript);
if (inlineScript.parentNode) inlineScript.parentNode.removeChild(inlineScript);
this.log(`执行内联脚本 [${sourceURL}]`, 'success');
} catch (error) {
console.error(error);
+1 -13
View File
@@ -1,13 +1 @@
/**
* 高性能跨资源共享系统 - (架构重构 V1.3.7.4)
*
* 【V1.3.7.4 更新内容】
* 1. 修正 <resource-share> 脚本 SourceURL 路径映射:
* - 现在生成完整路径 + VM_前缀文件名 (如 https://.../3.6.0/VM_jquery.min.js)。
* - 保留内联脚本的虚拟路径规则。
*
* Copyright (c) 2026 GVSDS Team
* Licensed under MIT License
*/
"use strict";function getFileNameFromPath(e){return e&&e.split(/[?#]/)[0].replace(/\\/g,"/").split("/").pop()||""}function getCurrentScriptFileName(){if(document.currentScript)return getFileNameFromPath(document.currentScript.src);const e=document.scripts;return getFileNameFromPath(e[e.length-1].src)}const ResourceShareFileName=getCurrentScriptFileName();function deepMerge(e={},t={}){const r={...e};for(const s in t)t.hasOwnProperty(s)&&("object"!=typeof t[s]||null===t[s]||Array.isArray(t[s])||"object"!=typeof e[s]||null===e[s]||Array.isArray(e[s])?r[s]=e.hasOwnProperty(s)?e[s]:t[s]:r[s]=deepMerge(e[s],t[s]));return r}function getAllowedLogTypes(e){const t="UI"===e?RS_CONFIG.LOG_LEVEL_UI:RS_CONFIG.LOG_LEVEL_DEVTOOLS;return RS_CONFIG.LOG_LEVEL_MAP[t]||RS_CONFIG.LOG_LEVEL_MAP.debug}function generateRandomString(e){const t="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";let r="";for(let s=0;s<e;s++)r+=t.charAt(Math.floor(36*Math.random()));return r}window.isDarkTheme=()=>{if(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches)return!0;try{const e=window.getComputedStyle(document.body).backgroundColor.match(/\d+/g);if(e&&e.length>=3){return(299*parseInt(e[0])+587*parseInt(e[1])+114*parseInt(e[2]))/1e3<128}}catch(e){}return!1},window.RS_CONFIG=deepMerge(window.RS_CONFIG||{},{VERSION:"1.3.7.5",COPYRIGHT:"© 2026 GVSDS Team",SOURCE_FILE:"ResourceShare.js",ENABLE_UI:!0,POST_MESSAGE_LIMIT:1048576,BLOB_LIMIT:1073741824,SCRIPT_TIMEOUT:3e5,PING_INTERVAL:100,PING_TIMEOUT:5e3,REQUEST_TIMEOUT:1e4,RETRY:{COUNT:3,DELAY:1500},Z_INDEX:{SPLASH:2147483647,PROGRESS:2147483646,PANEL:2147483645,ERROR:2147483648},LOG_LEVEL_UI:"debug",LOG_LEVEL_DEVTOOLS:"error",LOG_LEVEL_MAP:{debug:["info","success","warning","danger","cache","request"],info:["info","success","warning","danger"],error:["danger"],none:[]},ANIMATION:{SPLASH_HIDE:800,PROGRESS_COMPLETE:800,PANEL_HIDE:500,ERROR_SLIDE_UP:400,LOG_FADE_IN:200},UI_STYLE:{FONT_FAMILY:"'Segoe UI Variable', 'Segoe UI', sans-serif",MONO_FONT:"'Consolas', 'Monaco', monospace",BLUR_VALUE:"20px",SHADOW_OPACITY:.12}});const DIAGNOSTIC_RULES=[{id:"jquery_missing",check:e=>e.message&&(e.message.includes("$ is not defined")||e.message.includes("jQuery is not defined")),diagnose:e=>"<strong>jQuery 依赖错误:</strong><br/>脚本依赖 jQuery,但似乎未加载或加载顺序错误。<br/><em>解决方案: 请检查 resource-share 中 jQuery 是否在其他依赖库之前声明。</em>",headerStyle:"color: #ff3b30; font-weight: bold; font-size: 14px;",bodyStyle:"color: #fbff00; font-size: 12px;"},{id:"layui_missing",check:e=>e.message&&(e.message.includes("layui is not defined")||e.message.includes("Layui is not defined")),diagnose:e=>"<strong>Layui 缺失:</strong><br/>脚本依赖 Layui,但 Layui 似乎未加载。<br/><em>解决方案: 确保已正确引入 Layui 相关资源。</em>",headerStyle:"color: #ff9500; font-weight: bold; font-size: 14px;",bodyStyle:"color: #ffe4b5; font-size: 12px;"},{id:"syntax_error",check:e=>"SyntaxError"===e.name,diagnose:e=>`<strong>语法错误:</strong><br/>代码中存在语法错误。<br/><em>详情: ${e.message}</em>`,headerStyle:"color: #8e0000; font-weight: bold; font-size: 14px;",bodyStyle:"color: #ffb3ba; font-size: 12px;"},{id:"type_error",check:e=>"TypeError"===e.name,diagnose:e=>`<strong>类型错误:</strong><br/>尝试调用 undefined/null 的方法或访问属性。<br/><em>详情: ${e.message}</em>`,headerStyle:"color: #ff3a30; font-weight: bold; font-size: 14px;",bodyStyle:"color: #ffd6d6; font-size: 12px;"},{id:"network_error",check:e=>e.message&&(e.message.includes("Failed to fetch")||e.message.includes("NetworkError")||e.message.includes("4")||e.message.includes("5")),diagnose:e=>"<strong>网络错误:</strong><br/>资源无法获取,可能是断网或服务器故障。<br/><em>解决方案: 请检查网络连接。</em>",headerStyle:"color: #ff2d55; font-weight: bold; font-size: 14px;",bodyStyle:"color: #ffc7d0; font-size: 12px;"},{id:"cors_error",check:e=>e.message&&e.message.includes("CORS"),diagnose:e=>"<strong>跨域错误 (CORS):</strong><br/>跨域请求被浏览器安全策略拦截。<br/><em>解决方案: 配置服务器响应头 (Access-Control-Allow-Origin) 或使用代理。</em>",headerStyle:"color: #007aff; font-weight: bold; font-size: 14px;",bodyStyle:"color: #b3d4ff; font-size: 12px;"}];function runDiagnostics(e){let t="";DIAGNOSTIC_RULES.forEach(r=>{r.check(e)&&(t+=r.diagnose(e)+"<br/><br/>")}),t||(t='<span style="color:#666">未找到匹配此错误的诊断规则。</span>');let r=!1;return DIAGNOSTIC_RULES.forEach(t=>{if(t.check(e))try{let s=t.diagnose(e).replace(/<br\s*\/?>/gi,"\n").replace(/<[^>]*>?/gm,"").trim();const o=t.headerStyle||"color: #ff3b30; font-weight: bold; font-size: 14px;",n=t.bodyStyle||"color: #fbff00; font-size: 12px;";console.error(`%c[ResourceShare 诊断]%c ${s}`,o,n),r=!0}catch(e){console.error(`[ResourceShare 诊断] 诊断逻辑自身出错: ${e.message}`)}}),r||console.error(Line truncated
"use strict";function getFileNameFromPath(e){return e&&e.split(/[?#]/)[0].replace(/\\/g,"/").split("/").pop()||""}function getCurrentScriptFileName(){if(document.currentScript)return getFileNameFromPath(document.currentScript.src);const e=document.scripts;return getFileNameFromPath(e[e.length-1].src)}const ResourceShareFileName=getCurrentScriptFileName();function deepMerge(e={},t={}){const r={...e};for(const s in t)t.hasOwnProperty(s)&&("object"!=typeof t[s]||null===t[s]||Array.isArray(t[s])||"object"!=typeof e[s]||null===e[s]||Array.isArray(e[s])?r[s]=e.hasOwnProperty(s)?e[s]:t[s]:r[s]=deepMerge(e[s],t[s]));return r}function getAllowedLogTypes(e){const t="UI"===e?RS_CONFIG.LOG_LEVEL_UI:RS_CONFIG.LOG_LEVEL_DEVTOOLS;return RS_CONFIG.LOG_LEVEL_MAP[t]||RS_CONFIG.LOG_LEVEL_MAP.debug}function generateRandomString(e){const t="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";let r="";for(let s=0;s<e;s++)r+=t.charAt(Math.floor(36*Math.random()));return r}window.isDarkTheme=()=>{if(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches)return!0;try{const e=window.getComputedStyle(document.body).backgroundColor.match(/\d+/g);if(e&&e.length>=3){return(299*parseInt(e[0])+587*parseInt(e[1])+114*parseInt(e[2]))/1e3<128}}catch(e){}return!1},window.RS_CONFIG=deepMerge(window.RS_CONFIG||{},{VERSION:"1.3.7.5",COPYRIGHT:"© 2026 GVSDS Team",SOURCE_FILE:"ResourceShare.js",ENABLE_UI:!0,ENABLE_OVERLAY_PANEL:!1,POST_MESSAGE_LIMIT:1048576,BLOB_LIMIT:1073741824,SCRIPT_TIMEOUT:3e5,PING_INTERVAL:100,PING_TIMEOUT:5e3,REQUEST_TIMEOUT:1e4,RETRY:{COUNT:3,DELAY:1500},Z_INDEX:{SPLASH:2147483647,PROGRESS:2147483646,PANEL:2147483645,ERROR:2147483648},LOG_LEVEL_UI:"debug",LOG_LEVEL_DEVTOOLS:"error",LOG_LEVEL_MAP:{debug:["info","success","warning","danger","cache","request"],info:["info","success","warning","danger"],error:["danger"],none:[]},ANIMATION:{SPLASH_HIDE:800,PROGRESS_COMPLETE:800,PANEL_HIDE:500,ERROR_SLIDE_UP:400,LOG_FADE_IN:200},UI_STYLE:{FONT_FAMILY:"'Segoe UI Variable', 'Segoe UI', sans-serif",MONO_FONT:"'Consolas', 'Monaco', monospace",BLUR_VALUE:"20px",SHADOW_OPACITY:.12}});const DIAGNOSTIC_RULES=[{id:"jquery_missing",check:e=>e.message&&(e.message.includes("$ is not defined")||e.message.includes("jQuery is not defined")),diagnose:e=>"<strong>jQuery 依赖错误:</strong><br/>脚本依赖 jQuery,但似乎未加载或加载顺序错误。<br/><em>解决方案: 请检查 resource-share 中 jQuery 是否在其他依赖库之前声明。</em>",headerStyle:"color: #ff3b30; font-weight: bold; font-size: 14px;",bodyStyle:"color: #fbff00; font-size: 12px;"},{id:"layui_missing",check:e=>e.message&&(e.message.includes("layui is not defined")||e.message.includes("Layui is not defined")),diagnose:e=>"<strong>Layui 缺失:</strong><br/>脚本依赖 Layui,但 Layui 似乎未加载。<br/><em>解决方案: 确保已正确引入 Layui 相关资源。</em>",headerStyle:"color: #ff9500; font-weight: bold; font-size: 14px;",bodyStyle:"color: #ffe4b5; font-size: 12px;"},{id:"syntax_error",check:e=>"SyntaxError"===e.name,diagnose:e=>`<strong>语法错误:</strong><br/>代码中存在语法错误。<br/><em>详情: ${e.message}</em>`,headerStyle:"color: #8e0000; font-weight: bold; font-size: 14px;",bodyStyle:"color: #ffb3ba; font-size: 12px;"},{id:"type_error",check:e=>"TypeError"===e.name,diagnose:e=>`<strong>类型错误:</strong><br/>尝试调用 undefined/null 的方法或访问属性。<br/><em>详情: ${e.message}</em>`,headerStyle:"color: #ff3a30; font-weight: bold; font-size: 14px;",bodyStyle:"color: #ffd6d6; font-size: 12px;"},{id:"network_error",check:e=>e.message&&(e.message.includes("Failed to fetch")||e.message.includes("NetworkError")||e.message.includes("4")||e.message.includes("5")),diagnose:e=>"<strong>网络错误:</strong><br/>资源无法获取,可能是断网或服务器故障。<br/><em>解决方案: 请检查网络连接。</em>",headerStyle:"color: #ff2d55; font-weight: bold; font-size: 14px;",bodyStyle:"color: #ffc7d0; font-size: 12px;"},{id:"cors_error",check:e=>e.message&&e.message.includes("CORS"),diagnose:e=>"<strong>跨域错误 (CORS):</strong><br/>跨域请求被浏览器安全策略拦截。<br/><em>解决方案: 配置服务器响应头 (Access-Control-Allow-Origin) 或使用代理。</em>",headerStyle:"color: #007aff; font-weight: bold; font-size: 14px;",bodyStyle:"color: #b3d4ff; font-size: 12px;"}];function runDiagnostics(e){let t="";DIAGNOSTIC_RULES.forEach(r=>{r.check(e)&&(t+=r.diagnose(e)+"<br/><br/>")}),t||(t='<span style="color:#666">未找到匹配此错误的诊断规则。</span>');let r=!1;return DIAGNOSTIC_RULES.forEach(t=>{if(t.check(e))try{let s=t.diagnose(e).replace(/<br\s*\/?>/gi,"\n").replace(/<[^>]*>?/gm,"").trim();const o=t.headerStyle||"color: #ff3b30; font-weight: bold; font-size: 14px;",n=t.bodyStyle||"color: #fbff00; font-size: 12px;";console.error(`%c[ResourceShare 诊断]%c ${s}`,o,n),r=!0}catch(e){console.error(`[ResourceShare 诊断] 诊断逻辑自身出错: ${e.messageLine truncated
+2 -2
View File
@@ -1,2 +1,2 @@
0.0000.0195.4126
1954126
0.0000.0195.4129
1954129
+7 -1
View File
@@ -1,4 +1,10 @@
from tools import *
def openw(file, write, mode="1", encoding="UTF-8"):
if mode == "1" or mode == 1:
with open(file, "w", encoding=encoding) as f:
f.write(write)
elif mode == "2" or mode == 2:
with open(file, "wb") as f:
f.write(write)
def read_ver():
try:
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
HINT: ForeignKey(unique=True) is usually better served by a OneToOneField.
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+25
View File
@@ -0,0 +1,25 @@
$path = "d:\SMANAGE\gvsdsdk\models.py"
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
$content = [System.IO.File]::ReadAllText($path, $utf8NoBom)
$CRLF = "`r`n"
$sq = [char]39
$dq = [char]34
$tripleDq = "$dq$dq$dq"
# 构造死函数块(含 MARKER 行和尾部 2 个空行)
$deadFunc = "def _EltEndTimeDefault():$CRLF $tripleDq" + "已废弃:ELT 令牌过期时间默认值,仅为迁移历史兼容保留" + "$tripleDq$CRLF from django.conf import settings$CRLF days = getattr(settings, ${sq}ELT_TOKEN_LIFETIME_DAYS${sq}, 30) # MARKER$CRLF return _UtcNow() + _timedelta(days=days)$CRLF$CRLF$CRLF"
$before = $content.Length
$found1 = $content.Contains($deadFunc)
$content = $content.Replace($deadFunc, "")
# 删除死导入
$deadImport = "from datetime import timedelta as _timedelta$CRLF"
$found2 = $content.Contains($deadImport)
$content = $content.Replace($deadImport, "")
[System.IO.File]::WriteAllText($path, $content, $utf8NoBom)
$removed = $before - $content.Length
Write-Output "deadFunc found: $found1"
Write-Output "deadImport found: $found2"
Write-Output "chars removed: $removed"
+27
View File
@@ -0,0 +1,27 @@
; GVS Celery Beat 定时调度进程守护配置
; 部署:sudo cp deploy/supervisor/gvsds-celery-beat.conf /etc/supervisor/conf.d/
; 重载:sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start gvsds-celery-beat
[program:gvsds-celery-beat]
command=/opt/gvsds/venv/bin/celery -A gvsds beat -l info --schedule=/opt/gvsds/celerybeat-schedule --pidfile=/tmp/gvsds-celery-beat.pid
directory=/opt/gvsds
user=www-data
group=www-data
; beat 必须单实例运行,不可并发
autostart=true
autorestart=true
startsecs=10
startretries=999
stopwaitsecs=30
stopasgroup=true
killasgroup=true
stdout_logfile=/var/log/gvsds/celery-beat.out.log
stderr_logfile=/var/log/gvsds/celery-beat.err.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=10
stderr_logfile_maxbytes=50MB
stderr_logfile_backups=10
environment=DJANGO_SETTINGS_MODULE="gvsds.settings",PATH="/opt/gvsds/venv/bin:/usr/local/bin:/usr/bin:/bin"
@@ -0,0 +1,29 @@
; GVS Celery Worker 进程守护配置
; 部署:sudo cp deploy/supervisor/gvsds-celery-worker.conf /etc/supervisor/conf.d/
; 重载:sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start gvsds-celery-worker
[program:gvsds-celery-worker]
command=/opt/gvsds/venv/bin/celery -A gvsds worker -l info --time-limit=300 --soft-time-limit=240
directory=/opt/gvsds
user=www-data
group=www-data
autostart=true
autorestart=true
startsecs=10
startretries=999
stopwaitsecs=60
stopasgroup=true
killasgroup=true
; Celery 需要并发 worker 数通过 -c 参数控制(默认 prefork CPU 数)
; 如需指定:command=... celery -A gvsds worker -l info -c 4 --time-limit=300
stdout_logfile=/var/log/gvsds/celery-worker.out.log
stderr_logfile=/var/log/gvsds/celery-worker.err.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=10
stderr_logfile_maxbytes=50MB
stderr_logfile_backups=10
environment=DJANGO_SETTINGS_MODULE="gvsds.settings",PATH="/opt/gvsds/venv/bin:/usr/local/bin:/usr/bin:/bin"
+27
View File
@@ -0,0 +1,27 @@
; GVS Gunicorn Web 进程守护配置
; 部署:sudo cp deploy/supervisor/gvsds-web.conf /etc/supervisor/conf.d/
; 重载:sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start gvsds-web
[program:gvsds-web]
command=/opt/gvsds/venv/bin/gunicorn -c /opt/gvsds/gunicorn.conf.py gvsds.wsgi:application
directory=/opt/gvsds
user=www-data
group=www-data
autostart=true
autorestart=true
startsecs=5
startretries=999
stopwaitsecs=30
stopasgroup=true
killasgroup=true
; 日志轮转
stdout_logfile=/var/log/gvsds/web.out.log
stderr_logfile=/var/log/gvsds/web.err.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=10
stderr_logfile_maxbytes=50MB
stderr_logfile_backups=10
environment=DJANGO_SETTINGS_MODULE="gvsds.settings",PATH="/opt/gvsds/venv/bin:/usr/local/bin:/usr/bin:/bin"
+55
View File
@@ -0,0 +1,55 @@
"""Gunicorn 生产部署配置
启动:gunicorn -c gunicorn.conf.py gvsds.wsgi:application
参数可通过环境变量覆盖:
GUNICORN_BIND 监听地址(默认 127.0.0.1:8000)
GUNICORN_WORKERS worker 数(默认 CPU*2+1)
GUNICORN_TIMEOUT 请求超时秒(默认 60)
GUNICORN_MAX_REQUESTS 单 worker 最大请求数(防内存泄漏,默认 1000)
"""
import os
import multiprocessing
Bind = os.environ.get('GUNICORN_BIND', '127.0.0.1:8000')
# worker 数:默认 CPU*2+1,可环境变量覆盖
Workers = int(os.environ.get('GUNICORN_WORKERS', multiprocessing.cpu_count() * 2 + 1))
# 同步 worker 模式(WSGI 标准,适合 Django)
WorkerClass = 'sync'
# 请求超时(秒)——长任务应卸载到 Celery
Timeout = int(os.environ.get('GUNICORN_TIMEOUT', 60))
# 优雅关闭超时
GracefulTimeout = 30
# 保持连接
Keepalive = 5
# 单 worker 最大请求数后重启,防止内存泄漏
MaxRequests = int(os.environ.get('GUNICORN_MAX_REQUESTS', 1000))
MaxRequestsJitter = 50
# 预加载应用(共享连接池,节省内存,加快启动)
PreloadApp = True
# 限制请求行/字段大小,防大文件攻击
LimitRequestLine = 8190
LimitRequestField = 8190
LimitRequestFieldSize = 8190
# 日志
Accesslog = '-'
Errorlog = '-'
Loglevel = os.environ.get('GUNICORN_LOG_LEVEL', 'info')
# 优雅重启:收到 SIGTERM 时等待请求完成
CaptureOutput = True
# 进程名(便于 ps/top 识别)
ProcName = 'gvsds-gunicorn'
# 当 SIGTERM 时,先停止接受新请求,等待 GracefulTimeout 后强杀
GracefulExist = True
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -8
View File
@@ -1,7 +1,7 @@
from django.contrib import admin
from .models import (
User, UserAvatar, User2FA, UserPending2FA,
EphemeralToken, UserLoginLog, AccountActivation,
UserLoginLog, AccountActivation,
)
@@ -33,13 +33,6 @@ class UserPending2FAAdmin(admin.ModelAdmin):
list_display = ('UserUUID', 'ExpireAt')
@admin.register(EphemeralToken)
class EphemeralTokenAdmin(admin.ModelAdmin):
"""临时令牌管理"""
list_display = ('ELTUserUUID', 'ELTType', 'ELTStatus', 'ELTCreateTime', 'ELTEndTime')
list_filter = ('ELTType', 'ELTStatus')
@admin.register(UserLoginLog)
class UserLoginLogAdmin(admin.ModelAdmin):
"""登录日志管理"""
@@ -0,0 +1,27 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0002_user_is_staff_user_is_superuser'),
]
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[
migrations.RunSQL(
sql=[
"DROP TABLE IF EXISTS `EphemeralTokens`;",
"DROP TABLE IF EXISTS `EphemeralToken`;",
],
reverse_sql=migrations.RunSQL.noop,
),
],
state_operations=[
migrations.DeleteModel(
name='EphemeralToken',
),
],
),
]
+8 -33
View File
@@ -7,11 +7,12 @@ from gvsdsdk.model_utils import UUIDObj
"""模块:Core 数据模型
定义系统核心表:用户、头像、双因素认证、临时令牌、登录日志、账户激活。
定义系统核心表:用户、头像、双因素认证、登录日志、账户激活。
所有 ORM 字段与方法遵循帕斯卡命名法(首字母大写、缩写大写、单单词小写)。"""
def _elt_end_time_default():
"""已废弃:ELT 令牌过期时间默认值,仅为迁移历史兼容保留"""
from gvsds.app_config import getint as _app_getint
days = _app_getint('elt', 'TOKEN_LIFETIME_DAYS', fallback=30)
return datetime.datetime.utcnow() + datetime.timedelta(days=days)
@@ -100,7 +101,7 @@ class UserManager(BaseUserManager):
if not username:
raise ValueError('The Username field must be set')
user = self.model(
UserUUID=uuid.uuid4().bytes,
UserUUID=UUIDObj(uuid.uuid4()),
UserName=username,
UserEmail=email or '',
**extra_fields
@@ -224,14 +225,14 @@ class User(QModel):
user_roles = UserRole.objects.filter(
UserUUID=self.UserUUID
)
user_role_uuids = user_roles.values_list('RoleUUID_id', flat=True)
company_uuids = list(set(user_roles.values_list('CompanyUUID_id', flat=True)))
user_role_uuids = user_roles.values_list('RoleUUID', flat=True)
company_uuids = list(set(user_roles.values_list('CompanyUUID', flat=True)))
rp_qs = RolePermission.objects.filter(
RoleUUID_id__in=user_role_uuids
RoleUUID__in=user_role_uuids
)
if company_uuids:
rp_qs = rp_qs.filter(CompanyUUID_id__in=company_uuids)
perm_uuids = rp_qs.values_list('PermUUID_id', flat=True)
rp_qs = rp_qs.filter(CompanyUUID__in=company_uuids)
perm_uuids = rp_qs.values_list('PermUUID', flat=True)
return Permission.objects.filter(
PermUUID__in=perm_uuids,
PermCode=perm_code,
@@ -314,32 +315,6 @@ class UserPending2FA(QModel):
verbose_name_plural = '2FA待确认'
class EphemeralToken(QModel):
"""临时令牌表——用于 API 认证与会话管理"""
ELTUUID = UUIDField(primary_key=True, verbose_name='令牌UUID')
ELTUserUUID = UUIDField(db_index=True, verbose_name='用户UUID')
ELTCreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
ELTEndTime = models.DateTimeField(default=_elt_end_time_default, verbose_name='过期时间')
ELTType = models.SmallIntegerField(default=0, verbose_name='令牌类型')
ELTAllowIP = models.JSONField(default=list, verbose_name='允许IP')
ELTStatus = models.SmallIntegerField(default=1, db_index=True, verbose_name='状态')
class Meta:
db_table = 'EphemeralToken'
app_label = 'core'
verbose_name = '临时令牌'
verbose_name_plural = '临时令牌'
def IsExpired(self):
"""检查令牌是否过期"""
return datetime.datetime.utcnow() > self.ELTEndTime.replace(tzinfo=None)
def IsValid(self):
"""检查令牌是否有效"""
return self.ELTStatus == 1 and not self.IsExpired()
class UserLoginLog(QModel):
"""用户登录日志表"""
+3 -2
View File
@@ -134,8 +134,9 @@ class SubdomainView(View):
# ugm 子域:未登录则跳转登录页,已登录则跳转 gerp
if subdomain == 'ugm':
elt_token = request.COOKIES.get('ELToken')
if not elt_token:
# JWT Cookie 认证:检查 AccessToken 或 RefreshToken
has_jwt = request.COOKIES.get('AccessToken') or request.COOKIES.get('RefreshToken')
if not has_jwt:
# 未登录 → 跳转 www 登录页
if file_path and file_path not in ('/', 'login.html', 'register.html'):
login_url = f"https://www.gvsds.com/login.html?redirect=https://gerp.gvsds.com/"
Binary file not shown.
+267 -66
View File
@@ -1,9 +1,13 @@
import uuid
import datetime
from django.db import connection
import re
import logging
from django.db import connection, transaction, IntegrityError
from django.db.models import Q
from django.db.models.fields import NOT_PROVIDED
logger = logging.getLogger(__name__)
from gvsds.apps.gerp.models import (
Tenant, CompanyInfo, Company, Department, Role, UserRole, RolePermission,
Permission, Menu, UserABAC, ItemReference, Backpack,
@@ -11,8 +15,9 @@ from gvsds.apps.gerp.models import (
DepartmentSMTM, Workgroup, SimulatedTrigger,
CashReceiptsPaymentsLedger, Asset, Liability, UserSettings,
PositionTemplate, PositionTemplatePermission, Position, UserDept,
AuditLog,
)
from gvsds.apps.core.models import User, UserAvatar, EphemeralToken
from gvsds.apps.core.models import User, UserAvatar, User2FA, UserPending2FA
from gvsdsdk.order.models import Order, OrderItem, OrderLevel, OrderTemplate, OrderTemplateItem
from gvsds.apps.gerp.i18n import translate_field, translate_table
@@ -22,11 +27,10 @@ SYSTEM_COMPANY_UUID = uuid.UUID('00000000-0000-0000-0000-000000000001').bytes
def create_default_role_for_user(user, admin_user=None):
"""为新用户创建默认角色及用户-角色关联。
"""为新用户关联全局默认角色(不创建专有角色)。
角色名格式为"{用户名}的默认角色",状态为启用。
默认绑定系统公司(00000000-0000-0000-0000-000000000001),
角色租户取自该系统公司所属租户,回退到管理员租户或系统首个租户。
全局默认角色名为"默认角色",在系统租户下唯一存在。
新用户通过 UserRole 关联到该角色,如需单独权限则手动通过 UserRole 指定其他角色。
Args:
user: 新创建的 User 实例
@@ -44,10 +48,13 @@ def create_default_role_for_user(user, admin_user=None):
# 回退:当前操作管理员的首个角色租户
if not tenant_uuid and admin_user is not None:
admin_ur = UserRole.objects.filter(
UserUUID_id=admin_user.UserUUID
).select_related('RoleUUID').first()
if admin_ur and admin_ur.RoleUUID_id and admin_ur.RoleUUID:
tenant_uuid = admin_ur.RoleUUID.TenantUUID_id
UserUUID=admin_user.UserUUID
).first()
if admin_ur:
role_uuid = admin_ur.RoleUUID
if role_uuid:
role = Role.objects.filter(RoleUUID=role_uuid).first()
tenant_uuid = role.TenantUUID if role else None
# 回退:系统首个租户
if not tenant_uuid:
tenant = Tenant.objects.first()
@@ -55,19 +62,34 @@ def create_default_role_for_user(user, admin_user=None):
tenant_uuid = tenant.TenantUUID
if not tenant_uuid:
return
user_name = user.UserName or '用户'
role = Role.objects.create(
RoleUUID=uuid.uuid4().bytes,
TenantUUID_id=tenant_uuid,
RoleName=f"{user_name}的默认角色",
RoleStatus=1,
)
UserRole.objects.create(
UserRoleUUID=uuid.uuid4().bytes,
UserUUID_id=user.UserUUID,
RoleUUID_id=role.RoleUUID,
CompanyUUID_id=company_uuid,
)
# 查找或创建全局唯一的"默认角色"(不再为每个用户创建专有角色)
default_role = Role.objects.filter(
TenantUUID=tenant_uuid,
RoleName='默认角色',
).first()
if not default_role:
default_role = Role.objects.create(
RoleUUID=uuid.uuid4().bytes,
TenantUUID=tenant_uuid,
RoleName='默认角色',
RoleStatus=1,
AssignScope=0,
RoleDesc='系统默认角色,所有新用户自动关联',
)
# 避免重复关联
existing = UserRole.objects.filter(
UserUUID=user.UserUUID,
RoleUUID=default_role.RoleUUID,
).exists()
if not existing:
UserRole.objects.create(
UserRoleUUID=uuid.uuid4().bytes,
UserUUID=user.UserUUID,
RoleUUID=default_role.RoleUUID,
CompanyUUID=company_uuid,
)
except Exception:
pass
@@ -100,7 +122,6 @@ class FormService:
TABLE_MAP = {
'User': User,
'UserAvatar': UserAvatar,
'EphemeralToken': EphemeralToken,
'Tenant': Tenant,
'CompanyInfo': CompanyInfo,
'Company': Company,
@@ -135,16 +156,25 @@ class FormService:
'OrderLevel': OrderLevel,
'OrderTemplate': OrderTemplate,
'OrderTemplateItem': OrderTemplateItem,
'AuditLog': AuditLog,
}
# 敏感表:暂不限制,后续通过 RBAC 细粒度控制
ADMIN_ONLY_TABLES = set()
# 敏感表:仅管理员可操作(审计日志仅管理员可查)
ADMIN_ONLY_TABLES = {'AuditLog'}
# 只读表:普通用户只能查询,不能增删改
READONLY_TABLES_FOR_NORMAL = {'Company', 'CompanyInfo', 'Tenant', 'Role', 'Password', 'EphemeralToken'}
READONLY_TABLES_FOR_NORMAL = {'Company', 'CompanyInfo', 'Tenant', 'Role', 'Password'}
def __init__(self, user, current_company_uuid=None, admin_verify=False, current_role_uuid=None):
# 公司隔离豁免表:系统级全局表不按公司/租户过滤(菜单/权限是全局共享配置)
COMPANY_SCOPE_EXEMPT_TABLES = {'Menu', 'Permission'}
# 敏感表:写操作(增/删/改)自动记录审计日志
AUDIT_TABLES = {'User', 'Role', 'Permission', 'Tenant', 'Company'}
def __init__(self, user, current_company_uuid=None, admin_verify=False, current_role_uuid=None, current_tenant_uuid=None, ip_address='', role_scope=None):
self.user = user
self.ip_address = ip_address or ''
self.role_scope = role_scope
self.current_role_uuid = None
if current_role_uuid:
if hasattr(current_role_uuid, 'bytes'):
@@ -174,34 +204,33 @@ class FormService:
self.current_company_uuid = current_company_uuid
else:
self.current_company_uuid = None
# 当前租户 UUID,统一转为 bytes(从 Company.TenantUUID 推导)
self.current_tenant_uuid = None
if current_tenant_uuid is not None:
if hasattr(current_tenant_uuid, 'bytes'):
self.current_tenant_uuid = current_tenant_uuid.bytes
elif isinstance(current_tenant_uuid, str):
try:
self.current_tenant_uuid = uuid.UUID(current_tenant_uuid).bytes
except ValueError:
self.current_tenant_uuid = current_tenant_uuid
else:
self.current_tenant_uuid = current_tenant_uuid
# 强公司隔离:默认启用,仅当 admin_verify=True 且用户是管理员时才关闭
if admin_verify and self._is_admin(user):
self.company_scope = False
else:
self.company_scope = True
import logging
logging.info('[FormService] __init__: user=%s, company_scope=%s, current_company_uuid=%s (type=%s)',
logging.info('[FormService] __init__: user=%s, company_scope=%s, current_company_uuid=%s, current_tenant_uuid=%s',
user, self.company_scope,
self.current_company_uuid.hex() if isinstance(self.current_company_uuid, bytes) else self.current_company_uuid,
type(self.current_company_uuid).__name__ if self.current_company_uuid else 'None')
self.current_tenant_uuid.hex() if isinstance(self.current_tenant_uuid, bytes) else self.current_tenant_uuid)
def _is_admin(self, user):
"""判断用户是否为管理员:超级用户或当前角色拥有 admin:super 权限"""
if not user or not hasattr(user, 'is_authenticated') or not user.is_authenticated:
return False
# 超级用户始终是管理员
if getattr(user, 'IsSuperuser', False):
return True
# 通过 RolePermission 判断:当前角色是否拥有 admin:super 权限
if self.current_role_uuid:
try:
return RolePermission.objects.filter(
RoleUUID_id=self.current_role_uuid,
PermUUID__PermCode='admin:super'
).exists()
except Exception:
pass
return False
"""判断用户是否为管理员(委托给 gvsdsdk.auth.admin_service.IsAdmin)"""
from gvsdsdk.auth.admin_service import IsAdmin
return IsAdmin(user=user, role_uuid=self.current_role_uuid)
def _build_field_map(self, model):
pascal_map = {}
@@ -576,10 +605,19 @@ class FormService:
except Exception:
return {'records': [], 'total': 0}
# 强公司隔离:非管理员自动启用,仅返回当前公司数据
if self.company_scope:
# 强公司隔离:非管理员自动启用,仅返回当前公司/租户数据
# 系统级表(Menu/Permission)豁免隔离
if self.company_scope and table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES:
field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')}
if 'CompanyUUID' in field_names:
# 租户隔离:Tenant 表按 TenantUUID 过滤,只看自己所属租户
if table_name == 'Tenant':
if not self.current_tenant_uuid:
import logging
logging.warning('[FormService] Tenant query but current_tenant_uuid is None, user=%s', self.user)
return {'records': [], 'total': 0, 'page': 1, 'PageSize': 20}
qs = qs.filter(TenantUUID=self.current_tenant_uuid)
# 公司隔离:有 CompanyUUID 字段的表按当前公司过滤
elif 'CompanyUUID' in field_names:
if not self.current_company_uuid:
# 无公司上下文时返回空结果
import logging
@@ -596,6 +634,28 @@ class FormService:
else:
# UUIDField(包括 Company 表自身的主键)
qs = qs.filter(CompanyUUID=self.current_company_uuid)
# 租户隔离:有 TenantUUID 字段但无 CompanyUUID 的表(Role/CompanyInfo 等)按租户过滤
elif 'TenantUUID' in field_names:
if not self.current_tenant_uuid:
import logging
logging.warning('[FormService] tenant scope but current_tenant_uuid is None, user=%s, table=%s', self.user, table_name)
return {'records': [], 'total': 0, 'page': 1, 'PageSize': 20}
# 判断 TenantUUID 字段类型:ForeignKey 用 _id 后缀
tenant_field = None
for f in model._meta.get_fields():
if hasattr(f, 'name') and f.name == 'TenantUUID':
tenant_field = f
break
if tenant_field and tenant_field.__class__.__name__ == 'ForeignKey':
qs = qs.filter(TenantUUID_id=self.current_tenant_uuid)
else:
qs = qs.filter(TenantUUID=self.current_tenant_uuid)
# 角色作用域过滤:针对 User/UserRole/Order 表按角色差异化可见范围
# admin 看全部,dept_manager 看本部门,workgroup_lead 看本工作组,member 只看自己
if self.role_scope and table_name in ('User', 'UserRole', 'Order'):
from gvsds.apps.gerp.role_scope import ApplyScopeToQueryset
qs = ApplyScopeToQueryset(qs, self.role_scope, table_name)
# 精确字段过滤:URL 参数中与模型字段名匹配的参数用于精确过滤
# 已知的分页/搜索参数不参与过滤
@@ -653,8 +713,10 @@ class FormService:
# 识别 entityPicker 字段,使用 select_related 预加载关联实体
ep_fields = self._get_entity_picker_fields(model)
if ep_fields:
fk_names = [f['field_name'] for f in ep_fields]
qs = qs.select_related(*fk_names)
fk_names = [f['field_name'] for f in ep_fields
if f['field_name'] not in ('RoleUUID', 'PermUUID', 'PositionTemplateUUID', 'PositionUUID')]
if fk_names:
qs = qs.select_related(*fk_names)
page_qs = qs[start:start + page_size]
# 先获取模型实例列表(用于 _enrich_entity_labels)
obj_list = list(page_qs)
@@ -705,7 +767,8 @@ class FormService:
create_data[attr_name] = val
# 强公司隔离:当启用 company_scope 且当前角色有公司上下文时,
# 自动注入当前公司 UUID(若模型有 CompanyUUID 字段且未显式提供)
if self.company_scope and self.current_company_uuid:
# 系统级表(Menu/Permission/Tenant/Company 等)豁免,不自动注入公司
if self.company_scope and self.current_company_uuid and table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES:
field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')}
if 'CompanyUUID' in field_names:
pk_field = model._meta.pk
@@ -714,28 +777,49 @@ class FormService:
if 'CompanyUUID' not in create_data:
create_data['CompanyUUID'] = self.current_company_uuid
else:
# CompanyUUID 是外键:使用 _id 后缀
has_company = any(
k == 'CompanyUUID' or k == 'CompanyUUID_id' for k in create_data
)
if not has_company:
create_data['CompanyUUID_id'] = self.current_company_uuid
# 判断 CompanyUUID 字段类型:ForeignKey 用 _id,UUIDField 用字段名
company_field = None
for f in model._meta.get_fields():
if hasattr(f, 'name') and f.name == 'CompanyUUID':
company_field = f
break
if company_field and company_field.__class__.__name__ == 'ForeignKey':
create_data['CompanyUUID_id'] = self.current_company_uuid
else:
create_data['CompanyUUID'] = self.current_company_uuid
pk_field_name = model._meta.pk.name if model._meta.pk else None
if pk_field_name and pk_field_name not in create_data:
pk_field = model._meta.pk
if pk_field.__class__.__name__ == 'UUIDField':
create_data[pk_field_name] = uuid.uuid4().bytes
# 特殊处理:User 表的密码需要 bcrypt 加密,不能明文存储
if table_name == 'User' and 'UserPassword' in create_data:
raw_password = create_data.pop('UserPassword')
obj = model(**create_data)
obj.SetPassword(raw_password)
obj.save()
else:
obj = model.objects.create(**create_data)
try:
if table_name == 'User' and 'UserPassword' in create_data:
raw_password = create_data.pop('UserPassword')
obj = model(**create_data)
obj.SetPassword(raw_password)
obj.save()
else:
obj = model.objects.create(**create_data)
except IntegrityError as e:
# 捕获数据库完整性错误(如必填字段为 null),返回友好的错误信息
error_msg = str(e)
m = re.search(r"Column '(\w+)' cannot be null", error_msg)
if m:
field_name = m.group(1)
logger.warning('[add_record/%s] 必填字段 %s 为空, create_data=%s', table_name, field_name, create_data)
return {'error': f"必填字段 '{field_name}' 不能为空(可能前端未通过 URL 传入关联字段,或表单缺少必填项)", 'field': field_name}
logger.warning('[add_record/%s] IntegrityError: %s, create_data=%s', table_name, error_msg, create_data)
return {'error': f'数据完整性错误: {error_msg}'}
# 新增用户时自动创建默认角色(与 register 流程保持一致)
if table_name == 'User':
self._create_default_role_for_user(obj)
# 审计日志:记录新增操作
self._record_audit(table_name, 'add', target_obj=obj, new_data=self._get_record_snapshot(obj))
return {'PK': str(obj.pk) if obj.pk else None}
def _create_default_role_for_user(self, user):
@@ -758,9 +842,11 @@ class FormService:
obj = model.objects.get(**{pk_field_name: pk_value})
except model.DoesNotExist:
return None
# 强公司隔离:company_scope 启用时校验目标记录归属当前公司
if self.company_scope and self.current_company_uuid and not self._check_company_owner(obj):
# 强公司隔离:company_scope 启用时校验目标记录归属当前公司(系统级表豁免)
if self.company_scope and self.current_company_uuid and table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES and not self._check_company_owner(obj):
return None
# 审计日志:修改前捕获旧数据快照
old_snapshot = self._get_record_snapshot(obj) if table_name in self.AUDIT_TABLES else None
pascal_map = self._build_field_map(model)
update_fields = []
for key, val in data.items():
@@ -790,6 +876,10 @@ class FormService:
update_fields.append(attr_name)
if update_fields:
obj.save(update_fields=update_fields)
# 审计日志:记录修改操作
if table_name in self.AUDIT_TABLES:
new_snapshot = self._get_record_snapshot(obj)
self._record_audit(table_name, 'modify', target_obj=obj, old_data=old_snapshot, new_data=new_snapshot)
return {'PK': str(obj.pk) if obj.pk else None}
def modify_item(self, data):
@@ -811,10 +901,48 @@ class FormService:
obj = model.objects.get(**{pk_field_name: pk_value})
except model.DoesNotExist:
return False
# 强公司隔离:company_scope 启用时校验目标记录归属当前公司
if self.company_scope and self.current_company_uuid and not self._check_company_owner(obj):
# 强公司隔离:company_scope 启用时校验目标记录归属当前公司(系统级表豁免)
if self.company_scope and self.current_company_uuid and table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES and not self._check_company_owner(obj):
return False
# 审计日志:删除前捕获旧数据快照和目标 UUID
old_snapshot = self._get_record_snapshot(obj) if table_name in self.AUDIT_TABLES else None
_audit_target_uuid = obj.pk if table_name in self.AUDIT_TABLES else None
# 用户删除时级联清理:默认角色 + 所有 UserRole 关联
if table_name == 'User':
with transaction.atomic():
user_uuid = obj.UserUUID
user_name = getattr(obj, 'UserName', '') or ''
# 查找该用户的所有 UserRole 关联
user_roles = list(UserRole.objects.filter(
UserUUID=user_uuid
))
# 识别默认角色:名称匹配 "{用户名}的默认角色" 且仅此用户一人
default_role_ids = []
for ur in user_roles:
role = Role.objects.filter(RoleUUID=ur.RoleUUID).first()
if role and getattr(role, 'RoleName', '') == f"{user_name}的默认角色":
other_count = UserRole.objects.filter(
RoleUUID=role.RoleUUID
).exclude(UserUUID=user_uuid).count()
if other_count == 0:
default_role_ids.append(role.RoleUUID)
# 删除所有 UserRole 关联
UserRole.objects.filter(UserUUID=user_uuid).delete()
# 删除默认角色(RolePermission 会被 cascade 删除)
if default_role_ids:
Role.objects.filter(RoleUUID__in=default_role_ids).delete()
# 清理 UserAvatar / User2FA / UserPending2FA(这些表用 UUIDField 而非 ForeignKey,不会自动级联)
UserAvatar.objects.filter(UserUUID=user_uuid).delete()
User2FA.objects.filter(UserUUID=user_uuid).delete()
UserPending2FA.objects.filter(UserUUID=user_uuid).delete()
# 删除用户本身
obj.delete()
# 审计日志:记录删除操作
self._record_audit(table_name, 'delete', old_data=old_snapshot, target_uuid=_audit_target_uuid)
return True
obj.delete()
# 审计日志:记录删除操作
self._record_audit(table_name, 'delete', old_data=old_snapshot, target_uuid=_audit_target_uuid)
return True
def _check_company_owner(self, obj):
@@ -831,11 +959,84 @@ class FormService:
if pk_field and pk_field.name == 'CompanyUUID':
return obj.pk == self.current_company_uuid
# CompanyUUID 是外键时比较 _id
obj_company = getattr(obj, 'CompanyUUID_id', None)
obj_company = getattr(obj, 'CompanyUUID_id', None) or getattr(obj, 'CompanyUUID', None)
return obj_company == self.current_company_uuid
except Exception:
return True
def _get_record_snapshot(self, obj):
"""将模型实例转为可 JSON 序列化的字典(用于审计日志数据快照)"""
try:
record = {}
for f in obj._meta.concrete_fields:
val = getattr(obj, f.attname, None)
if isinstance(val, bytes) and len(val) == 16:
try:
val = str(uuid.UUID(bytes=val))
except ValueError:
pass
elif isinstance(val, (datetime.datetime, datetime.date)):
val = val.isoformat()
record[f.name] = val
return record
except Exception:
return {}
def _record_audit(self, table_name, action, target_obj=None, old_data=None, new_data=None, target_uuid=None):
"""记录审计日志——仅对 AUDIT_TABLES 中的表生效
Args:
table_name: 表名
action: 操作类型(add/modify/delete)
target_obj: 目标记录对象(用于提取 TargetUUID 和公司/租户归属)
old_data: 变更前数据快照
new_data: 变更后数据快照
target_uuid: 显式指定目标 UUID(删除后对象已不存在时使用)
"""
if table_name not in self.AUDIT_TABLES:
return
try:
company_uuid = self.current_company_uuid
tenant_uuid = self.current_tenant_uuid
if target_obj is not None:
pk_val = getattr(target_obj, 'pk', None)
if pk_val and isinstance(pk_val, bytes) and len(pk_val) == 16:
target_uuid = pk_val
elif pk_val:
target_uuid = pk_val
# 从目标记录推导公司/租户归属(若存在字段)
field_names = {f.name for f in target_obj._meta.get_fields() if hasattr(f, 'name')}
if 'CompanyUUID' in field_names and not company_uuid:
obj_company = getattr(target_obj, 'CompanyUUID_id', None) or getattr(target_obj, 'CompanyUUID', None)
if obj_company and isinstance(obj_company, bytes):
company_uuid = obj_company
if 'TenantUUID' in field_names and not tenant_uuid:
obj_tenant = getattr(target_obj, 'TenantUUID_id', None) or getattr(target_obj, 'TenantUUID', None)
if obj_tenant and isinstance(obj_tenant, bytes):
tenant_uuid = obj_tenant
user_uuid = None
if self.user and hasattr(self.user, 'UserUUID'):
user_uuid = self.user.UserUUID
if isinstance(user_uuid, str):
try:
user_uuid = uuid.UUID(user_uuid).bytes
except ValueError:
pass
AuditLog.objects.create(
AuditLogUUID=uuid.uuid4().bytes,
TenantUUID_id=tenant_uuid,
CompanyUUID_id=company_uuid,
UserUUID_id=user_uuid,
TableName=table_name,
Action=action,
TargetUUID=target_uuid,
IPAddress=self.ip_address,
OldData=old_data,
NewData=new_data,
)
except Exception:
logger.warning('[AuditLog] 记录审计日志失败: table=%s action=%s', table_name, action, exc_info=True)
def code_display(self, data):
table_name = data.get('Table', '')
code_field = data.get('CodeField', 'PermCode')
Whitespace-only changes.
@@ -0,0 +1,65 @@
"""清理过期的 AuditLog 审计日志记录。
用法:
python manage.py CleanAuditLogs # 默认保留 90 天
python manage.py CleanAuditLogs --days=30 # 自定义保留天数
python manage.py CleanAuditLogs --days=90 --dry-run # 仅预览将删除的数量
"""
import datetime
import logging
from django.core.management.base import BaseCommand
from django.utils import timezone
from gvsds.apps.gerp.models import AuditLog
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = '清理过期的审计日志(AuditLog),默认保留 90 天'
def add_arguments(self, parser):
parser.add_argument(
'--days',
type=int,
default=90,
help='保留最近 N 天的日志,默认 90 天',
)
parser.add_argument(
'--dry-run',
action='store_true',
default=False,
help='仅统计将删除的数量,不实际删除',
)
def handle(self, *args, **options):
days = options.get('days', 90)
dry_run = options.get('dry-run', False)
if days < 1:
self.stdout.write(self.style.ERROR('保留天数必须大于 0'))
return
# 按 CreateTime 字段过滤(AuditLog.CreateTime 为 UTC 时间)
threshold = timezone.now() - datetime.timedelta(days=days)
qs = AuditLog.objects.filter(CreateTime__lt=threshold)
count = qs.count()
if dry_run:
self.stdout.write(self.style.WARNING(
f'[DryRun] 将删除 {count} 条早于 {days} 天的审计日志(阈值: {threshold.isoformat()})'
))
return
if count == 0:
self.stdout.write(self.style.SUCCESS(
f'无需清理:没有早于 {days} 天的审计日志'
))
return
deleted_count, _ = qs.delete()
self.stdout.write(self.style.SUCCESS(
f'清理完成:已删除 {deleted_count} 条早于 {days} 天的审计日志(阈值: {threshold.isoformat()})'
))
logger.info('[CleanAuditLogs] 删除 %s 条审计日志(保留 %s 天)', deleted_count, days)
Whitespace-only changes.
@@ -0,0 +1,15 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('gerp', '0008_workgroup_companyuuid'),
]
operations = [
migrations.RunSQL(
sql='DROP TABLE IF EXISTS `RoleAdmin`;',
reverse_sql=migrations.RunSQL.noop,
),
]
@@ -0,0 +1,39 @@
# Generated for AuditLog model
import datetime
import django.db.models.deletion
import gvsds.apps.core.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
('gerp', '0009_remove_roleadmin'),
]
operations = [
migrations.CreateModel(
name='AuditLog',
fields=[
('audit_log_uuid', gvsds.apps.core.models.UUIDField(db_column='AuditLogUUID', primary_key=True, serialize=False)),
('table_name', models.CharField(db_column='TableName', db_index=True, max_length=64)),
('action', models.CharField(db_column='Action', max_length=16)),
('target_uuid', gvsds.apps.core.models.UUIDField(blank=True, db_column='TargetUUID', null=True)),
('ip_address', models.CharField(blank=True, db_column='IPAddress', default='', max_length=64)),
('old_data', models.JSONField(blank=True, db_column='OldData', null=True)),
('new_data', models.JSONField(blank=True, db_column='NewData', null=True)),
('create_time', models.DateTimeField(db_column='CreateTime', db_index=True, default=datetime.datetime.utcnow)),
('tenant_uuid', models.ForeignKey(blank=True, db_column='TenantUUID', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gerp.tenant')),
('company_uuid', models.ForeignKey(blank=True, db_column='CompanyUUID', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gerp.company')),
('user_uuid', models.ForeignKey(blank=True, db_column='UserUUID', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.user')),
],
options={
'db_table': 'AuditLog',
'ordering': ['-CreateTime'],
'verbose_name': '操作审计日志',
'verbose_name_plural': '操作审计日志',
},
),
]
@@ -0,0 +1,46 @@
# Generated for AbnormalUserLog model
import datetime
import django.db.models.deletion
import gvsds.apps.core.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
('gerp', '0010_auditlog'),
]
operations = [
migrations.CreateModel(
name='AbnormalUserLog',
fields=[
('log_uuid', gvsds.apps.core.models.UUIDField(db_column='LogUUID', primary_key=True, serialize=False, verbose_name='日志UUID')),
('account_id', models.CharField(db_column='AccountID', db_index=True, default='', max_length=64, verbose_name='账号标识')),
('ip_address', models.CharField(db_column='IPAddress', db_index=True, default='', max_length=64, verbose_name='客户端IP')),
('real_ip', models.CharField(blank=True, db_column='RealIP', default='', max_length=64, verbose_name='真实IP')),
('x_forwarded_for', models.CharField(blank=True, db_column='XForwardedFor', default='', max_length=512, verbose_name='XFF头')),
('user_agent', models.CharField(blank=True, db_column='UserAgent', default='', max_length=512, verbose_name='User-Agent')),
('request_method', models.CharField(blank=True, db_column='RequestMethod', default='', max_length=16, verbose_name='请求方法')),
('request_path', models.CharField(db_column='RequestPath', db_index=True, default='', max_length=512, verbose_name='请求路径')),
('request_params', models.JSONField(blank=True, db_column='RequestParams', null=True, verbose_name='查询参数')),
('request_body', models.TextField(blank=True, db_column='RequestBody', default='', verbose_name='请求体摘要')),
('request_headers', models.JSONField(blank=True, db_column='RequestHeaders', null=True, verbose_name='请求头')),
('attempt_type', models.CharField(db_column='AttemptType', db_index=True, default='', max_length=32, verbose_name='攻击类型')),
('response_status', models.IntegerField(db_column='ResponseStatus', default=0, verbose_name='响应状态码')),
('remark', models.CharField(blank=True, db_column='Remark', default='', max_length=512, verbose_name='备注')),
('create_time', models.DateTimeField(db_column='CreateTime', db_index=True, default=datetime.datetime.utcnow, verbose_name='创建时间')),
('tenant_uuid', models.ForeignKey(blank=True, db_column='TenantUUID', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gerp.tenant', verbose_name='租户UUID')),
('company_uuid', models.ForeignKey(blank=True, db_column='CompanyUUID', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gerp.company', verbose_name='公司UUID')),
('user_uuid', models.ForeignKey(blank=True, db_column='UserUUID', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.user', verbose_name='用户UUID')),
],
options={
'db_table': 'AbnormalUserLog',
'ordering': ['-CreateTime'],
'verbose_name': '异常用户攻击日志',
'verbose_name_plural': '异常用户攻击日志',
},
),
]
@@ -0,0 +1,54 @@
"""Data Migration: 添加"文档编辑器"菜单项
为在线 Word 文档编辑器页面(pages/doc/editor.html)加入 GERP 菜单系统。
采用公有菜单(CompanyUUID 为空),任何公司用户均可从导航进入。
幂等性:通过固定 MenuUUID + update_or_create 保证多次运行不重复创建。
注意:historical model 的字段名为小写形式(如 menu_uuid),而非 PascalCase。
db_column 才是数据库列名(如 MenuUUID)。
"""
import uuid as _uuid
from django.db import migrations
# 固定菜单 UUID,保证幂等(多次 migrate 不会重复创建)
DOC_EDITOR_MENU_UUID = _uuid.UUID('00000000-0000-4000-8000-0000000000d0').bytes
def AddDocEditorMenu(apps, schema_editor):
"""正向:插入"文档编辑器"菜单项。"""
Menu = apps.get_model('gerp', 'Menu')
Menu.objects.update_or_create(
menu_uuid=DOC_EDITOR_MENU_UUID,
defaults={
'company_uuid': None, # 公有菜单(全局可见)
'parent_menu_uuid': None, # 根菜单
'menu_name': 'doc-editor',
'menu_title': '文档编辑器',
'menu_icon': '<i class="fas fa-file-word"></i>',
'menu_link': 'pages/doc/editor.html',
'menu_sort': 900, # 排在系统类菜单之后
'permission_level': 1.0, # 普通用户可见
'permission_attr': '', # 无额外权限要求
'menu_status': 1, # 启用
'menu_type': 1, # 页面类型
},
)
def RemoveDocEditorMenu(apps, schema_editor):
"""逆向:删除"文档编辑器"菜单项。"""
Menu = apps.get_model('gerp', 'Menu')
Menu.objects.filter(menu_uuid=DOC_EDITOR_MENU_UUID).delete()
class Migration(migrations.Migration):
dependencies = [
('gerp', '0011_abnormaluserlog'),
]
operations = [
migrations.RunPython(AddDocEditorMenu, reverse_code=RemoveDocEditorMenu),
]
+54 -272
View File
@@ -12,7 +12,6 @@
所有 ORM 字段与方法遵循帕斯卡命名法(首字母大写、缩写大写、单单词小写)。"""
from gvsdsdk.model_base import QModel
import uuid
import datetime
from django.db import models
from gvsds.apps.core.models import UUIDField, User
@@ -175,148 +174,6 @@ class Department(QModel):
return self.DeptName
class Role(QModel):
"""角色表——权限分配单元
角色是真正承载操作权限的实体,在单个租户内部独立存在,
不依附任何公司或部门。通过 AssignScope 控制角色的分配可见范围:
- 0:全租户管理员可分发
- 1:仅指定公司管理员可分发(AssignScopeUUID = CompanyUUID)
- 2:仅指定部门管理员可分发(AssignScopeUUID = DeptUUID)
"""
RoleUUID = UUIDField(primary_key=True, verbose_name='角色UUID')
TenantUUID = models.ForeignKey('Tenant', on_delete=models.CASCADE, related_name='+', db_column='TenantUUID', verbose_name='租户UUID')
RoleName = models.CharField(max_length=128, verbose_name='角色名称')
RoleType = models.SmallIntegerField(default=2, verbose_name='角色类型')
RoleStatus = models.SmallIntegerField(default=1, verbose_name='角色状态')
AssignScope = models.SmallIntegerField(default=0, verbose_name='分配可见范围')
AssignScopeUUID = UUIDField(null=True, blank=True, verbose_name='分配范围UUID')
RoleDesc = models.CharField(max_length=512, default='', verbose_name='角色描述')
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
class Meta:
db_table = 'Role'
app_label = 'gerp'
ordering = ['-CreateTime']
verbose_name = '角色'
verbose_name_plural = '角色'
def __str__(self):
return self.RoleName
def GetPermissions(self):
"""获取角色拥有的权限列表(通过 RolePermission 中间表)"""
return Permission.objects.filter(
rolepermission__RoleUUID=self.RoleUUID,
PermStatus=1,
)
def GetUsers(self):
"""获取拥有该角色的用户列表"""
from gvsds.apps.core.models import User
return User.objects.filter(userrole__RoleUUID=self.RoleUUID)
class RoleAdmin(QModel):
"""管理员角色表——标记哪些角色具有管理员权限
只要在本表中存在对应 RoleUUID 记录,该角色即为管理员角色。
不存在则非管理员。通过单独表实现,避免在 Role 表上增加冗余字段。
"""
RoleAdminUUID = UUIDField(primary_key=True, verbose_name='主键UUID')
RoleUUID = models.ForeignKey('Role', on_delete=models.CASCADE, related_name='roleadmin_set', db_column='RoleUUID', verbose_name='角色UUID')
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
class Meta:
db_table = 'RoleAdmin'
app_label = 'gerp'
verbose_name = '管理员角色'
verbose_name_plural = '管理员角色'
def __str__(self):
return f'RoleAdmin({self.RoleUUID})'
class UserRole(QModel):
"""用户角色关联表——用户与角色的多对多映射
通过 CompanyUUID 标识该用户-角色关联所属的公司,
用于菜单按公司差异化显示、数据权限按公司过滤等场景。
同一用户在不同公司下可绑定不同角色。
DeptUUID、WorkgroupUUID 为可选字段,用于将关联进一步限定到部门或工作组。
RoleType 标识该用户在部门/工作组中的角色类型:0=普通成员/部门员工,1=组长/部门负责人。
"""
UserRoleUUID = UUIDField(primary_key=True, verbose_name='关联UUID')
UserUUID = models.ForeignKey(User, on_delete=models.CASCADE, related_name='userrole_set', db_column='UserUUID', db_index=True, verbose_name='用户UUID')
RoleUUID = models.ForeignKey('Role', on_delete=models.CASCADE, related_name='userrole_set', db_column='RoleUUID', verbose_name='角色UUID')
CompanyUUID = models.ForeignKey('Company', on_delete=models.CASCADE, related_name='userrole_set', db_column='CompanyUUID', verbose_name='公司UUID')
DeptUUID = models.ForeignKey('Department', on_delete=models.SET_NULL, null=True, blank=True, related_name='+', db_column='DeptUUID', verbose_name='部门UUID')
WorkgroupUUID = models.ForeignKey('Workgroup', on_delete=models.SET_NULL, null=True, blank=True, related_name='+', db_column='WorkgroupUUID', verbose_name='工作组UUID')
RoleType = models.SmallIntegerField(default=0, db_column='RoleType', verbose_name='角色类型')
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
class Meta:
db_table = 'UserRole'
app_label = 'gerp'
verbose_name = '用户角色关联'
verbose_name_plural = '用户角色关联'
class RolePermission(QModel):
"""角色权限关联表——角色与操作权限的多对多映射
一个角色可以拥有多个权限,一个权限也可以被多个角色引用。
角色和具体操作权限为多对多绑定关系,一套权限集合可以打包在一个角色内统一维护。
同一角色在不同公司下可拥有不同权限集合,通过 CompanyUUID 区分。
"""
RolePermUUID = UUIDField(primary_key=True, verbose_name='关联UUID')
RoleUUID = models.ForeignKey('Role', on_delete=models.CASCADE, related_name='rolepermission_set', db_column='RoleUUID', verbose_name='角色UUID')
PermUUID = models.ForeignKey('Permission', on_delete=models.CASCADE, related_name='rolepermission_set', db_column='PermUUID', verbose_name='权限UUID')
CompanyUUID = models.ForeignKey('Company', on_delete=models.CASCADE, related_name='rolepermission_set', db_column='CompanyUUID', verbose_name='公司UUID')
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
class Meta:
db_table = 'RolePermission'
app_label = 'gerp'
verbose_name = '角色权限关联'
verbose_name_plural = '角色权限关联'
class Permission(QModel):
"""权限表——操作权限定义
独立的权限定义表,租户内全局可用,不再挂在角色下。
通过 RolePermission 中间表与角色建立多对多关系,
通过 PositionTemplatePermission 中间表与岗位模板建立多对多关系。
"""
PermUUID = UUIDField(primary_key=True, verbose_name='权限UUID')
TenantUUID = models.ForeignKey('Tenant', on_delete=models.CASCADE, related_name='+', db_column='TenantUUID', verbose_name='租户UUID')
PermName = models.CharField(max_length=128, verbose_name='权限名称')
PermCode = models.CharField(max_length=128, db_index=True, verbose_name='权限键')
PermCategory = models.CharField(max_length=64, default='', verbose_name='权限分类')
PermStatus = models.SmallIntegerField(default=1, verbose_name='是否启用')
PermDesc = models.CharField(max_length=512, default='', verbose_name='描述')
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
class Meta:
db_table = 'Permission'
app_label = 'gerp'
verbose_name = '权限'
verbose_name_plural = '权限'
def __str__(self):
return f"{self.PermName} ({self.PermCode})"
def GetRoles(self):
"""获取拥有该权限的角色列表"""
return Role.objects.filter(rolepermission__PermUUID=self.PermUUID)
class FileAsset(QModel):
"""文件资产表——文件存储与引用管理"""
@@ -408,20 +265,6 @@ class Menu(QModel):
verbose_name_plural = '菜单'
class UserABAC(QModel):
"""用户 ABAC 表——基于属性的访问控制"""
UserABACUUID = UUIDField(primary_key=True, verbose_name='ABAC UUID')
UserUUID = UUIDField(verbose_name='用户UUID')
HierarchicalPermissionIdentifiers = models.CharField(max_length=256, verbose_name='层级权限标识')
class Meta:
db_table = 'UserABAC'
app_label = 'gerp'
verbose_name = '用户ABAC'
verbose_name_plural = '用户ABAC'
class LegacyDepartment(QModel):
"""遗留部门表——历史数据兼容"""
@@ -468,19 +311,6 @@ class Workgroup(QModel):
verbose_name_plural = '工作组'
class Password(QModel):
"""密码表——独立密码存储"""
UserUUID = UUIDField(primary_key=True, verbose_name='用户UUID')
Password = models.CharField(max_length=1024, verbose_name='密码哈希')
class Meta:
db_table = 'Password'
app_label = 'gerp'
verbose_name = '密码'
verbose_name_plural = '密码'
class ItemReference(QModel):
"""物品引用表——物品模板定义"""
@@ -714,127 +544,79 @@ class UserSettings(QModel):
verbose_name_plural = '用户设置'
class PositionTemplate(QModel):
"""岗位模板表——权限初始化快照模板
class AuditLog(QModel):
"""操作审计日志表——记录敏感表的写操作(增/删/改)数据快照
租户内全局可重复使用,定位是权限初始化快照模板,
不直接分配给员工、不参与运行时鉴权。
新建角色时可一键复制模板内的全部权限内容生成新角色。
模板后续调整只会作用于之后新创建的角色,
已有角色不会同步变更,实现模板复用和存量权限独立管控互不干扰。
仅记录 User/Role/Permission/Tenant/Company 等敏感表的写操作,
按公司隔离查询(CompanyUUID 字段),含变更前后的数据快照。
通过管理命令 python manage.py CleanAuditLogs --days=90 自动清理过期日志。
"""
PositionTemplateUUID = UUIDField(primary_key=True, verbose_name='岗位模板UUID')
TenantUUID = models.ForeignKey('Tenant', on_delete=models.CASCADE, related_name='+', db_column='TenantUUID', verbose_name='租户UUID')
TemplateName = models.CharField(max_length=128, verbose_name='模板名称')
TemplateCode = models.CharField(max_length=64, default='', verbose_name='模板编码')
TemplateStatus = models.SmallIntegerField(default=1, verbose_name='模板状态')
TemplateDesc = models.CharField(max_length=512, default='', verbose_name='模板描述')
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
UpdateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='更新时间')
AuditLogUUID = UUIDField(primary_key=True, verbose_name='日志UUID')
TenantUUID = models.ForeignKey('Tenant', on_delete=models.SET_NULL, null=True, blank=True, related_name='+', db_column='TenantUUID', verbose_name='租户UUID')
CompanyUUID = models.ForeignKey('Company', on_delete=models.SET_NULL, null=True, blank=True, related_name='+', db_column='CompanyUUID', verbose_name='公司UUID')
UserUUID = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='+', db_column='UserUUID', verbose_name='操作人UUID')
TableName = models.CharField(max_length=64, db_index=True, verbose_name='表名')
Action = models.CharField(max_length=16, verbose_name='操作类型')
TargetUUID = UUIDField(null=True, blank=True, verbose_name='目标记录UUID')
IPAddress = models.CharField(max_length=64, default='', verbose_name='IP地址')
OldData = models.JSONField(null=True, blank=True, verbose_name='变更前数据')
NewData = models.JSONField(null=True, blank=True, verbose_name='变更后数据')
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, db_index=True, verbose_name='创建时间')
class Meta:
db_table = 'PositionTemplate'
db_table = 'AuditLog'
app_label = 'gerp'
ordering = ['-CreateTime']
verbose_name = '岗位模板'
verbose_name_plural = '岗位模板'
verbose_name = '操作审计日志'
verbose_name_plural = '操作审计日志'
def __str__(self):
return self.TemplateName
def GetPermissions(self):
"""获取模板关联的权限列表"""
return Permission.objects.filter(
positiontemplatepermission__PositionTemplateUUID=self.PositionTemplateUUID,
PermStatus=1,
)
def CopyToRole(self, role_name, tenant_uuid):
"""从模板复制权限到新角色(一次性复制,后续模板变更不影响已有角色)"""
role = Role.objects.create(
RoleUUID=uuid.uuid4().bytes,
TenantUUID_id=tenant_uuid,
RoleName=role_name,
)
template_perms = PositionTemplatePermission.objects.filter(
PositionTemplateUUID=self.PositionTemplateUUID
)
for tp in template_perms:
RolePermission.objects.create(
RolePermUUID=uuid.uuid4().bytes,
RoleUUID=role,
PermUUID=tp.PermUUID,
CompanyUUID_id=tenant_uuid, # 岗位模板复制时默认使用租户UUID作为公司
)
return role
return f'{self.TableName}:{self.Action} @ {self.CreateTime}'
class PositionTemplatePermission(QModel):
"""岗位模板权限关联表——岗位模板与操作权限的多对多映射
class AbnormalUserLog(QModel):
"""异常用户攻击日志表——记录恶意越权/攻击请求的完整指纹
仅作为复制源头,不参与运行时鉴权。
新建角色时一键复制模板权限到角色,复制后模板与角色无运行时绑定。
由 AttackDetectMiddleware 自动记录:403/429 响应、重复鉴权失败、
越权访问尝试等。按公司隔离查询,用于安全审计与攻击溯源。
"""
PTPermUUID = UUIDField(primary_key=True, verbose_name='关联UUID')
PositionTemplateUUID = models.ForeignKey('PositionTemplate', on_delete=models.CASCADE, related_name='positiontemplatepermission_set', db_column='PositionTemplateUUID', verbose_name='岗位模板UUID')
PermUUID = models.ForeignKey('Permission', on_delete=models.CASCADE, related_name='positiontemplatepermission_set', db_column='PermUUID', verbose_name='权限UUID')
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
LogUUID = UUIDField(primary_key=True, verbose_name='日志UUID')
TenantUUID = models.ForeignKey('Tenant', on_delete=models.SET_NULL, null=True, blank=True, related_name='+', db_column='TenantUUID', verbose_name='租户UUID')
CompanyUUID = models.ForeignKey('Company', on_delete=models.SET_NULL, null=True, blank=True, related_name='+', db_column='CompanyUUID', verbose_name='公司UUID')
UserUUID = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='+', db_column='UserUUID', verbose_name='用户UUID')
AccountID = models.CharField(max_length=64, default='', db_index=True, verbose_name='账号标识')
IPAddress = models.CharField(max_length=64, default='', db_index=True, verbose_name='客户端IP')
RealIP = models.CharField(max_length=64, default='', verbose_name='真实IP')
XForwardedFor = models.CharField(max_length=512, default='', verbose_name='XFF头')
UserAgent = models.CharField(max_length=512, default='', verbose_name='User-Agent')
RequestMethod = models.CharField(max_length=16, default='', verbose_name='请求方法')
RequestPath = models.CharField(max_length=512, default='', db_index=True, verbose_name='请求路径')
RequestParams = models.JSONField(null=True, blank=True, verbose_name='查询参数')
RequestBody = models.TextField(default='', verbose_name='请求体摘要')
RequestHeaders = models.JSONField(null=True, blank=True, verbose_name='请求头')
AttemptType = models.CharField(max_length=32, default='', db_index=True, verbose_name='攻击类型')
ResponseStatus = models.IntegerField(default=0, verbose_name='响应状态码')
Remark = models.CharField(max_length=512, default='', verbose_name='备注')
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, db_index=True, verbose_name='创建时间')
class Meta:
db_table = 'PositionTemplatePermission'
app_label = 'gerp'
verbose_name = '岗位模板权限关联'
verbose_name_plural = '岗位模板权限关联'
class Position(QModel):
"""人事职务标签表——仅用于人事管理标注
员工会标注对应的职务标签,该标签仅为人事标注信息,
不直接绑定权限,和角色、权限体系完全解耦。
人员调岗、升职只修改组织归属和人事职务标签,权限只需增删绑定角色即可,互不干扰。
"""
PositionUUID = UUIDField(primary_key=True, verbose_name='职务UUID')
TenantUUID = models.ForeignKey('Tenant', on_delete=models.CASCADE, related_name='+', db_column='TenantUUID', verbose_name='租户UUID')
PositionName = models.CharField(max_length=128, verbose_name='职务名称')
PositionCode = models.CharField(max_length=64, default='', verbose_name='职务编码')
PositionLevel = models.SmallIntegerField(default=0, verbose_name='职务级别')
PositionStatus = models.SmallIntegerField(default=1, verbose_name='状态')
PositionDesc = models.CharField(max_length=512, default='', verbose_name='描述')
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
UpdateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='更新时间')
class Meta:
db_table = 'Position'
db_table = 'AbnormalUserLog'
app_label = 'gerp'
ordering = ['-CreateTime']
verbose_name = '人事职务标签'
verbose_name_plural = '人事职务标签'
verbose_name = '异常用户攻击日志'
verbose_name_plural = '异常用户攻击日志'
def __str__(self):
return self.PositionName
return f'{self.AttemptType}:{self.IPAddress} @ {self.CreateTime}'
class UserDept(QModel):
"""员工部门关联表——员工与部门的多对多映射
支持一人多部门兼职,同时标注该员工在对应部门的职务标签。
该层级仅用于组织架构展示、审批流转、人员统计,
同时为数据权限判断提供人员所属组织属性依据,不参与操作权限校验。
"""
UserDeptUUID = UUIDField(primary_key=True, verbose_name='关联UUID')
UserUUID = models.ForeignKey(User, on_delete=models.CASCADE, related_name='userdept_set', db_index=True, db_column='UserUUID', verbose_name='用户UUID')
DeptUUID = models.ForeignKey('Department', on_delete=models.CASCADE, related_name='userdept_set', db_column='DeptUUID', verbose_name='部门UUID')
PositionUUID = models.ForeignKey('Position', on_delete=models.SET_NULL, null=True, blank=True, related_name='userdept_set', db_column='PositionUUID', verbose_name='职务标签UUID')
IsMainDept = models.SmallIntegerField(default=1, verbose_name='是否主部门')
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
class Meta:
db_table = 'UserDept'
app_label = 'gerp'
verbose_name = '员工部门关联'
verbose_name_plural = '员工部门关联'
# ==================== 从 gvsdsdk 导入重复模型 ====================
# 以下模型原在 gerp/models.py 定义,现统一依赖 gvsdsdk.models(映射 SAAS 主库)
# 注意:gvsdsdk 模型用 UUIDField(非 ForeignKey),不支持 select_related 和关联属性访问
from gvsdsdk.models import (
Role, Permission, UserRole, RolePermission,
UserABAC, Password, PositionTemplate, PositionTemplatePermission,
Position, UserDept,
)
+217
View File
@@ -0,0 +1,217 @@
"""模块:角色作用域服务
判断当前用户的角色作用域(管理员/部门负责人/组长/组员),用于数据权限过滤。
作用域类型:
admin —— 管理员(IsSuperuser 或 admin:super 权限),看全公司
dept_manager —— 部门负责人(UserRole.DeptUUID 非空 + RoleType=1),看本部门成员
workgroup_lead —— 工作组长(UserRole.WorkgroupUUID 非空 + RoleType=1),看本工作组成员
member —— 普通组员,只看自己
用法:
scope = GetUserScope(request)
if scope.Type == 'dept_manager':
qs = qs.filter(DeptUUID=scope.DeptUUID)
"""
import logging
import uuid as _uuid
from typing import Optional, NamedTuple
from gvsds.apps.gerp.models import RolePermission, UserRole
logger = logging.getLogger(__name__)
class RoleScope(NamedTuple):
Type: str # admin / dept_manager / workgroup_lead / member
DeptUUID: Optional[bytes] # 部门 UUID(dept_manager 时非空)
WorkgroupUUID: Optional[bytes] # 工作组 UUID(workgroup_lead 时非空)
UserUUID: Optional[bytes] # 当前用户 UUID(member 时用于过滤)
RoleType: int # 当前角色类型
def _to_bytes(value):
if value is None:
return None
if hasattr(value, 'bytes'):
return value.bytes
if isinstance(value, bytes):
return value
if isinstance(value, str):
try:
return _uuid.UUID(value).bytes
except ValueError:
return None
return None
def IsAdmin(request) -> bool:
"""判断用户是否为管理员(委托给 gvsdsdk.auth.admin_service.IsAdmin)
保留此函数以兼容现有调用点,新代码应直接使用 gvsdsdk.auth.IsAdmin。
"""
from gvsdsdk.auth.admin_service import IsAdmin as _IsAdminSvc
return _IsAdminSvc(request)
def GetUserScope(request) -> RoleScope:
"""获取当前用户的角色作用域
优先级:
1. 管理员(IsAdmin=true)→ admin
2. 当前 UserRole 的 RoleType=1 且 DeptUUID 非空 → dept_manager
3. 当前 UserRole 的 RoleType=1 且 WorkgroupUUID 非空 → workgroup_lead
4. 其他 → member
"""
user = getattr(request, 'user', None) if request else None
user_uuid = getattr(user, 'UserUUID', None) if user else None
user_uuid_bytes = _to_bytes(user_uuid)
# 1. 管理员
if IsAdmin(request):
return RoleScope(
Type='admin',
DeptUUID=None,
WorkgroupUUID=None,
UserUUID=user_uuid_bytes,
RoleType=1,
)
# 获取当前角色关联的 UserRole 记录(含 DeptUUID/WorkgroupUUID/RoleType)
current_role = getattr(request, 'current_role', None)
role_uuid = getattr(current_role, 'RoleUUID', None) if current_role else None
current_company = getattr(request, 'current_company_uuid', None)
company_uuid_bytes = None
if current_company is not None:
if hasattr(current_company, 'pk'):
company_uuid_bytes = current_company.pk
elif hasattr(current_company, 'bytes'):
company_uuid_bytes = current_company.bytes
else:
company_uuid_bytes = _to_bytes(current_company)
if not role_uuid or not user_uuid_bytes:
return RoleScope(
Type='member',
DeptUUID=None,
WorkgroupUUID=None,
UserUUID=user_uuid_bytes,
RoleType=0,
)
# 查询当前用户在当前角色+公司下的 UserRole 记录
# 注意:gvsdsdk 的 UserRole 使用 UUIDField(非 ForeignKey),查询时用字段名而非 _id 后缀
ur_qs = UserRole.objects.filter(
UserUUID=user_uuid_bytes,
RoleUUID=role_uuid,
)
if company_uuid_bytes:
ur_qs = ur_qs.filter(CompanyUUID=company_uuid_bytes)
ur = ur_qs.first()
if not ur:
return RoleScope(
Type='member',
DeptUUID=None,
WorkgroupUUID=None,
UserUUID=user_uuid_bytes,
RoleType=0,
)
role_type = ur.RoleType or 0
# gvsdsdk UUIDField 返回 UUIDObj,用 _to_bytes 统一转换为 bytes
dept_uuid = _to_bytes(ur.DeptUUID)
workgroup_uuid = _to_bytes(ur.WorkgroupUUID)
# 2. 部门负责人
if role_type == 1 and dept_uuid:
return RoleScope(
Type='dept_manager',
DeptUUID=dept_uuid,
WorkgroupUUID=None,
UserUUID=user_uuid_bytes,
RoleType=role_type,
)
# 3. 工作组长
if role_type == 1 and workgroup_uuid:
return RoleScope(
Type='workgroup_lead',
DeptUUID=None,
WorkgroupUUID=workgroup_uuid,
UserUUID=user_uuid_bytes,
RoleType=role_type,
)
# 4. 普通组员
return RoleScope(
Type='member',
DeptUUID=None,
WorkgroupUUID=None,
UserUUID=user_uuid_bytes,
RoleType=role_type,
)
def ApplyScopeToQueryset(qs, scope: RoleScope, table_name: str):
"""对查询集应用角色作用域过滤
针对 User/UserRole/Order 表做差异化过滤:
- User: dept_manager 看本部门成员,workgroup_lead 看本工作组,member 只看自己
- UserRole: 同上
- Order: dept_manager 看本部门成员订单,workgroup_lead 看本工作组,member 只看自己订单
"""
if scope.Type == 'admin':
return qs # 管理员看全部
if table_name == 'User':
if scope.Type == 'member':
# 只看自己
if hasattr(qs.model, 'UserUUID'):
return qs.filter(UserUUID=scope.UserUUID)
# dept_manager / workgroup_lead 通过 UserRole 关联查找成员
member_uuids = _get_scope_members(scope)
if member_uuids is not None and hasattr(qs.model, 'UserUUID'):
return qs.filter(UserUUID__in=member_uuids)
return qs
if table_name == 'UserRole':
if scope.Type == 'member':
return qs.filter(UserUUID=scope.UserUUID)
member_uuids = _get_scope_members(scope)
if member_uuids is not None:
return qs.filter(UserUUID__in=member_uuids)
return qs
if table_name == 'Order':
if scope.Type == 'member':
# 组员只看自己订单
if hasattr(qs.model, 'BuyerUserUUID'):
return qs.filter(BuyerUserUUID=scope.UserUUID)
return qs
# dept_manager / workgroup_lead 看本部门/工作组成员的订单
member_uuids = _get_scope_members(scope)
if member_uuids is not None and hasattr(qs.model, 'BuyerUserUUID'):
return qs.filter(BuyerUserUUID__in=member_uuids)
return qs
return qs
def _get_scope_members(scope: RoleScope):
"""获取当前作用域下的成员 UUID 列表
dept_manager: 查询 UserRole 中 DeptUUID = scope.DeptUUID 的所有用户
workgroup_lead: 查询 UserRole 中 WorkgroupUUID = scope.WorkgroupUUID 的所有用户
"""
try:
if scope.Type == 'dept_manager' and scope.DeptUUID:
qs = UserRole.objects.filter(DeptUUID=scope.DeptUUID)
elif scope.Type == 'workgroup_lead' and scope.WorkgroupUUID:
qs = UserRole.objects.filter(WorkgroupUUID=scope.WorkgroupUUID)
else:
return None
# 返回去重的 UserUUID 列表(UUIDField 直接用字段名,无需 _id 后缀)
return list(set(qs.values_list('UserUUID', flat=True)))
except Exception:
logger.warning('[RoleScope] 获取作用域成员失败: type=%s', scope.Type, exc_info=True)
return None
+1 -1
View File
@@ -280,7 +280,7 @@ class FinanceShowSerializer(PascalCaseSerializer):
class DocAddSerializer(PascalCaseSerializer):
DocTitle = serializers.CharField(max_length=256)
DocContent = serializers.CharField()
DocContent = serializers.CharField(required=False, allow_blank=True, default='')
SerieUUID = serializers.CharField(required=False)
DocTags = serializers.JSONField(default=list)
+21 -3
View File
@@ -1,6 +1,5 @@
from rest_framework import permissions
from rest_framework.views import APIView
from gvsdsdk.auth.elt_auth import ELTAuthentication
class IsAuthenticatedOrELT(permissions.BasePermission):
@@ -9,7 +8,7 @@ class IsAuthenticatedOrELT(permissions.BasePermission):
class ELTAuthenticatedAPIView(APIView):
authentication_classes = [ELTAuthentication]
pass
from .viewsets import (
@@ -72,6 +71,7 @@ from .site_user import (
SiteUserDisableView,
SiteUserEmailVerifyView,
SiteUserBackpackView,
SiteUser2FAInfoView,
)
from .asset import (
AssetUploadInitView,
@@ -81,7 +81,7 @@ from .asset import (
AssetRefLinkView,
)
from .message import MessageCompatView
from .menu import MenuListCompatView
from .menu import MenuListCompatView, MenuAdminListView, MenuSaveView, MenuDeleteView
from .form_api import FormAPICompatView, FormAPIView, CheckAdminView
from .doc import DocView, SerieView
from .position import (
@@ -105,3 +105,21 @@ from .msync_admin import (
)
from .i18n_api import I18nModelView
from .user_detail import UserDetailView, UserListAPIView
from .tenant_review import (
TenantApplyView,
TenantPendingListView,
TenantApproveView,
TenantRejectView,
)
from .role_permission import (
RolePermissionsTreeView,
RoleSavePermissionsView,
)
from .order_flow import OrderFlowView
from .payment_config import (
PaymentConfigListView,
PaymentConfigDetailView,
PaymentConfigSaveView,
PaymentConfigDeleteView,
)
from .dashboard import DashboardStatsView
Loaded 100 of 252 files, more files were not shown because too many files have changed in this diff. Show more