Skip to content

滢滢的记账本 · 技术文档 ​

项目说明
文档版本对应业务版本 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…)不展开

目录

  1. 系统架构
  2. 技术栈
  3. 后端设计(yudao-module-ledger)
  4. 移动端设计(ledger-mobile)
  5. 开发规范与协作
  6. 已知坑点与解决方案
  7. 部署与发布
  8. 附录 · 版本 ↔ 技术特性对照
  9. 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/isDataWritable vs isLedgerVisible/isLedgerWritable)。
  • 明细快照:加购商品落 yudao_ledger_goods_item 快照行,商品改名/删除不影响历史账与统计。
  • 统计走内存聚合:数据量级小(单店),一次区间查询后在 Java 侧按维度分组,避免复杂 SQL 与数据库方言差异。

2. 技术栈 ​

2.1 后端 ​

项值
框架Spring Boot(javax 命名空间,JDK 8 语法体系)+ yudao 脚手架
模块yudao-module-ledger,packaging=jar,parent yudao,版本 ${revision}
依赖 starteryudao-spring-boot-starter-biz-tenant、-security、-mybatis、spring-boot-starter-validation、yudao-module-system、yudao-spring-boot-starter-test
ORMMyBatis-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.ledgerLedgerController记账 CRUD、列表、归属人候选、年度/日/排行榜统计(/ledger)
CustomerController客户 CRUD 与列表(/ledger/customer)
GoodsController商品 CRUD、列表、按二维码编号取商品(/ledger/goods)
controller.admin.ledger.voLedgerSaveReqVO / LedgerListReqVO / LedgerRespVO记账入参、查询、返回
LedgerGoodsSaveReqVO / LedgerGoodsRespVO加购明细行
LedgerYearStatRespVO / LedgerMonthStatRespVO / LedgerDayStatRespVO年度 → 月度 → 日度统计
LedgerRankingReqVO / LedgerRankingRespVO排行榜三维度
LedgerOwnerRespVO归属人员候选(id + name)
Customer* / Goods* VO客户、商品入参出参
service.ledgerLedgerService / LedgerServiceImpl记账主逻辑、统计、排行、库存联动、归属人校验
CustomerService(Impl)客户逻辑 + 删除关联校验
GoodsService(Impl)商品逻辑 + 库存原子扣减/回滚 + 二维码编号生成
dal.dataobject.ledgerLedgerDO / CustomerDO / GoodsDO / LedgerGoodsDO四张表映射
dal.mysql.ledgerLedgerMapper / CustomerMapper / GoodsMapper / LedgerGoodsMapper查询封装,含 applyDataScope
framework.datapermissionLedgerDataScope / LedgerDataScopeHelper数据范围值对象 + 角色解析
enumsErrorCodeConstants1-020-xxx-xxx 错误码

3.2 数据模型 ​

表名与主键序列(@KeySequence 兼容 Oracle/PG 等):

DO表名说明
LedgerDOyudao_ledger记账主表
LedgerGoodsDOyudao_ledger_goods_item记账商品明细(快照)
CustomerDOyudao_ledger_customer客户
GoodsDOyudao_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 字段

字段类型约束/说明
idLong主键
amountBigDecimal金额,@DecimalMin("0.01")
nameString名称,列 NOT NULL,可空串(≤64),空时前端显示「未命名」
remarkString备注
typeInteger1=支出(TYPE_EXPENSE),2=收入(TYPE_INCOME)
recordDateLocalDate记账日期,不传默认当天;列表按它倒序
customerIdLong关联客户,null 表示未关联;updateStrategy = FieldStrategy.ALWAYS 允许清空
mediaUrlsString附件地址逗号串,≤2000;出参统一转 List<String>,空时返回 []
deptIdLong记账人所在部门,来自 SecurityFrameworkUtils.getLoginUserDeptId()
ownerIdLong归属人员用户编号,新增默认当前登录人
creatorId / updaterIdLong创建/更新人编号

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/createLedgerSaveReqVOLong创建记账(含明细与库存扣减)
PUT/ledger/updateLedgerSaveReqVO(含 id)Boolean更新记账(明细覆盖式)
DELETE/ledger/delete?id=idBoolean删除记账并回滚库存
GET/ledger/get?id=idLedgerRespVO详情,越权时返回 null
GET/ledger/listLedgerListReqVOList<LedgerRespVO>列表:name(like)、type、customerId、goodsId、startTime、endTime;按 recordDate、createTime 倒序
GET/ledger/owner-list—List<LedgerOwnerRespVO>归属人员候选(本部门启用用户 + 本人兜底)
GET/ledger/statistics/year?year=yearLedgerYearStatRespVO年度 + 12 个月收支
GET/ledger/statistics/days?year=&month=year、month(1-12)List<LedgerDayStatRespVO>当月每日收支
GET/ledger/statistics/rankingLedgerRankingReqVOList<LedgerRankingRespVO>排行榜
POST/PUT/DELETE/GET`/ledger/customer/createupdatedeleteget
POST/PUT/DELETE/GET`/ledger/goods/createupdatedeleteget
GET/ledger/goods/get-by-code?code=codeGoodsRespVO扫码加购入口,越权/不存在返回 null

LedgerSaveReqVO 语义约定(易错点):

字段不传(null)传空数组/空值
name新增存空串;更新保持原值传空串可清空
customerId归一化为 null(0/负数同样视为未关联)—
ownerId新增默认当前登录人;更新时保持原值,不跟随编辑人漂移—
goodsItems更新时保持原明细不变传 [] 表示清空明细
recordDate新增默认当天—

3.4 数据范围与权限 ​

角色解析(LedgerDataScopeHelper):优先角色名称,编码兜底,两者任一命中即认定。

角色名称编码RoleType
店长记账本店长ledger_managerMANAGER
员工记账本员工ledger_staffSTAFF
其他——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):

维度分组键名称/图标来源空值处理
customercustomerId(>0 才参与)CustomerDO.name/avatar客户已删 → 未知客户,id 保留原值
goods明细表 goodsId(null 归 0)最近一次加购的 goodsName/goodsImage 快照无名 → 未知商品;quantity 汇总销量,count 为去重后的记账笔数
ownerresolveRankingOwnerId:ownerId 优先,回退 creatorIdAdminUserApi.getUserMap 批量取 nickname/avatar用户已删/无昵称 → 未知人员

排序统一 sortAndLimit:金额倒序 → 笔数倒序 → id 升序(稳定),再 limit。明细金额缺失时按 price × quantity 回退(goodsAmount)。

跨模块调用一律批量取(getUserMap、getCustomerMap、getLedgerGoodsMap)以避免 N+1。

3.7 错误码(1-020 段) ​

编号常量文案
1_020_000_000LEDGER_NOT_EXISTS记账记录不存在
1_020_000_001—(已废弃,编号保留不复用)曾用于「员工禁用统计」
1_020_000_002LEDGER_RECORD_DATE_FORBIDDEN员工只能记录近 7 天内的账单
1_020_000_003LEDGER_OWNER_NOT_EXISTS归属人员不存在
1_020_000_004LEDGER_OWNER_NOT_IN_DEPT归属人员必须是本部门成员
1_020_001_000CUSTOMER_NOT_EXISTS客户不存在
1_020_001_001CUSTOMER_EXISTS_LEDGER该客户已关联 {} 条记账,请先删除相关记账
1_020_002_000GOODS_NOT_EXISTS商品不存在
1_020_002_001GOODS_STOCK_NOT_ENOUGH商品【{}】库存不足,本次需要 {} 件
1_020_002_002GOODS_EXISTS_LEDGER该商品已关联 {} 条记账,请先删除相关记账
1_020_002_003GOODS_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'](记账页在主包)
easycomyd-* → @/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)
Lintnpm 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/indexi-carbon-wallet
统计pages/ledger/statistics/indexi-carbon-chart-column
搜索pages/ledger/search/indexi-carbon-search
我的pages/user/indexi-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.tsuserInfo(含 userId、nickname)、roles(编码)、roleNames(名称)角色判定与水印昵称;roleNames 来自 get-permission-info
token.ts单/双 token、有效期、refreshToken、logout鉴权与无感刷新
watermark.tsenabled(默认 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-style1tbs + allowSingleLineelse/catch/} 不换行
perfectionist/sort-importsoffuni 条件编译注释会包裹 import,自动排序会破坏平台条件边界
no-console / no-unused-varsoff小程序端调试依赖 console
import/no-duplicatesprefer-inline: false类型与值允许分开 import
ignoressrc/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.portapplication-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 / CIscript/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
2Lintnpx eslint <改动文件>(或 npm run lint:fix)
3类型npx vue-tsc --noEmit -p tsconfig.json(比对 5.5 基线)
4小程序构建npm run build:mp-weixin,期望 DONE Build complete.
5SQL本次是否新增列/索引?有 → 补增量脚本 + 回填,写入 7.3 表格
6契约新增出入参字段同步 src/api/types/ledger.ts 与后端 VO;数组字段保证非 null
7权限新增查询入口是否走了 applyDataScope?新增写入是否校 validate*Writable?

8. 附录 · 版本 ↔ 技术特性对照 ​

版本列表与提交归属见 release-notes.md;本表只回答“该特性落在哪个章节的哪个机制”。

版本主题对应技术机制文档章节
v0.1.0脚手架与记账 CRUDunibest + 页面地图基座、/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 store3.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_INCOME1 / 2LedgerDO
STAFF_KEEP_DAYS7LedgerDataScope ↔ utils/ledger-role.ts
排行默认 limit / 范围10 / 1-50LedgerRankingReqVO
goodsItems 上限50LedgerSaveReqVO
mediaUrls 长度上限2000LedgerDO / DDL
名称长度上限64记账 / 客户 / 商品 DDL
上传大小上限前端 5MB / nginx 50museUpload.ts / conf.nginx
后端端口 / 前缀48080 / /admin-apiapplication-*.yaml / WebProperties
租户编号1env/.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) 曾为统计页注册过隐藏静态路由,已在后续调整中连同页面一起删除(见 9.9)
登录态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)。PC src/views/ledger/goods/code.ts ↔ 移动端 src/pages/ledger/goods/code.ts。
  • 后端 GoodsServiceImpl.resolveGoodsCode 兜底:为空则生成、原有则沿用(code 上限 64),因此前端留空提交也安全。
  • 入口三处:表单弹窗(生成 / 重新生成 / 复制 + 实时二维码)、列表「编号」列 + 操作列「标签」、详情弹窗二维码面板 + 「打印标签」。
  • 重新生成前必须二次确认(旧标签随即失效,保存后生效);列表页对 !row.code 的历史数据直接拦住提示先编辑保存。
  • 打印组件 GoodsCodePrint.vue 沿用 WMS ReceiptOrderPrint.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 端自检 ​

#检查命令
1ESLintnpx eslint src/views/ledger src/api/ledger(有可自动修规则时先 --fix)
2Prettiernpx prettier --check <改动文件>;全量修复 pnpm lint:format
3Stylelintnpx 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;面板自己不拉接口,口径与排序完全由宿主弹窗决定。
    • 列差异:客户模式给「备注 + 附件」,商品模式给「关联客户 + 单笔金额」,「归属人员」列两端都有;商品模式不重复展示备注/附件以控宽。
  • 入口收敛: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 的层级叠加,与迁移前的「详情弹窗内编辑客户」是同一套已验证行为。

滢滢的记账本 · 技术文档