优化审批功能
This commit is contained in:
1 parent
184d5b0403
commit
395c7f717e
15 files changed
+2997
-16
No files matched your search
@@ -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)})
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"server/models"
|
"server/models"
|
||||||
"server/pkg/jwtutil"
|
"server/pkg/jwtutil"
|
||||||
"server/services"
|
"server/services"
|
||||||
|
approvalEngine "server/services/approval"
|
||||||
|
|
||||||
"github.com/beego/beego/v2/client/orm"
|
"github.com/beego/beego/v2/client/orm"
|
||||||
beego "github.com/beego/beego/v2/server/web"
|
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))
|
list := make([]map[string]interface{}, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
|
// 审批已结束但业务状态未及时回写时,读取即自愈
|
||||||
|
row.Status = syncReimburseStatusFromInstance(&row)
|
||||||
list = append(list, map[string]interface{}{
|
list = append(list, map[string]interface{}{
|
||||||
"id": row.ID,
|
"id": row.ID,
|
||||||
"tid": row.Tid,
|
"tid": row.Tid,
|
||||||
@@ -430,9 +433,58 @@ func reimburseDetailData(claims *jwtutil.Claims, id uint64, scheme, host string)
|
|||||||
if err != nil && err != orm.ErrNoRows {
|
if err != nil && err != orm.ErrNoRows {
|
||||||
return nil, 500, "查询明细失败: " + err.Error()
|
return nil, 500, "查询明细失败: " + err.Error()
|
||||||
}
|
}
|
||||||
|
// 审批已结束但业务状态未及时回写时,读取即自愈
|
||||||
|
row.Status = syncReimburseStatusFromInstance(row)
|
||||||
return reimbursementDTO(*row, items, scheme, host), 200, ""
|
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
|
// Create POST /backend/reimbursements
|
||||||
func (c *BackendReimburseController) Create() {
|
func (c *BackendReimburseController) Create() {
|
||||||
claims, ok := c.claims()
|
claims, ok := c.claims()
|
||||||
@@ -827,8 +879,12 @@ func (c *BackendReimburseController) Submit() {
|
|||||||
c.reply(200, "success", nil)
|
c.reply(200, "success", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// reimburseSubmitCore 提交审批共享逻辑:仅草稿可提交,需租户已配置启用审批流,
|
// reimburseSubmitCore 提交审批共享逻辑:仅草稿可提交,需租户已配置启用审批流。
|
||||||
// 提交后状态置为审批中(1),并写入 action=1 的审批记录。
|
//
|
||||||
|
// 提交进入统一审批引擎(biz_type=reimburse、biz_id=报销单ID),引擎负责节点流转;
|
||||||
|
// 审批最终结果由 services/approval.SyncBizResult 回写到报销单状态,保证
|
||||||
|
// 「审批中心」与「报销管理」看到的状态一致。
|
||||||
|
// 提交后报销单置为审批中(1),并写入 action=1 的审批记录供业务详情时间线展示。
|
||||||
func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
|
func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
|
||||||
row, err := findOwnedReimbursement(claims, id)
|
row, err := findOwnedReimbursement(claims, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -837,24 +893,57 @@ func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
|
|||||||
if row.Status != 0 {
|
if row.Status != 0 {
|
||||||
return 400, "当前状态不可提交"
|
return 400, "当前状态不可提交"
|
||||||
}
|
}
|
||||||
var flow models.BackendApprovalFlow
|
tid := uint64(claims.TenantId)
|
||||||
err = models.Orm.QueryTable(new(models.BackendApprovalFlow)).
|
flowID := findReimburseFlowID(tid)
|
||||||
Filter("tid", claims.TenantId).Filter("is_active", 1).Filter("is_deleted", 0).
|
if flowID == 0 {
|
||||||
OrderBy("step_order").One(&flow)
|
|
||||||
if err != nil {
|
|
||||||
return 400, "尚未配置审批流程"
|
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()
|
now := time.Now()
|
||||||
_, err = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).Update(orm.Params{
|
_, 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 {
|
if err != nil {
|
||||||
return 500, "提交失败"
|
return 500, "提交失败"
|
||||||
}
|
}
|
||||||
_, err = models.Orm.Insert(&models.BackendApprovalRecord{
|
_, err = models.Orm.Insert(&models.BackendApprovalRecord{
|
||||||
Tid: uint64(claims.TenantId), UID: uint64Ptr(uint64(claims.UserID)), ReimbursementID: id,
|
Tid: tid, UID: uint64Ptr(uint64(claims.UserID)), ReimbursementID: id,
|
||||||
ApproverID: uint64(claims.UserID), ApproverName: claims.Username, Action: 1,
|
ApproverID: uint64(claims.UserID), ApproverName: claims.Username, Action: models.ApprovalActionSubmit,
|
||||||
Step: flow.StepOrder, PrevStep: 0,
|
Step: 0, PrevStep: 0,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 500, "提交审批记录失败"
|
return 500, "提交审批记录失败"
|
||||||
@@ -862,6 +951,52 @@ func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
|
|||||||
return 200, ""
|
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 {
|
type reimbursementStatusPayload struct {
|
||||||
Status int8 `json:"status"`
|
Status int8 `json:"status"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,16 @@ func Register() {
|
|||||||
beego.Router("/api/oa/contact/org-tree", &controllers.AppOaController{}, "get:ContactOrgTree")
|
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/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")
|
beego.Router("/api/activity/list", &controllers.AppActivityController{}, "get:GetList")
|
||||||
|
|
||||||
|
|||||||
@@ -985,6 +985,127 @@ func finishInstance(instID uint64, status int8) {
|
|||||||
"finish_time": now,
|
"finish_time": now,
|
||||||
"update_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
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================ 运行时:审批 ============================
|
// ============================ 运行时:审批 ============================
|
||||||
|
|||||||
@@ -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'
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<view class="apv-head" :style="headPad">
|
||||||
|
<!-- 左侧:返回按钮 + 可选标题前缀 -->
|
||||||
|
<view class="apv-head-left">
|
||||||
|
<view class="apv-back" hover-class="apv-back-active" @tap="onBack">
|
||||||
|
<FaIcon name="chevron-left" color="#1f2b48" :size="20" />
|
||||||
|
</view>
|
||||||
|
<text v-if="backText" class="apv-back-text">{{ backText }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 中间:标题 -->
|
||||||
|
<view class="apv-head-center">
|
||||||
|
<text class="apv-title">{{ title }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 右侧:可选操作槽(默认占位,避免标题被挤偏) -->
|
||||||
|
<view class="apv-head-right">
|
||||||
|
<slot name="right" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { mpHeadPad } from '@/utils/mpHead.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** 标题 */
|
||||||
|
title: { type: String, required: true },
|
||||||
|
/** 返回按钮旁的文字(不传则只显示箭头图标)。常见「返回」「关闭」。 */
|
||||||
|
backText: { type: String, default: '' },
|
||||||
|
/**
|
||||||
|
* 自定义返回行为。函数返回 false 时阻止默认 navigateBack。
|
||||||
|
* 例:detail 页面有未保存修改时拦截。
|
||||||
|
*/
|
||||||
|
beforeBack: { type: Function, default: null }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['back'])
|
||||||
|
|
||||||
|
function onBack() {
|
||||||
|
if (typeof props.beforeBack === 'function' && props.beforeBack() === false) return
|
||||||
|
emit('back')
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
if (pages.length > 1) {
|
||||||
|
uni.navigateBack({ delta: 1 })
|
||||||
|
} else {
|
||||||
|
// 栈内只有自己(直链进入),回到子系统首页
|
||||||
|
uni.reLaunch({ url: '/pages/nav/nav' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态栏高度避让:胶囊可用则按胶囊底缘 + 8px,否则回退到 safe-area-inset-top
|
||||||
|
const headPad = computed(() => mpHeadPad(8))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/src/styles/page-common.scss';
|
||||||
|
|
||||||
|
.apv-head {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 88rpx;
|
||||||
|
padding: 0 $page-padding-x;
|
||||||
|
background: $color-card;
|
||||||
|
box-shadow: $shadow-header;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
/* #ifdef MP-WEIXIN */
|
||||||
|
padding-top: 0; /* mpHeadPad 已注入 paddingTop;这里不再叠加 */
|
||||||
|
/* #endif */
|
||||||
|
}
|
||||||
|
|
||||||
|
.apv-head-left,
|
||||||
|
.apv-head-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
min-width: 120rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apv-head-right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apv-head-center {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apv-back {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apv-back-active {
|
||||||
|
background: $color-bg-page;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apv-back-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apv-title {
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $color-text;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -63,7 +63,7 @@ import { mpHeadPad } from '@/utils/mpHead.js'
|
|||||||
const headPad = mpHeadPad()
|
const headPad = mpHeadPad()
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
import { isLoggedIn } from '@/utils/auth.js'
|
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 { useSubsystem } from '@/utils/subsystem.js'
|
||||||
import AppTabbar from '@/components/AppTabbar.vue'
|
import AppTabbar from '@/components/AppTabbar.vue'
|
||||||
|
|
||||||
@@ -88,13 +88,25 @@ const fallbackIcon = computed(() => {
|
|||||||
return map[String(sub.module || '').toLowerCase()] || 'file-lines'
|
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 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 = {}
|
const byId = {}
|
||||||
menus.forEach((m) => { byId[m.id] = m })
|
menus.forEach((m) => { byId[m.id] = m })
|
||||||
|
|
||||||
|
|||||||
@@ -366,6 +366,27 @@
|
|||||||
"navigationBarTitleText": "报销详情",
|
"navigationBarTitleText": "报销详情",
|
||||||
"navigationBarBackgroundColor": "#F8FAFC"
|
"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": {
|
"globalStyle": {
|
||||||
|
|||||||
@@ -0,0 +1,653 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<!-- 系统导航栏已带返回按钮 + 标题,无需自定义 header -->
|
||||||
|
|
||||||
|
<scroll-view class="body" scroll-y :show-scrollbar="false">
|
||||||
|
<!-- 顶部摘要 -->
|
||||||
|
<view class="summary">
|
||||||
|
<view class="summary-main">
|
||||||
|
<text class="summary-title">{{ item.biz_title || '审批详情' }}</text>
|
||||||
|
<text class="summary-sub">{{ item.biz_type || '' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="summary-status">
|
||||||
|
<u-tag
|
||||||
|
:text="item.status_label || ''"
|
||||||
|
:type="item.status_tag || 'info'"
|
||||||
|
size="medium"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<view class="card">
|
||||||
|
<text class="card-title">基本信息</text>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">单据编号</text>
|
||||||
|
<text class="info-value">#{{ item.id || '-' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">单据类型</text>
|
||||||
|
<text class="info-value">{{ item.biz_type || '-' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">发起人</text>
|
||||||
|
<text class="info-value">{{ item.submit_user_name || '-' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">提交时间</text>
|
||||||
|
<text class="info-value">{{ item.submit_time || item.create_time || '-' }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="item.finish_time" class="info-row">
|
||||||
|
<text class="info-label">完成时间</text>
|
||||||
|
<text class="info-value">{{ item.finish_time }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="item.current_node" class="info-row">
|
||||||
|
<text class="info-label">当前节点</text>
|
||||||
|
<text class="info-value">{{ item.current_node }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 审批流程时间线 -->
|
||||||
|
<view class="card">
|
||||||
|
<text class="card-title">审批流程</text>
|
||||||
|
<view v-if="!timeline.length" class="timeline-empty">暂无流程节点</view>
|
||||||
|
<view v-else class="timeline-list">
|
||||||
|
<view
|
||||||
|
v-for="(task, idx) in timeline"
|
||||||
|
:key="task.id || idx"
|
||||||
|
class="timeline-item"
|
||||||
|
:class="{ current: task.is_current }"
|
||||||
|
>
|
||||||
|
<view class="timeline-dot">
|
||||||
|
<view class="dot-inner" :class="dotClass(task)"></view>
|
||||||
|
</view>
|
||||||
|
<view class="timeline-content" v-if="idx === 0 || task.node_key !== timeline[idx - 1].node_key">
|
||||||
|
<view class="timeline-head">
|
||||||
|
<text class="node-name">{{ task.node_name || task.node_key }}</text>
|
||||||
|
<u-tag
|
||||||
|
:text="task.status_label || ''"
|
||||||
|
:type="task.status_tag || 'info'"
|
||||||
|
size="mini"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view v-if="task.is_current" class="timeline-row">
|
||||||
|
<text class="node-meta current">当前节点 · 等待 {{ task.approver_name || '审批人' }} 处理</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="task.status === 1" class="timeline-row">
|
||||||
|
<text class="node-meta">{{ task.approver_name || '-' }} · 通过于 {{ task.finish_time }}</text>
|
||||||
|
<text v-if="task.comment" class="node-comment">{{ task.comment }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="task.status === 2" class="timeline-row">
|
||||||
|
<text class="node-meta">{{ task.approver_name || '-' }} · 驳回于 {{ task.finish_time }}</text>
|
||||||
|
<text v-if="task.comment" class="node-comment">{{ task.comment }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-else class="timeline-row">
|
||||||
|
<text class="node-meta">{{ task.approver_name || '-' }} · {{ task.finish_time || task.create_time }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="bottom-space" />
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 底部操作栏:只有有操作权限时才显示 -->
|
||||||
|
<view v-if="showActionBar" class="action-bar">
|
||||||
|
<!-- 当前审批人:通过 + 驳回 -->
|
||||||
|
<view v-if="canApprove" class="btn-row">
|
||||||
|
<view class="btn-reject" hover-class="btn-active" @tap="openApprove(false)">驳回</view>
|
||||||
|
<view class="btn-pass" hover-class="btn-active" @tap="openApprove(true)">通过</view>
|
||||||
|
</view>
|
||||||
|
<!-- 发起人:仅撤回(不能审批自己的单子) -->
|
||||||
|
<view v-else-if="canWithdraw" class="btn-row">
|
||||||
|
<view class="btn-withdraw" hover-class="btn-active" @tap="doWithdraw">撤回审批</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 审批意见弹窗 -->
|
||||||
|
<view v-if="showApprove" class="modal-overlay" @tap="hideApprove">
|
||||||
|
<view class="modal-container" @tap.stop>
|
||||||
|
<view class="modal-head">
|
||||||
|
<text class="modal-title">{{ approveForm.pass ? '通过审批' : '驳回审批' }}</text>
|
||||||
|
<text class="modal-close" @tap="hideApprove">✕</text>
|
||||||
|
</view>
|
||||||
|
<view class="approve-summary">
|
||||||
|
<text class="summary-label">单据</text>
|
||||||
|
<text class="summary-value">{{ item.biz_title || '-' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="approve-summary">
|
||||||
|
<text class="summary-label">节点</text>
|
||||||
|
<text class="summary-value">{{ currentNodeLabel || '-' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="field">
|
||||||
|
<text class="field-label">审批意见</text>
|
||||||
|
<textarea
|
||||||
|
v-model="approveForm.comment"
|
||||||
|
class="field-textarea"
|
||||||
|
placeholder="请输入审批意见(选填)"
|
||||||
|
placeholder-class="field-placeholder"
|
||||||
|
rows="4"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="modal-footer">
|
||||||
|
<view
|
||||||
|
class="btn-primary"
|
||||||
|
:class="[approveForm.pass ? 'btn-pass' : 'btn-reject', { disabled: approving }]"
|
||||||
|
@tap="doApprove"
|
||||||
|
>
|
||||||
|
{{ approving ? '提交中...' : (approveForm.pass ? '确认通过' : '确认驳回') }}
|
||||||
|
</view>
|
||||||
|
<view class="btn-cancel" hover-class="btn-active" @tap="hideApprove">取消</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import {
|
||||||
|
fetchApprovalDetail,
|
||||||
|
approveTask,
|
||||||
|
withdrawApproval
|
||||||
|
} from '@/api/approval.js'
|
||||||
|
import { isLoggedIn } from '@/utils/auth.js'
|
||||||
|
|
||||||
|
const item = ref({})
|
||||||
|
const timeline = ref([])
|
||||||
|
const canApprove = ref(false)
|
||||||
|
const canWithdraw = ref(false)
|
||||||
|
const currentTaskID = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const showApprove = ref(false)
|
||||||
|
const approving = ref(false)
|
||||||
|
const approveForm = ref({ pass: true, comment: '' })
|
||||||
|
|
||||||
|
const showActionBar = computed(() => canApprove.value || canWithdraw.value)
|
||||||
|
|
||||||
|
// 当前节点名(审批弹窗展示用)
|
||||||
|
const currentNodeLabel = computed(() => {
|
||||||
|
const cur = timeline.value.find(t => t.is_current)
|
||||||
|
return cur ? (cur.node_name || cur.node_key) : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
onLoad((options) => {
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
uni.reLaunch({ url: '/pages/login/login' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = options && options.id
|
||||||
|
if (!id) {
|
||||||
|
uni.showToast({ title: '缺少审批ID', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadDetail(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadDetail(id) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await fetchApprovalDetail(id)
|
||||||
|
item.value = data.item || {}
|
||||||
|
timeline.value = data.timeline || []
|
||||||
|
canApprove.value = !!data.canApprove
|
||||||
|
canWithdraw.value = !!data.canWithdraw
|
||||||
|
currentTaskID.value = data.currentTaskID || 0
|
||||||
|
} catch (e) {
|
||||||
|
// request.js 已 toast
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dotClass(task) {
|
||||||
|
if (task.is_current) return 'dot-running'
|
||||||
|
if (task.status === 1) return 'dot-pass'
|
||||||
|
if (task.status === 2) return 'dot-reject'
|
||||||
|
return 'dot-default'
|
||||||
|
}
|
||||||
|
|
||||||
|
function openApprove(pass) {
|
||||||
|
approveForm.value = { pass, comment: '' }
|
||||||
|
showApprove.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideApprove() {
|
||||||
|
showApprove.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doApprove() {
|
||||||
|
if (approving.value) return
|
||||||
|
// currentTaskID 为 0 表示当前用户不是审批人(典型:发起人视角),
|
||||||
|
// 仍然把请求交给后端判定:后端会返回 403「您无权处理该任务」并由 request.js 弹 toast。
|
||||||
|
approving.value = true
|
||||||
|
try {
|
||||||
|
await approveTask({
|
||||||
|
taskId: currentTaskID.value || 0,
|
||||||
|
pass: approveForm.value.pass,
|
||||||
|
comment: approveForm.value.comment.trim()
|
||||||
|
})
|
||||||
|
uni.showToast({
|
||||||
|
title: approveForm.value.pass ? '已通过' : '已驳回',
|
||||||
|
icon: 'success'
|
||||||
|
})
|
||||||
|
hideApprove()
|
||||||
|
|
||||||
|
// 如果是报销单审批,跳转回报销详情页并触发刷新事件
|
||||||
|
// (biz_type 兼容新写法 reimburse 与早期按模块记录的 oa)
|
||||||
|
if (['reimburse', 'oa'].includes(item.value.biz_type) && item.value.biz_id) {
|
||||||
|
uni.$emit('expense-updated', { id: item.value.biz_id })
|
||||||
|
uni.redirectTo({
|
||||||
|
url: `/pages/tools/expense/detail?id=${item.value.biz_id}`
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDetail(item.value.id)
|
||||||
|
} catch (e) {
|
||||||
|
// request.js 已 toast
|
||||||
|
} finally {
|
||||||
|
approving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doWithdraw() {
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认撤回',
|
||||||
|
content: '撤回后该审批将立即结束,确认要撤回吗?',
|
||||||
|
confirmColor: '#f04438',
|
||||||
|
cancelText: '取消',
|
||||||
|
confirmText: '撤回',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
try {
|
||||||
|
await withdrawApproval(item.value.id)
|
||||||
|
uni.showToast({ title: '已撤回', icon: 'success' })
|
||||||
|
loadDetail(item.value.id)
|
||||||
|
} catch (e) {
|
||||||
|
// request.js 已 toast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/src/styles/page-common.scss';
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $color-bg-page;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 20rpx $page-padding-x;
|
||||||
|
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== 摘要区 ====== */
|
||||||
|
.summary {
|
||||||
|
@include card;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
padding: 28rpx 28rpx 24rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $color-text;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-sub {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== 卡片 ====== */
|
||||||
|
.card {
|
||||||
|
@include card;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
padding: 28rpx 28rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $color-text;
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 12rpx 0;
|
||||||
|
font-size: 26rpx;
|
||||||
|
border-bottom: 1rpx solid $color-divider;
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
width: 140rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: $color-text;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== 时间线 ====== */
|
||||||
|
.timeline-empty {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
padding: 16rpx 0 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-left: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 18rpx;
|
||||||
|
|
||||||
|
&:not(:last-child)::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 18rpx;
|
||||||
|
top: 30rpx;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2rpx;
|
||||||
|
background: $color-divider;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.current {
|
||||||
|
.node-name { color: $color-primary; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot {
|
||||||
|
width: 36rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-inner {
|
||||||
|
width: 20rpx;
|
||||||
|
height: 20rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $color-text-muted;
|
||||||
|
|
||||||
|
&.dot-default { background: $color-text-muted; }
|
||||||
|
&.dot-pass { background: #16a34a; }
|
||||||
|
&.dot-reject { background: #f04438; }
|
||||||
|
&.dot-running {
|
||||||
|
background: $color-primary;
|
||||||
|
box-shadow: 0 0 0 8rpx rgba(37, 99, 235, 0.16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
|
||||||
|
&:last-child { margin-bottom: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 节点头部:节点名 + 状态 tag 横排,tag 不要撑满 100% */
|
||||||
|
.timeline-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $color-text;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-meta {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
word-break: break-all;
|
||||||
|
|
||||||
|
&.current {
|
||||||
|
color: $color-primary;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-comment {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
background: $color-bg-page;
|
||||||
|
padding: 10rpx 14rpx;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-space {
|
||||||
|
height: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== 底部操作栏 ====== */
|
||||||
|
.action-bar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: $color-card;
|
||||||
|
padding: 20rpx $page-padding-x;
|
||||||
|
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||||
|
box-shadow: 0 -4rpx 16rpx rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pass,
|
||||||
|
.btn-reject,
|
||||||
|
.btn-withdraw {
|
||||||
|
flex: 1;
|
||||||
|
height: 88rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pass {
|
||||||
|
background: $color-primary;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reject {
|
||||||
|
background: #f04438;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-withdraw {
|
||||||
|
background: $color-bg-page;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
border: 1rpx solid $color-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-active { opacity: 0.85; }
|
||||||
|
|
||||||
|
/* ====== 审批弹窗 ====== */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
width: 640rpx;
|
||||||
|
max-height: 80vh;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24rpx 24rpx 16rpx;
|
||||||
|
border-bottom: 1rpx solid $color-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approve-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 8rpx;
|
||||||
|
padding: 12rpx 24rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
border-bottom: 1rpx dashed $color-border;
|
||||||
|
|
||||||
|
&:last-of-type { border-bottom: 1rpx solid $color-border; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
color: $color-text-muted;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 80rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
color: $color-text;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
padding: 20rpx 24rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 160rpx;
|
||||||
|
border: 1rpx solid $color-border;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: 16rpx 20rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $color-text;
|
||||||
|
background: $color-bg-page;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 16rpx;
|
||||||
|
border-top: 1rpx solid $color-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 16rpx 32rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
|
||||||
|
&.btn-pass { background: $color-primary; }
|
||||||
|
&.btn-reject { background: #f04438; }
|
||||||
|
&.disabled { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
padding: 16rpx 32rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
background: $color-bg-page;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"path": "pages/approval/index",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "审批列表"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<!-- 顶部工具栏(系统导航栏已带返回按钮 + 标题,这里只放搜索/筛选) -->
|
||||||
|
<view class="toolbar">
|
||||||
|
<view class="search-box">
|
||||||
|
<FaIcon name="magnifying-glass" color="#94a3b8" :size="16" />
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
v-model="keyword"
|
||||||
|
placeholder="搜索单据标题"
|
||||||
|
confirm-type="search"
|
||||||
|
@confirm="onSearch"
|
||||||
|
/>
|
||||||
|
<text v-if="keyword" class="search-clear" @tap="clearKeyword">清空</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 状态下拉面板 -->
|
||||||
|
<view
|
||||||
|
v-for="tab in statusTabs"
|
||||||
|
:key="String(tab.value)"
|
||||||
|
class="dropdown-option"
|
||||||
|
:class="{ active: currentStatus === tab.value }"
|
||||||
|
hover-class="dropdown-option-active"
|
||||||
|
@tap="onStatusChange(tab.value)"
|
||||||
|
>
|
||||||
|
<text class="dropdown-option-text">{{ tab.label }}</text>
|
||||||
|
<FaIcon v-if="currentStatus === tab.value" name="check" color="#2563eb" :size="14" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 列表 -->
|
||||||
|
<scroll-view class="list-scroll" scroll-y :show-scrollbar="false" @scrolltolower="loadMore">
|
||||||
|
<view v-if="loading && !list.length" class="state-box">
|
||||||
|
<text class="state-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="!list.length" class="state-box">
|
||||||
|
<u-empty mode="list" text="暂无审批记录" />
|
||||||
|
</view>
|
||||||
|
<block v-else>
|
||||||
|
<view
|
||||||
|
v-for="row in list"
|
||||||
|
:key="row.id"
|
||||||
|
class="approval-card"
|
||||||
|
>
|
||||||
|
<view class="card-top">
|
||||||
|
<text class="card-title">{{ row.biz_title || '(无标题)' }}</text>
|
||||||
|
<u-tag
|
||||||
|
:text="row.status_label || ''"
|
||||||
|
:type="row.status_tag || 'info'"
|
||||||
|
size="mini"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="card-meta">
|
||||||
|
<text class="meta-text">{{ row.biz_type || '-' }}</text>
|
||||||
|
<text class="meta-text">发起人:{{ row.submit_user_name || '-' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="card-meta">
|
||||||
|
<text class="meta-text">提交:{{ row.submit_time || row.create_time || '-' }}</text>
|
||||||
|
<text v-if="row.finish_time" class="meta-text">完成:{{ row.finish_time }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="row.current_node" class="card-meta">
|
||||||
|
<text class="meta-text">当前节点:{{ row.current_node }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="card-actions">
|
||||||
|
<view class="op-btn op-view" hover-class="btn-active" @tap.stop="openDetail(row.id)">查看详情</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="finished" class="load-done">
|
||||||
|
<text>{{ list.length ? '没有更多了' : '' }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-else class="more-tip" @tap="loadMore">
|
||||||
|
<text>加载更多</text>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
<view class="bottom-space" />
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||||
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
|
import {
|
||||||
|
fetchApprovalList,
|
||||||
|
APPROVAL_INSTANCE_STATUS_OPTIONS
|
||||||
|
} from '@/api/approval.js'
|
||||||
|
import { isLoggedIn } from '@/utils/auth.js'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
/** 状态 tab(首个为「全部」,不展示「草稿」) */
|
||||||
|
const statusTabs = [
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
...APPROVAL_INSTANCE_STATUS_OPTIONS.filter(o => o.value !== 0)
|
||||||
|
]
|
||||||
|
|
||||||
|
const list = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadingMore = ref(false)
|
||||||
|
const finished = ref(false)
|
||||||
|
const keyword = ref('')
|
||||||
|
const currentStatus = ref('')
|
||||||
|
|
||||||
|
/** 当前展开的下拉:'status' | '' */
|
||||||
|
const openDropdown = ref('')
|
||||||
|
|
||||||
|
/** filter-bar 的底部坐标(用于下拉面板 fixed 定位) */
|
||||||
|
const filterBarBottom = ref(0)
|
||||||
|
|
||||||
|
const bizTypes = ref([])
|
||||||
|
|
||||||
|
/** 当前状态下拉框展示文案 */
|
||||||
|
const currentStatusLabel = computed(() => {
|
||||||
|
const hit = statusTabs.find(s => s.value === currentStatus.value)
|
||||||
|
return hit ? hit.label : '全部'
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 下拉面板顶部偏移(紧贴 filter-bar 底部 + 8px 间隙) */
|
||||||
|
const panelTopStyle = computed(() => ({
|
||||||
|
top: (filterBarBottom.value + 8) + 'px'
|
||||||
|
}))
|
||||||
|
|
||||||
|
function toggleDropdown(name) {
|
||||||
|
openDropdown.value = openDropdown.value === name ? '' : name
|
||||||
|
if (openDropdown.value) {
|
||||||
|
// 打开后下一帧再取位置(filter-bar 还未渲染完成则取到 0)
|
||||||
|
nextTick(() => measureFilterBar())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
openDropdown.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 动态获取 filter-bar 在视口中的底部坐标 */
|
||||||
|
function measureFilterBar() {
|
||||||
|
uni.createSelectorQuery()
|
||||||
|
.select('.filter-bar')
|
||||||
|
.boundingClientRect(rect => {
|
||||||
|
if (rect && typeof rect.bottom === 'number') {
|
||||||
|
filterBarBottom.value = Math.round(rect.bottom)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.exec()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
uni.reLaunch({ url: '/pages/login/login' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
// 取 filter-bar 位置供下拉面板定位用
|
||||||
|
nextTick(() => measureFilterBar())
|
||||||
|
})
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
if (isLoggedIn()) refresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadBizTypes() {
|
||||||
|
// 类型筛选已移除,保留此函数以避免潜在依赖
|
||||||
|
bizTypes.value = [{ value: '', label: '全部' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
page.value = 1
|
||||||
|
finished.value = false
|
||||||
|
list.value = []
|
||||||
|
await loadPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (finished.value || loadingMore.value) return
|
||||||
|
loadingMore.value = true
|
||||||
|
page.value += 1
|
||||||
|
await loadPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPage() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await fetchApprovalList({
|
||||||
|
page: page.value,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
keyword: keyword.value.trim(),
|
||||||
|
status: currentStatus.value
|
||||||
|
})
|
||||||
|
const rows = data.list || []
|
||||||
|
list.value = page.value === 1 ? rows : [...list.value, ...rows]
|
||||||
|
total.value = data.total || 0
|
||||||
|
finished.value = list.value.length >= total.value
|
||||||
|
} catch (e) {
|
||||||
|
if (page.value > 1) page.value -= 1
|
||||||
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
loadingMore.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch() {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearKeyword() {
|
||||||
|
keyword.value = ''
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStatusChange(value) {
|
||||||
|
currentStatus.value = value
|
||||||
|
closeDropdown()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(id) {
|
||||||
|
if (!id) {
|
||||||
|
uni.showToast({ title: '单据数据异常', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.navigateTo({ url: `/pages/approval/detail?id=${id}` })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/src/styles/page-common.scss';
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $color-bg-page;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== 顶部搜索 ====== */
|
||||||
|
.toolbar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 20rpx $page-padding-x;
|
||||||
|
background: $color-card;
|
||||||
|
box-shadow: $shadow-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
background: $color-bg-page;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
box-shadow: $shadow-card;
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== 筛选条(顶部下拉触发器) ====== */
|
||||||
|
.filter-bar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 12rpx $page-padding-x;
|
||||||
|
background: $color-card;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-trigger {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background: $color-bg-page;
|
||||||
|
border: 1rpx solid transparent;
|
||||||
|
transition: background 0.2s, border-color 0.2s;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
background: $color-primary-bg;
|
||||||
|
border-color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-trigger-active {
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $color-text;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-trigger.open .filter-value {
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== 下拉面板 ====== */
|
||||||
|
.dropdown-mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: transparent;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-panel {
|
||||||
|
position: absolute;
|
||||||
|
left: $page-padding-x;
|
||||||
|
right: $page-padding-x;
|
||||||
|
background: $color-card;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
box-shadow: 0 12rpx 32rpx rgba(15, 23, 42, 0.16);
|
||||||
|
z-index: 60;
|
||||||
|
max-height: 480rpx;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding: 20rpx 28rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $color-text;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: $color-primary;
|
||||||
|
font-weight: 500;
|
||||||
|
background: $color-primary-bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-option-active {
|
||||||
|
background: $color-bg-page;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-option-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== 列表 ====== */
|
||||||
|
.list-scroll {
|
||||||
|
flex: 1;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-card {
|
||||||
|
@include card;
|
||||||
|
margin: 16rpx $page-padding-x 0;
|
||||||
|
padding: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-active { opacity: 0.85; }
|
||||||
|
|
||||||
|
.card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $color-text;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding-top: 14rpx;
|
||||||
|
border-top: 1rpx solid $color-divider;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.op-btn {
|
||||||
|
padding: 14rpx 36rpx;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.op-view { background: $color-primary; }
|
||||||
|
|
||||||
|
.btn-active { opacity: 0.85; }
|
||||||
|
|
||||||
|
.more-tip {
|
||||||
|
padding: 12rpx 0 4rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-done {
|
||||||
|
padding: 12rpx 0 4rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-box {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 140rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-space {
|
||||||
|
height: 40rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<!-- 顶部:搜索(系统导航栏已带返回按钮 + 标题) -->
|
||||||
|
<view class="toolbar">
|
||||||
|
<view class="search-box">
|
||||||
|
<FaIcon name="magnifying-glass" color="#94a3b8" :size="16" />
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
v-model="keyword"
|
||||||
|
placeholder="搜索单据标题"
|
||||||
|
confirm-type="search"
|
||||||
|
@confirm="onSearch"
|
||||||
|
/>
|
||||||
|
<text v-if="keyword" class="search-clear" @tap="clearKeyword">清空</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="stat-bar">
|
||||||
|
<text class="stat-text">共 {{ total }} 条待办</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 待办列表 -->
|
||||||
|
<scroll-view class="list-scroll" scroll-y :show-scrollbar="false" @scrolltolower="loadMore">
|
||||||
|
<view v-if="loading && !list.length" class="state-box">
|
||||||
|
<text class="state-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="!list.length" class="state-box">
|
||||||
|
<u-empty mode="list" text="暂无待我审批的任务" />
|
||||||
|
</view>
|
||||||
|
<block v-else>
|
||||||
|
<view
|
||||||
|
v-for="task in list"
|
||||||
|
:key="task.id"
|
||||||
|
class="todo-card"
|
||||||
|
hover-class="card-active"
|
||||||
|
@tap="openDetail(task.instance)"
|
||||||
|
>
|
||||||
|
<view class="card-top">
|
||||||
|
<text class="card-title">{{ task.instance && task.instance.biz_title ? task.instance.biz_title : '(无标题)' }}</text>
|
||||||
|
<u-tag
|
||||||
|
:text="task.instance && task.instance.status_label || ''"
|
||||||
|
:type="task.instance && task.instance.status_tag || 'info'"
|
||||||
|
size="mini"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="card-meta">
|
||||||
|
<text class="meta-text">节点:{{ task.node_name || '-' }}</text>
|
||||||
|
<text class="meta-text">发起人:{{ task.instance && task.instance.submit_user_name || '-' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="card-meta">
|
||||||
|
<text class="meta-text">单据:{{ task.instance && task.instance.biz_type || '-' }}</text>
|
||||||
|
<text class="meta-text">提交:{{ task.instance && (task.instance.submit_time || task.instance.create_time) || '-' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="card-meta" v-if="task.deadline">
|
||||||
|
<text class="meta-text">截止:{{ task.deadline }}</text>
|
||||||
|
</view>
|
||||||
|
<!-- card-actions 不接管 tap,只把"查看详情"按钮的 tap 单独拦在按钮层,
|
||||||
|
这样 card 主体其它区域的 tap 仍能正常冒泡到 .todo-card 触发跳转 -->
|
||||||
|
<view class="card-actions">
|
||||||
|
<view class="op-btn op-view" hover-class="btn-active" @tap.stop="openDetail(task.instance)">查看详情</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="finished" class="load-done">
|
||||||
|
<text>{{ list.length ? '没有更多了' : '' }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-else class="more-tip" @tap="loadMore">
|
||||||
|
<text>加载更多</text>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
<view class="bottom-space" />
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||||
|
import {
|
||||||
|
fetchApprovalTodoList
|
||||||
|
} from '@/api/approval.js'
|
||||||
|
import { isLoggedIn } from '@/utils/auth.js'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
const list = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadingMore = ref(false)
|
||||||
|
const finished = ref(false)
|
||||||
|
const keyword = ref('')
|
||||||
|
|
||||||
|
onLoad(() => {
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
uni.reLaunch({ url: '/pages/login/login' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
if (isLoggedIn() && list.value.length === 0) refresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
page.value = 1
|
||||||
|
finished.value = false
|
||||||
|
list.value = []
|
||||||
|
await loadPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (finished.value || loadingMore.value) return
|
||||||
|
loadingMore.value = true
|
||||||
|
page.value += 1
|
||||||
|
await loadPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPage() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await fetchApprovalTodoList({
|
||||||
|
page: page.value,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
keyword: keyword.value.trim()
|
||||||
|
})
|
||||||
|
const rows = (data.list || []).filter(t => {
|
||||||
|
if (!keyword.value) return true
|
||||||
|
const title = t.instance && t.instance.biz_title ? String(t.instance.biz_title) : ''
|
||||||
|
return title.toLowerCase().includes(keyword.value.toLowerCase())
|
||||||
|
})
|
||||||
|
list.value = page.value === 1 ? rows : [...list.value, ...rows]
|
||||||
|
total.value = data.total || 0
|
||||||
|
finished.value = list.value.length >= total.value
|
||||||
|
} catch (e) {
|
||||||
|
if (page.value > 1) page.value -= 1
|
||||||
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
loadingMore.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch() { refresh() }
|
||||||
|
|
||||||
|
function clearKeyword() {
|
||||||
|
keyword.value = ''
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(instance) {
|
||||||
|
if (!instance || !instance.id) {
|
||||||
|
uni.showToast({ title: '单据数据异常', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.navigateTo({ url: `/pages/approval/detail?id=${instance.id}` })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/src/styles/page-common.scss';
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $color-bg-page;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== 顶部搜索 ====== */
|
||||||
|
.toolbar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 20rpx $page-padding-x;
|
||||||
|
background: $color-card;
|
||||||
|
box-shadow: $shadow-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
background: $color-bg-page;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
box-shadow: $shadow-card;
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== 计数条 ====== */
|
||||||
|
.stat-bar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 16rpx $page-padding-x;
|
||||||
|
background: $color-card;
|
||||||
|
border-bottom: 1rpx solid $color-divider;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== 列表 ====== */
|
||||||
|
.list-scroll {
|
||||||
|
flex: 1;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-card {
|
||||||
|
@include card;
|
||||||
|
margin: 16rpx $page-padding-x 0;
|
||||||
|
padding: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-active { opacity: 0.85; }
|
||||||
|
|
||||||
|
.card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $color-text;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding-top: 14rpx;
|
||||||
|
border-top: 1rpx solid $color-divider;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.op-btn {
|
||||||
|
padding: 14rpx 36rpx;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.op-view {
|
||||||
|
background: $color-primary;
|
||||||
|
}
|
||||||
|
.btn-active { opacity: 0.85; }
|
||||||
|
|
||||||
|
.more-tip {
|
||||||
|
padding: 12rpx 0 4rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-done {
|
||||||
|
padding: 12rpx 0 4rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-box {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 140rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-space {
|
||||||
|
height: 40rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
import { onLoad, onShow, onUniAppEvent } from '@dcloudio/uni-app'
|
||||||
import {
|
import {
|
||||||
getExpense,
|
getExpense,
|
||||||
deleteExpense,
|
deleteExpense,
|
||||||
@@ -152,6 +152,14 @@ onShow(() => {
|
|||||||
if (expenseId.value) loadDetail()
|
if (expenseId.value) loadDetail()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 监听审批通过后的刷新事件
|
||||||
|
onUniAppEvent('expense-updated', (data) => {
|
||||||
|
if (data?.id === expenseId.value) {
|
||||||
|
// 延迟一点刷新,确保后端数据已同步
|
||||||
|
setTimeout(() => loadDetail(), 300)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
/** 后端时间字段可能为 "YYYY-MM-DD HH:mm:ss" 格式,iOS 需转 T 分隔 */
|
/** 后端时间字段可能为 "YYYY-MM-DD HH:mm:ss" 格式,iOS 需转 T 分隔 */
|
||||||
function formatTime(value) {
|
function formatTime(value) {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// approval.js — 审批列表 API 服务
|
||||||
|
// 提供审批列表的增删改查接口,供 `pages/approval/index.vue` 使用。
|
||||||
|
|
||||||
|
import { request } from '@/api/request.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取审批列表
|
||||||
|
* @param {object} params - 分页、筛选参数
|
||||||
|
* @returns {Promise<object>} { list, total, page, pageSize }
|
||||||
|
*/
|
||||||
|
export async function fetchApprovalList(params) {
|
||||||
|
const res = await request({
|
||||||
|
url: '/approval/list',
|
||||||
|
method: 'GET',
|
||||||
|
...params,
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取审批类型下拉选项(用于筛选)
|
||||||
|
* @returns {Promise<object>} 审批类型列表
|
||||||
|
*/
|
||||||
|
export async function fetchApprovalTypes() {
|
||||||
|
const res = await request({
|
||||||
|
url: '/approval/types',
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取审批项详情
|
||||||
|
* @param {number} id - 审批项 ID
|
||||||
|
* @returns {Promise<object>} 审批项详情
|
||||||
|
*/
|
||||||
|
export async function fetchApprovalDetail(id) {
|
||||||
|
const res = await request({
|
||||||
|
url: `/approval/detail/${id}`,
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建审批项
|
||||||
|
* @param {object} data - 审批项数据
|
||||||
|
* @returns {Promise<object>} 创建结果
|
||||||
|
*/
|
||||||
|
export async function createApproval(data) {
|
||||||
|
const res = await request({
|
||||||
|
url: '/approval/create',
|
||||||
|
method: 'POST',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新审批项
|
||||||
|
* @param {number} id - 审批项 ID
|
||||||
|
* @param {object} data - 更新的数据
|
||||||
|
* @returns {Promise<object>} 更新结果
|
||||||
|
*/
|
||||||
|
export async function updateApproval(id, data) {
|
||||||
|
const res = await request({
|
||||||
|
url: `/approval/update/${id}`,
|
||||||
|
method: 'PUT',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除审批项
|
||||||
|
* @param {number} id - 审批项 ID
|
||||||
|
* @returns {Promise<object>} 删除结果
|
||||||
|
*/
|
||||||
|
export async function deleteApproval(id) {
|
||||||
|
const res = await request({
|
||||||
|
url: `/approval/delete/${id}`,
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除审批项
|
||||||
|
* @param {number[]} ids - 审批项 ID 数组
|
||||||
|
* @returns {Promise<object>} 批量删除结果
|
||||||
|
*/
|
||||||
|
export async function batchDeleteApprovals(ids) {
|
||||||
|
const res = await request({
|
||||||
|
url: '/approval/batch-delete',
|
||||||
|
method: 'POST',
|
||||||
|
data: { ids },
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同意/通过审批项
|
||||||
|
* @param {number} id - 审批项 ID
|
||||||
|
* @param {string} reason - 通过原因
|
||||||
|
* @returns {Promise<object>} 通过结果
|
||||||
|
*/
|
||||||
|
export async function approveApproval(id, reason) {
|
||||||
|
const res = await request({
|
||||||
|
url: `/approval/approve/${id}`,
|
||||||
|
method: 'POST',
|
||||||
|
data: { reason },
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驳回审批项
|
||||||
|
* @param {number} id - 审批项 ID
|
||||||
|
* @param {string} reason - 驳回原因
|
||||||
|
* @returns {Promise<object>} 驳回结果
|
||||||
|
*/
|
||||||
|
export async function rejectApproval(id, reason) {
|
||||||
|
const res = await request({
|
||||||
|
url: `/approval/reject/${id}`,
|
||||||
|
method: 'POST',
|
||||||
|
data: { reason },
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取审批进度(待办 / 进行中 / 已完成)
|
||||||
|
* @param {number} id - 审批项 ID
|
||||||
|
* @returns {Promise<object>} 进度信息
|
||||||
|
*/
|
||||||
|
export async function fetchApprovalProgress(id) {
|
||||||
|
const res = await request({
|
||||||
|
url: `/approval/progress/${id}`,
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
+16
-2
@@ -15,6 +15,10 @@ const NATIVE_RULES = [
|
|||||||
{ pattern: /记事本|笔记|便签|notebook/i, route: '/pages/tools/notepad/index' },
|
{ pattern: /记事本|笔记|便签|notebook/i, route: '/pages/tools/notepad/index' },
|
||||||
// 「日程提醒」与「工作日程」是两个独立功能:提醒在前,命中"提醒"后即不再落到日历页
|
// 「日程提醒」与「工作日程」是两个独立功能:提醒在前,命中"提醒"后即不再落到日历页
|
||||||
{ pattern: /提醒|reminder/i, route: '/pages/tools/schedule/reminder' },
|
{ pattern: /提醒|reminder/i, route: '/pages/tools/schedule/reminder' },
|
||||||
|
// 自定义审批流(移动端):放在工作日程之前,避免「待办审批」「审批中心」被工作日程吞掉
|
||||||
|
// 「我发起的审批 / 我的发起」也是同一页(业务口径就是申请人视角的实例列表)
|
||||||
|
{ pattern: /审批列表|我的审批|我发起的|审批管理|审批申请|approvalList|approval\/list/i, route: '/pages/approval/index' },
|
||||||
|
{ pattern: /待我审批|我审批的|审批待办|审批中心|approvalTodo|approval\/todo/i, route: '/pages/approval/verify' },
|
||||||
{ pattern: /工作日程|日程|日历|待办|calendar|schedule/i, route: '/pages/tools/schedule/index' },
|
{ pattern: /工作日程|日程|日历|待办|calendar|schedule/i, route: '/pages/tools/schedule/index' },
|
||||||
// 报销管理(注意放在日程规则之后即可,与日程关键词无冲突)
|
// 报销管理(注意放在日程规则之后即可,与日程关键词无冲突)
|
||||||
{ pattern: /报销|reimburse|expense/i, route: '/pages/tools/expense/index' },
|
{ pattern: /报销|reimburse|expense/i, route: '/pages/tools/expense/index' },
|
||||||
@@ -34,7 +38,13 @@ const WEBVIEW_PAGE = '/pages/apps/webview'
|
|||||||
* 命中 → webview 打开 Web 端;未命中 → 「功能开发中」占位页。
|
* 命中 → webview 打开 Web 端;未命中 → 「功能开发中」占位页。
|
||||||
* 新功能上线时把对应关键词加进来即可。
|
* 新功能上线时把对应关键词加进来即可。
|
||||||
*/
|
*/
|
||||||
const WEB_RULES = []
|
export const WEB_RULES = [
|
||||||
|
// 审批流配置(Web 端 /backend/approval):端上没有原生实现,直接跳 Web 配置页
|
||||||
|
'审批设置',
|
||||||
|
'审批流配置',
|
||||||
|
'审批流程',
|
||||||
|
'审批配置'
|
||||||
|
]
|
||||||
|
|
||||||
/** 开发中原生占位页 */
|
/** 开发中原生占位页 */
|
||||||
const DEVELOPING_PAGE = '/pages/common/developing'
|
const DEVELOPING_PAGE = '/pages/common/developing'
|
||||||
@@ -48,8 +58,12 @@ function openDeveloping(name) {
|
|||||||
|
|
||||||
/** 菜单是否命中移动端原生页面 */
|
/** 菜单是否命中移动端原生页面 */
|
||||||
export function resolveNativeRoute(menu = {}) {
|
export function resolveNativeRoute(menu = {}) {
|
||||||
const haystack = (menu.title || '') + ' ' + (menu.path || '')
|
const title = String(menu.title || '')
|
||||||
|
const path = String(menu.path || '')
|
||||||
|
const haystack = title + ' ' + path
|
||||||
const hit = NATIVE_RULES.find((r) => r.pattern.test(haystack))
|
const hit = NATIVE_RULES.find((r) => r.pattern.test(haystack))
|
||||||
|
// 调试日志:方便排查菜单跳转是否符合预期(HBuilderX / 微信开发者工具 控制台可见)
|
||||||
|
console.log('[nav] resolveNativeRoute', { title, path, hit: hit ? hit.route : '' })
|
||||||
return hit ? hit.route : ''
|
return hit ? hit.route : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user