Appearance
滢滢的记账本 · 技术文档
| 项目 | 说明 |
|---|---|
| 文档版本 | 对应业务版本 v0.1.0 ~ v0.8.0(2026-10-26 ~ 2026-11-06) |
| 关联文档 | 版本更新记录 |
| 长图产物 | docs/images/technical-doc-long.png(全文单页)+ technical-doc-p1~p4.png(按章节分片,更适合阅读);排版源 docs/technical-doc.html,由 node docs/render-doc.mjs --split 从本文档直接生成 |
| 代码范围 | ledger-mobile(uni-app 小程序/H5)、ledger-service(Spring Boot 多模块,核心是 yudao-module-ledger)、ledger-pc(Vue3 管理后台,记账模块见第 9 章) |
| 未覆盖 | ledger-pc 仅覆盖记账相关页面与组件,后台其它模块(system/infra/bpm…)不展开 |
目录
- 系统架构
- 技术栈
- 后端设计(yudao-module-ledger)
- 移动端设计(ledger-mobile)
- 开发规范与协作
- 已知坑点与解决方案
- 部署与发布
- 附录 · 版本 ↔ 技术特性对照
- PC 端设计(ledger-pc)· 记账功能
1. 系统架构
1.1 组成
| 层 | 载体 | 说明 |
|---|---|---|
| 客户端 | 微信小程序 / H5(ledger-mobile) | 记账本主入口,tabbar 四页 + ledger 子页面 |
| 反向代理 | nginx(ledger-mobile/conf.nginx) | 静态站点 + /admin-api 代理 + PC 站点配置块 |
| 服务端 | yudao-server 聚合启动,业务在 yudao-module-ledger | 统一 /admin-api 前缀,端口默认 48080 |
| 依赖模块 | yudao-module-system、yudao-framework 各 starter | 用户/部门/角色/权限、安全、多租户、MyBatis |
| 存储 | MySQL(业务表)+ Redis(token/缓存)+ 文件服务(附件) | 附件以 URL 逗号串存字段,不走对象关联表 |
1.2 请求链路
uni.request → http/interceptor.ts(拼 baseUrl、注入 Authorization / tenant-id、可选 API 加密)
→ nginx(/admin-api 透传)
→ yudao-server(TenantContext、SecurityContext 过滤)
→ LedgerController → LedgerServiceImpl → LedgerDataScopeHelper(判定角色数据范围)
→ LedgerMapper(LambdaQueryWrapperX + applyDataScope)→ MySQL
响应:CommonResult{code,data,msg} → http.ts(解密 → 业务码判定 → 401 无感刷新 → resolve(data))1.3 关键设计取向
- 单库单表 + 应用层数据范围:不启用 yudao 通用数据权限注解,
ledger模块自研LedgerDataScope值对象,在 Mapper 与 Service 两侧各判一次(查询条件 + 单条校验),保证「列表看不到」与「按 id 直接访问也看不到」一致。 - 店铺资源与账单资源分轨:客户/商品是部门共享资源,账单是按人隔离的业务数据,两套判定方法分开(
isVisible/isDataWritablevsisLedgerVisible/isLedgerWritable)。 - 明细快照:加购商品落
yudao_ledger_goods_item快照行,商品改名/删除不影响历史账与统计。 - 统计走内存聚合:数据量级小(单店),一次区间查询后在 Java 侧按维度分组,避免复杂 SQL 与数据库方言差异。
2. 技术栈
2.1 后端
| 项 | 值 |
|---|---|
| 框架 | Spring Boot(javax 命名空间,JDK 8 语法体系)+ yudao 脚手架 |
| 模块 | yudao-module-ledger,packaging=jar,parent yudao,版本 ${revision} |
| 依赖 starter | yudao-spring-boot-starter-biz-tenant、-security、-mybatis、spring-boot-starter-validation、yudao-module-system、yudao-spring-boot-starter-test |
| ORM | MyBatis-Plus(BaseMapperX / LambdaQueryWrapperX),DO 继承 TenantBaseDO |
| 文档 | Swagger v3 注解(@Tag/@Operation/@Schema),Knife4j 由 server 提供 |
| 工具 | Hutool(StrUtil/RandomUtil)、Lombok、BeanUtils.toBean |
| 事务 | @Transactional(rollbackFor = Exception.class) 用于记账增删改(涉及明细与库存) |
2.2 移动端
| 项 | 值 |
|---|---|
| 模板 | unibest 4.1.0(yudao-version: 2026.08.0-snapshot),pnpm@10.10.0,Node ≥ 20 |
| 框架 | uni-app 3.0.0-4070620250821001 + Vue 3.4 + TypeScript 5.8 + Vite 5.2.8 |
| UI | @wot-ui/ui ^2.2.0(Wot Design Uni)、UnoCSS 66(preset-uni,图标 @iconify-json/carbon) |
| 状态 | pinia 2.0.36 + pinia-plugin-persistedstate |
| 图表 | echarts ^5.6.0(统计页);z-paging 2.8.8(分页能力,ledger 列表当前为自实现滚动) |
| 其他 | dayjs、qrcode(生成商品码)、crypto-js/jsencrypt(API 加解密)、miniprogram-ci(上传) |
| 关键插件 | @uni-helper/vite-plugin-uni-pages(路由生成)、-layouts、-manifest、-components(本地组件自动导入)、@uni-ku/bundle-optimizer(微信小程序分包优化)、@uni-ku/root |
3. 后端设计(yudao-module-ledger)
3.1 包结构
包根:cn.iocoder.yudao.module.ledger
| 路径 | 文件 | 职责 |
|---|---|---|
controller.admin.ledger | LedgerController | 记账 CRUD、列表、归属人候选、年度/日/排行榜统计(/ledger) |
CustomerController | 客户 CRUD 与列表(/ledger/customer) | |
GoodsController | 商品 CRUD、列表、按二维码编号取商品(/ledger/goods) | |
controller.admin.ledger.vo | LedgerSaveReqVO / LedgerListReqVO / LedgerRespVO | 记账入参、查询、返回 |
LedgerGoodsSaveReqVO / LedgerGoodsRespVO | 加购明细行 | |
LedgerYearStatRespVO / LedgerMonthStatRespVO / LedgerDayStatRespVO | 年度 → 月度 → 日度统计 | |
LedgerRankingReqVO / LedgerRankingRespVO | 排行榜三维度 | |
LedgerOwnerRespVO | 归属人员候选(id + name) | |
Customer* / Goods* VO | 客户、商品入参出参 | |
service.ledger | LedgerService / LedgerServiceImpl | 记账主逻辑、统计、排行、库存联动、归属人校验 |
CustomerService(Impl) | 客户逻辑 + 删除关联校验 | |
GoodsService(Impl) | 商品逻辑 + 库存原子扣减/回滚 + 二维码编号生成 | |
dal.dataobject.ledger | LedgerDO / CustomerDO / GoodsDO / LedgerGoodsDO | 四张表映射 |
dal.mysql.ledger | LedgerMapper / CustomerMapper / GoodsMapper / LedgerGoodsMapper | 查询封装,含 applyDataScope |
framework.datapermission | LedgerDataScope / LedgerDataScopeHelper | 数据范围值对象 + 角色解析 |
enums | ErrorCodeConstants | 1-020-xxx-xxx 错误码 |
3.2 数据模型
表名与主键序列(@KeySequence 兼容 Oracle/PG 等):
| DO | 表名 | 说明 |
|---|---|---|
LedgerDO | yudao_ledger | 记账主表 |
LedgerGoodsDO | yudao_ledger_goods_item | 记账商品明细(快照) |
CustomerDO | yudao_ledger_customer | 客户 |
GoodsDO | yudao_ledger_goods | 商品 |
所有 DO 继承 TenantBaseDO,自动带 tenant_id、create_time、last_update_time、creator、updater、deleted 字段;业务侧另存显式的 creatorId/updaterId/deptId 用于数据范围判定(不依赖框架 creator 字符串)。
关系
yudao_ledger 1 ── N yudao_ledger_goods_item (ledger_id)
yudao_ledger N ── 1 yudao_ledger_customer (customer_id,可空)
yudao_ledger_goods_item.goods_id → yudao_ledger_goods.id(仅快照,删除不级联)
yudao_ledger.owner_id / creator_id / dept_id → system 用户与部门(跨模块,无外键)LedgerDO 字段
| 字段 | 类型 | 约束/说明 |
|---|---|---|
id | Long | 主键 |
amount | BigDecimal | 金额,@DecimalMin("0.01") |
name | String | 名称,列 NOT NULL,可空串(≤64),空时前端显示「未命名」 |
remark | String | 备注 |
type | Integer | 1=支出(TYPE_EXPENSE),2=收入(TYPE_INCOME) |
recordDate | LocalDate | 记账日期,不传默认当天;列表按它倒序 |
customerId | Long | 关联客户,null 表示未关联;updateStrategy = FieldStrategy.ALWAYS 允许清空 |
mediaUrls | String | 附件地址逗号串,≤2000;出参统一转 List<String>,空时返回 [] |
deptId | Long | 记账人所在部门,来自 SecurityFrameworkUtils.getLoginUserDeptId() |
ownerId | Long | 归属人员用户编号,新增默认当前登录人 |
creatorId / updaterId | Long | 创建/更新人编号 |
LedgerGoodsDO 字段:ledgerId、goodsId(可为 0 表示商品已删)、goodsName、goodsImage(首图快照)、price、quantity、amount(= price × quantity,服务端重算,HALF_UP 保留 2 位)。
CustomerDO 字段:name、phone、wechatName、gender(1 男 / 2 女,常量 GENDER_MALE/GENDER_FEMALE)、source、customerLevel(VIP/HIGH/NORMAL/POTENTIAL)、birthday(LocalDate)、avatar、remark、mediaUrls、deptId、creatorId/updaterId。
GoodsDO 字段:name、code(二维码编号,同时是二维码内容)、images(逗号串,出参转数组)、intro、detailHtml(富文本)、price、quantity(库存)、remark、deptId、creatorId/updaterId。
3.3 API 清单
统一返回 CommonResult<T>;除创建返回 Long id 外,写操作返回 Boolean。
| 方法 | 路径 | 入参 | 出参 | 说明 |
|---|---|---|---|---|
| POST | /ledger/create | LedgerSaveReqVO | Long | 创建记账(含明细与库存扣减) |
| PUT | /ledger/update | LedgerSaveReqVO(含 id) | Boolean | 更新记账(明细覆盖式) |
| DELETE | /ledger/delete?id= | id | Boolean | 删除记账并回滚库存 |
| GET | /ledger/get?id= | id | LedgerRespVO | 详情,越权时返回 null |
| GET | /ledger/list | LedgerListReqVO | List<LedgerRespVO> | 列表:name(like)、type、customerId、goodsId、startTime、endTime;按 recordDate、createTime 倒序 |
| GET | /ledger/owner-list | — | List<LedgerOwnerRespVO> | 归属人员候选(本部门启用用户 + 本人兜底) |
| GET | /ledger/statistics/year?year= | year | LedgerYearStatRespVO | 年度 + 12 个月收支 |
| GET | /ledger/statistics/days?year=&month= | year、month(1-12) | List<LedgerDayStatRespVO> | 当月每日收支 |
| GET | /ledger/statistics/ranking | LedgerRankingReqVO | List<LedgerRankingRespVO> | 排行榜 |
| POST/PUT/DELETE/GET | `/ledger/customer/create | update | delete | get |
| POST/PUT/DELETE/GET | `/ledger/goods/create | update | delete | get |
| GET | /ledger/goods/get-by-code?code= | code | GoodsRespVO | 扫码加购入口,越权/不存在返回 null |
LedgerSaveReqVO 语义约定(易错点):
| 字段 | 不传(null) | 传空数组/空值 |
|---|---|---|
name | 新增存空串;更新保持原值 | 传空串可清空 |
customerId | 归一化为 null(0/负数同样视为未关联) | — |
ownerId | 新增默认当前登录人;更新时保持原值,不跟随编辑人漂移 | — |
goodsItems | 更新时保持原明细不变 | 传 [] 表示清空明细 |
recordDate | 新增默认当天 | — |
3.4 数据范围与权限
角色解析(LedgerDataScopeHelper):优先角色名称,编码兜底,两者任一命中即认定。
| 角色 | 名称 | 编码 | RoleType |
|---|---|---|---|
| 店长 | 记账本店长 | ledger_manager | MANAGER |
| 员工 | 记账本员工 | ledger_staff | STAFF |
| 其他 | — | — | NORMAL(仅本人数据,功能不受限) |
LedgerDataScope.of(userId, deptId, roleType) 生成范围对象,字段:userId、deptId、ownerUserId、minRecordDate、roleType。
判定矩阵(v0.8.0 现行口径)
| 维度 | MANAGER(店长) | STAFF(员工) | NORMAL |
|---|---|---|---|
| 账单可见 | 本部门全部(dept_id),不限日期 | 仅归属自己(owner_id),不限日期 | 仅自己创建(creator_id) |
| 账单可维护 | 部门内他人账单可纠错/删除 | 归属自己 或 自己创建(替他人记的账也能改) | 仅自己创建 |
| 客户/商品 | 本部门共享 | 本部门共享 | 仅本人 |
| 统计(年/日/排行) | 全部门数据 | 可用,且只汇总归属自己的账单 | 本人数据 |
| 记录日期写入 | 不限 | 仅近 7 天(STAFF_KEEP_DAYS = 7) | 不限 |
| 页面水印 | 可关 | 强制开启 | 可关 |
SQL 侧过滤(LedgerMapper.applyDataScope,必须与 isLedgerVisible 保持一致):
java
if (scope.isLedgerOwnerScope()) query.eq(LedgerDO::getOwnerId, scope.getOwnerUserId());
else if (scope.isLedgerDeptScope()) query.eq(LedgerDO::getDeptId, scope.getDeptId());
else query.eq(LedgerDO::getCreatorId, scope.getUserId());删除客户前的关联计数(selectCountByCustomerId)刻意不复用账单口径,而按「部门优先,否则本人」统计:客户是店铺公共资源,员工替他人记的账也要能阻止误删。
越权即「不存在」:validateLedgerWritable / validateGoodsWritable 等校验失败统一抛 *_NOT_EXISTS,不暴露他人数据存在性。
3.5 记账写入链路
createLedger: 归一化(name/customerId) → recordDate 默认今天 → isRecordDateAllowed 校验
→ 写 creator/updater/creatorId/updaterId/deptId → resolveOwnerId(默认本人,传了则 validateOwnerId)
→ buildLedgerGoodsList(服务端重算小计、按 goodsId 回填缺失名称)
→ deductGoodsStock(原子 UPDATE,影响行数 0 即库存不足 → 整单失败)
→ insert 主表 → 批量 insert 明细
updateLedger: validateLedgerWritable → 日期校验 → BeanUtils 覆盖 → customerId 归一化
→ ownerId 传了才校验 → 明细:先 restoreGoodsStock(旧) 再 deductGoodsStock(新)
→ updateById → deleteByLedgerId + 重新 insert 明细
deleteLedger: validateLedgerWritable → restoreGoodsStock(明细汇总) → 删主表 → 删明细库存:GoodsMapper.deductStock/restoreStock 为条件更新(quantity >= ?),扣减失败即抛 GOODS_STOCK_NOT_ENOUGH;已删除或不在数据范围内的商品不回扣(getGoodsNameMap 过滤),保证历史账仍可保存。
归属人员校验(validateOwnerId):用户必须存在;双方部门都非空且不一致时抛 LEDGER_OWNER_NOT_IN_DEPT(任一方无部门则跳过,避免历史数据不可编辑)。
候选列表(getOwnerList):adminUserApi.getUserListByDeptIds → 过滤 CommonStatusEnum.ENABLE → LinkedHashMap 去重保序 → 本人不在列表时补一条,保证默认值可选中;展示名昵称优先,空则退化为编号。
3.6 统计与排行
| 接口 | 数据获取 | 聚合 |
|---|---|---|
| 年度 | selectListByRecordDateBetween(1-1, 12-31) | 按 recordDate.getMonthValue() 分 12 桶,sumAmount(type) 分别算收入/支出,balance = income - expense,附 count |
| 每日 | 同上,按 YearMonth 区间 | 按每一天精确匹配(含无账日,返回 0 值占位) |
| 排行榜 | 同上 + 仅保留 type = TYPE_INCOME | 见下 |
排行榜三维度(LedgerRankingReqVO.DIMENSION_*,@Pattern(regexp = "customer|goods|owner"),limit 默认 10、范围 1-50):
| 维度 | 分组键 | 名称/图标来源 | 空值处理 |
|---|---|---|---|
customer | customerId(>0 才参与) | CustomerDO.name/avatar | 客户已删 → 未知客户,id 保留原值 |
goods | 明细表 goodsId(null 归 0) | 最近一次加购的 goodsName/goodsImage 快照 | 无名 → 未知商品;quantity 汇总销量,count 为去重后的记账笔数 |
owner | resolveRankingOwnerId:ownerId 优先,回退 creatorId | AdminUserApi.getUserMap 批量取 nickname/avatar | 用户已删/无昵称 → 未知人员 |
排序统一 sortAndLimit:金额倒序 → 笔数倒序 → id 升序(稳定),再 limit。明细金额缺失时按 price × quantity 回退(goodsAmount)。
跨模块调用一律批量取(getUserMap、getCustomerMap、getLedgerGoodsMap)以避免 N+1。
3.7 错误码(1-020 段)
| 编号 | 常量 | 文案 |
|---|---|---|
| 1_020_000_000 | LEDGER_NOT_EXISTS | 记账记录不存在 |
| 1_020_000_001 | —(已废弃,编号保留不复用) | 曾用于「员工禁用统计」 |
| 1_020_000_002 | LEDGER_RECORD_DATE_FORBIDDEN | 员工只能记录近 7 天内的账单 |
| 1_020_000_003 | LEDGER_OWNER_NOT_EXISTS | 归属人员不存在 |
| 1_020_000_004 | LEDGER_OWNER_NOT_IN_DEPT | 归属人员必须是本部门成员 |
| 1_020_001_000 | CUSTOMER_NOT_EXISTS | 客户不存在 |
| 1_020_001_001 | CUSTOMER_EXISTS_LEDGER | 该客户已关联 {} 条记账,请先删除相关记账 |
| 1_020_002_000 | GOODS_NOT_EXISTS | 商品不存在 |
| 1_020_002_001 | GOODS_STOCK_NOT_ENOUGH | 商品【{}】库存不足,本次需要 {} 件 |
| 1_020_002_002 | GOODS_EXISTS_LEDGER | 该商品已关联 {} 条记账,请先删除相关记账 |
| 1_020_002_003 | GOODS_CODE_EXISTS | 商品编号【{}】已被其它商品使用 |
3.8 商品二维码编号
- 生成规则:
"G" + Long.toString(System.currentTimeMillis(), 36).toUpperCase() + RandomUtil.randomStringUpper(4),最多重试 5 次,仍冲突则抛GOODS_CODE_EXISTS。 - 唯一性范围:按数据范围判定(部门内唯一,无部门时用户内唯一),复用
GoodsMapper.selectByCode(code, scope)。 - 修改场景:
code不传时优先沿用原编号,原编号也为空才自动生成(历史数据首次保存补齐)。 - 移动端
parseGoodsCode支持直接扫原始编号或包含code=的链接。
4. 移动端设计(ledger-mobile)
4.1 工程与构建
| 项 | 值 |
|---|---|
| 路由生成 | @uni-helper/vite-plugin-uni-pages + 页面内 definePage({...}),产物 src/pages.json;dts: src/types/uni-pages.d.ts |
| 分包 | subPackages: ['src/pages-core', 'src/pages-system', 'src/pages-infra'](记账页在主包) |
| easycom | yd-* → @/components/yudao-ui/yd-*/yd-*.vue;wd-* → @wot-ui/ui;fg-*、z-paging* |
| 自动导入 | AutoImport(imports: ['vue','uni-app'], dirs: ['src/hooks']),unplugin-auto-import 生成 src/types/auto-import.d.ts |
| 分包优化 | @uni-ku/bundle-optimizer 仅在 UNI_PLATFORM === 'mp-weixin' 启用 |
| 全局根组件 | @uni-ku/root(App.ku.vue 注入全局层) |
| env 目录 | envDir: './env',按 mode 加载 .env + .env.development/.test/.production |
| 别名 | @ → ./src,@img → ./src/static/images |
| 自定义插件 | vite-plugins/copy-native-resources.ts(原生插件资源复制,App 端)、sync-manifest-plugins.ts(manifest 插件同步)、scripts/vite-plugin-eruda(H5 调试面板)、scripts/open-dev-tools(自动开微信开发者工具,SKIP_OPEN_DEVTOOLS=true 跳过) |
常用命令:
| 场景 | 命令 |
|---|---|
| 微信小程序开发 | npm run dev:mp-weixin(等价 uni -p mp-weixin,会自动打开开发者工具) |
| 微信小程序构建 | npm run build:mp-weixin → dist/build/mp-weixin |
| H5 开发 / 构建 | npm run dev:h5 / npm run build:h5(生产 build:h5:prod) |
| 类型检查 | npm run type-check(vue-tsc --noEmit) |
| Lint | npm run lint / npm run lint:fix(husky + lint-staged 提交前自动 fix) |
| 上传体验版 | npm run upload:mp-weixin(miniprogram-ci) |
环境要点(env/):VITE_APP_TITLE='滢滢的记账本'、VITE_WX_APPID、VITE_UNI_APPID、VITE_SERVER_BASEURL(dev/prod 均指向 https://qingheyun.top/admin-api,本地调试可切 http://localhost:48080/admin-api)、VITE_UPLOAD_TYPE=server、VITE_APP_TENANT_ENABLE=true、VITE_APP_CAPTCHA_ENABLE=false、VITE_AUTH_MODE=double、VITE_APP_API_ENCRYPT_ENABLE=true(AES,密钥见 env 文件,勿写入文档/仓库外传)。
4.2 页面地图
tabbar(src/tabbar/config.ts,策略 CUSTOM_TABBAR_WITH_CACHE,缓存式自定义 tabbar):
| tab | 页面 | 图标 |
|---|---|---|
| 记账本 | pages/ledger/index/index | i-carbon-wallet |
| 统计 | pages/ledger/statistics/index | i-carbon-chart-column |
| 搜索 | pages/ledger/search/index | i-carbon-search |
| 我的 | pages/user/index | i-carbon-user |
v0.8.0 起统计/搜索不再按角色隐藏(
hiddenRoles已移除);tabbar/store.ts的isTabbarItemVisible仍保留roles/hiddenRoles能力备用。
ledger 业务页(15 个 .vue):
| 页面 | 路径 | 职责与要点 |
|---|---|---|
| 记账首页 | pages/ledger/index/index.vue | 日期切换 + 当日账单列表;员工提示条;下拉刷新与定时器静默刷新 |
| 记账编辑 | pages/ledger/edit/index.vue | 金额/类型/名称/备注/日期/客户/归属人/加购/附件;底部删除;日期下限 = 员工近 7 天 |
| 统计 | pages/ledger/statistics/index.vue | 三视图:每日明细 / 年度汇总 / 排行榜(客户·商品·业绩 × 月度·年度) |
| 搜索 | pages/ledger/search/index.vue | 名称/客户/商品多维搜索 + 热词,左滑删除 |
| 小票 | pages/ledger/receipt/index.vue | 购物小票排版与图片导出 |
| 视频页 | pages/ledger/video/index.vue | 附件视频播放 |
| 分组列表 | pages/ledger/components/ledger-group-list.vue | 客户/商品分组列表复用组件 |
| 客户 | customer/index.vue、customer/edit、customer/detail、customer/statistics | 列表(含选择模式)、编辑、详情、按客户记账统计 |
| 商品 | goods/index.vue、goods/edit、goods/detail、goods/statistics | 列表/加购模式合并、编辑(含二维码)、详情、按商品记账统计 |
4.3 API 层与类型契约
src/api/ledger.ts(MOCK_ENABLED = false,本地 mock 实现保留但停用):
| 前端函数 | 后端路径 |
|---|---|
getLedgerList(query) | GET /ledger/list |
getLedger(id) | GET /ledger/get?id= |
createLedger(data) / updateLedger(data) | POST /ledger/create / PUT /ledger/update |
deleteLedger(id) | DELETE /ledger/delete?id= |
getLedgerYearStatistics(year) | GET /ledger/statistics/year |
getLedgerMonthDaysStatistics(year, month) | GET /ledger/statistics/days |
getLedgerRanking(query) | GET /ledger/statistics/ranking |
getLedgerOwnerList() | GET /ledger/owner-list |
src/api/customer.ts → /ledger/customer/*,src/api/goods.ts → /ledger/goods/*(含 getGoodsByCode)。
类型定义集中在 src/api/types/ledger.ts:LedgerVO(含 customerName/customerAvatar/ownerName/goodsItems/mediaUrls: string[])、LedgerSaveReqVO(mediaUrls 为逗号串)、LedgerGoodsItemVO / LedgerGoodsSaveReqVO、LedgerOwnerVO、Ledger{Year,Month,Day}StatVO、LedgerRankingDimension = 'customer' | 'goods' | 'owner'、LedgerRankingVO、CustomerVO / GoodsVO 及各自 Save/List 类型。
契约差异点:入参
mediaUrls是逗号分隔字符串,出参是string[](后端buildMediaUrls过滤空项,空时返回[]而非null);images同理。
4.4 网络与鉴权
src/http/interceptor.ts:query 序列化 → 拼baseUrl(H5 可走代理前缀)→timeout 60s→Authorization: Bearer <token>(白名单/login、/refresh-token、/system/tenant/get-id-by-name不带 token)→tenant-id(当前固定 1,visit-tenant-id按需)→ 可选请求体 AES 加密并打X-Api-Encrypt头。src/http/http.ts:响应可选解密 →code非 0/200 时reject并 toast 后端msg(hideErrorToast可关)→ 401 走双 token 无感刷新(taskQueue挂起并发请求,刷新成功后replayTaskQueue重放;重试仍 401 则不再循环)→ 登录失效统一handleLoginExpired(清登录态 + 带redirect跳登录页)。- 文件上传:
src/hooks/useUpload.ts+src/utils/uploadFile.ts,走${getEnvBaseUrl()}/upload,微信端uni.chooseMedia、其他端uni.chooseImage,默认maxSize 5MB。
4.5 状态管理(src/store)
| store | 关键状态 | 用途 |
|---|---|---|
user.ts | userInfo(含 userId、nickname)、roles(编码)、roleNames(名称) | 角色判定与水印昵称;roleNames 来自 get-permission-info |
token.ts | 单/双 token、有效期、refreshToken、logout | 鉴权与无感刷新 |
watermark.ts | enabled(默认 false,持久化) | 页面水印开关,员工侧被强制视为开启 |
dict.ts / theme.ts / index.ts | 字典、主题、聚合导出 | 通用能力 |
4.6 角色与前端权限
src/utils/ledger-role.ts → useLedgerRole():
- 常量:
MANAGER_ROLE_NAMES=['记账本店长']、MANAGER_ROLE_CODES=['ledger_manager']、STAFF_ROLE_NAMES=['记账本员工']、STAFF_ROLE_CODES=['ledger_staff']、STAFF_KEEP_DAYS=7(与后端同名常量对齐)。 - 派生:
roleSet = new Set([...roles, ...roleNames]);isManager、isStaff(店长优先,员工需非店长)、earliestDateStr(员工为「今天 - 6 天」,其他角色空串表示不限)。 - 用法:编辑页日期选择器下限、首页员工提示条、归属他人 toast、App 水印强制开启。不再有
canUseStatistics/canUseSearch(v0.8.0 移除,权限改由后端数据范围收敛)。
4.7 公共组件(src/components/yudao-ui)
| 组件 | 用途 |
|---|---|
yd-ledger-card | 记账卡片(列表/搜索/统计/客户/商品页复用);props:item、showCustomer=true、showOwner=true、showDate=false、customerClickable=false、goodsClickable=false;emits:click、customerClick、goodsClick(后两者 stop 冒泡,避免与整卡跳编辑冲突) |
yd-ledger-media | 附件图片/视频网格与预览 |
yd-watermark | 水印层(#ifndef H5 渲染,2 列 × 4 行稀疏铺满;H5 由 App.vue 注入全局 DOM 层) |
yd-qrcode | 商品二维码生成展示 |
yd-upload / yd-upload-img / yd-upload-imgs / yd-upload-file | 上传能力族 |
yd-form-picker、yd-search-picker、yd-search-date-range、yd-tree-select | 表单/筛选通用控件 |
4.8 关键交互实现
| 能力 | 实现要点 |
|---|---|
| 扫码加购 | pages/ledger/goods/code.ts 的 scanGoodsCode() → uni.scanCode({ scanType: ['qrCode'] }) → parseGoodsCode 提取编号 → GET /ledger/goods/get-by-code → 命中即带入加购卡片 |
| 小票导出 | 纯函数排版(ReceiptLayout 指令数组)→ uni.createCanvasContext 绘制到隐藏 canvas → sleep(150) 等落盘 → uni.canvasToTempFilePath → 保存相册;导出容器宽度受控,避免撑宽页面 |
| 商品/客户统计页 | 复用 GET /ledger/list?customerId= / ?goodsId=,前端按日/月聚合,口径与后端数据范围一致 |
| 排行榜 | 维度分段(客户/商品/业绩)× 周期分段(月度/年度);owner 维度不可跳转、空态文案单独处理、兜底图标按维度切换 |
| 左滑操作 | 列表页统一 wd-swipe-action,onShow 重置滑动状态。现状:记账首页已无左滑(点卡进编辑);客户/商品列表只保留「统计」;搜索页仍保留左滑「编辑/删除」(历史实现,尚未按 5.3 约定收敛) |
| 日期选择 | wd-datetime-picker 显式设置 minDate;员工下限取 earliestDateStr,首页浏览下限为常量 2000-01-01 |
| 静默刷新 | keep-alive 页面定时器管理,跨零点与后台降频场景需重置日期缓存 |
5. 开发规范与协作
5.1 仓库与提交
| 项 | 约定 |
|---|---|
| 仓库划分 | ledger-mobile、ledger-service、ledger-pc 三个独立 git 仓库(工作区根目录本身不是仓库),跨端需求要分别在两个仓库提交 |
| 提交格式 | Conventional Commits,@commitlint/config-conventional(.commitlintrc.cjs);.trae/rules/git-commit-message.md 给定范式示例 feat: 新增记账日期字段并支持按日期筛选排序,标题用中文描述具体业务点 |
| 版本 tag | 目前未打 tag,业务版本号(v0.1.0 ~ v0.8.0)是按提交日期 + 主题聚类归因出来的,详见 release-notes.md;package.json 的 4.1.0 是 unibest 模板版本,yudao-version: 2026.08.0-snapshot 才是上传小程序时用的版本号 |
| 钩子 | husky + lint-staged(配置见 package.json,对 js/ts/vue/json/css/html/yaml 等跑 eslint --fix)。仓库内只提交了 .husky/_,钩子脚本需本机执行 npm run init-husky(或 pnpm prepare)生成,未初始化时提交不会自动 lint |
5.2 前端代码风格(ESLint 为准,不要手工对抗格式)
eslint.config.mjs 基于 @uni-helper/eslint-config(antfu 风格),项目级关键取舍:
| 规则 | 值 | 原因 |
|---|---|---|
vue/block-order | [['script', 'template'], 'style'] | SFC 默认 script 在前(芋道约定) |
brace-style | 1tbs + allowSingleLine | else/catch/} 不换行 |
perfectionist/sort-imports | off | uni 条件编译注释会包裹 import,自动排序会破坏平台条件边界 |
no-console / no-unused-vars | off | 小程序端调试依赖 console |
import/no-duplicates | prefer-inline: false | 类型与值允许分开 import |
ignores | src/pages.json、src/manifest.json、auto-import.d.ts、uni-pages.d.ts、dist、uni_modules、src/service/** | 均为插件产物,来回变动不维护 |
编码约定:
- 目录即模块:
src/api/{ledger,customer,goods}.ts+ 类型集中src/api/types/ledger.ts;页面按pages/ledger/<业务>/{index,edit,detail,statistics}.vue排布。 - 组件按需走 easycom 前缀(
yd-*业务组件、wd-*Wot UI、fg-*框架组件),不手写全局注册。 - 公共组件新增字段时必须提供可配置 prop(默认值保持旧行为),不要在组件里写死页面差异;事件是否可点用显式 boolean prop 声明(见 6.3)。
- 精确定位「谁改了什么」的能力,一律走
ownerId/creatorId双字段而不是单字段,见 3.4。
5.3 交互与 UI 约定(已固化,新页面照此办理)
| 约定 | 内容 |
|---|---|
| 操作入口分工 | 列表卡片不提供左滑「编辑/删除」:点整卡进编辑页,编辑与删除都在编辑页底部固定栏完成。记账首页已整体移除 wd-swipe-action;客户/商品列表左滑只保留「统计」这一个纯跳转入口。遗留:搜索页左滑仍有「编辑/删除」,属待收敛项 |
| navbar 右侧 | 微信小程序自定义导航栏右侧被系统胶囊按钮占据,操作按钮不放 navbar 右槽,统一落底部栏 |
| 删除交互 | uni.showModal 二次确认(confirmColor: '#f43f5e'),确认文案带上后端关联校验约束;成功 toast 后 setTimeout 500ms 再 navigateBack,靠上一页 onShow 刷新;saving || deleting 互斥守卫 |
| 选择模式 | ?mode=select 时卡片点击仍归编辑页,选中动作由卡片上的独立控件承载并 @click.stop |
| 按钮样式 | 破坏性操作用浅红底胶囊:底 #fef2f2、边框 #fecaca、字 #f43f5e,flex-shrink: 0 且高度略低于主按钮 |
| 附件展示 | 统一用 yd-ledger-media:按扩展名区分图片(预览)/视频(播放入口),未知类型给提示,数据为空时组件整体不渲染不占位 |
| 页面风格 | 选择/列表类页面与客户列表页对齐(yd-page-container + wd-navbar + 搜索栏 + scroll-view 卡片),主题色 #0e9c86 |
| 提示条 | 涉及口径差异的地方给显式浅色提示,如统计页员工横幅「员工账号仅统计归属自己的账单」 |
| 日期选择器 | 必须显式设置 minDate;员工下限来自 earliestDateStr,不要写死 7 天 |
5.4 后端代码规范
- Controller 统一
@Tag(name = "管理后台 - XXX")+@Operation,返回CommonResult<T>(success(...)静态导入),入参@Valid。ledger 模块接口不加@PreAuthorize:单店场景菜单权限由系统角色控制,数据可见性完全由LedgerDataScope兜底,二者职责分离。 /admin-api前缀不是手写的,由WebProperties.adminApi = new Api("/admin-api", "**.controller.admin.**")按包名自动施加,因此新 Controller 必须放在controller.admin包下。- DTO/DO 转换只用
BeanUtils.toBean;出参组装(如客户名/头像回填、mediaUrls拆数组)放在 Controller,Service 不感知 VO。 - 枚举型语义常量集中定义(
TYPE_EXPENSE = 1、TYPE_INCOME = 2、GENDER_MALE/FEMALE、DIMENSION_*),禁止散落魔法数字。 - 跨模块只走
Api接口(AdminUserApi、PermissionCommonApi)且必须批量取,禁止循环单查;不允许直连 system 表。 - 涉及明细/库存的写操作加
@Transactional(rollbackFor = Exception.class)。 - 错误码按
1_020_模块_序号分段,废弃编号保留不复用(以注释形式标注,见 3.7)。 - 数据范围两处判定(
LedgerMapper.applyDataScope与LedgerDataScope.isLedgerVisible)是同一条规则的两份实现,改一处必须同步改另一处,并在注释里互相指认。
5.5 改动自检四链(每次交付前跑)
powershell
# 1. 后端编译
cd ledger-service; mvn -o -q -pl yudao-module-ledger -am -DskipTests compile
# 2. 前端定向 lint
cd ledger-mobile; npx eslint src/pages/ledger/statistics/index.vue src/utils/ledger-role.ts
# 3. 前端类型检查
cd ledger-mobile; npx vue-tsc --noEmit -p tsconfig.json
# 4. 微信小程序构建
cd ledger-mobile; npm run build:mp-weixin基线已知问题:vue-tsc 恒报 src/pages-core/auth/register.vue:114:49 - Cannot find name 'TenantPicker'(与 ledger 无关,判定是否新引入时以此为准)。
6. 已知坑点与解决方案
以下均为实际踩过的坑,按「现象 → 根因 → 处理」记录,改动相关模块前先对照。
6.1 小程序端 scroll-view 加横向 padding 导致内容右侧被裁
- 现象:页面内容整体右移,右对齐元素(金额等)被屏幕边裁掉;H5 正常,只在微信小程序复现。
- 根因:UnoCSS 的
*{box-sizing:border-box}preflight 在 WXSS 中不生效,view/scroll-view默认content-box,横向 padding 加在宽度之外 → 实际宽度 = 视口宽 + 左右 padding。 - 处理:① 横向内边距不写在
scroll-view上,改为内层套一个普通块级容器(.list-inner { box-sizing: border-box; padding: 8rpx 24rpx });② 带横向 padding 的 flex 子项(汇总条、底部栏)显式声明box-sizing: border-box;③ 卡片加overflow-hidden,防止 nowrap 子元素撑宽。
6.2 inset box-shadow 描边被 wd-swipe-action 裁角
- 现象:卡片选中高亮边框四角断开、某一边不闭合。
- 根因:结构是「外壳(
overflow:hidden+ 圆角)→wd-swipe-action(自带overflow:hidden且无圆角)→ 卡片本体」,inset 阴影会被中间那层直角容器切掉四角。 - 处理:描边放到最外层带圆角的壳上做真
border(常驻border: 2rpx solid transparent,选中只改border-color,配box-sizing: border-box防尺寸跳动),卡片本体只负责淡色背景;卡片圆角 = 外壳圆角 − 边框宽度。
6.3 getCurrentInstance().vnode.props 探测父组件事件监听在微信小程序恒为 false
- 现象:卡片里某些区域「点了没反应」,H5 正常。
- 根因:用
vnode.props.onXxxClick自动探测父页面是否监听事件,是 Vue H5 runtime 的实现细节;编译到 mp-weixin 后 props 结构/命名不一致,守卫直接把处理函数 return 掉。 - 处理:改为父页面显式传 boolean prop(
customerClickable/goodsClickable,默认 false),组件内用computed作守卫与样式开关,同时保留@click.stop。
6.4 @uni-ku/bundle-optimizer 引发 ENOENT ... index-vendor.js
- 现象:开发模式微信开发者工具报「index-vendor.js 文件不存在」,实际功能不受影响。
- 根因:分包优化插件在页面清单更新时会把页面 js 在
index.js与index-vendor.js间重命名,开发者工具缓存了旧路径。 - 处理:开发者工具「清缓存 → 全部清除」后重新编译;或停 dev 进程删产物重建:
Remove-Item -Recurse -Force dist\dev\mp-weixin; npm run dev:mp-weixin。
6.5 LambdaQueryWrapperX 链式条件断链
- 现象:Mapper 里加可选集合过滤后编译报「找不到符号」。
- 根因:插入 MyBatis-Plus 父类的三参 condition 版
in(condition, ...)/between(...)(LambdaQueryWrapperX未重写),返回值退回父类LambdaQueryWrapper,后续likeIfPresent/eqIfPresent全断链。 - 处理:只用 X 自己重写的
inIfPresent/eqIfPresent/geIfPresent/leIfPresent。注意inIfPresent对 null / 空集合是跳过条件(等价不限定),「按关联表筛选但无命中」必须在 Service 侧提前return Collections.emptyList(),否则会返回全量数据。日期区间同理:手写ge/le+parseDate(兼容yyyy-MM-dd与yyyy-MM-dd HH:mm:ss)。
6.6 清空字段与空值契约
customerId之类「可以为空」的字段,DO 上必须@TableField(updateStrategy = FieldStrategy.ALWAYS),否则默认NOT_NULL策略下传 null 会被忽略、取消关联保存后仍在。mediaUrls/images:入参是逗号分隔字符串,出参是数组,且恒不为 null(buildMediaUrls空时返回Collections.emptyList()),前端可直接.length。入参侧归一化把0/负数视为未关联。- 明细金额可能缺失(历史脏数据),聚合处按
price × quantity回退。
6.7 权限与数据口径
- 员工按
owner_id可见后,A 替 B 记的账(归属 B)会从 A 的列表消失,容易被当成「数据丢了」。处理:编辑页选择归属他人时 toast 明示;A 仍可凭 id 纠错(isLedgerWritable兼容creatorId)。 - 统计与列表共用同一数据范围,因此放开员工统计入口不需要改统计 SQL;反过来,任何新统计口径必须先确认
applyDataScope是否已收窄。 - 越权统一按「不存在」处理(返回
null/ 抛*_NOT_EXISTS),不要返回 403 语义,避免暴露他人数据存在性。 - 删除客户/商品前的关联计数按「部门优先,否则本人」统计,刻意不复用账单可见口径,否则员工替他人记的账无法阻止误删。
6.8 运行时与调试
- 后台标签页定时器被降频、跨零点后日期缓存过期:keep-alive 页面在
onShow重置定时器与日期缓存,静默刷新要判断「今天是否已变」。 - 小票 canvas 导出必须
sleep(150)等绘制落盘再canvasToTempFilePath;导出容器宽度受控,否则撑宽页面。 - Windows PowerShell 不支持
&&,多命令用;;带管道的git log/Get-Content中文易乱码,改用git log --format=...直读。
7. 部署与发布
7.1 生产拓扑
微信小程序 / 浏览器
→ https://qingheyun.top/admin-api (nginx 443,HTTP2,TLS1.2/1.3)
→ proxy_pass http://127.0.0.1:48080 (不带路径,原始 URI 整段透传)
→ yudao-server(含 yudao-module-ledger)
→ MySQL(业务表)+ Redis(token / 缓存)+ 文件服务(附件)nginx 要点(ledger-mobile/conf.nginx,行内已有详细中文注释):
| 要点 | 当前值 / 约定 |
|---|---|
| 域名收敛 | 80 统一 301 到 https://qingheyun.top;证书 SAN 只有裸域,不能跳 www |
| 证书位置 | 必须在每个 443 server 块内声明,全局写会互相串站 |
/admin-api/ | proxy_pass 结尾不带路径(后端 API 前缀由框架按 controller.admin 包名自动施加为 /admin-api,无需 rewrite);proxy_buffering off + proxy_request_buffering off 有利流式/大文件;读写超时 300s |
| 兜底 | location = /admin-api { return 404; },防止接口 404 回落拿到 HTML |
| 缓存 | /assets/ 30d immutable;index.html no-cache(发版刷新生效);其余 try_files … /index.html |
| 上传限制 | client_max_body_size 50m(前端 useUpload 默认 5MB,另一层闸门) |
| H5 站点 | h5.qingheyun.top 已注释关闭(2026-10-29,无独立证书);主用渠道是微信小程序 |
| PC 站点 | pc.qingheyun.top → /www/wwwroot/qingheyun.top/PC/dist 静态托管 |
7.2 后端
powershell
cd ledger-service
mvn -o clean package -pl yudao-server -am -DskipTests
# 产物:yudao-server/target/yudao-server.jar(finalName = artifactId)
java -jar yudao-server/target/yudao-server.jar --spring.profiles.active=local| 配置项 | 位置 | 说明 |
|---|---|---|
server.port | application-local.yaml / application-dev.yaml | 均为 48080 |
| 数据源 / Redis | 同上(spring.datasource、spring.data.redis) | local 用 db0、dev 用 db1;Redis 密码不得外传,建议生产开启 |
| API 前缀 | 框架自动(WebProperties) | /admin-api ↔ **.controller.admin.**;若把 Controller 放到 controller.app 包下,前缀会变成 /app-api |
| 多租户 | application.yaml → yudao.tenant.enable: true | 请求必须带 tenant-id,否则写入租户为空 |
| 验证码 | aj.captcha | 前端 VITE_APP_CAPTCHA_ENABLE=false 需与后端开关保持一致 |
| API 加解密 | yudao.api-encrypt.enable: false(默认关) | 前端 VITE_APP_API_ENCRYPT_ENABLE=true 但只有显式传 isEncrypt: true 的请求才加密,目前 ledger 接口无一使用 → 机制已接好但处于闲置状态;密钥同时存在于 env 与 server yaml,不要新写入文档或仓库外传;真正启用前需两侧同步开启并验证 JDK 8u120+ |
| Docker / CI | script/docker-compose/(mysql8.4 + redis7.4 + yudao-server + nginx,体验用)、script/jenkins/Jenkinsfile、script/shell/deploy.sh | 非生产高可用方案,按实际情况改造 |
7.3 SQL 发布(新→旧)
| 文件 | 作用 | 可重复执行 |
|---|---|---|
sql/mysql/ruoyi-vue-pro.sql、quartz.sql | 脚手架基础表(首次建库) | — |
sql/mysql/yudao-ledger.sql | 记账主表 + 明细表建表语句;下半部分是历史版本增量升级脚本(dept_id、owner_id、goods_image、废弃 goods_id) | 否(ALTER 只跑一次,新库无需执行) |
sql/mysql/yudao-ledger-customer.sql | 客户表 | 建表部分 IF NOT EXISTS |
sql/mysql/yudao-ledger-goods.sql | 商品表 | 同上 |
sql/mysql/yudao-ledger-goods-code.sql | 商品 code 字段 + idx_code,含 information_schema 存在性判定与 G{id} 历史回填 | 是 |
sql/demo/seed-ledger-249-idempotent.sql | 演示数据(12 客户 / 10 商品 / 120 记账),由同目录 seed-ledger-249.js 生成(固定种子 20261105,结果可复现) | 是(先按 id 段 DELETE 再 INSERT) |
要点:① 带列新增的版本发布,先执行 SQL 再重启后端(DO 字段不存在时 SELECT * 直接报错);② 回填脚本必须在启用新权限口径前跑完(owner_id = creator_id、dept_id ← system_users),否则员工历史账会“消失”;③ 演示脚本的 id 段避开真实数据,严禁在生产库执行。
7.4 微信小程序
powershell
cd ledger-mobile
npm run build:mp:prod # 产物 dist/build/mp-weixin
npm run upload:mp-weixin -- --dry-run # 只校验参数/私钥/产物
npm run upload:mp-weixin -- --version=0.8.0 --desc="员工按归属人隔离" --robot=1上传脚本(scripts/upload-weixin.js)行为:读 env/.env + env/.env.production 取 VITE_WX_APPID;版本号优先级 --version > WECHAT_UPLOAD_VERSION > yudao-version > version;描述优先级 --desc > WECHAT_UPLOAD_DESC > git log -1 --pretty="%an: %s" > 时间戳(所以上传前先提交代码,描述会自动是本次变更);私钥按 --private-key-path > WECHAT_UPLOAD_PRIVATE_KEY_PATH > private.{appid}.key > private.key 查找(已在 .gitignore,绝不提交);默认会先跑一次构建并设 SKIP_OPEN_DEVTOOLS=true,--skip-build 可复用现有产物;robot 必须 1-30(多人分工时固定编号区分上传人)。
微信侧配套检查:小程序后台「开发管理 → 服务器域名」必须包含 https://qingheyun.top(request + uploadFile);扫码加购依赖真机 uni.scanCode,开发者工具模拟器不可验证;体验版回归至少覆盖:登录 → 记一笔(含加购/附件)→ 当日列表 → 统计三视图 → 搜索 → 客户/商品详情 → 小票导出。
7.5 发布前检查清单
| # | 检查 | 命令 / 手段 |
|---|---|---|
| 1 | 后端编译 | mvn -o -q -pl yudao-module-ledger -am -DskipTests compile |
| 2 | Lint | npx eslint <改动文件>(或 npm run lint:fix) |
| 3 | 类型 | npx vue-tsc --noEmit -p tsconfig.json(比对 5.5 基线) |
| 4 | 小程序构建 | npm run build:mp-weixin,期望 DONE Build complete. |
| 5 | SQL | 本次是否新增列/索引?有 → 补增量脚本 + 回填,写入 7.3 表格 |
| 6 | 契约 | 新增出入参字段同步 src/api/types/ledger.ts 与后端 VO;数组字段保证非 null |
| 7 | 权限 | 新增查询入口是否走了 applyDataScope?新增写入是否校 validate*Writable? |
8. 附录 · 版本 ↔ 技术特性对照
版本列表与提交归属见 release-notes.md;本表只回答“该特性落在哪个章节的哪个机制”。
| 版本 | 主题 | 对应技术机制 | 文档章节 |
|---|---|---|---|
| v0.1.0 | 脚手架与记账 CRUD | unibest + 页面地图基座、/ledger/* 接口、mock 开关 | 2.2 / 3.3 / 4.1 / 4.3 |
| v0.1.0 | 记账日期字段 | record_date + FieldStrategy;列表倒序;minDate 选择器 | 3.2 / 4.8 |
| v0.4.0 | 客户 / 商品 / 搜索 / 附件 | 两张表 + CRUD、mediaUrls 逗号串↔数组、多维搜索 | 3.2 / 3.3 / 6.6 |
| v0.5.0 | 加购商品与关联客户 | yudao_ledger_goods_item 快照、库存原子扣减/回滚、customerId 可清空 | 3.2 / 3.5 |
| v0.6.0 | 卡片组件化与详情页统计 | yd-ledger-card props/emits、分组列表复用、前端聚合口径对齐 | 4.7 / 4.8 / 6.3 |
| v0.7.0 | 排行榜、页面水印 | type = TYPE_INCOME 过滤 + sortAndLimit;H5 DOM 注入 / 非 H5 yd-watermark;watermark store | 3.6 / 4.5 / 4.7 |
| v0.8.0 | 二维码扫码加购、小票导出、归属人员、店长/员工权限、业绩排行、员工统计开放 | code 生成与 get-by-code;canvas 排版导出;owner_id 全链路(候选列表/校验/排行);LedgerDataScope 双轨判定;统计去拦截 | 3.4 / 3.5 / 3.6 / 3.8 / 4.8 / 6.7 |
| v0.8.0+ | 记账能力迁移 PC 管理后台 | 同一套 /admin-api/ledger/* 接口;归属人录入/筛选/业绩排行;商品编号与标签打印、购物小票(浏览器打印)、月度趋势图 | 9 |
| v0.8.0+ 后续 | PC 客户 / 商品详情弹窗一体化 | 统计页并入详情弹窗(共用 LedgerRecordsPanel);删除 customer/records.vue、goods/records.vue 与 /ledger-assoc 隐藏路由 | 9.9 |
关键常量速查(改动时前后端必须同步):
| 常量 | 值 | 位置 |
|---|---|---|
TYPE_EXPENSE / TYPE_INCOME | 1 / 2 | LedgerDO |
STAFF_KEEP_DAYS | 7 | LedgerDataScope ↔ utils/ledger-role.ts |
排行默认 limit / 范围 | 10 / 1-50 | LedgerRankingReqVO |
goodsItems 上限 | 50 | LedgerSaveReqVO |
mediaUrls 长度上限 | 2000 | LedgerDO / DDL |
| 名称长度上限 | 64 | 记账 / 客户 / 商品 DDL |
| 上传大小上限 | 前端 5MB / nginx 50m | useUpload.ts / conf.nginx |
| 后端端口 / 前缀 | 48080 / /admin-api | application-*.yaml / WebProperties |
| 租户编号 | 1 | env/.env(VITE_APP_DEFAULT_LOGIN_TENANT_ID)/ interceptor |
| 商品编号前缀 / 随机位 | G + base36 时间戳 + 4 位(去 0O1I) | goods/code.ts(两端各一份,必须同步)/ GoodsServiceImpl |
PC 打印 zIndex / 等待策略 | 20003 / 双 nextTick + 双 rAF(二维码额外等 done,2s 兜底) | LedgerReceiptDialog / GoodsCodePrint |
| PC 详情弹窗宽度 | 980px(资料区 + 关联记账面板分段往下滚) | CustomerDetailDialog / GoodsDetailDialog |
| 主题色 | #0e9c86;破坏性操作 #f43f5e | 页面样式 |
9. PC 端设计(ledger-pc)· 记账功能
把移动端已交付的记账能力整体搬到管理后台。接口与业务口径完全复用后端,PC 只做展示层,不新增任何写接口。
9.1 工程与路由
| 项 | 说明 |
|---|---|
| 基座 | yudao-ui-admin-vue3(ledger-pc):Vue 3 + Element Plus + UnoCSS + SCSS scoped + pinia,构建 vite |
| API 层 | src/api/ledger/{index,types,customer,goods}.ts,走 request.get/post/put/delete,自动加 /admin-api 前缀 |
| 页面 | src/views/ledger/:index.vue(记账列表)、search/index.vue、statistics/index.vue、customer/{index,CustomerFormDialog}、goods/{index,GoodsFormDialog,code.ts}、components/*(详情弹窗与共用面板都在 components/) |
| 路由 | 由「系统管理 → 菜单管理」动态下发(组件路径相对 src/views/)。本次迁移没有新增路由页,全部落在既有页面与组件内,因此无需改菜单;后续新增页面时才需要配 路由地址 + 组件路径 + 权限标识。/ledger-assoc(customer-records / goods-records) |
| 登录态 | useUserStore().getUser(id/nickname/avatar/deptId),归属人默认值取它的 id |
9.2 迁移清单
| 能力 | 移动端 | PC 落点 |
|---|---|---|
| 归属人员录入 | 新增记账页下拉 | components/LedgerFormDialog.vue 归属人员行(默认当前登录人,option 内标「本人」) |
| 归属人员展示 / 筛选 | 列表头部筛选 | index.vue、statistics/index.vue、search/index.vue 与详情弹窗面板 components/LedgerRecordsPanel.vue 均加「归属人员」列 + 筛选 |
| 业绩排行 | 统计页维度切换 | statistics/index.vue 排行榜加 owner 维度(「业绩排行」) |
| 商品二维码编号 | 商品编辑页二维码卡片 | goods/code.ts + GoodsFormDialog.vue 编号行(生成/重新生成/复制 + 实时预览) |
| 标签打印 | 保存二维码到相册 | components/GoodsCodePrint.vue 浏览器打印(列表「标签」按钮 + 详情弹窗) |
| 购物小票 | pages/ledger/receipt canvas 导出 | components/LedgerReceiptDialog.vue 浏览器打印 / 另存 PDF |
| 月度趋势图 | 无(移动端只有月分组列表) | components/RecordsTrendChart.vue(PC 增量能力,挂在详情弹窗的面板里) |
| 客户 / 商品统计 | customer/statistics、goods/statistics 独立页 | 合并进详情弹窗:components/LedgerRecordsPanel.vue + {Customer,Goods}DetailDialog.vue,不再单独占路由(见 9.9) |
9.3 归属人员体系
- 展示用
ownerName(后端buildLedgerRespVOList回填),候选来自GET /ledger/owner-list(本部门员工,含本人)。 - 列表接口没有
ownerId参数(LedgerListReqVO不含该字段),所以归属人筛选只能在 PC 前端的computed filteredList里做本地过滤,不下推到查询参数。 - 归属人不漂移:
LedgerDO.ownerId没有updateStrategy,更新传 null 不会清空,与移动端「编辑不换归属人」一致;表单提交统一ownerId: formData.ownerId ?? null。 - 排行
owner维度由后端LedgerRankingReqVO支持(customer|goods|owner,只统计收入,resolveRankingOwnerId优先 ownerId、回退 creatorId);PC 侧 owner 榜的id是用户编号而不是记账维度,因此rankClickable = rankDimension !== 'owner',禁止点行进明细。 - 搜索关键词仍是四维匹配(name / remark / customerName / goodsName,不含归属人),与移动端
matchesKeyword严格一致,归属人只作为附加筛选列。
9.4 商品二维码编号与标签打印
- 编号规则两端必须同步:
G+Date.now().toString(36).toUpperCase()+ 4 位随机(字符集23456789ABCDEFGHJKLMNPQRSTUVWXYZ,去掉易混 0/O/1/I)。PCsrc/views/ledger/goods/code.ts↔ 移动端src/pages/ledger/goods/code.ts。 - 后端
GoodsServiceImpl.resolveGoodsCode兜底:为空则生成、原有则沿用(code上限 64),因此前端留空提交也安全。 - 入口三处:表单弹窗(生成 / 重新生成 / 复制 + 实时二维码)、列表「编号」列 + 操作列「标签」、详情弹窗二维码面板 + 「打印标签」。
- 重新生成前必须二次确认(旧标签随即失效,保存后生效);列表页对
!row.code的历史数据直接拦住提示先编辑保存。 - 打印组件
GoodsCodePrint.vue沿用 WMSReceiptOrderPrint.vue的范式:隐藏打印区 +v-print+printButtonRef.click();因为Qrcode是异步画到 canvas,触发打印前要await waitQrcode()(done事件 + 2s 兜底),否则标签是一块空白。
9.5 购物小票
components/LedgerReceiptDialog.vue版式逐段对齐移动端pages/ledger/receipt:品牌行 / 标题 / 收支 badge / 单号(NO.+ id 补零 6 位)/ 记账日期 + 周X / 客户(空显示「会员」)/ 名称 / 明细行(缩略图 + 单价×数量 + 小计,无明细回退成名称行)/ 合计件数 + 商品金额 / 收・支金额 / 备注 / 谢谢惠顾 + 打印时间。- 纸张固定 320px 窄幅 + 等宽字体(
Courier New)+ 虚线分隔,贴近热敏小票。 - 直接复用列表行
open(row),不再回查接口;输出方式是浏览器打印(可另存 PDF 或连小票打印机),不做 canvas 图片导出。
9.6 打印通用机制(关键踩坑)
vue3-print-nb的getHead()会把父文档document.styleSheets全量复制进打印 iframe,因此 scoped 与 UnoCSS 样式在打印窗口内有效;getBody()只复制document.getElementById(id).outerHTML。- 目标节点可以放在
.hidden(display:none)容器里:祖先的 display 不参与复制,屏幕上看不见、照样能打印。 - WMS 配置里的
extraCss: '/print.css'是历史遗留,ledger-pc/public下并没有这个文件,本次不引用。 - 打印区 id 用
getCurrentInstance()?.uid拼唯一值:记账列表 / 统计 / 搜索三页各挂一个小票弹窗,keep-alive 同时在档时固定 id 会让getElementById命中另一个实例的陈旧小票。
9.7 月度趋势图
components/RecordsTrendChart.vue由关联记账面板LedgerRecordsPanel.vue使用(客户与商品详情弹窗共用);数据源是宿主弹窗已拉取的明细items,不额外请求统计接口(与移动端「前端聚合」口径一致)。- 按
recordDate.slice(0,7)分桶,升序取最近 12 个月;脏数据(无记账日期)直接跳过;跨年份时月份标签自动带上年份。 - 两种模式:默认「收入柱 + 支出柱 + 净额折线」;传
goodsId时切为「商品金额柱 + 售出件数折线(右轴,minInterval: 1)」,件数只统计该商品的下单快照。
9.8 PC 端自检
| # | 检查 | 命令 |
|---|---|---|
| 1 | ESLint | npx eslint src/views/ledger src/api/ledger(有可自动修规则时先 --fix) |
| 2 | Prettier | npx prettier --check <改动文件>;全量修复 pnpm lint:format |
| 3 | Stylelint | npx stylelint --fix "src/views/ledger/**/*.{vue,scss}"(主要规则是 order/properties-order) |
| 4 | 类型 | pnpm ts:check(脚本内已带 --max_old_space_size=8192,直接跑 npx vue-tsc 会 OOM) |
存量偏差:
views/ledger/components/{CustomerDetailDialog,LedgerGoodsItems,GoodsSelectDialog}.vue、api/ledger/customer.ts等在迁移前就不满足 prettier / stylelint 规则,本次只格式化实际改动的文件,避免产生无关 diff。
9.9 客户 / 商品详情弹窗一体化(合并统计页)
动机:
CustomerDetailDialog(弹窗)与customer/records.vue(路由页)跑的是同一个接口、同一份口径,只是一个有资料没统计、一个有统计没资料,商品侧同理。两套代码各自维护容易漂移,因此合并为一个弹窗。
- 形态:弹窗一体化 + 分段往下滚(不拆 Tab、不跳页)。两个详情弹窗宽度
760px → 980px,自上而下依次是:① 资料区(客户头像/等级/描述列表,或商品图/价格/库存 + 二维码面板 + 简介/备注/图文详情)→ ② 关联记账面板。 - 共用面板
components/LedgerRecordsPanel.vue:汇总四卡(笔数 / 收入 / 支出 / 净额)+ 月度趋势图 + 完整明细表(可行内编辑、可打印小票)。商品模式额外有「累计售出 N 件 · 商品金额」条与「本商品」列(只取该商品的下单快照)。- props:
mode(customer|goods)、items、loading、goodsId;面板自己不拉接口,口径与排序完全由宿主弹窗决定。 - 列差异:客户模式给「备注 + 附件」,商品模式给「关联客户 + 单笔金额」,「归属人员」列两端都有;商品模式不重复展示备注/附件以控宽。
- props:
- 入口收敛:
customer/index.vue、goods/index.vue操作列的「统计」按钮改为「详情」并直调detailRef.open(row.id, row.name);记账列表 / 统计 / 搜索三页点客户头像·客户名·加购商品名,打开的是同一个详情弹窗(原来商品弹窗只读、看不到统计)。 - 数据与刷新链:弹窗
loadData()用Promise.all并行拉「详情 +LedgerApi.getLedgerList({ customerId | goodsId })」,失败的一侧用列表行带回的名称快照兜底(资料被删时仍能看历史记账);排序保持「recordDate倒序 →createTime倒序」。面板内编辑单条记账 →LedgerFormDialog@success→ 面板emit('recordsUpdated')→ 弹窗重新loadData()并再emit('recordsUpdated')→ 宿主页面刷新自己的列表(manualRefresh/refreshAfterChange/loadData)。商品侧库存会随记账变动,所以goods/index.vue同时监听@updated和@records-updated拉列表。 - 已删除:
customer/records.vue、goods/records.vue,以及router/modules/remaining.ts里的/ledger-assoc(含LedgerAssocRoot/LedgerCustomerRecords/LedgerGoodsRecords)。它们原本是隐藏静态路由、不依赖菜单,因此不用去菜单管理里删任何东西;但旧的命名路由跳转和直接收藏的#/ledger-assoc/customer-records?...地址会失效。 - 代价与边界:明细表改为弹窗内
max-height: 360滚动,没有页面级的刷新按钮 / 可分享 URL / 侧边栏高亮;嵌套弹窗(详情 → 记账表单、客户表单、小票)沿用 Element Plus 的层级叠加,与迁移前的「详情弹窗内编辑客户」是同一套已验证行为。
