Commit b9f1ac5d authored by 刘斌's avatar 刘斌

fix: 完善消费记录和充值记录

parent b54c4d35
...@@ -20,6 +20,12 @@ ...@@ -20,6 +20,12 @@
<artifactId>admin</artifactId> <artifactId>admin</artifactId>
<version>${binfast.version}</version> <version>${binfast.version}</version>
</dependency> </dependency>
<!-- 用户密码加密 如数据库连接等 -->
<dependency>
<groupId>com.github.ulisesbocchio</groupId>
<artifactId>jasypt-spring-boot-starter</artifactId>
<version>3.0.5</version>
</dependency>
<dependency> <dependency>
<groupId>top.binfast</groupId> <groupId>top.binfast</groupId>
<artifactId>daemon-codegen</artifactId> <artifactId>daemon-codegen</artifactId>
...@@ -36,6 +42,8 @@ ...@@ -36,6 +42,8 @@
</dependencies> </dependencies>
<build> <build>
<!-- 打包产物名(不含扩展名),mvn package 后生成 admin-ai.${project.version}.jar -->
<finalName>admin-ai.${project.version}</finalName>
<plugins> <plugins>
<!-- 跳过单元测试 --> <!-- 跳过单元测试 -->
<plugin> <plugin>
......
package com.anplus.hr.ai.controller; package com.anplus.hr.ai.controller;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.lang.tree.Tree;
import com.alibaba.cola.dto.MultiResponse;
import com.alibaba.cola.dto.PageResponse; import com.alibaba.cola.dto.PageResponse;
import com.alibaba.cola.dto.Response; import com.alibaba.cola.dto.Response;
import com.alibaba.cola.dto.SingleResponse; import com.alibaba.cola.dto.SingleResponse;
...@@ -11,8 +13,11 @@ import org.springframework.validation.annotation.Validated; ...@@ -11,8 +13,11 @@ import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import com.anplus.hr.ai.domain.params.AiConsumeRecordListParam; import com.anplus.hr.ai.domain.params.AiConsumeRecordListParam;
import com.anplus.hr.ai.domain.params.AiConsumeRecordParam; import com.anplus.hr.ai.domain.params.AiConsumeRecordParam;
import com.anplus.hr.ai.domain.vo.AiConsumeRecordSummaryVo;
import com.anplus.hr.ai.domain.vo.AiConsumeRecordVo; import com.anplus.hr.ai.domain.vo.AiConsumeRecordVo;
import com.anplus.hr.ai.service.AiConsumeRecordServ; import com.anplus.hr.ai.service.AiConsumeRecordServ;
import top.binfast.app.biz.sysapi.bean.params.sysDept.SysDeptParam;
import top.binfast.app.biz.sysbiz.service.SysDeptServ;
import top.binfast.common.core.constant.BusinessType; import top.binfast.common.core.constant.BusinessType;
import top.binfast.common.core.util.ResponseUtils; import top.binfast.common.core.util.ResponseUtils;
import top.binfast.common.excel.annotion.ExcelExport; import top.binfast.common.excel.annotion.ExcelExport;
...@@ -33,6 +38,7 @@ import java.util.List; ...@@ -33,6 +38,7 @@ import java.util.List;
public class AiConsumeRecordCtrl { public class AiConsumeRecordCtrl {
private final AiConsumeRecordServ aiConsumeRecordServ; private final AiConsumeRecordServ aiConsumeRecordServ;
private final SysDeptServ sysDeptServ;
/** /**
* 分页查询AI工具消费记录列表。 * 分页查询AI工具消费记录列表。
...@@ -46,6 +52,18 @@ public class AiConsumeRecordCtrl { ...@@ -46,6 +52,18 @@ public class AiConsumeRecordCtrl {
return aiConsumeRecordServ.queryPageList(param); return aiConsumeRecordServ.queryPageList(param);
} }
/**
* 查询当前筛选条件下的金额合计(跨所有分页的全量结果,口径与列表完全一致)。
*
* @param param 查询条件(与分页查询共用同一条件对象)
* @return 合计载体(amountCny 保留两位小数;无匹配数据时为 0.00)
*/
@SaCheckPermission("ai:consume:list")
@GetMapping("/summary")
public SingleResponse<AiConsumeRecordSummaryVo> summary(AiConsumeRecordListParam param) {
return SingleResponse.of(aiConsumeRecordServ.querySummary(param));
}
/** /**
* 导出AI工具消费记录列表。 * 导出AI工具消费记录列表。
* *
...@@ -71,6 +89,18 @@ public class AiConsumeRecordCtrl { ...@@ -71,6 +89,18 @@ public class AiConsumeRecordCtrl {
return SingleResponse.of(aiConsumeRecordServ.queryById(id)); return SingleResponse.of(aiConsumeRecordServ.queryById(id));
} }
/**
* 按批次号获取整批消费记录详情(主从批量编辑回填用)。
*
* @param batchNo 批次号
* @return 整批详情(公共字段 + details 明细列表,line_sort 升序)
*/
@SaCheckPermission("ai:consume:query")
@GetMapping("/batch/{batchNo}")
public SingleResponse<AiConsumeRecordVo> getBatchDetail(@PathVariable String batchNo) {
return SingleResponse.of(aiConsumeRecordServ.queryByBatchNo(batchNo));
}
/** /**
* 新增AI工具消费记录。 * 新增AI工具消费记录。
* *
...@@ -109,4 +139,16 @@ public class AiConsumeRecordCtrl { ...@@ -109,4 +139,16 @@ public class AiConsumeRecordCtrl {
@PathVariable Long[] ids) { @PathVariable Long[] ids) {
return ResponseUtils.ofResult(aiConsumeRecordServ.delByIds(List.of(ids))); return ResponseUtils.ofResult(aiConsumeRecordServ.delByIds(List.of(ids)));
} }
/**
* 获取消费记录筛选用的部门树。
*
* @param dept 部门查询条件
* @return 部门树列表
*/
@SaCheckPermission("ai:consume:list")
@GetMapping("/deptTree")
public MultiResponse<Tree<Long>> deptTree(SysDeptParam dept) {
return MultiResponse.of(sysDeptServ.selectDeptTreeList(dept));
}
} }
package com.anplus.hr.ai.controller; package com.anplus.hr.ai.controller;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.lang.tree.Tree;
import com.alibaba.cola.dto.MultiResponse;
import com.alibaba.cola.dto.PageResponse; import com.alibaba.cola.dto.PageResponse;
import com.alibaba.cola.dto.Response; import com.alibaba.cola.dto.Response;
import com.alibaba.cola.dto.SingleResponse; import com.alibaba.cola.dto.SingleResponse;
...@@ -11,8 +13,11 @@ import org.springframework.validation.annotation.Validated; ...@@ -11,8 +13,11 @@ import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import com.anplus.hr.ai.domain.params.AiRechargeRecordListParam; import com.anplus.hr.ai.domain.params.AiRechargeRecordListParam;
import com.anplus.hr.ai.domain.params.AiRechargeRecordParam; import com.anplus.hr.ai.domain.params.AiRechargeRecordParam;
import com.anplus.hr.ai.domain.vo.AiRechargeRecordSummaryVo;
import com.anplus.hr.ai.domain.vo.AiRechargeRecordVo; import com.anplus.hr.ai.domain.vo.AiRechargeRecordVo;
import com.anplus.hr.ai.service.AiRechargeRecordServ; import com.anplus.hr.ai.service.AiRechargeRecordServ;
import top.binfast.app.biz.sysapi.bean.params.sysDept.SysDeptParam;
import top.binfast.app.biz.sysbiz.service.SysDeptServ;
import top.binfast.common.core.constant.BusinessType; import top.binfast.common.core.constant.BusinessType;
import top.binfast.common.core.util.ResponseUtils; import top.binfast.common.core.util.ResponseUtils;
import top.binfast.common.excel.annotion.ExcelExport; import top.binfast.common.excel.annotion.ExcelExport;
...@@ -33,6 +38,7 @@ import java.util.List; ...@@ -33,6 +38,7 @@ import java.util.List;
public class AiRechargeRecordCtrl { public class AiRechargeRecordCtrl {
private final AiRechargeRecordServ aiRechargeRecordServ; private final AiRechargeRecordServ aiRechargeRecordServ;
private final SysDeptServ sysDeptServ;
/** /**
* 分页查询AI工具充值记录列表。 * 分页查询AI工具充值记录列表。
...@@ -46,6 +52,18 @@ public class AiRechargeRecordCtrl { ...@@ -46,6 +52,18 @@ public class AiRechargeRecordCtrl {
return aiRechargeRecordServ.queryPageList(param); return aiRechargeRecordServ.queryPageList(param);
} }
/**
* 查询当前筛选条件下的双金额合计(跨所有分页的全量结果,口径与列表完全一致)。
*
* @param param 查询条件(与分页查询共用同一条件对象)
* @return 合计载体(amountUsd、amountCny 各保留两位小数;无匹配数据时为 0.00)
*/
@SaCheckPermission("ai:recharge:list")
@GetMapping("/summary")
public SingleResponse<AiRechargeRecordSummaryVo> summary(AiRechargeRecordListParam param) {
return SingleResponse.of(aiRechargeRecordServ.querySummary(param));
}
/** /**
* 导出AI工具充值记录列表。 * 导出AI工具充值记录列表。
* *
...@@ -109,4 +127,16 @@ public class AiRechargeRecordCtrl { ...@@ -109,4 +127,16 @@ public class AiRechargeRecordCtrl {
@PathVariable Long[] ids) { @PathVariable Long[] ids) {
return ResponseUtils.ofResult(aiRechargeRecordServ.delByIds(List.of(ids))); return ResponseUtils.ofResult(aiRechargeRecordServ.delByIds(List.of(ids)));
} }
/**
* 获取充值记录筛选用的部门树。
*
* @param dept 部门查询条件
* @return 部门树列表
*/
@SaCheckPermission("ai:recharge:list")
@GetMapping("/deptTree")
public MultiResponse<Tree<Long>> deptTree(SysDeptParam dept) {
return MultiResponse.of(sysDeptServ.selectDeptTreeList(dept));
}
} }
...@@ -8,7 +8,7 @@ import lombok.EqualsAndHashCode; ...@@ -8,7 +8,7 @@ import lombok.EqualsAndHashCode;
import java.io.Serial; import java.io.Serial;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDate;
/** /**
* AI工具消费记录对象 ai_consume_record * AI工具消费记录对象 ai_consume_record
...@@ -26,7 +26,12 @@ public class AiConsumeRecord extends TenantModel { ...@@ -26,7 +26,12 @@ public class AiConsumeRecord extends TenantModel {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** /**
* 项目名称(字典ai_project_name的dict_value) * 所属部门ID(关联sys_dept.id)
*/
private Long deptId;
/**
* 项目名称(用户直接输入的自由文本)
*/ */
private String projectName; private String projectName;
...@@ -36,7 +41,7 @@ public class AiConsumeRecord extends TenantModel { ...@@ -36,7 +41,7 @@ public class AiConsumeRecord extends TenantModel {
private String userName; private String userName;
/** /**
* AI工具/平台名称(字典ai_tool_name的dict_value * AI工具/平台名称(用户直接输入的自由文本
*/ */
private String toolName; private String toolName;
...@@ -58,11 +63,21 @@ public class AiConsumeRecord extends TenantModel { ...@@ -58,11 +63,21 @@ public class AiConsumeRecord extends TenantModel {
/** /**
* 消费发生时间 * 消费发生时间
*/ */
private LocalDateTime consumeTime; private LocalDate consumeTime;
/** /**
* 备注 * 备注
*/ */
private String remark; private String remark;
/**
* 批次号(主从批量录入:同批多行共享同一 batch_no,全局唯一)
*/
private String batchNo;
/**
* 行序号(批次内从 1 连续自增)
*/
private Integer lineSort;
} }
...@@ -9,7 +9,6 @@ import lombok.EqualsAndHashCode; ...@@ -9,7 +9,6 @@ import lombok.EqualsAndHashCode;
import java.io.Serial; import java.io.Serial;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
/** /**
* AI工具充值记录对象 ai_recharge_record * AI工具充值记录对象 ai_recharge_record
...@@ -27,7 +26,12 @@ public class AiRechargeRecord extends TenantModel { ...@@ -27,7 +26,12 @@ public class AiRechargeRecord extends TenantModel {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** /**
* AI工具/平台名称(字典ai_tool_name的dict_value) * 所属部门ID(关联sys_dept.id)
*/
private Long deptId;
/**
* AI工具/平台名称(用户直接输入的自由文本)
*/ */
private String toolName; private String toolName;
...@@ -44,7 +48,7 @@ public class AiRechargeRecord extends TenantModel { ...@@ -44,7 +48,7 @@ public class AiRechargeRecord extends TenantModel {
/** /**
* 充值完成时间 * 充值完成时间
*/ */
private LocalDateTime rechargeTime; private LocalDate rechargeTime;
/** /**
* 到期时间(纯字段,无联动) * 到期时间(纯字段,无联动)
......
package com.anplus.hr.ai.domain.params;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import java.math.BigDecimal;
/**
* AI工具消费记录明细行业务对象(主从批量录入的子表行)
*
* <p>对应 {@code ai_consume_record} 单行的工具维度字段。消耗积分与金额维持非必填
* (Clarify Q4:对齐既有 baseline 与现有代码口径,放宽需求文档「必填」措辞),
* 仅约束最多 2 位小数。校验注解均不带 groups,由父对象 {@code @Valid} 级联触发。</p>
*
* @author LiuBin
* @date 2026-09-08
*/
@Data
public class AiConsumeDetailParam {
/**
* AI工具/平台名称(用户直接输入的自由文本)
*/
@NotBlank(message = "AI工具/平台名称不能为空")
private String toolName;
/**
* 国内/国外(中文原文,固定二值)
*/
@NotBlank(message = "国内/国外不能为空")
@Pattern(regexp = "国内|国外", message = "国内/国外仅接受「国内」或「国外」")
private String region;
/**
* 消耗积分(非必填,最多2位小数)
*/
@Digits(integer = 16, fraction = 2, message = "消耗积分最多2位小数")
private BigDecimal consumePoints;
/**
* 金额(人民币)(非必填,最多2位小数)
*/
@Digits(integer = 16, fraction = 2, message = "金额(人民币)最多2位小数")
private BigDecimal amountCny;
}
...@@ -18,7 +18,7 @@ import java.util.Map; ...@@ -18,7 +18,7 @@ import java.util.Map;
public class AiConsumeRecordListParam extends PageQueryParam { public class AiConsumeRecordListParam extends PageQueryParam {
/** /**
* 项目名称(字典ai_project_name的dict_value * 项目名称(用户直接输入的自由文本
*/ */
private String projectName; private String projectName;
...@@ -28,10 +28,15 @@ public class AiConsumeRecordListParam extends PageQueryParam { ...@@ -28,10 +28,15 @@ public class AiConsumeRecordListParam extends PageQueryParam {
private String userName; private String userName;
/** /**
* AI工具/平台名称(字典ai_tool_name的dict_value * AI工具/平台名称(用户直接输入的自由文本
*/ */
private String toolName; private String toolName;
/**
* 归属部门id(部门树,含子部门)
*/
private Long belongDeptId;
private Map<String, Object> params = new HashMap<>(); private Map<String, Object> params = new HashMap<>();
} }
package com.anplus.hr.ai.domain.params; package com.anplus.hr.ai.domain.params;
import io.github.linpeilie.annotations.AutoMapper; import jakarta.validation.Valid;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.Data; import lombok.Data;
import com.anplus.hr.ai.domain.model.AiConsumeRecord;
import java.math.BigDecimal; import java.time.LocalDate;
import java.time.LocalDateTime; import java.util.List;
/** /**
* AI工具消费记录业务对象 ai_consume_record * AI工具消费记录业务对象(主从批量录入)ai_consume_record
*
* <p>上部公共字段对整批生效,下部 {@code details} 为明细行列表;新增时 {@code batchNo}
* 为空由服务端生成,编辑时携带 {@code batchNo} 定位原批(整批全删重插)。校验注解均不带
* groups,配合 Controller 的 {@code @Validated} 默认组生效(对齐既有 Param 风格)。</p>
* *
* @author LiuBin * @author LiuBin
* @date 2026-09-04 * @date 2026-09-04
*/ */
@Data @Data
@AutoMapper(target = AiConsumeRecord.class, reverseConvertGenerate = false)
public class AiConsumeRecordParam { public class AiConsumeRecordParam {
/** /**
* 主键ID(新增为空,修改必填 * 批次号(编辑时定位原批;新增时为空、由服务端生成
*/ */
private Long id; private String batchNo;
/** /**
* 项目名称(字典ai_project_name的dict_value) * 所属部门ID(关联sys_dept.id)
*/
@NotNull(message = "所属部门不能为空")
private Long deptId;
/**
* 项目名称(用户直接输入的自由文本)
*/ */
@NotBlank(message = "项目名称不能为空") @NotBlank(message = "项目名称不能为空")
private String projectName; private String projectName;
...@@ -39,36 +46,11 @@ public class AiConsumeRecordParam { ...@@ -39,36 +46,11 @@ public class AiConsumeRecordParam {
@NotBlank(message = "使用人姓名不能为空") @NotBlank(message = "使用人姓名不能为空")
private String userName; private String userName;
/**
* AI工具/平台名称(字典ai_tool_name的dict_value)
*/
@NotBlank(message = "AI工具/平台名称不能为空")
private String toolName;
/**
* 国内/国外(中文原文,固定二值)
*/
@NotBlank(message = "国内/国外不能为空")
@Pattern(regexp = "国内|国外", message = "国内/国外仅接受「国内」或「国外」")
private String region;
/**
* 消耗积分
*/
@Digits(integer = 16, fraction = 2, message = "消耗积分最多2位小数")
private BigDecimal consumePoints;
/**
* 金额(人民币)
*/
@Digits(integer = 16, fraction = 2, message = "金额(人民币)最多2位小数")
private BigDecimal amountCny;
/** /**
* 消费发生时间 * 消费发生时间
*/ */
@NotNull(message = "消费发生时间不能为空") @NotNull(message = "消费发生时间不能为空")
private LocalDateTime consumeTime; private LocalDate consumeTime;
/** /**
* 备注 * 备注
...@@ -76,4 +58,11 @@ public class AiConsumeRecordParam { ...@@ -76,4 +58,11 @@ public class AiConsumeRecordParam {
@Size(max = 200, message = "备注最长200字符") @Size(max = 200, message = "备注最长200字符")
private String remark; private String remark;
/**
* 消费明细行列表(至少一行,级联校验每行)
*/
@Valid
@NotEmpty(message = "消费明细不能为空")
private List<AiConsumeDetailParam> details;
} }
...@@ -18,7 +18,7 @@ import java.util.Map; ...@@ -18,7 +18,7 @@ import java.util.Map;
public class AiRechargeRecordListParam extends PageQueryParam { public class AiRechargeRecordListParam extends PageQueryParam {
/** /**
* AI工具/平台名称(字典ai_tool_name的dict_value * AI工具/平台名称(用户直接输入的自由文本
*/ */
private String toolName; private String toolName;
...@@ -32,6 +32,11 @@ public class AiRechargeRecordListParam extends PageQueryParam { ...@@ -32,6 +32,11 @@ public class AiRechargeRecordListParam extends PageQueryParam {
*/ */
private String rechargeCategory; private String rechargeCategory;
/**
* 归属部门id(部门树,含子部门)
*/
private Long belongDeptId;
private Map<String, Object> params = new HashMap<>(); private Map<String, Object> params = new HashMap<>();
} }
...@@ -12,7 +12,6 @@ import com.anplus.hr.ai.domain.model.AiRechargeRecord; ...@@ -12,7 +12,6 @@ import com.anplus.hr.ai.domain.model.AiRechargeRecord;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
/** /**
* AI工具充值记录业务对象 ai_recharge_record * AI工具充值记录业务对象 ai_recharge_record
...@@ -30,7 +29,13 @@ public class AiRechargeRecordParam { ...@@ -30,7 +29,13 @@ public class AiRechargeRecordParam {
private Long id; private Long id;
/** /**
* AI工具/平台名称(字典ai_tool_name的dict_value) * 所属部门ID(关联sys_dept.id)
*/
@NotNull(message = "所属部门不能为空")
private Long deptId;
/**
* AI工具/平台名称(用户直接输入的自由文本)
*/ */
@NotBlank(message = "AI工具/平台名称不能为空") @NotBlank(message = "AI工具/平台名称不能为空")
private String toolName; private String toolName;
...@@ -52,7 +57,7 @@ public class AiRechargeRecordParam { ...@@ -52,7 +57,7 @@ public class AiRechargeRecordParam {
* 充值完成时间 * 充值完成时间
*/ */
@NotNull(message = "充值完成时间不能为空") @NotNull(message = "充值完成时间不能为空")
private LocalDateTime rechargeTime; private LocalDate rechargeTime;
/** /**
* 到期时间(纯字段,无联动) * 到期时间(纯字段,无联动)
......
package com.anplus.hr.ai.domain.vo;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* AI工具消费记录合计视图对象(列表底部合计行 / GET /ai/consume/summary 载体)
*
* @author LiuBin
* @date 2026-09-08
*/
@Data
public class AiConsumeRecordSummaryVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 金额(人民币)合计:当前查询条件筛选出的全部结果(跨所有分页)amountCny 求和,保留两位小数
*/
private BigDecimal amountCny;
}
package com.anplus.hr.ai.domain.vo; package com.anplus.hr.ai.domain.vo;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.github.linpeilie.annotations.AutoMapper; import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data; import lombok.Data;
import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated; import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated;
import org.apache.fesod.sheet.annotation.ExcelProperty; import org.apache.fesod.sheet.annotation.ExcelProperty;
import com.anplus.hr.ai.domain.model.AiConsumeRecord; import com.anplus.hr.ai.domain.model.AiConsumeRecord;
import top.binfast.common.excel.annotion.ExcelDictFormat; import top.binfast.common.translation.annotation.Translation;
import top.binfast.common.excel.converters.ExcelDictConvert; import top.binfast.common.translation.constant.TransConstant;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
/** /**
...@@ -34,10 +37,31 @@ public class AiConsumeRecordVo implements Serializable { ...@@ -34,10 +37,31 @@ public class AiConsumeRecordVo implements Serializable {
private Long id; private Long id;
/** /**
* 项目名称(字典ai_project_name的dict_value * 批次号(主从批量录入元数据;非展示、非导出,前端编辑流按此定位整批
*/ */
@ExcelProperty(value = "项目名称", converter = ExcelDictConvert.class) private String batchNo;
@ExcelDictFormat(dictType = "ai_project_name")
/**
* 行序号(批次内从 1 连续;非展示、非导出)
*/
private Integer lineSort;
/**
* 所属部门ID(关联sys_dept.id)
*/
private Long deptId;
/**
* 所属部门名称(@Translation 列表自动翻译;导出由 ServiceImpl 批量回填)
*/
@Translation(type = TransConstant.DEPT_ID_TO_NAME, mapper = "deptId")
@ExcelProperty(value = "所属部门")
private String deptName;
/**
* 项目名称(用户直接输入的自由文本)
*/
@ExcelProperty(value = "项目名称")
private String projectName; private String projectName;
/** /**
...@@ -47,10 +71,9 @@ public class AiConsumeRecordVo implements Serializable { ...@@ -47,10 +71,9 @@ public class AiConsumeRecordVo implements Serializable {
private String userName; private String userName;
/** /**
* AI工具/平台名称(字典ai_tool_name的dict_value * AI工具/平台名称(用户直接输入的自由文本
*/ */
@ExcelProperty(value = "AI工具/平台名称", converter = ExcelDictConvert.class) @ExcelProperty(value = "AI工具/平台名称")
@ExcelDictFormat(dictType = "ai_tool_name")
private String toolName; private String toolName;
/** /**
...@@ -75,7 +98,7 @@ public class AiConsumeRecordVo implements Serializable { ...@@ -75,7 +98,7 @@ public class AiConsumeRecordVo implements Serializable {
* 消费发生时间 * 消费发生时间
*/ */
@ExcelProperty(value = "消费时间") @ExcelProperty(value = "消费时间")
private LocalDateTime consumeTime; private LocalDate consumeTime;
/** /**
* 备注 * 备注
...@@ -85,4 +108,11 @@ public class AiConsumeRecordVo implements Serializable { ...@@ -85,4 +108,11 @@ public class AiConsumeRecordVo implements Serializable {
private LocalDateTime createTime; private LocalDateTime createTime;
/**
* 明细行列表(仅整批详情接口 GET /ai/consume/batch/{batchNo} 返回;
* 列表与单条详情为 null 且不参与 JSON 序列化;非展示、非导出)
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
private List<AiConsumeRecordVo> details;
} }
package com.anplus.hr.ai.domain.vo;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* AI工具充值记录合计视图对象(列表底部合计行 / GET /ai/recharge/summary 载体)
*
* @author LiuBin
* @date 2026-09-08
*/
@Data
public class AiRechargeRecordSummaryVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 充值金额(美元)合计:当前查询条件筛选出的全部结果(跨所有分页)amountUsd 求和,保留两位小数
*/
private BigDecimal amountUsd;
/**
* 充值金额(人民币)合计:当前查询条件筛选出的全部结果(跨所有分页)amountCny 求和,保留两位小数
*/
private BigDecimal amountCny;
}
...@@ -7,6 +7,8 @@ import org.apache.fesod.sheet.annotation.ExcelProperty; ...@@ -7,6 +7,8 @@ import org.apache.fesod.sheet.annotation.ExcelProperty;
import com.anplus.hr.ai.domain.model.AiRechargeRecord; import com.anplus.hr.ai.domain.model.AiRechargeRecord;
import top.binfast.common.excel.annotion.ExcelDictFormat; import top.binfast.common.excel.annotion.ExcelDictFormat;
import top.binfast.common.excel.converters.ExcelDictConvert; import top.binfast.common.excel.converters.ExcelDictConvert;
import top.binfast.common.translation.annotation.Translation;
import top.binfast.common.translation.constant.TransConstant;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
...@@ -35,10 +37,21 @@ public class AiRechargeRecordVo implements Serializable { ...@@ -35,10 +37,21 @@ public class AiRechargeRecordVo implements Serializable {
private Long id; private Long id;
/** /**
* AI工具/平台名称(字典ai_tool_name的dict_value * 所属部门ID(关联sys_dept.id
*/ */
@ExcelProperty(value = "AI工具/平台名称", converter = ExcelDictConvert.class) private Long deptId;
@ExcelDictFormat(dictType = "ai_tool_name")
/**
* 所属部门名称(@Translation 列表自动翻译;导出由 ServiceImpl 批量回填)
*/
@Translation(type = TransConstant.DEPT_ID_TO_NAME, mapper = "deptId")
@ExcelProperty(value = "所属部门")
private String deptName;
/**
* AI工具/平台名称(用户直接输入的自由文本)
*/
@ExcelProperty(value = "AI工具/平台名称")
private String toolName; private String toolName;
/** /**
...@@ -58,7 +71,7 @@ public class AiRechargeRecordVo implements Serializable { ...@@ -58,7 +71,7 @@ public class AiRechargeRecordVo implements Serializable {
* 充值完成时间 * 充值完成时间
*/ */
@ExcelProperty(value = "充值完成时间") @ExcelProperty(value = "充值完成时间")
private LocalDateTime rechargeTime; private LocalDate rechargeTime;
/** /**
* 到期时间(纯字段,无联动) * 到期时间(纯字段,无联动)
......
package com.anplus.hr.ai.mapper; package com.anplus.hr.ai.mapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.github.yulichang.base.MPJBaseMapper; import com.github.yulichang.base.MPJBaseMapper;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import com.anplus.hr.ai.domain.model.AiConsumeRecord; import com.anplus.hr.ai.domain.model.AiConsumeRecord;
import top.binfast.common.mybatis.mapper.BinBaseMapper; import top.binfast.common.mybatis.mapper.BinBaseMapper;
import java.util.List;
/** /**
* AI工具消费记录Mapper接口 * AI工具消费记录Mapper接口
* *
...@@ -14,4 +18,29 @@ import top.binfast.common.mybatis.mapper.BinBaseMapper; ...@@ -14,4 +18,29 @@ import top.binfast.common.mybatis.mapper.BinBaseMapper;
@Mapper @Mapper
public interface AiConsumeRecordMapper extends BinBaseMapper<AiConsumeRecord>, MPJBaseMapper<AiConsumeRecord> { public interface AiConsumeRecordMapper extends BinBaseMapper<AiConsumeRecord>, MPJBaseMapper<AiConsumeRecord> {
/**
* 按批次号查询整批记录(line_sort 升序)。
*
* <p>租户过滤由全局 TenantLineInnerInterceptor 自动注入,逻辑删除行由 @TableLogic 自动排除。</p>
*
* @param batchNo 批次号
* @return 该批次下的全部有效记录,按行序号升序
*/
default List<AiConsumeRecord> selectByBatchNo(String batchNo) {
return this.selectList(new LambdaQueryWrapper<AiConsumeRecord>()
.eq(AiConsumeRecord::getBatchNo, batchNo)
.orderByAsc(AiConsumeRecord::getLineSort));
}
/**
* 按批次号物理删除整批记录(编辑「整批全删重插」用,绕过 @TableLogic 逻辑删除)。
*
* <p>SQL 显式带 tenant_id 条件(design.md D3 / R1),杜绝跨租户误删同 batch_no 行。</p>
*
* @param batchNo 批次号
* @param tenantId 当前租户ID
* @return 删除的行数
*/
int deleteByBatchNo(@Param("batchNo") String batchNo, @Param("tenantId") Long tenantId);
} }
...@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.spring.service.IService; ...@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.spring.service.IService;
import com.anplus.hr.ai.domain.model.AiConsumeRecord; import com.anplus.hr.ai.domain.model.AiConsumeRecord;
import com.anplus.hr.ai.domain.params.AiConsumeRecordListParam; import com.anplus.hr.ai.domain.params.AiConsumeRecordListParam;
import com.anplus.hr.ai.domain.params.AiConsumeRecordParam; import com.anplus.hr.ai.domain.params.AiConsumeRecordParam;
import com.anplus.hr.ai.domain.vo.AiConsumeRecordSummaryVo;
import com.anplus.hr.ai.domain.vo.AiConsumeRecordVo; import com.anplus.hr.ai.domain.vo.AiConsumeRecordVo;
import java.util.List; import java.util.List;
...@@ -33,6 +34,14 @@ public interface AiConsumeRecordServ extends IService<AiConsumeRecord> { ...@@ -33,6 +34,14 @@ public interface AiConsumeRecordServ extends IService<AiConsumeRecord> {
*/ */
List<AiConsumeRecordVo> queryList(AiConsumeRecordListParam param); List<AiConsumeRecordVo> queryList(AiConsumeRecordListParam param);
/**
* 查询当前条件下的金额合计(跨所有分页的全量筛选结果,口径与列表完全一致)
*
* @param param 查询条件(与分页查询共用同一条件对象 AiConsumeRecordListParam)
* @return 合计载体(amountCny 保留两位小数;无匹配数据时为 0.00)
*/
AiConsumeRecordSummaryVo querySummary(AiConsumeRecordListParam param);
/** /**
* 查询AI工具消费记录 * 查询AI工具消费记录
* *
...@@ -41,6 +50,14 @@ public interface AiConsumeRecordServ extends IService<AiConsumeRecord> { ...@@ -41,6 +50,14 @@ public interface AiConsumeRecordServ extends IService<AiConsumeRecord> {
*/ */
AiConsumeRecordVo queryById(Long id); AiConsumeRecordVo queryById(Long id);
/**
* 按批次号查询整批详情(公共字段 + 明细列表,line_sort 升序)
*
* @param batchNo 批次号
* @return 整批详情;批次不存在或跨租户返回 null
*/
AiConsumeRecordVo queryByBatchNo(String batchNo);
/** /**
* 新增AI工具消费记录 * 新增AI工具消费记录
* *
......
...@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.spring.service.IService; ...@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.spring.service.IService;
import com.anplus.hr.ai.domain.model.AiRechargeRecord; import com.anplus.hr.ai.domain.model.AiRechargeRecord;
import com.anplus.hr.ai.domain.params.AiRechargeRecordListParam; import com.anplus.hr.ai.domain.params.AiRechargeRecordListParam;
import com.anplus.hr.ai.domain.params.AiRechargeRecordParam; import com.anplus.hr.ai.domain.params.AiRechargeRecordParam;
import com.anplus.hr.ai.domain.vo.AiRechargeRecordSummaryVo;
import com.anplus.hr.ai.domain.vo.AiRechargeRecordVo; import com.anplus.hr.ai.domain.vo.AiRechargeRecordVo;
import java.util.List; import java.util.List;
...@@ -33,6 +34,14 @@ public interface AiRechargeRecordServ extends IService<AiRechargeRecord> { ...@@ -33,6 +34,14 @@ public interface AiRechargeRecordServ extends IService<AiRechargeRecord> {
*/ */
List<AiRechargeRecordVo> queryList(AiRechargeRecordListParam param); List<AiRechargeRecordVo> queryList(AiRechargeRecordListParam param);
/**
* 查询当前条件下的双金额合计(跨所有分页的全量筛选结果,口径与列表完全一致)
*
* @param param 查询条件(与分页查询共用同一条件对象 AiRechargeRecordListParam)
* @return 合计载体(amountUsd、amountCny 各保留两位小数;无匹配数据时为 0.00)
*/
AiRechargeRecordSummaryVo querySummary(AiRechargeRecordListParam param);
/** /**
* 查询AI工具充值记录 * 查询AI工具充值记录
* *
......
package com.anplus.hr.ai.service.impl; package com.anplus.hr.ai.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.cola.dto.PageResponse; import com.alibaba.cola.dto.PageResponse;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl; import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.anplus.hr.ai.domain.model.AiConsumeRecord; import com.anplus.hr.ai.domain.model.AiConsumeRecord;
import com.anplus.hr.ai.domain.params.AiConsumeDetailParam;
import com.anplus.hr.ai.domain.params.AiConsumeRecordListParam; import com.anplus.hr.ai.domain.params.AiConsumeRecordListParam;
import com.anplus.hr.ai.domain.params.AiConsumeRecordParam; import com.anplus.hr.ai.domain.params.AiConsumeRecordParam;
import com.anplus.hr.ai.domain.vo.AiConsumeRecordSummaryVo;
import com.anplus.hr.ai.domain.vo.AiConsumeRecordVo; import com.anplus.hr.ai.domain.vo.AiConsumeRecordVo;
import com.anplus.hr.ai.mapper.AiConsumeRecordMapper; import com.anplus.hr.ai.mapper.AiConsumeRecordMapper;
import com.anplus.hr.ai.service.AiConsumeRecordServ; import com.anplus.hr.ai.service.AiConsumeRecordServ;
import top.binfast.app.biz.sysapi.dao.auth.SysDeptMapper;
import top.binfast.app.biz.sysbiz.service.SysDeptServ;
import top.binfast.common.core.exception.PlatformException;
import top.binfast.common.core.util.MapstructUtils; import top.binfast.common.core.util.MapstructUtils;
import top.binfast.common.mybatis.query.LambdaQueryBuilder; import top.binfast.common.mybatis.query.LambdaQueryBuilder;
import top.binfast.common.mybatis.query.QueryBuilder; import top.binfast.common.mybatis.query.QueryBuilder;
import top.binfast.common.mybatis.tenant.core.TenantHelper;
import top.binfast.common.mybatis.util.IdGeneratorUtil;
import top.binfast.common.mybatis.util.QueryUtil; import top.binfast.common.mybatis.util.QueryUtil;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/** /**
* AI工具消费记录Service业务层处理 * AI工具消费记录Service业务层处理
...@@ -31,6 +48,8 @@ import java.util.Map; ...@@ -31,6 +48,8 @@ import java.util.Map;
public class AiConsumeRecordServImpl extends ServiceImpl<AiConsumeRecordMapper, AiConsumeRecord> implements AiConsumeRecordServ { public class AiConsumeRecordServImpl extends ServiceImpl<AiConsumeRecordMapper, AiConsumeRecord> implements AiConsumeRecordServ {
private final AiConsumeRecordMapper aiConsumeRecordMapper; private final AiConsumeRecordMapper aiConsumeRecordMapper;
private final SysDeptMapper deptMapper;
private final SysDeptServ sysDeptServ;
/** /**
* 分页查询AI工具消费记录列表 * 分页查询AI工具消费记录列表
...@@ -55,18 +74,74 @@ public class AiConsumeRecordServImpl extends ServiceImpl<AiConsumeRecordMapper, ...@@ -55,18 +74,74 @@ public class AiConsumeRecordServImpl extends ServiceImpl<AiConsumeRecordMapper,
@Override @Override
public List<AiConsumeRecordVo> queryList(AiConsumeRecordListParam param) { public List<AiConsumeRecordVo> queryList(AiConsumeRecordListParam param) {
LambdaQueryWrapper<AiConsumeRecord> lambdaQuery = this.buildQueryWrapper(param); LambdaQueryWrapper<AiConsumeRecord> lambdaQuery = this.buildQueryWrapper(param);
return MapstructUtils.convert(aiConsumeRecordMapper.selectList(lambdaQuery), AiConsumeRecordVo.class); List<AiConsumeRecordVo> list = MapstructUtils.convert(aiConsumeRecordMapper.selectList(lambdaQuery), AiConsumeRecordVo.class);
//导出路径批量回填部门名:@Translation 仅在 JSON 响应增强管线生效,Excel 导出走 fesod 直接反射读字段,需手动回填
Set<Long> deptIds = list.stream()
.map(AiConsumeRecordVo::getDeptId)
.filter(ObjectUtil::isNotNull)
.collect(Collectors.toSet());
if (CollUtil.isNotEmpty(deptIds)) {
Map<Long, String> deptNames = sysDeptServ.selectDeptNamesByIds(deptIds);
list.forEach(vo -> vo.setDeptName(deptNames.get(vo.getDeptId())));
}
//导出末尾追加合计行:全量数据已在内存,流式求和(filter nonNull 与 SQL SUM 忽略 NULL 语义一致),不发第二次 SQL(design D7)
BigDecimal amountCnyTotal = list.stream()
.map(AiConsumeRecordVo::getAmountCny)
.filter(ObjectUtil::isNotNull)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(2, RoundingMode.HALF_UP);
//合计行仅 deptName + amountCny 有值,其余字段保持 null(导出为空单元格,列结构不变,design D10/D11)
AiConsumeRecordVo totalRow = new AiConsumeRecordVo();
totalRow.setDeptName("合计");
totalRow.setAmountCny(amountCnyTotal);
list.add(totalRow);
return list;
} }
private LambdaQueryWrapper<AiConsumeRecord> buildQueryWrapper(AiConsumeRecordListParam param) { /**
* 查询当前条件下的金额合计(跨所有分页的全量筛选结果,口径与列表完全一致)。
*
* @param param 查询条件(与分页查询共用同一条件对象)
* @return 合计载体(amountCny 保留两位小数;无匹配数据时为 0.00)
*/
@Override
public AiConsumeRecordSummaryVo querySummary(AiConsumeRecordListParam param) {
//复用 buildCondition(不含 orderBy),规避 ONLY_FULL_GROUP_BY 下「聚合 + 非聚合列排序」报错(design D3)
LambdaQueryWrapper<AiConsumeRecord> wrapper = buildCondition(param)
.selectSum(AiConsumeRecord::getAmountCny)
.build();
//SUM 无 GROUP BY 恒返回单行;空结果集时该行为 null,落 ZERO(design D5)
List<Object> objs = aiConsumeRecordMapper.selectObjs(wrapper);
BigDecimal amountCny = CollUtil.isEmpty(objs)
? BigDecimal.ZERO
: Convert.toBigDecimal(objs.get(0), BigDecimal.ZERO);
AiConsumeRecordSummaryVo summary = new AiConsumeRecordSummaryVo();
summary.setAmountCny(amountCny.setScale(2, RoundingMode.HALF_UP));
return summary;
}
/**
* 构造查询条件(不含排序),供列表查询与合计聚合共用,确保两者筛选口径完全一致(design D3)。
*
* @param param 查询条件
* @return 承载全部筛选条件(多租户 + 部门树子树 + 搜索条件 + 时间范围)的 builder,不含 orderBy
*/
private LambdaQueryBuilder<AiConsumeRecord> buildCondition(AiConsumeRecordListParam param) {
Map<String, Object> params = param.getParams(); Map<String, Object> params = param.getParams();
LambdaQueryBuilder<AiConsumeRecord> builder = QueryBuilder.lambda(AiConsumeRecord.class) return QueryBuilder.lambda(AiConsumeRecord.class)
.eqIfText(AiConsumeRecord::getProjectName, param.getProjectName()) .eqIfText(AiConsumeRecord::getProjectName, param.getProjectName())
.eqIfText(AiConsumeRecord::getToolName, param.getToolName()) .eqIfText(AiConsumeRecord::getToolName, param.getToolName())
.likeIfText(AiConsumeRecord::getUserName, param.getUserName()) .likeIfText(AiConsumeRecord::getUserName, param.getUserName())
.betweenParams(AiConsumeRecord::getConsumeTime, params, "beginTime", "endTime") .betweenParams(AiConsumeRecord::getConsumeTime, params, "beginTime", "endTime")
.orderByDesc(AiConsumeRecord::getConsumeTime); //部门树筛选(含子部门子树,对齐 SysUserServImpl.buildQueryWrapper)
return builder.build(); .and(ObjectUtil.isNotNull(param.getBelongDeptId()), x -> {
List<Long> deptIds = deptMapper.selectDeptAndChildById(param.getBelongDeptId());
x.in(AiConsumeRecord::getDeptId, deptIds);
});
}
private LambdaQueryWrapper<AiConsumeRecord> buildQueryWrapper(AiConsumeRecordListParam param) {
return buildCondition(param).orderByDesc(AiConsumeRecord::getConsumeTime).build();
} }
/** /**
...@@ -82,27 +157,87 @@ public class AiConsumeRecordServImpl extends ServiceImpl<AiConsumeRecordMapper, ...@@ -82,27 +157,87 @@ public class AiConsumeRecordServImpl extends ServiceImpl<AiConsumeRecordMapper,
} }
/** /**
* 新增AI工具消费记录 * 按批次号查询整批详情(公共字段取首行,details 为全部明细行,line_sort 升序)。
* *
* @param param AI工具消费记录 * @param batchNo 批次号
* @return 整批详情视图对象;批次不存在或跨租户时返回 null
*/
@Override
public AiConsumeRecordVo queryByBatchNo(String batchNo) {
List<AiConsumeRecord> rows = aiConsumeRecordMapper.selectByBatchNo(batchNo);
if (CollUtil.isEmpty(rows)) {
return null;
}
AiConsumeRecordVo vo = MapstructUtils.convert(rows.get(0), AiConsumeRecordVo.class);
vo.setDetails(MapstructUtils.convert(rows, AiConsumeRecordVo.class));
return vo;
}
/**
* 新增AI工具消费记录(主从批量:一次提交生成共享同一 batch_no 的多行)
*
* @param param AI工具消费记录(公共字段 + 明细列表)
* @return 是否新增成功 * @return 是否新增成功
*/ */
@Override @Override
@Transactional(rollbackFor = Exception.class)
public Boolean insertByParam(AiConsumeRecordParam param) { public Boolean insertByParam(AiConsumeRecordParam param) {
AiConsumeRecord aiConsumeRecord = MapstructUtils.convert(param, AiConsumeRecord.class); //生成全局唯一批次号(项目标准雪花设施:IdGeneratorUtil 底层为已注册的 SnGen 生成器)
return this.save(aiConsumeRecord); String batchNo = IdGeneratorUtil.nextId();
List<AiConsumeRecord> records = this.buildRecords(param, batchNo);
return this.saveBatch(records);
} }
/** /**
* 修改AI工具消费记录 * 修改AI工具消费记录(主从批量:事务内「整批全删重插」,batch_no 保持不变)
* *
* @param param AI工具消费记录 * @param param AI工具消费记录(batchNo + 公共字段 + 明细列表)
* @return 是否修改成功 * @return 是否修改成功
*/ */
@Override @Override
@Transactional(rollbackFor = Exception.class)
public Boolean updateByParam(AiConsumeRecordParam param) { public Boolean updateByParam(AiConsumeRecordParam param) {
AiConsumeRecord aiConsumeRecord = MapstructUtils.convert(param, AiConsumeRecord.class); String batchNo = param.getBatchNo();
return this.updateById(aiConsumeRecord); if (StrUtil.isBlank(batchNo)) {
throw new PlatformException("批次号不能为空");
}
//先按 batch_no 物理删除原批(显式带 tenant_id,杜绝跨租户误删,D3/R1),再以原 batch_no 重插当前明细
aiConsumeRecordMapper.deleteByBatchNo(batchNo, TenantHelper.getTenantId());
List<AiConsumeRecord> records = this.buildRecords(param, batchNo);
return this.saveBatch(records);
}
/**
* 按公共字段 + 明细列表组装待落库记录,line_sort 从 1 连续赋值(同批共享 batch_no)。
*
* @param param 入参(公共字段 + details)
* @param batchNo 批次号
* @return 记录列表
*/
private List<AiConsumeRecord> buildRecords(AiConsumeRecordParam param, String batchNo) {
List<AiConsumeDetailParam> details = param.getDetails();
List<AiConsumeRecord> records = new ArrayList<>(details.size());
for (int i = 0; i < details.size(); i++) {
AiConsumeDetailParam detail = details.get(i);
AiConsumeRecord record = new AiConsumeRecord();
//公共字段(同批各行冗余存储)
record.setDeptId(param.getDeptId());
record.setProjectName(param.getProjectName());
record.setUserName(param.getUserName());
//消费时间:LocalDate 直接落库(date 列,无时分秒)
record.setConsumeTime(param.getConsumeTime());
record.setRemark(param.getRemark());
//批次元数据
record.setBatchNo(batchNo);
record.setLineSort(i + 1);
//明细字段
record.setToolName(detail.getToolName());
record.setRegion(detail.getRegion());
record.setConsumePoints(detail.getConsumePoints());
record.setAmountCny(detail.getAmountCny());
records.add(record);
}
return records;
} }
/** /**
......
package com.anplus.hr.ai.service.impl; package com.anplus.hr.ai.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.ObjectUtil;
import com.alibaba.cola.dto.PageResponse; import com.alibaba.cola.dto.PageResponse;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
...@@ -9,16 +12,23 @@ import org.springframework.stereotype.Service; ...@@ -9,16 +12,23 @@ import org.springframework.stereotype.Service;
import com.anplus.hr.ai.domain.model.AiRechargeRecord; import com.anplus.hr.ai.domain.model.AiRechargeRecord;
import com.anplus.hr.ai.domain.params.AiRechargeRecordListParam; import com.anplus.hr.ai.domain.params.AiRechargeRecordListParam;
import com.anplus.hr.ai.domain.params.AiRechargeRecordParam; import com.anplus.hr.ai.domain.params.AiRechargeRecordParam;
import com.anplus.hr.ai.domain.vo.AiRechargeRecordSummaryVo;
import com.anplus.hr.ai.domain.vo.AiRechargeRecordVo; import com.anplus.hr.ai.domain.vo.AiRechargeRecordVo;
import com.anplus.hr.ai.mapper.AiRechargeRecordMapper; import com.anplus.hr.ai.mapper.AiRechargeRecordMapper;
import com.anplus.hr.ai.service.AiRechargeRecordServ; import com.anplus.hr.ai.service.AiRechargeRecordServ;
import top.binfast.app.biz.sysapi.dao.auth.SysDeptMapper;
import top.binfast.app.biz.sysbiz.service.SysDeptServ;
import top.binfast.common.core.util.MapstructUtils; import top.binfast.common.core.util.MapstructUtils;
import top.binfast.common.mybatis.query.LambdaQueryBuilder; import top.binfast.common.mybatis.query.LambdaQueryBuilder;
import top.binfast.common.mybatis.query.QueryBuilder; import top.binfast.common.mybatis.query.QueryBuilder;
import top.binfast.common.mybatis.util.QueryUtil; import top.binfast.common.mybatis.util.QueryUtil;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/** /**
* AI工具充值记录Service业务层处理 * AI工具充值记录Service业务层处理
...@@ -31,6 +41,8 @@ import java.util.Map; ...@@ -31,6 +41,8 @@ import java.util.Map;
public class AiRechargeRecordServImpl extends ServiceImpl<AiRechargeRecordMapper, AiRechargeRecord> implements AiRechargeRecordServ { public class AiRechargeRecordServImpl extends ServiceImpl<AiRechargeRecordMapper, AiRechargeRecord> implements AiRechargeRecordServ {
private final AiRechargeRecordMapper aiRechargeRecordMapper; private final AiRechargeRecordMapper aiRechargeRecordMapper;
private final SysDeptMapper deptMapper;
private final SysDeptServ sysDeptServ;
/** /**
* 分页查询AI工具充值记录列表 * 分页查询AI工具充值记录列表
...@@ -55,18 +67,81 @@ public class AiRechargeRecordServImpl extends ServiceImpl<AiRechargeRecordMapper ...@@ -55,18 +67,81 @@ public class AiRechargeRecordServImpl extends ServiceImpl<AiRechargeRecordMapper
@Override @Override
public List<AiRechargeRecordVo> queryList(AiRechargeRecordListParam param) { public List<AiRechargeRecordVo> queryList(AiRechargeRecordListParam param) {
LambdaQueryWrapper<AiRechargeRecord> lambdaQuery = this.buildQueryWrapper(param); LambdaQueryWrapper<AiRechargeRecord> lambdaQuery = this.buildQueryWrapper(param);
return MapstructUtils.convert(aiRechargeRecordMapper.selectList(lambdaQuery), AiRechargeRecordVo.class); List<AiRechargeRecordVo> list = MapstructUtils.convert(aiRechargeRecordMapper.selectList(lambdaQuery), AiRechargeRecordVo.class);
//导出路径批量回填部门名:@Translation 仅在 JSON 响应增强管线生效,Excel 导出走 fesod 直接反射读字段,需手动回填
Set<Long> deptIds = list.stream()
.map(AiRechargeRecordVo::getDeptId)
.filter(ObjectUtil::isNotNull)
.collect(Collectors.toSet());
if (CollUtil.isNotEmpty(deptIds)) {
Map<Long, String> deptNames = sysDeptServ.selectDeptNamesByIds(deptIds);
list.forEach(vo -> vo.setDeptName(deptNames.get(vo.getDeptId())));
}
//导出末尾追加合计行:全量数据已在内存,两金额列各流式求和(filter nonNull 与 SQL SUM 忽略 NULL 语义一致),不发第二次 SQL(design D7)
BigDecimal amountUsdTotal = list.stream()
.map(AiRechargeRecordVo::getAmountUsd)
.filter(ObjectUtil::isNotNull)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(2, RoundingMode.HALF_UP);
BigDecimal amountCnyTotal = list.stream()
.map(AiRechargeRecordVo::getAmountCny)
.filter(ObjectUtil::isNotNull)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(2, RoundingMode.HALF_UP);
//合计行仅 deptName + 两金额有值,其余字段(含 rechargeCategory)保持 null:
//rechargeCategory=null 经 ExcelDictConvert null 分支落空单元格,不触发字典转换异常(design D7/Risks)
AiRechargeRecordVo totalRow = new AiRechargeRecordVo();
totalRow.setDeptName("合计");
totalRow.setAmountUsd(amountUsdTotal);
totalRow.setAmountCny(amountCnyTotal);
list.add(totalRow);
return list;
} }
private LambdaQueryWrapper<AiRechargeRecord> buildQueryWrapper(AiRechargeRecordListParam param) { /**
* 查询当前条件下的双金额合计(跨所有分页的全量筛选结果,口径与列表完全一致)。
*
* @param param 查询条件(与分页查询共用同一条件对象)
* @return 合计载体(amountUsd、amountCny 各保留两位小数;无匹配数据时为 0.00)
*/
@Override
public AiRechargeRecordSummaryVo querySummary(AiRechargeRecordListParam param) {
//amountUsd、amountCny 各构造一次聚合查询(均复用 buildCondition,不含 orderBy,规避 ONLY_FULL_GROUP_BY,design D3/D4)
List<Object> usdObjs = aiRechargeRecordMapper.selectObjs(
buildCondition(param).selectSum(AiRechargeRecord::getAmountUsd).build());
List<Object> cnyObjs = aiRechargeRecordMapper.selectObjs(
buildCondition(param).selectSum(AiRechargeRecord::getAmountCny).build());
//SUM 无 GROUP BY 恒返回单行;空结果集时该行为 null,落 ZERO(design D5)
BigDecimal amountUsd = CollUtil.isEmpty(usdObjs) ? BigDecimal.ZERO : Convert.toBigDecimal(usdObjs.get(0), BigDecimal.ZERO);
BigDecimal amountCny = CollUtil.isEmpty(cnyObjs) ? BigDecimal.ZERO : Convert.toBigDecimal(cnyObjs.get(0), BigDecimal.ZERO);
AiRechargeRecordSummaryVo summary = new AiRechargeRecordSummaryVo();
summary.setAmountUsd(amountUsd.setScale(2, RoundingMode.HALF_UP));
summary.setAmountCny(amountCny.setScale(2, RoundingMode.HALF_UP));
return summary;
}
/**
* 构造查询条件(不含排序),供列表查询与合计聚合共用,确保两者筛选口径完全一致(design D3)。
*
* @param param 查询条件
* @return 承载全部筛选条件(多租户 + 部门树子树 + 搜索条件 + 时间范围)的 builder,不含 orderBy
*/
private LambdaQueryBuilder<AiRechargeRecord> buildCondition(AiRechargeRecordListParam param) {
Map<String, Object> params = param.getParams(); Map<String, Object> params = param.getParams();
LambdaQueryBuilder<AiRechargeRecord> builder = QueryBuilder.lambda(AiRechargeRecord.class) return QueryBuilder.lambda(AiRechargeRecord.class)
.eqIfText(AiRechargeRecord::getToolName, param.getToolName()) .eqIfText(AiRechargeRecord::getToolName, param.getToolName())
.eqIfText(AiRechargeRecord::getRegion, param.getRegion()) .eqIfText(AiRechargeRecord::getRegion, param.getRegion())
.eqIfText(AiRechargeRecord::getRechargeCategory, param.getRechargeCategory()) .eqIfText(AiRechargeRecord::getRechargeCategory, param.getRechargeCategory())
.betweenParams(AiRechargeRecord::getRechargeTime, params, "beginTime", "endTime") .betweenParams(AiRechargeRecord::getRechargeTime, params, "beginTime", "endTime")
.orderByDesc(AiRechargeRecord::getRechargeTime); //部门树筛选(含子部门子树,对齐 SysUserServImpl.buildQueryWrapper)
return builder.build(); .and(ObjectUtil.isNotNull(param.getBelongDeptId()), x -> {
List<Long> deptIds = deptMapper.selectDeptAndChildById(param.getBelongDeptId());
x.in(AiRechargeRecord::getDeptId, deptIds);
});
}
private LambdaQueryWrapper<AiRechargeRecord> buildQueryWrapper(AiRechargeRecordListParam param) {
return buildCondition(param).orderByDesc(AiRechargeRecord::getRechargeTime).build();
} }
/** /**
......
...@@ -4,4 +4,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" ...@@ -4,4 +4,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd"> "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.anplus.hr.ai.mapper.AiConsumeRecordMapper"> <mapper namespace="com.anplus.hr.ai.mapper.AiConsumeRecordMapper">
<!--
按批次号物理删除整批(编辑「整批全删重插」用,绕过 @TableLogic 逻辑删除)。
显式带 tenant_id 条件(design.md D3 / R1),杜绝跨租户误删同 batch_no 行。
-->
<delete id="deleteByBatchNo">
delete from ai_consume_record
where batch_no = #{batchNo} and tenant_id = #{tenantId}
</delete>
</mapper> </mapper>
import type { ID, IDS, PageQuery, PageResult } from '@/api/common'; import type { ID, IDS, PageQuery, PageResult } from '@/api/common';
import type { DeptTree } from '@/api/system/user/model';
import type { Consume } from './model'; import type { Consume, ConsumeForm, ConsumeSummary } from './model';
import { commonExport } from '@/api/helper'; import { commonExport } from '@/api/helper';
import { alovaInstance } from '@/utils/http'; import { alovaInstance } from '@/utils/http';
...@@ -8,6 +9,7 @@ import { alovaInstance } from '@/utils/http'; ...@@ -8,6 +9,7 @@ import { alovaInstance } from '@/utils/http';
enum Api { enum Api {
consumeExport = '/ai/consume/export', consumeExport = '/ai/consume/export',
consumeList = '/ai/consume/list', consumeList = '/ai/consume/list',
consumeSummary = '/ai/consume/summary',
root = '/ai/consume', root = '/ai/consume',
} }
...@@ -20,6 +22,15 @@ export function consumeList(params?: PageQuery) { ...@@ -20,6 +22,15 @@ export function consumeList(params?: PageQuery) {
return alovaInstance.get<PageResult<Consume>>(Api.consumeList, { params }); return alovaInstance.get<PageResult<Consume>>(Api.consumeList, { params });
} }
/**
* 查询消费记录金额合计(跨所有分页的全量筛选结果,口径与列表一致)
* @param params 请求参数(与列表查询共用同一条件)
* @returns 合计载体(amountCny)
*/
export function consumeSummary(params?: PageQuery) {
return alovaInstance.get<ConsumeSummary>(Api.consumeSummary, { params });
}
/** /**
* 导出消费记录excel * 导出消费记录excel
* @param data 请求参数 * @param data 请求参数
...@@ -29,7 +40,7 @@ export function consumeExport(data: Partial<Consume>) { ...@@ -29,7 +40,7 @@ export function consumeExport(data: Partial<Consume>) {
} }
/** /**
* 消费记录详情 * 消费记录详情(单条)
* @param id id * @param id id
* @returns 详情 * @returns 详情
*/ */
...@@ -38,18 +49,27 @@ export function consumeInfo(id: ID) { ...@@ -38,18 +49,27 @@ export function consumeInfo(id: ID) {
} }
/** /**
* 消费记录新增 * 消费记录整批详情(主从批量录入编辑回填用)
* @param data 参数 * @param batchNo 批次号
* @returns 整批详情(公共字段 + details 明细列表)
*/
export function consumeBatchDetail(batchNo: string) {
return alovaInstance.get<Consume>(`${Api.root}/batch/${batchNo}`);
}
/**
* 消费记录新增(主从批量:公共字段 + details 明细数组)
* @param data 批量入参
*/ */
export function consumeAdd(data: Partial<Consume>) { export function consumeAdd(data: ConsumeForm) {
return alovaInstance.postWithMsg<void>(Api.root, data); return alovaInstance.postWithMsg<void>(Api.root, data);
} }
/** /**
* 消费记录修改 * 消费记录修改(主从批量:整批全删重插)
* @param data 参数 * @param data 批量入参
*/ */
export function consumeUpdate(data: Partial<Consume>) { export function consumeUpdate(data: ConsumeForm) {
return alovaInstance.putWithMsg<void>(Api.root, data); return alovaInstance.putWithMsg<void>(Api.root, data);
} }
...@@ -60,3 +80,11 @@ export function consumeUpdate(data: Partial<Consume>) { ...@@ -60,3 +80,11 @@ export function consumeUpdate(data: Partial<Consume>) {
export function consumeRemove(ids: IDS) { export function consumeRemove(ids: IDS) {
return alovaInstance.deleteWithMsg<void>(`${Api.root}/${ids}`); return alovaInstance.deleteWithMsg<void>(`${Api.root}/${ids}`);
} }
/**
* 消费记录专用 - 获取部门树
* @returns 部门树
*/
export function consumeDeptTreeSelect() {
return alovaInstance.get<DeptTree[]>('/ai/consume/deptTree');
}
...@@ -4,7 +4,27 @@ ...@@ -4,7 +4,27 @@
export interface Consume { export interface Consume {
id: number; id: number;
/** /**
* 项目名称(字典ai_project_name的dict_value) * 批次号(主从批量录入元数据;非展示、非导出,编辑流按此定位整批)
*/
batchNo?: string;
/**
* 行序号(批次内从 1 连续;非展示、非导出)
*/
lineSort?: number;
/**
* 所属部门ID
*/
deptId?: number;
/**
* 所属部门名称(后端 @Translation 翻译 / 导出回填)
*/
deptName?: string;
/**
* 归属部门id(部门树查询用,含子部门)
*/
belongDeptId?: number | string;
/**
* 项目名称(用户直接输入的自由文本)
*/ */
projectName: string; projectName: string;
/** /**
...@@ -12,7 +32,7 @@ export interface Consume { ...@@ -12,7 +32,7 @@ export interface Consume {
*/ */
userName: string; userName: string;
/** /**
* AI工具/平台名称(字典ai_tool_name的dict_value * AI工具/平台名称(用户直接输入的自由文本
*/ */
toolName: string; toolName: string;
/** /**
...@@ -36,4 +56,74 @@ export interface Consume { ...@@ -36,4 +56,74 @@ export interface Consume {
*/ */
remark: string; remark: string;
createTime: string; createTime: string;
/**
* 明细行列表(仅整批详情接口 GET /batch/{batchNo} 返回;列表与单条详情为 undefined)
*/
details?: Consume[];
}
/**
* @description: 消费明细行入参(对应后端 AiConsumeDetailParam)
*/
export interface ConsumeDetailForm {
/**
* AI工具/平台名称
*/
toolName?: string;
/**
* 国内/国外(固定二值:国内 | 国外)
*/
region?: string;
/**
* 消耗积分(非必填,最多 2 位小数)
*/
consumePoints?: number;
/**
* 金额(人民币,非必填,最多 2 位小数)
*/
amountCny?: number;
}
/**
* @description: 消费记录批量入参(主从结构,对应后端 AiConsumeRecordParam)
*/
export interface ConsumeForm {
/**
* 批次号(编辑时定位原批;新增时为空、由服务端生成)
*/
batchNo?: string;
/**
* 所属部门ID
*/
deptId?: number;
/**
* 项目名称(用户直接输入的自由文本)
*/
projectName?: string;
/**
* 使用人姓名
*/
userName?: string;
/**
* 消费发生时间(日期类型,落库 date 当天日期)
*/
consumeTime?: string;
/**
* 备注
*/
remark?: string;
/**
* 消费明细行列表(至少一行)
*/
details: ConsumeDetailForm[];
}
/**
* @description: 消费记录金额合计载体(对应后端 AiConsumeRecordSummaryVo)
*/
export interface ConsumeSummary {
/**
* 金额(人民币)全量合计(两位小数)
*/
amountCny: number;
} }
import type { ID, IDS, PageQuery, PageResult } from '@/api/common'; import type { ID, IDS, PageQuery, PageResult } from '@/api/common';
import type { DeptTree } from '@/api/system/user/model';
import type { Recharge } from './model'; import type { Recharge, RechargeSummary } from './model';
import { commonExport } from '@/api/helper'; import { commonExport } from '@/api/helper';
import { alovaInstance } from '@/utils/http'; import { alovaInstance } from '@/utils/http';
...@@ -8,6 +9,7 @@ import { alovaInstance } from '@/utils/http'; ...@@ -8,6 +9,7 @@ import { alovaInstance } from '@/utils/http';
enum Api { enum Api {
rechargeExport = '/ai/recharge/export', rechargeExport = '/ai/recharge/export',
rechargeList = '/ai/recharge/list', rechargeList = '/ai/recharge/list',
rechargeSummary = '/ai/recharge/summary',
root = '/ai/recharge', root = '/ai/recharge',
} }
...@@ -20,6 +22,15 @@ export function rechargeList(params?: PageQuery) { ...@@ -20,6 +22,15 @@ export function rechargeList(params?: PageQuery) {
return alovaInstance.get<PageResult<Recharge>>(Api.rechargeList, { params }); return alovaInstance.get<PageResult<Recharge>>(Api.rechargeList, { params });
} }
/**
* 查询充值记录双金额合计(跨所有分页的全量筛选结果,口径与列表一致)
* @param params 请求参数(与列表查询共用同一条件)
* @returns 合计载体(amountUsd、amountCny)
*/
export function rechargeSummary(params?: PageQuery) {
return alovaInstance.get<RechargeSummary>(Api.rechargeSummary, { params });
}
/** /**
* 导出充值记录excel * 导出充值记录excel
* @param data 请求参数 * @param data 请求参数
...@@ -60,3 +71,11 @@ export function rechargeUpdate(data: Partial<Recharge>) { ...@@ -60,3 +71,11 @@ export function rechargeUpdate(data: Partial<Recharge>) {
export function rechargeRemove(ids: IDS) { export function rechargeRemove(ids: IDS) {
return alovaInstance.deleteWithMsg<void>(`${Api.root}/${ids}`); return alovaInstance.deleteWithMsg<void>(`${Api.root}/${ids}`);
} }
/**
* 充值记录专用 - 获取部门树
* @returns 部门树
*/
export function rechargeDeptTreeSelect() {
return alovaInstance.get<DeptTree[]>('/ai/recharge/deptTree');
}
...@@ -4,7 +4,19 @@ ...@@ -4,7 +4,19 @@
export interface Recharge { export interface Recharge {
id: number; id: number;
/** /**
* AI工具/平台名称(字典ai_tool_name的dict_value) * 所属部门ID
*/
deptId?: number;
/**
* 所属部门名称(后端 @Translation 翻译 / 导出回填)
*/
deptName?: string;
/**
* 归属部门id(部门树查询用,含子部门)
*/
belongDeptId?: number | string;
/**
* AI工具/平台名称(用户直接输入的自由文本)
*/ */
toolName: string; toolName: string;
/** /**
...@@ -49,3 +61,17 @@ export interface Recharge { ...@@ -49,3 +61,17 @@ export interface Recharge {
remark: string; remark: string;
createTime: string; createTime: string;
} }
/**
* @description: 充值记录双金额合计载体(对应后端 AiRechargeRecordSummaryVo)
*/
export interface RechargeSummary {
/**
* 充值金额(美元)全量合计(两位小数)
*/
amountUsd: number;
/**
* 充值金额(人民币)全量合计(两位小数)
*/
amountCny: number;
}
import type { GenerateMenuAndRoutesOptions, RouteRecordRaw } from '@/types'; import type { GenerateMenuAndRoutesOptions } from '@/types';
import type { Component, DefineComponent } from 'vue'; import type { Component, DefineComponent } from 'vue';
...@@ -8,7 +8,6 @@ import { ...@@ -8,7 +8,6 @@ import {
cloneDeep, cloneDeep,
generateMenus, generateMenus,
generateRoutesByBackend, generateRoutesByBackend,
generateRoutesByFrontend,
isFunction, isFunction,
isString, isString,
mapTree, mapTree,
...@@ -80,16 +79,11 @@ async function generateAccessible(options: GenerateMenuAndRoutesOptions) { ...@@ -80,16 +79,11 @@ async function generateAccessible(options: GenerateMenuAndRoutesOptions) {
* @param options * @param options
*/ */
async function generateRoutes(options: GenerateMenuAndRoutesOptions) { async function generateRoutes(options: GenerateMenuAndRoutesOptions) {
const { forbiddenComponent, roles, routes } = options; // 本项目采用「后端权限模式」:后端 /auth/menu/getRouters 返回当前用户授权的路由
// name 集合,fetchMenuListAsync 已基于前端路由表完成过滤与组件解析,故直接以后端
const [frontendResultRoutes, backendResultRoutes] = await Promise.all([ // 结果为唯一真相源,不再并入前端模式。(roles 为空时 generateRoutesByFrontend 会
generateRoutesByFrontend(routes, roles || [], forbiddenComponent), // 返回全量路由表,经合并会把后端已过滤掉的无权限路由又加回来,导致越权显示全部菜单。)
generateRoutesByBackend(options), let resultRoutes = await generateRoutesByBackend(options);
]);
let resultRoutes = mergeRoutesByName(
backendResultRoutes,
frontendResultRoutes,
);
/** /**
* 调整路由树,做以下处理: * 调整路由树,做以下处理:
...@@ -146,63 +140,4 @@ async function generateRoutes(options: GenerateMenuAndRoutesOptions) { ...@@ -146,63 +140,4 @@ async function generateRoutes(options: GenerateMenuAndRoutesOptions) {
return resultRoutes; return resultRoutes;
} }
/**
* 根据 name 合并前后端路由
* @param baseRoutes 后端路由
* @param extraRoutes 前端路由
*/
function mergeRoutesByName(
baseRoutes: RouteRecordRaw[],
extraRoutes: RouteRecordRaw[],
): RouteRecordRaw[] {
const result: RouteRecordRaw[] = [];
const routeMap = new Map<string, RouteRecordRaw>();
for (const route of baseRoutes) {
const clone = { ...route } as RouteRecordRaw;
result.push(clone);
if (clone.name && isString(clone.name)) {
routeMap.set(clone.name as string, clone);
}
}
for (const route of extraRoutes) {
if (
route.name &&
isString(route.name) &&
routeMap.has(route.name as string)
) {
const existing = routeMap.get(route.name as string);
if (!existing) {
continue;
}
const existingChildren = existing.children ?? [];
const routeChildren = route.children ?? [];
const merged = {
...route,
...existing, // keep backend as base
meta: {
...route.meta,
...existing.meta, // backend meta wins on conflicts
},
} as RouteRecordRaw;
if (existingChildren.length > 0 || routeChildren.length > 0) {
merged.children = mergeRoutesByName(existingChildren, routeChildren);
}
Object.assign(existing, merged);
} else {
const clone = { ...route } as RouteRecordRaw;
result.push(clone);
if (clone.name && isString(clone.name)) {
routeMap.set(clone.name as string, clone);
}
}
}
return result;
}
export { generateAccessible }; export { generateAccessible };
export const DictEnum = { export const DictEnum = {
AI_PROJECT_NAME: 'ai_project_name', // 项目名称
AI_RECHARGE_CATEGORY: 'ai_recharge_category', // 充值分类 AI_RECHARGE_CATEGORY: 'ai_recharge_category', // 充值分类
AI_TOOL_NAME: 'ai_tool_name', // AI工具/平台名称
SYS_COMMON_STATUS: 'sys_common_status', SYS_COMMON_STATUS: 'sys_common_status',
SYS_DEVICE_TYPE: 'sys_device_type', // 设备类型 SYS_DEVICE_TYPE: 'sys_device_type', // 设备类型
SYS_GRANT_TYPE: 'sys_grant_type', // 授权类型 SYS_GRANT_TYPE: 'sys_grant_type', // 授权类型
......
...@@ -219,25 +219,27 @@ function backMenuToVbenMenuV2( ...@@ -219,25 +219,27 @@ function backMenuToVbenMenuV2(
let componentPath = ''; let componentPath = '';
// 如果是动态组件或异步加载函数,可以提取路径字符串 // 如果是动态组件或异步加载函数,可以提取路径字符串
if (typeof component === 'function') { if (typeof component === 'function') {
// 优先使用 meta.componentPath:路由静态声明,dev/生产一致可靠,
// 转换后(@/ → ../)正好匹配 import.meta.glob('../views/**/*.vue') 的 key。
if (meta?.componentPath) {
componentPath = (meta.componentPath as string).replace('@/', '../');
} else {
// 回退:解析 () => import('xxx') 的源码字符串。
// 生产环境经 Rollup 转译后 component.toString() 不再是源码形式,
// 该回退仅在 dev 可靠,故动态路由务必显式配置 meta.componentPath。
try { try {
// 获取 () => import('xxx') 中的路径字符串
// componentPath =
// component.toString().match(/import\("(.*)"\)/)?.[1] || '';
const match = component.toString().match(/import\("(.*)"\)/); const match = component.toString().match(/import\("(.*)"\)/);
if (match && match[1]) { if (match && match[1]) {
// 替换 '/src' 为 '../views' componentPath = match[1].replace('/src', '..');
// componentPath = match[1].replace('/src', '..');
componentPath = meta?.componentPath
? (meta.componentPath as string).replace('@/', '../')
: match[1].replace('/src', '..');
if (componentPath.includes('?t=')) {
componentPath = componentPath.split('?')[0] || '';
}
} }
} catch { } catch {
console.warn(`无法解析组件路径:${String(route.name)}`); console.warn(`无法解析组件路径:${String(route.name)}`);
} }
} }
if (componentPath.includes('?t=')) {
componentPath = componentPath.split('?')[0] || '';
}
}
const processedChildren = children const processedChildren = children
? backMenuToVbenMenuV2(menuList, children as RouteRecordRaw[]) ? backMenuToVbenMenuV2(menuList, children as RouteRecordRaw[])
......
...@@ -25,6 +25,7 @@ const routes: RouteRecordRaw[] = [ ...@@ -25,6 +25,7 @@ const routes: RouteRecordRaw[] = [
meta: { meta: {
affixTab: true, affixTab: true,
title: $t('page.dashboard.analytics'), title: $t('page.dashboard.analytics'),
componentPath: '@/views/dashboard/analytics/index.vue',
}, },
}, },
{ {
...@@ -48,6 +49,7 @@ const routes: RouteRecordRaw[] = [ ...@@ -48,6 +49,7 @@ const routes: RouteRecordRaw[] = [
title: '更新日志', title: '更新日志',
badge: `当前: ${version}`, badge: `当前: ${version}`,
badgeVariants: 'bg-primary', badgeVariants: 'bg-primary',
componentPath: '@/views/_core/changelog/index.vue',
}, },
}, },
], ],
...@@ -58,6 +60,7 @@ const routes: RouteRecordRaw[] = [ ...@@ -58,6 +60,7 @@ const routes: RouteRecordRaw[] = [
icon: 'lucide:copyright', icon: 'lucide:copyright',
order: 9999, order: 9999,
title: $t('demos.vben.about'), title: $t('demos.vben.about'),
componentPath: '@/views/_core/about/index.vue',
}, },
name: 'About', name: 'About',
path: '/vben-admin/about', path: '/vben-admin/about',
......
...@@ -9,6 +9,7 @@ const routes: RouteRecordRaw[] = [ ...@@ -9,6 +9,7 @@ const routes: RouteRecordRaw[] = [
title: $t('ui.widgets.profile'), title: $t('ui.widgets.profile'),
hideInMenu: true, hideInMenu: true,
requireHomeRedirect: true, requireHomeRedirect: true,
componentPath: '@/views/_core/profile/index.vue',
}, },
name: 'Profile', name: 'Profile',
path: '/profile', path: '/profile',
......
...@@ -43,8 +43,8 @@ const rememberMe = ref(!!localUsername); ...@@ -43,8 +43,8 @@ const rememberMe = ref(!!localUsername);
const formState = reactive({ const formState = reactive({
tenantId: DEFAULT_TENANT_ID, tenantId: DEFAULT_TENANT_ID,
username: localUsername || 'admin', username: localUsername || '',
password: '111111', password: '',
code: '', code: '',
}); });
......
<script setup lang="ts"> <script setup lang="tsx">
import type { Consume } from '@/api/ai/consume/model'; import type { ConsumeDetailForm, ConsumeForm } from '@/api/ai/consume/model';
import type { DeptTree } from '@/api/system/user/model';
import type { AntdFormRules } from '@/types/form'; import type { AntdFormRules } from '@/types/form';
import type { FormInstance } from 'antdv-next'; import type { FormInstance } from 'antdv-next';
import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
import { computed, ref } from 'vue'; import { computed, nextTick, ref, useTemplateRef } from 'vue';
import { consumeAdd, consumeInfo, consumeUpdate } from '@/api/ai/consume'; import { consumeAdd, consumeBatchDetail, consumeUpdate } from '@/api/ai/consume';
import { getDeptTree } from '@/api/system/user';
import { useVbenDrawer } from '@/components'; import { useVbenDrawer } from '@/components';
import { import {
FormInput as Input, FormInput,
FormInputNumber as InputNumber, FormTextArea,
FormSelect as Select, FormTreeSelect,
FormTextArea as TextArea,
} from '@/components/global/form'; } from '@/components/global/form';
import { DictEnum } from '@/constants'; import { withDefaultVxeGridOptions } from '@/components/vxe-table';
import { $t } from '@/locales'; import { $t } from '@/locales';
import { cloneDeep, getPopupContainer } from '@/utils'; import { addFullName, cloneDeep, getPopupContainer } from '@/utils';
import { getDictOptions } from '@/utils/dict';
import { useBeforeCloseDiff } from '@/utils/popup'; import { useBeforeCloseDiff } from '@/utils/popup';
import { DatePicker, Form, FormItem } from 'antdv-next'; import {
Button,
DatePicker,
Form,
FormItem,
Input,
InputNumber,
Select,
} from 'antdv-next';
import { VxeGrid } from 'vxe-table';
const emit = defineEmits<{ reload: [] }>(); const emit = defineEmits<{ reload: [] }>();
...@@ -27,41 +37,214 @@ const title = computed(() => { ...@@ -27,41 +37,214 @@ const title = computed(() => {
return isUpdate.value ? $t('pages.common.edit') : $t('pages.common.add'); return isUpdate.value ? $t('pages.common.edit') : $t('pages.common.add');
}); });
type FormData = Partial<Consume>; // 公共字段(主表):所属部门 / 项目名称 / 使用人姓名 / 消费时间 / 备注
type CommonFormData = {
// 国内/国外为固定二值(非字典,Clarify C2') batchNo?: string;
const regionOptions = [ consumeTime?: string;
{ label: '国内', value: '国内' }, deptId?: number | string;
{ label: '国外', value: '国外' }, projectName?: string;
]; remark?: string;
userName?: string;
};
function getDefaultValues(): FormData { function getDefaultCommon(): CommonFormData {
return { return {
id: undefined, batchNo: undefined,
deptId: undefined,
projectName: undefined, projectName: undefined,
userName: undefined, userName: undefined,
consumeTime: undefined,
remark: '',
};
}
// 明细默认一空行
function getDefaultDetail(): ConsumeDetailForm {
return {
toolName: undefined, toolName: undefined,
region: undefined, region: undefined,
consumePoints: undefined, consumePoints: undefined,
amountCny: undefined, amountCny: undefined,
consumeTime: undefined,
remark: '',
}; };
} }
const formData = ref<FormData>(getDefaultValues()); const formData = ref<CommonFormData>(getDefaultCommon());
const formInstance = ref<FormInstance>(); const formInstance = ref<FormInstance>();
const deptTreeData = ref<DeptTree[]>([]);
const formRules = ref<AntdFormRules<FormData>>({ const formRules = ref<AntdFormRules<CommonFormData>>({
projectName: [{ required: true, message: $t('ui.formRules.selectRequired') }], deptId: [{ required: true, message: $t('ui.formRules.selectRequired') }],
projectName: [{ required: true, message: $t('ui.formRules.required') }],
userName: [{ required: true, message: $t('ui.formRules.required') }], userName: [{ required: true, message: $t('ui.formRules.required') }],
toolName: [{ required: true, message: $t('ui.formRules.selectRequired') }],
region: [{ required: true, message: $t('ui.formRules.selectRequired') }],
consumeTime: [{ required: true, message: $t('ui.formRules.required') }], consumeTime: [{ required: true, message: $t('ui.formRules.required') }],
}); });
// 国内/国外为固定二值(非字典,Clarify C2')
const regionOptions = [
{ label: '国内', value: '国内' },
{ label: '国外', value: '国外' },
];
// 明细子表格校验:工具名称/国内国外必填,积分/金额非必填(D6/Q4)
const detailRules: VxeGridProps['editRules'] = {
toolName: [{ required: true, message: '请输入AI工具/平台名称' }],
region: [{ required: true, message: '请选择国内/国外' }],
};
const detailColumns: VxeGridProps['columns'] = [
{ type: 'seq', title: '序号', width: 60, align: 'center' },
{
field: 'toolName',
title: 'AI工具/平台名称',
minWidth: 160,
align: 'left',
editRender: {},
slots: {
edit: (props) => {
const { row, $grid } = props;
return (
<Input
allowClear
class="w-full"
onChange={() => $grid?.updateStatus(props)}
placeholder="请输入"
v-model:value={row.toolName}
/>
);
},
},
},
{
field: 'region',
title: '国内/国外',
minWidth: 120,
align: 'center',
editRender: {},
slots: {
edit: (props) => {
const { row, $grid } = props;
return (
<Select
class="w-full"
getPopupContainer={() => document.body}
onChange={() => $grid?.updateStatus(props)}
options={regionOptions}
placeholder="请选择"
v-model:value={row.region}
/>
);
},
},
},
{
field: 'consumePoints',
title: '消耗积分',
minWidth: 120,
align: 'center',
editRender: {},
slots: {
edit: (props) => {
const { row, $grid } = props;
return (
<InputNumber
class="w-full"
min={0}
onChange={() => $grid?.updateStatus(props)}
placeholder="请输入"
precision={2}
v-model:value={row.consumePoints}
/>
);
},
},
},
{
field: 'amountCny',
title: '金额(人民币)',
minWidth: 130,
align: 'center',
editRender: {},
slots: {
edit: (props) => {
const { row, $grid } = props;
return (
<InputNumber
class="w-full"
min={0}
onChange={() => $grid?.updateStatus(props)}
placeholder="请输入"
precision={2}
v-model:value={row.amountCny}
/>
);
},
},
},
{
field: 'action',
title: '操作',
width: 90,
align: 'center',
slots: {
default: ({ $table, row }) => {
function handleDeleteRow() {
window.modal.confirm({
title: '提示',
okType: 'danger',
content: '确认删除该明细行吗?',
onOk: async () => {
await $table.remove(row);
},
});
}
return (
<Button danger onClick={handleDeleteRow} size="small">
删除
</Button>
);
},
},
},
];
const gridOptions = withDefaultVxeGridOptions({
columns: detailColumns,
editConfig: { trigger: 'click', mode: 'cell', showStatus: true },
editRules: detailRules,
rowConfig: { isCurrent: true, useKey: true },
columnConfig: { resizable: true },
proxyConfig: { enabled: false },
pagerConfig: { enabled: false },
toolbarConfig: { enabled: false },
border: true,
showOverflow: false,
cellConfig: { height: 50 },
height: 300,
keepSource: true,
});
const tableRef = useTemplateRef<VxeGridInstance>('tableRef');
async function setupDeptSelect() {
const deptTree = await getDeptTree();
// 选中后显示在输入框的值 即父节点 / 子节点
addFullName(deptTree, 'label', ' / ');
deptTreeData.value = deptTree;
}
// 明细新增一行(对齐 familyMembersTable 先例:末尾追加 + setEditCell 聚焦首列)
async function handleAddRow() {
const result = await tableRef.value?.insertAt(getDefaultDetail(), -1);
const newRow = result?.row;
if (newRow) {
await tableRef.value?.setEditCell(newRow, 'toolName');
}
}
// 关闭前 diff:公共字段 + 明细一并纳入变更检测
function customFormValueGetter() { function customFormValueGetter() {
return JSON.stringify(formData.value); const details = tableRef.value?.getFullData?.() ?? [];
return JSON.stringify({ common: formData.value, details });
} }
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff( const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
...@@ -80,16 +263,36 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({ ...@@ -80,16 +263,36 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
return null; return null;
} }
drawerApi.drawerLoading(true); drawerApi.drawerLoading(true);
const { id } = drawerApi.getData() as { id?: number | string }; const { batchNo, id } = drawerApi.getData() as {
batchNo?: string;
id?: number | string;
};
isUpdate.value = !!id; isUpdate.value = !!id;
// 更新 && 赋值 // 初始化部门树
if (isUpdate.value && id) { await setupDeptSelect();
const record = await consumeInfo(id); await nextTick();
// 编辑态:按 batch_no 回填整批(公共字段 + 明细);新增态:默认一空行
if (isUpdate.value && batchNo) {
const batch = await consumeBatchDetail(batchNo);
formData.value = { formData.value = {
...getDefaultValues(), batchNo: batch?.batchNo ?? batchNo,
...record, deptId: batch?.deptId,
remark: record.remark ?? '', projectName: batch?.projectName,
userName: batch?.userName,
consumeTime: batch?.consumeTime,
remark: batch?.remark ?? '',
}; };
const details = (batch?.details ?? []).map((item) => ({
toolName: item.toolName,
region: item.region,
consumePoints: item.consumePoints,
amountCny: item.amountCny,
}));
await tableRef.value?.loadData(
details.length > 0 ? details : [getDefaultDetail()],
);
} else {
await tableRef.value?.loadData([getDefaultDetail()]);
} }
await markInitialized(); await markInitialized();
drawerApi.drawerLoading(false); drawerApi.drawerLoading(false);
...@@ -99,8 +302,32 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({ ...@@ -99,8 +302,32 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
async function handleConfirm() { async function handleConfirm() {
try { try {
drawerApi.lock(true); drawerApi.lock(true);
// 1. 公共字段校验
await formInstance.value?.validate(); await formInstance.value?.validate();
const data = cloneDeep(formData.value); // 2. 明细单元格校验
const hasDetailError = await tableRef.value?.validate();
if (hasDetailError) {
window.message.warning('请完善消费明细必填项');
return;
}
// 3. 明细至少一行(getFullData 含新增行;getData 只返回 loadData 源数据,会漏掉 insert 的临时行)
const details = (tableRef.value?.getFullData?.() ??
[]) as ConsumeDetailForm[];
if (details.length === 0) {
window.message.warning('请至少添加一行消费明细');
return;
}
// 4. 组装主从批量入参
const common = cloneDeep(formData.value);
const data: ConsumeForm = {
batchNo: common.batchNo,
consumeTime: common.consumeTime,
deptId: common.deptId as number | undefined,
details: cloneDeep(details),
projectName: common.projectName,
remark: common.remark,
userName: common.userName,
};
await (isUpdate.value ? consumeUpdate(data) : consumeAdd(data)); await (isUpdate.value ? consumeUpdate(data) : consumeAdd(data));
resetInitialized(); resetInitialized();
emit('reload'); emit('reload');
...@@ -113,94 +340,77 @@ async function handleConfirm() { ...@@ -113,94 +340,77 @@ async function handleConfirm() {
} }
async function handleClosed() { async function handleClosed() {
formData.value = getDefaultValues(); formData.value = getDefaultCommon();
formInstance.value?.resetFields(); formInstance.value?.resetFields();
deptTreeData.value = [];
resetInitialized(); resetInitialized();
} }
</script> </script>
<template> <template>
<BasicDrawer :title="title" :size="700"> <BasicDrawer :title="title" :size="900">
<Form <Form
ref="formInstance" ref="formInstance"
:model="formData" :model="formData"
:label-col="{ style: { width: '140px' } }" :label-col="{ style: { width: '110px' } }"
> >
<FormItem label="项目名称" name="projectName" :rules="formRules.projectName"> <FormItem label="所属部门" name="deptId" :rules="formRules.deptId">
<Select <FormTreeSelect
allow-clear
class="w-full" class="w-full"
:allow-clear="false" :field-names="{ label: 'label', value: 'id', children: 'children' }"
option-filter-prop="label"
show-search
:get-popup-container="getPopupContainer" :get-popup-container="getPopupContainer"
:options="getDictOptions(DictEnum.AI_PROJECT_NAME)" show-search
v-model:value="formData.projectName" :tree-data="deptTreeData"
tree-default-expand-all
:tree-line="{ showLeafIcon: false }"
tree-node-filter-prop="label"
tree-node-label-prop="fullName"
v-model:value="formData.deptId"
/> />
</FormItem> </FormItem>
<FormItem label="使用人姓名" name="userName" :rules="formRules.userName"> <FormItem label="项目名称" name="projectName" :rules="formRules.projectName">
<Input <FormInput
class="w-full" class="w-full"
allow-clear allow-clear
v-model:value="formData.userName" v-model:value="formData.projectName"
/>
</FormItem>
<FormItem label="AI工具/平台名称" name="toolName" :rules="formRules.toolName">
<Select
class="w-full"
:allow-clear="false"
option-filter-prop="label"
show-search
:get-popup-container="getPopupContainer"
:options="getDictOptions(DictEnum.AI_TOOL_NAME)"
v-model:value="formData.toolName"
/> />
</FormItem> </FormItem>
<FormItem label="国内/国外" name="region" :rules="formRules.region"> <FormItem label="使用人姓名" name="userName" :rules="formRules.userName">
<Select <FormInput
class="w-full" class="w-full"
:allow-clear="false" allow-clear
:get-popup-container="getPopupContainer" v-model:value="formData.userName"
:options="regionOptions"
v-model:value="formData.region"
/> />
</FormItem> </FormItem>
<FormItem label="消费时间" name="consumeTime" :rules="formRules.consumeTime"> <FormItem label="消费时间" name="consumeTime" :rules="formRules.consumeTime">
<DatePicker <DatePicker
class="w-full" class="w-full"
show-time format="YYYY-MM-DD"
format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD"
value-format="YYYY-MM-DD HH:mm:ss"
v-model:value="formData.consumeTime" v-model:value="formData.consumeTime"
/> />
</FormItem> </FormItem>
<FormItem label="消耗积分" name="consumePoints">
<InputNumber
class="w-full"
:style="{ width: '50%' }"
:min="0"
:precision="2"
v-model:value="formData.consumePoints"
/>
</FormItem>
<FormItem label="金额(人民币)" name="amountCny">
<InputNumber
class="w-full"
:style="{ width: '50%' }"
:min="0"
:precision="2"
v-model:value="formData.amountCny"
/>
</FormItem>
<FormItem label="备注" name="remark"> <FormItem label="备注" name="remark">
<TextArea <FormTextArea
allow-clear allow-clear
class="w-full" class="w-full"
:rows="4" :rows="3"
:maxlength="200" :maxlength="200"
show-count show-count
v-model:value="formData.remark" v-model:value="formData.remark"
/> />
</FormItem> </FormItem>
</Form> </Form>
<div class="mt-2 flex items-center justify-between">
<span class="text-base font-medium">消费明细</span>
<Button class="vxe-table--ignore-clear" size="small" type="primary" @click="handleAddRow">
新增明细
</Button>
</div>
<div class="mt-2">
<VxeGrid ref="tableRef" v-bind="gridOptions" />
</div>
</BasicDrawer> </BasicDrawer>
</template> </template>
...@@ -4,12 +4,10 @@ import type { Dayjs } from 'dayjs'; ...@@ -4,12 +4,10 @@ import type { Dayjs } from 'dayjs';
import { ref } from 'vue'; import { ref } from 'vue';
import { FormInput, FormSelect } from '@/components/global/form'; import { FormInput } from '@/components/global/form';
import { SearchButtonGroup } from '@/components/table'; import { SearchButtonGroup } from '@/components/table';
import { tableSeachClass } from '@/components/vxe-table'; import { tableSeachClass } from '@/components/vxe-table';
import { DictEnum } from '@/constants'; import { formatDate } from '@/utils';
import { formatDateTime } from '@/utils';
import { getDictOptions } from '@/utils/dict';
import { Card, DateRangePicker, Form, FormItem } from 'antdv-next'; import { Card, DateRangePicker, Form, FormItem } from 'antdv-next';
const emit = defineEmits<{ const emit = defineEmits<{
...@@ -38,8 +36,9 @@ function buildSearchParams(values: ConsumeSearchFormParams) { ...@@ -38,8 +36,9 @@ function buildSearchParams(values: ConsumeSearchFormParams) {
const params: Record<string, any> = { ...values }; const params: Record<string, any> = { ...values };
if (params.consumeTime) { if (params.consumeTime) {
params.params = { params.params = {
beginTime: formatDateTime(params.consumeTime[0]), // date 列 BETWEEN 两端均为纯日期,天然含结束日当天(无需再补 00:00:00/23:59:59)
endTime: formatDateTime(params.consumeTime[1]), beginTime: formatDate(params.consumeTime[0], 'YYYY-MM-DD'),
endTime: formatDate(params.consumeTime[1], 'YYYY-MM-DD'),
}; };
Reflect.deleteProperty(params, 'consumeTime'); Reflect.deleteProperty(params, 'consumeTime');
} }
...@@ -77,25 +76,13 @@ defineExpose({ ...@@ -77,25 +76,13 @@ defineExpose({
<div :class="tableSeachClass"> <div :class="tableSeachClass">
<template v-if="!searchCollapsed"> <template v-if="!searchCollapsed">
<FormItem label="项目名称" name="projectName"> <FormItem label="项目名称" name="projectName">
<FormSelect <FormInput v-model:value="model.projectName" allow-clear />
allow-clear
option-filter-prop="label"
show-search
v-model:value="model.projectName"
:options="getDictOptions(DictEnum.AI_PROJECT_NAME)"
/>
</FormItem> </FormItem>
<FormItem label="姓名" name="userName"> <FormItem label="姓名" name="userName">
<FormInput v-model:value="model.userName" allow-clear /> <FormInput v-model:value="model.userName" allow-clear />
</FormItem> </FormItem>
<FormItem label="AI工具/平台" name="toolName"> <FormItem label="AI工具/平台" name="toolName">
<FormSelect <FormInput v-model:value="model.toolName" allow-clear />
allow-clear
option-filter-prop="label"
show-search
v-model:value="model.toolName"
:options="getDictOptions(DictEnum.AI_TOOL_NAME)"
/>
</FormItem> </FormItem>
<FormItem label="消费时间" name="consumeTime"> <FormItem label="消费时间" name="consumeTime">
<DateRangePicker v-model:value="model.consumeTime" allow-clear /> <DateRangePicker v-model:value="model.consumeTime" allow-clear />
......
import type { VxeGridProps } from 'vxe-table'; import type { VxeGridProps } from 'vxe-table';
import { DictEnum } from '@/constants';
import { renderDict } from '@/utils/render';
export const columns: VxeGridProps['columns'] = [ export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 }, { type: 'checkbox', width: 60 },
{
title: '所属部门',
field: 'deptName',
},
{ {
title: '项目名称', title: '项目名称',
field: 'projectName', field: 'projectName',
slots: {
default: ({ row }) => {
return renderDict(row.projectName, DictEnum.AI_PROJECT_NAME);
},
},
}, },
{ {
title: '姓名', title: '姓名',
...@@ -21,11 +17,6 @@ export const columns: VxeGridProps['columns'] = [ ...@@ -21,11 +17,6 @@ export const columns: VxeGridProps['columns'] = [
{ {
title: 'AI工具/平台名称', title: 'AI工具/平台名称',
field: 'toolName', field: 'toolName',
slots: {
default: ({ row }) => {
return renderDict(row.toolName, DictEnum.AI_TOOL_NAME);
},
},
}, },
{ {
title: '国内/国外', title: '国内/国外',
......
...@@ -4,7 +4,13 @@ import type { VxeGridInstance, VxeGridListeners } from 'vxe-table'; ...@@ -4,7 +4,13 @@ import type { VxeGridInstance, VxeGridListeners } from 'vxe-table';
import { ref, useTemplateRef } from 'vue'; import { ref, useTemplateRef } from 'vue';
import { consumeExport, consumeList, consumeRemove } from '@/api/ai/consume'; import {
consumeDeptTreeSelect,
consumeExport,
consumeList,
consumeRemove,
consumeSummary,
} from '@/api/ai/consume';
import { Page, useVbenDrawer } from '@/components'; import { Page, useVbenDrawer } from '@/components';
import { import {
resolveQueryFormValues, resolveQueryFormValues,
...@@ -12,17 +18,26 @@ import { ...@@ -12,17 +18,26 @@ import {
withDefaultVxeGridOptions, withDefaultVxeGridOptions,
} from '@/components/vxe-table'; } from '@/components/vxe-table';
import { useBlobExport } from '@/utils/file/export'; import { useBlobExport } from '@/utils/file/export';
import DeptTree from '@/views/system/user/dept-tree.vue';
import { Popconfirm, Space, Spin } from 'antdv-next'; import { Popconfirm, Space, Spin } from 'antdv-next';
import { VxeGrid } from 'vxe-table'; import { VxeGrid } from 'vxe-table';
import { columns } from './data';
import consumeDrawer from './consume-drawer.vue'; import consumeDrawer from './consume-drawer.vue';
import ConsumeSearchForm from './consume-search.vue'; import ConsumeSearchForm from './consume-search.vue';
import { columns } from './data';
// 左边部门用
const selectDeptId = ref<string[]>([]);
const searchFormRef = ref<InstanceType<typeof ConsumeSearchForm>>(); const searchFormRef = ref<InstanceType<typeof ConsumeSearchForm>>();
// 缓存最近一次搜索参数,部门树切换时重新查询用
const currentSearchParams = ref<Record<string, any>>({});
const tableLoading = ref(false); const tableLoading = ref(false);
// 列表底部合计行数据(独立 ref,不写回 gridOptions,规避 TS 自引用循环类型推断,design D6)
const footerData = ref<Record<string, any>[]>([]);
const gridOptions = withDefaultVxeGridOptions<Consume>({ const gridOptions = withDefaultVxeGridOptions<Consume>({
checkboxConfig: { checkboxConfig: {
// 高亮 // 高亮
...@@ -42,11 +57,27 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({ ...@@ -42,11 +57,27 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
const values = await resolveQueryFormValues(searchFormRef, formValues); const values = await resolveQueryFormValues(searchFormRef, formValues);
tableLoading.value = true; tableLoading.value = true;
try { try {
return await consumeList({ // 部门树选择处理
if (selectDeptId.value.length === 1) {
values.belongDeptId = selectDeptId.value[0];
} else {
Reflect.deleteProperty(values, 'belongDeptId');
}
// 列表与合计并行拉取:合计走独立接口、跨页全量,口径与列表完全一致(design D1/D6)
const [listRes, sum] = await Promise.all([
consumeList({
pageNum: page.currentPage, pageNum: page.currentPage,
pageSize: page.pageSize, pageSize: page.pageSize,
...values, ...values,
}); }),
consumeSummary(values),
]);
// 合计写入独立 footerData;键名按 data.ts 列 field 对齐;toFixed(2) 规避 JSON 数值退化(design D6)
footerData.value = [
{ deptName: '合计', amountCny: Number(sum.amountCny).toFixed(2) },
];
return listRes;
} finally { } finally {
tableLoading.value = false; tableLoading.value = false;
} }
...@@ -56,6 +87,8 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({ ...@@ -56,6 +87,8 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
rowConfig: { rowConfig: {
keyField: 'id', keyField: 'id',
}, },
// footer 页面局部开启(不改全局 vxe 配置,design D2)
showFooter: true,
toolbarConfig: { toolbarConfig: {
slots: { slots: {
buttons: 'toolbar-left', buttons: 'toolbar-left',
...@@ -88,7 +121,7 @@ function handleAdd() { ...@@ -88,7 +121,7 @@ function handleAdd() {
} }
async function handleEdit(record: Consume) { async function handleEdit(record: Consume) {
drawerApi.setData({ id: record.id }); drawerApi.setData({ batchNo: record.batchNo, id: record.id });
drawerApi.open(); drawerApi.open();
} }
...@@ -121,19 +154,33 @@ const { exportBlob, exportLoading, buildExportFileName } = ...@@ -121,19 +154,33 @@ const { exportBlob, exportLoading, buildExportFileName } =
async function handleExport() { async function handleExport() {
// 构建表单请求参数 // 构建表单请求参数
const formValues = (await searchFormRef.value?.getValues()) ?? {}; const formValues = (await searchFormRef.value?.getValues()) ?? {};
// 部门树选择处理(与 query 口径一致,导出携带 belongDeptId)
if (selectDeptId.value.length === 1) {
formValues.belongDeptId = selectDeptId.value[0];
} else {
Reflect.deleteProperty(formValues, 'belongDeptId');
}
// 文件名 // 文件名
const fileName = buildExportFileName('消费记录数据'); const fileName = buildExportFileName('消费记录数据');
exportBlob({ data: formValues, fileName }); exportBlob({ data: formValues, fileName });
} }
function handleSearchSubmit(data: Record<string, any>) { function handleSearchSubmit(data: Record<string, any>) {
currentSearchParams.value = data;
reload(data); reload(data);
} }
function handleSearchReset() { function handleSearchReset() {
currentSearchParams.value = {};
selectDeptId.value = [];
reload(); reload();
} }
function handleDeptSelect(keys: string[]) {
selectDeptId.value = keys;
reload(currentSearchParams.value);
}
function getCheckedRows() { function getCheckedRows() {
const table = tableRef.value; const table = tableRef.value;
if (!table) { if (!table) {
...@@ -158,7 +205,15 @@ function syncCheckedRows() { ...@@ -158,7 +205,15 @@ function syncCheckedRows() {
size="large" size="large"
:delay="300" :delay="300"
> >
<div class="flex h-full flex-col gap-2"> <div class="flex h-full gap-[8px]">
<DeptTree
:api="consumeDeptTreeSelect"
v-model:select-dept-id="selectDeptId"
class="w-[260px]"
@reload="() => reload()"
@select="handleDeptSelect"
/>
<div class="flex flex-1 flex-col gap-4 overflow-hidden">
<ConsumeSearchForm <ConsumeSearchForm
ref="searchFormRef" ref="searchFormRef"
@submit="handleSearchSubmit" @submit="handleSearchSubmit"
...@@ -169,6 +224,7 @@ function syncCheckedRows() { ...@@ -169,6 +224,7 @@ function syncCheckedRows() {
ref="tableRef" ref="tableRef"
class="p-2 pt-0" class="p-2 pt-0"
v-bind="gridOptions" v-bind="gridOptions"
:footer-data="footerData"
v-on="gridEvents" v-on="gridEvents"
> >
<template #toolbar-left> <template #toolbar-left>
...@@ -231,6 +287,7 @@ function syncCheckedRows() { ...@@ -231,6 +287,7 @@ function syncCheckedRows() {
</VxeGrid> </VxeGrid>
</div> </div>
</div> </div>
</div>
</Spin> </Spin>
<ConsumeDrawer @reload="() => query()" /> <ConsumeDrawer @reload="() => query()" />
</Page> </Page>
......
...@@ -5,14 +5,13 @@ import { renderDict } from '@/utils/render'; ...@@ -5,14 +5,13 @@ import { renderDict } from '@/utils/render';
export const columns: VxeGridProps['columns'] = [ export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 }, { type: 'checkbox', width: 60 },
{
title: '所属部门',
field: 'deptName',
},
{ {
title: 'AI工具/平台名称', title: 'AI工具/平台名称',
field: 'toolName', field: 'toolName',
slots: {
default: ({ row }) => {
return renderDict(row.toolName, DictEnum.AI_TOOL_NAME);
},
},
}, },
{ {
title: '国内/国外', title: '国内/国外',
......
...@@ -4,7 +4,13 @@ import type { VxeGridInstance, VxeGridListeners } from 'vxe-table'; ...@@ -4,7 +4,13 @@ import type { VxeGridInstance, VxeGridListeners } from 'vxe-table';
import { ref, useTemplateRef } from 'vue'; import { ref, useTemplateRef } from 'vue';
import { rechargeExport, rechargeList, rechargeRemove } from '@/api/ai/recharge'; import {
rechargeDeptTreeSelect,
rechargeExport,
rechargeList,
rechargeRemove,
rechargeSummary,
} from '@/api/ai/recharge';
import { Page, useVbenDrawer } from '@/components'; import { Page, useVbenDrawer } from '@/components';
import { import {
resolveQueryFormValues, resolveQueryFormValues,
...@@ -12,6 +18,7 @@ import { ...@@ -12,6 +18,7 @@ import {
withDefaultVxeGridOptions, withDefaultVxeGridOptions,
} from '@/components/vxe-table'; } from '@/components/vxe-table';
import { useBlobExport } from '@/utils/file/export'; import { useBlobExport } from '@/utils/file/export';
import DeptTree from '@/views/system/user/dept-tree.vue';
import { Popconfirm, Space, Spin } from 'antdv-next'; import { Popconfirm, Space, Spin } from 'antdv-next';
import { VxeGrid } from 'vxe-table'; import { VxeGrid } from 'vxe-table';
...@@ -19,10 +26,18 @@ import { columns } from './data'; ...@@ -19,10 +26,18 @@ import { columns } from './data';
import rechargeDrawer from './recharge-drawer.vue'; import rechargeDrawer from './recharge-drawer.vue';
import RechargeSearchForm from './recharge-search.vue'; import RechargeSearchForm from './recharge-search.vue';
// 左边部门用
const selectDeptId = ref<string[]>([]);
const searchFormRef = ref<InstanceType<typeof RechargeSearchForm>>(); const searchFormRef = ref<InstanceType<typeof RechargeSearchForm>>();
// 缓存最近一次搜索参数,部门树切换时重新查询用
const currentSearchParams = ref<Record<string, any>>({});
const tableLoading = ref(false); const tableLoading = ref(false);
// 列表底部合计行数据(独立 ref,不写回 gridOptions,规避 TS 自引用循环类型推断,design D6)
const footerData = ref<Record<string, any>[]>([]);
const gridOptions = withDefaultVxeGridOptions<Recharge>({ const gridOptions = withDefaultVxeGridOptions<Recharge>({
checkboxConfig: { checkboxConfig: {
// 高亮 // 高亮
...@@ -42,11 +57,31 @@ const gridOptions = withDefaultVxeGridOptions<Recharge>({ ...@@ -42,11 +57,31 @@ const gridOptions = withDefaultVxeGridOptions<Recharge>({
const values = await resolveQueryFormValues(searchFormRef, formValues); const values = await resolveQueryFormValues(searchFormRef, formValues);
tableLoading.value = true; tableLoading.value = true;
try { try {
return await rechargeList({ // 部门树选择处理
if (selectDeptId.value.length === 1) {
values.belongDeptId = selectDeptId.value[0];
} else {
Reflect.deleteProperty(values, 'belongDeptId');
}
// 列表与合计并行拉取:合计走独立接口、跨页全量,口径与列表完全一致(design D1/D6)
const [listRes, sum] = await Promise.all([
rechargeList({
pageNum: page.currentPage, pageNum: page.currentPage,
pageSize: page.pageSize, pageSize: page.pageSize,
...values, ...values,
}); }),
rechargeSummary(values),
]);
// 合计写入独立 footerData;两金额键按 data.ts 列 field 对齐;toFixed(2) 规避 JSON 数值退化(design D6)
footerData.value = [
{
deptName: '合计',
amountUsd: Number(sum.amountUsd).toFixed(2),
amountCny: Number(sum.amountCny).toFixed(2),
},
];
return listRes;
} finally { } finally {
tableLoading.value = false; tableLoading.value = false;
} }
...@@ -56,6 +91,8 @@ const gridOptions = withDefaultVxeGridOptions<Recharge>({ ...@@ -56,6 +91,8 @@ const gridOptions = withDefaultVxeGridOptions<Recharge>({
rowConfig: { rowConfig: {
keyField: 'id', keyField: 'id',
}, },
// footer 页面局部开启(不改全局 vxe 配置,design D2)
showFooter: true,
toolbarConfig: { toolbarConfig: {
slots: { slots: {
buttons: 'toolbar-left', buttons: 'toolbar-left',
...@@ -121,19 +158,33 @@ const { exportBlob, exportLoading, buildExportFileName } = ...@@ -121,19 +158,33 @@ const { exportBlob, exportLoading, buildExportFileName } =
async function handleExport() { async function handleExport() {
// 构建表单请求参数 // 构建表单请求参数
const formValues = (await searchFormRef.value?.getValues()) ?? {}; const formValues = (await searchFormRef.value?.getValues()) ?? {};
// 部门树选择处理(与 query 口径一致,导出携带 belongDeptId)
if (selectDeptId.value.length === 1) {
formValues.belongDeptId = selectDeptId.value[0];
} else {
Reflect.deleteProperty(formValues, 'belongDeptId');
}
// 文件名 // 文件名
const fileName = buildExportFileName('充值记录数据'); const fileName = buildExportFileName('充值记录数据');
exportBlob({ data: formValues, fileName }); exportBlob({ data: formValues, fileName });
} }
function handleSearchSubmit(data: Record<string, any>) { function handleSearchSubmit(data: Record<string, any>) {
currentSearchParams.value = data;
reload(data); reload(data);
} }
function handleSearchReset() { function handleSearchReset() {
currentSearchParams.value = {};
selectDeptId.value = [];
reload(); reload();
} }
function handleDeptSelect(keys: string[]) {
selectDeptId.value = keys;
reload(currentSearchParams.value);
}
function getCheckedRows() { function getCheckedRows() {
const table = tableRef.value; const table = tableRef.value;
if (!table) { if (!table) {
...@@ -158,7 +209,15 @@ function syncCheckedRows() { ...@@ -158,7 +209,15 @@ function syncCheckedRows() {
size="large" size="large"
:delay="300" :delay="300"
> >
<div class="flex h-full flex-col gap-2"> <div class="flex h-full gap-[8px]">
<DeptTree
:api="rechargeDeptTreeSelect"
v-model:select-dept-id="selectDeptId"
class="w-[260px]"
@reload="() => reload()"
@select="handleDeptSelect"
/>
<div class="flex flex-1 flex-col gap-4 overflow-hidden">
<RechargeSearchForm <RechargeSearchForm
ref="searchFormRef" ref="searchFormRef"
@submit="handleSearchSubmit" @submit="handleSearchSubmit"
...@@ -169,6 +228,7 @@ function syncCheckedRows() { ...@@ -169,6 +228,7 @@ function syncCheckedRows() {
ref="tableRef" ref="tableRef"
class="p-2 pt-0" class="p-2 pt-0"
v-bind="gridOptions" v-bind="gridOptions"
:footer-data="footerData"
v-on="gridEvents" v-on="gridEvents"
> >
<template #toolbar-left> <template #toolbar-left>
...@@ -231,6 +291,7 @@ function syncCheckedRows() { ...@@ -231,6 +291,7 @@ function syncCheckedRows() {
</VxeGrid> </VxeGrid>
</div> </div>
</div> </div>
</div>
</Spin> </Spin>
<RechargeDrawer @reload="() => query()" /> <RechargeDrawer @reload="() => query()" />
</Page> </Page>
......
<script setup lang="ts"> <script setup lang="ts">
import type { Recharge } from '@/api/ai/recharge/model'; import type { Recharge } from '@/api/ai/recharge/model';
import type { DeptTree } from '@/api/system/user/model';
import type { AntdFormRules } from '@/types/form'; import type { AntdFormRules } from '@/types/form';
import type { FormInstance } from 'antdv-next'; import type { FormInstance } from 'antdv-next';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { rechargeAdd, rechargeInfo, rechargeUpdate } from '@/api/ai/recharge'; import { rechargeAdd, rechargeInfo, rechargeUpdate } from '@/api/ai/recharge';
import { getDeptTree } from '@/api/system/user';
import { useVbenDrawer } from '@/components'; import { useVbenDrawer } from '@/components';
import { import {
FormInput as Input,
FormInputNumber as InputNumber, FormInputNumber as InputNumber,
FormSelect as Select, FormSelect as Select,
FormTextArea as TextArea, FormTextArea as TextArea,
FormTreeSelect as TreeSelect,
} from '@/components/global/form'; } from '@/components/global/form';
import { DictEnum } from '@/constants'; import { DictEnum } from '@/constants';
import { $t } from '@/locales'; import { $t } from '@/locales';
import { cloneDeep, getPopupContainer } from '@/utils'; import { addFullName, cloneDeep, getPopupContainer } from '@/utils';
import { getDictOptions } from '@/utils/dict'; import { getDictOptions } from '@/utils/dict';
import { useBeforeCloseDiff } from '@/utils/popup'; import { useBeforeCloseDiff } from '@/utils/popup';
import { DatePicker, Form, FormItem } from 'antdv-next'; import { DatePicker, Form, FormItem } from 'antdv-next';
...@@ -26,7 +30,9 @@ const title = computed(() => { ...@@ -26,7 +30,9 @@ const title = computed(() => {
return isUpdate.value ? $t('pages.common.edit') : $t('pages.common.add'); return isUpdate.value ? $t('pages.common.edit') : $t('pages.common.add');
}); });
type FormData = Partial<Recharge>; type FormData = Partial<Recharge> & {
deptId?: number | string;
};
// 国内/国外为固定二值(非字典,Clarify C2) // 国内/国外为固定二值(非字典,Clarify C2)
const regionOptions = [ const regionOptions = [
...@@ -37,6 +43,7 @@ const regionOptions = [ ...@@ -37,6 +43,7 @@ const regionOptions = [
function getDefaultValues(): FormData { function getDefaultValues(): FormData {
return { return {
id: undefined, id: undefined,
deptId: undefined,
toolName: undefined, toolName: undefined,
region: undefined, region: undefined,
rechargeCategory: undefined, rechargeCategory: undefined,
...@@ -53,9 +60,11 @@ function getDefaultValues(): FormData { ...@@ -53,9 +60,11 @@ function getDefaultValues(): FormData {
const formData = ref<FormData>(getDefaultValues()); const formData = ref<FormData>(getDefaultValues());
const formInstance = ref<FormInstance>(); const formInstance = ref<FormInstance>();
const deptTreeData = ref<DeptTree[]>([]);
const formRules = ref<AntdFormRules<FormData>>({ const formRules = ref<AntdFormRules<FormData>>({
toolName: [{ required: true, message: $t('ui.formRules.selectRequired') }], deptId: [{ required: true, message: $t('ui.formRules.selectRequired') }],
toolName: [{ required: true, message: $t('ui.formRules.required') }],
region: [{ required: true, message: $t('ui.formRules.selectRequired') }], region: [{ required: true, message: $t('ui.formRules.selectRequired') }],
rechargeCategory: [ rechargeCategory: [
{ required: true, message: $t('ui.formRules.selectRequired') }, { required: true, message: $t('ui.formRules.selectRequired') },
...@@ -64,6 +73,13 @@ const formRules = ref<AntdFormRules<FormData>>({ ...@@ -64,6 +73,13 @@ const formRules = ref<AntdFormRules<FormData>>({
amountCny: [{ required: true, message: $t('ui.formRules.required') }], amountCny: [{ required: true, message: $t('ui.formRules.required') }],
}); });
async function setupDeptSelect() {
const deptTree = await getDeptTree();
// 选中后显示在输入框的值 即父节点 / 子节点
addFullName(deptTree, 'label', ' / ');
deptTreeData.value = deptTree;
}
function customFormValueGetter() { function customFormValueGetter() {
return JSON.stringify(formData.value); return JSON.stringify(formData.value);
} }
...@@ -86,6 +102,8 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({ ...@@ -86,6 +102,8 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
drawerApi.drawerLoading(true); drawerApi.drawerLoading(true);
const { id } = drawerApi.getData() as { id?: number | string }; const { id } = drawerApi.getData() as { id?: number | string };
isUpdate.value = !!id; isUpdate.value = !!id;
// 初始化部门树
await setupDeptSelect();
// 更新 && 赋值 // 更新 && 赋值
if (isUpdate.value && id) { if (isUpdate.value && id) {
const record = await rechargeInfo(id); const record = await rechargeInfo(id);
...@@ -119,6 +137,7 @@ async function handleConfirm() { ...@@ -119,6 +137,7 @@ async function handleConfirm() {
async function handleClosed() { async function handleClosed() {
formData.value = getDefaultValues(); formData.value = getDefaultValues();
formInstance.value?.resetFields(); formInstance.value?.resetFields();
deptTreeData.value = [];
resetInitialized(); resetInitialized();
} }
</script> </script>
...@@ -130,14 +149,25 @@ async function handleClosed() { ...@@ -130,14 +149,25 @@ async function handleClosed() {
:model="formData" :model="formData"
:label-col="{ style: { width: '140px' } }" :label-col="{ style: { width: '140px' } }"
> >
<FormItem label="AI工具/平台名称" name="toolName" :rules="formRules.toolName"> <FormItem label="所属部门" name="deptId" :rules="formRules.deptId">
<Select <TreeSelect
allow-clear
class="w-full" class="w-full"
:allow-clear="false" :field-names="{ label: 'label', value: 'id', children: 'children' }"
option-filter-prop="label"
show-search
:get-popup-container="getPopupContainer" :get-popup-container="getPopupContainer"
:options="getDictOptions(DictEnum.AI_TOOL_NAME)" show-search
:tree-data="deptTreeData"
tree-default-expand-all
:tree-line="{ showLeafIcon: false }"
tree-node-filter-prop="label"
tree-node-label-prop="fullName"
v-model:value="formData.deptId"
/>
</FormItem>
<FormItem label="AI工具/平台名称" name="toolName" :rules="formRules.toolName">
<Input
class="w-full"
allow-clear
v-model:value="formData.toolName" v-model:value="formData.toolName"
/> />
</FormItem> </FormItem>
...@@ -170,9 +200,8 @@ async function handleClosed() { ...@@ -170,9 +200,8 @@ async function handleClosed() {
> >
<DatePicker <DatePicker
class="w-full" class="w-full"
show-time format="YYYY-MM-DD"
format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD"
value-format="YYYY-MM-DD HH:mm:ss"
v-model:value="formData.rechargeTime" v-model:value="formData.rechargeTime"
/> />
</FormItem> </FormItem>
......
...@@ -4,11 +4,11 @@ import type { Dayjs } from 'dayjs'; ...@@ -4,11 +4,11 @@ import type { Dayjs } from 'dayjs';
import { ref } from 'vue'; import { ref } from 'vue';
import { FormSelect } from '@/components/global/form'; import { FormInput, FormSelect } from '@/components/global/form';
import { SearchButtonGroup } from '@/components/table'; import { SearchButtonGroup } from '@/components/table';
import { tableSeachClass } from '@/components/vxe-table'; import { tableSeachClass } from '@/components/vxe-table';
import { DictEnum } from '@/constants'; import { DictEnum } from '@/constants';
import { formatDateTime } from '@/utils'; import { formatDate } from '@/utils';
import { getDictOptions } from '@/utils/dict'; import { getDictOptions } from '@/utils/dict';
import { Card, DateRangePicker, Form, FormItem } from 'antdv-next'; import { Card, DateRangePicker, Form, FormItem } from 'antdv-next';
...@@ -44,8 +44,9 @@ function buildSearchParams(values: RechargeSearchFormParams) { ...@@ -44,8 +44,9 @@ function buildSearchParams(values: RechargeSearchFormParams) {
const params: Record<string, any> = { ...values }; const params: Record<string, any> = { ...values };
if (params.rechargeTime) { if (params.rechargeTime) {
params.params = { params.params = {
beginTime: formatDateTime(params.rechargeTime[0]), // date 列 BETWEEN 两端均为纯日期,天然含结束日当天(无需再补 00:00:00/23:59:59)
endTime: formatDateTime(params.rechargeTime[1]), beginTime: formatDate(params.rechargeTime[0], 'YYYY-MM-DD'),
endTime: formatDate(params.rechargeTime[1], 'YYYY-MM-DD'),
}; };
Reflect.deleteProperty(params, 'rechargeTime'); Reflect.deleteProperty(params, 'rechargeTime');
} }
...@@ -86,13 +87,7 @@ defineExpose({ ...@@ -86,13 +87,7 @@ defineExpose({
<DateRangePicker v-model:value="model.rechargeTime" allow-clear /> <DateRangePicker v-model:value="model.rechargeTime" allow-clear />
</FormItem> </FormItem>
<FormItem label="AI工具/平台" name="toolName"> <FormItem label="AI工具/平台" name="toolName">
<FormSelect <FormInput v-model:value="model.toolName" allow-clear />
allow-clear
option-filter-prop="label"
show-search
v-model:value="model.toolName"
:options="getDictOptions(DictEnum.AI_TOOL_NAME)"
/>
</FormItem> </FormItem>
<FormItem label="国内/国外" name="region"> <FormItem label="国内/国外" name="region">
<FormSelect <FormSelect
......
...@@ -410,7 +410,7 @@ async function handleCopyConfig() { ...@@ -410,7 +410,7 @@ async function handleCopyConfig() {
/> />
</FormItem> </FormItem>
<FormItem <FormItem
v-if="showMenu" v-if="showNotButton"
label="页面名称" label="页面名称"
name="pageName" name="pageName"
:rules="formRules.pageName" :rules="formRules.pageName"
......
...@@ -25,7 +25,7 @@ const tabOptions = [ ...@@ -25,7 +25,7 @@ const tabOptions = [
]; ];
interface RolePermissionData { interface RolePermissionData {
roleId?: number | string; id?: number | string;
roleKey?: string; roleKey?: string;
roleName?: string; roleName?: string;
// 菜单权限 // 菜单权限
...@@ -91,7 +91,7 @@ const [BasicModal, modalApi] = useVbenModal({ ...@@ -91,7 +91,7 @@ const [BasicModal, modalApi] = useVbenModal({
]); ]);
formData.value = { formData.value = {
roleId: record.id, id: record.id,
roleName: record.roleName, roleName: record.roleName,
roleKey: record.roleKey, roleKey: record.roleKey,
menuCheckStrictly: record.menuCheckStrictly, menuCheckStrictly: record.menuCheckStrictly,
......
...@@ -19,7 +19,7 @@ ...@@ -19,7 +19,7 @@
<mapstruct-plus.version>1.5.1</mapstruct-plus.version> <mapstruct-plus.version>1.5.1</mapstruct-plus.version>
<lombok-mapstruct-binding.version>0.2.0</lombok-mapstruct-binding.version> <lombok-mapstruct-binding.version>0.2.0</lombok-mapstruct-binding.version>
<therapi-javadoc.version>0.15.0</therapi-javadoc.version> <therapi-javadoc.version>0.15.0</therapi-javadoc.version>
<binfast.version>2.0.2</binfast.version> <binfast.version>2.0.3</binfast.version>
<lombok.version>1.18.46</lombok.version> <lombok.version>1.18.46</lombok.version>
</properties> </properties>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment