SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。 == 此快照内容 == - 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091) - 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static) - 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB) - 交接文档 go.md + go-code-reference/endpoints/entities/database.md - 多代理建设脚本 .claude/wf-*.js == 状态 == - 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板) - 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用 - W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成) - 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456 == 排除(gitignore, 可再生) == node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui) 完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules) 时间戳: 20260615-191517 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
# 核心代码逐字参考(go-code-reference.md)
|
||||
|
||||
> 配套 `go.md`。这里把**核心基础类**和**一套端到端完整范例**逐字贴出,新工具照抄即可遵守全部约定。
|
||||
> 范例选 `RdAnnualBudget`(部门年度研发预算,W6 产物)——含状态机/聚合/超支 409/导出,覆盖面广。
|
||||
|
||||
---
|
||||
|
||||
## A. 核心基础类(每个控制器都依赖)
|
||||
|
||||
### A.1 `common/ApiResp.java`(统一响应包装,**record**,22 行,全文)
|
||||
```java
|
||||
package com.kaidi.oa.common;
|
||||
|
||||
/** code==0 成功;非0为错误,message 中文原因;data 载荷(错误时 null)。 */
|
||||
public record ApiResp<T>(int code, String message, T data) {
|
||||
public static <T> ApiResp<T> ok(T data) { return new ApiResp<>(0, "ok", data); }
|
||||
public static <T> ApiResp<T> ok() { return new ApiResp<>(0, "ok", null); }
|
||||
public static <T> ApiResp<T> error(int code, String message) { return new ApiResp<>(code, message, null); }
|
||||
}
|
||||
```
|
||||
> 取数据用 **`.data()`**(record 访问器),不是 `.getData()`。控制器统一 `return ApiResp.ok(payload);`。
|
||||
|
||||
### A.2 异常类(控制器抛这两个,GlobalExceptionHandler 统一转 HTTP 状态)
|
||||
```java
|
||||
// common/ApiException.java —— 业务错误:new ApiException(400, "中文原因") / (409, "冲突原因")
|
||||
// common/NotFoundException.java —— 资源不存在:抛出后 GlobalExceptionHandler 映射 404
|
||||
```
|
||||
用法(见下面控制器):`throw new ApiException(409, "已关闭的预算不可修改");`、`throw new NotFoundException("xxx不存在:" + id);`
|
||||
|
||||
### A.3 `common/Money.java`(金额工具,全文要点)
|
||||
```java
|
||||
public final class Money {
|
||||
public static final int SCALE = 2; // 标度2(分)
|
||||
public static final BigDecimal ZERO = ...; // 标度2的0
|
||||
public static BigDecimal of(Double v); // 装箱Double→标度2;null当0;NaN/Inf→400
|
||||
public static BigDecimal of(double v);
|
||||
public static BigDecimal of(BigDecimal v); // = nz(v)
|
||||
public static BigDecimal nz(BigDecimal v); // null安全:null→0
|
||||
public static BigDecimal add(BigDecimal a, BigDecimal b); // 都当0再加,结果标度2
|
||||
public static BigDecimal sub(BigDecimal a, BigDecimal b);
|
||||
public static boolean gt(BigDecimal a, BigDecimal b); // a>b(null当0)
|
||||
public static boolean lte0(BigDecimal a); // a<=0
|
||||
}
|
||||
```
|
||||
> **铁律**:所有"钱"用 `BigDecimal`,控制器里收前端传来的金额一律 `Money.of(req.amount())`(req.amount() 是 Double),算术用 `Money.add/sub`,比较用 `Money.gt/lte0`。科学量/数量(不是钱)可用 double。
|
||||
|
||||
### A.4 前端 `oa/api/http.ts`(HTTP 客户端,导出对象全文)
|
||||
```ts
|
||||
// token 存 localStorage 键 'oa.token';自动带 Authorization: Bearer + ngrok-skip-browser-warning;
|
||||
// 自动解包 {code,message,data} 取 data;非2xx/code!=0 抛 OaApiError;401 派发 OA_AUTH_REQUIRED_EVENT。
|
||||
export interface RequestOptions {
|
||||
method?: 'GET'|'POST'|'PUT'|'DELETE'|'PATCH'
|
||||
query?: Record<string, string|number|boolean|undefined|null> // 查询参数;undefined/null 自动丢弃
|
||||
body?: unknown
|
||||
timeoutMs?: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
export const http = {
|
||||
get: <T>(path, query?, opts?) => request<T>(path, { ...opts, method:'GET', query }), // 第2参=query对象
|
||||
post: <T>(path, body?, opts?) => request<T>(path, { ...opts, method:'POST', body }),
|
||||
put: <T>(path, body?, opts?) => request<T>(path, { ...opts, method:'PUT', body }),
|
||||
patch: <T>(path, body?, opts?) => request<T>(path, { ...opts, method:'PATCH', body }),
|
||||
del: <T>(path, opts?) => request<T>(path, { ...opts, method:'DELETE' }), // 删除是 del 不是 delete
|
||||
}
|
||||
```
|
||||
> 调用:`await http.get<Row[]>('/rd-annual-budgets', { fiscalYear: 2026 })`、`await http.post('/x', {...body})`、`await http.del(\`/x/${id}\`)`。路径不带 `/api/oa` 前缀(客户端自动拼)。
|
||||
|
||||
---
|
||||
|
||||
## B. 安全:`config/AuthInterceptor.java`(default-deny 分级,核心逻辑全文)
|
||||
|
||||
四张前缀表(`List<String>`,元素是 `/api/oa/xxx`):
|
||||
- `ADMIN_PREFIXES`(行52起):仅 ADMIN。
|
||||
- `FINANCE_PREFIXES`(行67起):财务/主数据;写需 ADMIN/APPROVER,**且读侧自动同门禁**(见 isSensitiveRead)。
|
||||
- `SENSITIVE_READ_PREFIXES`(行130起):敏感读前缀(读也要 ADMIN/APPROVER)。**新增返回金额/PII/机密聚合的读端点,把它的前缀加这里**。
|
||||
- `SELF_SERVICE_WRITE_PREFIXES`(行382起):个人/协作类,任意已授角色可写(对象级属主校验在控制器内)。
|
||||
|
||||
鉴权决策(逐字):
|
||||
```java
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) return true; // CORS 预检放行
|
||||
String path = normalizePath(request.getRequestURI()); // 规范化防绕过(见下)
|
||||
if (PUBLIC.contains(path)) return true; // 登录等公开端点
|
||||
SysUser user = currentUser.resolve(request);
|
||||
if (user == null) { writeError(401,"未登录或会话已过期"); return false; }
|
||||
Set<String> have = authz.roleCodesOf(user.getId());
|
||||
if (have.isEmpty()) { writeError(403,"无权限:账号未分配任何角色"); return false; } // 无角色=零权限
|
||||
boolean isWrite = WRITE_METHODS.contains(request.getMethod().toUpperCase());
|
||||
if (isWrite) {
|
||||
Set<String> required = requiredRolesFor(path); // 写:查所需角色
|
||||
if (required != null && required.stream().noneMatch(have::contains)) { writeError(403,...); return false; }
|
||||
} else {
|
||||
if (isSensitiveRead(path) && APPROVER_OR_ADMIN.stream().noneMatch(have::contains)) { writeError(403,...); return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 写口角色判定:ADMIN前缀→ADMIN;自助白名单→任意角色(null);其余一切默认→ADMIN/APPROVER(default-deny翻转)
|
||||
private static Set<String> requiredRolesFor(String path) {
|
||||
if (path.endsWith("/convert-bid") || (path.startsWith("/api/oa/projects/") && path.endsWith("/advance")))
|
||||
return APPROVER_OR_ADMIN; // 侧写财务副作用端点特判
|
||||
if (path.endsWith("/vote")) return null; // 问卷投票公共动作
|
||||
for (String p : ADMIN_PREFIXES) if (hit(path,p)) return ADMIN;
|
||||
for (String p : SELF_SERVICE_WRITE_PREFIXES) if (hit(path,p)) return null;
|
||||
for (String p : FINANCE_PREFIXES) if (hit(path,p)) return APPROVER_OR_ADMIN;
|
||||
return APPROVER_OR_ADMIN; // ★默认需 ADMIN/APPROVER
|
||||
}
|
||||
|
||||
// 读口敏感判定:后缀特判 + SENSITIVE_READ_PREFIXES + FINANCE_PREFIXES(写收了读也收)
|
||||
private static boolean isSensitiveRead(String path) {
|
||||
if (path.endsWith("/evidence-chain")) return true; // 研发证据链含金额明细
|
||||
if (path.endsWith("/water-quality/trace")) return true; // 水质溯源含客户主数据
|
||||
for (String p : SENSITIVE_READ_PREFIXES) if (hit(path,p)) return true;
|
||||
for (String p : FINANCE_PREFIXES) if (hit(path,p)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 规范化防越权绕过:URL解码(%75→u)+剥矩阵参数(;k=v)+折叠斜杠+去尾斜杠+转小写
|
||||
static String normalizePath(String uri) {
|
||||
String p = URLDecoder.decode(uri, UTF_8); // 异常则保留原串(宁多拦不漏拦)
|
||||
p = p.replaceAll(";[^/]*","").replaceAll("/{2,}","/");
|
||||
if (p.length()>1 && p.endsWith("/")) p = p.substring(0,p.length()-1);
|
||||
return p.toLowerCase();
|
||||
}
|
||||
```
|
||||
> **新建控制器默认就受保护**(写口默认档=ADMIN/APPROVER)。普通业务读默认放行给"有角色的登录用户";返回敏感数据的读口必须登记进 SENSITIVE_READ_PREFIXES(或落在 FINANCE_PREFIXES 内)。
|
||||
|
||||
---
|
||||
|
||||
## C. 一套端到端完整范例(控制器+实体+仓库+Seeder+Vue页)
|
||||
|
||||
> 业务:部门年度研发费用预算。资源路径 `/api/oa/rd-annual-budgets`,表 `rd_annual_budget`。
|
||||
> **新建一个功能就照这 5 个文件的样子复制。**
|
||||
|
||||
### C.1 实体 `domain/RdAnnualBudget.java`(@Entity,金额 BigDecimal=ZERO 默认)
|
||||
```java
|
||||
package com.kaidi.oa.domain;
|
||||
import java.math.BigDecimal; import java.time.Instant;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "rd_annual_budget") // 物理表名,camelCase→snake_case;保留字列要 @Column(name=)
|
||||
public class RdAnnualBudget {
|
||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
private Integer fiscalYear; // 预算年度
|
||||
private String dept;
|
||||
private String budgetType; // 研发费用 / 知识产权费用
|
||||
private String expenseItem; // 人工费/材料费/.../合计
|
||||
private BigDecimal amount = BigDecimal.ZERO; // ★金额默认 BigDecimal.ZERO(防 NOT NULL 500)
|
||||
private BigDecimal actualUsed = BigDecimal.ZERO; // 实际已用
|
||||
private String rdProjectCodes; // 逗号分隔的立项编号
|
||||
private String status; // 编制中/已批准/已关闭
|
||||
private String owner;
|
||||
private String remark;
|
||||
private Instant createdAt;
|
||||
private Instant updatedAt;
|
||||
// —— 每个字段标准 getter/setter(省略,必须全有,控制器靠 setXxx/getXxx 读写)——
|
||||
}
|
||||
```
|
||||
|
||||
### C.2 仓库 `repository/RdAnnualBudgetRepository.java`(派生查询,属性名必须和实体字段精确一致)
|
||||
```java
|
||||
package com.kaidi.oa.repository;
|
||||
import com.kaidi.oa.domain.RdAnnualBudget;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import java.util.List;
|
||||
|
||||
public interface RdAnnualBudgetRepository extends JpaRepository<RdAnnualBudget, Long> {
|
||||
List<RdAnnualBudget> findByFiscalYear(Integer fiscalYear);
|
||||
List<RdAnnualBudget> findByFiscalYearAndDept(Integer fiscalYear, String dept);
|
||||
List<RdAnnualBudget> findByFiscalYearAndBudgetType(Integer fiscalYear, String budgetType);
|
||||
List<RdAnnualBudget> findByFiscalYearAndDeptAndBudgetType(Integer fiscalYear, String dept, String budgetType);
|
||||
}
|
||||
```
|
||||
> 派生查询 `findBy<字段名>`:字段名必须和实体字段**精确一致**(大小写敏感、单复数一致),否则 Spring **启动期**才报 `No property 'xxx'`。模糊查用 `Containing`(**别用 ContainingIgnoreCase**,见 go.md 坑 9.3)。
|
||||
|
||||
### C.3 控制器 `web/RdAnnualBudgetController.java`(全文,覆盖所有约定)
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/oa/rd-annual-budgets")
|
||||
public class RdAnnualBudgetController {
|
||||
private final RdAnnualBudgetRepository repo;
|
||||
public RdAnnualBudgetController(RdAnnualBudgetRepository repo) { this.repo = repo; } // 构造注入
|
||||
|
||||
private static final List<String> STATUSES = List.of("编制中", "已批准", "已关闭");
|
||||
|
||||
// 列表:多条件可选过滤
|
||||
@GetMapping
|
||||
public ApiResp<List<RdAnnualBudget>> list(@RequestParam(required=false) Integer fiscalYear,
|
||||
@RequestParam(required=false) String dept,
|
||||
@RequestParam(required=false) String budgetType) {
|
||||
if (fiscalYear != null) return ApiResp.ok(repo.findByFiscalYear(fiscalYear));
|
||||
return ApiResp.ok(repo.findAll());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResp<RdAnnualBudget> get(@PathVariable Long id) { return ApiResp.ok(find(id)); }
|
||||
|
||||
public record BudgetReq(Integer fiscalYear, String dept, String budgetType, String expenseItem,
|
||||
Double amount, String rdProjectCodes, String owner, String remark) {} // ★record 请求体
|
||||
|
||||
@PostMapping
|
||||
@Transactional // ★写操作/状态迁移加 @Transactional
|
||||
public ApiResp<RdAnnualBudget> create(@RequestBody BudgetReq req) {
|
||||
if (req.fiscalYear() == null) throw new ApiException(400, "预算年度不能为空"); // 校验→400中文
|
||||
RdAnnualBudget b = new RdAnnualBudget();
|
||||
b.setFiscalYear(req.fiscalYear());
|
||||
b.setAmount(Money.of(req.amount())); // ★金额收口 Money.of(Double)
|
||||
b.setActualUsed(Money.ZERO);
|
||||
b.setStatus("编制中");
|
||||
b.setCreatedAt(Instant.now()); b.setUpdatedAt(Instant.now());
|
||||
return ApiResp.ok(repo.save(b));
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
@Transactional
|
||||
public ApiResp<RdAnnualBudget> update(@PathVariable Long id, @RequestBody BudgetReq req) {
|
||||
RdAnnualBudget b = find(id);
|
||||
if ("已关闭".equals(b.getStatus())) throw new ApiException(409, "已关闭的预算不可修改"); // 状态冲突→409
|
||||
if (req.amount() != null) b.setAmount(Money.of(req.amount())); // PATCH:非null才改
|
||||
b.setUpdatedAt(Instant.now());
|
||||
return ApiResp.ok(repo.save(b));
|
||||
}
|
||||
|
||||
public record StatusReq(String status) {}
|
||||
@PostMapping("/{id}/status") // 状态机端点
|
||||
@Transactional
|
||||
public ApiResp<RdAnnualBudget> setStatus(@PathVariable Long id, @RequestBody StatusReq req) {
|
||||
RdAnnualBudget b = find(id);
|
||||
String to = req.status() == null ? "" : req.status().trim();
|
||||
if (!STATUSES.contains(to)) throw new ApiException(400, "未知状态:" + to);
|
||||
b.setStatus(to); b.setUpdatedAt(Instant.now());
|
||||
return ApiResp.ok(repo.save(b));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Transactional
|
||||
public ApiResp<Void> delete(@PathVariable Long id) {
|
||||
RdAnnualBudget b = find(id);
|
||||
if (Money.gt(b.getActualUsed(), Money.ZERO)) // 业务校验:有占用不可删
|
||||
throw new ApiException(409, "已有费用占用,不可删除");
|
||||
repo.deleteById(id);
|
||||
return ApiResp.ok(null);
|
||||
}
|
||||
|
||||
// 费用归集写入:找对应预算行扣减 actualUsed,超支 409(force=true 强占)
|
||||
public record OccupyReq(Integer fiscalYear, String dept, String budgetType, String expenseItem,
|
||||
Double amount, boolean force) {}
|
||||
@PostMapping("/occupy")
|
||||
@Transactional
|
||||
public ApiResp<RdAnnualBudget> occupy(@RequestBody OccupyReq req) {
|
||||
if (req.amount() == null || req.amount() <= 0) throw new ApiException(400, "占用金额必须大于零");
|
||||
RdAnnualBudget b = ...; // 按年度/部门/类型/科目查
|
||||
BigDecimal newUsed = Money.add(b.getActualUsed(), Money.of(req.amount()));
|
||||
if (Money.gt(newUsed, b.getAmount()) && !req.force())
|
||||
throw new ApiException(409, "超支预警:... 如需强制占用请带 force=true");
|
||||
b.setActualUsed(newUsed); b.setUpdatedAt(Instant.now());
|
||||
return ApiResp.ok(repo.save(b));
|
||||
}
|
||||
|
||||
// 聚合端点:执行汇总(record 出参,前端直接渲染)
|
||||
public record YearDeptSummary(int fiscalYear, String dept, String budgetType,
|
||||
double totalBudget, double totalUsed, double totalBalance,
|
||||
double rate, List<ItemLine> byItem) {}
|
||||
@GetMapping("/summary")
|
||||
public ApiResp<List<YearDeptSummary>> summary(@RequestParam int fiscalYear, ...) { ... }
|
||||
|
||||
private RdAnnualBudget find(Long id) {
|
||||
return repo.findById(id).orElseThrow(() -> new NotFoundException("研发年度预算行不存在:" + id));
|
||||
}
|
||||
}
|
||||
```
|
||||
(完整 328 行在源码 `oa-backend/src/main/java/com/kaidi/oa/web/RdAnnualBudgetController.java`。)
|
||||
|
||||
### C.4 演示种子 `seed/CslDemoSeeder.java`(@Order + 幂等守卫 + Money + 真实数据,全文要点)
|
||||
```java
|
||||
@Component
|
||||
@Order(7) // 排序;DataSeeder 是 @Order(1)
|
||||
public class CslDemoSeeder implements ApplicationRunner {
|
||||
private final CslProjectRepository projectRepo;
|
||||
private final CslTimesheetRepository timesheetRepo;
|
||||
public CslDemoSeeder(CslProjectRepository p, CslTimesheetRepository t) { this.projectRepo=p; this.timesheetRepo=t; }
|
||||
|
||||
@Override @Transactional
|
||||
public void run(ApplicationArguments args) { seedProjects(); }
|
||||
|
||||
private void seedProjects() {
|
||||
if (projectRepo.findAll().stream().noneMatch(p -> "CSL-2025-001".equals(p.getCode()))) { // ★幂等守卫
|
||||
CslProject p1 = new CslProject();
|
||||
p1.setCode("CSL-2025-001");
|
||||
p1.setName("某市地铁5号线可行性研究");
|
||||
p1.setContractAmount(Money.of(new BigDecimal("1280000.00"))); // ★金额 Money.of
|
||||
p1.setStage("执行"); p1.setProgress(62);
|
||||
p1.setPlanStart("2025-03-01"); p1.setPlanEnd("2025-09-30"); // 日期用 String
|
||||
p1.setCreatedAt(Instant.now()); p1.setUpdatedAt(Instant.now());
|
||||
CslProject saved = projectRepo.save(p1);
|
||||
seedTimesheets(saved.getId(), "CSL-2025-001"); // 关联子表
|
||||
}
|
||||
// ... 项目2、项目3 ...
|
||||
}
|
||||
private void save(Long projectId, String person, String role, String workDate, double hours,
|
||||
double rate, String workContent, String status) {
|
||||
CslTimesheet t = new CslTimesheet();
|
||||
t.setProjectId(projectId); t.setPerson(person); t.setHours(hours);
|
||||
BigDecimal rateBd = Money.of(rate);
|
||||
t.setRate(rateBd);
|
||||
t.setLaborCost(Money.of(rateBd.multiply(BigDecimal.valueOf(hours)))); // 人工成本=费率×工时
|
||||
t.setStatus(status); t.setCreatedAt(Instant.now());
|
||||
timesheetRepo.save(t);
|
||||
}
|
||||
}
|
||||
```
|
||||
> **空表补种子是把"功能在但无数据演示"的 PARTIAL 推到可演示 MET 的最廉价手段**(go-database.md 列了所有空表)。守卫必须有(重启不重复插)。
|
||||
|
||||
### C.5 前端页 `oa/pages/rd/rdannualbudget.vue`(`<script setup>` + Element Plus,全文要点)
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, reactive, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, DataAnalysis, Download } from '@element-plus/icons-vue' // 图标必须真存在
|
||||
import { http } from '../../api/http'
|
||||
|
||||
interface RdAnnualBudget { id:number; fiscalYear:number; dept:string; amount:number; actualUsed:number; status:string /*...*/ }
|
||||
const list = ref<RdAnnualBudget[]>([])
|
||||
const loading = ref(false)
|
||||
const form = reactive<Record<string, any>>({ fiscalYear: new Date().getFullYear(), amount: 0 /*...*/ }) // query/form 用 Record<string,any>
|
||||
const money = (v: unknown) => '¥' + Number(v||0).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { fiscalYear: 2026 }
|
||||
list.value = await http.get<RdAnnualBudget[]>('/rd-annual-budgets', params) // 第2参直接是 query
|
||||
} catch { ElMessage.error('加载失败') } finally { loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
try { await http.post('/rd-annual-budgets', { ...form }); ElMessage.success('已创建'); await load() }
|
||||
catch { ElMessage.error('创建失败') }
|
||||
}
|
||||
async function del(id: number) {
|
||||
await ElMessageBox.confirm('确定删除?', '警告', { type:'warning' })
|
||||
await http.del(`/rd-annual-budgets/${id}`); await load() // 删除 http.del
|
||||
}
|
||||
onMounted(() => { load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="padding:20px">
|
||||
<el-button type="primary" :icon="Plus" @click="createVisible=true">新增</el-button>
|
||||
<el-table :data="list" :loading="loading" border size="small">
|
||||
<el-table-column prop="dept" label="部门" width="140" />
|
||||
<el-table-column label="预算金额" width="130">
|
||||
<template #default="{ row }">{{ money(row.amount) }}</template> <!-- 插槽写 #default="{ row }" -->
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status==='编制中'" size="small" type="success" @click="setStatus(row,'已批准')">批准</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-dialog v-model="createVisible" title="新增预算行" width="540px">
|
||||
<el-form :model="form" label-width="110px" size="small"> <!-- 表单项... --> </el-form>
|
||||
<template #footer> <!-- footer 是 el-dialog 直接子节点,绝不嵌 div -->
|
||||
<el-button @click="createVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
(完整 323 行在源码 `app/src/oa/pages/rd/rdannualbudget.vue`。)
|
||||
|
||||
### C.6 接入导航 `data/oaModules.ts`(页面建好后加一行,路由自动发现)
|
||||
在 `id: 'rd'` 模块的 `children` 数组里加:
|
||||
```ts
|
||||
{ key: 'rdannualbudget', label: '年度研发费用预算', kind: 'list', path: '/rd/rdannualbudget' },
|
||||
// ↑ key 对应文件名 rdannualbudget.vue kind 只能是枚举(list/board/report/...),没有 'page'
|
||||
```
|
||||
> 路由由 `import.meta.glob` 自动发现(页面在 `pages/rd/rdannualbudget.vue` + nav 这行 → 自动可访问 `/rd/rdannualbudget`,**不用改 router**)。
|
||||
|
||||
---
|
||||
|
||||
## D. 从 0 新建一个功能的标准步骤(照 C 抄)
|
||||
|
||||
1. `domain/Xxx.java`:`@Entity @Table(name="xxx")`,金额字段 `BigDecimal = BigDecimal.ZERO`,全 getter/setter。
|
||||
2. `repository/XxxRepository.java`:`extends JpaRepository<Xxx, Long>`,加派生查询(字段名精确一致,模糊用 `Containing`)。
|
||||
3. `web/XxxController.java`:`@RestController @RequestMapping("/api/oa/xxx")`,构造注入 repo,`ApiResp.ok()`,record 请求体,`Money.of` 收金额,写操作 `@Transactional`,校验/冲突抛 `ApiException(400/409,"中文")`,找不到抛 `NotFoundException`。
|
||||
4. (可选)`seed/XxxSeeder.java`:`@Component @Order(n) implements ApplicationRunner`,幂等守卫,播真实演示数据。
|
||||
5. `pages/<module>/<key>.vue`:`<script setup>` + Element Plus,`http.get/post/del`,插槽 `#default="{ row }"`,图标真存在,footer 不嵌 div。
|
||||
6. `data/oaModules.ts`:对应模块 children 加 `{ key, label, kind, path }`(kind 非 'page')。
|
||||
7. 若端点返回金额/PII:把 `/api/oa/xxx` 加进 `AuthInterceptor.SENSITIVE_READ_PREFIXES`。
|
||||
8. 构建发布:`npm run build`(8G堆) → `bootJar` → 换 8091 进程 →(若改了既有实体字段)跑 schema-sync。
|
||||
|
||||
**改代码前务必读 `go.md` 第 9 节"血泪坑大全"**(Map.of≤10 / SQLite 保留字+漏列 schema-sync / ContainingIgnoreCase / 中文引号嵌套 / Vue 插槽嵌 div / kind 没 'page' / 派生查询属性名 / OOM 等)。
|
||||
Reference in New Issue
Block a user