# -*- coding: utf-8 -*- CSS = """ @page { size:A4; margin:14mm 15mm; } *{margin:0;padding:0;box-sizing:border-box;} body{font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;color:#1d2433;font-size:12px;line-height:1.65;} .page{page-break-after:always;} .page:last-child{page-break-after:auto;} .cover{height:250mm;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;} .cover .badge{display:inline-block;background:#1d2433;color:#fff;font-size:12px;padding:5px 16px;border-radius:20px;margin-bottom:22px;} .cover h1{font-size:40px;color:#1947aa;margin-bottom:12px;} .cover .csub{font-size:16px;color:#667085;} .cover .meta{margin-top:34px;font-size:13px;color:#8a94a6;} .chap{display:inline-block;background:#eef3ff;color:#2764e7;font-weight:700;font-size:12px;padding:3px 11px;border-radius:5px;margin-bottom:7px;} h2{font-size:19px;color:#1d2433;margin-bottom:10px;border-bottom:2px solid #2764e7;padding-bottom:6px;} h3{font-size:14px;color:#1947aa;margin:14px 0 6px;} p{margin-bottom:8px;} ul{margin:0 0 8px 18px;} li{margin-bottom:3px;} code{font-family:"SF Mono",Menlo,Consolas,monospace;background:#f3f5f7;color:#c7254e;padding:1px 5px;border-radius:4px;font-size:11px;} pre{background:#1d2433;color:#e8ebf1;font-family:"SF Mono",Menlo,Consolas,monospace;font-size:10.5px;line-height:1.55;padding:12px 14px;border-radius:8px;margin:8px 0;white-space:pre-wrap;overflow-wrap:anywhere;} pre .c{color:#8a94a6;} pre .k{color:#7ec3ff;} table{width:100%;border-collapse:collapse;margin:8px 0;font-size:11px;} th,td{border:1px solid #e8ebf1;padding:6px 9px;text-align:left;vertical-align:top;} th{background:#f7f8fb;color:#1947aa;font-weight:700;} .warn{background:#fdecec;border-left:3px solid #c73535;padding:8px 12px;border-radius:0 6px 6px 0;margin:8px 0;font-size:11.5px;color:#9a2a2a;} .tip{background:#e9f6ef;border-left:3px solid #16845b;padding:8px 12px;border-radius:0 6px 6px 0;margin:8px 0;font-size:11.5px;color:#16845b;} .flow{display:flex;align-items:center;flex-wrap:wrap;gap:6px;margin:10px 0;} .fb{background:#fff;border:1.5px solid #2764e7;border-radius:8px;padding:8px 12px;font-size:11px;font-weight:600;color:#1947aa;} .fb.db{border-color:#16845b;color:#16845b;} .fb.ext{border-color:#b86b00;color:#9a5b00;} .ar{color:#2764e7;font-weight:700;font-size:16px;} .toc div{padding:6px 0;border-bottom:1px solid #eef1f5;font-size:13px;} .toc b{color:#2764e7;margin-right:10px;} .ent{display:inline-block;border:1.5px solid #2764e7;border-radius:8px;padding:8px 12px;margin:5px;font-size:11px;vertical-align:top;background:#fff;} .ent b{color:#1947aa;display:block;margin-bottom:3px;} .ent small{color:#667085;display:block;font-size:10px;} """ def page(inner): return f'
一套 ERP + OA 一体化平台。前后端打包成单个 jar,同一端口(8090)既提供 SPA 静态资源、又提供 REST API;通过 ngrok 隧道对公网演示。
| 层 | 技术 | 说明 |
|---|---|---|
| 前端 | Vue 3 + TypeScript + Vite + Element Plus 2.14.1 | vue-router createWebHistory(base '/',干净路径无 #);图标只用 @element-plus/icons-vue |
| 后端 | Spring Boot 3.2.5 + Spring Data JPA | REST 接口前缀 /api/oa/*;统一返回 ApiResp 信封 |
| 数据库 | SQLite(org.hibernate.community.dialect.SQLiteDialect) | jdbc:sqlite:./data/oa.db;ddl-auto=update 自动建表加列 |
| 实时 | WebSocket + yjs CRDT | 文档协同实时编辑(doccollab) |
| 部署 | 单 jar(gradlew bootJar) | SPA 打进 classpath:/static,与 API 同端口 |
npm run build 的产物直接输出到 oa-backend/src/main/resources/static(emptyOutDir=true),所以打 jar 时 SPA 被一起封进去——一个 jar 跑全栈。| 包 | 数量 | 职责 |
|---|---|---|
domain/ | 90 | JPA 实体(@Entity)。Contract/Payment/SealUse/Invoice/Project/Bid/FormInstance/AutomationLog/BizRecord … |
repository/ | 90 | Spring Data JpaRepository(findByXxx 派生查询) |
web/ | 98 | @RestController,REST CRUD + 业务动作 |
service/ | 8 | WorkflowService(审批引擎)、TriggerRuleEngine(自动联动)、PaymentService、AuthService、NodeAssigneeResolver、NotificationService、FullTextSearchService、MentionService |
common/ | 5 | ApiResp、ApiException、GlobalExceptionHandler、NotFoundException、PasswordUtil(PBKDF2) |
config/ | 5 | AuthInterceptor、CorsConfig、JacksonConfig、SpaWebConfig(SPA fallback)、WebSocketConfig |
data/oaModules.ts | 导航契约(顶部模块 + 子页面),路由自动生成的唯一来源 |
router/index.ts | 路由表:glob 自动发现 oa/pages/**,无文件回退 OaGenericPage;自定义路由手动加 |
oa/pages/<模块>/<页>.vue | 各业务页面(170 个) |
oa/api/ | HTTP 封装(http.ts)+ 各模块 API + settingList.ts(通用台账) |
oa/engine/ | 表单引擎(OaFormWindow/OaFormRenderer)+ 模板 |
components/erp/ | MasterDataPage、ErpDataTable、ErpPageHeader、ErpStatusTag、ErpDrawer … |
styles/tokens.css | 设计令牌 --erp-*(颜色/字号/间距/圆角) |
public record ApiResp<T>(int code, String message, T data) {
static ok(data) -> {code:0, message:"ok", data}
static error(code,msg)
}
code==0 成功,非 0 是业务错误。前端 http.ts 自动拆信封,code!=0 抛 OaApiError。
POST /api/oa/auth/login,请求体字段是 loginName/password(不是 username)oa.token,每次请求带 Authorization: Bearer <token>AuthInterceptor 守 /api/oa/**,白名单:login / session / logoutpbkdf2$120000$<saltB64>$<hashB64>CorsConfig.allowedOriginPatterns 含 "*"——隧道 Origin 才不被拦(curl 不带 Origin 永远通,浏览器才触发)ngrok-skip-browser-warning: true 头,绕过 ngrok-free 的浏览器拦截页复杂领域走专用实体,靠外键字段互链;通用台账走 BizRecord。核心互链字段:
| 关系 | 字段 |
|---|---|
| 项目 ← 合同/投标/发票/付款/用印 | 各实体的 projectId 指向 Project.id |
| 合同 ← 用印/发票/付款 | 各实体的 contractId 指向 Contract.id |
| 总包 → 分包(合同树) | Contract.parentId 自引用 |
| 商机 → 投标 → 项目 | Bid.opportunityId / Bid.projectId |
系统「会自己跑」的核心。某审批事项办结时自动写下游单据/状态。
fire() 按 category + templateName 关键词命中一条规则 → 写下游实体 + 一条 AutomationLog(数据中心可查)。
| 关键词 | 下游动作(含链式接力) |
|---|---|
| 付款/报销/请款 | 生成「待付」付款单(钱权分离,不放款) |
| 收款/到账/回款 | 生成「待开」销项发票草稿(用项目反查合同拿甲方) |
| 用印/盖章 | 用印台账置「已用印」 |
| 验收/结项/竣工 | 项目置「验收」 + 链式:自动生成项目档案 |
| 立项 | 新建项目台账 |
| 供应商/准入 | 供应商置「合格」 |
| 合同 | 合同置「已生效」 + 链式:用章中心生成待用印单 |
另有 BidController(中标→建项目+总包合同)、OpportunityController(商机→投标)、InvoiceController(开票→回写合同已开票额)。
field.id(英文 contractName)提交,规则按中文 label(合同名称)查找。enrichWithLabels() 读模板 schema 把值按中文 label 补一份键——没有这步,自动联动在真实表单上全部静默失效。fire() 加 else if (containsAny(tag, "关键词")) 分支ruleXxx() 方法(写下游 + safeLog)ruleKeyFor() 映射动作→稳定 ruleKey(幂等键,(instanceId,ruleKey) 只触发一次)49 个补齐的部室台账页(运营工艺/HR资质/财务应收/行政后勤…)共用一套行级真后端,而非各写一个实体。
domain/BizRecord | 行级实体:id + bizType(台账类型)+ dataJson(灵活字段)+ 时间戳 |
web/BizRecordController | REST:GET/POST /api/oa/biz/{type}、PUT/DELETE /biz/{type}/{id},对外扁平化为 {id, ...字段} |
前端 oa/api/settingList.ts | settingListStore(bizType, seed) → 调 /biz/{type},喂给 MasterDataPage;首次空则把 seed 播种成真记录 |
顶部导航 + 每个模块的子页面(key/label/kind/path)都在这一个文件。路由从它自动生成,加页面通常只改这里。
// router/index.ts const pages = import.meta.glob('../oa/pages/**/*.vue') // 有 oa/pages/<模块>/<key>.vue 就用它,没有则回退 OaGenericPage // 带参/不进导航的页(/orgview/dept、/collab/handle)手动加进 routes[]
传 columns / load / create / update / remove / createFields 就得到「列表 + 搜索 + 新建 + 详情抽屉 + 增删改」。绝大多数台账页就是它 + 一个 store。
tokens.css 的 --erp-* 令牌cd ofbiz-framework/plugins/modern-ui/app npm run build # = vue-tsc --noEmit && vite build # 输出到 oa-backend/src/main/resources/static(emptyOutDir)
cd oa-backend
JAVA_HOME=/Users/qiu/Desktop/ERP/.jdks/jdk-17.0.19+10/Contents/Home \\
./gradlew bootJar
# 产物:oa-backend/build/libs/oa-backend-0.1.0.jar
JAVA_HOME=/Users/qiu/Desktop/ERP/.jdks/jdk-17.0.19+10/Contents/Home \\
java -jar build/libs/oa-backend-0.1.0.jar
# http://localhost:8090 —— SPA + API 同端口
PID=$(lsof -ti tcp:8090 -sTCP:LISTEN) ps -p $PID -o command= | grep oa-backend-0.1.0.jar # 确认是本服务 kill $PID # 再 nohup java -jar ... 启动
pkill java / killall java——会杀掉机器上别的 Java 进程(如 IDE/构建)。永远只杀 8090 LISTEN 的那个 pid。| 日志 | /tmp/oa-backend.log(nohup 重定向) |
| 数据库 | oa-backend/data/oa.db(SQLite 单文件,备份直接拷贝该文件) |
| 建表/加列 | ddl-auto=update 启动时自动同步(加实体字段会自动加列,不丢数据) |
| JDK | /Users/qiu/Desktop/ERP/.jdks/jdk-17.0.19+10/Contents/Home |
| 公网隧道 | ngrok http 8090(演示用);隧道挂了重起 ngrok 即可,不用重启服务 |
| 账号 | admin / zhangwei / lina …,初始密码 123456 |
| 坑 | 对策 |
|---|---|
| vite 白屏:manualChunks 单拆 vendor-vue,Vue 与耦合库(tiptap/yjs/vuedraggable)跨块 TDZ「Cannot access 'X' before initialization」 | Vue + 所有 Vue 耦合库必须同一个 chunk。构建绿≠能跑,构建后必须浏览器验 #app 已挂载 |
| SQLite 多表启动崩溃 | 已配 hbm2ddl.jdbc_metadata_extraction_strategy=individually |
| SQLite 写争用:快速连续 PUT 会 500 | 操作间隔 / busy_timeout;批量写别并发轰 |
| 自动联动静默失效:表单 field.id 英文 vs 规则中文 label | TriggerRuleEngine.enrichWithLabels() 读模板 schema 补中文 label 键 |
| 排查方向错:只看 HTTP 状态 | 业务错误是 HTTP 200 + body code≠0,要看 body |
| 误杀进程 | 重启只杀 8090 LISTEN pid,绝不 pkill/killall java |
| 脚本批量生成页面 | cwd 要在 oa/pages(否则相对 import 失效);数组元素逗号分隔;加类型注解 :MdColumn[]/:MdField[];MdField 无 'date' 类型用 text |
| ngrok-free 拦截页 | 所有请求带 ngrok-skip-browser-warning 头 |