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

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

parent 5e5f9568
......@@ -11,11 +11,12 @@ import jakarta.validation.constraints.NotEmpty;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import com.anplus.hr.ai.domain.params.AiConsumeRecordListParam;
import com.anplus.hr.ai.domain.params.AiConsumeRecordParam;
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 com.anplus.hr.ai.domain.vo.AiConsumeRecordVo;
import com.anplus.hr.ai.service.AiConsumeRecordServ;
import com.anplus.hr.ai.service.AiConsumeProjectServ;
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;
......@@ -26,122 +27,116 @@ import top.binfast.common.log.annotation.PinSysLog;
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
* @date 2026-09-04
* @date 2026-09-18
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/ai/consume")
public class AiConsumeRecordCtrl {
public class AiConsumeProjectCtrl {
private final AiConsumeRecordServ aiConsumeRecordServ;
private final AiConsumeProjectServ aiConsumeProjectServ;
private final SysDeptServ sysDeptServ;
/**
* 分页查询AI工具消费记录列表。
* 树形分页查询消费项目列表(按父行计数,每父行内嵌明细 children)。
*
* @param param 查询条件
* @return AI工具消费记录分页结果
* @return 消费项目树形分页结果
*/
@SaCheckPermission("ai:consume:list")
@GetMapping("/list")
public PageResponse<AiConsumeRecordVo> pageList(AiConsumeRecordListParam param) {
return aiConsumeRecordServ.queryPageList(param);
public PageResponse<AiConsumeProjectVo> pageList(AiConsumeProjectListParam param) {
return aiConsumeProjectServ.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));
public SingleResponse<AiConsumeRecordSummaryVo> summary(AiConsumeProjectListParam param) {
return SingleResponse.of(aiConsumeProjectServ.querySummary(param));
}
/**
* 导出AI工具消费记录列表。
* 导出消费记录(扁平明细行 + 父级汇总列重复 + 末尾合计行)。
*
* @param param 查询条件
* @return 扁平导出行列表
*/
@ExcelExport
@SaCheckPermission("ai:consume:export")
@PinSysLog(value = "AI工具消费记录", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public List<AiConsumeRecordVo> export(AiConsumeRecordListParam param) {
return aiConsumeRecordServ.queryList(param);
public List<AiConsumeExportVo> export(AiConsumeProjectListParam param) {
return aiConsumeProjectServ.queryList(param);
}
/**
* 获取AI工具消费记录详细信息
* 获取消费项目详细信息(父行公共字段 + 全部明细子行,line_sort 升序)。
*
* @param id 主键
* @param id 项目id(project_id)
* @return 整项目详情
*/
@SaCheckPermission("ai:consume:query")
@GetMapping("/{id}")
public SingleResponse<AiConsumeRecordVo> getDetail(@PathVariable @Min(1)
Long id) {
return SingleResponse.of(aiConsumeRecordServ.queryById(id));
public SingleResponse<AiConsumeProjectVo> getDetail(@PathVariable @Min(1) Long id) {
return SingleResponse.of(aiConsumeProjectServ.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工具消费记录。
*
* @param param AI工具消费记录参数
* @param param 消费项目参数
* @return 操作结果
*/
@SaCheckPermission("ai:consume:add")
@PinSysLog(value = "AI工具消费记录", businessType = BusinessType.INSERT)
@PostMapping()
public Response add(@Validated @RequestBody AiConsumeRecordParam param) {
return ResponseUtils.ofResult(aiConsumeRecordServ.insertByParam(param));
public Response add(@Validated @RequestBody AiConsumeProjectParam param) {
return ResponseUtils.ofResult(aiConsumeProjectServ.insertByParam(param));
}
/**
* 修改AI工具消费记录。
* 修改消费项目(整批编辑:更新父行 + 明细全删重插 + 重算汇总,project_id 不变)。
*
* @param param AI工具消费记录参数
* @param param 消费项目参数
* @return 操作结果
*/
@SaCheckPermission("ai:consume:edit")
@PinSysLog(value = "AI工具消费记录", businessType = BusinessType.UPDATE)
@PutMapping()
public Response edit(@Validated @RequestBody AiConsumeRecordParam param) {
return ResponseUtils.ofResult(aiConsumeRecordServ.updateByParam(param));
public Response edit(@Validated @RequestBody AiConsumeProjectParam param) {
return ResponseUtils.ofResult(aiConsumeProjectServ.updateByParam(param));
}
/**
* 删除AI工具消费记录
* 删除消费项目(级联逻辑删除父行 + 其全部明细子行)。
*
* @param ids 主键串
* @param ids 项目id(project_id)串
* @return 操作结果
*/
@SaCheckPermission("ai:consume:remove")
@PinSysLog(value = "AI工具消费记录", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public Response remove(@NotEmpty(message = "主键不能为空")
@PathVariable Long[] ids) {
return ResponseUtils.ofResult(aiConsumeRecordServ.delByIds(List.of(ids)));
public Response remove(@NotEmpty(message = "主键不能为空") @PathVariable Long[] ids) {
return ResponseUtils.ofResult(aiConsumeProjectServ.delByIds(List.of(ids)));
}
/**
* 获取消费记录筛选用的部门树。
* 获取消费记录筛选用的部门树(当前租户,含子部门子树)。
*
* @param dept 部门查询条件
* @return 部门树列表
......@@ -151,4 +146,5 @@ public class AiConsumeRecordCtrl {
public MultiResponse<Tree<Long>> deptTree(SysDeptParam 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;
import java.io.Serial;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* AI工具消费记录对象 ai_consume_record
* AI工具消费记录明细对象(子表) ai_consume_record
*
* <p>父子表模型下的明细级记录:仅保留工具明细字段,项目级公共字段
* (部门/项目名/姓名/消费时间/备注)上提至父表 {@link AiConsumeProject},
* 以 projectId 外键关联。</p>
*
* @author LiuBin
* @date 2026-09-04
......@@ -26,19 +29,9 @@ public class AiConsumeRecord extends TenantModel {
private static final long serialVersionUID = 1L;
/**
* 所属部门ID(关联sys_dept.id)
*/
private Long deptId;
/**
* 项目名称(用户直接输入的自由文本)
* 所属项目ID(关联 ai_consume_project.id)
*/
private String projectName;
/**
* 使用人姓名(支持模糊查询)
*/
private String userName;
private Long projectId;
/**
* AI工具/平台名称(用户直接输入的自由文本)
......@@ -56,27 +49,12 @@ public class AiConsumeRecord extends TenantModel {
private BigDecimal consumePoints;
/**
* 金额(人民币)
* 单条明细金额(人民币;区别于父表 ai_consume_project.amount_cny 项目总金额,R6)
*/
private BigDecimal amountCny;
/**
* 消费发生时间
*/
private LocalDate consumeTime;
/**
* 备注
*/
private String remark;
/**
* 批次号(主从批量录入:同批多行共享同一 batch_no,全局唯一)
*/
private String batchNo;
/**
* 行序号(批次内从 1 连续自增)
* 行序号(项目内明细从 1 连续排序)
*/
private Integer lineSort;
......
......@@ -8,35 +8,43 @@ import java.util.HashMap;
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
* @date 2026-09-04
* @date 2026-09-18
*/
@Getter
@Setter
public class AiConsumeRecordListParam extends PageQueryParam {
public class AiConsumeProjectListParam extends PageQueryParam {
/**
* 项目名称(用户直接输入的自由文本)
* 项目名称(父级,eq 精确匹配)
*/
private String projectName;
/**
* 使用人姓名(支持模糊查询)
* 使用人姓名(父级,like 模糊查询)
*/
private String userName;
/**
* AI工具/平台名称(用户直接输入的自由文本)
* AI工具/平台名称(明细字段,以 EXISTS 子查询 like 定位含匹配明细的父行)
*/
private String toolName;
/**
* 归属部门id(部门树,含子部门)
* 归属部门id(部门树,含子部门子树)
*/
private Long belongDeptId;
/**
* 其它查询参数(承载 beginTime/endTime 消费时间范围,纯日期边界)
*/
private Map<String, Object> params = new HashMap<>();
}
......@@ -4,6 +4,7 @@ import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import lombok.Data;
......@@ -11,22 +12,23 @@ import java.time.LocalDate;
import java.util.List;
/**
* AI工具消费记录业务对象(主从批量录入)ai_consume_record
* AI工具消费项目业务对象(父子表项目级录入)ai_consume_project
*
* <p>上部公共字段对整批生效,下部 {@code details} 为明细行列表;新增时 {@code batchNo}
* 为空由服务端生成,编辑时携带 {@code batchNo} 定位原批(整批全删重插)。校验注解均不带
* groups,配合 Controller 的 {@code @Validated} 默认组生效(对齐既有 Param 风格)。</p>
* <p>上部项目级公共字段对整个项目生效,下部 {@code details} 为明细行列表;新增时
* {@code projectId} 为空、由数据库自增生成,编辑时携带 {@code projectId} 定位原项目
* (父行公共字段更新 + 明细整批全删重插)。校验注解均不带 groups,配合 Controller 的
* {@code @Validated} 默认组生效(对齐既有 Param 风格)。</p>
*
* @author LiuBin
* @date 2026-09-04
* @date 2026-09-18
*/
@Data
public class AiConsumeRecordParam {
public class AiConsumeProjectParam {
/**
* 批次号(编辑时定位原批;新增时为空、由服务端生成)
* 项目ID(父行主键;编辑时定位原项目,新增时为空由数据库自增生成)
*/
private String batchNo;
private Long projectId;
/**
* 所属部门ID(关联sys_dept.id)
......@@ -47,11 +49,17 @@ public class AiConsumeRecordParam {
private String userName;
/**
* 消费发生时间
* 消费发生日期(父级,纯日期)
*/
@NotNull(message = "消费发生时间不能为空")
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;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated;
import org.apache.fesod.sheet.annotation.ExcelProperty;
import com.anplus.hr.ai.domain.model.AiConsumeRecord;
import com.anplus.hr.ai.domain.model.AiConsumeProject;
import top.binfast.common.translation.annotation.Translation;
import top.binfast.common.translation.constant.TransConstant;
......@@ -16,36 +15,29 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
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
* @date 2026-09-04
* @date 2026-09-18
*/
@Data
@ExcelIgnoreUnannotated
@AutoMapper(target = AiConsumeRecord.class)
public class AiConsumeRecordVo implements Serializable {
@AutoMapper(target = AiConsumeProject.class)
public class AiConsumeProjectVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键ID
* 项目ID(父行主键;详情/编辑/删除以此定位项目)
*/
private Long id;
/**
* 批次号(主从批量录入元数据;非展示、非导出,前端编辑流按此定位整批)
*/
private String batchNo;
/**
* 行序号(批次内从 1 连续;非展示、非导出)
*/
private Integer lineSort;
/**
* 所属部门ID(关联sys_dept.id)
*/
......@@ -55,64 +47,49 @@ public class AiConsumeRecordVo implements Serializable {
* 所属部门名称(@Translation 列表自动翻译;导出由 ServiceImpl 批量回填)
*/
@Translation(type = TransConstant.DEPT_ID_TO_NAME, mapper = "deptId")
@ExcelProperty(value = "所属部门")
private String deptName;
/**
* 项目名称(用户直接输入的自由文本)
*/
@ExcelProperty(value = "项目名称")
private String projectName;
/**
* 使用人姓名
*/
@ExcelProperty(value = "姓名")
private String userName;
/**
* AI工具/平台名称(用户直接输入的自由文本)
* 消费发生日期(父级,纯日期)
*/
@ExcelProperty(value = "AI工具/平台名称")
private String toolName;
/**
* 国内/国外(中文原文,固定二值)
*/
@ExcelProperty(value = "国内/国外")
private String region;
private LocalDate consumeTime;
/**
* 消耗积分
* 项目总金额(= Σ 子表明细金额,事务重算冗余存储,人民币)
*/
@ExcelProperty(value = "消耗积分")
private BigDecimal consumePoints;
private BigDecimal amountCny;
/**
* 金额(人民币)
* 成片时长(秒)
*/
@ExcelProperty(value = "金额(人民币)")
private BigDecimal amountCny;
private Integer durationSeconds;
/**
* 消费发生时间
* 每分钟金额(= 项目总金额×60÷成片时长,2位小数HALF_UP;成片时长为0时NULL)
*/
@ExcelProperty(value = "消费时间")
private LocalDate consumeTime;
private BigDecimal amountPerMinute;
/**
* 备注
* 备注(父级)
*/
@ExcelProperty(value = "备注")
private String remark;
private LocalDateTime createTime;
/**
* 明细行列表(仅整批详情接口 GET /ai/consume/batch/{batchNo} 返回;
* 列表与单条详情为 null 且不参与 JSON 序列化;非展示、非导出)
* 明细子行列表(树形分页/详情内嵌返回,line_sort 升序;为 null 时不参与 JSON 序列化)
*/
@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;
public interface AiConsumeRecordMapper extends BinBaseMapper<AiConsumeRecord>, MPJBaseMapper<AiConsumeRecord> {
/**
* 按批次号查询整批记录(line_sort 升序)。
* 按项目ID查询明细记录(line_sort 升序)。
*
* <p>租户过滤由全局 TenantLineInnerInterceptor 自动注入,逻辑删除行由 @TableLogic 自动排除。</p>
*
* @param batchNo 批次号
* @return 该批次下的全部有效记录,按行序号升序
* @param projectId 所属项目ID
* @return 该项目下的全部有效明细,按行序号升序
*/
default List<AiConsumeRecord> selectByBatchNo(String batchNo) {
default List<AiConsumeRecord> selectByProjectId(Long projectId) {
return this.selectList(new LambdaQueryWrapper<AiConsumeRecord>()
.eq(AiConsumeRecord::getBatchNo, batchNo)
.eq(AiConsumeRecord::getProjectId, projectId)
.orderByAsc(AiConsumeRecord::getLineSort));
}
/**
* 按批次号物理删除整批记录(编辑「整批全删重插」用,绕过 @TableLogic 逻辑删除)。
* 按项目ID物理删除该项目全部明细(编辑「整项目全删重插」/级联删除用,绕过 @TableLogic 逻辑删除)。
*
* <p>SQL 显式带 tenant_id 条件(design.md D3 / R1),杜绝跨租户误删同 batch_no 行。</p>
*
* @param batchNo 批次号
* @param tenantId 当前租户ID
* @param projectId 所属项目ID
* @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"
<mapper namespace="com.anplus.hr.ai.mapper.AiConsumeRecordMapper">
<!--
按批次号物理删除整批(编辑「整批全删重插」用,绕过 @TableLogic 逻辑删除)。
显式带 tenant_id 条件(design.md D3 / R1),杜绝跨租户误删同 batch_no 行。
按项目ID物理删除该项目全部明细(编辑「整项目全删重插」/级联删除用,绕过 @TableLogic 逻辑删除)。
显式带 tenant_id 条件(design.md D4 / R1),杜绝跨租户误删同 project_id 行。
-->
<delete id="deleteByBatchNo">
delete from ai_consume_record
where batch_no = #{batchNo} and tenant_id = #{tenantId}
<delete id="deleteByProjectId">
delete from ai_consume_record where project_id = #{projectId}
</delete>
</mapper>
......@@ -40,25 +40,16 @@ export function consumeExport(data: Partial<Consume>) {
}
/**
* 消费记录详情(单条)
* @param id id
* @returns 详情
* 消费记录项目详情(父行公共字段 + durationSeconds + 内嵌全量明细子行 children;编辑回填用)
* @param id 项目ID(project_id)
* @returns 项目详情
*/
export function consumeInfo(id: ID) {
return alovaInstance.get<Consume>(`${Api.root}/${id}`);
}
/**
* 消费记录整批详情(主从批量录入编辑回填用)
* @param batchNo 批次号
* @returns 整批详情(公共字段 + details 明细列表)
*/
export function consumeBatchDetail(batchNo: string) {
return alovaInstance.get<Consume>(`${Api.root}/batch/${batchNo}`);
}
/**
* 消费记录新增(主从批量:公共字段 + details 明细数组)
* 消费记录新增(父子结构:项目级公共字段 + details 明细数组)
* @param data 批量入参
*/
export function consumeAdd(data: ConsumeForm) {
......@@ -66,8 +57,8 @@ export function consumeAdd(data: ConsumeForm) {
}
/**
* 消费记录修改(主从批量:整批全删重插)
* @param data 批量入参
* 消费记录修改(父子结构:父行公共字段更新 + 明细整批全删重插)
* @param data 项目入参
*/
export function consumeUpdate(data: ConsumeForm) {
return alovaInstance.putWithMsg<void>(Api.root, data);
......
/**
* @description: Consume interface
* @description: Consume interface(项目父行,对应后端 AiConsumeProjectVo)
*/
export interface Consume {
id: number;
/**
* 批次号(主从批量录入元数据;非展示、非导出,编辑流按此定位整批)
*/
batchNo?: string;
/**
* 行序号(批次内从 1 连续;非展示、非导出)
* 项目ID(父行主键;详情/编辑/删除以此定位项目)
*/
lineSort?: number;
id: number;
/**
* 所属部门ID
* 所属部门ID(关联 sys_dept.id)
*/
deptId?: number;
/**
* 所属部门名称(后端 @Translation 翻译 / 导出回填)
*/
deptName?: string;
/**
* 归属部门id(部门树查询用,含子部门)
*/
belongDeptId?: number | string;
/**
* 项目名称(用户直接输入的自由文本)
*/
......@@ -31,6 +22,41 @@ export interface Consume {
* 使用人姓名
*/
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工具/平台名称(用户直接输入的自由文本)
*/
......@@ -42,24 +68,15 @@ export interface Consume {
/**
* 消耗积分
*/
consumePoints: number;
/**
* 金额(人民币)
*/
amountCny: number;
/**
* 消费发生时间
*/
consumeTime: string;
consumePoints?: number;
/**
* 备注
* 单条明细金额(人民币;区别于父行项目总金额);展开子表「金额(人民币)」列绑定字段
*/
remark: string;
createTime: string;
amountCny?: number;
/**
* 明细行列表(仅整批详情接口 GET /batch/{batchNo} 返回;列表与单条详情为 undefined)
* 行序号(项目内明细从 1 连续排序)
*/
details?: Consume[];
lineSort?: number;
}
/**
......@@ -85,13 +102,13 @@ export interface ConsumeDetailForm {
}
/**
* @description: 消费记录批量入参(主从结构,对应后端 AiConsumeRecordParam)
* @description: 消费项目入参(父子结构,对应后端 AiConsumeProjectParam)
*/
export interface ConsumeForm {
/**
* 批次号(编辑时定位原批;新增时为空、由服务端生成)
* 项目ID(编辑时定位原项目;新增时为空、由数据库自增生成)
*/
batchNo?: string;
projectId?: number;
/**
* 所属部门ID
*/
......@@ -105,9 +122,13 @@ export interface ConsumeForm {
*/
userName?: string;
/**
* 消费发生时间(日期类型,落库 date 当天日期)
* 消费发生日期(日期类型,落库 date 当天日期)
*/
consumeTime?: string;
/**
* 成片时长(秒,非负整数;无视频产出默认 0)
*/
durationSeconds?: number;
/**
* 备注
*/
......@@ -123,7 +144,7 @@ export interface ConsumeForm {
*/
export interface ConsumeSummary {
/**
* 金额(人民币)全量合计(两位小数)
* 项目总金额全量合计(两位小数)
*/
amountCny: number;
}
......@@ -7,7 +7,7 @@ import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
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 { useVbenDrawer } from '@/components';
import {
......@@ -27,6 +27,7 @@ import {
Input,
InputNumber,
Select,
TimePicker,
} from 'antdv-next';
import { VxeGrid } from 'vxe-table';
......@@ -37,23 +38,45 @@ const title = computed(() => {
return isUpdate.value ? $t('pages.common.edit') : $t('pages.common.add');
});
// 公共字段(主表):所属部门 / 项目名称 / 使用人姓名 / 消费时间 / 备注
// 公共字段(项目级):所属部门 / 项目名称 / 使用人姓名 / 消费时间 / 成片时长 / 备注
type CommonFormData = {
batchNo?: string;
projectId?: number;
consumeTime?: string;
deptId?: number | string;
projectName?: string;
remark?: 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 {
return {
batchNo: undefined,
projectId: undefined,
deptId: undefined,
projectName: undefined,
userName: undefined,
consumeTime: undefined,
duration: undefined,
remark: '',
};
}
......@@ -263,26 +286,27 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
return null;
}
drawerApi.drawerLoading(true);
const { batchNo, id } = drawerApi.getData() as {
batchNo?: string;
const { id } = drawerApi.getData() as {
id?: number | string;
};
isUpdate.value = !!id;
// 初始化部门树
await setupDeptSelect();
await nextTick();
// 编辑态:按 batch_no 回填整批(公共字段 + 明细);新增态:默认一空行
if (isUpdate.value && batchNo) {
const batch = await consumeBatchDetail(batchNo);
// 编辑态:按 project_id 调 GET /ai/consume/{id} 回填整项目(公共字段 + 成片时长 + 全量明细);
// 不复用列表行——列表 children 在 toolName 搜索时仅含匹配子行会丢明细(design D6)
if (isUpdate.value && id) {
const project = await consumeInfo(id);
formData.value = {
batchNo: batch?.batchNo ?? batchNo,
deptId: batch?.deptId,
projectName: batch?.projectName,
userName: batch?.userName,
consumeTime: batch?.consumeTime,
remark: batch?.remark ?? '',
projectId: project?.id,
deptId: project?.deptId,
projectName: project?.projectName,
userName: project?.userName,
consumeTime: project?.consumeTime,
duration: secondsToMmss(project?.durationSeconds),
remark: project?.remark ?? '',
};
const details = (batch?.details ?? []).map((item) => ({
const details = (project?.children ?? []).map((item) => ({
toolName: item.toolName,
region: item.region,
consumePoints: item.consumePoints,
......@@ -317,12 +341,13 @@ async function handleConfirm() {
window.message.warning('请至少添加一行消费明细');
return;
}
// 4. 组装主从批量入参
// 4. 组装父子项目入参(成片时长 mm:ss → 整数秒 durationSeconds,design D5)
const common = cloneDeep(formData.value);
const data: ConsumeForm = {
batchNo: common.batchNo,
projectId: common.projectId,
consumeTime: common.consumeTime,
deptId: common.deptId as number | undefined,
durationSeconds: mmssToSeconds(common.duration),
details: cloneDeep(details),
projectName: common.projectName,
remark: common.remark,
......@@ -391,6 +416,15 @@ async function handleClosed() {
v-model:value="formData.consumeTime"
/>
</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">
<FormTextArea
allow-clear
......
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'] = [
{ type: 'checkbox', width: 60 },
{ type: 'expand', width: 50, slots: { content: 'expandContent' } },
{
title: '所属部门',
field: 'deptName',
minWidth: 130,
},
{
title: '项目名称',
field: 'projectName',
minWidth: 140,
},
{
title: '姓名',
field: 'userName',
minWidth: 100,
},
{
title: 'AI工具/平台名称',
field: 'toolName',
},
{
title: '国内/国外',
field: 'region',
title: '项目总金额',
field: 'amountCny',
minWidth: 100,
align: 'right',
formatter: ({ cellValue }) => amountText(cellValue),
},
{
title: '消耗积分',
field: 'consumePoints',
title: '成片时长',
field: 'durationSeconds',
minWidth: 100,
align: 'center',
formatter: ({ cellValue }) => durationText(cellValue),
},
{
title: '金额(人民币)',
field: 'amountCny',
title: '每分钟金额',
field: 'amountPerMinute',
minWidth: 90,
align: 'right',
formatter: ({ cellValue }) => amountText(cellValue),
},
{
title: '消费时间',
field: 'consumeTime',
minWidth: 120,
},
{
title: '备注',
field: 'remark',
minWidth: 120,
},
{
field: 'action',
......@@ -47,3 +86,36 @@ export const columns: VxeGridProps['columns'] = [
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';
import consumeDrawer from './consume-drawer.vue';
import ConsumeSearchForm from './consume-search.vue';
import { columns } from './data';
import { columns, detailColumns } from './data';
// 左边部门用
const selectDeptId = ref<string[]>([]);
......@@ -45,8 +45,10 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
// 翻页时保留选中状态
reserve: true,
trigger: 'cell',
// 主网格每行均为项目父行(明细移入展开子表),全部可勾选,无需 checkMethod 过滤(design D7)
},
columns,
size: 'medium',
height: 'auto',
keepSource: true,
pagerConfig: {},
......@@ -73,10 +75,13 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
}),
consumeSummary(values),
]);
// 合计写入独立 footerData;键名按 data.ts 列 field 对齐;toFixed(2) 规避 JSON 数值退化(design D6)
// 合计写入独立 footerData;amountCny 键对齐 data.ts「项目总金额」列 field——
// 合计口径为命中明细金额之和,落「项目总金额」列、其余列留空(design D5,req1);值来源仍是 summary 的 amountCny 单值
footerData.value = [
{ deptName: '合计', amountCny: Number(sum.amountCny).toFixed(2) },
];
// 主网格每行 = 一个项目父行,children 原样保留供展开子表直接渲染(design D7);
// 明细移入独立子表实例,父子不再同表格 → 无需子行字段改名 / 复合键 _rid / rowType 判别
return listRes;
} finally {
tableLoading.value = false;
......@@ -85,10 +90,14 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
},
},
rowConfig: {
// 主网格每行为项目父行,直接用项目 id 作唯一键(明细子表另有独立实例,无父子 id 冲突,design D7)
keyField: 'id',
},
// footer 页面局部开启(不改全局 vxe 配置,design D2)
// footer 页面局部开启(不改全局 vxe 配置,design D7)
showFooter: true,
// 展开行:主网格每行为项目父行,展开后内嵌明细子表(数据取 row.children,见 expandContent slot);
// pagerConfig 保持启用 → 分页按父行计数(明细子表不参与分页,design D7)
expandConfig: {},
toolbarConfig: {
slots: {
buttons: 'toolbar-left',
......@@ -98,6 +107,28 @@ const gridOptions = withDefaultVxeGridOptions<Consume>({
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 { query, reload } = useTableQuery(
searchFormRef,
......@@ -121,7 +152,8 @@ function handleAdd() {
}
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();
}
......@@ -186,6 +218,7 @@ function getCheckedRows() {
if (!table) {
return [];
}
// 主网格每行均为项目父行(明细在独立子表),勾选记录即项目行,无需 rowType 过滤(design D7)
return [
...table.getCheckboxRecords(),
...table.getCheckboxReserveRecords(),
......@@ -258,6 +291,29 @@ function syncCheckedRows() {
</a-button>
</Space>
</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 }">
<Space>
<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