From ffaadc26aea4ae9b6c88fd581fba1029cfe4033c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=AB=E5=9C=B0=E5=83=A7?= <357099073@qq.com> Date: Wed, 7 Oct 2026 13:46:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=81=9A=E5=8F=8C=E7=AB=AF=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/components.d.ts | 1 + backend/package-lock.json | 39 + backend/package.json | 3 + backend/src/api/approval.js | 54 + backend/src/api/domain.js | 9 - backend/src/api/onepage.js | 4 +- backend/src/api/platformRole.js | 39 - backend/src/api/tenantUser.js | 13 +- .../src/components/approval/FlowCanvas.vue | 562 ++++++++ backend/src/router/index.js | 7 + backend/src/views/apps/cms/domain/audit.vue | 296 ----- backend/src/views/system/approval/index.vue | 357 ++++++ go/controllers/app_employee_file.go | 264 ++++ go/controllers/backend_approval.go | 466 +++++++ go/controllers/backend_domain.go | 54 +- go/controllers/backend_frontend_content.go | 38 + go/controllers/index_upgrade_notice.go | 65 + go/controllers/platform_approval.go | 660 ++++++++++ go/controllers/platform_domain.go | 55 +- go/controllers/platform_domain_approval.go | 171 +++ ..._upgrade.go => platform_upgrade_notice.go} | 17 +- go/controllers/platform_website_site.go | 27 + go/docs/sql/create_approval_flow.sql | 198 +++ go/docs/sql/platform_approval_menu.sql | 38 + go/middleware/jwt.go | 37 +- go/middleware/operationLog.go | 64 +- go/middleware/permission.go | 92 +- go/models/approval.go | 246 ++++ go/models/init.go | 7 + go/routers/api/api.go | 102 +- go/routers/app/app.go | 78 -- go/routers/backend/backend.go | 14 +- go/routers/index/index.go | 3 + go/routers/platform/platform.go | 19 + go/routers/router.go | 12 +- go/services/approval/engine.go | 1133 +++++++++++++++++ platform/components.d.ts | 3 + platform/package-lock.json | 39 + platform/package.json | 3 + platform/src/api/approval.js | 62 + platform/src/api/domain.js | 8 + platform/src/api/login.js | 6 +- platform/src/api/onepage.js | 4 +- platform/src/api/role.js | 7 - .../src/components/approval/FlowCanvas.vue | 562 ++++++++ platform/src/router/index.js | 7 + platform/src/views/apps/cms/domain/audit.vue | 98 +- .../views/platform/platformupgrade/index.vue | 2 +- platform/src/views/system/approval/index.vue | 350 +++++ .../src/views/system/permissions/index.vue | 2 +- uniapp/App.vue | 34 +- uniapp/api/auth.js | 28 +- uniapp/api/contact.js | 8 +- uniapp/api/note.js | 12 +- uniapp/api/oa.js | 51 +- uniapp/api/schedule.js | 12 +- uniapp/api/workspace.js | 4 +- uniapp/components/sub/SubHome.vue | 15 +- uniapp/components/sub/SubMine.vue | 4 +- uniapp/components/sub/SubTodo.vue | 4 +- uniapp/pages.json | 14 +- uniapp/pages/dashboard/dashboard.vue | 4 +- uniapp/pages/oa/employeefile/detail.vue | 496 ++++++++ uniapp/pages/oa/employeefile/index.vue | 618 ++++----- uniapp/src/styles/page-common.scss | 24 + uniapp/utils/nav.js | 2 +- uniapp/utils/subsystem.js | 2 +- website/src/components/SiteFooter.vue | 2 +- .../views/home/components/ScheduleSection.vue | 2 +- 69 files changed, 6662 insertions(+), 1071 deletions(-) create mode 100644 backend/src/api/approval.js delete mode 100644 backend/src/api/platformRole.js create mode 100644 backend/src/components/approval/FlowCanvas.vue delete mode 100644 backend/src/views/apps/cms/domain/audit.vue create mode 100644 backend/src/views/system/approval/index.vue create mode 100644 go/controllers/app_employee_file.go create mode 100644 go/controllers/backend_approval.go create mode 100644 go/controllers/index_upgrade_notice.go create mode 100644 go/controllers/platform_approval.go create mode 100644 go/controllers/platform_domain_approval.go rename go/controllers/{api_platform_upgrade.go => platform_upgrade_notice.go} (81%) create mode 100644 go/docs/sql/create_approval_flow.sql create mode 100644 go/docs/sql/platform_approval_menu.sql create mode 100644 go/models/approval.go delete mode 100644 go/routers/app/app.go create mode 100644 go/services/approval/engine.go create mode 100644 platform/src/api/approval.js create mode 100644 platform/src/components/approval/FlowCanvas.vue create mode 100644 platform/src/views/system/approval/index.vue create mode 100644 uniapp/pages/oa/employeefile/detail.vue diff --git a/backend/components.d.ts b/backend/components.d.ts index 1765f3d..75eea65 100644 --- a/backend/components.d.ts +++ b/backend/components.d.ts @@ -83,6 +83,7 @@ declare module 'vue' { ElTree: typeof import('element-plus/es')['ElTree'] ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect'] ElUpload: typeof import('element-plus/es')['ElUpload'] + FlowCanvas: typeof import('./src/components/approval/FlowCanvas.vue')['default'] MessageDetailDialog: typeof import('./src/components/MessageDetailDialog.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] diff --git a/backend/package-lock.json b/backend/package-lock.json index ca09586..5d55d76 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,9 @@ "@turbodocx/html-to-docx": "^1.22.2", "@types/qrcode": "^1.5.6", "@umoteam/editor": "^10.2.1", + "@vue-flow/background": "^1.3.2", + "@vue-flow/controls": "^1.1.3", + "@vue-flow/core": "^1.48.2", "axios": "^1.13.1", "chart": "^0.1.2", "chart.js": "^4.5.1", @@ -3062,6 +3065,42 @@ "vue": "^3.2.25" } }, + "node_modules/@vue-flow/background": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/@vue-flow/background/-/background-1.3.2.tgz", + "integrity": "sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==", + "license": "MIT", + "peerDependencies": { + "@vue-flow/core": "^1.23.0", + "vue": "^3.3.0" + } + }, + "node_modules/@vue-flow/controls": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@vue-flow/controls/-/controls-1.1.3.tgz", + "integrity": "sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==", + "license": "MIT", + "peerDependencies": { + "@vue-flow/core": "^1.23.0", + "vue": "^3.3.0" + } + }, + "node_modules/@vue-flow/core": { + "version": "1.48.2", + "resolved": "https://registry.npmmirror.com/@vue-flow/core/-/core-1.48.2.tgz", + "integrity": "sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==", + "license": "MIT", + "dependencies": { + "@vueuse/core": "^10.5.0", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.29", "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.29.tgz", diff --git a/backend/package.json b/backend/package.json index d4c6ab9..0d9ea3c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,9 @@ "@turbodocx/html-to-docx": "^1.22.2", "@types/qrcode": "^1.5.6", "@umoteam/editor": "^10.2.1", + "@vue-flow/background": "^1.3.2", + "@vue-flow/controls": "^1.1.3", + "@vue-flow/core": "^1.48.2", "axios": "^1.13.1", "chart": "^0.1.2", "chart.js": "^4.5.1", diff --git a/backend/src/api/approval.js b/backend/src/api/approval.js new file mode 100644 index 0000000..872dd89 --- /dev/null +++ b/backend/src/api/approval.js @@ -0,0 +1,54 @@ +import request from '@/utils/request'; + +// ==================== 租户端审批流配置 ==================== + +// 编排数据源:当前租户的功能模块 + 审批人候选(用户/角色/部门/职位) +export function getApprovalOptions() { + return request({ + url: '/backend/approval/options', + method: 'get', + }); +} + +// 流程列表(按功能模块过滤,module 即 biz_type) +export function getApprovalFlowList(params) { + return request({ + url: '/backend/approval/flow/list', + method: 'get', + params, + }); +} + +// 流程详情(含全部节点) +export function getApprovalFlowDetail(id) { + return request({ + url: `/backend/approval/flow/detail/${id}`, + method: 'get', + }); +} + +// 保存流程(新建或更新,节点全量覆盖) +export function saveApprovalFlow(data) { + return request({ + url: '/backend/approval/flow/save', + method: 'post', + data, + }); +} + +// 删除流程 +export function deleteApprovalFlow(id) { + return request({ + url: `/backend/approval/flow/delete/${id}`, + method: 'delete', + }); +} + +// 审批流编号轻量清单(按分类返回启用流程的 flow_code/flow_name,供其它功能按编号调用) +export function getApprovalFlowPick(params) { + return request({ + url: '/backend/approval/flow/pick', + method: 'get', + params, + }); +} diff --git a/backend/src/api/domain.js b/backend/src/api/domain.js index 87d5444..ebae2f3 100644 --- a/backend/src/api/domain.js +++ b/backend/src/api/domain.js @@ -109,15 +109,6 @@ export function getCustomDomainGuide() { }) } -// 审核租户域名 -export function auditTenantDomain(data) { - return request({ - url: '/backend/domain/tenant/audit', - method: 'post', - data - }) -} - // 禁用/启用租户域名 export function toggleTenantDomainStatus(id) { return request({ diff --git a/backend/src/api/onepage.js b/backend/src/api/onepage.js index 0ac6826..c6b1a4b 100644 --- a/backend/src/api/onepage.js +++ b/backend/src/api/onepage.js @@ -51,13 +51,13 @@ export function deleteOnePage(id) { } /** - * 根据路径获取单页(前端使用) + * 根据路径获取单页(后端租户端独立接口) * @param {string} path 路由路径 * @returns {Promise} */ export function getOnePageByPath(path) { return request({ - url: `/index/onepage/${encodeURIComponent(path)}`, + url: `/backend/onepage/path/${encodeURIComponent(path)}`, method: "get", }); } diff --git a/backend/src/api/platformRole.js b/backend/src/api/platformRole.js deleted file mode 100644 index f0dffea..0000000 --- a/backend/src/api/platformRole.js +++ /dev/null @@ -1,39 +0,0 @@ -import request from '@/utils/request' - -// 平台端角色管理(yz_system_admin_role,可同时管理平台角色 cid=1 与租户角色 cid=2) -export function getAllPlatformRoles() { - return request({ - url: '/platform/allRoles', - method: 'get' - }) -} - -export function getPlatformRoleById(id) { - return request({ - url: `/platform/roles/${id}`, - method: 'get' - }) -} - -export function createPlatformRole(data) { - return request({ - url: '/platform/roles', - method: 'post', - data - }) -} - -export function updatePlatformRole(id, data) { - return request({ - url: `/platform/roles/${id}`, - method: 'put', - data - }) -} - -export function deletePlatformRole(id) { - return request({ - url: `/platform/roles/${id}`, - method: 'delete' - }) -} diff --git a/backend/src/api/tenantUser.js b/backend/src/api/tenantUser.js index 3d1e7b1..35f5469 100644 --- a/backend/src/api/tenantUser.js +++ b/backend/src/api/tenantUser.js @@ -1,9 +1,10 @@ import request from '@/utils/request' -// 平台端租户用户(yz_system_tenant_user)相关接口 +// 租户后台用户管理(yz_system_tenant_user),走后端租户端 /backend/* 独立接口 +// (BackendAdminUserController 直接操作 SystemTenantUser,租户隔离由 JWT 完成) export function getTenantUserList(params) { return request({ - url: '/platform/tenantUser/list', + url: '/backend/getAllUsers', method: 'get', params, }) @@ -11,14 +12,14 @@ export function getTenantUserList(params) { export function getTenantUsersByTid(tid) { return request({ - url: `/platform/getTenantUsers/${tid}`, + url: `/backend/getTenantUsers/${tid}`, method: 'get', }) } export function createTenantUser(data) { return request({ - url: '/platform/tenantUser/create', + url: '/backend/addUser', method: 'post', data, }) @@ -26,7 +27,7 @@ export function createTenantUser(data) { export function editTenantUser(id, data) { return request({ - url: `/platform/tenantUser/edit/${id}`, + url: `/backend/editUser/${id}`, method: 'post', data, }) @@ -34,7 +35,7 @@ export function editTenantUser(id, data) { export function deleteTenantUser(id) { return request({ - url: `/platform/tenantUser/delete/${id}`, + url: `/backend/deleteUser/${id}`, method: 'delete', }) } diff --git a/backend/src/components/approval/FlowCanvas.vue b/backend/src/components/approval/FlowCanvas.vue new file mode 100644 index 0000000..cec2b43 --- /dev/null +++ b/backend/src/components/approval/FlowCanvas.vue @@ -0,0 +1,562 @@ + + + + + 节点面板 + + + {{ p.label }} + + + 拖入画布添加节点;从节点右侧圆点拖到目标节点连线;双击节点配置;点击连线设置分支条件。 + + + + + + + + + + {{ typeShort(nodeProps.data.nodeType) }} + {{ nodeProps.data.name || '未命名' }} + {{ nodeProps.data.summary }} + + + + + + + + + + + + + + + + + + {{ t.label }} + + + + + + + + + 该类型运行期自动定位审批人,无需指定对象 + + + + + 或签 + + + + 或签 (OR) + 所选审批人任一人通过/驳回,即视为本节点完成; + 其余待办任务将自动作废并推进至下一节点。 + + + + + + + 会签 + + + + 会签 (AND) + 所选审批人须全部通过,本节点才算完成; + 任一人驳回则本节点立即驳回,流程回退。 + + + + + + + + + + + + + + + + 条件网关:拖出多条连线,点击连线分别填写分支条件(留空为默认分支)。 + + + + 删除节点 + + 取消 + 确定 + + + + + + + + + + + 支持 > >= < <= == != 与 && / ||。条件仅在“条件网关”的出边上生效。 + + + + 删除连线 + + 取消 + 确定 + + + + + + + + + + diff --git a/backend/src/router/index.js b/backend/src/router/index.js index f2b8ec4..2ebcd67 100644 --- a/backend/src/router/index.js +++ b/backend/src/router/index.js @@ -15,6 +15,13 @@ const staticMainChildren = [ component: () => import("@/views/dashboard/index.vue"), meta: { requiresAuth: true, title: "工作台" } }, + // 审批流配置(租户侧):静态注册保证 URL 直达,菜单入口由数据库驱动 + { + path: "/system/approval", + name: "BackendApprovalFlow", + component: () => import("@/views/system/approval/index.vue"), + meta: { requiresAuth: true, title: "审批流", modulePath: "/systemManager" } + }, // CMS 文章/产品/解决方案是系统内置功能,不依赖数据库菜单配置。 // 统一结构:index 为列表页,type 为分类页。 { diff --git a/backend/src/views/apps/cms/domain/audit.vue b/backend/src/views/apps/cms/domain/audit.vue deleted file mode 100644 index a9fc335..0000000 --- a/backend/src/views/apps/cms/domain/audit.vue +++ /dev/null @@ -1,296 +0,0 @@ - - - - 租户域名审核 - - - 刷新 - - - - - - - - - - - - - - - - - - 搜索 - - - - - - - - - 自有域名 - 平台二级域名 - - - - - - 已生效 - 已禁用 - {{ isCustom(scope.row) ? '待生效' : '审核中' }} - - - - - — - - {{ verifyText(scope.row.verify_status) }} - - {{ verifyText(scope.row.verify_status) }} - - - - - 平台证书 - - 签发失败 - - - 已签发 - - {{ sslText(scope.row.ssl_status) }} - - - - {{ formatDateTime(row.create_time) }} - - - - - - - 检测解析 - - - 禁用 - - - 启用 - - - - - 通过 - - - 拒绝 - - - - - 禁用 - - - - - - - - - - - - - - - - - - diff --git a/backend/src/views/system/approval/index.vue b/backend/src/views/system/approval/index.vue new file mode 100644 index 0000000..46f0a3b --- /dev/null +++ b/backend/src/views/system/approval/index.vue @@ -0,0 +1,357 @@ + + + + 审批流配置 + + + + 新建流程 + + + + + + + + + + + 选择分类 + + + + {{ m.name }} + 未开通 + + + + + + {{ f.flow_name }} + 默认 + + {{ f.flow_code || '(保存后生成编号)' }} + + {{ f.node_count }} 个节点 + + {{ f.status === 1 ? '启用' : '停用' }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 同分类仅一条默认,新申请走默认流程 + + + + + + + + + + + 流程编排(拖拽节点 · 手动连线 · 条件分支) + + + + + 删除流程 + 保存流程 + + + + + + + + + + + + + + diff --git a/go/controllers/app_employee_file.go b/go/controllers/app_employee_file.go new file mode 100644 index 0000000..ce3b996 --- /dev/null +++ b/go/controllers/app_employee_file.go @@ -0,0 +1,264 @@ +package controllers + +import ( + "strconv" + "strings" + + "server/models" + "server/pkg/jwtutil" + "server/services" + + "github.com/beego/beego/v2/client/orm" +) + +// 移动端员工档案「全公司视角」接口(HR 使用)。 +// +// 与 Web 端(/backend/oa/employeefile/*)的关系: +// - 数据口径完全一致:一人一档(yz_backend_employee_file)+ 教育/工作/合同/证照子记录, +// DTO 组装直接复用 Web 端 efAssembleDTOList,保证两端展示字段完全一致; +// - 只读:建档、改档、离职等写操作仍只在 Web 端提供; +// - 数据一律按 JWT 中的 TenantId 隔离,前端无法指定租户。 +// +// 权限口径:租户角色的 rights 里勾选了「员工档案」菜单(或其祖先节点)即视为 HR,可见全公司档案; +// 租户管理员 / 未分配角色 / 角色未配置权限等既有「全权限」情形同样放行。 +// 未放行的用户仍可查看本人档案(/api/oa/employee-file),前端据此展示不同视图。 + +// employeeFileMenuIDs 命中「员工档案」菜单的菜单ID集合(含其祖先节点)。 +// 菜单表由租户自行维护,标题/路径可能被改写,因此按 路径关键字 + 标题关键字 双重匹配, +// 与移动端菜单跳转规则(uniapp/utils/nav.js 中的 employeefile 规则)保持同一识别口径。 +func employeeFileMenuIDs() map[uint64]bool { + ids := map[uint64]bool{} + if models.Orm == nil { + return ids + } + var rows []models.SystemMenu + if _, err := models.Orm.QueryTable(new(models.SystemMenu)). + Filter("status", 1). + Filter("delete_time__isnull", true). + All(&rows); err != nil { + return ids + } + + pidOf := make(map[uint64]int64, len(rows)) + hit := map[uint64]bool{} + for _, m := range rows { + pidOf[m.ID] = m.Pid + hay := strings.ToLower(m.Title + " " + derefString(m.Path) + " " + derefString(m.ComponentPath)) + if strings.Contains(hay, "employeefile") || strings.Contains(hay, "employee-file") || + strings.Contains(m.Title, "员工档案") || strings.Contains(m.Title, "人事档案") { + hit[m.ID] = true + } + } + if len(hit) == 0 { + return ids + } + + // 角色权限勾到父目录即代表含全部下级,因此把命中菜单的祖先一并纳入判定。 + for id := range hit { + cur := id + for i := 0; i < 32; i++ { // 限制深度,防御脏数据造成的环 + if _, ok := pidOf[cur]; !ok { + break + } + ids[cur] = true + pid := pidOf[cur] + if pid <= 0 { + break + } + cur = uint64(pid) + } + } + return ids +} + +// canViewAllEmployeeFiles 判断当前用户是否有权查看全公司员工档案(HR 视角)。 +func canViewAllEmployeeFiles(claims *jwtutil.Claims) bool { + if claims == nil || claims.TenantId <= 0 { + return false + } + // 平台管理员 + if claims.UserType == "platform" { + return true + } + // 租户管理员 / 未分配角色 / 角色未配置权限 —— 与 Web 端 isTenantAdmin 同一口径 + if isTenantAdmin(claims.UserID, claims.TenantId) { + return true + } + + var tu models.SystemTenantUser + if models.Orm.QueryTable(new(models.SystemTenantUser)). + Filter("uid", tenantUIDOf(claims)). + Filter("tid", claims.TenantId). + Filter("delete_time__isnull", true).One(&tu) != nil { + return false + } + if tu.GroupID == 0 { + return true + } + var role models.AdminRole + if models.Orm.QueryTable(new(models.AdminRole)). + Filter("id", tu.GroupID). + Filter("cid", 2).One(&role) != nil { + return false + } + if strings.TrimSpace(role.Name) == "租户管理员" { + return true + } + rights := "" + if role.Rights != nil { + rights = strings.TrimSpace(*role.Rights) + } + if rights == "" || rights == "[]" || rights == "null" { + return true + } + + allowed := parseRightsToSet(rights) + if len(allowed) == 0 { + return true + } + for id := range employeeFileMenuIDs() { + if allowed[id] { + return true + } + } + return false +} + +// tenantUIDOf 令牌 user_id 是认证中心 identity_id,换算成本租户的老 uid。 +func tenantUIDOf(claims *jwtutil.Claims) uint64 { + return services.ResolveTenantUID(uint64(claims.TenantId), uint64(claims.UserID)) +} + +// EmployeeFileAccess GET /api/oa/employee-file/access +// 返回当前用户是否具备 HR 视角(可见全公司档案),前端据此决定展示列表还是本人档案。 +func (c *AppOaController) EmployeeFileAccess() { + claims, err := c.oaClaims() + if err != nil { + c.oaErr(401, 401, "未登录或无权限") + return + } + c.oaOk(map[string]interface{}{"can_view_all": canViewAllEmployeeFiles(claims)}) +} + +// EmployeeFileList GET /api/oa/employee-file/list 全公司档案分页列表(需 HR 权限)。 +// 支持 keyword(档案编号/身份证/员工姓名/账号/手机)、status(在职状态)、org_id(部门,含下级)。 +func (c *AppOaController) EmployeeFileList() { + claims, err := c.oaClaims() + if err != nil { + c.oaErr(401, 401, "未登录或无权限") + return + } + if !canViewAllEmployeeFiles(claims) { + c.oaErr(403, 403, "您没有查看全部员工档案的权限") + return + } + tid := claims.TenantId + + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 20) + keyword := strings.TrimSpace(c.GetString("keyword")) + statusRaw := strings.TrimSpace(c.GetString("status")) + orgID, _ := c.GetUint64("org_id") + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + web := &BackendEmployeeFileController{} + qs := web.efQuery(tid) + + if orgID > 0 { + empIDs := web.efEmployeeIDsInOrgs(tid, web.efSubtreeOrgIDs(tid, orgID)) + if len(empIDs) == 0 { + c.oaOk(map[string]interface{}{"list": []employeeFileDTO{}, "total": 0, "page": page, "pageSize": pageSize}) + return + } + qs = qs.Filter("employee_id__in", empIDs) + } + if statusRaw != "" { + status, convErr := strconv.Atoi(statusRaw) + if convErr != nil || status < 1 || status > 3 { + c.oaErr(400, 400, "在职状态参数无效") + return + } + qs = qs.Filter("employment_status", int8(status)) + } + if keyword != "" { + // 关键词同时命中档案自身字段(编号/身份证)与员工字段(姓名/账号/手机) + empIDs := web.efEmployeeIDsByKeyword(tid, keyword) + cond := orm.NewCondition(). + Or("file_no__icontains", keyword). + Or("id_card__icontains", keyword) + if len(empIDs) > 0 { + cond = cond.Or("employee_id__in", empIDs) + } + qs = qs.SetCond(orm.NewCondition().AndCond(cond)) + } + + total, _ := qs.Count() + var files []models.BackendEmployeeFile + if _, err := qs.OrderBy("-id").Limit(pageSize).Offset((page - 1) * pageSize).All(&files); err != nil && err != orm.ErrNoRows { + c.oaErr(500, 500, "查询失败") + return + } + + c.oaOk(map[string]interface{}{ + "list": web.efAssembleDTOList(tid, files), + "total": total, + "page": page, + "pageSize": pageSize, + }) +} + +// EmployeeFileDetail GET /api/oa/employee-file/detail/:id 员工档案详情(含子记录)。 +// +// 两种访问路径: +// - HR 视角:可查看任意员工的档案(租户内隔离); +// - 普通员工:仅可查看本人档案,看他人档案返回 403。 +func (c *AppOaController) EmployeeFileDetail() { + claims, err := c.oaClaims() + if err != nil { + c.oaErr(401, 401, "未登录或无权限") + return + } + tid := claims.TenantId + + id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if id == 0 { + c.oaErr(400, 400, "无效的档案ID") + return + } + + web := &BackendEmployeeFileController{} + var file models.BackendEmployeeFile + if web.efQuery(tid).Filter("id", id).One(&file) != nil { + c.oaErr(404, 404, "档案不存在") + return + } + + if !canViewAllEmployeeFiles(claims) { + emp, ok := c.oaMyEmployee(claims) + if !ok || uint64(emp.ID) != file.EmployeeID { + c.oaErr(403, 403, "您没有查看他人档案的权限") + return + } + } + + var records []models.BackendEmployeeFileRecord + if _, err := models.Orm.QueryTable(new(models.BackendEmployeeFileRecord)). + Filter("tid", tid). + Filter("is_deleted", 0). + Filter("employee_id", file.EmployeeID). + OrderBy("sort", "id"). + All(&records); err != nil && err != orm.ErrNoRows { + c.oaErr(500, 500, "查询档案记录失败") + return + } + if records == nil { + records = []models.BackendEmployeeFileRecord{} + } + dtos := web.efAssembleDTOList(tid, []models.BackendEmployeeFile{file}) + c.oaOk(map[string]interface{}{"file": dtos[0], "records": records}) +} \ No newline at end of file diff --git a/go/controllers/backend_approval.go b/go/controllers/backend_approval.go new file mode 100644 index 0000000..bdfa8fd --- /dev/null +++ b/go/controllers/backend_approval.go @@ -0,0 +1,466 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "server/models" + "server/services" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +// BackendApprovalController 租户端审批流配置(流程头 + 节点编排)。 +// +// 与平台端差异: +// - 数据按登录租户隔离(tid 取自 JWT,写入/查询均带 tid); +// - 「功能」下拉取当前租户已开通的模块(SystemModules),流程 module_code / biz_type 均绑定模块 code, +// 实现「选功能 → 命名 → 把审批流引用到该功能」; +// - 审批人取本租户主体:租户用户 / 租户角色 / 部门主管 / 职位 / 发起人 / 发起人主管(引擎支持 6 种)。 +type BackendApprovalController struct { + beego.Controller +} + +// readTenantBody 读取并解析 JSON 请求体 +func (c *BackendApprovalController) readTenantBody(dst interface{}) error { + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + return fmt.Errorf("参数错误") + } + if err := json.Unmarshal(raw, dst); err != nil { + return fmt.Errorf("参数错误: %s", err.Error()) + } + return nil +} + +// tenantFlowList GET /backend/approval/flow/list?module=oa +// 当前租户的流程列表;按分类(module_code)过滤,附带节点数。 +func (c *BackendApprovalController) FlowList() { + claims, err := requireBackend(&c.Controller) + if err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + tid := uint64(claims.TenantId) + module := strings.TrimSpace(c.GetString("module")) + + qs := models.Orm.QueryTable(new(models.TenantApprovalFlow)). + Filter("tid", tid).Filter("is_deleted", 0) + if module != "" { + qs = qs.Filter("module_code", module) + } + var rows []models.TenantApprovalFlow + _, _ = qs.OrderBy("-is_default", "id").All(&rows) + + list := make([]approvalFlowView, 0, len(rows)) + for _, r := range rows { + cnt, _ := models.Orm.QueryTable(new(models.TenantApprovalNode)). + Filter("flow_id", r.ID).Filter("is_deleted", 0).Count() + list = append(list, approvalFlowView{ + ID: r.ID, + ModuleCode: r.ModuleCode, + BizType: r.BizType, + FlowCode: r.FlowCode, + FlowName: r.FlowName, + Description: r.Description, + IsDefault: r.IsDefault, + Status: r.Status, + NodeCount: int(cnt), + CreateTime: r.CreateTime.Format("2006-01-02 15:04:05"), + }) + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": list} + _ = c.ServeJSON() +} + +// FlowDetail GET /backend/approval/flow/detail/:id +// 返回本租户某流程及其全部节点(按 sort 升序),供编排界面回显。 +func (c *BackendApprovalController) FlowDetail() { + claims, err := requireBackend(&c.Controller) + if err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + tid := uint64(claims.TenantId) + id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + var f models.TenantApprovalFlow + if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)). + Filter("id", id).Filter("tid", tid).Filter("is_deleted", 0).One(&f); err != nil { + jsonErr(&c.Controller, 404, 404, "流程不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": tenantFlowToView(f)} + _ = c.ServeJSON() +} + +// tenantFlowToView 组装租户流程视图(含节点与连线) +func tenantFlowToView(f models.TenantApprovalFlow) approvalFlowView { + var nodes []models.TenantApprovalNode + _, _ = models.Orm.QueryTable(new(models.TenantApprovalNode)). + Filter("flow_id", f.ID).Filter("is_deleted", 0). + OrderBy("sort", "id").All(&nodes) + view := approvalFlowView{ + ID: f.ID, + ModuleCode: f.ModuleCode, + BizType: f.BizType, + FlowCode: f.FlowCode, + FlowName: f.FlowName, + Description: f.Description, + IsDefault: f.IsDefault, + Status: f.Status, + NodeCount: len(nodes), + CreateTime: f.CreateTime.Format("2006-01-02 15:04:05"), + Nodes: make([]approvalNodePayload, 0, len(nodes)), + } + edgeNodes := make([]flowEdgeNode, 0, len(nodes)) + for _, n := range nodes { + view.Nodes = append(view.Nodes, approvalNodePayload{ + NodeKey: n.NodeKey, + NodeName: n.NodeName, + NodeType: n.NodeType, + ApproverType: n.ApproverType, + ApproverIDs: parseIDSlice(n.ApproverIDs), + MultiMode: n.MultiMode, + AllowReturn: n.AllowReturn, + TimeoutHours: n.TimeoutHours, + Sort: n.Sort, + PosX: n.PosX, + PosY: n.PosY, + ConditionExpr: n.ConditionExpr, + }) + edgeNodes = append(edgeNodes, flowEdgeNode{NodeKey: n.NodeKey, NextKeys: n.NextKeys, Condition: n.ConditionExpr}) + } + view.Edges = rebuildEdges(edgeNodes) + return view +} + +// FlowSave POST /backend/approval/flow/save +// 新建或更新本租户流程;节点采用「全量覆盖」语义(先软删旧节点,再按传入顺序重建)。 +func (c *BackendApprovalController) FlowSave() { + claims, err := requireBackend(&c.Controller) + if err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + tid := uint64(claims.TenantId) + var p approvalFlowSave + if err := c.readTenantBody(&p); err != nil { + jsonErr(&c.Controller, 400, 400, err.Error()) + return + } + if strings.TrimSpace(p.FlowName) == "" { + jsonErr(&c.Controller, 400, 400, "流程名称不能为空") + return + } + if strings.TrimSpace(p.ModuleCode) == "" { + jsonErr(&c.Controller, 400, 400, "请选择要配置的分类") + return + } + // 分类与单据类型对齐:运行期按 biz_type=分类 code 命中默认流 + if strings.TrimSpace(p.BizType) == "" { + p.BizType = p.ModuleCode + } + if err := validateTenantApprovalNodes(p.Nodes); err != nil { + jsonErr(&c.Controller, 400, 400, err.Error()) + return + } + if err := validateApprovalGraph(p.Nodes, p.Edges); err != nil { + jsonErr(&c.Controller, 400, 400, err.Error()) + return + } + g := buildGraph(p.Nodes, p.Edges) + now := time.Now() + + o := models.Orm + var flowID uint64 + var flowCode string + if p.ID > 0 { + var exist models.TenantApprovalFlow + if err := o.QueryTable(new(models.TenantApprovalFlow)). + Filter("id", p.ID).Filter("tid", tid).Filter("is_deleted", 0).One(&exist); err != nil { + jsonErr(&c.Controller, 404, 404, "流程不存在") + return + } + flowID = p.ID + flowCode = exist.FlowCode + _, err := o.QueryTable(new(models.TenantApprovalFlow)). + Filter("id", flowID).Filter("tid", tid).Update(orm.Params{ + "module_code": p.ModuleCode, + "biz_type": p.BizType, + "flow_name": p.FlowName, + "description": p.Description, + "is_default": p.IsDefault, + "status": p.Status, + "update_time": now, + }) + if err != nil { + jsonErr(&c.Controller, 500, 500, "保存失败: "+err.Error()) + return + } + } else { + row := models.TenantApprovalFlow{ + Tid: tid, + ModuleCode: p.ModuleCode, + BizType: p.BizType, + FlowName: p.FlowName, + Description: p.Description, + IsDefault: p.IsDefault, + Status: p.Status, + CreateTime: now, + } + id, err := o.Insert(&row) + if err != nil { + jsonErr(&c.Controller, 500, 500, "保存失败: "+err.Error()) + return + } + flowID = uint64(id) + flowCode = genApprovalFlowCode(flowID) + _, _ = o.QueryTable(new(models.TenantApprovalFlow)).Filter("id", flowID).Filter("tid", tid). + Update(orm.Params{"flow_code": flowCode}) + } + + // 默认流程唯一:同租户同单据类型下只允许一条 is_default=1 + if p.IsDefault == 1 { + _, _ = o.QueryTable(new(models.TenantApprovalFlow)). + Filter("tid", tid).Filter("biz_type", p.BizType).Filter("is_deleted", 0). + Exclude("id", flowID).Update(orm.Params{"is_default": 0, "update_time": now}) + } + + // 覆盖节点:软删旧节点后按 sort 重建 + _, _ = o.QueryTable(new(models.TenantApprovalNode)). + Filter("flow_id", flowID).Filter("is_deleted", 0). + Update(orm.Params{"is_deleted": 1, "delete_time": now}) + for i, nd := range p.Nodes { + sort := nd.Sort + if sort == 0 { + sort = i + 1 + } + nodeKey := strings.TrimSpace(nd.NodeKey) + if nodeKey == "" { + nodeKey = fmt.Sprintf("n%d", sort) + } + nodeType := nd.NodeType + if nodeType == 0 { + nodeType = models.ApprovalNodeApproval + } + multi := nd.MultiMode + if multi != models.ApprovalMultiAll { + multi = models.ApprovalMultiAny + } + timeout := nd.TimeoutHours + if timeout <= 0 { + timeout = 48 + } + node := models.TenantApprovalNode{ + FlowID: flowID, + Tid: tid, + NodeKey: nodeKey, + NodeName: strings.TrimSpace(nd.NodeName), + NodeType: nodeType, + ApproverType: nd.ApproverType, + ApproverIDs: joinIDs(nd.ApproverIDs), + MultiMode: multi, + AllowReturn: nd.AllowReturn, + TimeoutHours: timeout, + Sort: sort, + PosX: nd.PosX, + PosY: nd.PosY, + PrevKeys: strings.Join(g.prev[nodeKey], ","), + NextKeys: strings.Join(g.next[nodeKey], ","), + ConditionExpr: g.cond[nodeKey], + CreateTime: now, + } + if _, err := o.Insert(&node); err != nil { + jsonErr(&c.Controller, 500, 500, "保存节点失败: "+err.Error()) + return + } + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功", "data": map[string]interface{}{"id": flowID, "flow_code": flowCode}} + _ = c.ServeJSON() +} + +// Pick GET /backend/approval/flow/pick +// 轻量清单:按分类返回本租户启用流程的 {flow_code, flow_name},供其它功能“选/填编号调用”。 +func (c *BackendApprovalController) Pick() { + claims, err := requireBackend(&c.Controller) + if err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + tid := uint64(claims.TenantId) + module := strings.TrimSpace(c.GetString("module")) + qs := models.Orm.QueryTable(new(models.TenantApprovalFlow)). + Filter("tid", tid).Filter("is_deleted", 0).Filter("status", 1) + if module != "" { + qs = qs.Filter("module_code", module) + } + var rows []models.TenantApprovalFlow + _, _ = qs.OrderBy("module_code", "id").All(&rows) + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, map[string]interface{}{ + "module_code": r.ModuleCode, + "flow_code": r.FlowCode, + "flow_name": r.FlowName, + }) + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": list} + _ = c.ServeJSON() +} + +// FlowDelete DELETE /backend/approval/flow/delete/:id +// 软删本租户流程及其节点。存在在途实例时禁止删除,避免审批中断。 +func (c *BackendApprovalController) FlowDelete() { + claims, err := requireBackend(&c.Controller) + if err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + tid := uint64(claims.TenantId) + id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + var f models.TenantApprovalFlow + if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)). + Filter("id", id).Filter("tid", tid).Filter("is_deleted", 0).One(&f); err != nil { + jsonErr(&c.Controller, 404, 404, "流程不存在") + return + } + running, _ := models.Orm.QueryTable(new(models.ApprovalInstance)). + Filter("flow_id", id).Filter("scope", models.ApprovalScopeTenant). + Filter("status", models.ApprovalInstanceRunning).Filter("is_deleted", 0).Count() + if running > 0 { + jsonErr(&c.Controller, 400, 400, fmt.Sprintf("该流程还有 %d 条在途审批,暂不能删除", running)) + return + } + now := time.Now() + _, _ = models.Orm.QueryTable(new(models.TenantApprovalFlow)).Filter("id", id).Filter("tid", tid). + Update(orm.Params{"is_deleted": 1, "delete_time": now, "update_time": now}) + _, _ = models.Orm.QueryTable(new(models.TenantApprovalNode)).Filter("flow_id", id).Filter("tid", tid). + Update(orm.Params{"is_deleted": 1, "delete_time": now}) + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "已删除"} + _ = c.ServeJSON() +} + +// Options GET /backend/approval/options +// 编排界面数据源:当前租户的功能模块 + 审批人候选(用户 / 角色 / 部门 / 职位)。 +func (c *BackendApprovalController) Options() { + claims, err := requireBackend(&c.Controller) + if err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + tid := uint64(claims.TenantId) + + // 功能模块:状态可用、Web 可见,并标记当前租户是否已开通 + var mods []models.SystemModules + _, _ = models.Orm.QueryTable(new(models.SystemModules)). + Filter("delete_time__isnull", true).Filter("status", 1).Filter("is_show", 1). + OrderBy("sort", "id").All(&mods) + mods = services.FilterModulesByPlatform(mods, models.PlatformWeb) + allowed := services.GetTenantModuleCodes(tid) + moduleList := make([]map[string]interface{}, 0, len(mods)) + for _, m := range mods { + enabled := true + if len(allowed) > 0 { + enabled = allowed[services.NormalizeModuleCode(m.Code)] + } + moduleList = append(moduleList, map[string]interface{}{ + "code": m.Code, "name": m.Name, "enabled": enabled, + }) + } + + // 租户用户 + var tu []models.SystemTenantUser + _, _ = models.Orm.QueryTable(new(models.SystemTenantUser)). + Filter("tid", tid).Filter("status", 1).Filter("delete_time__isnull", true). + OrderBy("id").All(&tu) + userList := make([]map[string]interface{}, 0, len(tu)) + for _, u := range tu { + userList = append(userList, map[string]interface{}{ + "id": u.Uid, "name": firstNonEmptyStr(u.Name, u.Account), + }) + } + + // 租户角色(cid=2,含平台预置 tenant_id=0 与本租户自定义) + var ar []models.AdminRole + _, _ = models.Orm.QueryTable(new(models.AdminRole)). + Filter("cid", 2).Filter("tenant_id__in", []uint64{0, tid}). + Filter("status", 1).Filter("delete_time__isnull", true). + OrderBy("id").All(&ar) + roleList := make([]map[string]interface{}, 0, len(ar)) + for _, r := range ar { + roleList = append(roleList, map[string]interface{}{"id": r.ID, "name": r.Name}) + } + + // 部门 / 职位 + var og []models.BackendOrganization + _, _ = models.Orm.QueryTable(new(models.BackendOrganization)). + Filter("tid", tid).Filter("status", 1).Filter("delete_time__isnull", true). + OrderBy("sort", "id").All(&og) + orgList := make([]map[string]interface{}, 0, len(og)) + for _, o := range og { + orgList = append(orgList, map[string]interface{}{"id": o.ID, "name": o.OrgName}) + } + + var ps []models.BackendPosition + _, _ = models.Orm.QueryTable(new(models.BackendPosition)). + Filter("tid", tid).Filter("status", 1).Filter("delete_time__isnull", true). + OrderBy("sort", "id").All(&ps) + posList := make([]map[string]interface{}, 0, len(ps)) + for _, p := range ps { + posList = append(posList, map[string]interface{}{"id": p.ID, "name": p.PositionName}) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, "msg": "ok", + "data": map[string]interface{}{ + "modules": moduleList, + "users": userList, + "roles": roleList, + "orgs": orgList, + "positions": posList, + }, + } + _ = c.ServeJSON() +} + +// ============================ 辅助函数 ============================ + +// firstNonEmptyStr 取字符串指针/普通字符串中第一个非空值 +func firstNonEmptyStr(vals ...*string) string { + for _, v := range vals { + if v != nil && strings.TrimSpace(*v) != "" { + return strings.TrimSpace(*v) + } + } + return "" +} + +// validateTenantApprovalNodes 校验租户节点:审批/条件节点须合法选择审批人来源。 +// 租户侧支持 6 种审批人类型:1用户 2角色 3部门主管 4职位 5发起人本人 6发起人主管。 +func validateTenantApprovalNodes(nodes []approvalNodePayload) error { + for i, nd := range nodes { + if nd.NodeType == models.ApprovalNodeStart || nd.NodeType == models.ApprovalNodeEnd || + nd.NodeType == models.ApprovalNodeCondition { + continue + } + if strings.TrimSpace(nd.NodeName) == "" { + return fmt.Errorf("第 %d 个节点名称不能为空", i+1) + } + if nd.ApproverType < models.ApprovalApproverUser || nd.ApproverType > models.ApprovalApproverInitLeader { + return fmt.Errorf("节点「%s」审批人类型非法", nd.NodeName) + } + // 发起人相关类型无需指定对象;其余必须选择非空来源 + needIDs := nd.ApproverType != models.ApprovalApproverInitiator && + nd.ApproverType != models.ApprovalApproverInitLeader + if needIDs && len(nd.ApproverIDs) == 0 { + return fmt.Errorf("节点「%s」未选择审批人", nd.NodeName) + } + } + return nil +} diff --git a/go/controllers/backend_domain.go b/go/controllers/backend_domain.go index e32085f..93de83a 100644 --- a/go/controllers/backend_domain.go +++ b/go/controllers/backend_domain.go @@ -564,58 +564,12 @@ func (c *BackendTenantDomainController) Apply() { jsonErr(&c.Controller, 500, 500, "申请失败: "+err.Error()) return } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "申请提交成功,等待审核", "data": map[string]interface{}{"id": uint64(id)}} - _ = c.ServeJSON() -} - -// Audit POST /backend/domain/tenant/audit body:{id,action} action=approve/reject -func (c *BackendTenantDomainController) Audit() { - if _, err := requireBackend(&c.Controller); err != nil { - jsonErr(&c.Controller, 401, 401, err.Error()) + // 提交后进入平台侧审批流(backend 提起、platform 审核);严格:未配置流程/无有效审批人则提交失败并回滚 + if errMsg := submitDomainApprovalChecked(uint64(id), tenantID, uint64(claims.UserID), claims.Username, full); errMsg != "" { + jsonErr(&c.Controller, 400, 400, errMsg) return } - raw, err := io.ReadAll(c.Ctx.Request.Body) - if err != nil { - jsonErr(&c.Controller, 400, 400, "参数错误") - return - } - var p struct { - ID uint64 `json:"id"` - Action string `json:"action"` - } - if err := json.Unmarshal(raw, &p); err != nil || p.ID == 0 { - jsonErr(&c.Controller, 400, 400, "参数错误") - return - } - var row models.SystemTenantDomain - if err := models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("id", p.ID).One(&row); err != nil { - jsonErr(&c.Controller, 404, 404, "域名不存在") - return - } - if row.DomainType == models.DomainTypeCustom { - jsonErr(&c.Controller, 400, 400, "自有域名无需人工审核,解析检测与证书签发通过后自动生效") - return - } - if row.Status != 0 { - jsonErr(&c.Controller, 400, 400, "该域名已审核过了") - return - } - newStatus := 2 - msg := "已拒绝" - if strings.ToLower(strings.TrimSpace(p.Action)) == "approve" { - newStatus = 1 - msg = "审核通过" - } - now := time.Now() - _, err = models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("id", p.ID).Update(map[string]interface{}{ - "status": newStatus, - "update_time": now, - }) - if err != nil { - jsonErr(&c.Controller, 500, 500, "审核失败: "+err.Error()) - return - } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": msg} + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "申请提交成功,等待审批", "data": map[string]interface{}{"id": uint64(id)}} _ = c.ServeJSON() } diff --git a/go/controllers/backend_frontend_content.go b/go/controllers/backend_frontend_content.go index 31629a0..52c1337 100644 --- a/go/controllers/backend_frontend_content.go +++ b/go/controllers/backend_frontend_content.go @@ -677,6 +677,44 @@ func (c *BackendOnepageController) ListAll() { _ = c.ServeJSON() } +// GetOnePageByPath GET /backend/onepage/path/:path 按路径读取租户单页(后端端独立接口) +func (c *BackendOnepageController) GetOnePageByPath() { + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + path := strings.TrimSpace(c.Ctx.Input.Param(":path")) + if path == "" { + c.cmsJSONErr(400, 400, "路径标识不能为空") + return + } + + var row models.CmsFrontendOnepage + err = models.Orm.QueryTable(new(models.CmsFrontendOnepage)). + Filter("tid", tid). + Filter("path", path). + Filter("delete_time__isnull", true). + One(&row) + if err == orm.ErrNoRows { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": nil} + _ = c.ServeJSON() + return + } + if err != nil { + c.cmsJSONErr(500, 500, "获取单页失败") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": cmsOnepageToMap(row)} + _ = c.ServeJSON() +} + // Create POST /backend/createonepage func (c *BackendOnepageController) Create() { claims, err := c.cmsClaims() diff --git a/go/controllers/index_upgrade_notice.go b/go/controllers/index_upgrade_notice.go new file mode 100644 index 0000000..8d7ced1 --- /dev/null +++ b/go/controllers/index_upgrade_notice.go @@ -0,0 +1,65 @@ +package controllers + +import ( + "strconv" + + "server/models" + + beego "github.com/beego/beego/v2/server/web" +) + +// IndexUpgradeNoticeController 官网端「平台更新公告」公开接口。 +// 数据同源于 models.PlatformUpgradeNotice,但作为官网端独立接口, +// 不与平台端 /platform、移动端 /api 复用同一 handler。 +type IndexUpgradeNoticeController struct { + beego.Controller +} + +// List GET /site/upgrade/list 官网首页公开展示的更新公告(仅启用、未删除) +func (c *IndexUpgradeNoticeController) List() { + limitStr := c.GetString("limit") + limit := 10 + if limitStr != "" { + val, err := strconv.Atoi(limitStr) + if err == nil { + limit = val + } + } + if limit <= 0 || limit > 50 { + limit = 10 + } + + var upgradeList []models.PlatformUpgradeNotice + qs := models.Orm.QueryTable(new(models.PlatformUpgradeNotice)). + Filter("status", 1). + Filter("delete_time__isnull", true) + + _, err := qs.OrderBy("-sort", "-create_time").Limit(limit).All(&upgradeList) + if err != nil { + c.Data["json"] = map[string]interface{}{ + "code": 500, + "msg": "查询失败", + } + _ = c.ServeJSON() + return + } + + data := make([]map[string]interface{}, 0, len(upgradeList)) + for _, item := range upgradeList { + record := map[string]interface{}{ + "id": item.ID, + "update_date": item.UpdateDate, + "title": item.Title, + "content": item.Content, + "create_time": item.CreateTime.Format("2006-01-02 15:04:05"), + } + data = append(data, record) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": data, + } + _ = c.ServeJSON() +} diff --git a/go/controllers/platform_approval.go b/go/controllers/platform_approval.go new file mode 100644 index 0000000..c9f89eb --- /dev/null +++ b/go/controllers/platform_approval.go @@ -0,0 +1,660 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "server/models" + "server/services" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +// PlatformApprovalController 平台端审批流配置(流程头 + 节点编排)。 +// +// 当期面向「域名申请」这类平台侧管控场景,按 module_code + biz_type 联合唯一组织流程; +// 平台账号不归属租户,故流程与节点均无 tid,审批人取平台用户 / 平台角色。 +type PlatformApprovalController struct { + beego.Controller +} + +// ---- 配置侧 JSON 结构 ---- + +// approvalNodePayload 单个节点(前端画布节点)。 +// node_type:1开始 2审批 3结束 4条件网关;pos_x/pos_y 为画布坐标回显; +// condition_expr 为该节点作为「条件网关出边目标」时的进入条件(由 edges 反推落库)。 +type approvalNodePayload struct { + NodeKey string `json:"node_key"` + NodeName string `json:"node_name"` + NodeType int8 `json:"node_type"` // 1开始 2审批 3结束 4条件网关 + ApproverType int8 `json:"approver_type"` // 1指定用户 2指定角色 ... + ApproverIDs []uint64 `json:"approver_ids"` + MultiMode int8 `json:"multi_mode"` // 1或签 2会签 + AllowReturn int8 `json:"allow_return"` // 是否允许退回 + TimeoutHours int `json:"timeout_hours"` // 超时小时 + Sort int `json:"sort"` // 顺序(越小越靠前) + PosX int `json:"pos_x"` // 画布 X 坐标 + PosY int `json:"pos_y"` // 画布 Y 坐标 + ConditionExpr string `json:"condition_expr"` +} + +// approvalEdge 画布连线:from→to,condition 为该出边的分支条件(落到 to 节点的 condition_expr) +type approvalEdge struct { + From string `json:"from"` + To string `json:"to"` + Condition string `json:"condition"` +} + +// approvalFlowSave 保存流程(图:节点 + 连线,节点全量覆盖) +type approvalFlowSave struct { + ID uint64 `json:"id"` + ModuleCode string `json:"module_code"` + BizType string `json:"biz_type"` + FlowName string `json:"flow_name"` + Description string `json:"description"` + IsDefault int8 `json:"is_default"` + Status int8 `json:"status"` + Nodes []approvalNodePayload `json:"nodes"` + Edges []approvalEdge `json:"edges"` +} + +// approvalFlowView 流程 + 节点 + 连线(详情返回) +type approvalFlowView struct { + ID uint64 `json:"id"` + ModuleCode string `json:"module_code"` + BizType string `json:"biz_type"` + FlowCode string `json:"flow_code"` + FlowName string `json:"flow_name"` + Description string `json:"description"` + IsDefault int8 `json:"is_default"` + Status int8 `json:"status"` + NodeCount int `json:"node_count"` + CreateTime string `json:"create_time"` + Nodes []approvalNodePayload `json:"nodes"` + Edges []approvalEdge `json:"edges"` +} + +// readApprovalBody 读取并解析 JSON 请求体 +func (c *PlatformApprovalController) readApprovalBody(dst interface{}) error { + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + return fmt.Errorf("参数错误") + } + if err := json.Unmarshal(raw, dst); err != nil { + return fmt.Errorf("参数错误: %s", err.Error()) + } + return nil +} + +// FlowList GET /platform/approval/flow/list?biz_type=domain +// 平台端流程列表;可按单据类型过滤,附带节点数。 +func (c *PlatformApprovalController) FlowList() { + if _, err := requirePlatform(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + bizType := strings.TrimSpace(c.GetString("biz_type")) + if bizType == "" { + bizType = strings.TrimSpace(c.GetString("module")) // 兼容:分类即业务类型 + } + qs := models.Orm.QueryTable(new(models.PlatformApprovalFlow)). + Filter("is_deleted", 0) + if bizType != "" { + qs = qs.Filter("biz_type", bizType) + } + var rows []models.PlatformApprovalFlow + _, _ = qs.OrderBy("-is_default", "id").All(&rows) + + list := make([]approvalFlowView, 0, len(rows)) + for _, r := range rows { + cnt, _ := models.Orm.QueryTable(new(models.PlatformApprovalNode)). + Filter("flow_id", r.ID).Filter("is_deleted", 0).Count() + list = append(list, approvalFlowView{ + ID: r.ID, + ModuleCode: r.ModuleCode, + BizType: r.BizType, + FlowCode: r.FlowCode, + FlowName: r.FlowName, + Description: r.Description, + IsDefault: r.IsDefault, + Status: r.Status, + NodeCount: int(cnt), + CreateTime: r.CreateTime.Format("2006-01-02 15:04:05"), + }) + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": list} + _ = c.ServeJSON() +} + +// FlowDetail GET /platform/approval/flow/detail/:id +// 返回流程及其全部节点(按 sort 升序),供编排界面回显。 +func (c *PlatformApprovalController) FlowDetail() { + if _, err := requirePlatform(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + var f models.PlatformApprovalFlow + if err := models.Orm.QueryTable(new(models.PlatformApprovalFlow)). + Filter("id", id).Filter("is_deleted", 0).One(&f); err != nil { + jsonErr(&c.Controller, 404, 404, "流程不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": c.flowToView(f)} + _ = c.ServeJSON() +} + +// flowToView 组装流程视图(含节点与连线) +func (c *PlatformApprovalController) flowToView(f models.PlatformApprovalFlow) approvalFlowView { + var nodes []models.PlatformApprovalNode + _, _ = models.Orm.QueryTable(new(models.PlatformApprovalNode)). + Filter("flow_id", f.ID).Filter("is_deleted", 0). + OrderBy("sort", "id").All(&nodes) + view := approvalFlowView{ + ID: f.ID, + ModuleCode: f.ModuleCode, + BizType: f.BizType, + FlowCode: f.FlowCode, + FlowName: f.FlowName, + Description: f.Description, + IsDefault: f.IsDefault, + Status: f.Status, + NodeCount: len(nodes), + CreateTime: f.CreateTime.Format("2006-01-02 15:04:05"), + Nodes: make([]approvalNodePayload, 0, len(nodes)), + } + edgeNodes := make([]flowEdgeNode, 0, len(nodes)) + for _, n := range nodes { + view.Nodes = append(view.Nodes, approvalNodePayload{ + NodeKey: n.NodeKey, + NodeName: n.NodeName, + NodeType: n.NodeType, + ApproverType: n.ApproverType, + ApproverIDs: parseIDSlice(n.ApproverIDs), + MultiMode: n.MultiMode, + AllowReturn: n.AllowReturn, + TimeoutHours: n.TimeoutHours, + Sort: n.Sort, + PosX: n.PosX, + PosY: n.PosY, + ConditionExpr: n.ConditionExpr, + }) + edgeNodes = append(edgeNodes, flowEdgeNode{NodeKey: n.NodeKey, NextKeys: n.NextKeys, Condition: n.ConditionExpr}) + } + view.Edges = rebuildEdges(edgeNodes) + return view +} + +// FlowSave POST /platform/approval/flow/save +// 新建或更新流程;节点采用「全量覆盖」语义(先软删旧节点,再按传入顺序重建)。 +func (c *PlatformApprovalController) FlowSave() { + if _, err := requirePlatform(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + var p approvalFlowSave + if err := c.readApprovalBody(&p); err != nil { + jsonErr(&c.Controller, 400, 400, err.Error()) + return + } + if strings.TrimSpace(p.FlowName) == "" { + jsonErr(&c.Controller, 400, 400, "流程名称不能为空") + return + } + // 分类(module_code)与单据类型(biz_type)对齐:二者留空时互补,至少其一非空 + if strings.TrimSpace(p.ModuleCode) == "" { + p.ModuleCode = p.BizType + } + if strings.TrimSpace(p.BizType) == "" { + p.BizType = p.ModuleCode + } + if strings.TrimSpace(p.BizType) == "" { + jsonErr(&c.Controller, 400, 400, "请选择所属分类") + return + } + if err := validateApprovalNodes(p.Nodes); err != nil { + jsonErr(&c.Controller, 400, 400, err.Error()) + return + } + if err := validateApprovalGraph(p.Nodes, p.Edges); err != nil { + jsonErr(&c.Controller, 400, 400, err.Error()) + return + } + g := buildGraph(p.Nodes, p.Edges) + now := time.Now() + + o := models.Orm + var flowID uint64 + var flowCode string + if p.ID > 0 { + var exist models.PlatformApprovalFlow + if err := o.QueryTable(new(models.PlatformApprovalFlow)). + Filter("id", p.ID).Filter("is_deleted", 0).One(&exist); err != nil { + jsonErr(&c.Controller, 404, 404, "流程不存在") + return + } + flowID = p.ID + flowCode = exist.FlowCode + _, err := o.QueryTable(new(models.PlatformApprovalFlow)).Filter("id", flowID).Update(orm.Params{ + "module_code": p.ModuleCode, + "biz_type": p.BizType, + "flow_name": p.FlowName, + "description": p.Description, + "is_default": p.IsDefault, + "status": p.Status, + "update_time": now, + }) + if err != nil { + jsonErr(&c.Controller, 500, 500, "保存失败: "+err.Error()) + return + } + } else { + row := models.PlatformApprovalFlow{ + ModuleCode: p.ModuleCode, + BizType: p.BizType, + FlowName: p.FlowName, + Description: p.Description, + IsDefault: p.IsDefault, + Status: p.Status, + CreateTime: now, + } + id, err := o.Insert(&row) + if err != nil { + jsonErr(&c.Controller, 500, 500, "保存失败: "+err.Error()) + return + } + flowID = uint64(id) + flowCode = genApprovalFlowCode(flowID) + _, _ = o.QueryTable(new(models.PlatformApprovalFlow)).Filter("id", flowID). + Update(orm.Params{"flow_code": flowCode}) + } + + // 默认流程唯一:同 biz_type 下只允许一条 is_default=1 + if p.IsDefault == 1 { + _, _ = o.QueryTable(new(models.PlatformApprovalFlow)). + Filter("biz_type", p.BizType).Filter("is_deleted", 0). + Exclude("id", flowID).Update(orm.Params{"is_default": 0, "update_time": now}) + } + + // 覆盖节点:软删旧节点后按 sort 重建 + _, _ = o.QueryTable(new(models.PlatformApprovalNode)). + Filter("flow_id", flowID).Filter("is_deleted", 0). + Update(orm.Params{"is_deleted": 1, "delete_time": now}) + for i, nd := range p.Nodes { + sort := nd.Sort + if sort == 0 { + sort = i + 1 + } + nodeKey := strings.TrimSpace(nd.NodeKey) + if nodeKey == "" { + nodeKey = fmt.Sprintf("n%d", sort) + } + nodeType := nd.NodeType + if nodeType == 0 { + nodeType = models.ApprovalNodeApproval + } + multi := nd.MultiMode + if multi != models.ApprovalMultiAll { + multi = models.ApprovalMultiAny + } + timeout := nd.TimeoutHours + if timeout <= 0 { + timeout = 48 + } + node := models.PlatformApprovalNode{ + FlowID: flowID, + NodeKey: nodeKey, + NodeName: strings.TrimSpace(nd.NodeName), + NodeType: nodeType, + ApproverType: nd.ApproverType, + ApproverIDs: joinIDs(nd.ApproverIDs), + MultiMode: multi, + AllowReturn: nd.AllowReturn, + TimeoutHours: timeout, + Sort: sort, + PosX: nd.PosX, + PosY: nd.PosY, + PrevKeys: strings.Join(g.prev[nodeKey], ","), + NextKeys: strings.Join(g.next[nodeKey], ","), + ConditionExpr: g.cond[nodeKey], + CreateTime: now, + } + if _, err := o.Insert(&node); err != nil { + jsonErr(&c.Controller, 500, 500, "保存节点失败: "+err.Error()) + return + } + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功", "data": map[string]interface{}{"id": flowID, "flow_code": flowCode}} + _ = c.ServeJSON() +} + +// Pick GET /platform/approval/flow/pick +// 轻量清单:按分类返回启用流程的 {flow_code, flow_name},供其它功能“选/填编号调用”。 +func (c *PlatformApprovalController) Pick() { + if _, err := requirePlatform(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + module := strings.TrimSpace(c.GetString("module")) + if module == "" { + module = strings.TrimSpace(c.GetString("biz_type")) + } + qs := models.Orm.QueryTable(new(models.PlatformApprovalFlow)). + Filter("is_deleted", 0).Filter("status", 1) + if module != "" { + qs = qs.Filter("biz_type", module) + } + var rows []models.PlatformApprovalFlow + _, _ = qs.OrderBy("biz_type", "id").All(&rows) + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, map[string]interface{}{ + "module_code": r.ModuleCode, + "biz_type": r.BizType, + "flow_code": r.FlowCode, + "flow_name": r.FlowName, + }) + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": list} + _ = c.ServeJSON() +} + +// FlowDelete DELETE /platform/approval/flow/delete/:id +// 软删流程及其节点。存在在途实例时禁止删除,避免审批中断。 +func (c *PlatformApprovalController) FlowDelete() { + if _, err := requirePlatform(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + var f models.PlatformApprovalFlow + if err := models.Orm.QueryTable(new(models.PlatformApprovalFlow)). + Filter("id", id).Filter("is_deleted", 0).One(&f); err != nil { + jsonErr(&c.Controller, 404, 404, "流程不存在") + return + } + running, _ := models.Orm.QueryTable(new(models.ApprovalInstance)). + Filter("flow_id", id).Filter("scope", models.ApprovalScopePlatform). + Filter("status", models.ApprovalInstanceRunning).Filter("is_deleted", 0).Count() + if running > 0 { + jsonErr(&c.Controller, 400, 400, fmt.Sprintf("该流程还有 %d 条在途审批,暂不能删除", running)) + return + } + now := time.Now() + _, _ = models.Orm.QueryTable(new(models.PlatformApprovalFlow)).Filter("id", id). + Update(orm.Params{"is_deleted": 1, "delete_time": now, "update_time": now}) + _, _ = models.Orm.QueryTable(new(models.PlatformApprovalNode)).Filter("flow_id", id). + Update(orm.Params{"is_deleted": 1, "delete_time": now}) + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "已删除"} + _ = c.ServeJSON() +} + +// Options GET /platform/approval/options +// 编排界面「选审批人」用:返回平台角色(cid=1)与平台用户列表。 +func (c *PlatformApprovalController) Options() { + if _, err := requirePlatform(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + var roles []models.AdminRole + _, _ = models.Orm.QueryTable(new(models.AdminRole)). + Filter("cid", 1).Filter("status", 1).Filter("delete_time__isnull", true). + OrderBy("id").All(&roles) + roleList := make([]map[string]interface{}, 0, len(roles)) + for _, r := range roles { + roleList = append(roleList, map[string]interface{}{"id": r.ID, "name": r.Name}) + } + + var users []models.AdminUser + _, _ = models.Orm.QueryTable(new(models.AdminUser)). + Filter("status", 1).Filter("delete_time__isnull", true). + OrderBy("id").All(&users) + userList := make([]map[string]interface{}, 0, len(users)) + for _, u := range users { + userList = append(userList, map[string]interface{}{ + "id": u.ID, + "name": approverDisplayName(u.Name, u.Account), + "role": u.RoleID, + }) + } + + // 分类(复用功能模块):Web 可见、启用、未删除 + var mods []models.SystemModules + _, _ = models.Orm.QueryTable(new(models.SystemModules)). + Filter("delete_time__isnull", true).Filter("status", 1).Filter("is_show", 1). + OrderBy("sort", "id").All(&mods) + mods = services.FilterModulesByPlatform(mods, models.PlatformWeb) + moduleList := make([]map[string]interface{}, 0, len(mods)) + for _, m := range mods { + moduleList = append(moduleList, map[string]interface{}{ + "code": m.Code, "name": m.Name, "enabled": true, + }) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, "msg": "ok", + "data": map[string]interface{}{"roles": roleList, "users": userList, "modules": moduleList}, + } + _ = c.ServeJSON() +} + +// BizTypes GET /platform/approval/biz-types +// 可绑定业务类型字典(当期平台侧为「域名申请」),供配置界面顶级下拉。 +func (c *PlatformApprovalController) BizTypes() { + if _, err := requirePlatform(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": []map[string]interface{}{ + {"value": models.ApprovalBizDomain, "label": "域名申请"}, + }} + _ = c.ServeJSON() +} + +// ============================ 辅助函数 ============================ + +// approverDisplayName 优先姓名,回退账号(AdminUser.Account 为普通字符串) +func approverDisplayName(name *string, account string) string { + if name != nil && strings.TrimSpace(*name) != "" { + return strings.TrimSpace(*name) + } + return strings.TrimSpace(account) +} + +// validateApprovalNodes 校验审批节点:必须选定审批人来源且非空(开始/结束/条件网关跳过) +func validateApprovalNodes(nodes []approvalNodePayload) error { + for i, nd := range nodes { + if nd.NodeType == models.ApprovalNodeStart || nd.NodeType == models.ApprovalNodeEnd || + nd.NodeType == models.ApprovalNodeCondition { + continue + } + if strings.TrimSpace(nd.NodeName) == "" { + return fmt.Errorf("第 %d 个节点名称不能为空", i+1) + } + if nd.ApproverType != models.ApprovalApproverUser && nd.ApproverType != models.ApprovalApproverRole { + return fmt.Errorf("节点「%s」审批人类型非法(仅支持指定用户/指定角色)", nd.NodeName) + } + if len(nd.ApproverIDs) == 0 { + return fmt.Errorf("节点「%s」未选择审批人", nd.NodeName) + } + } + return nil +} + +// joinIDs 将 ID 数组拼成逗号分隔串(存库口径) +func joinIDs(ids []uint64) string { + parts := make([]string, 0, len(ids)) + for _, id := range ids { + if id > 0 { + parts = append(parts, strconv.FormatUint(id, 10)) + } + } + return strings.Join(parts, ",") +} + +// parseIDSlice 解析逗号分隔 ID 串为数组 +func parseIDSlice(raw string) []uint64 { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + out := make([]uint64, 0) + for _, p := range strings.Split(raw, ",") { + if v, err := strconv.ParseUint(strings.TrimSpace(p), 10, 64); err == nil && v > 0 { + out = append(out, v) + } + } + return out +} + +// ============================ 图(连线)辅助 ============================ + +// flowEdgeNode 连线重建输入:节点标识 + 出边(next_keys) + 本节点进入条件 +type flowEdgeNode struct { + NodeKey string + NextKeys string + Condition string +} + +// splitKeyList 拆分逗号分隔 node_key 列表 +func splitKeyList(raw string) []string { + out := make([]string, 0, 4) + for _, p := range strings.Split(raw, ",") { + if s := strings.TrimSpace(p); s != "" { + out = append(out, s) + } + } + return out +} + +// containsStr 列表是否包含某字符串 +func containsStr(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +// approvalGraph 由画布连线反推的邻接与条件 +type approvalGraph struct { + next map[string][]string + prev map[string][]string + cond map[string]string +} + +// buildGraph 由 edges 反推每个节点的 prev_keys/next_keys 与进入条件(条件落到目标节点) +func buildGraph(nodes []approvalNodePayload, edges []approvalEdge) approvalGraph { + g := approvalGraph{next: map[string][]string{}, prev: map[string][]string{}, cond: map[string]string{}} + for _, e := range edges { + f := strings.TrimSpace(e.From) + t := strings.TrimSpace(e.To) + if f == "" || t == "" || f == t { + continue + } + if !containsStr(g.next[f], t) { + g.next[f] = append(g.next[f], t) + } + if !containsStr(g.prev[t], f) { + g.prev[t] = append(g.prev[t], f) + } + if c := strings.TrimSpace(e.Condition); c != "" { + g.cond[t] = c + } + } + return g +} + +// rebuildEdges 由节点 next_keys 邻接重建画布连线;连线条件取目标节点 condition_expr +func rebuildEdges(nodes []flowEdgeNode) []approvalEdge { + condByKey := make(map[string]string, len(nodes)) + for _, n := range nodes { + condByKey[n.NodeKey] = n.Condition + } + edges := make([]approvalEdge, 0) + for _, n := range nodes { + for _, to := range splitKeyList(n.NextKeys) { + edges = append(edges, approvalEdge{From: n.NodeKey, To: to, Condition: condByKey[to]}) + } + } + return edges +} + +// validateApprovalGraph 校验画布图的完整性:单一开始、审批节点≥1、网关出边≥2、全部可达、连线端点有效 +func validateApprovalGraph(nodes []approvalNodePayload, edges []approvalEdge) error { + if len(nodes) == 0 { + return fmt.Errorf("流程至少需包含开始与审批节点") + } + keySet := map[string]bool{} + var startCount, approvalCount int + var condKeys []string + var startKey string + for _, n := range nodes { + k := strings.TrimSpace(n.NodeKey) + if k == "" { + return fmt.Errorf("存在缺少标识的节点,请重新连线后再保存") + } + if keySet[k] { + return fmt.Errorf("节点标识重复:%s", k) + } + keySet[k] = true + switch n.NodeType { + case models.ApprovalNodeStart: + startCount++ + startKey = k + case models.ApprovalNodeApproval: + approvalCount++ + case models.ApprovalNodeCondition: + condKeys = append(condKeys, k) + } + } + if startCount != 1 { + return fmt.Errorf("流程需有且仅有一个「开始」节点") + } + if approvalCount < 1 { + return fmt.Errorf("流程需至少包含一个「审批」节点") + } + nextAdj := map[string][]string{} + for _, e := range edges { + f := strings.TrimSpace(e.From) + t := strings.TrimSpace(e.To) + if !keySet[f] || !keySet[t] { + return fmt.Errorf("存在连向未知节点的连线,请检查画布") + } + nextAdj[f] = append(nextAdj[f], t) + } + for _, k := range condKeys { + if len(nextAdj[k]) < 2 { + return fmt.Errorf("条件网关需至少两条分支连线") + } + } + visited := map[string]bool{} + queue := []string{startKey} + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + if visited[cur] { + continue + } + visited[cur] = true + queue = append(queue, nextAdj[cur]...) + } + for _, n := range nodes { + if !visited[strings.TrimSpace(n.NodeKey)] { + return fmt.Errorf("节点「%s」未接入流程,请检查连线", strings.TrimSpace(n.NodeName)) + } + } + return nil +} + +// genApprovalFlowCode 由自增 id 生成审批流编号(全局唯一、供各程序按编号发起) +func genApprovalFlowCode(id uint64) string { + return fmt.Sprintf("FL%06d", id) +} diff --git a/go/controllers/platform_domain.go b/go/controllers/platform_domain.go index aef4cc7..9e87c6f 100644 --- a/go/controllers/platform_domain.go +++ b/go/controllers/platform_domain.go @@ -463,7 +463,8 @@ func (c *PlatformTenantDomainController) CustomDomainGuide() { // Apply POST /platform/domain/tenant/apply body:{tid,sub_domain,main_domain} func (c *PlatformTenantDomainController) Apply() { - if _, err := requirePlatform(&c.Controller); err != nil { + claims, err := requirePlatform(&c.Controller) + if err != nil { jsonErr(&c.Controller, 401, 401, err.Error()) return } @@ -551,13 +552,21 @@ func (c *PlatformTenantDomainController) Apply() { jsonErr(&c.Controller, 500, 500, "申请失败: "+err.Error()) return } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "申请提交成功,等待审核", "data": map[string]interface{}{"id": uint64(id)}} + // 进入平台侧审批流(严格:未配置流程/无有效审批人则提交失败并回滚) + if errMsg := submitDomainApprovalChecked(uint64(id), p.Tid, uint64(claims.UserID), claims.Username, full); errMsg != "" { + jsonErr(&c.Controller, 400, 400, errMsg) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "申请提交成功,等待审批", "data": map[string]interface{}{"id": uint64(id)}} _ = c.ServeJSON() } -// Audit POST /platform/domain/tenant/audit body:{id,action} action=approve/reject +// Audit POST /platform/domain/tenant/audit body:{id,action,comment} action=approve/reject +// 引擎驱动:已配置审批流则走多级流转,仅当前待办人可操作; +// 未配置流程(无在途实例)则回退到单级直改状态,保证兼容。 func (c *PlatformTenantDomainController) Audit() { - if _, err := requirePlatform(&c.Controller); err != nil { + claims, err := requirePlatform(&c.Controller) + if err != nil { jsonErr(&c.Controller, 401, 401, err.Error()) return } @@ -567,8 +576,9 @@ func (c *PlatformTenantDomainController) Audit() { return } var p struct { - ID uint64 `json:"id"` - Action string `json:"action"` + ID uint64 `json:"id"` + Action string `json:"action"` + Comment string `json:"comment"` } if err := json.Unmarshal(raw, &p); err != nil || p.ID == 0 { jsonErr(&c.Controller, 400, 400, "参数错误") @@ -587,25 +597,34 @@ func (c *PlatformTenantDomainController) Audit() { jsonErr(&c.Controller, 400, 400, "该域名已审核过了") return } - newStatus := 2 - msg := "已拒绝" - if strings.ToLower(strings.TrimSpace(p.Action)) == "approve" { - newStatus = 1 - msg = "审核通过" + pass := strings.ToLower(strings.TrimSpace(p.Action)) == "approve" + var bizTid uint64 + if row.Tid != nil { + bizTid = *row.Tid } - now := time.Now() - _, err = models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("id", p.ID).Update(map[string]interface{}{ - "status": newStatus, - "update_time": now, - }) - if err != nil { - jsonErr(&c.Controller, 500, 500, "审核失败: "+err.Error()) + + // 严格引擎驱动:必须命中在途实例与当前待办,否则拒绝(已移除单级兼容回退) + msg, code := engineDomainAudit(p.ID, uint64(claims.UserID), pass, p.Comment, bizTid) + if code != 200 { + jsonErr(&c.Controller, code, code, msg) return } c.Data["json"] = map[string]interface{}{"code": 200, "msg": msg} _ = c.ServeJSON() } +// AuditProgress GET /platform/domain/tenant/audit/progress/:id +// 返回该域名的审批进度(节点 + 各审批人状态),供平台端展示流转。 +func (c *PlatformTenantDomainController) AuditProgress() { + if _, err := requirePlatform(&c.Controller); err != nil { + jsonErr(&c.Controller, 401, 401, err.Error()) + return + } + id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": domainApprovalProgress(id)} + _ = c.ServeJSON() +} + // ToggleStatus POST /platform/domain/tenant/toggleStatus body:{id} func (c *PlatformTenantDomainController) ToggleStatus() { if _, err := requirePlatform(&c.Controller); err != nil { diff --git a/go/controllers/platform_domain_approval.go b/go/controllers/platform_domain_approval.go new file mode 100644 index 0000000..7cfb5ac --- /dev/null +++ b/go/controllers/platform_domain_approval.go @@ -0,0 +1,171 @@ +package controllers + +import ( + "fmt" + "time" + + "server/models" + "server/services/approval" + + "github.com/beego/beego/v2/client/orm" +) + +// ============================================================= +// 域名申请 × 审批流引擎 的接入层(严格模式,无单级兼容) +// +// 口径: +// - 域名申请提交必须命中已配置的默认流程,走引擎多级流转; +// - 未配置流程 / 节点无有效审批人 → 提交失败并回滚刚创建的域名行,绝不留下无法审批的脏数据; +// - 审核只能通过引擎:必须存在在途实例且命中当前审批人的待办,否则拒绝操作。 +// ============================================================= + +// submitDomainApprovalChecked 将域名提交进平台侧审批流。 +// 成功返回空串;失败则软删回滚刚创建的域名行,并返回给前端的错误文案。 +func submitDomainApprovalChecked(domainRowID, bizTid, submitUID uint64, submitName, fullDomain string) string { + _, err := approval.Submit(approval.SubmitInput{ + Scope: models.ApprovalScopePlatform, + BizTid: bizTid, + BizType: models.ApprovalBizDomain, + BizID: domainRowID, + BizTitle: fullDomain, + SubmitUID: submitUID, + SubmitName: submitName, + }) + if err == nil { + return "" + } + // 提交失败:回滚刚创建的域名行(软删除),避免出现无法审批的待审僵尸数据 + _, _ = models.Orm.QueryTable(new(models.SystemTenantDomain)). + Filter("id", domainRowID).Update(map[string]interface{}{"delete_time": time.Now()}) + if _, ok := err.(*approval.ErrNoFlow); ok { + return "平台尚未配置域名审批流程,无法提交申请" + } + if _, ok := err.(*approval.ErrNoApprover); ok { + return "域名审批流程未配置有效审批人,无法提交申请" + } + return "提交审批失败:" + err.Error() +} + +// findRunningDomainInstance 取某域名当前在途的平台侧审批实例 +func findRunningDomainInstance(domainID uint64) (*models.ApprovalInstance, bool) { + var inst models.ApprovalInstance + err := models.Orm.QueryTable(new(models.ApprovalInstance)). + Filter("scope", models.ApprovalScopePlatform). + Filter("biz_type", models.ApprovalBizDomain). + Filter("biz_id", domainID). + Filter("status", models.ApprovalInstanceRunning). + Filter("is_deleted", 0). + OrderBy("-id").One(&inst) + if err == orm.ErrNoRows { + return nil, false + } + if err != nil { + return nil, false + } + return &inst, true +} + +// myPendingDomainTask 取指定审批人在该实例上的当前待办任务 +func myPendingDomainTask(instID, approverID uint64) (*models.ApprovalTask, bool) { + var task models.ApprovalTask + err := models.Orm.QueryTable(new(models.ApprovalTask)). + Filter("instance_id", instID). + Filter("approver_id", approverID). + Filter("status", models.ApprovalTaskPending). + Filter("is_deleted", 0). + OrderBy("sort", "id").One(&task) + if err != nil { + return nil, false + } + return &task, true +} + +// applyDomainApprovalResult 依据引擎结果回写域名状态(仅实例终结时改动) +func applyDomainApprovalResult(domainID uint64, res *approval.ApproveResult) { + if res == nil || !res.Finished { + return + } + newStatus := 2 // 默认拒绝/驳回 + if res.InstanceStat == models.ApprovalInstanceApproved { + newStatus = 1 // 通过生效 + } + now := time.Now() + _, _ = models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("id", domainID). + Update(orm.Params{"status": newStatus, "update_time": now}) +} + +// domainApprovalProgress 组装域名审批进度(节点 + 任务),供平台端展示 +func domainApprovalProgress(domainID uint64) map[string]interface{} { + inst, ok := findRunningDomainInstance(domainID) + if !ok { + // 无在途实例:可能未走引擎(单级审核)或已结束 + return map[string]interface{}{"engine": false} + } + flow, err := approval.LoadFlow(models.ApprovalScopePlatform, 0, models.ApprovalBizDomain, inst.FlowID) + nodes := []map[string]interface{}{} + if err == nil && flow != nil { + for _, nd := range flow.Nodes { + nodes = append(nodes, map[string]interface{}{ + "node_key": nd.NodeKey, + "node_name": nd.NodeName, + "node_type": nd.NodeType, + "sort": nd.Sort, + }) + } + } + var tasks []models.ApprovalTask + _, _ = models.Orm.QueryTable(new(models.ApprovalTask)). + Filter("instance_id", inst.ID).Filter("is_deleted", 0). + OrderBy("sort", "id").All(&tasks) + taskList := make([]map[string]interface{}, 0, len(tasks)) + for _, t := range tasks { + taskList = append(taskList, map[string]interface{}{ + "id": t.ID, + "node_key": t.NodeKey, + "node_name": t.NodeName, + "approver_id": t.ApproverID, + "approver_name": t.ApproverName, + "status": t.Status, + "action": t.Action, + "comment": t.Comment, + }) + } + return map[string]interface{}{ + "engine": true, + "instance_id": inst.ID, + "current_node": inst.CurrentNode, + "status": inst.Status, + "nodes": nodes, + "tasks": taskList, + } +} + +// engineDomainAudit 平台端审批入口(严格引擎驱动): +// 必须存在在途实例且命中当前审批人的待办;否则拒绝,不做任何单级回退。 +func engineDomainAudit(domainID, adminID uint64, pass bool, comment string, bizTid uint64) (msg string, code int) { + inst, ok := findRunningDomainInstance(domainID) + if !ok { + return "该域名申请未进入审批流程(未配置流程或已处理)", 400 + } + task, ok := myPendingDomainTask(inst.ID, adminID) + if !ok { + return "该单据在审批流程中,当前不在您的待办节点", 403 + } + res, err := approval.Approve(task.ID, approval.ApproveInput{ + ApproverID: adminID, + Pass: pass, + Comment: comment, + BizTid: bizTid, + }) + if err != nil { + return err.Error(), 400 + } + applyDomainApprovalResult(domainID, res) + if res.Finished { + if res.InstanceStat == models.ApprovalInstanceApproved { + return "审批通过,域名已生效", 200 + } + return "已驳回", 200 + } + return fmt.Sprintf("已%s,流程继续(%s)", map[bool]string{true: "通过", false: "驳回"}[pass], res.Message), 200 +} diff --git a/go/controllers/api_platform_upgrade.go b/go/controllers/platform_upgrade_notice.go similarity index 81% rename from go/controllers/api_platform_upgrade.go rename to go/controllers/platform_upgrade_notice.go index 42eaa6b..7e653cf 100644 --- a/go/controllers/api_platform_upgrade.go +++ b/go/controllers/platform_upgrade_notice.go @@ -9,14 +9,15 @@ import ( beego "github.com/beego/beego/v2/server/web" ) -// ApiPlatformUpgradeController 平台更新内容接口 -type ApiPlatformUpgradeController struct { +// PlatformUpgradeNoticeController 平台端「平台更新公告」接口。 +// 数据同源于 models.PlatformUpgradeNotice,但作为平台端独立接口, +// 不与移动端 /api、官网 /index 复用同一 handler。 +type PlatformUpgradeNoticeController struct { beego.Controller } -// List GET /api/platform/upgrade/list 获取平台更新列表(公开接口) -func (c *ApiPlatformUpgradeController) List() { - // 可选参数:limit - 返回数量限制,默认 10 +// List GET /platform/upgrade/list 获取平台更新列表 +func (c *PlatformUpgradeNoticeController) List() { limitStr := c.GetString("limit") limit := 10 if limitStr != "" { @@ -29,7 +30,6 @@ func (c *ApiPlatformUpgradeController) List() { limit = 10 } - // 查询显示状态的更新记录,按排序字段和创建时间倒序 var upgradeList []models.PlatformUpgradeNotice qs := models.Orm.QueryTable(new(models.PlatformUpgradeNotice)). Filter("status", 1). @@ -45,7 +45,6 @@ func (c *ApiPlatformUpgradeController) List() { return } - // 转换为前端需要的格式 data := make([]map[string]interface{}, 0, len(upgradeList)) for _, item := range upgradeList { record := map[string]interface{}{ @@ -66,8 +65,8 @@ func (c *ApiPlatformUpgradeController) List() { _ = c.ServeJSON() } -// Detail GET /api/platform/upgrade/detail?id={id} 获取单条更新详情 -func (c *ApiPlatformUpgradeController) Detail() { +// Detail GET /platform/upgrade/detail?id={id} 获取单条更新详情 +func (c *PlatformUpgradeNoticeController) Detail() { idStr := strings.TrimSpace(c.GetString("id")) if idStr == "" { c.Data["json"] = map[string]interface{}{ diff --git a/go/controllers/platform_website_site.go b/go/controllers/platform_website_site.go index 1337b06..22a5761 100644 --- a/go/controllers/platform_website_site.go +++ b/go/controllers/platform_website_site.go @@ -6,6 +6,7 @@ import ( "server/models" + "github.com/beego/beego/v2/client/orm" beego "github.com/beego/beego/v2/server/web" ) @@ -552,6 +553,32 @@ func (c *PlatformWebsitePageController) Get() { websiteOK(&c.Controller, row) } +// GetByPath GET /platform/website/page/path/:path — 按路径读取单页(平台端独立接口) +func (c *PlatformWebsitePageController) GetByPath() { + if _, ok := websiteAuth(&c.Controller); !ok { + return + } + path := normalizePath(c.Ctx.Input.Param(":path")) + if path == "" { + jsonErr(&c.Controller, 400, 400, "路径标识不能为空") + return + } + var row models.PlatformWebsitePage + err := models.Orm.QueryTable(new(models.PlatformWebsitePage)). + Filter("path", path). + Filter("delete_time__isnull", true). + One(&row) + if err == orm.ErrNoRows { + websiteOK(&c.Controller, nil) + return + } + if err != nil { + jsonErr(&c.Controller, 500, 500, "获取单页失败: "+err.Error()) + return + } + websiteOK(&c.Controller, row) +} + // Create POST /platform/website/page func (c *PlatformWebsitePageController) Create() { if _, ok := websiteAuth(&c.Controller); !ok { diff --git a/go/docs/sql/create_approval_flow.sql b/go/docs/sql/create_approval_flow.sql new file mode 100644 index 0000000..3b1031a --- /dev/null +++ b/go/docs/sql/create_approval_flow.sql @@ -0,0 +1,198 @@ +-- ============================================================= +-- 自定义审批流(租户侧 + 平台侧) +-- +-- 对应模型:go/models/approval.go +-- +-- 设计要点: +-- 1. 配置侧与实例侧分离:flow/node 是配置(租户可编辑), +-- instance/task 是运行时(提交后生成,不可编辑)。 +-- 2. 节点表用 prev_keys / next_keys 存连线,当期顺序链只填 sort, +-- 后续上拖拽图与条件分支无需改表。 +-- 3. scope 区分租户侧(tenant)与平台侧(platform):平台账号不归属租户, +-- 其流程不带 tid,实例用 biz_tid 标记申请来源租户。 +-- +-- 注意:不显式指定 COLLATE,沿用数据库默认排序规则,避免与既有表 +-- (utf8mb4_0900_ai_ci)在 JOIN 比较字符串时报 Illegal mix of collations。 +-- ============================================================= + +SET NAMES utf8mb4; + +-- ---------------- 1. 租户侧审批流程 ---------------- +CREATE TABLE IF NOT EXISTS `yz_backend_approval_flow` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID', + `module_code` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '所属功能模块 code(yz_system_modules.code):oa/erp/crm/cms', + `biz_type` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '适用单据类型:reimburse/bid/domain/...', + `flow_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '审批流编号(自动生成、全局唯一,供各程序按编号发起):FL000007', + `flow_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '流程名称', + `description` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '流程说明', + `is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '是否该单据类型的默认流程:1是 0否', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', + `is_deleted` TINYINT NOT NULL DEFAULT 0, + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + `delete_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_tid` (`tid`), + KEY `idx_tid_module` (`tid`,`module_code`), + KEY `idx_tid_biz` (`tid`,`biz_type`), + KEY `idx_tid_code` (`tid`,`flow_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-自定义审批流程(租户侧)'; + +-- ---------------- 2. 租户侧审批节点 ---------------- +CREATE TABLE IF NOT EXISTS `yz_backend_approval_node` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `flow_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属流程ID', + `tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID(冗余,便于按租户清理)', + `node_key` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '节点唯一标识(流程内唯一)', + `node_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '节点名称', + `node_type` TINYINT NOT NULL DEFAULT 2 COMMENT '1开始 2审批 3结束 4条件分支', + `approver_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1指定用户 2指定角色 3部门主管 4指定职位 5发起人本人 6发起人主管', + `approver_ids` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '审批人ID列表,逗号分隔(user=SystemTenantUser.uid / role=AdminRole.id / position=BackendPosition.id)', + `multi_mode` TINYINT NOT NULL DEFAULT 1 COMMENT '多人审批方式:1或签(任一通过) 2会签(全部通过)', + `allow_return` TINYINT NOT NULL DEFAULT 0 COMMENT '是否允许退回上一节点:1是 0否', + `timeout_hours` INT NOT NULL DEFAULT 48 COMMENT '超时小时数,超时后自动通过', + `prev_keys` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '前置节点 node_key 列表,逗号分隔(为条件分支预留)', + `next_keys` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '后继节点 node_key 列表,逗号分隔(为条件分支预留)', + `condition_expr` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '进入条件表达式,如 amount>5000(条件分支网关的出边条件)', + `sort` INT NOT NULL DEFAULT 0 COMMENT '顺序号,顺序链按此升序流转', + `pos_x` INT NOT NULL DEFAULT 0 COMMENT '画布X坐标(图编辑器回显)', + `pos_y` INT NOT NULL DEFAULT 0 COMMENT '画布Y坐标(图编辑器回显)', + `is_deleted` TINYINT NOT NULL DEFAULT 0, + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + `delete_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_flow` (`flow_id`), + KEY `idx_tid` (`tid`), + KEY `idx_flow_sort` (`flow_id`,`sort`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-自定义审批节点(租户侧)'; + +-- ---------------- 3. 平台侧审批流程 ---------------- +CREATE TABLE IF NOT EXISTS `yz_platform_approval_flow` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `module_code` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '所属平台模块 code', + `biz_type` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '适用单据类型:domain/tenant/...', + `flow_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '审批流编号(自动生成、全局唯一):FL000007', + `flow_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '流程名称', + `description` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '流程说明', + `is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认流程:1是 0否', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', + `is_deleted` TINYINT NOT NULL DEFAULT 0, + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + `delete_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_module` (`module_code`), + KEY `idx_biz` (`biz_type`), + KEY `idx_code` (`flow_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台-自定义审批流程'; + +-- ---------------- 4. 平台侧审批节点 ---------------- +CREATE TABLE IF NOT EXISTS `yz_platform_approval_node` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `flow_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属流程ID', + `node_key` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '节点唯一标识(流程内唯一)', + `node_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '节点名称', + `node_type` TINYINT NOT NULL DEFAULT 2 COMMENT '1开始 2审批 3结束 4条件分支', + `approver_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1指定平台用户 2指定平台角色 5发起人本人', + `approver_ids` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '审批人ID列表,逗号分隔(user=AdminUser.id / role=AdminRole.id)', + `multi_mode` TINYINT NOT NULL DEFAULT 1 COMMENT '1或签 2会签', + `allow_return` TINYINT NOT NULL DEFAULT 0 COMMENT '是否允许退回:1是 0否', + `timeout_hours` INT NOT NULL DEFAULT 48 COMMENT '超时小时数', + `prev_keys` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '前置节点(为条件分支预留)', + `next_keys` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '后继节点(为条件分支预留)', + `condition_expr` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '进入条件(条件分支网关出边条件)', + `sort` INT NOT NULL DEFAULT 0 COMMENT '顺序号', + `pos_x` INT NOT NULL DEFAULT 0 COMMENT '画布X坐标(图编辑器回显)', + `pos_y` INT NOT NULL DEFAULT 0 COMMENT '画布Y坐标(图编辑器回显)', + `is_deleted` TINYINT NOT NULL DEFAULT 0, + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + `delete_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_flow` (`flow_id`), + KEY `idx_flow_sort` (`flow_id`,`sort`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台-自定义审批节点'; + +-- ---------------- 5. 审批实例(运行时,租户侧与平台侧共用) ---------------- +CREATE TABLE IF NOT EXISTS `yz_approval_instance` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `scope` TINYINT NOT NULL DEFAULT 1 COMMENT '1租户侧 2平台侧', + `tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID(租户侧=本租户;平台侧=发起申请的平台账号所属租户,平台账号通常为0)', + `biz_tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '业务来源租户ID(平台侧审批必填:申请由哪个租户提交)', + `flow_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '流程ID(配置可能被改,实例内节点快照为准)', + `biz_type` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '单据类型:reimburse/bid/domain', + `biz_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '单据ID', + `biz_title` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '单据标题快照', + `submit_user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '发起人ID', + `submit_user_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '发起人姓名快照', + `submit_dept_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '发起人部门ID快照(部门主管审批依赖)', + `form_data` TEXT NULL COMMENT '发起表单数据快照(JSON),条件分支求值依据', + `current_node` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '当前节点 node_key', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '0草稿 1审批中 2已通过 3已驳回 4已撤回 5已取消', + `submit_time` DATETIME DEFAULT NULL COMMENT '提交时间', + `finish_time` DATETIME DEFAULT NULL COMMENT '完成时间', + `is_deleted` TINYINT NOT NULL DEFAULT 0, + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + `delete_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_scope_tid` (`scope`,`tid`), + KEY `idx_biz` (`biz_type`,`biz_id`), + KEY `idx_biz_tid` (`scope`,`biz_tid`,`biz_type`), + KEY `idx_status` (`status`), + KEY `idx_flow` (`flow_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批实例(运行时)'; + +-- ---------------- 6. 审批任务(运行时,节点级待办) ---------------- +CREATE TABLE IF NOT EXISTS `yz_approval_task` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `instance_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '实例ID', + `scope` TINYINT NOT NULL DEFAULT 1 COMMENT '1租户侧 2平台侧', + `tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID', + `biz_type` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '单据类型(冗余,便于列表筛选)', + `biz_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '单据ID(冗余)', + `node_key` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '节点 node_key', + `node_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '节点名称快照', + `sort` INT NOT NULL DEFAULT 0 COMMENT '节点顺序号快照', + `approver_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '审批人ID', + `approver_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '审批人姓名快照', + `multi_mode` TINYINT NOT NULL DEFAULT 1 COMMENT '1或签 2会签', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待审批 1已通过 2已驳回 3已退回 4已跳过 5已超时', + `action` TINYINT NOT NULL DEFAULT 0 COMMENT '1提交 2通过 3驳回 4退回 5撤回 6超时自动通过', + `comment` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '审批意见', + `deadline` DATETIME DEFAULT NULL COMMENT '超时时间点', + `finish_time` DATETIME DEFAULT NULL COMMENT '处理时间', + `is_deleted` TINYINT NOT NULL DEFAULT 0, + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + `delete_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_instance` (`instance_id`), + KEY `idx_approver` (`scope`,`approver_id`,`status`), + KEY `idx_biz` (`biz_type`,`biz_id`), + KEY `idx_tid` (`tid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批任务(运行时·待办)'; + +-- ============================================================= +-- 旧表迁移说明: +-- yz_backend_approval_flows(扁平步骤行结构)仅被 OA 报销 Submit 读取。 +-- 第一期接管报销后改走新引擎,旧表保留只读兼容,不再写入。 +-- 如需保留历史配置,可按如下语句转换(按需执行): +-- +-- INSERT INTO yz_backend_approval_flow +-- (tid, module_code, biz_type, flow_name, is_default, status) +-- SELECT DISTINCT tid, 'oa', 'reimburse', flow_name, 1, 1 +-- FROM yz_backend_approval_flows +-- WHERE is_active = 1 AND is_deleted = 0 +-- GROUP BY tid, flow_name; +-- +-- INSERT INTO yz_backend_approval_node +-- (flow_id, tid, node_key, node_name, node_type, approver_type, approver_ids, timeout_hours, sort) +-- SELECT f.id, f.tid, CONCAT('step_', s.step_order), s.flow_name, 2, 2, '', s.timeout_hours, s.step_order +-- FROM yz_backend_approval_flows s +-- JOIN yz_backend_approval_flow f +-- ON f.tid = s.tid AND f.biz_type = 'reimburse' +-- WHERE s.is_active = 1 AND s.is_deleted = 0; +-- ============================================================= \ No newline at end of file diff --git a/go/docs/sql/platform_approval_menu.sql b/go/docs/sql/platform_approval_menu.sql new file mode 100644 index 0000000..d083f26 --- /dev/null +++ b/go/docs/sql/platform_approval_menu.sql @@ -0,0 +1,38 @@ +-- ============================================================ +-- 审批流配置 —— 平台端菜单(yz_system_menu) +-- 挂在「域名管理」目录(path = /system/domains)下,紧邻「域名审核」。 +-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单。 +-- 执行:mysql -h -P -u -p < platform_approval_menu.sql +-- ============================================================ + +-- 字段说明: +-- type 1=目录 2=页面;views [1]=平台端;is_visible/status=1 显示并启用 +-- path 与 platform 前端静态路由 /system/approval 一致(component 同源,双保险) + +SET @domain_dir := ( + SELECT id FROM yz_system_menu + WHERE path = '/system/domains' AND delete_time IS NULL + LIMIT 1 +); + +INSERT INTO yz_system_menu + (pid, title, path, component_path, icon, sort, status, is_visible, views, type, creater, remark) +SELECT @domain_dir, '审批流配置', '/system/approval', 'system/approval/index.vue', + 'SetUp', 5, 1, 1, '[1]', 2, 'system', '平台侧审批流节点编排(域名申请等管控场景)' +FROM DUAL +WHERE @domain_dir IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/system/approval' AND delete_time IS NULL) x); + +-- 若未找到「域名管理」目录,则退化为平台端顶级菜单(pid=0) +INSERT INTO yz_system_menu + (pid, title, path, component_path, icon, sort, status, is_visible, views, type, creater, remark) +SELECT 0, '审批流配置', '/system/approval', 'system/approval/index.vue', + 'SetUp', 90, 1, 1, '[1]', 2, 'system', '平台侧审批流节点编排(域名申请等管控场景)' +FROM DUAL +WHERE @domain_dir IS NULL + AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/system/approval' AND delete_time IS NULL) x); + +-- 结果校验 +SELECT id, pid, title, path, component_path, views, type, is_visible, status +FROM yz_system_menu +WHERE delete_time IS NULL AND path = '/system/approval'; diff --git a/go/middleware/jwt.go b/go/middleware/jwt.go index 270c434..74d351a 100644 --- a/go/middleware/jwt.go +++ b/go/middleware/jwt.go @@ -25,9 +25,16 @@ const ( ) // protectedPrefixes 需要鉴权的业务前缀。 -// 仅保护业务 API:官网(/ /site/ /index/…)、开放 API(/api/…)、 +// 仅保护业务 API:官网(/ /site/ /index/…)、开放/登录器接口(/api/ 下的公开项)、 // 静态资源与 ACME 验证路径均不在其中,避免误伤公开接口。 -var protectedPrefixes = []string{"/backend/", "/platform/", "/app/"} +// 移动端(uniapp)已并入 /api/,故显式列出其中需要鉴权的移动端前缀, +// 而登录器公开接口(/api/getcard、/api/cursor/*、/api/softwareupgrade/check 等) +// 不在下列前缀内,保持公开。 +var protectedPrefixes = []string{ + "/backend/", "/platform/", + "/api/oa/", "/api/notebook/", "/api/schedule", "/api/currentUser", + "/api/activity/", "/api/home", "/api/quick-menus", +} // authWhitelist 受保护前缀内无需鉴权的路径(前缀匹配) var authWhitelist = []string{ @@ -44,23 +51,25 @@ var authWhitelist = []string{ "/backend/verifyAccount", "/backend/logout", - // platform 登录/找回密码 + // platform 登录/注册/找回密码 "/platform/login", "/platform/sendLoginCode", + "/platform/register", + "/platform/sendRegisterCode", "/platform/resetPassword", "/platform/logout", - // 平台开放接口(客户端更新通知,无需登录) - "/platform/api/", - // app 登录/注册/找回密码 - "/app/login", - "/app/sendLoginCode", - "/app/register", - "/app/sendRegisterCode", - "/app/resetPassword", - "/app/sendResetCode", - "/app/verifyAccount", - "/app/logout", + // 移动端(/api)登录/注册/找回密码(/api/login 前缀同时覆盖 loginBySms、极验配置) + "/api/login", + "/api/sendLoginCode", + "/api/register", + "/api/sendRegisterCode", + "/api/resetPassword", + "/api/sendResetCode", + "/api/verifyAccount", + "/api/logout", + // 登录器日程提醒确认(公开回调) + "/api/schedule/reminder/ack", } var ( diff --git a/go/middleware/operationLog.go b/go/middleware/operationLog.go index 56b25b6..c74a165 100644 --- a/go/middleware/operationLog.go +++ b/go/middleware/operationLog.go @@ -60,16 +60,16 @@ func BeginOperationLog(ctx *context.Context) { go func() { time.Sleep(100 * time.Millisecond) // 等待响应完成 logRow := &models.SystemOperationLog{ - Tid: tid, - UserID: uid, - Module: module, - Action: action, - Method: method, - URL: truncateString(url, 255), - IP: ip, - UserAgent: userAgent, - RequestData: strPtr(reqBody), - Status: 1, + Tid: tid, + UserID: uid, + Module: module, + Action: action, + Method: method, + URL: truncateString(url, 255), + IP: ip, + UserAgent: userAgent, + RequestData: strPtr(reqBody), + Status: 1, ExecutionTime: 0, } _, err := models.Orm.Insert(logRow) @@ -88,20 +88,40 @@ func strPtr(s string) *string { return &s } +// isMobileAppPath 判断是否属于移动端(/api/)功能接口前缀集合。 +// 登录器公开接口(/api/getcard、/api/cursor/*、/api/softwareupgrade/*、 +// /api/schedule/reminder/ack 等)不在此集合内。 +func isMobileAppPath(url string) bool { + mobilePrefixes := []string{ + "/api/oa/", "/api/notebook/", "/api/currentUser", + "/api/activity/", "/api/home", "/api/quick-menus", + } + // /api/schedule 需区分:/api/schedule/reminder/ack 属登录器,排除 + if strings.HasPrefix(url, "/api/schedule") { + return !strings.HasPrefix(url, "/api/schedule/reminder/ack") + } + for _, p := range mobilePrefixes { + if strings.HasPrefix(url, p) { + return true + } + } + return false +} + // WriteDeleteLog 控制器删除后直接写操作日志(带标题) func WriteDeleteLog(userID uint64, tid int, module, title string) { moduleCN := oplogModuleLabel(module) desc := fmt.Sprintf("您删除了%s:【%s】", moduleCN, truncateString(title, 20)) tidVal := uint64(tid) logRow := &models.SystemOperationLog{ - UserID: userID, - Tid: &tidVal, - Module: module, - Action: "删除", - Method: "DELETE", - URL: fmt.Sprintf("/app/%s", module), - Status: 1, - RequestData: &desc, + UserID: userID, + Tid: &tidVal, + Module: module, + Action: "删除", + Method: "DELETE", + URL: fmt.Sprintf("/api/%s", module), + Status: 1, + RequestData: &desc, ExecutionTime: 0, } models.Orm.Insert(logRow) @@ -291,12 +311,8 @@ func shouldSkipLogging(method, url string) bool { if strings.HasPrefix(url, "/api/softwareupgrade/check") { return true } - // App 端读接口跳过(只记录写操作:POST/PUT/DELETE) - if strings.HasPrefix(url, "/app/") && method == "GET" { - return true - } - // App 端 DELETE 由控制器自行记录(需要标题信息) - if strings.HasPrefix(url, "/app/") && method == "DELETE" { + // 移动端(已并入 /api/)读接口跳过(只记录写操作:POST/PUT/DELETE) + if isMobileAppPath(url) { return true } } diff --git a/go/middleware/permission.go b/go/middleware/permission.go index 655aa95..6a375a8 100644 --- a/go/middleware/permission.go +++ b/go/middleware/permission.go @@ -13,7 +13,7 @@ func PermissionMiddleware() func(ctx *context.Context) { return func(ctx *context.Context) { // 获取当前请求的路径 path := ctx.Input.URL() - + // 不需要权限验证的路径列表 publicPaths := []string{ "/api/login", @@ -23,19 +23,19 @@ func PermissionMiddleware() func(ctx *context.Context) { "/api/program-infos/public", "/api/files/public", } - + // 检查是否为公开路径 for _, p := range publicPaths { if path == p { return } } - + // 检查是否为公开预览接口 if strings.HasPrefix(path, "/api/files/public-preview/") { return } - + // 获取用户ID userIdData := ctx.Input.GetData("userId") if userIdData == nil { @@ -43,7 +43,7 @@ func PermissionMiddleware() func(ctx *context.Context) { // 这里直接返回,因为JWT中间件已经拦截了 return } - + userId, ok := userIdData.(int) if !ok { ctx.Output.JSON(map[string]interface{}{ @@ -52,15 +52,15 @@ func PermissionMiddleware() func(ctx *context.Context) { }, false, false) return } - + // 获取当前路由对应的权限标识 permission := getPermissionByPath(path, ctx.Input.Method()) - + // 如果没有权限标识,说明该接口不需要权限控制 if permission == "" { return } - + // 检查用户是否拥有该权限 hasPermission, err := services.CheckUserPermission(userId, permission) if err != nil { @@ -71,7 +71,7 @@ func PermissionMiddleware() func(ctx *context.Context) { }, false, false) return } - + if !hasPermission { ctx.Output.JSON(map[string]interface{}{ "success": false, @@ -90,26 +90,19 @@ func getPermissionByPath(path, method string) string { // 这里只列举了部分示例,实际应该从数据库中加载 permissionMap := map[string]string{ // 用户管理 - "GET:/api/allUsers": "user:list", - "GET:/api/user/:id": "user:detail", - "POST:/api/addUser": "user:add", - "POST:/api/editUser/:id": "user:edit", - "DELETE:/api/deleteUser/:id": "user:delete", - "POST:/api/changePassword/:id":"user:changePassword", - - // 角色管理 - "GET:/api/roles": "role:list", - "POST:/api/roles": "role:create", - "GET:/api/roles/:id": "role:detail", - "POST:/api/roles/:id": "role:update", - "DELETE:/api/roles/:id": "role:delete", - + "GET:/api/allUsers": "user:list", + "GET:/api/user/:id": "user:detail", + "POST:/api/addUser": "user:add", + "POST:/api/editUser/:id": "user:edit", + "DELETE:/api/deleteUser/:id": "user:delete", + "POST:/api/changePassword/:id": "user:changePassword", + // 菜单管理 - "GET:/api/allmenu": "menu:list", - "POST:/api/menu": "menu:create", - "PUT:/api/menu/:id": "menu:update", - "DELETE:/api/menu/:id": "menu:delete", - + "GET:/api/allmenu": "menu:list", + "POST:/api/menu": "menu:create", + "PUT:/api/menu/:id": "menu:update", + "DELETE:/api/menu/:id": "menu:delete", + // 文件管理 "GET:/api/files": "file:list", "POST:/api/files": "file:upload", @@ -121,29 +114,29 @@ func getPermissionByPath(path, method string) string { "DELETE:/api/files/:id": "file:delete", "GET:/api/files/search": "file:search", "GET:/api/files/statistics": "file:statistics", - + // 租户管理 - "GET:/api/tenant/list": "tenant:list", - "POST:/api/tenant": "tenant:create", - "PUT:/api/tenant/:id": "tenant:update", - "DELETE:/api/tenant/:id": "tenant:delete", - "POST:/api/tenant/:id/audit": "tenant:audit", - "GET:/api/tenant/:id": "tenant:detail", - + "GET:/api/tenant/list": "tenant:list", + "POST:/api/tenant": "tenant:create", + "PUT:/api/tenant/:id": "tenant:update", + "DELETE:/api/tenant/:id": "tenant:delete", + "POST:/api/tenant/:id/audit": "tenant:audit", + "GET:/api/tenant/:id": "tenant:detail", + // 知识库 - "GET:/api/knowledge/list": "knowledge:list", - "GET:/api/knowledge/detail": "knowledge:detail", - "POST:/api/knowledge/create": "knowledge:create", - "POST:/api/knowledge/update": "knowledge:update", - "POST:/api/knowledge/delete": "knowledge:delete", + "GET:/api/knowledge/list": "knowledge:list", + "GET:/api/knowledge/detail": "knowledge:detail", + "POST:/api/knowledge/create": "knowledge:create", + "POST:/api/knowledge/update": "knowledge:update", + "POST:/api/knowledge/delete": "knowledge:delete", } - + // 匹配路径(简化版本,不支持动态参数匹配) key := method + ":" + path if perm, ok := permissionMap[key]; ok { return perm } - + // 尝试匹配动态路由(简单的ID参数替换) // 例如:/api/user/123 -> /api/user/:id pathParts := strings.Split(path, "/") @@ -152,19 +145,19 @@ func getPermissionByPath(path, method string) string { if len(parts) != 2 { continue } - + methodPart := parts[0] pathPattern := parts[1] - + if methodPart != method { continue } - + patternParts := strings.Split(pathPattern, "/") if len(patternParts) != len(pathParts) { continue } - + match := true for i, part := range patternParts { if strings.HasPrefix(part, ":") { @@ -176,13 +169,12 @@ func getPermissionByPath(path, method string) string { break } } - + if match { return perm } } - + // 如果没有找到匹配的权限标识,返回空字符串(表示不需要权限控制) return "" } - diff --git a/go/models/approval.go b/go/models/approval.go new file mode 100644 index 0000000..987db66 --- /dev/null +++ b/go/models/approval.go @@ -0,0 +1,246 @@ +package models + +import "time" + +// ============================================================= +// 自定义审批流(配置侧) +// +// 设计要点: +// - 租户侧与平台侧配置分表隔离:平台账号不归属租户,其流程不带 tid; +// - 节点表用 prev_keys / next_keys / condition_expr 承载连线与条件, +// 当期顺序链只按 sort 流转,后续上拖拽图与条件分支无需改表。 +// ============================================================= + +// 审批适用范围:租户侧 / 平台侧 +const ( + ApprovalScopeTenant = 1 // 租户侧(backend / uniapp) + ApprovalScopePlatform = 2 // 平台侧(platform,跨租户管控) +) + +// 节点类型 +const ( + ApprovalNodeStart = 1 // 开始 + ApprovalNodeApproval = 2 // 审批 + ApprovalNodeEnd = 3 // 结束 + ApprovalNodeCondition = 4 // 条件分支(预留) +) + +// 审批人类型 +const ( + ApprovalApproverUser = 1 // 指定用户 + ApprovalApproverRole = 2 // 指定角色 + ApprovalApproverOrgLeader = 3 // 部门主管 + ApprovalApproverPosition = 4 // 指定职位 + ApprovalApproverInitiator = 5 // 发起人本人 + ApprovalApproverInitLeader = 6 // 发起人所在部门主管 +) + +// 多人审批方式 +const ( + ApprovalMultiAny = 1 // 或签:任一通过即通过 + ApprovalMultiAll = 2 // 会签:全部通过才通过 +) + +// 单据类型 +const ( + ApprovalBizReimburse = "reimburse" // OA 报销单 + ApprovalBizBid = "bid" // CRM 投标立项 + ApprovalBizDomain = "domain" // 域名申请(平台侧审批) +) + +// ---------------- 租户侧配置 ---------------- + +// TenantApprovalFlow 租户侧审批流程 yz_backend_approval_flow +type TenantApprovalFlow struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid)" json:"tid"` + ModuleCode string `orm:"column(module_code);size(50);default()" json:"module_code"` + BizType string `orm:"column(biz_type);size(50);default()" json:"biz_type"` + FlowCode string `orm:"column(flow_code);size(64);default()" json:"flow_code"` + FlowName string `orm:"column(flow_name);size(100);default()" json:"flow_name"` + Description string `orm:"column(description);size(500);default()" json:"description"` + IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"` + Status int8 `orm:"column(status);default(1)" json:"status"` + IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *TenantApprovalFlow) TableName() string { + return "yz_backend_approval_flow" +} + +// TenantApprovalNode 租户侧审批节点 yz_backend_approval_node +type TenantApprovalNode struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + FlowID uint64 `orm:"column(flow_id)" json:"flow_id"` + Tid uint64 `orm:"column(tid);default(0)" json:"tid"` + NodeKey string `orm:"column(node_key);size(50);default()" json:"node_key"` + NodeName string `orm:"column(node_name);size(100);default()" json:"node_name"` + NodeType int8 `orm:"column(node_type);default(2)" json:"node_type"` + ApproverType int8 `orm:"column(approver_type);default(1)" json:"approver_type"` + ApproverIDs string `orm:"column(approver_ids);size(500);default()" json:"approver_ids"` + MultiMode int8 `orm:"column(multi_mode);default(1)" json:"multi_mode"` + AllowReturn int8 `orm:"column(allow_return);default(0)" json:"allow_return"` + TimeoutHours int `orm:"column(timeout_hours);default(48)" json:"timeout_hours"` + PrevKeys string `orm:"column(prev_keys);size(500);default()" json:"prev_keys"` + NextKeys string `orm:"column(next_keys);size(500);default()" json:"next_keys"` + ConditionExpr string `orm:"column(condition_expr);size(500);default()" json:"condition_expr"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + PosX int `orm:"column(pos_x);default(0)" json:"pos_x"` + PosY int `orm:"column(pos_y);default(0)" json:"pos_y"` + IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *TenantApprovalNode) TableName() string { + return "yz_backend_approval_node" +} + +// ---------------- 平台侧配置 ---------------- + +// PlatformApprovalFlow 平台侧审批流程 yz_platform_approval_flow +// 平台账号不归属租户,因此无 tid 字段。 +type PlatformApprovalFlow struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + ModuleCode string `orm:"column(module_code);size(50);default()" json:"module_code"` + BizType string `orm:"column(biz_type);size(50);default()" json:"biz_type"` + FlowCode string `orm:"column(flow_code);size(64);default()" json:"flow_code"` + FlowName string `orm:"column(flow_name);size(100);default()" json:"flow_name"` + Description string `orm:"column(description);size(500);default()" json:"description"` + IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"` + Status int8 `orm:"column(status);default(1)" json:"status"` + IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *PlatformApprovalFlow) TableName() string { + return "yz_platform_approval_flow" +} + +// PlatformApprovalNode 平台侧审批节点 yz_platform_approval_node +// approver_ids 存平台主体:user=AdminUser.id / role=AdminRole.id +type PlatformApprovalNode struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + FlowID uint64 `orm:"column(flow_id)" json:"flow_id"` + NodeKey string `orm:"column(node_key);size(50);default()" json:"node_key"` + NodeName string `orm:"column(node_name);size(100);default()" json:"node_name"` + NodeType int8 `orm:"column(node_type);default(2)" json:"node_type"` + ApproverType int8 `orm:"column(approver_type);default(1)" json:"approver_type"` + ApproverIDs string `orm:"column(approver_ids);size(500);default()" json:"approver_ids"` + MultiMode int8 `orm:"column(multi_mode);default(1)" json:"multi_mode"` + AllowReturn int8 `orm:"column(allow_return);default(0)" json:"allow_return"` + TimeoutHours int `orm:"column(timeout_hours);default(48)" json:"timeout_hours"` + PrevKeys string `orm:"column(prev_keys);size(500);default()" json:"prev_keys"` + NextKeys string `orm:"column(next_keys);size(500);default()" json:"next_keys"` + ConditionExpr string `orm:"column(condition_expr);size(500);default()" json:"condition_expr"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + PosX int `orm:"column(pos_x);default(0)" json:"pos_x"` + PosY int `orm:"column(pos_y);default(0)" json:"pos_y"` + IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *PlatformApprovalNode) TableName() string { + return "yz_platform_approval_node" +} + +// ---------------- 运行时(租户侧与平台侧共用) ---------------- + +// 实例状态 +const ( + ApprovalInstanceDraft = 0 // 草稿 + ApprovalInstanceRunning = 1 // 审批中 + ApprovalInstanceApproved = 2 // 已通过 + ApprovalInstanceRejected = 3 // 已驳回 + ApprovalInstanceWithdrawn = 4 // 已撤回 + ApprovalInstanceCanceled = 5 // 已取消 +) + +// 任务状态 +const ( + ApprovalTaskPending = 0 // 待审批 + ApprovalTaskApproved = 1 // 已通过 + ApprovalTaskRejected = 2 // 已驳回 + ApprovalTaskReturned = 3 // 已退回 + ApprovalTaskSkipped = 4 // 已跳过 + ApprovalTaskTimeout = 5 // 已超时 +) + +// 任务动作 +const ( + ApprovalActionSubmit = 1 // 提交 + ApprovalActionApprove = 2 // 通过 + ApprovalActionReject = 3 // 驳回 + ApprovalActionReturn = 4 // 退回 + ApprovalActionWithdraw = 5 // 撤回 + ApprovalActionAutoPass = 6 // 超时自动通过 +) + +// ApprovalInstance 审批实例 yz_approval_instance +// +// scope 区分租户侧/平台侧。平台侧实例的 biz_tid 标记申请来源租户, +// 使平台端能按租户筛选并代租户操作。 +type ApprovalInstance struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Scope int8 `orm:"column(scope);default(1)" json:"scope"` + Tid uint64 `orm:"column(tid);default(0)" json:"tid"` + BizTid uint64 `orm:"column(biz_tid);default(0)" json:"biz_tid"` + FlowID uint64 `orm:"column(flow_id);default(0)" json:"flow_id"` + BizType string `orm:"column(biz_type);size(50);default()" json:"biz_type"` + BizID uint64 `orm:"column(biz_id);default(0)" json:"biz_id"` + BizTitle string `orm:"column(biz_title);size(200);default()" json:"biz_title"` + SubmitUserID uint64 `orm:"column(submit_user_id);default(0)" json:"submit_user_id"` + SubmitUserName string `orm:"column(submit_user_name);size(64);default()" json:"submit_user_name"` + SubmitDeptID uint64 `orm:"column(submit_dept_id);default(0)" json:"submit_dept_id"` + FormData *string `orm:"column(form_data);type(text);null" json:"form_data"` + CurrentNode string `orm:"column(current_node);size(50);default()" json:"current_node"` + Status int8 `orm:"column(status);default(1)" json:"status"` + SubmitTime *time.Time `orm:"column(submit_time);type(datetime);null" json:"submit_time"` + FinishTime *time.Time `orm:"column(finish_time);type(datetime);null" json:"finish_time"` + IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *ApprovalInstance) TableName() string { + return "yz_approval_instance" +} + +// ApprovalTask 审批任务 yz_approval_task +// 节点级待办:一个节点可展开多条任务(会签时每个审批人一条)。 +type ApprovalTask struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + InstanceID uint64 `orm:"column(instance_id)" json:"instance_id"` + Scope int8 `orm:"column(scope);default(1)" json:"scope"` + Tid uint64 `orm:"column(tid);default(0)" json:"tid"` + BizType string `orm:"column(biz_type);size(50);default()" json:"biz_type"` + BizID uint64 `orm:"column(biz_id);default(0)" json:"biz_id"` + NodeKey string `orm:"column(node_key);size(50);default()" json:"node_key"` + NodeName string `orm:"column(node_name);size(100);default()" json:"node_name"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + ApproverID uint64 `orm:"column(approver_id);default(0)" json:"approver_id"` + ApproverName string `orm:"column(approver_name);size(64);default()" json:"approver_name"` + MultiMode int8 `orm:"column(multi_mode);default(1)" json:"multi_mode"` + Status int8 `orm:"column(status);default(0)" json:"status"` + Action int8 `orm:"column(action);default(0)" json:"action"` + Comment string `orm:"column(comment);size(500);default()" json:"comment"` + Deadline *time.Time `orm:"column(deadline);type(datetime);null" json:"deadline"` + FinishTime *time.Time `orm:"column(finish_time);type(datetime);null" json:"finish_time"` + IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *ApprovalTask) TableName() string { + return "yz_approval_task" +} diff --git a/go/models/init.go b/go/models/init.go index f109595..cb5977c 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -50,6 +50,13 @@ func Init(_ string) { new(BackendErpContact), new(BackendApprovalFlow), new(BackendApprovalRecord), + // 自定义审批流(docs/sql/create_approval_flow.sql) + new(TenantApprovalFlow), + new(TenantApprovalNode), + new(PlatformApprovalFlow), + new(PlatformApprovalNode), + new(ApprovalInstance), + new(ApprovalTask), new(BackendReimbursement), new(BackendReimbursementItem), new(BackendReimbursementExpenseType), diff --git a/go/routers/api/api.go b/go/routers/api/api.go index 905b0a7..852e8ea 100644 --- a/go/routers/api/api.go +++ b/go/routers/api/api.go @@ -6,40 +6,106 @@ import ( beego "github.com/beego/beego/v2/server/web" ) -// Register 注册移动端 / 开放 API(api)路由。 +// Register 注册移动端 / 客户端开放 API(api)路由。 +// 说明:/api 统一承载「所有移动端接口」(uniapp)与「登录器等客户端开放接口」, +// 与 /platform、/backend、/index、/auth 各端完全隔离,不复用其它端的功能接口。 +// +// 注意:字面量路由必须注册在 :id 通配之前(beego 按注册顺序匹配)。 func Register() { - // 平台更新通知列表(无需登录) - beego.Router("/platform/api/upgrade/list", &controllers.ApiPlatformUpgradeController{}, "get:List") - - // 平台更新通知详情(无需登录) - beego.Router("/platform/api/upgrade/detail", &controllers.ApiPlatformUpgradeController{}, "get:Detail") - - // 客户端检查更新(无需登录) + // ===== 客户端登录器 / 开放接口(无需登录)===== + // 客户端检查更新 beego.Router("/api/softwareupgrade/check", &controllers.ApiSoftwareUpgradeController{}, "get:Check") - // 登录器上报 Cursor 设备信息(无需登录) + // 登录器上报 Cursor 设备信息 beego.Router("/api/cursor/equipment/report", &controllers.ApiCursorEquipmentController{}, "post:Report") - - // 登录器使用激活码激活/续期 Cursor 设备(无需登录) + // 登录器使用激活码激活/续期 Cursor 设备 beego.Router("/api/cursor/equipment/activateByCode", &controllers.ApiCursorEquipmentController{}, "post:ActivateByCode") - - // 登录器心跳接口,用于更新在线状态(无需登录) + // 登录器心跳接口,用于更新在线状态 beego.Router("/api/cursor/equipment/heartbeat", &controllers.ApiCursorEquipmentController{}, "post:Heartbeat") - // Cursor Token 顺序读取/检测接口(无需登录,peek 不改变号池状态) + // Cursor Token 顺序读取/检测接口(peek 不改变号池状态) // GET /api/cursor/token/peek?id=11&data_type=tk beego.Router("/api/cursor/token/peek", &controllers.ApiCursorDetectController{}, "get:PeekToken") - - // Cursor Token 可用状态标记接口(无需登录) + // Cursor Token 可用状态标记接口 // POST /api/cursor/token/available?id=11 // POST /api/cursor/token/unavailable?id=11 beego.Router("/api/cursor/token/available", &controllers.ApiCursorDetectController{}, "post:MarkTokenAvailable") beego.Router("/api/cursor/token/unavailable", &controllers.ApiCursorDetectController{}, "post:MarkTokenUnavailable") - // 对外提卡接口(无需登录) + // 对外提卡接口 // GET /api/getcard?type=xianyu&module=cursor&data_type=tk beego.Router("/api/getcard", &controllers.ApiGetCardController{}, "get:GetCard") - // 日程提醒确认接口(无需登录) + // 日程提醒确认接口 beego.Router("/api/schedule/reminder/ack", &controllers.ApiReminderController{}, "get:AckReminder") + + // ===== 移动端(uniapp)登录相关(AppAuthController)===== + beego.Router("/api/login", &controllers.AppAuthController{}, "post:LoginBackend") + beego.Router("/api/sendLoginCode", &controllers.AppAuthController{}, "post:SendLoginCode") + beego.Router("/api/loginBySms", &controllers.AppAuthController{}, "post:LoginBySms") + beego.Router("/api/logout", &controllers.AppAuthController{}, "post:Logout") + + // 当前用户信息 + beego.Router("/api/currentUser", &controllers.AppAuthController{}, "get:GetCurrentUser") + + // 极验与登录验证配置 + beego.Router("/api/login/getGeetest3Infos", &controllers.AppAuthController{}, "get:GetGeetest3Infos") + beego.Router("/api/login/getGeetest4Infos", &controllers.AppAuthController{}, "get:GetGeetest4Infos") + beego.Router("/api/login/getOpenVerify", &controllers.AppAuthController{}, "get:GetOpenVerify") + + // 找回密码(三步流程) + beego.Router("/api/verifyAccount", &controllers.AppAuthController{}, "post:VerifyAccount") + beego.Router("/api/sendResetCode", &controllers.AppAuthController{}, "post:SendResetCode") + beego.Router("/api/resetPassword", &controllers.AppAuthController{}, "post:ResetPassword") + + // 注册 + beego.Router("/api/register", &controllers.AppAuthController{}, "post:Register") + beego.Router("/api/sendRegisterCode", &controllers.AppAuthController{}, "post:SendRegisterCode") + + // 日程提醒 + beego.Router("/api/schedule/list", &controllers.AppReminderController{}, "get:GetList") + beego.Router("/api/schedule", &controllers.AppReminderController{}, "post:Create") + beego.Router("/api/schedule/:id", &controllers.AppReminderController{}, "get:GetDetail;put:Update;delete:Delete") + beego.Router("/api/schedule/:id/toggle", &controllers.AppReminderController{}, "post:ToggleComplete") + + // 记事本 + beego.Router("/api/notebook/list", &controllers.AppNotebookController{}, "get:GetList") + beego.Router("/api/notebook", &controllers.AppNotebookController{}, "post:Create") + beego.Router("/api/notebook/:id", &controllers.AppNotebookController{}, "get:GetDetail;put:Update;delete:Delete") + beego.Router("/api/notebook/:id/togglePin", &controllers.AppNotebookController{}, "post:TogglePin") + + // OA 协同办公(移动端只读:通知公告 / 文档库 / 薪酬工资单 / 员工档案) + beego.Router("/api/oa/notice/list", &controllers.AppOaController{}, "get:NoticeList") + beego.Router("/api/oa/notice/:id", &controllers.AppOaController{}, "get:NoticeDetail") + + beego.Router("/api/oa/document/categories", &controllers.AppOaController{}, "get:DocCategories") + beego.Router("/api/oa/document/list", &controllers.AppOaController{}, "get:DocList") + beego.Router("/api/oa/document/download/:id", &controllers.AppOaController{}, "get:DocDownload") + beego.Router("/api/oa/document/:id/star", &controllers.AppOaController{}, "post:DocStar") + beego.Router("/api/oa/document/:id", &controllers.AppOaController{}, "get:DocDetail") + + beego.Router("/api/oa/compensation/payrolls", &controllers.AppOaController{}, "get:PayrollList") + beego.Router("/api/oa/compensation/payrolls/:id", &controllers.AppOaController{}, "get:PayrollDetail") + + beego.Router("/api/oa/employee-file", &controllers.AppOaController{}, "get:MyEmployeeFile") + // 员工档案 HR 视角:access 判定是否可看全公司档案,list 全公司档案列表,detail 档案详情 + beego.Router("/api/oa/employee-file/access", &controllers.AppOaController{}, "get:EmployeeFileAccess") + beego.Router("/api/oa/employee-file/list", &controllers.AppOaController{}, "get:EmployeeFileList") + beego.Router("/api/oa/employee-file/detail/:id", &controllers.AppOaController{}, "get:EmployeeFileDetail") + + // OA 工作台仪表盘(移动端首屏顶部聚合:待办/报销统计 + 通知公告 + 今日日程) + beego.Router("/api/oa/dashboard", &controllers.AppOaController{}, "get:Dashboard") + + // 通讯录(移动端只读:列表 / 组织树 / 详情) + beego.Router("/api/oa/contact/list", &controllers.AppOaController{}, "get:ContactList") + beego.Router("/api/oa/contact/org-tree", &controllers.AppOaController{}, "get:ContactOrgTree") + beego.Router("/api/oa/contact/:id", &controllers.AppOaController{}, "get:ContactDetail") + + // 活动日志 + beego.Router("/api/activity/list", &controllers.AppActivityController{}, "get:GetList") + + // 工作台聚合(ERP/OA/CRM/CMS 应用门户首屏数据) + beego.Router("/api/home", &controllers.AppAggregateController{}, "get:Home") + // 移动端用户自定义「常用功能」保存(读取随 /api/home 下发 quick_menu_ids) + beego.Router("/api/quick-menus", &controllers.AppAggregateController{}, "post:SaveQuickMenus") } diff --git a/go/routers/app/app.go b/go/routers/app/app.go deleted file mode 100644 index 5a5dff6..0000000 --- a/go/routers/app/app.go +++ /dev/null @@ -1,78 +0,0 @@ -package app - -import ( - "server/controllers" - - beego "github.com/beego/beego/v2/server/web" -) - -// Register 注册移动端(app)路由。 -func Register() { - // 登录相关(使用 AppAuthController) - beego.Router("/app/login", &controllers.AppAuthController{}, "post:LoginBackend") - beego.Router("/app/sendLoginCode", &controllers.AppAuthController{}, "post:SendLoginCode") - beego.Router("/app/loginBySms", &controllers.AppAuthController{}, "post:LoginBySms") - beego.Router("/app/logout", &controllers.AppAuthController{}, "post:Logout") - - // 当前用户信息 - beego.Router("/app/currentUser", &controllers.AppAuthController{}, "get:GetCurrentUser") - - // 极验与登录验证配置 - beego.Router("/app/login/getGeetest3Infos", &controllers.AppAuthController{}, "get:GetGeetest3Infos") - beego.Router("/app/login/getGeetest4Infos", &controllers.AppAuthController{}, "get:GetGeetest4Infos") - beego.Router("/app/login/getOpenVerify", &controllers.AppAuthController{}, "get:GetOpenVerify") - - // 找回密码(三步流程) - beego.Router("/app/verifyAccount", &controllers.AppAuthController{}, "post:VerifyAccount") - beego.Router("/app/sendResetCode", &controllers.AppAuthController{}, "post:SendResetCode") - beego.Router("/app/resetPassword", &controllers.AppAuthController{}, "post:ResetPassword") - - // 注册 - beego.Router("/app/register", &controllers.AppAuthController{}, "post:Register") - beego.Router("/app/sendRegisterCode", &controllers.AppAuthController{}, "post:SendRegisterCode") - - // 日程提醒 - beego.Router("/app/schedule/list", &controllers.AppReminderController{}, "get:GetList") - beego.Router("/app/schedule", &controllers.AppReminderController{}, "post:Create") - beego.Router("/app/schedule/:id", &controllers.AppReminderController{}, "get:GetDetail;put:Update;delete:Delete") - beego.Router("/app/schedule/:id/toggle", &controllers.AppReminderController{}, "post:ToggleComplete") - - // 记事本 - beego.Router("/app/notebook/list", &controllers.AppNotebookController{}, "get:GetList") - beego.Router("/app/notebook", &controllers.AppNotebookController{}, "post:Create") - beego.Router("/app/notebook/:id", &controllers.AppNotebookController{}, "get:GetDetail;put:Update;delete:Delete") - beego.Router("/app/notebook/:id/togglePin", &controllers.AppNotebookController{}, "post:TogglePin") - - // OA 协同办公(移动端只读:通知公告 / 文档库 / 薪酬工资单 / 员工档案) - // 注意:字面量路由必须注册在 :id 通配之前,beego 按注册顺序匹配。 - beego.Router("/app/oa/notice/list", &controllers.AppOaController{}, "get:NoticeList") - beego.Router("/app/oa/notice/:id", &controllers.AppOaController{}, "get:NoticeDetail") - - beego.Router("/app/oa/document/categories", &controllers.AppOaController{}, "get:DocCategories") - beego.Router("/app/oa/document/list", &controllers.AppOaController{}, "get:DocList") - beego.Router("/app/oa/document/download/:id", &controllers.AppOaController{}, "get:DocDownload") - beego.Router("/app/oa/document/:id/star", &controllers.AppOaController{}, "post:DocStar") - beego.Router("/app/oa/document/:id", &controllers.AppOaController{}, "get:DocDetail") - - beego.Router("/app/oa/compensation/payrolls", &controllers.AppOaController{}, "get:PayrollList") - beego.Router("/app/oa/compensation/payrolls/:id", &controllers.AppOaController{}, "get:PayrollDetail") - - beego.Router("/app/oa/employee-file", &controllers.AppOaController{}, "get:MyEmployeeFile") - - // OA 工作台仪表盘(移动端首屏顶部聚合:待办/报销统计 + 通知公告 + 今日日程) - beego.Router("/app/oa/dashboard", &controllers.AppOaController{}, "get:Dashboard") - - // 通讯录(移动端只读:列表 / 组织树 / 详情) - // 同样:字面量路由必须排在 :id 通配之前。 - beego.Router("/app/oa/contact/list", &controllers.AppOaController{}, "get:ContactList") - beego.Router("/app/oa/contact/org-tree", &controllers.AppOaController{}, "get:ContactOrgTree") - beego.Router("/app/oa/contact/:id", &controllers.AppOaController{}, "get:ContactDetail") - - // 活动日志 - beego.Router("/app/activity/list", &controllers.AppActivityController{}, "get:GetList") - - // 工作台聚合(ERP/OA/CRM/CMS 应用门户首屏数据) - beego.Router("/app/home", &controllers.AppAggregateController{}, "get:Home") - // 移动端用户自定义「常用功能」保存(读取随 /app/home 下发 quick_menu_ids) - beego.Router("/app/quick-menus", &controllers.AppAggregateController{}, "post:SaveQuickMenus") -} diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go index 81aa799..9d04594 100644 --- a/go/routers/backend/backend.go +++ b/go/routers/backend/backend.go @@ -15,6 +15,18 @@ func Register() { RegisterProductRoutes() RegisterWechatMpRoutes() RegisterDictRoutes() + RegisterApprovalRoutes() +} + +// RegisterApprovalRoutes 注册租户端「审批流配置」路由。 +// 数据按登录租户隔离,审批人取本租户用户/角色/部门/职位。 +func RegisterApprovalRoutes() { + beego.Router("/backend/approval/options", &controllers.BackendApprovalController{}, "get:Options") + beego.Router("/backend/approval/flow/list", &controllers.BackendApprovalController{}, "get:FlowList") + beego.Router("/backend/approval/flow/detail/:id", &controllers.BackendApprovalController{}, "get:FlowDetail") + beego.Router("/backend/approval/flow/save", &controllers.BackendApprovalController{}, "post:FlowSave") + beego.Router("/backend/approval/flow/delete/:id", &controllers.BackendApprovalController{}, "delete:FlowDelete") + beego.Router("/backend/approval/flow/pick", &controllers.BackendApprovalController{}, "get:Pick") } // RegisterProductRoutes 注册租户端「产品购买」路由。 @@ -228,6 +240,7 @@ func RegisterAuthRoutes() { beego.Router("/backend/friendlinks/:id", &controllers.BackendFriendlinkController{}, "put:Update;delete:Delete") beego.Router("/backend/allonepages", &controllers.BackendOnepageController{}, "get:ListAll") + beego.Router("/backend/onepage/path/:path", &controllers.BackendOnepageController{}, "get:GetOnePageByPath") beego.Router("/backend/createonepage", &controllers.BackendOnepageController{}, "post:Create") beego.Router("/backend/editonepage/:id", &controllers.BackendOnepageController{}, "post:Update") beego.Router("/backend/deleteonepage/:id", &controllers.BackendOnepageController{}, "delete:Delete") @@ -251,7 +264,6 @@ func RegisterAuthRoutes() { beego.Router("/backend/domain/tenant/bindCustom", &controllers.BackendTenantDomainController{}, "post:BindCustom") beego.Router("/backend/domain/tenant/checkCustomDns", &controllers.BackendTenantDomainController{}, "post:CheckCustomDns") beego.Router("/backend/domain/tenant/customDomainGuide", &controllers.BackendTenantDomainController{}, "get:CustomDomainGuide") - beego.Router("/backend/domain/tenant/audit", &controllers.BackendTenantDomainController{}, "post:Audit") beego.Router("/backend/domain/tenant/toggleStatus", &controllers.BackendTenantDomainController{}, "post:ToggleStatus") beego.Router("/backend/domain/tenant/delete/:id", &controllers.BackendTenantDomainController{}, "delete:Delete") diff --git a/go/routers/index/index.go b/go/routers/index/index.go index c62f8d6..b7ec31f 100644 --- a/go/routers/index/index.go +++ b/go/routers/index/index.go @@ -20,6 +20,9 @@ func Register() { // 官网页头导航菜单(平台「官网管理 - 导航菜单」配置,仅返回启用且显示的项) beego.Router("/site/nav", &controllers.IndexWebsiteController{}, "get:Nav") + // 官网端平台更新公告(公开读取,与平台端 /platform/upgrade 各自独立) + beego.Router("/site/upgrade/list", &controllers.IndexUpgradeNoticeController{}, "get:List") + // SPA 官网内容接口(读取平台官网管理 yz_platform_website_*,仅返回已发布内容) // 注意:静态段必须在 :id 之前注册,保证 /site/news/categories 不被当作详情 beego.Router("/site/news", &controllers.IndexWebsiteController{}, "get:News") diff --git a/go/routers/platform/platform.go b/go/routers/platform/platform.go index a50edde..4e1ecdf 100644 --- a/go/routers/platform/platform.go +++ b/go/routers/platform/platform.go @@ -64,6 +64,10 @@ func Register() { beego.Router("/platform/resetPassword", &controllers.PlatformAuthController{}, "post:ResetPassword") beego.Router("/platform/sendResetCode", &controllers.PlatformAuthController{}, "post:SendResetCode") + // 租户自助注册(平台端独立接口,与后端 /backend 不复用) + beego.Router("/platform/register", &controllers.PlatformAuthController{}, "post:Register") + beego.Router("/platform/sendRegisterCode", &controllers.PlatformAuthController{}, "post:SendRegisterCode") + // 平台菜单配置相关 beego.Router("/platform/menu/:id", &controllers.AdminMenuController{}, "get:GetMenu") beego.Router("/platform/allmenu", &controllers.AdminMenuController{}, "get:GetAllMenus") @@ -162,9 +166,19 @@ func Register() { beego.Router("/platform/domain/tenant/checkCustomDns", &controllers.PlatformTenantDomainController{}, "post:CheckCustomDns") beego.Router("/platform/domain/tenant/customDomainGuide", &controllers.PlatformTenantDomainController{}, "get:CustomDomainGuide") beego.Router("/platform/domain/tenant/audit", &controllers.PlatformTenantDomainController{}, "post:Audit") + beego.Router("/platform/domain/tenant/audit/progress/:id", &controllers.PlatformTenantDomainController{}, "get:AuditProgress") beego.Router("/platform/domain/tenant/toggleStatus", &controllers.PlatformTenantDomainController{}, "post:ToggleStatus") beego.Router("/platform/domain/tenant/delete/:id", &controllers.PlatformTenantDomainController{}, "delete:Delete") + // 审批流配置(平台侧:域名申请等管控场景) + beego.Router("/platform/approval/flow/list", &controllers.PlatformApprovalController{}, "get:FlowList") + beego.Router("/platform/approval/flow/detail/:id", &controllers.PlatformApprovalController{}, "get:FlowDetail") + beego.Router("/platform/approval/flow/save", &controllers.PlatformApprovalController{}, "post:FlowSave") + beego.Router("/platform/approval/flow/delete/:id", &controllers.PlatformApprovalController{}, "delete:FlowDelete") + beego.Router("/platform/approval/options", &controllers.PlatformApprovalController{}, "get:Options") + beego.Router("/platform/approval/biz-types", &controllers.PlatformApprovalController{}, "get:BizTypes") + beego.Router("/platform/approval/flow/pick", &controllers.PlatformApprovalController{}, "get:Pick") + // 模块管理(yz_system_modules) beego.Router("/platform/modules/list", &controllers.PlatformModulesController{}, "get:GetList") beego.Router("/platform/modules/getTenantList", &controllers.PlatformModulesController{}, "get:GetTenantList") @@ -193,6 +207,10 @@ func Register() { beego.Router("/platform/softwareupgrade", &controllers.PlatformSoftwareUpgradeController{}, "post:Create") beego.Router("/platform/softwareupgrade/:id", &controllers.PlatformSoftwareUpgradeController{}, "get:Detail;post:Update;delete:Delete") + // 平台更新公告(yz_platform_upgrade_notice,平台端独立接口) + beego.Router("/platform/upgrade/list", &controllers.PlatformUpgradeNoticeController{}, "get:List") + beego.Router("/platform/upgrade/detail", &controllers.PlatformUpgradeNoticeController{}, "get:Detail") + // 租户站点设置(yz_tenant_site_setting) beego.Router("/platform/normalInfos", &controllers.PlatformSiteSettingsController{}, "get:GetNormalInfos") beego.Router("/platform/saveNormalInfos", &controllers.PlatformSiteSettingsController{}, "post:SaveNormalInfos") @@ -426,6 +444,7 @@ func Register() { // 单页管理 yz_platform_website_page beego.Router("/platform/website/page/all", &controllers.PlatformWebsitePageController{}, "get:All") + beego.Router("/platform/website/page/path/:path", &controllers.PlatformWebsitePageController{}, "get:GetByPath") beego.Router("/platform/website/page", &controllers.PlatformWebsitePageController{}, "post:Create") beego.Router("/platform/website/page/:id", &controllers.PlatformWebsitePageController{}, "get:Get;post:Update;delete:Delete") diff --git a/go/routers/router.go b/go/routers/router.go index 6bc844a..93c16c8 100644 --- a/go/routers/router.go +++ b/go/routers/router.go @@ -6,7 +6,6 @@ import ( "server/controllers" "server/middleware" "server/routers/api" - "server/routers/app" authrouter "server/routers/auth" "server/routers/backend" "server/routers/index" @@ -67,28 +66,25 @@ func init() { switch mode { case "platform": platform.Register() - app.Register() + api.Register() // 在 platform 模式下,仍保留 backend 登录相关路由,避免后台登录 404 backend.RegisterAuthRoutes() case "backend": backend.Register() - app.Register() + api.Register() case "index": index.Register() - app.Register() + api.Register() case "api": api.Register() - case "app": - app.Register() case "all": platform.Register() backend.Register() index.Register() api.Register() - app.Register() default: // 未知模式时,退回到只启用平台端,避免启动失败 platform.Register() - app.Register() + api.Register() } } diff --git a/go/services/approval/engine.go b/go/services/approval/engine.go new file mode 100644 index 0000000..bc178f3 --- /dev/null +++ b/go/services/approval/engine.go @@ -0,0 +1,1133 @@ +// Package approval 提供自定义审批流的运行时引擎。 +// +// 设计说明: +// - 配置侧(flow/node)与运行时(instance/task)分离:配置由租户/平台编辑, +// 运行时实例创建后不可编辑,节点信息以任务表快照为准,避免改配置影响在途单据; +// - 租户侧与平台侧共用本引擎,通过 scope 区分数据来源: +// 租户侧审批人取租户用户/租户角色/组织主管/职位, +// 平台侧审批人取平台用户/平台角色(平台账号不归属租户); +// - 当期只实现顺序链流转(按 sort 升序),prev_keys / next_keys / condition_expr +// 为条件分支预留,后续扩展无需改表。 +package approval + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "server/models" + + "github.com/beego/beego/v2/client/orm" +) + +// Approver 展开后的单个审批人 +type Approver struct { + ID uint64 + Name string +} + +// SubmitInput 提交审批入参 +type SubmitInput struct { + Scope int8 // 1租户侧 2平台侧 + Tid uint64 // 租户ID(租户侧=本租户) + BizTid uint64 // 业务来源租户(平台侧必填) + BizType string + FlowCode string // 按审批流编号发起(优先于 BizType 默认流) + BizID uint64 + BizTitle string + SubmitUID uint64 // 发起人ID(租户侧为 uid,平台侧为 AdminUser.id) + SubmitName string + DeptID uint64 // 发起人部门 + FlowID uint64 // 指定流程;0 表示按 FlowCode/BizType 取流程 + FormData map[string]interface{} // 发起表单数据,供条件分支求值 +} + +// Node 流程节点(两侧统一结构,承载图连线) +type Node struct { + ID uint64 + NodeKey string + NodeName string + NodeType int8 + ApproverType int8 + ApproverIDs string + MultiMode int8 + AllowReturn int8 + TimeoutHours int + Sort int + PrevKeys string // 前置节点 node_key 列表(逗号分隔) + NextKeys string // 后继节点 node_key 列表(逗号分隔) + ConditionExpr string // 作为条件网关某出边目标时的进入条件 +} + +// Flow 流程定义(两侧统一结构,含图索引) +type Flow struct { + ID uint64 + BizType string + FlowName string + IsDefault int8 + Status int8 + Nodes []Node + ByKey map[string]*Node + StartKey string +} + +// ErrNoFlow 表示未找到可用流程 +type ErrNoFlow struct { + BizType string +} + +func (e *ErrNoFlow) Error() string { + return "未找到适用于该单据的审批流程" +} + +// ErrNoApprover 表示节点未能解析出任何审批人 +type ErrNoApprover struct { + NodeName string + NodeKey string +} + +func (e *ErrNoApprover) Error() string { + return fmt.Sprintf("节点「%s」未解析到有效审批人,请检查审批人配置", e.NodeName) +} + +// ============================ 配置读取 ============================ + +// tenantFlows 查询租户侧流程列表(可按 biz_type 过滤) +func tenantFlows(tid uint64, bizType string) ([]models.TenantApprovalFlow, error) { + qs := models.Orm.QueryTable(new(models.TenantApprovalFlow)). + Filter("tid", tid). + Filter("is_deleted", 0) + if bizType != "" { + qs = qs.Filter("biz_type", bizType) + } + var rows []models.TenantApprovalFlow + _, err := qs.OrderBy("-id").All(&rows) + return rows, err +} + +// tenantFlowNodes 查询租户侧流程的全部节点(含开始/审批/条件/结束,携带图连线) +func tenantFlowNodes(flowID uint64) ([]Node, error) { + var rows []models.TenantApprovalNode + if _, err := models.Orm.QueryTable(new(models.TenantApprovalNode)). + Filter("flow_id", flowID). + Filter("is_deleted", 0). + OrderBy("sort", "id"). + All(&rows); err != nil && err != orm.ErrNoRows { + return nil, err + } + out := make([]Node, 0, len(rows)) + for _, r := range rows { + out = append(out, Node{ + ID: r.ID, + NodeKey: r.NodeKey, + NodeName: r.NodeName, + NodeType: r.NodeType, + ApproverType: r.ApproverType, + ApproverIDs: r.ApproverIDs, + MultiMode: r.MultiMode, + AllowReturn: r.AllowReturn, + TimeoutHours: r.TimeoutHours, + Sort: r.Sort, + PrevKeys: r.PrevKeys, + NextKeys: r.NextKeys, + ConditionExpr: r.ConditionExpr, + }) + } + return out, nil +} + +// platformFlows 查询平台侧流程列表 +func platformFlows(bizType string) ([]models.PlatformApprovalFlow, error) { + qs := models.Orm.QueryTable(new(models.PlatformApprovalFlow)).Filter("is_deleted", 0) + if bizType != "" { + qs = qs.Filter("biz_type", bizType) + } + var rows []models.PlatformApprovalFlow + _, err := qs.OrderBy("-id").All(&rows) + return rows, err +} + +// platformFlowNodes 查询平台侧流程的全部节点(含图连线) +func platformFlowNodes(flowID uint64) ([]Node, error) { + var rows []models.PlatformApprovalNode + if _, err := models.Orm.QueryTable(new(models.PlatformApprovalNode)). + Filter("flow_id", flowID). + Filter("is_deleted", 0). + OrderBy("sort", "id"). + All(&rows); err != nil && err != orm.ErrNoRows { + return nil, err + } + out := make([]Node, 0, len(rows)) + for _, r := range rows { + out = append(out, Node{ + ID: r.ID, + NodeKey: r.NodeKey, + NodeName: r.NodeName, + NodeType: r.NodeType, + ApproverType: r.ApproverType, + ApproverIDs: r.ApproverIDs, + MultiMode: r.MultiMode, + AllowReturn: r.AllowReturn, + TimeoutHours: r.TimeoutHours, + Sort: r.Sort, + PrevKeys: r.PrevKeys, + NextKeys: r.NextKeys, + ConditionExpr: r.ConditionExpr, + }) + } + return out, nil +} + +// LoadFlow 按 scope 加载流程(flowID=0 时取该单据类型的默认启用流程) +func LoadFlow(scope int8, tid uint64, bizType string, flowID uint64) (*Flow, error) { + if scope == models.ApprovalScopePlatform { + var candidates []models.PlatformApprovalFlow + var err error + if flowID > 0 { + var one models.PlatformApprovalFlow + if e := models.Orm.QueryTable(new(models.PlatformApprovalFlow)). + Filter("id", flowID).Filter("is_deleted", 0).One(&one); e != nil { + return nil, &ErrNoFlow{BizType: bizType} + } + candidates = []models.PlatformApprovalFlow{one} + } else { + candidates, err = platformFlows(bizType) + } + if err != nil || len(candidates) == 0 { + return nil, &ErrNoFlow{BizType: bizType} + } + return buildPlatformFlow(pickPlatformFlow(candidates)) + } + + var candidates []models.TenantApprovalFlow + var err error + if flowID > 0 { + var one models.TenantApprovalFlow + if e := models.Orm.QueryTable(new(models.TenantApprovalFlow)). + Filter("id", flowID).Filter("tid", tid).Filter("is_deleted", 0).One(&one); e != nil { + return nil, &ErrNoFlow{BizType: bizType} + } + candidates = []models.TenantApprovalFlow{one} + } else { + candidates, err = tenantFlows(tid, bizType) + } + if err != nil || len(candidates) == 0 { + return nil, &ErrNoFlow{BizType: bizType} + } + return buildTenantFlow(pickTenantFlow(candidates)) +} + +// LoadFlowByCode 按审批流编号加载流程(供各程序按编号发起) +func LoadFlowByCode(scope int8, tid uint64, code string) (*Flow, error) { + code = strings.TrimSpace(code) + if code == "" { + return nil, &ErrNoFlow{BizType: code} + } + if scope == models.ApprovalScopePlatform { + var one models.PlatformApprovalFlow + if err := models.Orm.QueryTable(new(models.PlatformApprovalFlow)). + Filter("flow_code", code).Filter("is_deleted", 0).One(&one); err != nil { + return nil, &ErrNoFlow{BizType: code} + } + return buildPlatformFlow(one) + } + var one models.TenantApprovalFlow + if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)). + Filter("flow_code", code).Filter("tid", tid).Filter("is_deleted", 0).One(&one); err != nil { + return nil, &ErrNoFlow{BizType: code} + } + return buildTenantFlow(one) +} + +// buildTenantFlow / buildPlatformFlow 装配租户/平台流程(含图索引) +func buildTenantFlow(f models.TenantApprovalFlow) (*Flow, error) { + nodes, err := tenantFlowNodes(f.ID) + if err != nil { + return nil, err + } + if len(nodes) == 0 { + return nil, &ErrNoFlow{BizType: f.BizType} + } + return assembleFlow(f.ID, f.BizType, f.FlowName, nodes), nil +} + +func buildPlatformFlow(f models.PlatformApprovalFlow) (*Flow, error) { + nodes, err := platformFlowNodes(f.ID) + if err != nil { + return nil, err + } + if len(nodes) == 0 { + return nil, &ErrNoFlow{BizType: f.BizType} + } + return assembleFlow(f.ID, f.BizType, f.FlowName, nodes), nil +} + +// assembleFlow 构建图索引 ByKey 与入口 StartKey(优先开始类型节点,否则取无前置节点) +func assembleFlow(id uint64, bizType, name string, nodes []Node) *Flow { + f := &Flow{ID: id, BizType: bizType, FlowName: name, Nodes: nodes, ByKey: make(map[string]*Node, len(nodes))} + for i := range f.Nodes { + f.ByKey[f.Nodes[i].NodeKey] = &f.Nodes[i] + } + for i := range f.Nodes { + if f.Nodes[i].NodeType == models.ApprovalNodeStart { + f.StartKey = f.Nodes[i].NodeKey + break + } + } + if f.StartKey == "" { + for i := range f.Nodes { + if strings.TrimSpace(f.Nodes[i].PrevKeys) == "" { + f.StartKey = f.Nodes[i].NodeKey + break + } + } + } + if f.StartKey == "" && len(f.Nodes) > 0 { + f.StartKey = f.Nodes[0].NodeKey + } + return f +} + +// pickTenantFlow 在候选流程中择优:优先「默认 + 启用」,其次任一启用,最后首条。 +func pickTenantFlow(rows []models.TenantApprovalFlow) models.TenantApprovalFlow { + for _, r := range rows { + if r.IsDefault == 1 && r.Status == 1 { + return r + } + } + for _, r := range rows { + if r.Status == 1 { + return r + } + } + return rows[0] +} + +// pickPlatformFlow 同 pickTenantFlow,平台侧口径。 +func pickPlatformFlow(rows []models.PlatformApprovalFlow) models.PlatformApprovalFlow { + for _, r := range rows { + if r.IsDefault == 1 && r.Status == 1 { + return r + } + } + for _, r := range rows { + if r.Status == 1 { + return r + } + } + return rows[0] +} + +// ============================ 审批人解析 ============================ + +// parseIDs 解析逗号分隔的 ID 列表 +func parseIDs(raw string) []uint64 { + out := make([]uint64, 0, 4) + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if v, err := strconv.ParseUint(part, 10, 64); err == nil && v > 0 { + out = append(out, v) + } + } + return out +} + +// resolveApprovers 按 scope 解析节点审批人。 +// 发起人相关类型需要 SubmitInput 上下文。 +func resolveApprovers(scope int8, node Node, in SubmitInput) ([]Approver, error) { + ids := parseIDs(node.ApproverIDs) + + switch node.ApproverType { + case models.ApprovalApproverUser: + if scope == models.ApprovalScopePlatform { + return platformUsers(ids), nil + } + return tenantUsers(uint64(in.Tid), ids), nil + + case models.ApprovalApproverRole: + if scope == models.ApprovalScopePlatform { + return platformRoleUsers(ids), nil + } + return tenantRoleUsers(uint64(in.Tid), ids), nil + + case models.ApprovalApproverInitiator: + if in.SubmitUID == 0 { + return nil, nil + } + return []Approver{{ID: in.SubmitUID, Name: in.SubmitName}}, nil + + case models.ApprovalApproverOrgLeader: + // 指定部门的负责人;approver_ids 存组织ID + if len(ids) == 0 { + return nil, nil + } + return orgLeaders(in.Tid, ids), nil + + case models.ApprovalApproverInitLeader: + // 发起人所在部门负责人 + if in.DeptID == 0 { + return nil, nil + } + return orgLeaders(in.Tid, []uint64{in.DeptID}), nil + + case models.ApprovalApproverPosition: + // 指定职位(approver_ids 存 BackendPosition.id),按员工职位名匹配 + if len(ids) == 0 { + return nil, nil + } + return positionUsers(uint64(in.Tid), ids), nil + } + return nil, nil +} + +// tenantUsers 按 uid 取租户用户 +func tenantUsers(tid uint64, uids []uint64) []Approver { + if len(uids) == 0 { + return nil + } + var rows []models.SystemTenantUser + if _, err := models.Orm.QueryTable(new(models.SystemTenantUser)). + Filter("tid", tid). + Filter("uid__in", uids). + Filter("delete_time__isnull", true). + All(&rows); err != nil { + return nil + } + out := make([]Approver, 0, len(rows)) + for _, r := range rows { + out = append(out, Approver{ID: r.Uid, Name: firstNonEmptyPtr(r.Name, r.Account)}) + } + return out +} + +// tenantRoleUsers 按租户角色取全部成员 +func tenantRoleUsers(tid uint64, roleIDs []uint64) []Approver { + if len(roleIDs) == 0 { + return nil + } + var rows []models.SystemTenantUser + if _, err := models.Orm.QueryTable(new(models.SystemTenantUser)). + Filter("tid", tid). + Filter("group_id__in", roleIDs). + Filter("delete_time__isnull", true). + All(&rows); err != nil { + return nil + } + out := make([]Approver, 0, len(rows)) + for _, r := range rows { + out = append(out, Approver{ID: r.Uid, Name: firstNonEmptyPtr(r.Name, r.Account)}) + } + return out +} + +// platformUsers 按 id 取平台用户 +func platformUsers(ids []uint64) []Approver { + if len(ids) == 0 { + return nil + } + var rows []models.AdminUser + if _, err := models.Orm.QueryTable(new(models.AdminUser)). + Filter("id__in", ids). + Filter("delete_time__isnull", true). + All(&rows); err != nil { + return nil + } + out := make([]Approver, 0, len(rows)) + for _, r := range rows { + out = append(out, Approver{ID: r.ID, Name: approverName(r.Name, r.Account)}) + } + return out +} + +// platformRoleUsers 按平台角色取全部成员(AdminRole cid=1 为平台角色) +func platformRoleUsers(roleIDs []uint64) []Approver { + if len(roleIDs) == 0 { + return nil + } + var users []models.AdminUser + if _, err := models.Orm.QueryTable(new(models.AdminUser)). + Filter("role_id__in", roleIDs). + Filter("delete_time__isnull", true). + All(&users); err != nil { + return nil + } + out := make([]Approver, 0, len(users)) + for _, r := range users { + out = append(out, Approver{ID: r.ID, Name: approverName(r.Name, r.Account)}) + } + return out +} + +// orgLeaders 取组织负责人 +func orgLeaders(tid uint64, orgIDs []uint64) []Approver { + var orgs []models.BackendOrganization + if _, err := models.Orm.QueryTable(new(models.BackendOrganization)). + Filter("id__in", orgIDs). + Filter("delete_time__isnull", true). + All(&orgs); err != nil { + return nil + } + // 负责人可能指向员工ID,也可能指向租户用户uid,这里统一再查一次租户用户 + leaderIDs := make([]uint64, 0, len(orgs)) + for _, o := range orgs { + if o.LeaderID != nil && *o.LeaderID > 0 { + leaderIDs = append(leaderIDs, *o.LeaderID) + } + } + if len(leaderIDs) == 0 { + return nil + } + return tenantUsers(tid, leaderIDs) +} + +// positionUsers 按职位取员工:职位ID → 职位名 → 员工(职位字段) → 登录账号 → 租户用户 +func positionUsers(tid uint64, positionIDs []uint64) []Approver { + var positions []models.BackendPosition + if _, err := models.Orm.QueryTable(new(models.BackendPosition)). + Filter("id__in", positionIDs). + Filter("delete_time__isnull", true). + All(&positions); err != nil { + return nil + } + names := make([]string, 0, len(positions)) + for _, p := range positions { + if strings.TrimSpace(p.PositionName) != "" { + names = append(names, p.PositionName) + } + } + if len(names) == 0 { + return nil + } + var employees []models.BackendEmployee + if _, err := models.Orm.QueryTable(new(models.BackendEmployee)). + Filter("tid", tid). + Filter("position__in", names). + Filter("delete_time__isnull", true). + All(&employees); err != nil { + return nil + } + accounts := make([]string, 0, len(employees)) + for _, e := range employees { + if strings.TrimSpace(e.Account) != "" { + accounts = append(accounts, e.Account) + } + } + if len(accounts) == 0 { + return nil + } + var users []models.SystemTenantUser + if _, err := models.Orm.QueryTable(new(models.SystemTenantUser)). + Filter("tid", tid). + Filter("account__in", accounts). + Filter("delete_time__isnull", true). + All(&users); err != nil { + return nil + } + out := make([]Approver, 0, len(users)) + for _, u := range users { + out = append(out, Approver{ID: u.Uid, Name: firstNonEmptyPtr(u.Name, u.Account)}) + } + return out +} + +// approverName 优先取姓名字段,回退到账号(平台侧 Account 为普通字符串) +func approverName(name *string, account string) string { + if s := firstNonEmptyPtr(name); s != "" { + return s + } + return strings.TrimSpace(account) +} + +// firstNonEmptyPtr 取第一个非空字符串指针的值 +func firstNonEmptyPtr(vals ...*string) string { + for _, v := range vals { + if v != nil && strings.TrimSpace(*v) != "" { + return strings.TrimSpace(*v) + } + } + return "" +} + +// dedupeApprovers 去重并保持顺序 +func dedupeApprovers(in []Approver) []Approver { + seen := make(map[uint64]bool, len(in)) + out := make([]Approver, 0, len(in)) + for _, a := range in { + if a.ID == 0 || seen[a.ID] { + continue + } + seen[a.ID] = true + out = append(out, a) + } + return out +} + +// ============================ 运行时:提交 ============================ + +// Submit 提交单据进入审批,创建实例并按图展开首个可执行节点的任务。 +// 优先按 FlowCode 命中流程;无审批节点时直接置为已通过。 +func Submit(in SubmitInput) (uint64, error) { + var ( + flow *Flow + err error + ) + if strings.TrimSpace(in.FlowCode) != "" { + flow, err = LoadFlowByCode(in.Scope, in.Tid, in.FlowCode) + } else { + flow, err = LoadFlow(in.Scope, in.Tid, in.BizType, in.FlowID) + } + if err != nil { + return 0, err + } + + now := time.Now() + inst := models.ApprovalInstance{ + Scope: in.Scope, + Tid: in.Tid, + BizTid: in.BizTid, + FlowID: flow.ID, + BizType: in.BizType, + BizID: in.BizID, + BizTitle: in.BizTitle, + SubmitUserID: in.SubmitUID, + SubmitUserName: in.SubmitName, + SubmitDeptID: in.DeptID, + SubmitTime: &now, + Status: models.ApprovalInstanceRunning, + } + if len(in.FormData) > 0 { + if b, e := json.Marshal(in.FormData); e == nil { + s := string(b) + inst.FormData = &s + } + } + instID, err := models.Orm.Insert(&inst) + if err != nil { + return 0, err + } + + start := flow.ByKey[flow.StartKey] + hasApproval, firstApprovalName, firstApprovalKey := anyApprovalNode(flow) + node, approvers := nextExecutable(flow, in.FormData, start, in) + if node == nil { + // 无可执行审批节点 + if hasApproval { + // 配了审批节点但均无有效审批人:视为配置错误,回滚实例 + _, _ = models.Orm.QueryTable(new(models.ApprovalInstance)). + Filter("id", instID).Update(orm.Params{"is_deleted": 1, "status": models.ApprovalInstanceCanceled}) + return 0, &ErrNoApprover{NodeName: firstApprovalName, NodeKey: firstApprovalKey} + } + finishInstance(uint64(instID), models.ApprovalInstanceApproved) + return uint64(instID), nil + } + + if err := createTasks(uint64(instID), in, *node, approvers); err != nil { + return uint64(instID), err + } + if _, err := models.Orm.QueryTable(new(models.ApprovalInstance)). + Filter("id", instID).Update(orm.Params{"current_node": node.NodeKey}); err != nil { + return uint64(instID), err + } + return uint64(instID), nil +} + +// anyApprovalNode 判断流程是否含审批节点,并返回首个审批节点信息 +func anyApprovalNode(flow *Flow) (bool, string, string) { + for i := range flow.Nodes { + if flow.Nodes[i].NodeType == models.ApprovalNodeApproval { + return true, flow.Nodes[i].NodeName, flow.Nodes[i].NodeKey + } + } + return false, "", "" +} + +// advanceFrom 从 cur 沿出边前进,遇条件网关求值选路,返回首个审批节点(nil 表示到终点) +func advanceFrom(flow *Flow, formData map[string]interface{}, cur *Node) *Node { + visited := make(map[string]bool) + node := pickNext(flow, formData, cur) + for node != nil { + if visited[node.NodeKey] { + return nil // 环保护 + } + visited[node.NodeKey] = true + switch node.NodeType { + case models.ApprovalNodeApproval: + return node + case models.ApprovalNodeEnd: + return nil + default: // start / condition:继续向后 + node = pickNext(flow, formData, node) + } + } + return nil +} + +// pickNext 取节点的下一个目标:普通节点取唯一出边;条件网关按各目标 condition_expr 互斥选路,空条件为默认分支 +func pickNext(flow *Flow, formData map[string]interface{}, node *Node) *Node { + if node == nil { + return nil + } + outs := splitKeys(node.NextKeys) + if len(outs) == 0 { + return nil + } + if node.NodeType != models.ApprovalNodeCondition { + return flow.ByKey[outs[0]] + } + var def *Node + for _, k := range outs { + t := flow.ByKey[k] + if t == nil { + continue + } + if strings.TrimSpace(t.ConditionExpr) == "" { + if def == nil { + def = t + } + continue + } + if evalCondition(t.ConditionExpr, formData) { + return t + } + } + return def +} + +// nextExecutable 沿图找到下一个「有有效审批人」的审批节点;无人审批节点自动跳过 +func nextExecutable(flow *Flow, formData map[string]interface{}, cur *Node, in SubmitInput) (*Node, []Approver) { + node := advanceFrom(flow, formData, cur) + visited := make(map[string]bool) + for node != nil { + if visited[node.NodeKey] { + break + } + visited[node.NodeKey] = true + approvers, err := resolveApprovers(in.Scope, *node, in) + if err == nil { + approvers = dedupeApprovers(approvers) + if len(approvers) > 0 { + return node, approvers + } + } + node = advanceFrom(flow, formData, node) + } + return nil, nil +} + +// splitKeys 拆分逗号分隔的 node_key 列表 +func splitKeys(raw string) []string { + out := make([]string, 0, 4) + for _, p := range strings.Split(raw, ",") { + if s := strings.TrimSpace(p); s != "" { + out = append(out, s) + } + } + return out +} + +// instanceFormData 解析实例的表单快照 +func instanceFormData(inst *models.ApprovalInstance) map[string]interface{} { + if inst == nil || inst.FormData == nil || strings.TrimSpace(*inst.FormData) == "" { + return nil + } + m := make(map[string]interface{}) + if err := json.Unmarshal([]byte(*inst.FormData), &m); err != nil { + return nil + } + return m +} + +// submitFromInstance 由实例重建提交上下文(供推进时解析审批人) +func submitFromInstance(inst *models.ApprovalInstance, formData map[string]interface{}) SubmitInput { + return SubmitInput{ + Scope: inst.Scope, + Tid: inst.Tid, + BizTid: inst.BizTid, + BizType: inst.BizType, + BizID: inst.BizID, + SubmitUID: inst.SubmitUserID, + SubmitName: inst.SubmitUserName, + DeptID: inst.SubmitDeptID, + FormData: formData, + } +} + +// createTasks 为一个节点展开审批任务 +func createTasks(instID uint64, in SubmitInput, node Node, approvers []Approver) error { + timeout := node.TimeoutHours + if timeout <= 0 { + timeout = 48 + } + deadline := time.Now().Add(time.Duration(timeout) * time.Hour) + multiMode := node.MultiMode + if multiMode != models.ApprovalMultiAll { + multiMode = models.ApprovalMultiAny + } + + tasks := make([]models.ApprovalTask, 0, len(approvers)) + for _, a := range approvers { + tasks = append(tasks, models.ApprovalTask{ + InstanceID: instID, + Scope: in.Scope, + Tid: in.Tid, + BizType: in.BizType, + BizID: in.BizID, + NodeKey: node.NodeKey, + NodeName: node.NodeName, + Sort: node.Sort, + ApproverID: a.ID, + ApproverName: a.Name, + MultiMode: multiMode, + Status: models.ApprovalTaskPending, + Deadline: &deadline, + }) + } + _, err := models.Orm.InsertMulti(len(tasks), tasks) + return err +} + +// finishInstance 结束实例 +func finishInstance(instID uint64, status int8) { + now := time.Now() + _, _ = models.Orm.QueryTable(new(models.ApprovalInstance)). + Filter("id", instID).Update(orm.Params{ + "status": status, + "finish_time": now, + "update_time": now, + }) +} + +// ============================ 运行时:审批 ============================ + +// ApproveInput 审批入参 +type ApproveInput struct { + ApproverID uint64 // 审批人ID + Pass bool + Comment string + // BizTid 平台侧操作时按业务租户校验/展示用(可留空) + BizTid uint64 +} + +// ApproveResult 审批结果 +type ApproveResult struct { + InstanceID uint64 + InstanceStat int8 // 实例新状态 + CurrentNode string // 新当前节点(完成时为空) + Finished bool + Message string +} + +// Approve 处理一条审批任务:按或签/会签推进到下一节点或结束实例。 +func Approve(taskID uint64, in ApproveInput) (*ApproveResult, error) { + var task models.ApprovalTask + if err := models.Orm.QueryTable(new(models.ApprovalTask)). + Filter("id", taskID). + Filter("is_deleted", 0). + One(&task); err != nil { + return nil, err + } + if task.Status != models.ApprovalTaskPending { + return nil, fmt.Errorf("该审批任务已处理") + } + if task.ApproverID != in.ApproverID { + return nil, fmt.Errorf("您无权审批该任务") + } + + var inst models.ApprovalInstance + if err := models.Orm.QueryTable(new(models.ApprovalInstance)). + Filter("id", task.InstanceID). + Filter("is_deleted", 0). + One(&inst); err != nil { + return nil, err + } + if inst.Status != models.ApprovalInstanceRunning { + return nil, fmt.Errorf("该单据当前状态不可审批") + } + + now := time.Now() + + // 驳回:整个实例结束(与退回上一节点区分) + if !in.Pass { + _, err := models.Orm.QueryTable(new(models.ApprovalTask)). + Filter("id", taskID). + Update(orm.Params{ + "status": models.ApprovalTaskRejected, + "action": models.ApprovalActionReject, + "comment": in.Comment, + "finish_time": now, + "update_time": now, + }) + if err != nil { + return nil, err + } + // 同节点其余待办一并置为跳过,避免留下永远待办的僵尸任务 + _, _ = models.Orm.QueryTable(new(models.ApprovalTask)). + Filter("instance_id", task.InstanceID). + Filter("node_key", task.NodeKey). + Filter("status", models.ApprovalTaskPending). + Filter("id__neq", taskID). + Update(orm.Params{"status": models.ApprovalTaskSkipped, "update_time": now}) + + finishInstance(task.InstanceID, models.ApprovalInstanceRejected) + return &ApproveResult{ + InstanceID: task.InstanceID, + InstanceStat: models.ApprovalInstanceRejected, + Finished: true, + Message: "已驳回", + }, nil + } + + // 通过:更新当前任务 + _, err := models.Orm.QueryTable(new(models.ApprovalTask)). + Filter("id", taskID). + Update(orm.Params{ + "status": models.ApprovalTaskApproved, + "action": models.ApprovalActionApprove, + "comment": in.Comment, + "finish_time": now, + "update_time": now, + }) + if err != nil { + return nil, err + } + + // 或签:任一通过即推进,会签:需全部通过 + if task.MultiMode == models.ApprovalMultiAny { + _, _ = models.Orm.QueryTable(new(models.ApprovalTask)). + Filter("instance_id", task.InstanceID). + Filter("node_key", task.NodeKey). + Filter("status", models.ApprovalTaskPending). + Update(orm.Params{"status": models.ApprovalTaskSkipped, "update_time": now}) + } else { + remaining, _ := models.Orm.QueryTable(new(models.ApprovalTask)). + Filter("instance_id", task.InstanceID). + Filter("node_key", task.NodeKey). + Filter("status", models.ApprovalTaskPending). + Count() + if remaining > 0 { + return &ApproveResult{ + InstanceID: task.InstanceID, + InstanceStat: models.ApprovalInstanceRunning, + CurrentNode: task.NodeKey, + Message: fmt.Sprintf("已通过,等待其余 %d 位审批人", remaining), + }, nil + } + } + + // 当前节点完成 → 推进下一节点 + return advance(task.InstanceID, task.NodeKey, in) +} + +// advance 沿图推进到下一个可执行审批节点;无后继则结束实例 +func advance(instID uint64, currentKey string, in ApproveInput) (*ApproveResult, error) { + var inst models.ApprovalInstance + if err := models.Orm.QueryTable(new(models.ApprovalInstance)). + Filter("id", instID).Filter("is_deleted", 0).One(&inst); err != nil { + return nil, err + } + + flow, err := LoadFlow(inst.Scope, inst.Tid, inst.BizType, inst.FlowID) + if err != nil { + // 配置被删除时保守处理:按已完成结束 + finishInstance(instID, models.ApprovalInstanceApproved) + return &ApproveResult{ + InstanceID: instID, + InstanceStat: models.ApprovalInstanceApproved, + Finished: true, + Message: "审批已通过", + }, nil + } + + formData := instanceFormData(&inst) + si := submitFromInstance(&inst, formData) + cur := flow.ByKey[currentKey] + node, approvers := nextExecutable(flow, formData, cur, si) + if node == nil { + finishInstance(instID, models.ApprovalInstanceApproved) + return &ApproveResult{ + InstanceID: instID, + InstanceStat: models.ApprovalInstanceApproved, + Finished: true, + Message: "审批已全部通过", + }, nil + } + + if err := createTasks(instID, si, *node, approvers); err != nil { + return nil, err + } + now := time.Now() + if _, err := models.Orm.QueryTable(new(models.ApprovalInstance)). + Filter("id", instID).Update(orm.Params{"current_node": node.NodeKey, "update_time": now}); err != nil { + return nil, err + } + return &ApproveResult{ + InstanceID: instID, + InstanceStat: models.ApprovalInstanceRunning, + CurrentNode: node.NodeKey, + Message: "已流转至「" + node.NodeName + "」", + }, nil +} + +// ============================ 条件表达式求值 ============================ + +// evalCondition 求值条件表达式:支持 == != > >= < <= 与 && ||(不含括号)。空表达式恒真。 +func evalCondition(expr string, data map[string]interface{}) bool { + expr = strings.TrimSpace(expr) + if expr == "" { + return true + } + for _, orPart := range strings.Split(expr, "||") { + if evalAnd(orPart, data) { + return true + } + } + return false +} + +func evalAnd(part string, data map[string]interface{}) bool { + for _, atom := range strings.Split(part, "&&") { + if !evalAtom(atom, data) { + return false + } + } + return true +} + +var condOps = []string{">=", "<=", "!=", "==", ">", "<"} + +func evalAtom(s string, data map[string]interface{}) bool { + s = strings.TrimSpace(s) + if s == "" { + return true + } + for _, op := range condOps { + idx := strings.Index(s, op) + if idx <= 0 { + continue + } + lv := resolveCondVal(strings.TrimSpace(s[:idx]), data) + rv := parseCondLiteral(strings.TrimSpace(s[idx+len(op):])) + return compareCond(lv, op, rv) + } + return false +} + +// resolveCondVal 取字段值:支持点路径 a.b.c +func resolveCondVal(key string, data map[string]interface{}) interface{} { + if data == nil { + return nil + } + if v, ok := data[key]; ok { + return v + } + var cur interface{} = data + for _, seg := range strings.Split(key, ".") { + m, ok := cur.(map[string]interface{}) + if !ok { + return nil + } + v, ok := m[seg] + if !ok { + return nil + } + cur = v + } + return cur +} + +func parseCondLiteral(raw string) interface{} { + raw = strings.TrimSpace(raw) + if len(raw) >= 2 { + if (raw[0] == '"' || raw[0] == '\'') && raw[len(raw)-1] == raw[0] { + return raw[1 : len(raw)-1] + } + } + if n, err := strconv.ParseFloat(raw, 64); err == nil { + return n + } + return raw +} + +func toFloat(v interface{}) (float64, bool) { + switch x := v.(type) { + case float64: + return x, true + case float32: + return float64(x), true + case int: + return float64(x), true + case int64: + return float64(x), true + case uint64: + return float64(x), true + case json.Number: + if f, e := x.Float64(); e == nil { + return f, true + } + case string: + if f, e := strconv.ParseFloat(strings.TrimSpace(x), 64); e == nil { + return f, true + } + } + return 0, false +} + +func compareCond(l interface{}, op string, r interface{}) bool { + lf, lok := toFloat(l) + rf, rok := toFloat(r) + if lok && rok { + switch op { + case ">": + return lf > rf + case "<": + return lf < rf + case ">=": + return lf >= rf + case "<=": + return lf <= rf + case "==": + return lf == rf + case "!=": + return lf != rf + } + } + ls := condString(l) + rs := condString(r) + switch op { + case "==": + return ls == rs + case "!=": + return ls != rs + } + return false +} + +func condString(v interface{}) string { + if v == nil { + return "" + } + switch x := v.(type) { + case string: + return x + case bool: + if x { + return "true" + } + return "false" + case float64: + return strconv.FormatFloat(x, 'f', -1, 64) + default: + return fmt.Sprintf("%v", v) + } +} + +// SortNodes 按 sort 升序排序(导出供配置端校验使用) +func SortNodes(nodes []Node) { + sort.SliceStable(nodes, func(i, j int) bool { return nodes[i].Sort < nodes[j].Sort }) +} diff --git a/platform/components.d.ts b/platform/components.d.ts index 5d2371a..e4a03ad 100644 --- a/platform/components.d.ts +++ b/platform/components.d.ts @@ -62,6 +62,8 @@ declare module 'vue' { ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] ElSelect: typeof import('element-plus/es')['ElSelect'] ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] + ElStep: typeof import('element-plus/es')['ElStep'] + ElSteps: typeof import('element-plus/es')['ElSteps'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] @@ -75,6 +77,7 @@ declare module 'vue' { ElTree: typeof import('element-plus/es')['ElTree'] ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect'] ElUpload: typeof import('element-plus/es')['ElUpload'] + FlowCanvas: typeof import('./src/components/approval/FlowCanvas.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] } diff --git a/platform/package-lock.json b/platform/package-lock.json index ea9fece..007ad64 100644 --- a/platform/package-lock.json +++ b/platform/package-lock.json @@ -18,6 +18,9 @@ "@tiptap/starter-kit": "^3.28.0", "@tiptap/vue-3": "^3.28.0", "@umoteam/editor": "^10.2.1", + "@vue-flow/background": "^1.3.2", + "@vue-flow/controls": "^1.1.3", + "@vue-flow/core": "^1.48.2", "axios": "^1.13.1", "chart": "^0.1.2", "chart.js": "^4.5.1", @@ -2826,6 +2829,42 @@ "vue": "^3.2.25" } }, + "node_modules/@vue-flow/background": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/@vue-flow/background/-/background-1.3.2.tgz", + "integrity": "sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==", + "license": "MIT", + "peerDependencies": { + "@vue-flow/core": "^1.23.0", + "vue": "^3.3.0" + } + }, + "node_modules/@vue-flow/controls": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@vue-flow/controls/-/controls-1.1.3.tgz", + "integrity": "sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==", + "license": "MIT", + "peerDependencies": { + "@vue-flow/core": "^1.23.0", + "vue": "^3.3.0" + } + }, + "node_modules/@vue-flow/core": { + "version": "1.48.2", + "resolved": "https://registry.npmmirror.com/@vue-flow/core/-/core-1.48.2.tgz", + "integrity": "sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==", + "license": "MIT", + "dependencies": { + "@vueuse/core": "^10.5.0", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.29", "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.29.tgz", diff --git a/platform/package.json b/platform/package.json index 1dfa4e5..96713b7 100644 --- a/platform/package.json +++ b/platform/package.json @@ -20,6 +20,9 @@ "@tiptap/starter-kit": "^3.28.0", "@tiptap/vue-3": "^3.28.0", "@umoteam/editor": "^10.2.1", + "@vue-flow/background": "^1.3.2", + "@vue-flow/controls": "^1.1.3", + "@vue-flow/core": "^1.48.2", "axios": "^1.13.1", "chart": "^0.1.2", "chart.js": "^4.5.1", diff --git a/platform/src/api/approval.js b/platform/src/api/approval.js new file mode 100644 index 0000000..31a36d3 --- /dev/null +++ b/platform/src/api/approval.js @@ -0,0 +1,62 @@ +import request from '@/utils/request' + +// ==================== 平台端审批流配置 ==================== + +// 可绑定的业务类型(顶级选择,用于流程绑定模块) +export function getApprovalBizTypes() { + return request({ + url: '/platform/approval/biz-types', + method: 'get' + }) +} + +// 流程列表(可按 biz_type 过滤) +export function getApprovalFlowList(params) { + return request({ + url: '/platform/approval/flow/list', + method: 'get', + params + }) +} + +// 流程详情(含全部节点) +export function getApprovalFlowDetail(id) { + return request({ + url: `/platform/approval/flow/detail/${id}`, + method: 'get' + }) +} + +// 保存流程(新建或更新,节点全量覆盖) +export function saveApprovalFlow(data) { + return request({ + url: '/platform/approval/flow/save', + method: 'post', + data + }) +} + +// 删除流程 +export function deleteApprovalFlow(id) { + return request({ + url: `/platform/approval/flow/delete/${id}`, + method: 'delete' + }) +} + +// 审批人候选项(平台角色 + 平台用户 + 分类模块) +export function getApprovalOptions() { + return request({ + url: '/platform/approval/options', + method: 'get' + }) +} + +// 审批流编号轻量清单(按分类返回启用流程的 flow_code/flow_name) +export function getApprovalFlowPick(params) { + return request({ + url: '/platform/approval/flow/pick', + method: 'get', + params + }) +} diff --git a/platform/src/api/domain.js b/platform/src/api/domain.js index 6e69f6b..cac635d 100644 --- a/platform/src/api/domain.js +++ b/platform/src/api/domain.js @@ -118,6 +118,14 @@ export function auditTenantDomain(data) { }) } +// 获取域名审批进度(引擎节点与各审批人状态) +export function getDomainAuditProgress(id) { + return request({ + url: `/platform/domain/tenant/audit/progress/${id}`, + method: 'get' + }) +} + // 禁用/启用租户域名 export function toggleTenantDomainStatus(id) { return request({ diff --git a/platform/src/api/login.js b/platform/src/api/login.js index ae2471c..b61c654 100644 --- a/platform/src/api/login.js +++ b/platform/src/api/login.js @@ -106,10 +106,10 @@ export function sendResetCode(data) { }); } -// 租户端自助注册(/backend/*,与 go-platform routers/backend 一致) +// 租户自助注册(平台端独立接口 /platform/*,与 go routers/platform 一致) export function register(data) { return request({ - url: "/backend/register", + url: "/platform/register", method: "post", data, }); @@ -117,7 +117,7 @@ export function register(data) { export function sendRegisterCode(data) { return request({ - url: "/backend/sendRegisterCode", + url: "/platform/sendRegisterCode", method: "post", data, }); diff --git a/platform/src/api/onepage.js b/platform/src/api/onepage.js index a53afe8..3fee7a1 100644 --- a/platform/src/api/onepage.js +++ b/platform/src/api/onepage.js @@ -67,13 +67,13 @@ export function deleteOnePage(id) { } /** - * 租户官网前台:根据路径获取单页(公开接口,非平台管理) + * 平台端:根据路径读取单页(平台端独立接口,不跨端复用 /index) * @param {string} path 路由路径 * @returns {Promise} */ export function getOnePageByPath(path) { return request({ - url: `/index/onepage/${encodeURIComponent(path)}`, + url: `/platform/website/page/path/${encodeURIComponent(path)}`, method: "get", }); } diff --git a/platform/src/api/role.js b/platform/src/api/role.js index 6bfedc0..57da1fd 100644 --- a/platform/src/api/role.js +++ b/platform/src/api/role.js @@ -35,11 +35,4 @@ export function deleteRole(id) { url: `/platform/roles/${id}`, method: 'delete' }) -} - -export function getRoleByTenantId(tenantId) { - return request({ - url: '/api/role/tenant/' + tenantId, // 根据实际 API 调整 - method: 'get' - }) } \ No newline at end of file diff --git a/platform/src/components/approval/FlowCanvas.vue b/platform/src/components/approval/FlowCanvas.vue new file mode 100644 index 0000000..cec2b43 --- /dev/null +++ b/platform/src/components/approval/FlowCanvas.vue @@ -0,0 +1,562 @@ + + + + + 节点面板 + + + {{ p.label }} + + + 拖入画布添加节点;从节点右侧圆点拖到目标节点连线;双击节点配置;点击连线设置分支条件。 + + + + + + + + + + {{ typeShort(nodeProps.data.nodeType) }} + {{ nodeProps.data.name || '未命名' }} + {{ nodeProps.data.summary }} + + + + + + + + + + + + + + + + + + {{ t.label }} + + + + + + + + + 该类型运行期自动定位审批人,无需指定对象 + + + + + 或签 + + + + 或签 (OR) + 所选审批人任一人通过/驳回,即视为本节点完成; + 其余待办任务将自动作废并推进至下一节点。 + + + + + + + 会签 + + + + 会签 (AND) + 所选审批人须全部通过,本节点才算完成; + 任一人驳回则本节点立即驳回,流程回退。 + + + + + + + + + + + + + + + + 条件网关:拖出多条连线,点击连线分别填写分支条件(留空为默认分支)。 + + + + 删除节点 + + 取消 + 确定 + + + + + + + + + + + 支持 > >= < <= == != 与 && / ||。条件仅在“条件网关”的出边上生效。 + + + + 删除连线 + + 取消 + 确定 + + + + + + + + + + diff --git a/platform/src/router/index.js b/platform/src/router/index.js index 7d7f12d..0ba42cc 100644 --- a/platform/src/router/index.js +++ b/platform/src/router/index.js @@ -42,6 +42,13 @@ const staticMainChildren = [ component: () => import("@/views/system/authConfig/index.vue"), meta: { requiresAuth: true, title: "登录策略" } }, + // 审批流配置(平台侧:域名申请等管控场景) + { + path: "/system/approval", + name: "PlatformApprovalFlow", + component: () => import("@/views/system/approval/index.vue"), + meta: { requiresAuth: true, title: "审批流配置" } + }, { path: "/tools/passwordStore", name: "PlatformPasswordStore", diff --git a/platform/src/views/apps/cms/domain/audit.vue b/platform/src/views/apps/cms/domain/audit.vue index 1c6d3ad..1981500 100644 --- a/platform/src/views/apps/cms/domain/audit.vue +++ b/platform/src/views/apps/cms/domain/audit.vue @@ -2,10 +2,16 @@ 租户域名审核 - - - 刷新 - + + + + 审批流配置 + + + + 刷新 + + @@ -99,7 +105,10 @@ 启用 - + + + 进度 + 通过 @@ -129,19 +138,41 @@ @current-change="fetchData" /> + + + + + + + + + {{ t.approver_name || ('#' + t.approver_id) }} + {{ taskText(t.status) }} + {{ t.comment }} + + + + + + + diff --git a/platform/src/views/system/permissions/index.vue b/platform/src/views/system/permissions/index.vue index 5950f51..fdf7840 100644 --- a/platform/src/views/system/permissions/index.vue +++ b/platform/src/views/system/permissions/index.vue @@ -167,7 +167,7 @@ import { Select, RefreshLeft, } from '@element-plus/icons-vue'; -import { getRoleByTenantId, getAllRoles } from '@/api/role'; +import { getAllRoles } from '@/api/role'; import { getAllMenuPermissions, getRolePermissions, diff --git a/uniapp/App.vue b/uniapp/App.vue index b8262da..a919b18 100644 --- a/uniapp/App.vue +++ b/uniapp/App.vue @@ -29,7 +29,39 @@ box-sizing: border-box; } - view, text { + /* 全局统一 border-box:必须显式覆盖 scroll-view / swiper / input 等盒元素, + 否则它们 width:100% + padding 会按 content-box 溢出视口(小程序端高发), + 导致带左右内边距的列表/弹层右侧内容被裁切。 */ + view, + text, + scroll-view, + swiper, + swiper-item, + button, + input, + textarea, + label, + navigator { box-sizing: border-box; } + + /* ============ 全局自适应(宽屏:桌面 / 平板 H5)============ + 说明:手机竖屏 CSS 宽度普遍 < 560px,此断链不会命中,App / 小程 + 序 / 手机 H5 行为完全不变;仅在宽屏下把整个应用收敛为居中的手机列, + 避免页面铺满超宽屏、两侧大片留白。配合 pages.json 的 rpx 上限, + 无需逐页适配。 */ + @media (min-width: 560px) { + body { + background: #e9edf3; + } + + #app { + max-width: 560px; + min-height: 100vh; + margin-left: auto; + margin-right: auto; + background: $color-bg-page; + box-shadow: 0 0 1px rgba(15, 23, 42, 0.16), 0 10px 40px rgba(15, 23, 42, 0.08); + } + } diff --git a/uniapp/api/auth.js b/uniapp/api/auth.js index d36d03f..6f4b02a 100644 --- a/uniapp/api/auth.js +++ b/uniapp/api/auth.js @@ -1,12 +1,12 @@ /** - * 移动端认证接口 — 对接 Go /app/* + * 移动端认证接口 — 对接 Go /api/* */ import { request, requestRaw } from '@/api/request.js' /** 账号密码登录(旧:租户端直登) */ export function login(data) { return request({ - url: '/app/login', + url: '/api/login', method: 'POST', data }) @@ -157,7 +157,7 @@ export function revokeUAC(data = {}) { /** 发送登录验证码(账号二次校验用,需后台开启验证) */ export function sendLoginCode(data) { return request({ - url: '/app/sendLoginCode', + url: '/api/sendLoginCode', method: 'POST', data }) @@ -166,7 +166,7 @@ export function sendLoginCode(data) { /** 手机号验证码登录 */ export function loginBySms(data) { return request({ - url: '/app/loginBySms', + url: '/api/loginBySms', method: 'POST', data }) @@ -175,7 +175,7 @@ export function loginBySms(data) { /** 退出登录(后端无状态,失败可忽略;短超时避免卡住) */ export function logoutApi(data = {}) { return request({ - url: '/app/logout', + url: '/api/logout', method: 'POST', data, silent: true, @@ -186,7 +186,7 @@ export function logoutApi(data = {}) { /** 当前用户信息 */ export function getCurrentUser() { return request({ - url: '/app/currentUser', + url: '/api/currentUser', method: 'GET' }) } @@ -194,7 +194,7 @@ export function getCurrentUser() { /** 是否开启登录人机/验证码校验 */ export function getOpenVerify() { return request({ - url: '/app/login/getOpenVerify', + url: '/api/login/getOpenVerify', method: 'GET', silent: true, timeout: 8000 @@ -204,7 +204,7 @@ export function getOpenVerify() { /** 极验 3 配置 */ export function getGeetest3Infos() { return request({ - url: '/app/login/getGeetest3Infos', + url: '/api/login/getGeetest3Infos', method: 'GET', silent: true }) @@ -213,7 +213,7 @@ export function getGeetest3Infos() { /** 极验 4 配置 */ export function getGeetest4Infos() { return request({ - url: '/app/login/getGeetest4Infos', + url: '/api/login/getGeetest4Infos', method: 'GET', silent: true, timeout: 8000 @@ -223,7 +223,7 @@ export function getGeetest4Infos() { /** 注册 */ export function register(data) { return request({ - url: '/app/register', + url: '/api/register', method: 'POST', data }) @@ -232,7 +232,7 @@ export function register(data) { /** 发送注册验证码 */ export function sendRegisterCode(data) { return request({ - url: '/app/sendRegisterCode', + url: '/api/sendRegisterCode', method: 'POST', data }) @@ -241,7 +241,7 @@ export function sendRegisterCode(data) { /** 重置密码 */ export function resetPassword(data) { return request({ - url: '/app/resetPassword', + url: '/api/resetPassword', method: 'POST', data }) @@ -250,7 +250,7 @@ export function resetPassword(data) { /** 验证租户和账号(找回密码第一步) */ export function verifyAccount(data) { return request({ - url: '/app/verifyAccount', + url: '/api/verifyAccount', method: 'POST', data }) @@ -259,7 +259,7 @@ export function verifyAccount(data) { /** 发送找回密码验证码(找回密码第二步) */ export function sendResetCode(data) { return request({ - url: '/app/sendResetCode', + url: '/api/sendResetCode', method: 'POST', data }) diff --git a/uniapp/api/contact.js b/uniapp/api/contact.js index fe51277..8e290dd 100644 --- a/uniapp/api/contact.js +++ b/uniapp/api/contact.js @@ -1,6 +1,6 @@ /** * 通讯录(移动端)接口封装。 - * 对应后端 go/controllers/app_contact.go 的 /app/oa/contact/* 只读接口。 + * 对应后端 go/controllers/app_contact.go 的 /api/oa/contact/* 只读接口。 */ import { request } from '@/api/request.js' @@ -48,7 +48,7 @@ export function primaryPhone(contact) { /** 分页查询通讯录 */ export async function fetchContacts(params = {}) { const data = await request({ - url: '/app/oa/contact/list', + url: '/api/oa/contact/list', data: { page: params.page || 1, pageSize: params.pageSize || 20, @@ -62,10 +62,10 @@ export async function fetchContacts(params = {}) { /** 组织架构树(含各部门内部联系人数量) */ export function fetchContactOrgTree() { - return request({ url: '/app/oa/contact/org-tree' }) + return request({ url: '/api/oa/contact/org-tree' }) } /** 联系人详情 */ export function getContact(id) { - return request({ url: `/app/oa/contact/${id}` }) + return request({ url: `/api/oa/contact/${id}` }) } \ No newline at end of file diff --git a/uniapp/api/note.js b/uniapp/api/note.js index 2ac5b21..71795f3 100644 --- a/uniapp/api/note.js +++ b/uniapp/api/note.js @@ -5,7 +5,7 @@ import { request } from '@/api/request.js' */ export async function fetchNotes(params = {}) { const data = await request({ - url: '/app/notebook/list', + url: '/api/notebook/list', data: { keyword: params.keyword || '' } @@ -23,7 +23,7 @@ export async function fetchNotes(params = {}) { * 获取单条笔记 */ export async function getNote(id) { - const data = await request({ url: `/app/notebook/${id}` }) + const data = await request({ url: `/api/notebook/${id}` }) if (!data) return null return { ...data, @@ -38,7 +38,7 @@ export async function getNote(id) { */ export async function createNote(data) { const res = await request({ - url: '/app/notebook', + url: '/api/notebook', method: 'POST', data: { title: data.title || '', @@ -54,7 +54,7 @@ export async function createNote(data) { */ export async function updateNote(id, data) { const res = await request({ - url: `/app/notebook/${id}`, + url: `/api/notebook/${id}`, method: 'PUT', data: { title: data.title || '', @@ -69,7 +69,7 @@ export async function updateNote(id, data) { * 删除笔记 */ export async function deleteNote(id) { - await request({ url: `/app/notebook/${id}`, method: 'DELETE' }) + await request({ url: `/api/notebook/${id}`, method: 'DELETE' }) return true } @@ -78,7 +78,7 @@ export async function deleteNote(id) { */ export async function toggleNotePin(id) { const res = await request({ - url: `/app/notebook/${id}/togglePin`, + url: `/api/notebook/${id}/togglePin`, method: 'POST' }) return res diff --git a/uniapp/api/oa.js b/uniapp/api/oa.js index 5b8f159..de2822b 100644 --- a/uniapp/api/oa.js +++ b/uniapp/api/oa.js @@ -1,6 +1,6 @@ /** * OA 协同办公(移动端)接口封装。 - * 对应后端 go/controllers/app_oa.go 的 /app/oa/* 只读接口。 + * 对应后端 go/controllers/app_oa.go 的 /api/oa/* 只读接口。 */ import { request } from '@/api/request.js' @@ -110,7 +110,7 @@ export function formatFileSize(size) { export async function fetchNotices(params = {}) { const data = await request({ - url: '/app/oa/notice/list', + url: '/api/oa/notice/list', data: { page: params.page || 1, pageSize: params.pageSize || 20, @@ -121,18 +121,18 @@ export async function fetchNotices(params = {}) { } export function getNotice(id) { - return request({ url: `/app/oa/notice/${id}` }) + return request({ url: `/api/oa/notice/${id}` }) } // ---------------- 文档库 ---------------- export function fetchDocCategories(scope = 'shared') { - return request({ url: '/app/oa/document/categories', data: { scope } }) + return request({ url: '/api/oa/document/categories', data: { scope } }) } export async function fetchDocs(params = {}) { const data = await request({ - url: '/app/oa/document/list', + url: '/api/oa/document/list', data: { scope: params.scope || 'shared', keyword: params.keyword || '', @@ -148,22 +148,22 @@ export async function fetchDocs(params = {}) { } export function getDoc(id) { - return request({ url: `/app/oa/document/${id}` }) + return request({ url: `/api/oa/document/${id}` }) } export function downloadDoc(id) { - return request({ url: `/app/oa/document/download/${id}` }) + return request({ url: `/api/oa/document/download/${id}` }) } export function toggleDocStar(id) { - return request({ url: `/app/oa/document/${id}/star`, method: 'POST' }) + return request({ url: `/api/oa/document/${id}/star`, method: 'POST' }) } // ---------------- 薪酬工资单(仅本人) ---------------- export async function fetchPayrolls(params = {}) { const data = await request({ - url: '/app/oa/compensation/payrolls', + url: '/api/oa/compensation/payrolls', data: { payroll_month: params.payrollMonth || '', page: params.page || 1, @@ -174,11 +174,38 @@ export async function fetchPayrolls(params = {}) { } export function getPayroll(id) { - return request({ url: `/app/oa/compensation/payrolls/${id}` }) + return request({ url: `/api/oa/compensation/payrolls/${id}` }) } -// ---------------- 员工档案(仅本人) ---------------- +// ---------------- 员工档案 ---------------- +/** 当前用户是否具备 HR 视角(可见全公司档案)。角色里勾了「员工档案」菜单即为 HR。 */ +export async function getEmployeeFileAccess() { + const data = await request({ url: '/api/oa/employee-file/access' }) + return !!(data && data.can_view_all) +} + +/** 全公司档案分页列表(HR),支持 keyword / status / org_id */ +export async function fetchEmployeeFiles(params = {}) { + const data = await request({ + url: '/api/oa/employee-file/list', + data: { + keyword: params.keyword || '', + status: params.status || 0, + org_id: params.orgId || 0, + page: params.page || 1, + pageSize: params.pageSize || 20 + } + }) + return { list: (data && data.list) || [], total: (data && data.total) || 0 } +} + +/** 指定员工档案详情(含教育/工作/合同/证照子记录) */ +export function getEmployeeFileDetail(id) { + return request({ url: `/api/oa/employee-file/detail/${id}` }) +} + +/** 本人档案(无 HR 权限时的兜底视图) */ export function getMyEmployeeFile() { - return request({ url: '/app/oa/employee-file' }) + return request({ url: '/api/oa/employee-file' }) } \ No newline at end of file diff --git a/uniapp/api/schedule.js b/uniapp/api/schedule.js index 248c07a..06d732c 100644 --- a/uniapp/api/schedule.js +++ b/uniapp/api/schedule.js @@ -61,7 +61,7 @@ export function normalizeSchedule(item) { */ export async function fetchSchedules(params = {}) { const data = await request({ - url: '/app/schedule/list', + url: '/api/schedule/list', data: { page: params.page || 1, pageSize: params.pageSize || 50, @@ -77,7 +77,7 @@ export async function fetchSchedules(params = {}) { * 获取单条日程 */ export async function getSchedule(id) { - const data = await request({ url: `/app/schedule/${id}` }) + const data = await request({ url: `/api/schedule/${id}` }) return data ? normalizeSchedule(data) : null } @@ -90,7 +90,7 @@ export async function createSchedule(data) { return ch.toUpperCase() }) const res = await request({ - url: '/app/schedule', + url: '/api/schedule', method: 'POST', data: { content: data.content, @@ -113,7 +113,7 @@ export async function updateSchedule(id, data) { return ch.toUpperCase() }) await request({ - url: `/app/schedule/${id}`, + url: `/api/schedule/${id}`, method: 'PUT', data: { content: data.content, @@ -130,7 +130,7 @@ export async function updateSchedule(id, data) { * 删除日程 */ export async function deleteSchedule(id) { - await request({ url: `/app/schedule/${id}`, method: 'DELETE' }) + await request({ url: `/api/schedule/${id}`, method: 'DELETE' }) return true } @@ -139,7 +139,7 @@ export async function deleteSchedule(id) { */ export async function toggleScheduleComplete(id) { const res = await request({ - url: `/app/schedule/${id}/toggle`, + url: `/api/schedule/${id}/toggle`, method: 'POST' }) return { diff --git a/uniapp/api/workspace.js b/uniapp/api/workspace.js index e889d26..912fdd6 100644 --- a/uniapp/api/workspace.js +++ b/uniapp/api/workspace.js @@ -1,7 +1,7 @@ /** * 移动端「工作台」接口 * - * /app/home 一次返回首屏所需的全部数据(应用模块卡片 + 可见菜单 + KPI), + * /api/home 一次返回首屏所需的全部数据(应用模块卡片 + 可见菜单 + KPI), * 避免移动端首屏多次往返。 */ import { request } from '@/api/request.js' @@ -18,7 +18,7 @@ let cacheAt = 0 export async function fetchHome(force = false) { if (!force && cache && Date.now() - cacheAt < CACHE_TTL) return cache const data = await request({ - url: '/app/home', + url: '/api/home', method: 'GET', silent: true, timeout: 15000 diff --git a/uniapp/components/sub/SubHome.vue b/uniapp/components/sub/SubHome.vue index 845ba89..ab70146 100644 --- a/uniapp/components/sub/SubHome.vue +++ b/uniapp/components/sub/SubHome.vue @@ -297,7 +297,7 @@ function togglePick(id) { async function savePicker() { try { await request({ - url: '/app/quick-menus', + url: '/api/quick-menus', method: 'POST', data: { menu_ids: pickerSelected.value } }) @@ -314,7 +314,7 @@ async function savePicker() { async function loadDashboard() { if (!isOa.value) return try { - dash.value = await request({ url: '/app/oa/dashboard', method: 'GET', silent: true }) + dash.value = await request({ url: '/api/oa/dashboard', method: 'GET', silent: true }) } catch (e) { // 仪表盘失败不影响首页其余内容 dash.value = null @@ -326,8 +326,8 @@ const overdue = computed(() => pending.value.filter((s) => isOverdue(s)).length) async function loadTodo() { try { const [p, d] = await Promise.all([ - request({ url: '/app/schedule/list', data: { status: 'pending', pageSize: 50 } }), - request({ url: '/app/schedule/list', data: { status: 'done', pageSize: 50 } }) + request({ url: '/api/schedule/list', data: { status: 'pending', pageSize: 50 } }), + request({ url: '/api/schedule/list', data: { status: 'done', pageSize: 50 } }) ]) pending.value = (p && p.list) || [] doneCount.value = ((d && d.list) || []).length @@ -340,7 +340,7 @@ async function loadTodo() { async function loadNotices() { if (!isOa.value) return try { - const data = await request({ url: '/app/oa/notice/list', data: { pageSize: 3 } }) + const data = await request({ url: '/api/oa/notice/list', data: { pageSize: 3 } }) notices.value = (data && data.list) || [] } catch (e) { notices.value = [] @@ -714,6 +714,7 @@ onShow(() => { z-index: 100; display: flex; align-items: flex-end; + @include mask-responsive; } .picker-sheet { @@ -724,6 +725,7 @@ onShow(() => { display: flex; flex-direction: column; padding-bottom: env(safe-area-inset-bottom); + @include sheet-responsive; } .picker-head { @@ -789,6 +791,9 @@ onShow(() => { min-width: 0; font-size: 28rpx; color: $color-text; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } .picker-check { diff --git a/uniapp/components/sub/SubMine.vue b/uniapp/components/sub/SubMine.vue index 1044a17..55cf911 100644 --- a/uniapp/components/sub/SubMine.vue +++ b/uniapp/components/sub/SubMine.vue @@ -138,8 +138,8 @@ const avatarText = computed(() => { async function loadStats() { try { const [p, d] = await Promise.all([ - request({ url: '/app/schedule/list', data: { status: 'pending', pageSize: 50 } }), - request({ url: '/app/schedule/list', data: { status: 'done', pageSize: 50 } }) + request({ url: '/api/schedule/list', data: { status: 'pending', pageSize: 50 } }), + request({ url: '/api/schedule/list', data: { status: 'done', pageSize: 50 } }) ]) const pending = (p && p.list) || [] pendingCount.value = pending.length diff --git a/uniapp/components/sub/SubTodo.vue b/uniapp/components/sub/SubTodo.vue index 9c9f399..e237ede 100644 --- a/uniapp/components/sub/SubTodo.vue +++ b/uniapp/components/sub/SubTodo.vue @@ -81,8 +81,8 @@ const done = computed(() => finished.value.length) async function loadTodo() { try { const [p, f] = await Promise.all([ - request({ url: '/app/schedule/list', data: { status: 'pending', pageSize: 50 } }), - request({ url: '/app/schedule/list', data: { status: 'done', pageSize: 50 } }) + request({ url: '/api/schedule/list', data: { status: 'pending', pageSize: 50 } }), + request({ url: '/api/schedule/list', data: { status: 'done', pageSize: 50 } }) ]) pending.value = (p && p.list) || [] finished.value = (f && f.list) || [] diff --git a/uniapp/pages.json b/uniapp/pages.json index 3df55c5..2ca1672 100644 --- a/uniapp/pages.json +++ b/uniapp/pages.json @@ -151,7 +151,14 @@ { "path": "pages/oa/employeefile/index", "style": { - "navigationBarTitleText": "我的档案", + "navigationBarTitleText": "员工档案", + "navigationBarBackgroundColor": "#F8FAFC" + } + }, + { + "path": "pages/oa/employeefile/detail", + "style": { + "navigationBarTitleText": "档案详情", "navigationBarBackgroundColor": "#F8FAFC" } }, @@ -336,7 +343,10 @@ "navigationBarTextStyle": "black", "navigationBarTitleText": "云泽", "navigationBarBackgroundColor": "#F8FAFC", - "backgroundColor": "#F8FAFC" + "backgroundColor": "#F8FAFC", + "rpxCalcMaxDeviceWidth": 560, + "rpxCalcBaseDeviceWidth": 375, + "rpxCalcIncludeWidth": 750 }, "uniIdRouter": {} } diff --git a/uniapp/pages/dashboard/dashboard.vue b/uniapp/pages/dashboard/dashboard.vue index 677bf1f..44014a4 100644 --- a/uniapp/pages/dashboard/dashboard.vue +++ b/uniapp/pages/dashboard/dashboard.vue @@ -356,7 +356,7 @@ async function loadHome() { async function loadPending() { try { - const data = await request({ url: '/app/schedule/list', data: { status: 'pending', pageSize: 5 } }) + const data = await request({ url: '/api/schedule/list', data: { status: 'pending', pageSize: 5 } }) pendingList.value = (data && data.list ? data.list : []).slice(0, 5) } catch (e) { pendingList.value = [] @@ -365,7 +365,7 @@ async function loadPending() { async function loadActivities() { try { - const data = await request({ url: '/app/activity/list', data: { limit: 5 } }) + const data = await request({ url: '/api/activity/list', data: { limit: 5 } }) const list = Array.isArray(data) ? data : (data && data.list) || [] activities.value = list.slice(0, 5).map((item) => ({ type: activityTint(item.target_type), diff --git a/uniapp/pages/oa/employeefile/detail.vue b/uniapp/pages/oa/employeefile/detail.vue new file mode 100644 index 0000000..5207d7f --- /dev/null +++ b/uniapp/pages/oa/employeefile/detail.vue @@ -0,0 +1,496 @@ + + + + + + 加载中... + + + + + + {{ avatarText }} + + + + {{ file.employee_name || '本人' }} + + + {{ positionText }} + + + + + + + 档案完整度 + {{ file.completeness || 0 }}% + + + + + + + + + + 基本信息 + + + {{ row.label }} + {{ row.value || '-' }} + + + + + + + 联系方式 + + + {{ row.label }} + {{ row.value || '-' }} + + + + + + + 紧急联系人 + + + {{ row.label }} + {{ row.value || '-' }} + + + + + + + {{ group.label }} + {{ group.list.length }} + + + 暂无记录 + + + + {{ record.title || group.label }} + {{ record.sub_title }} + + + {{ recordDate(record) }} + + {{ record.extra }} + {{ record.description }} + + + 查看附件 + + + + + + + + + + + + + \ No newline at end of file diff --git a/uniapp/pages/oa/employeefile/index.vue b/uniapp/pages/oa/employeefile/index.vue index 043b8eb..3f1eead 100644 --- a/uniapp/pages/oa/employeefile/index.vue +++ b/uniapp/pages/oa/employeefile/index.vue @@ -1,241 +1,233 @@ - - + + + + + + + + + 加载中... - - - {{ avatarText }} - - - - {{ file.employee_name || '本人' }} - + + + + + + {{ item.employee_name || '未命名' }} + + {{ getEmploymentStatusLabel(item.employment_status) }} + + + {{ subLine(item) }} + + {{ item.file_no || '暂无编号' }} + 完整度 {{ item.completeness || 0 }}% + - {{ positionText }} + - - - - - 档案完整度 - {{ file.completeness || 0 }}% - - - - + + {{ loadingMore ? '加载中...' : '加载更多' }} - - - - - 基本信息 - - - {{ row.label }} - {{ row.value || '-' }} - - - - - - - 联系方式 - - - {{ row.label }} - {{ row.value || '-' }} - - - - - - - 紧急联系人 - - - {{ row.label }} - {{ row.value || '-' }} - - - - - - - {{ group.label }} - {{ group.list.length }} - - - 暂无记录 - - - - {{ record.title || group.label }} - {{ record.sub_title }} - - - {{ recordDate(record) }} - - {{ record.extra }} - {{ record.description }} - - - 查看附件 - - + + 已加载全部 {{ total }} 份档案 - @@ -249,215 +241,117 @@ function openAttachment(url) { flex-direction: column; } -.body { +.toolbar { + position: relative; + z-index: 10; + flex-shrink: 0; + padding: 20rpx $page-padding-x 0; + background: $color-card; + box-shadow: $shadow-header; +} + +.list-scroll { flex: 1; height: 0; } -.body-inner { +.file-list { padding: 24rpx $page-padding-x 0; } -.hero-card { +.file-card { @include card; display: flex; align-items: center; - gap: 24rpx; - padding: 32rpx 28rpx; - background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%); - box-shadow: $shadow-float; + gap: 22rpx; + padding: 24rpx; + margin-bottom: 18rpx; + + &:active { + background: $color-bg-page; + } } -.hero-avatar { - width: 104rpx; - height: 104rpx; - border-radius: $radius-full; - background: rgba(255, 255, 255, 0.22); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.hero-avatar text { - font-size: 44rpx; - font-weight: 600; - color: #ffffff; -} - -.hero-info { +.file-body { flex: 1; min-width: 0; } -.hero-name-row { +.file-head { display: flex; align-items: center; - gap: 14rpx; + gap: 12rpx; } -.hero-name { - font-size: 36rpx; +.file-name { + font-size: 31rpx; font-weight: 600; - color: #ffffff; - min-width: 0; - word-break: break-all; + color: $color-text; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .status-tag { flex-shrink: 0; -} - -.hero-sub { - display: block; - margin-top: 12rpx; - font-size: 24rpx; - color: rgba(255, 255, 255, 0.82); -} - -.group-card { - @include card; - margin-top: 20rpx; - padding: 24rpx 28rpx; -} - -.group-head { - display: flex; - align-items: center; - gap: 12rpx; - margin-bottom: 8rpx; -} - -.group-bar { - width: 6rpx; - height: 26rpx; - border-radius: 3rpx; - background: $color-primary; -} - -.group-title { - font-size: 29rpx; - font-weight: 600; - color: $color-text; -} - -.group-extra { - margin-left: auto; - font-size: 24rpx; + padding: 2rpx 12rpx; + font-size: 20rpx; + border-radius: $radius-sm; color: $color-text-muted; -} - -.progress-track { - height: 14rpx; - margin-top: 20rpx; - border-radius: $radius-full; background: $color-bg-page; - overflow: hidden; -} -.progress-fill { - height: 100%; - border-radius: $radius-full; - background: linear-gradient(90deg, #60a5fa 0%, #2563eb 100%); -} + &.st2 { + color: $color-primary; + background: $color-primary-bg; + } -.row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 20rpx; - padding: 22rpx 0; - border-bottom: 1rpx solid $color-divider; - - &:last-child { - border-bottom: none; + &.st3 { + color: #94a3b8; + background: #f1f5f9; } } -.row-label { - font-size: 27rpx; - color: $color-text-secondary; - flex-shrink: 0; -} - -.row-text { - font-size: 27rpx; - color: $color-text; - text-align: right; - flex: 1; - min-width: 0; - word-break: break-all; -} - -.record-item { - padding: 22rpx 0; - border-bottom: 1rpx solid $color-divider; - - &:last-child { - border-bottom: none; - } -} - -.record-top { - display: flex; - align-items: center; - gap: 14rpx; -} - -.record-title { - font-size: 28rpx; - font-weight: 600; - color: $color-text; - min-width: 0; - word-break: break-all; -} - -.record-sub { - font-size: 24rpx; - color: $color-text-secondary; - min-width: 0; - word-break: break-all; -} - -.record-date { - margin-top: 10rpx; - font-size: 23rpx; - color: $color-text-muted; -} - -.record-extra { +.file-sub { display: block; margin-top: 8rpx; - font-size: 24rpx; + font-size: 25rpx; color: $color-text-secondary; - word-break: break-all; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.record-desc { - display: block; - margin-top: 10rpx; - font-size: 24rpx; - line-height: 1.6; - color: $color-text-muted; - word-break: break-all; -} - -.record-file { - display: inline-flex; +.file-foot { + display: flex; align-items: center; - gap: 8rpx; - margin-top: 14rpx; - font-size: 24rpx; + gap: 16rpx; + margin-top: 10rpx; +} + +.file-no { + font-size: 22rpx; + color: $color-text-muted; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-rate { + margin-left: auto; + flex-shrink: 0; + font-size: 22rpx; color: $color-primary; } -.record-empty { - padding: 24rpx 0; -} +.more-tip { + padding: 12rpx 0 4rpx; + text-align: center; + font-size: 24rpx; + color: $color-primary; -.record-empty-text { - font-size: 25rpx; - color: $color-text-muted; + &.end { + color: $color-text-muted; + } } .state-box { @@ -465,7 +359,7 @@ function openAttachment(url) { flex-direction: column; align-items: center; justify-content: center; - padding: 200rpx 48rpx; + padding: 160rpx 48rpx; gap: 20rpx; } @@ -476,6 +370,6 @@ function openAttachment(url) { } .bottom-space { - height: 48rpx; + height: 40rpx; } \ No newline at end of file diff --git a/uniapp/src/styles/page-common.scss b/uniapp/src/styles/page-common.scss index 39ab0ec..451e5e0 100644 --- a/uniapp/src/styles/page-common.scss +++ b/uniapp/src/styles/page-common.scss @@ -69,3 +69,27 @@ font-size: 22rpx; line-height: 1.6; } + +// ============ 弹层宽屏自适应(全局约定,新弹层直接复用即可,无需逐页调) ============ +// 用法:铺满式遮罩根元素内 `@include mask-responsive;`,其底部面板内 `@include sheet-responsive;` +// 手机竖屏(<560px)不命中,行为不变;宽屏下遮罩仍铺满做暗色背景,面板收敛为居中手机列宽。 +@mixin mask-responsive { + @media (min-width: 560px) { + justify-content: center; + } +} + +@mixin sheet-responsive($w: 560px) { + @media (min-width: 560px) { + max-width: $w; + } +} + +// 居中弹窗(非贴底):宽屏下把固定定位的遮罩面板限制为列宽并水平+垂直居中 +@mixin dialog-responsive($w: 560px) { + @media (min-width: 560px) { + max-width: $w; + left: 50%; + transform: translateX(-50%); + } +} diff --git a/uniapp/utils/nav.js b/uniapp/utils/nav.js index 635c757..4ba03c5 100644 --- a/uniapp/utils/nav.js +++ b/uniapp/utils/nav.js @@ -38,7 +38,7 @@ export function isMenuLocked(menu = {}) { /** * 打开一个菜单功能 - * @param {object} menu /app/home 返回的菜单项 + * @param {object} menu /api/home 返回的菜单项 * @param {string} [fallbackTitle] 没有菜单时用于提示的功能名 */ export function openMenu(menu, fallbackTitle = '') { diff --git a/uniapp/utils/subsystem.js b/uniapp/utils/subsystem.js index 67cfb75..1bb28f7 100644 --- a/uniapp/utils/subsystem.js +++ b/uniapp/utils/subsystem.js @@ -3,7 +3,7 @@ * * 三个子系统页面(概览 / 功能 / 待办)都需要: * 1. 该子系统的配置(config/subsystems.js) - * 2. 该子系统下的菜单(/app/home → menus 按 module 归组) + * 2. 该子系统下的菜单(/api/home → menus 按 module 归组) * 这里统一封装,避免三个页面各写一遍。 */ import { ref, computed } from 'vue' diff --git a/website/src/components/SiteFooter.vue b/website/src/components/SiteFooter.vue index dfab81a..eef7488 100644 --- a/website/src/components/SiteFooter.vue +++ b/website/src/components/SiteFooter.vue @@ -1,5 +1,5 @@ diff --git a/website/src/views/home/components/ScheduleSection.vue b/website/src/views/home/components/ScheduleSection.vue index 7c30074..b1eaf2d 100644 --- a/website/src/views/home/components/ScheduleSection.vue +++ b/website/src/views/home/components/ScheduleSection.vue @@ -17,7 +17,7 @@ const fetchUpgradeList = async () => { loading.value = true; try { const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:9000'; - const response = await fetch(`${API_BASE_URL}/platform/api/upgrade/list?limit=3`); + const response = await fetch(`${API_BASE_URL}/site/upgrade/list?limit=3`); const result = await response.json(); if (result.code === 200 && result.data) { upgradeList.value = result.data;