Commit 6ceb318f authored by 刘斌's avatar 刘斌

fix: 重构消费记录为父子表结构

parent 5e5f9568
...@@ -11,11 +11,12 @@ import jakarta.validation.constraints.NotEmpty; ...@@ -11,11 +11,12 @@ import jakarta.validation.constraints.NotEmpty;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated; 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.AiConsumeProjectListParam;
import com.anplus.hr.ai.domain.params.AiConsumeRecordParam; import com.anplus.hr.ai.domain.params.AiConsumeProjectParam;
import com.anplus.hr.ai.domain.vo.AiConsumeExportVo;
import com.anplus.hr.ai.domain.vo.AiConsumeProjectVo;
import com.anplus.hr.ai.domain.vo.AiConsumeRecordSummaryVo; import com.anplus.hr.ai.domain.vo.AiConsumeRecordSummaryVo;
import com.anplus.hr.ai.domain.vo.AiConsumeRecordVo; import com.anplus.hr.ai.service.AiConsumeProjectServ;
import com.anplus.hr.ai.service.AiConsumeRecordServ;
import top.binfast.app.biz.sysapi.bean.params.sysDept.SysDeptParam; import top.binfast.app.biz.sysapi.bean.params.sysDept.SysDeptParam;
import top.binfast.app.biz.sysbiz.service.SysDeptServ; import top.binfast.app.biz.sysbiz.service.SysDeptServ;
import top.binfast.common.core.constant.BusinessType; import top.binfast.common.core.constant.BusinessType;
...@@ -26,122 +27,116 @@ import top.binfast.common.log.annotation.PinSysLog; ...@@ -26,122 +27,116 @@ import top.binfast.common.log.annotation.PinSysLog;
import java.util.List; import java.util.List;
/** /**
* AI工具消费记录 * AI工具消费记录(父子表树形聚合)
*
* <p>父表 {@code ai_consume_project} + 子表 {@code ai_consume_record} 模型下的消费记录接口层:树形分页、
* 底部合计、项目详情、主从新增/编辑、级联删除、扁平导出、部门树。注解/端点约定对齐
* {@code docs/reference-code/controller/SysPostCtrl.java} 与已删旧 {@code AiConsumeRecordCtrl};
* 原 {@code GET /ai/consume/batch/{batchNo}} 随 batch_no 模型废弃移除(由 {@code GET /ai/consume/{id}} 取代)。</p>
* *
* @author LiuBin * @author LiuBin
* @date 2026-09-04 * @date 2026-09-18
*/ */
@Validated @Validated
@RequiredArgsConstructor @RequiredArgsConstructor
@RestController @RestController
@RequestMapping("/ai/consume") @RequestMapping("/ai/consume")
public class AiConsumeRecordCtrl { public class AiConsumeProjectCtrl {
private final AiConsumeRecordServ aiConsumeRecordServ; private final AiConsumeProjectServ aiConsumeProjectServ;
private final SysDeptServ sysDeptServ; private final SysDeptServ sysDeptServ;
/** /**
* 分页查询AI工具消费记录列表。 * 树形分页查询消费项目列表(按父行计数,每父行内嵌明细 children)。
* *
* @param param 查询条件 * @param param 查询条件
* @return AI工具消费记录分页结果 * @return 消费项目树形分页结果
*/ */
@SaCheckPermission("ai:consume:list") @SaCheckPermission("ai:consume:list")
@GetMapping("/list") @GetMapping("/list")
public PageResponse<AiConsumeRecordVo> pageList(AiConsumeRecordListParam param) { public PageResponse<AiConsumeProjectVo> pageList(AiConsumeProjectListParam param) {
return aiConsumeRecordServ.queryPageList(param); return aiConsumeProjectServ.queryPageList(param);
} }
/** /**
* 查询当前筛选条件下的金额合计(跨所有分页的全量结果,口径与列表完全一致)。 * 查询当前筛选条件下的项目总金额合计(跨所有分页的全量结果,口径与列表完全一致)。
* *
* @param param 查询条件(与分页查询共用同一条件对象) * @param param 查询条件(与分页查询共用同一条件对象)
* @return 合计载体(amountCny 保留两位小数;无匹配数据时为 0.00) * @return 合计载体(amountCny 保留两位小数;无匹配数据时为 0.00)
*/ */
@SaCheckPermission("ai:consume:list") @SaCheckPermission("ai:consume:list")
@GetMapping("/summary") @GetMapping("/summary")
public SingleResponse<AiConsumeRecordSummaryVo> summary(AiConsumeRecordListParam param) { public SingleResponse<AiConsumeRecordSummaryVo> summary(AiConsumeProjectListParam param) {
return SingleResponse.of(aiConsumeRecordServ.querySummary(param)); return SingleResponse.of(aiConsumeProjectServ.querySummary(param));
} }
/** /**
* 导出AI工具消费记录列表。 * 导出消费记录(扁平明细行 + 父级汇总列重复 + 末尾合计行)。
* *
* @param param 查询条件 * @param param 查询条件
* @return 扁平导出行列表
*/ */
@ExcelExport @ExcelExport
@SaCheckPermission("ai:consume:export") @SaCheckPermission("ai:consume:export")
@PinSysLog(value = "AI工具消费记录", businessType = BusinessType.EXPORT) @PinSysLog(value = "AI工具消费记录", businessType = BusinessType.EXPORT)
@PostMapping("/export") @PostMapping("/export")
public List<AiConsumeRecordVo> export(AiConsumeRecordListParam param) { public List<AiConsumeExportVo> export(AiConsumeProjectListParam param) {
return aiConsumeRecordServ.queryList(param); return aiConsumeProjectServ.queryList(param);
} }
/** /**
* 获取AI工具消费记录详细信息 * 获取消费项目详细信息(父行公共字段 + 全部明细子行,line_sort 升序)。
* *
* @param id 主键 * @param id 项目id(project_id)
* @return 整项目详情
*/ */
@SaCheckPermission("ai:consume:query") @SaCheckPermission("ai:consume:query")
@GetMapping("/{id}") @GetMapping("/{id}")
public SingleResponse<AiConsumeRecordVo> getDetail(@PathVariable @Min(1) public SingleResponse<AiConsumeProjectVo> getDetail(@PathVariable @Min(1) Long id) {
Long id) { return SingleResponse.of(aiConsumeProjectServ.queryById(id));
return SingleResponse.of(aiConsumeRecordServ.queryById(id));
} }
/** /**
* 按批次号获取整批消费记录详情(主从批量编辑回填用)。 * 新增消费项目(主从批量:项目级公共字段 + 明细数组,单一事务建父插子重算)。
* *
* @param batchNo 批次号 * @param param 消费项目参数
* @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工具消费记录。
*
* @param param AI工具消费记录参数
* @return 操作结果 * @return 操作结果
*/ */
@SaCheckPermission("ai:consume:add") @SaCheckPermission("ai:consume:add")
@PinSysLog(value = "AI工具消费记录", businessType = BusinessType.INSERT) @PinSysLog(value = "AI工具消费记录", businessType = BusinessType.INSERT)
@PostMapping() @PostMapping()
public Response add(@Validated @RequestBody AiConsumeRecordParam param) { public Response add(@Validated @RequestBody AiConsumeProjectParam param) {
return ResponseUtils.ofResult(aiConsumeRecordServ.insertByParam(param)); return ResponseUtils.ofResult(aiConsumeProjectServ.insertByParam(param));
} }
/** /**
* 修改AI工具消费记录。 * 修改消费项目(整批编辑:更新父行 + 明细全删重插 + 重算汇总,project_id 不变)。
* *
* @param param AI工具消费记录参数 * @param param 消费项目参数
* @return 操作结果 * @return 操作结果
*/ */
@SaCheckPermission("ai:consume:edit") @SaCheckPermission("ai:consume:edit")
@PinSysLog(value = "AI工具消费记录", businessType = BusinessType.UPDATE) @PinSysLog(value = "AI工具消费记录", businessType = BusinessType.UPDATE)
@PutMapping() @PutMapping()
public Response edit(@Validated @RequestBody AiConsumeRecordParam param) { public Response edit(@Validated @RequestBody AiConsumeProjectParam param) {
return ResponseUtils.ofResult(aiConsumeRecordServ.updateByParam(param)); return ResponseUtils.ofResult(aiConsumeProjectServ.updateByParam(param));
} }
/** /**
* 删除AI工具消费记录 * 删除消费项目(级联逻辑删除父行 + 其全部明细子行)。
* *
* @param ids 主键串 * @param ids 项目id(project_id)串
* @return 操作结果
*/ */
@SaCheckPermission("ai:consume:remove") @SaCheckPermission("ai:consume:remove")
@PinSysLog(value = "AI工具消费记录", businessType = BusinessType.DELETE) @PinSysLog(value = "AI工具消费记录", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}") @DeleteMapping("/{ids}")
public Response remove(@NotEmpty(message = "主键不能为空") public Response remove(@NotEmpty(message = "主键不能为空") @PathVariable Long[] ids) {
@PathVariable Long[] ids) { return ResponseUtils.ofResult(aiConsumeProjectServ.delByIds(List.of(ids)));
return ResponseUtils.ofResult(aiConsumeRecordServ.delByIds(List.of(ids)));
} }
/** /**
* 获取消费记录筛选用的部门树。 * 获取消费记录筛选用的部门树(当前租户,含子部门子树)。
* *
* @param dept 部门查询条件 * @param dept 部门查询条件
* @return 部门树列表 * @return 部门树列表
...@@ -151,4 +146,5 @@ public class AiConsumeRecordCtrl { ...@@ -151,4 +146,5 @@ public class AiConsumeRecordCtrl {
public MultiResponse<Tree<Long>> deptTree(SysDeptParam dept) { public MultiResponse<Tree<Long>> deptTree(SysDeptParam dept) {
return MultiResponse.of(sysDeptServ.selectDeptTreeList(dept)); return MultiResponse.of(sysDeptServ.selectDeptTreeList(dept));
} }
} }
package com.anplus.hr.ai.domain.model;
import top.binfast.common.mybatis.bean.model.TenantModel;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import lombok.EqualsAndHashCode;
import java.io.Serial;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* AI工具消费项目对象(父表) ai_consume_project
*
* <p>承载一次录入提交的项目级公共字段(部门/项目名/姓名/消费时间/备注)与
* 派生汇总字段(成片时长/项目总金额/每分钟金额);明细工具消费存于子表
* {@link AiConsumeRecord},以 project_id 外键构成一对多。</p>
*
* @author LiuBin
* @date 2026-09-17
*/
@Getter
@Setter
@EqualsAndHashCode(callSuper = true)
@TableName("ai_consume_project")
public class AiConsumeProject extends TenantModel {
@Serial
private static final long serialVersionUID = 1L;
/**
* 所属部门ID(关联sys_dept.id)
*/
private Long deptId;
/**
* 项目名称(用户直接输入的自由文本)
*/
private String projectName;
/**
* 使用人姓名(支持模糊查询)
*/
private String userName;
/**
* 消费发生日期(父级,纯日期)
*/
private LocalDate consumeTime;
/**
* 成片时长(秒),非负整数,无视频产出默认0(项目级单一录入)
*/
private Integer durationSeconds;
/**
* 项目总金额(= Σ 子表明细金额,人民币;聚合重算逻辑属 C2)
*/
private BigDecimal amountCny;
/**
* 每分钟金额(= 项目总金额×60÷成片时长,2位小数HALF_UP;成片时长为0时为NULL;派生计算属 C2)
*/
private BigDecimal amountPerMinute;
/**
* 备注(父级)
*/
private String remark;
}
...@@ -8,10 +8,13 @@ import lombok.EqualsAndHashCode; ...@@ -8,10 +8,13 @@ import lombok.EqualsAndHashCode;
import java.io.Serial; import java.io.Serial;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
/** /**
* AI工具消费记录对象 ai_consume_record * AI工具消费记录明细对象(子表) ai_consume_record
*
* <p>父子表模型下的明细级记录:仅保留工具明细字段,项目级公共字段
* (部门/项目名/姓名/消费时间/备注)上提至父表 {@link AiConsumeProject},
* 以 projectId 外键关联。</p>
* *
* @author LiuBin * @author LiuBin
* @date 2026-09-04 * @date 2026-09-04
...@@ -26,19 +29,9 @@ public class AiConsumeRecord extends TenantModel { ...@@ -26,19 +29,9 @@ public class AiConsumeRecord extends TenantModel {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** /**
* 所属部门ID(关联sys_dept.id) * 所属项目ID(关联 ai_consume_project.id)
*/
private Long deptId;
/**
* 项目名称(用户直接输入的自由文本)
*/ */
private String projectName; private Long projectId;
/**
* 使用人姓名(支持模糊查询)
*/
private String userName;
/** /**
* AI工具/平台名称(用户直接输入的自由文本) * AI工具/平台名称(用户直接输入的自由文本)
...@@ -56,27 +49,12 @@ public class AiConsumeRecord extends TenantModel { ...@@ -56,27 +49,12 @@ public class AiConsumeRecord extends TenantModel {
private BigDecimal consumePoints; private BigDecimal consumePoints;
/** /**
* 金额(人民币) * 单条明细金额(人民币;区别于父表 ai_consume_project.amount_cny 项目总金额,R6)
*/ */
private BigDecimal amountCny; private BigDecimal amountCny;
/** /**
* 消费发生时间 * 行序号(项目内明细从 1 连续排序)
*/
private LocalDate consumeTime;
/**
* 备注
*/
private String remark;
/**
* 批次号(主从批量录入:同批多行共享同一 batch_no,全局唯一)
*/
private String batchNo;
/**
* 行序号(批次内从 1 连续自增)
*/ */
private Integer lineSort; private Integer lineSort;
......
...@@ -8,35 +8,43 @@ import java.util.HashMap; ...@@ -8,35 +8,43 @@ import java.util.HashMap;
import java.util.Map; import java.util.Map;
/** /**
* AI工具消费记录分页对象 ai_consume_record * AI工具消费项目分页对象 ai_consume_project
*
* <p>树形分页查询({@code GET /ai/consume/list})与合计查询({@code GET /ai/consume/summary})
* 共用本条件对象,确保两者筛选口径完全一致;分页作用于父表 ai_consume_project(按项目父行计数,
* 非按明细行计数)。搜索字段分父级(项目名/姓名/部门子树/消费时间范围)与明细(工具名,以 EXISTS
* 子查询定位命中父行)两类。</p>
* *
* @author LiuBin * @author LiuBin
* @date 2026-09-04 * @date 2026-09-18
*/ */
@Getter @Getter
@Setter @Setter
public class AiConsumeRecordListParam extends PageQueryParam { public class AiConsumeProjectListParam extends PageQueryParam {
/** /**
* 项目名称(用户直接输入的自由文本) * 项目名称(父级,eq 精确匹配)
*/ */
private String projectName; private String projectName;
/** /**
* 使用人姓名(支持模糊查询) * 使用人姓名(父级,like 模糊查询)
*/ */
private String userName; private String userName;
/** /**
* AI工具/平台名称(用户直接输入的自由文本) * AI工具/平台名称(明细字段,以 EXISTS 子查询 like 定位含匹配明细的父行)
*/ */
private String toolName; private String toolName;
/** /**
* 归属部门id(部门树,含子部门) * 归属部门id(部门树,含子部门子树)
*/ */
private Long belongDeptId; private Long belongDeptId;
/**
* 其它查询参数(承载 beginTime/endTime 消费时间范围,纯日期边界)
*/
private Map<String, Object> params = new HashMap<>(); private Map<String, Object> params = new HashMap<>();
} }
...@@ -4,6 +4,7 @@ import jakarta.validation.Valid; ...@@ -4,6 +4,7 @@ import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.Data; import lombok.Data;
...@@ -11,22 +12,23 @@ import java.time.LocalDate; ...@@ -11,22 +12,23 @@ import java.time.LocalDate;
import java.util.List; import java.util.List;
/** /**
* AI工具消费记录业务对象(主从批量录入)ai_consume_record * AI工具消费项目业务对象(父子表项目级录入)ai_consume_project
* *
* <p>上部公共字段对整批生效,下部 {@code details} 为明细行列表;新增时 {@code batchNo} * <p>上部项目级公共字段对整个项目生效,下部 {@code details} 为明细行列表;新增时
* 为空由服务端生成,编辑时携带 {@code batchNo} 定位原批(整批全删重插)。校验注解均不带 * {@code projectId} 为空、由数据库自增生成,编辑时携带 {@code projectId} 定位原项目
* groups,配合 Controller 的 {@code @Validated} 默认组生效(对齐既有 Param 风格)。</p> * (父行公共字段更新 + 明细整批全删重插)。校验注解均不带 groups,配合 Controller 的
* {@code @Validated} 默认组生效(对齐既有 Param 风格)。</p>
* *
* @author LiuBin * @author LiuBin
* @date 2026-09-04 * @date 2026-09-18
*/ */
@Data @Data
public class AiConsumeRecordParam { public class AiConsumeProjectParam {
/** /**
* 批次号(编辑时定位原批;新增时为空、由服务端生成) * 项目ID(父行主键;编辑时定位原项目,新增时为空由数据库自增生成)
*/ */
private String batchNo; private Long projectId;
/** /**
* 所属部门ID(关联sys_dept.id) * 所属部门ID(关联sys_dept.id)
...@@ -47,11 +49,17 @@ public class AiConsumeRecordParam { ...@@ -47,11 +49,17 @@ public class AiConsumeRecordParam {
private String userName; private String userName;
/** /**
* 消费发生时间 * 消费发生日期(父级,纯日期)
*/ */
@NotNull(message = "消费发生时间不能为空") @NotNull(message = "消费发生时间不能为空")
private LocalDate consumeTime; private LocalDate consumeTime;
/**
* 成片时长(秒):项目级单一录入,非负整数,无视频产出默认0
*/
@PositiveOrZero(message = "成片时长不能为负数")
private Integer durationSeconds;
/** /**
* 备注 * 备注
*/ */
......
package com.anplus.hr.ai.domain.vo;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import com.anplus.hr.ai.domain.model.AiConsumeRecord;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* AI工具消费记录明细子行视图对象 ai_consume_record
*
* <p>树形分页({@code GET /ai/consume/list})与项目详情({@code GET /ai/consume/{id}})响应中,
* 内嵌于父行 {@code AiConsumeProjectVo.children} 的明细子行;按 line_sort 升序。</p>
*
* @author LiuBin
* @date 2026-09-18
*/
@Data
@AutoMapper(target = AiConsumeRecord.class)
public class AiConsumeDetailVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 明细主键ID
*/
private Long id;
/**
* AI工具/平台名称(用户直接输入的自由文本)
*/
private String toolName;
/**
* 国内/国外(中文原文,固定二值)
*/
private String region;
/**
* 消耗积分
*/
private BigDecimal consumePoints;
/**
* 单条明细金额(人民币;区别于父表项目总金额)
*/
private BigDecimal amountCny;
/**
* 行序号(项目内明细从 1 连续排序)
*/
private Integer lineSort;
}
package com.anplus.hr.ai.domain.vo;
import lombok.Data;
import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated;
import org.apache.fesod.sheet.annotation.ExcelProperty;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* AI工具消费记录扁平导出行视图对象(导出专用,D8)
*
* <p>导出({@code POST /ai/consume/export})将父子表 JOIN 展开为扁平明细行:每行 = 一条明细子行
* + 该项目父级汇总列(所属部门 / 项目名称 / 姓名 / 消费时间 / 项目总金额 / 成片时长 / 每分钟金额 /
* 备注)重复展示,其后接明细列(AI工具名称 / 国内外 / 消耗积分 / 金额)。列序与标签对齐 spec
* 「导出末尾合计行」,并保持与既有导出列口径稳定;部门名由 ServiceImpl 批量回填(导出链不触发
* JSON 翻译)。末尾合计行:所属部门 = 「合计」、项目总金额 = 全量命中明细金额合计,其余列(含「金额(人民币)」
* 明细列)留空。成片时长列以分秒文本输出(spec「导出成片时长格式化」)。</p>
*
* <p>非导出辅助字段 {@code deptId} 无 {@code @ExcelProperty},被 {@code @ExcelIgnoreUnannotated}
* 忽略,仅用于批量查询部门名回填 {@code deptName}。两个金额列语义不同:{@code amountCny} 为父级
* 项目总金额(每明细行重复),{@code detailAmountCny} 为单条明细金额。</p>
*
* @author LiuBin
* @date 2026-09-18
*/
@Data
@ExcelIgnoreUnannotated
public class AiConsumeExportVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 所属部门ID(非导出列,仅用于批量回填部门名)
*/
private Long deptId;
/**
* 所属部门名称(父级汇总列,导出链批量回填;合计行固定填「合计」)
*/
@ExcelProperty(value = "所属部门")
private String deptName;
/**
* 项目名称(父级汇总列,每明细行重复)
*/
@ExcelProperty(value = "项目名称")
private String projectName;
/**
* 使用人姓名(父级汇总列,每明细行重复)
*/
@ExcelProperty(value = "姓名")
private String userName;
/**
* 消费时间(父级汇总列,纯日期 YYYY-MM-DD,每明细行重复)
*/
@ExcelProperty(value = "消费时间")
private LocalDate consumeTime;
/**
* 项目总金额(父级汇总列,= Σ 子表明细金额,每明细行重复;末尾合计行此列填全量命中明细金额合计)
*/
@ExcelProperty(value = "项目总金额")
private BigDecimal amountCny;
/**
* 成片时长(父级汇总列,每明细行重复):导出层格式化为分秒文本({@code >60 秒→「X分Y秒」、
* 1..60 秒→「X秒」、0→「0秒」、NULL→留白}),对齐前端列表 durationText 约定(spec「导出成片时长格式化」)
*/
@ExcelProperty(value = "成片时长")
private String durationText;
/**
* 每分钟金额(父级汇总列,= 项目总金额×60÷成片时长,每明细行重复)
*/
@ExcelProperty(value = "每分钟金额")
private BigDecimal amountPerMinute;
/**
* 备注(父级汇总列,每明细行重复)
*/
@ExcelProperty(value = "备注")
private String remark;
/**
* AI工具/平台名称(明细列)
*/
@ExcelProperty(value = "AI工具/平台名称")
private String toolName;
/**
* 国内/国外(明细列,中文原文)
*/
@ExcelProperty(value = "国内/国外")
private String region;
/**
* 消耗积分(明细列)
*/
@ExcelProperty(value = "消耗积分")
private BigDecimal consumePoints;
/**
* 金额(人民币)(明细列,单条明细金额;末尾合计行此列留空)
*/
@ExcelProperty(value = "金额(人民币)")
private BigDecimal detailAmountCny;
}
...@@ -4,8 +4,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; ...@@ -4,8 +4,7 @@ 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 com.anplus.hr.ai.domain.model.AiConsumeProject;
import com.anplus.hr.ai.domain.model.AiConsumeRecord;
import top.binfast.common.translation.annotation.Translation; import top.binfast.common.translation.annotation.Translation;
import top.binfast.common.translation.constant.TransConstant; import top.binfast.common.translation.constant.TransConstant;
...@@ -16,36 +15,29 @@ import java.time.LocalDate; ...@@ -16,36 +15,29 @@ import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
/** /**
* AI工具消费记录视图对象 ai_consume_record * AI工具消费项目父行视图对象 ai_consume_project
*
* <p>树形分页/项目详情响应的父行:项目级公共字段 + 派生汇总(项目总金额/成片时长/每分钟金额)
* + 部门名翻译 + 内嵌明细子行 {@code children}。{@code children} 与 {@code deptName} 由 Service
* 手动组装/翻译(非 MapStruct 自动嵌套),列表与详情之外为 null 且不参与 JSON 序列化。</p>
* *
* @author LiuBin * @author LiuBin
* @date 2026-09-04 * @date 2026-09-18
*/ */
@Data @Data
@ExcelIgnoreUnannotated @ExcelIgnoreUnannotated
@AutoMapper(target = AiConsumeRecord.class) @AutoMapper(target = AiConsumeProject.class)
public class AiConsumeRecordVo implements Serializable { public class AiConsumeProjectVo implements Serializable {
@Serial @Serial
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** /**
* 主键ID * 项目ID(父行主键;详情/编辑/删除以此定位项目)
*/ */
private Long id; private Long id;
/**
* 批次号(主从批量录入元数据;非展示、非导出,前端编辑流按此定位整批)
*/
private String batchNo;
/**
* 行序号(批次内从 1 连续;非展示、非导出)
*/
private Integer lineSort;
/** /**
* 所属部门ID(关联sys_dept.id) * 所属部门ID(关联sys_dept.id)
*/ */
...@@ -55,64 +47,49 @@ public class AiConsumeRecordVo implements Serializable { ...@@ -55,64 +47,49 @@ public class AiConsumeRecordVo implements Serializable {
* 所属部门名称(@Translation 列表自动翻译;导出由 ServiceImpl 批量回填) * 所属部门名称(@Translation 列表自动翻译;导出由 ServiceImpl 批量回填)
*/ */
@Translation(type = TransConstant.DEPT_ID_TO_NAME, mapper = "deptId") @Translation(type = TransConstant.DEPT_ID_TO_NAME, mapper = "deptId")
@ExcelProperty(value = "所属部门")
private String deptName; private String deptName;
/** /**
* 项目名称(用户直接输入的自由文本) * 项目名称(用户直接输入的自由文本)
*/ */
@ExcelProperty(value = "项目名称")
private String projectName; private String projectName;
/** /**
* 使用人姓名 * 使用人姓名
*/ */
@ExcelProperty(value = "姓名")
private String userName; private String userName;
/** /**
* AI工具/平台名称(用户直接输入的自由文本) * 消费发生日期(父级,纯日期)
*/ */
@ExcelProperty(value = "AI工具/平台名称") private LocalDate consumeTime;
private String toolName;
/**
* 国内/国外(中文原文,固定二值)
*/
@ExcelProperty(value = "国内/国外")
private String region;
/** /**
* 消耗积分 * 项目总金额(= Σ 子表明细金额,事务重算冗余存储,人民币)
*/ */
@ExcelProperty(value = "消耗积分") private BigDecimal amountCny;
private BigDecimal consumePoints;
/** /**
* 金额(人民币) * 成片时长(秒)
*/ */
@ExcelProperty(value = "金额(人民币)") private Integer durationSeconds;
private BigDecimal amountCny;
/** /**
* 消费发生时间 * 每分钟金额(= 项目总金额×60÷成片时长,2位小数HALF_UP;成片时长为0时NULL)
*/ */
@ExcelProperty(value = "消费时间") private BigDecimal amountPerMinute;
private LocalDate consumeTime;
/** /**
* 备注 * 备注(父级)
*/ */
@ExcelProperty(value = "备注")
private String remark; private String remark;
private LocalDateTime createTime; private LocalDateTime createTime;
/** /**
* 明细行列表(仅整批详情接口 GET /ai/consume/batch/{batchNo} 返回; * 明细子行列表(树形分页/详情内嵌返回,line_sort 升序;为 null 时不参与 JSON 序列化)
* 列表与单条详情为 null 且不参与 JSON 序列化;非展示、非导出)
*/ */
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
private List<AiConsumeRecordVo> details; private List<AiConsumeDetailVo> children;
} }
package com.anplus.hr.ai.mapper;
import com.github.yulichang.base.MPJBaseMapper;
import org.apache.ibatis.annotations.Mapper;
import com.anplus.hr.ai.domain.model.AiConsumeProject;
import top.binfast.common.mybatis.mapper.BinBaseMapper;
/**
* AI工具消费项目Mapper接口(父表)
*
* @author LiuBin
* @date 2026-09-17
*/
@Mapper
public interface AiConsumeProjectMapper extends BinBaseMapper<AiConsumeProject>, MPJBaseMapper<AiConsumeProject> {
}
...@@ -19,28 +19,26 @@ import java.util.List; ...@@ -19,28 +19,26 @@ import java.util.List;
public interface AiConsumeRecordMapper extends BinBaseMapper<AiConsumeRecord>, MPJBaseMapper<AiConsumeRecord> { public interface AiConsumeRecordMapper extends BinBaseMapper<AiConsumeRecord>, MPJBaseMapper<AiConsumeRecord> {
/** /**
* 按批次号查询整批记录(line_sort 升序)。 * 按项目ID查询明细记录(line_sort 升序)。
* *
* <p>租户过滤由全局 TenantLineInnerInterceptor 自动注入,逻辑删除行由 @TableLogic 自动排除。</p> * <p>租户过滤由全局 TenantLineInnerInterceptor 自动注入,逻辑删除行由 @TableLogic 自动排除。</p>
* *
* @param batchNo 批次号 * @param projectId 所属项目ID
* @return 该批次下的全部有效记录,按行序号升序 * @return 该项目下的全部有效明细,按行序号升序
*/ */
default List<AiConsumeRecord> selectByBatchNo(String batchNo) { default List<AiConsumeRecord> selectByProjectId(Long projectId) {
return this.selectList(new LambdaQueryWrapper<AiConsumeRecord>() return this.selectList(new LambdaQueryWrapper<AiConsumeRecord>()
.eq(AiConsumeRecord::getBatchNo, batchNo) .eq(AiConsumeRecord::getProjectId, projectId)
.orderByAsc(AiConsumeRecord::getLineSort)); .orderByAsc(AiConsumeRecord::getLineSort));
} }
/** /**
* 按批次号物理删除整批记录(编辑「整批全删重插」用,绕过 @TableLogic 逻辑删除)。 * 按项目ID物理删除该项目全部明细(编辑「整项目全删重插」/级联删除用,绕过 @TableLogic 逻辑删除)。
* *
* <p>SQL 显式带 tenant_id 条件(design.md D3 / R1),杜绝跨租户误删同 batch_no 行。</p>
* *
* @param batchNo 批次号 * @param projectId 所属项目ID
* @param tenantId 当前租户ID
* @return 删除的行数 * @return 删除的行数
*/ */
int deleteByBatchNo(@Param("batchNo") String batchNo, @Param("tenantId") Long tenantId); int deleteByProjectId(@Param("projectId") Long projectId);
} }
package com.anplus.hr.ai.service;
import com.alibaba.cola.dto.PageResponse;
import com.baomidou.mybatisplus.spring.service.IService;
import com.anplus.hr.ai.domain.model.AiConsumeProject;
import com.anplus.hr.ai.domain.params.AiConsumeProjectListParam;
import com.anplus.hr.ai.domain.params.AiConsumeProjectParam;
import com.anplus.hr.ai.domain.vo.AiConsumeExportVo;
import com.anplus.hr.ai.domain.vo.AiConsumeProjectVo;
import com.anplus.hr.ai.domain.vo.AiConsumeRecordSummaryVo;
import java.util.List;
/**
* AI工具消费项目Service接口
*
* <p>父子表模型(父 {@code ai_consume_project} + 子 {@code ai_consume_record})下的消费记录应用层:
* 树形分页(父行内嵌明细 children)、项目总金额事务重算、每分钟金额派生、级联逻辑删除、扁平导出与
* 底部合计。原基于单表 {@code batch_no} 的 {@code AiConsumeRecordServ} 已随 C2 重构废弃删除。</p>
*
* @author LiuBin
* @date 2026-09-18
*/
public interface AiConsumeProjectServ extends IService<AiConsumeProject> {
/**
* 树形分页查询消费项目列表(按父行计数,每父行内嵌明细 children,line_sort 升序)
*
* @param param 查询条件(父级字段 + 明细 toolName 的 EXISTS 命中)
* @return 消费项目树形分页列表
*/
PageResponse<AiConsumeProjectVo> queryPageList(AiConsumeProjectListParam param);
/**
* 查询符合条件的消费记录扁平导出行(父子表 JOIN 展开,每明细一行 + 父级汇总列重复,末尾追加合计行)
*
* @param param 查询条件(与分页/合计共用同一条件对象)
* @return 扁平导出行列表(含末尾合计行)
*/
List<AiConsumeExportVo> queryList(AiConsumeProjectListParam param);
/**
* 查询当前条件下的项目总金额合计(跨所有分页的全量筛选结果,口径与列表完全一致)
*
* @param param 查询条件(与分页查询共用同一条件对象 AiConsumeProjectListParam)
* @return 合计载体(amountCny 保留两位小数;无匹配数据时为 0.00)
*/
AiConsumeRecordSummaryVo querySummary(AiConsumeProjectListParam param);
/**
* 按项目id查询整项目详情(父行公共字段 + 全部明细子行,line_sort 升序)
*
* @param id 项目id(project_id)
* @return 整项目详情;项目不存在或跨租户返回 null
*/
AiConsumeProjectVo queryById(Long id);
/**
* 新增消费项目(单一事务:建父 + 插子 line_sort 从 1 连续 + 重算汇总回写父表)
*
* @param param 消费项目(项目级公共字段 + 明细列表 details)
* @return 是否新增成功
*/
Boolean insertByParam(AiConsumeProjectParam param);
/**
* 修改消费项目(单一事务:更新父行 + 明细全删重插 + 重算汇总,project_id 不变)
*
* @param param 消费项目(projectId + 项目级公共字段 + 明细列表 details)
* @return 是否修改成功
*/
Boolean updateByParam(AiConsumeProjectParam param);
/**
* 校验并批量级联逻辑删除消费项目(父行 + 其全部明细子行均 @TableLogic 逻辑删除)
*
* @param ids 待删除的项目id(project_id)集合
* @return 是否删除成功
*/
Boolean delByIds(List<Long> ids);
}
package com.anplus.hr.ai.service;
import com.alibaba.cola.dto.PageResponse;
import com.baomidou.mybatisplus.spring.service.IService;
import com.anplus.hr.ai.domain.model.AiConsumeRecord;
import com.anplus.hr.ai.domain.params.AiConsumeRecordListParam;
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 java.util.List;
/**
* AI工具消费记录Service接口
*
* @author LiuBin
* @date 2026-09-04
*/
public interface AiConsumeRecordServ extends IService<AiConsumeRecord> {
/**
* 分页查询AI工具消费记录列表
*
* @param param 查询条件
* @return AI工具消费记录分页列表
*/
PageResponse<AiConsumeRecordVo> queryPageList(AiConsumeRecordListParam param);
/**
* 查询符合条件的AI工具消费记录列表
*
* @param param 查询条件
* @return AI工具消费记录列表
*/
List<AiConsumeRecordVo> queryList(AiConsumeRecordListParam param);
/**
* 查询当前条件下的金额合计(跨所有分页的全量筛选结果,口径与列表完全一致)
*
* @param param 查询条件(与分页查询共用同一条件对象 AiConsumeRecordListParam)
* @return 合计载体(amountCny 保留两位小数;无匹配数据时为 0.00)
*/
AiConsumeRecordSummaryVo querySummary(AiConsumeRecordListParam param);
/**
* 查询AI工具消费记录
*
* @param id 主键
* @return AI工具消费记录
*/
AiConsumeRecordVo queryById(Long id);
/**
* 按批次号查询整批详情(公共字段 + 明细列表,line_sort 升序)
*
* @param batchNo 批次号
* @return 整批详情;批次不存在或跨租户返回 null
*/
AiConsumeRecordVo queryByBatchNo(String batchNo);
/**
* 新增AI工具消费记录
*
* @param param AI工具消费记录
* @return 是否新增成功
*/
Boolean insertByParam(AiConsumeRecordParam param);
/**
* 修改AI工具消费记录
*
* @param param AI工具消费记录
* @return 是否修改成功
*/
Boolean updateByParam(AiConsumeRecordParam param);
/**
* 校验并批量删除AI工具消费记录信息
*
* @param ids 待删除的主键集合
* @return 是否删除成功
*/
Boolean delByIds(List<Long> ids);
}
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.anplus.hr.ai.mapper.AiConsumeProjectMapper">
</mapper>
...@@ -5,12 +5,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" ...@@ -5,12 +5,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<mapper namespace="com.anplus.hr.ai.mapper.AiConsumeRecordMapper"> <mapper namespace="com.anplus.hr.ai.mapper.AiConsumeRecordMapper">
<!-- <!--
按批次号物理删除整批(编辑「整批全删重插」用,绕过 @TableLogic 逻辑删除)。 按项目ID物理删除该项目全部明细(编辑「整项目全删重插」/级联删除用,绕过 @TableLogic 逻辑删除)。
显式带 tenant_id 条件(design.md D3 / R1),杜绝跨租户误删同 batch_no 行。 显式带 tenant_id 条件(design.md D4 / R1),杜绝跨租户误删同 project_id 行。
--> -->
<delete id="deleteByBatchNo"> <delete id="deleteByProjectId">
delete from ai_consume_record delete from ai_consume_record where project_id = #{projectId}
where batch_no = #{batchNo} and tenant_id = #{tenantId}
</delete> </delete>
</mapper> </mapper>
...@@ -40,25 +40,16 @@ export function consumeExport(data: Partial<Consume>) { ...@@ -40,25 +40,16 @@ export function consumeExport(data: Partial<Consume>) {
} }
/** /**
* 消费记录详情(单条) * 消费记录项目详情(父行公共字段 + durationSeconds + 内嵌全量明细子行 children;编辑回填用)
* @param id id * @param id 项目ID(project_id)
* @returns 详情 * @returns 项目详情
*/ */
export function consumeInfo(id: ID) { export function consumeInfo(id: ID) {
return alovaInstance.get<Consume>(`${Api.root}/${id}`); return alovaInstance.get<Consume>(`${Api.root}/${id}`);
} }
/** /**
* 消费记录整批详情(主从批量录入编辑回填用) * 消费记录新增(父子结构:项目级公共字段 + details 明细数组)
* @param batchNo 批次号
* @returns 整批详情(公共字段 + details 明细列表)
*/
export function consumeBatchDetail(batchNo: string) {
return alovaInstance.get<Consume>(`${Api.root}/batch/${batchNo}`);
}
/**
* 消费记录新增(主从批量:公共字段 + details 明细数组)
* @param data 批量入参 * @param data 批量入参
*/ */
export function consumeAdd(data: ConsumeForm) { export function consumeAdd(data: ConsumeForm) {
...@@ -66,8 +57,8 @@ export function consumeAdd(data: ConsumeForm) { ...@@ -66,8 +57,8 @@ export function consumeAdd(data: ConsumeForm) {
} }
/** /**
* 消费记录修改(主从批量:整批全删重插) * 消费记录修改(父子结构:父行公共字段更新 + 明细整批全删重插)
* @param data 批量入参 * @param data 项目入参
*/ */
export function consumeUpdate(data: ConsumeForm) { export function consumeUpdate(data: ConsumeForm) {
return alovaInstance.putWithMsg<void>(Api.root, data); return alovaInstance.putWithMsg<void>(Api.root, data);
......
/** /**
* @description: Consume interface * @description: Consume interface(项目父行,对应后端 AiConsumeProjectVo)
*/ */
export interface Consume { export interface Consume {
id: number;
/**
* 批次号(主从批量录入元数据;非展示、非导出,编辑流按此定位整批)
*/
batchNo?: string;
/** /**
* 行序号(批次内从 1 连续;非展示、非导出) * 项目ID(父行主键;详情/编辑/删除以此定位项目)
*/ */
lineSort?: number; id: number;
/** /**
* 所属部门ID * 所属部门ID(关联 sys_dept.id)
*/ */
deptId?: number; deptId?: number;
/** /**
* 所属部门名称(后端 @Translation 翻译 / 导出回填) * 所属部门名称(后端 @Translation 翻译 / 导出回填)
*/ */
deptName?: string; deptName?: string;
/**
* 归属部门id(部门树查询用,含子部门)
*/
belongDeptId?: number | string;
/** /**
* 项目名称(用户直接输入的自由文本) * 项目名称(用户直接输入的自由文本)
*/ */
...@@ -31,6 +22,41 @@ export interface Consume { ...@@ -31,6 +22,41 @@ export interface Consume {
* 使用人姓名 * 使用人姓名
*/ */
userName: string; userName: string;
/**
* 消费发生日期(父级,纯日期 YYYY-MM-DD)
*/
consumeTime: string;
/**
* 项目总金额(= Σ 明细金额,人民币;列表「项目总金额」列绑定字段)
*/
amountCny: number;
/**
* 成片时长(秒,非负整数)
*/
durationSeconds?: number;
/**
* 每分钟金额(= 项目总金额×60÷成片时长,2 位小数;成片时长为 0 时为 null)
*/
amountPerMinute?: number;
/**
* 备注(父级)
*/
remark?: string;
createTime: string;
/**
* 明细子行列表(分页 / 项目详情内嵌返回,lineSort 升序);列表页作为展开子表数据源
*/
children?: ConsumeDetail[];
}
/**
* @description: 消费明细子行(对应后端 AiConsumeDetailVo;内嵌于父行 children)
*/
export interface ConsumeDetail {
/**
* 明细主键ID
*/
id: number;
/** /**
* AI工具/平台名称(用户直接输入的自由文本) * AI工具/平台名称(用户直接输入的自由文本)
*/ */
...@@ -42,24 +68,15 @@ export interface Consume { ...@@ -42,24 +68,15 @@ export interface Consume {
/** /**
* 消耗积分 * 消耗积分
*/ */
consumePoints: number; consumePoints?: number;
/**
* 金额(人民币)
*/
amountCny: number;
/**
* 消费发生时间
*/
consumeTime: string;
/** /**
* 备注 * 单条明细金额(人民币;区别于父行项目总金额);展开子表「金额(人民币)」列绑定字段
*/ */
remark: string; amountCny?: number;
createTime: string;
/** /**
* 明细行列表(仅整批详情接口 GET /batch/{batchNo} 返回;列表与单条详情为 undefined) * 行序号(项目内明细从 1 连续排序)
*/ */
details?: Consume[]; lineSort?: number;
} }
/** /**
...@@ -85,13 +102,13 @@ export interface ConsumeDetailForm { ...@@ -85,13 +102,13 @@ export interface ConsumeDetailForm {
} }
/** /**
* @description: 消费记录批量入参(主从结构,对应后端 AiConsumeRecordParam) * @description: 消费项目入参(父子结构,对应后端 AiConsumeProjectParam)
*/ */
export interface ConsumeForm { export interface ConsumeForm {
/** /**
* 批次号(编辑时定位原批;新增时为空、由服务端生成) * 项目ID(编辑时定位原项目;新增时为空、由数据库自增生成)
*/ */
batchNo?: string; projectId?: number;
/** /**
* 所属部门ID * 所属部门ID
*/ */
...@@ -105,9 +122,13 @@ export interface ConsumeForm { ...@@ -105,9 +122,13 @@ export interface ConsumeForm {
*/ */
userName?: string; userName?: string;
/** /**
* 消费发生时间(日期类型,落库 date 当天日期) * 消费发生日期(日期类型,落库 date 当天日期)
*/ */
consumeTime?: string; consumeTime?: string;
/**
* 成片时长(秒,非负整数;无视频产出默认 0)
*/
durationSeconds?: number;
/** /**
* 备注 * 备注
*/ */
...@@ -123,7 +144,7 @@ export interface ConsumeForm { ...@@ -123,7 +144,7 @@ export interface ConsumeForm {
*/ */
export interface ConsumeSummary { export interface ConsumeSummary {
/** /**
* 金额(人民币)全量合计(两位小数) * 项目总金额全量合计(两位小数)
*/ */
amountCny: number; amountCny: number;
} }
...@@ -7,7 +7,7 @@ import type { VxeGridInstance, VxeGridProps } from 'vxe-table'; ...@@ -7,7 +7,7 @@ import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
import { computed, nextTick, ref, useTemplateRef } from 'vue'; import { computed, nextTick, ref, useTemplateRef } from 'vue';
import { consumeAdd, consumeBatchDetail, consumeUpdate } from '@/api/ai/consume'; import { consumeAdd, consumeInfo, consumeUpdate } from '@/api/ai/consume';
import { getDeptTree } from '@/api/system/user'; import { getDeptTree } from '@/api/system/user';
import { useVbenDrawer } from '@/components'; import { useVbenDrawer } from '@/components';
import { import {
...@@ -27,6 +27,7 @@ import { ...@@ -27,6 +27,7 @@ import {
Input, Input,
InputNumber, InputNumber,
Select, Select,
TimePicker,
} from 'antdv-next'; } from 'antdv-next';
import { VxeGrid } from 'vxe-table'; import { VxeGrid } from 'vxe-table';
...@@ -37,23 +38,45 @@ const title = computed(() => { ...@@ -37,23 +38,45 @@ 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 CommonFormData = { type CommonFormData = {
batchNo?: string; projectId?: number;
consumeTime?: string; consumeTime?: string;
deptId?: number | string; deptId?: number | string;
projectName?: string; projectName?: string;
remark?: string;
userName?: string; userName?: string;
/** 成片时长 mm:ss(TimePicker 绑定串;提交换算为整数秒 durationSeconds,design D5) */
duration?: string;
remark?: string;
}; };
// 成片时长 mm:ss → 整数秒(留空/非法 → 0,非负,design D5)
function mmssToSeconds(mmss?: string): number {
if (!mmss) {
return 0;
}
const [m, s] = mmss.split(':').map((n) => Number.parseInt(n, 10) || 0);
return (m ?? 0) * 60 + (s ?? 0);
}
// 整数秒 → mm:ss 显示串(0/空 → undefined,编辑回填用,design D5)
function secondsToMmss(sec?: number): string | undefined {
if (!sec || sec <= 0) {
return undefined;
}
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function getDefaultCommon(): CommonFormData { function getDefaultCommon(): CommonFormData {
return { return {
batchNo: undefined, projectId: undefined,
deptId: undefined, deptId: undefined,
projectName: undefined, projectName: undefined,
userName: undefined, userName: undefined,
consumeTime: undefined, consumeTime: undefined,
duration: undefined,
remark: '', remark: '',
}; };
} }
...@@ -263,26 +286,27 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({ ...@@ -263,26 +286,27 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
return null; return null;
} }
drawerApi.drawerLoading(true); drawerApi.drawerLoading(true);
const { batchNo, id } = drawerApi.getData() as { const { id } = drawerApi.getData() as {
batchNo?: string;
id?: number | string; id?: number | string;
}; };
isUpdate.value = !!id; isUpdate.value = !!id;
// 初始化部门树 // 初始化部门树
await setupDeptSelect(); await setupDeptSelect();
await nextTick(); await nextTick();
// 编辑态:按 batch_no 回填整批(公共字段 + 明细);新增态:默认一空行 // 编辑态:按 project_id 调 GET /ai/consume/{id} 回填整项目(公共字段 + 成片时长 + 全量明细);
if (isUpdate.value && batchNo) { // 不复用列表行——列表 children 在 toolName 搜索时仅含匹配子行会丢明细(design D6)
const batch = await consumeBatchDetail(batchNo); if (isUpdate.value && id) {
const project = await consumeInfo(id);
formData.value = { formData.value = {
batchNo: batch?.batchNo ?? batchNo, projectId: project?.id,
deptId: batch?.deptId, deptId: project?.deptId,
projectName: batch?.projectName, projectName: project?.projectName,
userName: batch?.userName, userName: project?.userName,
consumeTime: batch?.consumeTime, consumeTime: project?.consumeTime,
remark: batch?.remark ?? '', duration: secondsToMmss(project?.durationSeconds),
remark: project?.remark ?? '',
}; };
const details = (batch?.details ?? []).map((item) => ({ const details = (project?.children ?? []).map((item) => ({
toolName: item.toolName, toolName: item.toolName,
region: item.region, region: item.region,
consumePoints: item.consumePoints, consumePoints: item.consumePoints,
...@@ -317,12 +341,13 @@ async function handleConfirm() { ...@@ -317,12 +341,13 @@ async function handleConfirm() {
window.message.warning('请至少添加一行消费明细'); window.message.warning('请至少添加一行消费明细');
return; return;
} }
// 4. 组装主从批量入参 // 4. 组装父子项目入参(成片时长 mm:ss → 整数秒 durationSeconds,design D5)
const common = cloneDeep(formData.value); const common = cloneDeep(formData.value);
const data: ConsumeForm = { const data: ConsumeForm = {
batchNo: common.batchNo, projectId: common.projectId,
consumeTime: common.consumeTime, consumeTime: common.consumeTime,
deptId: common.deptId as number | undefined, deptId: common.deptId as number | undefined,
durationSeconds: mmssToSeconds(common.duration),
details: cloneDeep(details), details: cloneDeep(details),
projectName: common.projectName, projectName: common.projectName,
remark: common.remark, remark: common.remark,
...@@ -391,6 +416,15 @@ async function handleClosed() { ...@@ -391,6 +416,15 @@ async function handleClosed() {
v-model:value="formData.consumeTime" v-model:value="formData.consumeTime"
/> />
</FormItem> </FormItem>
<FormItem label="成片时长" name="duration">
<TimePicker
class="w-full"
format="mm:ss"
value-format="mm:ss"
placeholder="无视频产出可留空"
v-model:value="formData.duration"
/>
</FormItem>
<FormItem label="备注" name="remark"> <FormItem label="备注" name="remark">
<FormTextArea <FormTextArea
allow-clear allow-clear
......
import type { VxeGridProps } from 'vxe-table'; import type { VxeGridProps } from 'vxe-table';
/**
* 金额格式化:空值留白(合并列中父/子行在对方列天然为 null),有值保留 2 位小数。
* 同时用于父行「项目总金额」与子行「金额(人民币)」列(design D8)
*/
function amountText(value?: null | number | string): string {
return value == null || value === '' ? '' : Number(value).toFixed(2);
}
/**
* 成片时长格式化:>60 秒「X分X秒」、≤60 秒「X秒」、0「0秒」;空值(子行)留白(design D8)
*/
function durationText(value?: null | number): string {
if (value == null) {
return '';
}
const s = Number(value);
if (s <= 0) {
return '0秒';
}
return s > 60 ? `${Math.floor(s / 60)}分${s % 60}秒` : `${s}秒`;
}
/**
* 主网格列集(design D7):仅项目父级列 + 展开列。
* - 展开列 type:'expand':点击在该行下方内嵌明细子表(列见 detailColumns,数据取 row.children)
* - 父级列:所属部门 / 项目名称 / 姓名 / 项目总金额(amountCny) / 成片时长 / 每分钟金额 / 消费时间 / 备注 / 操作
* - 明细字段(AI工具名称 / 国内外 / 消耗积分 / 金额)不再占主网格列,移入展开子表
*/
export const columns: VxeGridProps['columns'] = [ export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 }, { type: 'checkbox', width: 60 },
{ type: 'expand', width: 50, slots: { content: 'expandContent' } },
{ {
title: '所属部门', title: '所属部门',
field: 'deptName', field: 'deptName',
minWidth: 130,
}, },
{ {
title: '项目名称', title: '项目名称',
field: 'projectName', field: 'projectName',
minWidth: 140,
}, },
{ {
title: '姓名', title: '姓名',
field: 'userName', field: 'userName',
minWidth: 100,
}, },
{ {
title: 'AI工具/平台名称', title: '项目总金额',
field: 'toolName', field: 'amountCny',
}, minWidth: 100,
{ align: 'right',
title: '国内/国外', formatter: ({ cellValue }) => amountText(cellValue),
field: 'region',
}, },
{ {
title: '消耗积分', title: '成片时长',
field: 'consumePoints', field: 'durationSeconds',
minWidth: 100,
align: 'center',
formatter: ({ cellValue }) => durationText(cellValue),
}, },
{ {
title: '金额(人民币)', title: '每分钟金额',
field: 'amountCny', field: 'amountPerMinute',
minWidth: 90,
align: 'right',
formatter: ({ cellValue }) => amountText(cellValue),
}, },
{ {
title: '消费时间', title: '消费时间',
field: 'consumeTime', field: 'consumeTime',
minWidth: 120,
}, },
{ {
title: '备注', title: '备注',
field: 'remark', field: 'remark',
minWidth: 120,
}, },
{ {
field: 'action', field: 'action',
...@@ -47,3 +86,36 @@ export const columns: VxeGridProps['columns'] = [ ...@@ -47,3 +86,36 @@ export const columns: VxeGridProps['columns'] = [
width: 'auto', width: 'auto',
}, },
]; ];
/**
* 展开行明细子表列集(design D7 / spec「消费记录列表展开明细子表前端展示」):
* AI工具名称 / 国内外 / 消耗积分 / 金额(人民币)。数据源为父行内嵌 children(ConsumeDetail,
* 金额字段 amountCny);金额列复用 amountText 保留 2 位、空值留白。仅供主网格展开区嵌套只读子表使用。
*/
export const detailColumns: VxeGridProps['columns'] = [
{
title: 'AI工具/平台名称',
field: 'toolName',
minWidth: 160,
align: 'center',
},
{
title: '国内/国外',
field: 'region',
minWidth: 100,
align: 'center',
},
{
title: '消耗积分',
field: 'consumePoints',
minWidth: 100,
align: 'center',
},
{
title: '金额(人民币)',
field: 'amountCny',
minWidth: 120,
align: 'center',
formatter: ({ cellValue }) => amountText(cellValue),
},
];
...@@ -24,7 +24,7 @@ import { VxeGrid } from 'vxe-table'; ...@@ -24,7 +24,7 @@ import { VxeGrid } from 'vxe-table';
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'; import { columns, detailColumns } from './data';
// 左边部门用 // 左边部门用
const selectDeptId = ref<string[]>([]); const selectDeptId = ref<string[]>([]);
...@@ -45,8 +45,10 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({ ...@@ -45,8 +45,10 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
// 翻页时保留选中状态 // 翻页时保留选中状态
reserve: true, reserve: true,
trigger: 'cell', trigger: 'cell',
// 主网格每行均为项目父行(明细移入展开子表),全部可勾选,无需 checkMethod 过滤(design D7)
}, },
columns, columns,
size: 'medium',
height: 'auto', height: 'auto',
keepSource: true, keepSource: true,
pagerConfig: {}, pagerConfig: {},
...@@ -73,10 +75,13 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({ ...@@ -73,10 +75,13 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
}), }),
consumeSummary(values), consumeSummary(values),
]); ]);
// 合计写入独立 footerData;键名按 data.ts 列 field 对齐;toFixed(2) 规避 JSON 数值退化(design D6) // 合计写入独立 footerData;amountCny 键对齐 data.ts「项目总金额」列 field——
// 合计口径为命中明细金额之和,落「项目总金额」列、其余列留空(design D5,req1);值来源仍是 summary 的 amountCny 单值
footerData.value = [ footerData.value = [
{ deptName: '合计', amountCny: Number(sum.amountCny).toFixed(2) }, { deptName: '合计', amountCny: Number(sum.amountCny).toFixed(2) },
]; ];
// 主网格每行 = 一个项目父行,children 原样保留供展开子表直接渲染(design D7);
// 明细移入独立子表实例,父子不再同表格 → 无需子行字段改名 / 复合键 _rid / rowType 判别
return listRes; return listRes;
} finally { } finally {
tableLoading.value = false; tableLoading.value = false;
...@@ -85,10 +90,14 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({ ...@@ -85,10 +90,14 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
}, },
}, },
rowConfig: { rowConfig: {
// 主网格每行为项目父行,直接用项目 id 作唯一键(明细子表另有独立实例,无父子 id 冲突,design D7)
keyField: 'id', keyField: 'id',
}, },
// footer 页面局部开启(不改全局 vxe 配置,design D2) // footer 页面局部开启(不改全局 vxe 配置,design D7)
showFooter: true, showFooter: true,
// 展开行:主网格每行为项目父行,展开后内嵌明细子表(数据取 row.children,见 expandContent slot);
// pagerConfig 保持启用 → 分页按父行计数(明细子表不参与分页,design D7)
expandConfig: {},
toolbarConfig: { toolbarConfig: {
slots: { slots: {
buttons: 'toolbar-left', buttons: 'toolbar-left',
...@@ -98,6 +107,28 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({ ...@@ -98,6 +107,28 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
id: 'ai-consume-index', id: 'ai-consume-index',
}); });
// 展开区明细子表(只读,design D7):数据源为父行 row.children,复用 detailColumns;
// 关闭代理/分页/工具栏 + 紧凑尺寸,每个展开行渲染独立 VxeGrid 实例(对齐 consume-drawer 明细表标杆)
const subGridOptions = withDefaultVxeGridOptions({
columns: detailColumns,
proxyConfig: { enabled: false },
pagerConfig: { enabled: false },
toolbarConfig: { enabled: false },
rowConfig: { isHover: true, useKey: true },
border: true,
showOverflow: false,
size: 'mini',
minHeight: 40,
});
// 明细子表表头强调(design D7 增强):主色浅底 + 主色文字,与中性表头的项目父表区分更醒目。
// 应用 vxe 主题把表头变量定义在 .vxe-grid 根元素上,故在子表实例根节点内联覆盖(inline 优先级更高,仅作用于本实例,深浅模式均跟随主题)
const subGridHeaderStyle = {
'--vxe-ui-table-header-background-color':
'color-mix(in srgb, var(--ant-color-primary) 10%, transparent)',
'--vxe-ui-table-header-font-color': 'var(--ant-color-primary)',
};
const tableRef = useTemplateRef<VxeGridInstance<Consume>>('tableRef'); const tableRef = useTemplateRef<VxeGridInstance<Consume>>('tableRef');
const { query, reload } = useTableQuery( const { query, reload } = useTableQuery(
searchFormRef, searchFormRef,
...@@ -121,7 +152,8 @@ function handleAdd() { ...@@ -121,7 +152,8 @@ function handleAdd() {
} }
async function handleEdit(record: Consume) { async function handleEdit(record: Consume) {
drawerApi.setData({ batchNo: record.batchNo, id: record.id }); // 按项目主键 project_id 传递,抽屉调 consumeInfo(id) 回填整项目(design D6)
drawerApi.setData({ id: record.id });
drawerApi.open(); drawerApi.open();
} }
...@@ -186,6 +218,7 @@ function getCheckedRows() { ...@@ -186,6 +218,7 @@ function getCheckedRows() {
if (!table) { if (!table) {
return []; return [];
} }
// 主网格每行均为项目父行(明细在独立子表),勾选记录即项目行,无需 rowType 过滤(design D7)
return [ return [
...table.getCheckboxRecords(), ...table.getCheckboxRecords(),
...table.getCheckboxReserveRecords(), ...table.getCheckboxReserveRecords(),
...@@ -258,6 +291,29 @@ function syncCheckedRows() { ...@@ -258,6 +291,29 @@ function syncCheckedRows() {
</a-button> </a-button>
</Space> </Space>
</template> </template>
<template #expandContent="{ row }">
<!-- 展开区明细面板:灰底色带 + 主色标题条 + 白底子表卡片,与项目父行形成层次区分(design D7) -->
<div class="bg-muted px-4 py-3">
<div class="mb-2 flex items-center justify-between">
<span
class="text-foreground flex items-center gap-1.5 text-xs font-medium"
>
<span class="bg-primary h-3 w-[3px] rounded-full"></span>
消费明细
</span>
<span class="text-muted-foreground text-xs">
共 {{ row.children?.length ?? 0 }} 条
</span>
</div>
<div class="bg-card overflow-hidden rounded-md">
<VxeGrid
v-bind="subGridOptions"
:data="row.children ?? []"
:style="subGridHeaderStyle"
/>
</div>
</div>
</template>
<template #action="{ row }"> <template #action="{ row }">
<Space> <Space>
<action-button <action-button
......
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