diff --git a/go/controllers/app_approval.go b/go/controllers/app_approval.go
new file mode 100644
index 0000000..374e0d6
--- /dev/null
+++ b/go/controllers/app_approval.go
@@ -0,0 +1,726 @@
+package controllers
+
+import (
+ "encoding/json"
+ "io"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "server/models"
+ "server/pkg/jwtutil"
+ "server/services"
+ approvalEngine "server/services/approval"
+
+ "github.com/beego/beego/v2/client/orm"
+)
+
+// 移动端审批接口,挂在 AppOaController 上。
+//
+// 设计说明:
+// - 数据按 JWT 的 tid + user 双重隔离:发起列表仅看本人发起的审批,
+// 待办列表仅看本人作为审批人的待办;
+// - 复用 services/approval 引擎的 Submit / Approve,保证与 Web 端行为一致;
+// - 单据类型(biz_type)取审批流 yz_backend_approval_flow.biz_type;
+// 移动端不开放自由单据类型,仅允许本租户已配置的分类。
+//
+// 路由前缀:/api/oa/approval/*
+//
+// 接口清单:
+// GET /api/oa/approval/list 我发起的审批实例
+// GET /api/oa/approval/todo 我的待办(作为审批人)
+// GET /api/oa/approval/detail/:id 审批详情(含任务时间线)
+// POST /api/oa/approval/submit 提交审批
+// POST /api/oa/approval/approve 通过/驳回审批任务
+// POST /api/oa/approval/withdraw/:id 发起人撤回审批
+// GET /api/oa/approval/flows 可发起的流程清单(按分类)
+// GET /api/oa/approval/categories 单据类型(biz_type)下拉
+
+// ============================ 入参 ============================
+
+// oaApprovalSubmitPayload 提交审批入参
+type oaApprovalSubmitPayload struct {
+ BizType string `json:"biz_type"` // 单据类型(必填)
+ FlowCode string `json:"flow_code"` // 指定流程(可选;留空取该 biz_type 默认流)
+ FlowID uint64 `json:"flow_id"` // 指定流程ID(可选)
+ BizTitle string `json:"biz_title"` // 单据标题(必填)
+ BizID uint64 `json:"biz_id"` // 关联业务单据ID(可选)
+ FormData map[string]interface{} `json:"form_data"` // 表单数据
+}
+
+// oaApprovalApprovePayload 审批入参
+type oaApprovalApprovePayload struct {
+ TaskID uint64 `json:"task_id"` // 待审批任务ID(必填)
+ Pass bool `json:"pass"` // true 通过 false 驳回
+ Comment string `json:"comment"` // 审批意见
+}
+
+// ============================ 工具 ============================
+
+// oaApprovalIDParam 解析 :id 路由参数
+func oaApprovalIDParam(c *AppOaController) uint64 {
+ id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
+ if err != nil {
+ return 0
+ }
+ return id
+}
+
+// oaApprovalClaims 解析并校验 JWT
+func oaApprovalClaims(c *AppOaController) (*jwtutil.Claims, bool) {
+ claims, err := c.oaClaims()
+ if err != nil {
+ c.oaErr(401, 401, "未登录或无权限")
+ return nil, false
+ }
+ return claims, true
+}
+
+// oaApprovalEmployee 通过 JWT 找到当前登录用户对应的员工记录(用于取部门 ID)
+func oaApprovalEmployee(c *AppOaController, claims *jwtutil.Claims) *models.BackendEmployee {
+ emp, ok := c.oaMyEmployee(claims)
+ if !ok {
+ return nil
+ }
+ return emp
+}
+
+// oaApprovalSubmitUID 通过 JWT 拿到租户侧的 UID(SystemTenantUser.id),
+// 审批引擎 SubmitInput.SubmitUID 用的是租户侧用户 ID 而非平台 AdminUser.ID。
+func oaApprovalSubmitUID(claims *jwtutil.Claims) uint64 {
+ return services.ResolveTenantUID(uint64(claims.TenantId), uint64(claims.UserID))
+}
+
+// oaApprovalReadJSON 读取并解析 JSON 请求体,失败时输出错误响应
+func oaApprovalReadJSON(c *AppOaController, dst interface{}) bool {
+ raw, err := io.ReadAll(c.Ctx.Request.Body)
+ if err != nil {
+ c.oaErr(400, 400, "参数错误")
+ return false
+ }
+ if len(raw) == 0 {
+ return true
+ }
+ if err := json.Unmarshal(raw, dst); err != nil {
+ c.oaErr(400, 400, "参数错误: "+err.Error())
+ return false
+ }
+ return true
+}
+
+// oaApprovalUserName 取得发起人姓名(优先员工表,否则使用 JWT 用户名)
+func oaApprovalUserName(c *AppOaController, claims *jwtutil.Claims) string {
+ if emp := oaApprovalEmployee(c, claims); emp != nil && emp.Name != "" {
+ return emp.Name
+ }
+ return strings.TrimSpace(claims.Username)
+}
+
+// ============================ 实例组装 ============================
+
+// oaApprovalStatusText / oaApprovalStatusTag 状态展示文案/Tag 类型(uview-plus)
+func oaApprovalStatusText(status int8) string {
+ switch status {
+ case models.ApprovalInstanceDraft:
+ return "草稿"
+ case models.ApprovalInstanceRunning:
+ return "审批中"
+ case models.ApprovalInstanceApproved:
+ return "已通过"
+ case models.ApprovalInstanceRejected:
+ return "已驳回"
+ case models.ApprovalInstanceWithdrawn:
+ return "已撤回"
+ case models.ApprovalInstanceCanceled:
+ return "已取消"
+ default:
+ return "未知"
+ }
+}
+
+// oaApprovalStatusTagType 给前端 uview-plus u-tag 用的类型映射
+func oaApprovalStatusTagType(status int8) string {
+ switch status {
+ case models.ApprovalInstanceRunning:
+ return "warning"
+ case models.ApprovalInstanceApproved:
+ return "success"
+ case models.ApprovalInstanceRejected:
+ return "error"
+ case models.ApprovalInstanceWithdrawn, models.ApprovalInstanceCanceled:
+ return "info"
+ default:
+ return "info"
+ }
+}
+
+// oaApprovalInstanceView 把 ApprovalInstance 组装为移动端列表/详情通用的视图结构
+func oaApprovalInstanceView(inst models.ApprovalInstance) map[string]interface{} {
+ submitTime := ""
+ if inst.SubmitTime != nil {
+ submitTime = inst.SubmitTime.Format("2006-01-02 15:04:05")
+ }
+ finishTime := ""
+ if inst.FinishTime != nil {
+ finishTime = inst.FinishTime.Format("2006-01-02 15:04:05")
+ }
+ createTime := ""
+ if !inst.CreateTime.IsZero() {
+ createTime = inst.CreateTime.Format("2006-01-02 15:04:05")
+ }
+ return map[string]interface{}{
+ "id": inst.ID,
+ "biz_type": inst.BizType,
+ "flow_id": inst.FlowID,
+ "biz_id": inst.BizID,
+ "biz_title": inst.BizTitle,
+ "submit_user_id": inst.SubmitUserID,
+ "submit_user_name": inst.SubmitUserName,
+ "submit_dept_id": inst.SubmitDeptID,
+ "current_node": inst.CurrentNode,
+ "status": inst.Status,
+ "status_label": oaApprovalStatusText(inst.Status),
+ "status_tag": oaApprovalStatusTagType(inst.Status),
+ "submit_time": submitTime,
+ "finish_time": finishTime,
+ "create_time": createTime,
+ }
+}
+
+// oaApprovalBizTypes 返回本租户已配置(启用 + 未删除)的 biz_type 列表
+func oaApprovalBizTypes(tid uint64) []map[string]interface{} {
+ var flows []models.TenantApprovalFlow
+ _, _ = models.Orm.QueryTable(new(models.TenantApprovalFlow)).
+ Filter("tid", tid).Filter("is_deleted", 0).Filter("status", 1).
+ OrderBy("module_code", "id").All(&flows)
+
+ seen := map[string]bool{}
+ list := make([]map[string]interface{}, 0, len(flows))
+ for _, f := range flows {
+ biz := strings.TrimSpace(f.BizType)
+ if biz == "" {
+ biz = strings.TrimSpace(f.ModuleCode)
+ }
+ if biz == "" || seen[biz] {
+ continue
+ }
+ seen[biz] = true
+ list = append(list, map[string]interface{}{
+ "value": biz,
+ "label": biz,
+ "module_code": f.ModuleCode,
+ })
+ }
+ sort.Slice(list, func(i, j int) bool {
+ return list[i]["value"].(string) < list[j]["value"].(string)
+ })
+ return list
+}
+
+// ============================ 任务时间线 ============================
+
+// oaApprovalTasksByInstance 取出某实例下的全部任务(按 sort / id 升序),
+// 同时按 (instance_id, node_key) 找当前节点的当前审批人。
+func oaApprovalTasksByInstance(instanceID uint64) []models.ApprovalTask {
+ var tasks []models.ApprovalTask
+ _, _ = models.Orm.QueryTable(new(models.ApprovalTask)).
+ Filter("instance_id", instanceID).Filter("is_deleted", 0).
+ OrderBy("sort", "id").All(&tasks)
+ return tasks
+}
+
+// oaApprovalTaskText / oaApprovalTaskTag 任务级状态文案/Tag 类型
+func oaApprovalTaskText(status int8) string {
+ switch status {
+ case models.ApprovalTaskPending:
+ return "待审批"
+ case models.ApprovalTaskApproved:
+ return "已通过"
+ case models.ApprovalTaskRejected:
+ return "已驳回"
+ case models.ApprovalTaskReturned:
+ return "已退回"
+ case models.ApprovalTaskSkipped:
+ return "已跳过"
+ case models.ApprovalTaskTimeout:
+ return "已超时"
+ case models.ApprovalTaskNotified:
+ return "已通知"
+ default:
+ return "未知"
+ }
+}
+
+func oaApprovalTaskTag(status int8) string {
+ switch status {
+ case models.ApprovalTaskPending:
+ return "warning"
+ case models.ApprovalTaskApproved:
+ return "success"
+ case models.ApprovalTaskRejected:
+ return "error"
+ case models.ApprovalTaskReturned, models.ApprovalTaskSkipped, models.ApprovalTaskTimeout:
+ return "info"
+ default:
+ return "info"
+ }
+}
+
+// oaApprovalTaskView 任务视图(移动端时间线用)
+func oaApprovalTaskView(t models.ApprovalTask, isCurrent bool) map[string]interface{} {
+ finishTime := ""
+ if t.FinishTime != nil {
+ finishTime = t.FinishTime.Format("2006-01-02 15:04:05")
+ }
+ deadline := ""
+ if t.Deadline != nil {
+ deadline = t.Deadline.Format("2006-01-02 15:04:05")
+ }
+ return map[string]interface{}{
+ "id": t.ID,
+ "node_key": t.NodeKey,
+ "node_name": t.NodeName,
+ "approver_id": t.ApproverID,
+ "approver_name": t.ApproverName,
+ "multi_mode": t.MultiMode,
+ "status": t.Status,
+ "status_label": oaApprovalTaskText(t.Status),
+ "status_tag": oaApprovalTaskTag(t.Status),
+ "action": t.Action,
+ "comment": t.Comment,
+ "is_current": isCurrent,
+ "deadline": deadline,
+ "finish_time": finishTime,
+ "create_time": t.CreateTime.Format("2006-01-02 15:04:05"),
+ }
+}
+
+// ============================ 路由方法 ============================
+
+// ApprovalCategories GET /api/oa/approval/categories
+// 返回本租户可用的 biz_type 下拉项,移动端「发起审批」选择单据类型使用。
+func (c *AppOaController) ApprovalCategories() {
+ claims, ok := oaApprovalClaims(c)
+ if !ok {
+ return
+ }
+ c.oaOk(map[string]interface{}{
+ "list": oaApprovalBizTypes(uint64(claims.TenantId)),
+ "total": len(oaApprovalBizTypes(uint64(claims.TenantId))),
+ })
+}
+
+// ApprovalFlows GET /api/oa/approval/flows
+// 按 biz_type 返回本租户可发起的流程(含流程名 + 节点名列表),
+// 用于「选择流程」下拉。biz_type 为空时返回全部。
+func (c *AppOaController) ApprovalFlows() {
+ claims, ok := oaApprovalClaims(c)
+ if !ok {
+ return
+ }
+ tid := uint64(claims.TenantId)
+ biz := strings.TrimSpace(c.GetString("biz_type"))
+
+ qs := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
+ Filter("tid", tid).Filter("is_deleted", 0).Filter("status", 1)
+ if biz != "" {
+ qs = qs.Filter("biz_type", biz)
+ }
+ var flows []models.TenantApprovalFlow
+ _, _ = qs.OrderBy("is_default", "id").All(&flows)
+
+ list := make([]map[string]interface{}, 0, len(flows))
+ for _, f := range flows {
+ var nodes []models.TenantApprovalNode
+ _, _ = models.Orm.QueryTable(new(models.TenantApprovalNode)).
+ Filter("flow_id", f.ID).Filter("is_deleted", 0).
+ OrderBy("sort", "id").All(&nodes)
+
+ nodeNames := make([]string, 0, len(nodes))
+ for _, n := range nodes {
+ if n.NodeType == models.ApprovalNodeApproval {
+ nodeNames = append(nodeNames, n.NodeName)
+ }
+ }
+ list = append(list, map[string]interface{}{
+ "id": f.ID,
+ "flow_code": f.FlowCode,
+ "flow_name": f.FlowName,
+ "biz_type": f.BizType,
+ "module_code": f.ModuleCode,
+ "is_default": f.IsDefault,
+ "nodes": nodeNames,
+ })
+ }
+ c.oaOk(map[string]interface{}{"list": list, "total": len(list)})
+}
+
+// ApprovalList GET /api/oa/approval/list
+// 我发起的审批实例(移动端「我的发起」列表)。
+// 支持 keyword / status / biz_type 过滤。
+func (c *AppOaController) ApprovalList() {
+ claims, ok := oaApprovalClaims(c)
+ if !ok {
+ return
+ }
+ tid := uint64(claims.TenantId)
+ uid := oaApprovalSubmitUID(claims)
+
+ page, _ := c.GetInt("page", 1)
+ pageSize, _ := c.GetInt("pageSize", 20)
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 || pageSize > 100 {
+ pageSize = 20
+ }
+ keyword := strings.TrimSpace(c.GetString("keyword"))
+ statusStr := strings.TrimSpace(c.GetString("status"))
+ bizType := strings.TrimSpace(c.GetString("biz_type"))
+
+ qs := models.Orm.QueryTable(new(models.ApprovalInstance)).
+ Filter("scope", models.ApprovalScopeTenant).
+ Filter("tid", tid).
+ Filter("submit_user_id", uid).
+ Filter("is_deleted", 0)
+ if bizType != "" {
+ qs = qs.Filter("biz_type", bizType)
+ }
+ if statusStr != "" {
+ if s, err := strconv.Atoi(statusStr); err == nil {
+ qs = qs.Filter("status", int8(s))
+ }
+ }
+
+ total, _ := qs.Count()
+ var rows []models.ApprovalInstance
+ _, err := qs.OrderBy("-id").
+ Limit(pageSize).Offset((page - 1) * pageSize).All(&rows)
+ if err != nil && err != orm.ErrNoRows {
+ c.oaErr(500, 500, "查询失败")
+ return
+ }
+
+ list := make([]map[string]interface{}, 0, len(rows))
+ for _, inst := range rows {
+ item := oaApprovalInstanceView(inst)
+ if keyword != "" {
+ if !strings.Contains(strings.ToLower(inst.BizTitle), strings.ToLower(keyword)) {
+ continue
+ }
+ }
+ list = append(list, item)
+ }
+
+ c.oaOk(map[string]interface{}{
+ "list": list,
+ "total": total,
+ "page": page,
+ "pageSize": pageSize,
+ })
+}
+
+// ApprovalTodo GET /api/oa/approval/todo
+// 我的待办(作为审批人)。返回任务列表 + 关联实例基本信息,
+// 移动端「待我审批」直接展示。
+func (c *AppOaController) ApprovalTodo() {
+ claims, ok := oaApprovalClaims(c)
+ if !ok {
+ return
+ }
+ tid := uint64(claims.TenantId)
+ uid := oaApprovalSubmitUID(claims)
+
+ page, _ := c.GetInt("page", 1)
+ pageSize, _ := c.GetInt("pageSize", 20)
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 || pageSize > 100 {
+ pageSize = 20
+ }
+
+ qs := models.Orm.QueryTable(new(models.ApprovalTask)).
+ Filter("scope", models.ApprovalScopeTenant).
+ Filter("tid", tid).
+ Filter("approver_id", uid).
+ Filter("status", models.ApprovalTaskPending).
+ Filter("is_deleted", 0)
+ total, _ := qs.Count()
+
+ var tasks []models.ApprovalTask
+ _, err := qs.OrderBy("-id").
+ Limit(pageSize).Offset((page - 1) * pageSize).All(&tasks)
+ if err != nil && err != orm.ErrNoRows {
+ c.oaErr(500, 500, "查询失败")
+ return
+ }
+
+ list := make([]map[string]interface{}, 0, len(tasks))
+ for _, t := range tasks {
+ // 关联查询实例,校验实例仍在审批中(防止实例已结束但任务未及时同步)
+ var inst models.ApprovalInstance
+ if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
+ Filter("id", t.InstanceID).Filter("is_deleted", 0).One(&inst); err != nil {
+ continue
+ }
+ if inst.Status != models.ApprovalInstanceRunning {
+ continue
+ }
+ view := oaApprovalTaskView(t, true)
+ view["instance"] = oaApprovalInstanceView(inst)
+ list = append(list, view)
+ }
+
+ c.oaOk(map[string]interface{}{
+ "list": list,
+ "total": total,
+ "page": page,
+ "pageSize": pageSize,
+ })
+}
+
+// ApprovalDetail GET /api/oa/approval/detail/:id
+// 审批详情:实例基本信息 + 任务时间线 + 当前节点可执行动作。
+func (c *AppOaController) ApprovalDetail() {
+ claims, ok := oaApprovalClaims(c)
+ if !ok {
+ return
+ }
+ tid := uint64(claims.TenantId)
+ uid := oaApprovalSubmitUID(claims)
+
+ id := oaApprovalIDParam(c)
+ if id == 0 {
+ c.oaErr(400, 400, "无效ID")
+ return
+ }
+
+ var inst models.ApprovalInstance
+ if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
+ Filter("id", id).Filter("tid", tid).
+ Filter("scope", models.ApprovalScopeTenant).
+ Filter("is_deleted", 0).One(&inst); err != nil {
+ c.oaErr(404, 404, "审批不存在")
+ return
+ }
+
+ // 权限:仅发起人或当前节点的审批人可查看
+ // 两者独立判断:同一个用户既可能是发起人也可能是审批人(如自审批场景)
+ isSubmitter := inst.SubmitUserID == uid
+ isApprover := false
+ var currentTaskID uint64
+ {
+ var t models.ApprovalTask
+ err := models.Orm.QueryTable(new(models.ApprovalTask)).
+ Filter("instance_id", inst.ID).
+ Filter("approver_id", uid).
+ Filter("status", models.ApprovalTaskPending).
+ Filter("is_deleted", 0).One(&t)
+ if err == nil {
+ isApprover = true
+ currentTaskID = t.ID
+ }
+ }
+ if !isSubmitter && !isApprover {
+ c.oaErr(403, 403, "无权查看该审批")
+ return
+ }
+
+ tasks := oaApprovalTasksByInstance(inst.ID)
+ timeline := make([]map[string]interface{}, 0, len(tasks))
+ for _, t := range tasks {
+ timeline = append(timeline, oaApprovalTaskView(t, t.NodeKey == inst.CurrentNode))
+ }
+
+ c.oaOk(map[string]interface{}{
+ "item": oaApprovalInstanceView(inst),
+ "timeline": timeline,
+ "can_approve": isApprover,
+ "can_withdraw": isSubmitter && inst.Status == models.ApprovalInstanceRunning,
+ "current_task_id": currentTaskID,
+ })
+}
+
+// ApprovalSubmit POST /api/oa/approval/submit
+// 提交审批(发起)。引擎内部完成:创建实例 → 找首个审批节点 → 生成任务。
+func (c *AppOaController) ApprovalSubmit() {
+ claims, ok := oaApprovalClaims(c)
+ if !ok {
+ return
+ }
+
+ var p oaApprovalSubmitPayload
+ if !oaApprovalReadJSON(c, &p) {
+ return
+ }
+ if strings.TrimSpace(p.BizType) == "" {
+ c.oaErr(400, 400, "请选择单据类型")
+ return
+ }
+ if strings.TrimSpace(p.BizTitle) == "" {
+ c.oaErr(400, 400, "请填写单据标题")
+ return
+ }
+
+ tid := uint64(claims.TenantId)
+ uid := oaApprovalSubmitUID(claims)
+
+ // 发起人所在部门(用于审批引擎按部门主管匹配审批人)
+ var deptID uint64
+ var tu models.SystemTenantUser
+ if models.Orm.QueryTable(new(models.SystemTenantUser)).
+ Filter("uid", uid).Filter("tid", tid).
+ Filter("delete_time__isnull", true).One(&tu) == nil {
+ deptID = tu.OrgID
+ }
+
+ in := approvalEngine.SubmitInput{
+ Scope: models.ApprovalScopeTenant,
+ Tid: tid,
+ BizType: strings.TrimSpace(p.BizType),
+ FlowCode: strings.TrimSpace(p.FlowCode),
+ FlowID: p.FlowID,
+ BizID: p.BizID,
+ BizTitle: strings.TrimSpace(p.BizTitle),
+ SubmitUID: uid,
+ SubmitName: oaApprovalUserName(c, claims),
+ DeptID: deptID,
+ FormData: p.FormData,
+ }
+
+ instID, err := approvalEngine.Submit(in)
+ if err != nil {
+ // ErrNoFlow / ErrNoApprover 等业务错误统一以 400 返回
+ c.oaErr(400, 400, err.Error())
+ return
+ }
+
+ var inst models.ApprovalInstance
+ _ = models.Orm.QueryTable(new(models.ApprovalInstance)).
+ Filter("id", instID).One(&inst)
+
+ c.oaOk(map[string]interface{}{
+ "id": instID,
+ "item": oaApprovalInstanceView(inst),
+ })
+}
+
+// ApprovalApprove POST /api/oa/approval/approve
+// 审批人通过/驳回一条任务。
+func (c *AppOaController) ApprovalApprove() {
+ claims, ok := oaApprovalClaims(c)
+ if !ok {
+ return
+ }
+ tid := uint64(claims.TenantId)
+ uid := oaApprovalSubmitUID(claims)
+
+ var p oaApprovalApprovePayload
+ if !oaApprovalReadJSON(c, &p) {
+ return
+ }
+ if p.TaskID == 0 {
+ // 发起人视角在「我的发起」详情里也会看到「通过」按钮(操作入口对齐 Web 端),
+ // 但发起人不是当前审批人,没有可处理的 task:给一个明确的提示而不是含糊的「缺少任务ID」。
+ c.oaErr(403, 403, "您不是当前审批人,无法通过/驳回该审批")
+ return
+ }
+
+ // 校验任务归属本租户、当前审批人
+ var task models.ApprovalTask
+ if err := models.Orm.QueryTable(new(models.ApprovalTask)).
+ Filter("id", p.TaskID).
+ Filter("tid", tid).
+ Filter("scope", models.ApprovalScopeTenant).
+ Filter("is_deleted", 0).One(&task); err != nil {
+ c.oaErr(404, 404, "任务不存在")
+ return
+ }
+ if task.ApproverID != uid {
+ c.oaErr(403, 403, "您无权处理该任务")
+ return
+ }
+ if task.Status != models.ApprovalTaskPending {
+ c.oaErr(400, 400, "该任务已处理")
+ return
+ }
+
+ res, err := approvalEngine.Approve(p.TaskID, approvalEngine.ApproveInput{
+ ApproverID: uid,
+ Pass: p.Pass,
+ Comment: strings.TrimSpace(p.Comment),
+ })
+ if err != nil {
+ c.oaErr(500, 500, err.Error())
+ return
+ }
+
+ var inst models.ApprovalInstance
+ _ = models.Orm.QueryTable(new(models.ApprovalInstance)).
+ Filter("id", res.InstanceID).One(&inst)
+
+ c.oaOk(map[string]interface{}{
+ "finished": res.Finished,
+ "instance_stat": res.InstanceStat,
+ "current_node": res.CurrentNode,
+ "message": res.Message,
+ "item": oaApprovalInstanceView(inst),
+ })
+}
+
+// ApprovalWithdraw POST /api/oa/approval/withdraw/:id
+// 发起人撤回(仅审批中允许)。
+func (c *AppOaController) ApprovalWithdraw() {
+ claims, ok := oaApprovalClaims(c)
+ if !ok {
+ return
+ }
+ tid := uint64(claims.TenantId)
+ uid := oaApprovalSubmitUID(claims)
+
+ id := oaApprovalIDParam(c)
+ if id == 0 {
+ c.oaErr(400, 400, "无效ID")
+ return
+ }
+
+ var inst models.ApprovalInstance
+ if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
+ Filter("id", id).Filter("tid", tid).
+ Filter("scope", models.ApprovalScopeTenant).
+ Filter("is_deleted", 0).One(&inst); err != nil {
+ c.oaErr(404, 404, "审批不存在")
+ return
+ }
+ if inst.SubmitUserID != uid {
+ c.oaErr(403, 403, "仅发起人可撤回")
+ return
+ }
+ if inst.Status != models.ApprovalInstanceRunning {
+ c.oaErr(400, 400, "当前状态不允许撤回")
+ return
+ }
+
+ now := time.Now()
+ _, _ = models.Orm.QueryTable(new(models.ApprovalInstance)).
+ Filter("id", id).Update(orm.Params{
+ "status": models.ApprovalInstanceWithdrawn,
+ "finish_time": now,
+ "update_time": now,
+ })
+ // 同实例剩余待办置为已跳过
+ _, _ = models.Orm.QueryTable(new(models.ApprovalTask)).
+ Filter("instance_id", id).
+ Filter("status", models.ApprovalTaskPending).
+ Update(orm.Params{"status": models.ApprovalTaskSkipped, "update_time": now})
+
+ // 回写业务单据状态(如报销单置为已撤回),保持业务列表与审批中心一致
+ approvalEngine.SyncBizResult(id, models.ApprovalInstanceWithdrawn)
+
+ var updated models.ApprovalInstance
+ _ = models.Orm.QueryTable(new(models.ApprovalInstance)).
+ Filter("id", id).One(&updated)
+ c.oaOk(map[string]interface{}{"item": oaApprovalInstanceView(updated)})
+}
diff --git a/go/controllers/backend_reimburse.go b/go/controllers/backend_reimburse.go
index 4812114..76b58dd 100644
--- a/go/controllers/backend_reimburse.go
+++ b/go/controllers/backend_reimburse.go
@@ -13,6 +13,7 @@ import (
"server/models"
"server/pkg/jwtutil"
"server/services"
+ approvalEngine "server/services/approval"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
@@ -377,6 +378,8 @@ func reimburseListData(claims *jwtutil.Claims, page, pageSize int, keyword strin
}
list := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
+ // 审批已结束但业务状态未及时回写时,读取即自愈
+ row.Status = syncReimburseStatusFromInstance(&row)
list = append(list, map[string]interface{}{
"id": row.ID,
"tid": row.Tid,
@@ -430,9 +433,58 @@ func reimburseDetailData(claims *jwtutil.Claims, id uint64, scheme, host string)
if err != nil && err != orm.ErrNoRows {
return nil, 500, "查询明细失败: " + err.Error()
}
+ // 审批已结束但业务状态未及时回写时,读取即自愈
+ row.Status = syncReimburseStatusFromInstance(row)
return reimbursementDTO(*row, items, scheme, host), 200, ""
}
+// syncReimburseStatusFromInstance 报销单状态自愈:单据处于「审批中」时,
+// 按审批引擎中对应实例(biz_type=reimburse)的最终态回写业务状态。
+// 用于兜底引擎回写异常/历史数据,列表与详情读取时调用,无变化时原样返回。
+func syncReimburseStatusFromInstance(row *models.BackendReimbursement) int8 {
+ if row == nil {
+ return 0
+ }
+ if row.Status != 1 {
+ return row.Status
+ }
+ inst, ok := findReimburseInstance(row.Tid, row.ID)
+ if !ok || inst.Status == models.ApprovalInstanceRunning {
+ return row.Status
+ }
+ approvalEngine.SyncBizResult(inst.ID, inst.Status)
+ switch inst.Status {
+ case models.ApprovalInstanceApproved:
+ return 2
+ case models.ApprovalInstanceRejected:
+ return 3
+ case models.ApprovalInstanceWithdrawn:
+ return 4
+ }
+ return row.Status
+}
+
+// findReimburseInstance 查报销单对应的审批实例。
+// 兼容两种 biz_type 写法(reimburse / 早期的 oa),并按 tid、biz_tid 依次匹配,
+// 这样租户侧与平台侧产生的实例都能找到。
+func findReimburseInstance(tid, bizID uint64) (*models.ApprovalInstance, bool) {
+ if tid == 0 || bizID == 0 {
+ return nil, false
+ }
+ for _, key := range []string{"tid", "biz_tid"} {
+ var inst models.ApprovalInstance
+ if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
+ Filter(key, tid).
+ Filter("biz_type__in", models.ApprovalBizReimburse, approvalEngine.BizTypeLegacyOA).
+ Filter("biz_id", bizID).
+ Filter("is_deleted", 0).
+ OrderBy("-id").One(&inst); err == nil {
+ return &inst, true
+ }
+ }
+ return nil, false
+}
+
// Create POST /backend/reimbursements
func (c *BackendReimburseController) Create() {
claims, ok := c.claims()
@@ -827,8 +879,12 @@ func (c *BackendReimburseController) Submit() {
c.reply(200, "success", nil)
}
-// reimburseSubmitCore 提交审批共享逻辑:仅草稿可提交,需租户已配置启用审批流,
-// 提交后状态置为审批中(1),并写入 action=1 的审批记录。
+// reimburseSubmitCore 提交审批共享逻辑:仅草稿可提交,需租户已配置启用审批流。
+//
+// 提交进入统一审批引擎(biz_type=reimburse、biz_id=报销单ID),引擎负责节点流转;
+// 审批最终结果由 services/approval.SyncBizResult 回写到报销单状态,保证
+// 「审批中心」与「报销管理」看到的状态一致。
+// 提交后报销单置为审批中(1),并写入 action=1 的审批记录供业务详情时间线展示。
func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
row, err := findOwnedReimbursement(claims, id)
if err != nil {
@@ -837,24 +893,57 @@ func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
if row.Status != 0 {
return 400, "当前状态不可提交"
}
- var flow models.BackendApprovalFlow
- err = models.Orm.QueryTable(new(models.BackendApprovalFlow)).
- Filter("tid", claims.TenantId).Filter("is_active", 1).Filter("is_deleted", 0).
- OrderBy("step_order").One(&flow)
- if err != nil {
+ tid := uint64(claims.TenantId)
+ flowID := findReimburseFlowID(tid)
+ if flowID == 0 {
return 400, "尚未配置审批流程"
}
+
+ instID, err := approvalEngine.Submit(approvalEngine.SubmitInput{
+ Scope: models.ApprovalScopeTenant,
+ Tid: tid,
+ FlowID: flowID,
+ BizType: models.ApprovalBizReimburse,
+ BizID: id,
+ BizTitle: reimburseBizTitle(row),
+ SubmitUID: uint64(claims.UserID),
+ SubmitName: claims.Username,
+ DeptID: reimburseSubmitDeptID(tid, uint64(claims.UserID)),
+ FormData: map[string]interface{}{
+ "id": id,
+ "total_amount": row.TotalAmount,
+ "apply_date": row.ApplyDate.Format("2006-01-02"),
+ "description": reimburseDescription(row),
+ "department_id": row.DepartmentID,
+ },
+ })
+ if err != nil {
+ return 400, err.Error()
+ }
+
+ // 流程未配置审批节点时引擎会直接通过,此时报销单同步为已通过(2)
+ status := int8(1)
+ var inst models.ApprovalInstance
+ if e := models.Orm.QueryTable(new(models.ApprovalInstance)).Filter("id", instID).One(&inst); e == nil {
+ switch inst.Status {
+ case models.ApprovalInstanceApproved:
+ status = 2
+ case models.ApprovalInstanceRejected:
+ status = 3
+ }
+ }
+
now := time.Now()
_, err = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).Update(orm.Params{
- "status": 1, "current_step": flow.StepOrder, "current_approver_id": 0, "update_time": now,
+ "status": status, "current_step": 0, "current_approver_id": 0, "update_time": now,
})
if err != nil {
return 500, "提交失败"
}
_, err = models.Orm.Insert(&models.BackendApprovalRecord{
- Tid: uint64(claims.TenantId), UID: uint64Ptr(uint64(claims.UserID)), ReimbursementID: id,
- ApproverID: uint64(claims.UserID), ApproverName: claims.Username, Action: 1,
- Step: flow.StepOrder, PrevStep: 0,
+ Tid: tid, UID: uint64Ptr(uint64(claims.UserID)), ReimbursementID: id,
+ ApproverID: uint64(claims.UserID), ApproverName: claims.Username, Action: models.ApprovalActionSubmit,
+ Step: 0, PrevStep: 0,
})
if err != nil {
return 500, "提交审批记录失败"
@@ -862,6 +951,52 @@ func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
return 200, ""
}
+// findReimburseFlowID 查找报销单可用的启用审批流:
+// 优先精确匹配 biz_type=reimburse;若租户未区分单据类型(只按模块 oa 配置),回退到模块流程。
+func findReimburseFlowID(tid uint64) uint64 {
+ var flow models.TenantApprovalFlow
+ if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
+ Filter("tid", tid).Filter("biz_type", models.ApprovalBizReimburse).
+ Filter("status", 1).Filter("is_deleted", 0).
+ OrderBy("-is_default", "-id").One(&flow); err == nil {
+ return flow.ID
+ }
+ if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
+ Filter("tid", tid).Filter("module_code", "oa").
+ Filter("status", 1).Filter("is_deleted", 0).
+ Filter("biz_type__in", "", "oa").
+ OrderBy("-is_default", "-id").One(&flow); err == nil {
+ return flow.ID
+ }
+ return 0
+}
+
+// reimburseSubmitDeptID 取发起人所属组织ID,供审批引擎按「部门主管」规则解析审批人。
+func reimburseSubmitDeptID(tid, uid uint64) uint64 {
+ var tu models.SystemTenantUser
+ if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
+ Filter("uid", uid).Filter("tid", tid).
+ Filter("delete_time__isnull", true).One(&tu); err != nil {
+ return 0
+ }
+ return tu.OrgID
+}
+
+// reimburseBizTitle 审批标题:优先报销说明,其次带单据号。
+func reimburseBizTitle(row *models.BackendReimbursement) string {
+ if desc := reimburseDescription(row); desc != "" {
+ return desc
+ }
+ return fmt.Sprintf("报销单 #%d", row.ID)
+}
+
+func reimburseDescription(row *models.BackendReimbursement) string {
+ if row == nil || row.Description == nil {
+ return ""
+ }
+ return strings.TrimSpace(*row.Description)
+}
+
type reimbursementStatusPayload struct {
Status int8 `json:"status"`
}
diff --git a/go/routers/api/api.go b/go/routers/api/api.go
index f32d57a..386de8a 100644
--- a/go/routers/api/api.go
+++ b/go/routers/api/api.go
@@ -126,6 +126,16 @@ func Register() {
beego.Router("/api/oa/contact/org-tree", &controllers.AppOaController{}, "get:ContactOrgTree")
beego.Router("/api/oa/contact/:id", &controllers.AppOaController{}, "get:ContactDetail")
+ // 自定义审批流(移动端:我发起的 / 待我审批 / 详情 / 提交 / 通过驳回 / 撤回)
+ beego.Router("/api/oa/approval/categories", &controllers.AppOaController{}, "get:ApprovalCategories")
+ beego.Router("/api/oa/approval/flows", &controllers.AppOaController{}, "get:ApprovalFlows")
+ beego.Router("/api/oa/approval/list", &controllers.AppOaController{}, "get:ApprovalList")
+ beego.Router("/api/oa/approval/todo", &controllers.AppOaController{}, "get:ApprovalTodo")
+ beego.Router("/api/oa/approval/submit", &controllers.AppOaController{}, "post:ApprovalSubmit")
+ beego.Router("/api/oa/approval/approve", &controllers.AppOaController{}, "post:ApprovalApprove")
+ beego.Router("/api/oa/approval/withdraw/:id", &controllers.AppOaController{}, "post:ApprovalWithdraw")
+ beego.Router("/api/oa/approval/detail/:id", &controllers.AppOaController{}, "get:ApprovalDetail")
+
// 活动日志
beego.Router("/api/activity/list", &controllers.AppActivityController{}, "get:GetList")
diff --git a/go/services/approval/engine.go b/go/services/approval/engine.go
index 06ab29d..b08f3f1 100644
--- a/go/services/approval/engine.go
+++ b/go/services/approval/engine.go
@@ -985,6 +985,127 @@ func finishInstance(instID uint64, status int8) {
"finish_time": now,
"update_time": now,
})
+ SyncBizResult(instID, status)
+}
+
+// 报销单状态(与 yz_backend_reimbursements.status / 前端字典 oa_reimburse_status 一致)
+const (
+ reimburseStatusApproved int8 = 2 // 已通过
+ reimburseStatusRejected int8 = 3 // 已驳回
+ reimburseStatusWithdrawn int8 = 4 // 已撤回
+)
+
+// BizTypeLegacyOA 早期报销单提交进审批引擎时写入的 biz_type(按模块 code 记录)。
+// 与 ApprovalBizReimburse 一并识别,保证按老写法产生的在途/历史单据也能正常回写。
+const BizTypeLegacyOA = "oa"
+
+// IsReimburseBizType 判断审批实例是否属于 OA 报销单
+func IsReimburseBizType(bizType string) bool {
+ switch strings.TrimSpace(bizType) {
+ case models.ApprovalBizReimburse, BizTypeLegacyOA:
+ return true
+ }
+ return false
+}
+
+// SyncBizResult 把审批实例的最终结果回写到对应业务单据,保证「审批中心」与业务列表状态一致。
+//
+// 目前覆盖 OA 报销单(biz_type=reimburse / 老写法 oa):按实例状态同步
+// yz_backend_reimbursements.status,并补一条审批记录供业务详情页时间线展示。
+// 撤回入口(不走 finishInstance)也会显式调用本函数,重复调用按最终态幂等。
+func SyncBizResult(instID uint64, status int8) {
+ if instID == 0 {
+ return
+ }
+ var inst models.ApprovalInstance
+ if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
+ Filter("id", instID).One(&inst); err != nil {
+ return
+ }
+ // 无业务单据ID的实例(纯手工审批)不回写任何业务表
+ if inst.BizID == 0 || !IsReimburseBizType(inst.BizType) {
+ return
+ }
+ syncReimburseResult(&inst, status)
+}
+
+// bizTenantID 业务单据所属租户:平台侧实例的 tid 恒为 0,来源租户记在 biz_tid。
+func bizTenantID(inst *models.ApprovalInstance) uint64 {
+ if inst.Scope == models.ApprovalScopePlatform && inst.BizTid != 0 {
+ return inst.BizTid
+ }
+ return inst.Tid
+}
+
+// syncReimburseResult 回写报销单状态:审批通过/驳回/撤回分别对应业务状态 2/3/4。
+func syncReimburseResult(inst *models.ApprovalInstance, status int8) {
+ var bizStatus, action int8
+ switch status {
+ case models.ApprovalInstanceApproved:
+ bizStatus, action = reimburseStatusApproved, models.ApprovalActionApprove
+ case models.ApprovalInstanceRejected:
+ bizStatus, action = reimburseStatusRejected, models.ApprovalActionReject
+ case models.ApprovalInstanceWithdrawn:
+ bizStatus, action = reimburseStatusWithdrawn, models.ApprovalActionWithdraw
+ default:
+ return
+ }
+
+ tid := bizTenantID(inst)
+ if tid == 0 {
+ return
+ }
+ var row models.BackendReimbursement
+ if err := models.Orm.QueryTable(new(models.BackendReimbursement)).
+ Filter("id", inst.BizID).Filter("tid", tid).
+ Filter("is_deleted", 0).One(&row); err != nil {
+ return
+ }
+ // 已是目标状态说明同步过,避免重复插入审批记录
+ if row.Status == bizStatus {
+ return
+ }
+
+ now := time.Now()
+ if _, err := models.Orm.QueryTable(new(models.BackendReimbursement)).
+ Filter("id", inst.BizID).Filter("tid", tid).
+ Update(orm.Params{"status": bizStatus, "update_time": now}); err != nil {
+ return
+ }
+
+ // 业务详情页的时间线读 yz_backend_approval_records,这里补记一条审批结果
+ actorID, actorName, comment := lastApprovalActor(inst)
+ _, _ = models.Orm.Insert(&models.BackendApprovalRecord{
+ Tid: tid, UID: approvalUint64Ptr(actorID), ReimbursementID: inst.BizID,
+ ApproverID: actorID, ApproverName: actorName, Action: action,
+ Comment: approvalStringPtr(comment),
+ })
+}
+
+// lastApprovalActor 取实例上最近一次通过/驳回的审批人(撤回等场景回退到发起人)。
+func lastApprovalActor(inst *models.ApprovalInstance) (uint64, string, string) {
+ var task models.ApprovalTask
+ if err := models.Orm.QueryTable(new(models.ApprovalTask)).
+ Filter("instance_id", inst.ID).
+ Filter("status__in", models.ApprovalTaskApproved, models.ApprovalTaskRejected).
+ OrderBy("-finish_time", "-id").One(&task); err == nil {
+ return task.ApproverID, task.ApproverName, task.Comment
+ }
+ return inst.SubmitUserID, inst.SubmitUserName, ""
+}
+
+func approvalUint64Ptr(v uint64) *uint64 {
+ if v == 0 {
+ return nil
+ }
+ return &v
+}
+
+func approvalStringPtr(s string) *string {
+ if s == "" {
+ return nil
+ }
+ return &s
}
// ============================ 运行时:审批 ============================
diff --git a/uniapp/api/approval.js b/uniapp/api/approval.js
new file mode 100644
index 0000000..3c01b8a
--- /dev/null
+++ b/uniapp/api/approval.js
@@ -0,0 +1,227 @@
+/**
+ * 自定义审批流(移动端)接口封装。
+ * 对应后端 go/controllers/app_approval.go 的 /api/oa/approval/* 接口。
+ *
+ * 数据模型:yz_approval_instance(实例)+ yz_approval_task(任务)。
+ * 与 Web 端 /backend/approval/* 共用同一套数据与 services/approval 引擎,
+ * 仅鉴权放宽为 app 令牌(oaClaims)。
+ */
+import { request } from '@/api/request.js'
+
+// ============================ 常量 ============================
+
+/** 实例状态:0-草稿 1-审批中 2-已通过 3-已驳回 4-已撤回 5-已取消 */
+export const APPROVAL_INSTANCE_STATUS_OPTIONS = [
+ { value: 0, label: '草稿' },
+ { value: 1, label: '审批中' },
+ { value: 2, label: '已通过' },
+ { value: 3, label: '已驳回' },
+ { value: 4, label: '已撤回' },
+ { value: 5, label: '已取消' }
+]
+
+/** 任务状态:0-待审批 1-已通过 2-已驳回 3-已退回 4-已跳过 5-已超时 6-已通知 */
+export const APPROVAL_TASK_STATUS_OPTIONS = [
+ { value: 0, label: '待审批' },
+ { value: 1, label: '已通过' },
+ { value: 2, label: '已驳回' },
+ { value: 3, label: '已退回' },
+ { value: 4, label: '已跳过' },
+ { value: 5, label: '已超时' },
+ { value: 6, label: '已通知' }
+]
+
+/** 多签方式:1-或签(任一通过)2-会签(全部通过) */
+export const APPROVAL_MULTI_MODE_OPTIONS = [
+ { value: 1, label: '或签' },
+ { value: 2, label: '会签' }
+]
+
+/** uview-plus u-tag 类型映射 */
+const APPROVAL_STATUS_TAG = {
+ 0: 'info', // 草稿
+ 1: 'warning', // 审批中
+ 2: 'success', // 已通过
+ 3: 'error', // 已驳回
+ 4: 'info', // 已撤回
+ 5: 'info' // 已取消
+}
+
+const APPROVAL_TASK_TAG = {
+ 0: 'warning',
+ 1: 'success',
+ 2: 'error',
+ 3: 'info',
+ 4: 'info',
+ 5: 'info',
+ 6: 'info'
+}
+
+// ============================ 工具函数 ============================
+
+function labelOf(options, value, fallback = '') {
+ const hit = options.find(item => item.value === Number(value))
+ return hit ? hit.label : fallback
+}
+
+export function getApprovalStatusLabel(value) {
+ return labelOf(APPROVAL_INSTANCE_STATUS_OPTIONS, value, '未知')
+}
+
+export function getApprovalStatusTag(value) {
+ return APPROVAL_STATUS_TAG[Number(value)] || 'info'
+}
+
+export function getApprovalTaskStatusLabel(value) {
+ return labelOf(APPROVAL_TASK_STATUS_OPTIONS, value, '未知')
+}
+
+export function getApprovalTaskStatusTag(value) {
+ return APPROVAL_TASK_TAG[Number(value)] || 'info'
+}
+
+export function getApprovalMultiModeLabel(value) {
+ return labelOf(APPROVAL_MULTI_MODE_OPTIONS, value, '或签')
+}
+
+// ============================ 单据类型 / 流程 ============================
+
+/**
+ * 获取本租户可用的单据类型(biz_type)下拉项。
+ * 用于「发起审批」选择单据类型。
+ */
+export async function fetchApprovalCategories() {
+ const data = await request({ url: '/api/oa/approval/categories' })
+ const list = (data && data.list) || []
+ return list
+}
+
+/**
+ * 按 biz_type 获取本租户可发起的流程列表(含 flow_name + 节点名)。
+ */
+export async function fetchApprovalFlows(params = {}) {
+ const data = await request({
+ url: '/api/oa/approval/flows',
+ data: {
+ biz_type: params.bizType || ''
+ }
+ })
+ return { list: (data && data.list) || [], total: (data && data.total) || 0 }
+}
+
+// ============================ 我的发起 ============================
+
+/**
+ * 获取我发起的审批实例列表(移动端「我的发起」页面)。
+ * 入参:page / pageSize / keyword / status / biz_type
+ * 返回:{ list, total, page, pageSize }
+ */
+export async function fetchApprovalList(params = {}) {
+ const data = await request({
+ url: '/api/oa/approval/list',
+ data: {
+ page: params.page || 1,
+ pageSize: params.pageSize || 20,
+ keyword: params.keyword || '',
+ status: params.status === '' || params.status === undefined ? '' : Number(params.status),
+ biz_type: params.bizType || ''
+ }
+ })
+ return {
+ list: (data && data.list) || [],
+ total: (data && data.total) || 0,
+ page: (data && data.page) || 1,
+ pageSize: (data && data.pageSize) || 20
+ }
+}
+
+// ============================ 待我审批 ============================
+
+/**
+ * 获取我作为审批人的待办列表(移动端「待我审批」)。
+ * 每条 task 包含当前实例的基本信息(task.instance 字段)。
+ */
+export async function fetchApprovalTodoList(params = {}) {
+ const data = await request({
+ url: '/api/oa/approval/todo',
+ data: {
+ page: params.page || 1,
+ pageSize: params.pageSize || 20
+ }
+ })
+ return {
+ list: (data && data.list) || [],
+ total: (data && data.total) || 0,
+ page: (data && data.page) || 1,
+ pageSize: (data && data.pageSize) || 20
+ }
+}
+
+// ============================ 详情 ============================
+
+/**
+ * 获取审批详情(含实例 + 任务时间线 + 权限标志)。
+ * 返回结构:{ item, timeline, can_approve, can_withdraw, current_task_id }
+ */
+export async function fetchApprovalDetail(id) {
+ if (!id) {
+ throw new Error('缺少审批ID')
+ }
+ const data = await request({ url: `/api/oa/approval/detail/${id}` })
+ if (!data) return null
+ return {
+ item: data.item || {},
+ timeline: data.timeline || [],
+ canApprove: !!data.can_approve,
+ canWithdraw: !!data.can_withdraw,
+ currentTaskID: data.current_task_id || 0
+ }
+}
+
+// ============================ 发起 / 审批 / 撤回 ============================
+
+/**
+ * 提交审批(发起)。
+ * payload: { biz_type, flow_code?, flow_id?, biz_title, biz_id?, form_data? }
+ */
+export async function submitApproval(payload) {
+ return request({
+ url: '/api/oa/approval/submit',
+ method: 'POST',
+ data: {
+ biz_type: payload.bizType || '',
+ flow_code: payload.flowCode || '',
+ flow_id: payload.flowId || 0,
+ biz_title: payload.bizTitle || '',
+ biz_id: payload.bizId || 0,
+ form_data: payload.formData || {}
+ }
+ })
+}
+
+/**
+ * 审批人通过 / 驳回任务。
+ * payload: { task_id, pass, comment }
+ */
+export async function approveTask(payload) {
+ return request({
+ url: '/api/oa/approval/approve',
+ method: 'POST',
+ data: {
+ task_id: Number(payload.taskId) || 0,
+ pass: !!payload.pass,
+ comment: payload.comment || ''
+ }
+ })
+}
+
+/**
+ * 发起人撤回审批(仅审批中可撤回)。
+ */
+export async function withdrawApproval(id) {
+ if (!id) throw new Error('缺少审批ID')
+ return request({
+ url: `/api/oa/approval/withdraw/${id}`,
+ method: 'POST'
+ })
+}
diff --git a/uniapp/components/ApprovalHeader.vue b/uniapp/components/ApprovalHeader.vue
new file mode 100644
index 0000000..2e88411
--- /dev/null
+++ b/uniapp/components/ApprovalHeader.vue
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+ {{ backText }}
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/uniapp/components/sub/SubFunctions.vue b/uniapp/components/sub/SubFunctions.vue
index 443e449..581726d 100644
--- a/uniapp/components/sub/SubFunctions.vue
+++ b/uniapp/components/sub/SubFunctions.vue
@@ -63,7 +63,7 @@ import { mpHeadPad } from '@/utils/mpHead.js'
const headPad = mpHeadPad()
import { onShow } from '@dcloudio/uni-app'
import { isLoggedIn } from '@/utils/auth.js'
-import { openMenu } from '@/utils/nav.js'
+import { openMenu, WEB_RULES } from '@/utils/nav.js'
import { useSubsystem } from '@/utils/subsystem.js'
import AppTabbar from '@/components/AppTabbar.vue'
@@ -88,13 +88,25 @@ const fallbackIcon = computed(() => {
return map[String(sub.module || '').toLowerCase()] || 'file-lines'
})
+/** 判断菜单项是否命中 WEB_RULES(小程序端不再展示,由 Web 端处理) */
+function isWebEntry(menu) {
+ if (!menu) return false
+ const title = String(menu.title || '')
+ const path = String(menu.path || '')
+ return WEB_RULES.some(kw => title.includes(kw) || path.includes(kw))
+}
+
/**
* 按「一级分类目录」分组:
* 菜单层级形如 模块根 → 个人办公 → 通讯录,分组取一级目录(个人办公 / 人事管理 …),
* 组内为该目录下的全部可见功能。无分类归属的快捷功能不在功能页展示,由首页「常用功能」承载。
*/
const groups = computed(() => {
- const menus = allMenus.value.slice().sort((a, b) => (a.sort || 0) - (b.sort || 0))
+ // 先把命中 WEB_RULES 的菜单项过滤掉(这些是 Web 端入口,小程序功能页不展示)
+ const menus = allMenus.value
+ .filter(m => !isWebEntry(m))
+ .slice()
+ .sort((a, b) => (a.sort || 0) - (b.sort || 0))
const byId = {}
menus.forEach((m) => { byId[m.id] = m })
diff --git a/uniapp/pages.json b/uniapp/pages.json
index 66e8424..9ff1388 100644
--- a/uniapp/pages.json
+++ b/uniapp/pages.json
@@ -366,6 +366,27 @@
"navigationBarTitleText": "报销详情",
"navigationBarBackgroundColor": "#F8FAFC"
}
+ },
+ {
+ "path": "pages/approval/index",
+ "style": {
+ "navigationBarTitleText": "审批列表",
+ "navigationBarBackgroundColor": "#F8FAFC"
+ }
+ },
+ {
+ "path": "pages/approval/detail",
+ "style": {
+ "navigationBarTitleText": "审批详情",
+ "navigationBarBackgroundColor": "#F8FAFC"
+ }
+ },
+ {
+ "path": "pages/approval/verify",
+ "style": {
+ "navigationBarTitleText": "待我审批",
+ "navigationBarBackgroundColor": "#F8FAFC"
+ }
}
],
"globalStyle": {
diff --git a/uniapp/pages/approval/detail.vue b/uniapp/pages/approval/detail.vue
new file mode 100644
index 0000000..e6aa73a
--- /dev/null
+++ b/uniapp/pages/approval/detail.vue
@@ -0,0 +1,653 @@
+
+
+
+
+
+
+
+
+ {{ item.biz_title || '审批详情' }}
+ {{ item.biz_type || '' }}
+
+
+
+
+
+
+
+
+ 基本信息
+
+ 单据编号
+ #{{ item.id || '-' }}
+
+
+ 单据类型
+ {{ item.biz_type || '-' }}
+
+
+ 发起人
+ {{ item.submit_user_name || '-' }}
+
+
+ 提交时间
+ {{ item.submit_time || item.create_time || '-' }}
+
+
+ 完成时间
+ {{ item.finish_time }}
+
+
+ 当前节点
+ {{ item.current_node }}
+
+
+
+
+
+ 审批流程
+ 暂无流程节点
+
+
+
+
+
+
+
+ {{ task.node_name || task.node_key }}
+
+
+
+ 当前节点 · 等待 {{ task.approver_name || '审批人' }} 处理
+
+
+ {{ task.approver_name || '-' }} · 通过于 {{ task.finish_time }}
+
+
+
+ {{ task.approver_name || '-' }} · 驳回于 {{ task.finish_time }}
+
+
+
+ {{ task.approver_name || '-' }} · {{ task.finish_time || task.create_time }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 驳回
+ 通过
+
+
+
+ 撤回审批
+
+
+
+
+
+
+
+ {{ approveForm.pass ? '通过审批' : '驳回审批' }}
+ ✕
+
+
+ 单据
+ {{ item.biz_title || '-' }}
+
+
+ 节点
+ {{ currentNodeLabel || '-' }}
+
+
+ 审批意见
+
+
+
+
+
+
+
+
+
+
+
diff --git a/uniapp/pages/approval/index.json b/uniapp/pages/approval/index.json
new file mode 100644
index 0000000..08eb52a
--- /dev/null
+++ b/uniapp/pages/approval/index.json
@@ -0,0 +1,7 @@
+{
+ "path": "pages/approval/index",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "审批列表"
+ }
+}
diff --git a/uniapp/pages/approval/index.vue b/uniapp/pages/approval/index.vue
new file mode 100644
index 0000000..1c49cfe
--- /dev/null
+++ b/uniapp/pages/approval/index.vue
@@ -0,0 +1,469 @@
+
+
+
+
+
+
+
+ 清空
+
+
+
+
+
+ {{ tab.label }}
+
+
+
+
+
+
+
+ 加载中...
+
+
+
+
+
+
+
+ {{ row.biz_title || '(无标题)' }}
+
+
+
+ {{ row.biz_type || '-' }}
+ 发起人:{{ row.submit_user_name || '-' }}
+
+
+ 提交:{{ row.submit_time || row.create_time || '-' }}
+ 完成:{{ row.finish_time }}
+
+
+ 当前节点:{{ row.current_node }}
+
+
+ 查看详情
+
+
+
+ {{ list.length ? '没有更多了' : '' }}
+
+
+ 加载更多
+
+
+
+
+
+
+
+
+
+
diff --git a/uniapp/pages/approval/verify.vue b/uniapp/pages/approval/verify.vue
new file mode 100644
index 0000000..ba3c432
--- /dev/null
+++ b/uniapp/pages/approval/verify.vue
@@ -0,0 +1,309 @@
+
+
+
+
+
+
+
+ 清空
+
+
+
+
+ 共 {{ total }} 条待办
+
+
+
+
+
+ 加载中...
+
+
+
+
+
+
+
+ {{ task.instance && task.instance.biz_title ? task.instance.biz_title : '(无标题)' }}
+
+
+
+ 节点:{{ task.node_name || '-' }}
+ 发起人:{{ task.instance && task.instance.submit_user_name || '-' }}
+
+
+ 单据:{{ task.instance && task.instance.biz_type || '-' }}
+ 提交:{{ task.instance && (task.instance.submit_time || task.instance.create_time) || '-' }}
+
+
+ 截止:{{ task.deadline }}
+
+
+
+ 查看详情
+
+
+
+ {{ list.length ? '没有更多了' : '' }}
+
+
+ 加载更多
+
+
+
+
+
+
+
+
+
+
diff --git a/uniapp/pages/tools/expense/detail.vue b/uniapp/pages/tools/expense/detail.vue
index 783629f..211e8ae 100644
--- a/uniapp/pages/tools/expense/detail.vue
+++ b/uniapp/pages/tools/expense/detail.vue
@@ -114,7 +114,7 @@