From fddf3d913d47f391f84f9e460cb76d427ef02c44 Mon Sep 17 00:00:00 2001
From: liujie <1793218484@qq.com>
Date: 星期三, 14 十月 2026 18:06:05 +0800
Subject: [PATCH] Merge branch 'master' of http://120.76.84.145:10101/gitblit/r/java/Hugray
---
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/WeChatMiniappService.java | 140
hugray-user-miniapp/pages/address/list.vue | 102
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountCertificationApplyService.java | 47
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountProfileController.java | 274
hugray-admin/src/utils/member.js | 6
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/InviteQrcodeQuery.java | 27
hugray-partner-miniapp/pages/index/index.vue | 1007 ++
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/ResetPasswordBody.java | 59
doc/design/服务商端_登录_20261014.md | 172
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/SubAccountQuotaVo.java | 57
hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountWarehouseRecordMapper.xml | 63
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/SalesMonthSummaryVo.java | 63
hugray-partner-miniapp/api/index.js | 9
hugray-user-miniapp/pages/profile/invite.vue | 781 ++
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountMemberInnerController.java | 43
hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountUserMapper.xml | 71
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/WarehouseRecordVo.java | 117
hugray-partner-miniapp/static/industry-4-0-slogan-card.png | 0
hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/RemoteAccountUserService.java | 26
hugray-user-miniapp/pages/profile/index.vue | 10
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/AccountAuthController.java | 58
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/MemberPurchaseDto.java | 54
hugray-partner-miniapp/pages/login/forgot.vue | 333
hugray-partner-miniapp/pages/login/index.vue | 473 +
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountProviderMapper.java | 8
hugray-user-miniapp/api/index.js | 187
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/AccountLoginService.java | 31
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerSalesStatVo.java | 37
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountPartnerServiceImpl.java | 433 +
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/enums/PurchaseOrderStatusEnum.java | 66
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountUser.java | 21
hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/domain/AccountMember.java | 33
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerInviteStatVo.java | 77
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/SalesUserRankingVo.java | 63
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountMemberCenterServiceImpl.java | 387 +
doc/design/用户端_我的资料_头像上传_20261013.md | 229
hugray-partner-miniapp/static/check-icon.png | 0
hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountPartnerMapper.xml | 159
hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountCertificationApplyServiceImplTest.java | 292
hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountWarehouseMapper.xml | 13
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountWarehouseMapper.java | 22
hugray-server/ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ContactInfoVo.java | 42
hugray-partner-miniapp/static/medical-plus-icon.png | 0
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PurchaseOrderVo.java | 130
hugray-partner-miniapp/pages.json | 26
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountPurchaseOrder.java | 186
doc/design/用户端_会员中心_20261014.md | 475 +
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/ProductSalesRatioVo.java | 35
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/PartnerLoginService.java | 95
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountUserService.java | 97
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/SmsCodeService.java | 14
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountCertificationStatusVo.java | 66
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/domain/vo/InviteQrcodeVo.java | 22
hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountSalesRecordMapper.xml | 77
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/MemberSalesStatisticsVo.java | 144
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountInviteeVo.java | 104
hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountPurchaseOrderMapper.xml | 76
hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/model/AccountResetPasswordRequest.java | 61
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/InviteCodeVo.java | 24
hugray-admin/src/views/member/certification/detail.vue | 36
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountMemberController.java | 110
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountWarehouseRecord.java | 163
doc/design/用户端_实名认证_20261013.md | 305
hugray-user-miniapp/pages/profile/company-certification.vue | 1038 +++
hugray-partner-miniapp/static/metabolism-300-logo.png | 0
doc/nacos/dev/ruoyi-file-dev.yml | 34
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertification.java | 182
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountUserServiceImpl.java | 240
hugray-user-miniapp/utils/request.js | 94
hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountUserServiceImplTest.java | 78
hugray-server/sql/hugray_account_company_certification_20261013.sql | 113
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/TokenController.java | 22
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountUserMapper.java | 34
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerOverviewVo.java | 251
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerEarningStatVo.java | 37
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/CompanyCertificationApplyDto.java | 334 +
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountSalesRecord.java | 186
doc/design/用户端_收货地址_20261014.md | 276
doc/design/服务商端_忘记密码_20261014.md | 194
hugray-partner-miniapp/pages/sub-account/detail.vue | 839 --
hugray-user-miniapp/pages.json | 28
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountPartnerController.java | 92
hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationMapper.xml | 55
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountCertificationApplyServiceImpl.java | 246
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountAddressVo.java | 77
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountSalesRecordMapper.java | 72
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountWarehouseRecordMapper.java | 29
doc/design/用户端_个人中心_20261013.md | 2
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/enums/WarehouseChangeTypeEnum.java | 70
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountAddressMapper.java | 7
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerSubAccountPageVo.java | 67
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountReferrerVo.java | 89
hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/factory/RemoteAccountUserFallbackFactory.java | 13
hugray-user-miniapp/pages/member/index.vue | 1394 ++++
doc/design/用户端_企业个体认证_20261013.md | 330 +
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountCertificationApplyDto.java | 57
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountPurchaseOrderMapper.java | 42
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountMemberCenterService.java | 59
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountUserAvatarDto.java | 32
hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountAddressMapper.xml | 6
doc/nacos/dev/ruoyi-gateway-dev.yml | 2
hugray-user-miniapp/pages/profile/certification.vue | 390 +
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/PartnerLoginBody.java | 41
hugray-server/ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/constant/CacheConstants.java | 5
doc/design/服务商端_首页概览_20261014.md | 174
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertificationApply.java | 184
doc/design/服务商端_子账号管理_20261014.md | 204
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/CompanyCertificationStatusVo.java | 314
hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationApplyMapper.xml | 126
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountCertificationApplyMapper.java | 33
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountUserInfoVo.java | 29
hugray-user-miniapp/pages/address/add.vue | 73
hugray-partner-miniapp/pages/sub-account/add.vue | 310
hugray-server/ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/controller/SysContactInfoController.java | 52
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/SalesTrendItemVo.java | 58
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/MemberWarehouseVo.java | 89
doc/design/用户端_首页与导航_20261012.md | 2
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/PasswordResetService.java | 126
hugray-server/sql/hugray_account_member_center_20261014.sql | 96
hugray-user-miniapp/pages/contact/index.vue | 45
hugray-user-miniapp/pages/login/register.vue | 19
hugray-user-miniapp/pages/profile/edit.vue | 104
hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountProviderMapper.xml | 11
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerSubAccountVo.java | 77
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountPartnerMapper.java | 120
doc/design/用户端_我的邀请_20261013.md | 239
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerSubAccountStatVo.java | 89
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/SubAccountCreateDto.java | 44
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/SmsCodeBody.java | 2
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountAddressDto.java | 88
.pai/skill-update/update.json | 12
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/AccountTokenIssuer.java | 65
hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountPartnerService.java | 58
hugray-partner-miniapp/pages/sub-account/index.vue | 586 +
134 files changed, 17,376 insertions(+), 1,548 deletions(-)
diff --git a/.pai/skill-update/update.json b/.pai/skill-update/update.json
new file mode 100644
index 0000000..e727e2b
--- /dev/null
+++ b/.pai/skill-update/update.json
@@ -0,0 +1,12 @@
+{
+ "generate-mini-app": {
+ "lastCheck": 1791964919870,
+ "checkedAt": "2026-10-14T08:20:26.482Z",
+ "status": "skipped",
+ "reason": "skipped",
+ "skill_builtin_version": "1.0.2",
+ "skill_local_version": "",
+ "updatedAt": "",
+ "nextCheckAt": "2026-10-14T11:01:59.870Z"
+ }
+}
\ No newline at end of file
diff --git "a/doc/design/\346\234\215\345\212\241\345\225\206\347\253\257_\345\255\220\350\264\246\345\217\267\347\256\241\347\220\206_20261014.md" "b/doc/design/\346\234\215\345\212\241\345\225\206\347\253\257_\345\255\220\350\264\246\345\217\267\347\256\241\347\220\206_20261014.md"
new file mode 100644
index 0000000..9ebb922
--- /dev/null
+++ "b/doc/design/\346\234\215\345\212\241\345\225\206\347\253\257_\345\255\220\350\264\246\345\217\267\347\256\241\347\220\206_20261014.md"
@@ -0,0 +1,204 @@
+# 服务商端子账号管理开发设计
+
+日期:2026-10-14
+状态:已实现(2026-10-14,后端接口与前端页面均已落地,待联调)
+
+## 1. 功能概述与范围
+
+服务商在「子账号管理」页查看已开通的 L1/L2/L3 虚拟账号,并可新增子账号。本模块沿用服务商端首页已确认的规则:前端保持 uni-app + Vue 3 + `<script setup>` + SCSS,后端沿用 ruoyi-user 的 Controller → Service → Mapper 分层,返回统一走工程既有 `R<T>`(`{ code, msg, data }`,成功码 200),前端 `request.js` 的 `code === 200` 校验合同不变。
+
+交付范围:
+
+- 列表页 `pages/sub-account/index.vue`:去本地模拟数据,接入真实「子账号列表」接口,支持等级筛选、关键字搜索与分页加载。
+- 新增页 `pages/sub-account/add.vue`:去本地模拟数据,接入真实「剩余额度」「新增子账号」接口。
+- 后端新增三个端点:列表查询、额度查询、新增子账号。
+
+不在本次范围:子账号详情页接口(`GET /partner/subAccount/{id}`)、子账号停用/删除/改级。详情页是否本轮实现见第 9 节待确认项。
+
+### 1.1 搜索功能(2026-10-14 追加)
+
+- 交互:点列表页顶部搜索图标展开页内搜索条(输入框 + 搜索 + 取消),不新开独立搜索页;`confirm-type="search"` 回车等同点「搜索」。
+- 匹配:按 `nickname` 或 `phone` 模糊匹配,仅在本服务商名下子账号范围内检索;可与等级筛选叠加。
+- 参数:列表接口新增可选入参 `keyword`(去前后空格;空串=不过滤)。
+- 安全:关键字在服务层转义 like 通配符(`\` `%` `_`),SQL 用 `escape '\'`,避免用户输入扩大匹配范围。
+
+## 2. UI 布局与样式
+
+### 2.1 设计依据
+
+沿用首页设计规范(品牌深蓝 #003B68、绿色等级胶囊 #67B72C、页面白底、安全区按钮)。子账号管理沿用原型的信息结构:头像占位 + 姓名 + 等级胶囊 + 手机号 + 右箭头。
+
+### 2.2 列表页结构示意
+
+```text
+顶部筛选栏
+ 全部 | L1 | L2 | L3 搜索图标
+
+搜索条(点搜索图标展开)
+ 🔍 搜索昵称或手机号 × 搜索 取消
+
+子账号列表(滚动区)
+ 头像 姓名 「L1」胶囊
+ 13612346666 >
+
+底部安全区
+ 新增账号(深蓝主按钮)
+```
+
+- 等级筛选:`全部` 不传 `level`;L1/L2/L3 传对应值,切换即重新拉取第一页。
+- 搜索:点搜索图标展开页内搜索条,确认后按关键字回到第一页拉取;取消清空关键字并恢复全部;关键字与等级筛选可叠加。
+- 列表为空展示「暂无子账号」;搜索无结果显示「未找到相关子账号」;请求失败展示失败提示,不再用模拟数据兜底。
+- 分页:滚动到底部加载下一页(`scroll-view` 的 `@scrolltolower`),无更多数据时停止加载。
+- 点击列表项跳转详情页(详情页接口不在本次范围,跳转失败保持现有提示)。
+
+### 2.3 新增页结构示意
+
+```text
+等级
+ 请选择(下拉,选项带各等级剩余数) v
+当前剩余可用:X个L1 Y个L2 Z个L3
+
+登录手机号
+ 请输入账户
+
+底部安全区
+ 新增(深蓝主按钮)
+```
+
+- 等级下拉选项:`L1(剩余n个)` 形式;额度为 0 的等级仍展示,提交时后端拒绝。
+- 手机号前端内联校验 `^1[3-9]\d{9}$`,与后端一致。
+- 新增成功提示后返回列表页并刷新。
+
+### 2.4 适配规范
+
+- 750rpx 基准,页边距 28rpx,输入框/按钮高 88rpx,圆角 8rpx。
+- 底部按钮区使用 `safe-area-inset-bottom`。
+- 不在页面伪造系统控件,导航栏沿用工程既有模式。
+
+## 3. 接口设计
+
+网关前缀 `/miniapp/account` 由 `StripPrefix=2` 剥掉,Controller 映射为 `/partner`;服务商身份一律取网关注入的登录态 `SecurityUtils.getUserId()`,不接受前端传 userId,天然防水平越权。三个端点均返回 `R<T>`。
+
+### 3.1 子账号列表(分页)
+
+- 请求:`GET /miniapp/account/partner/subAccount/list`
+- 入参:`pageNum`、`pageSize`(走 `startPage()`),`level`(可选,空=全部;仅接受 L1/L2/L3,非法值按全部处理),`keyword`(可选,空=不过滤;按 `nickname` / `phone` 模糊匹配)
+- 分页范围:`account_user.supplier_id = 当前登录用户 and del_flag = '0' and system_level in ('L1','L2','L3')`,按 `user_id` 倒序(新开通在前)
+- 返回:`R<PartnerSubAccountPageVo>`
+
+| 字段 | 类型 | 含义 |
+| --- | --- | --- |
+| total | Long | 总条数 |
+| list | Array | 列表项,见下 |
+| pageNum | Integer | 当前页码 |
+| pageSize | Integer | 每页条数 |
+
+列表项 `PartnerSubAccountVo`:
+
+| 字段 | 类型 | 含义 |
+| --- | --- | --- |
+| userId | Long | 子账号会员ID |
+| nickname | String | 昵称(空时前端回退显示手机号) |
+| phone | String | 登录手机号 |
+| systemLevel | String | 等级(L1/L2/L3) |
+| avatar | String | 头像地址(可空,前端用占位) |
+
+分页结构说明:按用户规则「分页统一 `data = { list, total, pageNum, pageSize }`」,故用 `R<PartnerSubAccountPageVo>` 落地,不用 RuoYi 的 `TableDataInfo`(其字段为 `rows`),以保持前端现有 `data.list` 读取方式不变,也避免引入第二套分页返回类。
+
+### 3.2 剩余可开通额度
+
+- 请求:`GET /miniapp/account/partner/subAccount/quota`
+- 入参:无
+- 返回:`R<SubAccountQuotaVo>`
+
+| 字段 | 类型 | 含义 |
+| --- | --- | --- |
+| L1 | Integer | L1 剩余可开通数 |
+| L2 | Integer | L2 剩余可开通数 |
+| L3 | Integer | L3 剩余可开通数 |
+
+### 3.3 新增子账号
+
+- 请求:`POST /miniapp/account/partner/subAccount`
+- 入参:`{ level, phone }`(`SubAccountCreateDto`,`@Validated` 校验非空与手机号格式)
+- 校验:登录态 → 服务商存在且未禁用 → level ∈ {L1,L2,L3} → 手机号格式正确且全局未注册 → 该等级剩余额度 > 0
+- 写入:`account_user` 新增一行(见第 4 节)
+- 返回:`R.ok(data)`,`data` 为新子账号的 `userId`
+
+## 4. 数据库设计与口径
+
+本次不新增业务表,无 DDL 变更。
+
+| 用途 | 数据源 | 口径 |
+| --- | --- | --- |
+| 子账号列表 | account_user | supplier_id = 登录用户,del_flag='0',system_level ∈ {L1,L2,L3} |
+| 权益总额 | account_provider | l1_quota / l2_quota / l3_quota(`selectProviderByUserId` 必须查这三列,否则取不到权益、剩余额度恒为 0) |
+| 已开通数 | account_user | supplier_id = 登录用户,del_flag='0',按 system_level 分组计数 |
+
+- 剩余额度 = `quota − 已开通数`,下限 0。
+- 新增子账号写入字段:
+ - `username` = 手机号(与注册口径一致,账号即手机号)
+ - `phone` = 手机号
+ - `status` = `AccountUser.STATUS_NORMAL`(正常)
+ - `system_level` = 所选等级(L1/L2/L3)
+ - `supplier_id` = 当前登录用户ID,`supplier_bind_time` = 当前时间
+ - `my_referral_code` = 生成的全局唯一推荐码(子账号也是会员,可邀请)
+ - `create_by` = 登录用户手机号
+ - 不写密码、不写 `provider_flag`、不写 `company_auth_status`:沿用 C 端注册口径(账号=手机号,登录走手机号验证码),子账号后续可自行完善资料。
+- 手机号唯一性:工程 `account_user.phone/username` 无数据库唯一索引,唯一性由服务层查重保证(沿用 `selectAccountUserByPhone`)。
+- 并发下限流:新增前先查重与校验额度;极端并发下允许「超额开通」的窗口由后续停用/删除兜底(与工程现有注册流程一致),本次不引入分布式锁。
+
+## 5. 业务流程
+
+```text
+列表页
+ 进入 → 读取登录态 → 拉取列表(pageNum=1) → 渲染
+ 切换等级 → 重置到第1页重新拉取
+ 搜索关键字 → 重置到第1页重新拉取(与等级筛选叠加)
+ 滚动到底 → pageNum+1 → 追加渲染(无更多则停止)
+
+新增页
+ 进入 → 拉取剩余额度 → 渲染下拉与提示
+ 选等级 + 填手机号 → 前端校验 → 提交
+ 服务端:登录态 → 服务商校验 → 参数校验 → 手机号查重 → 额度校验 → 唯一推荐码 → 写入 → 返回
+ 成功 → 提示 → 返回列表页刷新
+```
+
+## 6. 枚举、状态与异常
+
+- 等级:入参只接受 `L1/L2/L3`(取自 `account_user.system_level`,服务层用常量集归一化,非法值按「全部/请选择等级」处理)。
+- 服务商资格复用 `AccountProvider.STATUS_NORMAL/STATUS_DISABLED`。
+- 子账号状态复用 `AccountUser.STATUS_NORMAL`。
+- 异常统一抛 `ServiceException`,由全局异常处理器返回 `{ code, msg, data }`,Controller 保持薄层,不散落 try-catch。
+- 典型错误文案:非服务商 / 服务商资格已禁用 / 请选择等级 / 请输入正确的手机号 / 手机号已注册 / 该等级剩余额度不足。
+- 页面状态:加载中、成功、失败;无数据的成功结果展示空态,失败不用模拟数据替代。
+
+## 7. 影响范围
+
+后端(ruoyi-user):
+
+- `controller/AccountPartnerController.java`:新增 3 个方法;列表方法追加 `keyword` 入参。
+- `service/IAccountPartnerService.java` + `service/impl/AccountPartnerServiceImpl.java`:新增列表/额度/新增业务方法;列表方法追加 `keyword` 入参并做通配符转义。
+- `mapper/AccountPartnerMapper.java` + `resources/mapper/user/AccountPartnerMapper.xml`:新增子账号分页查询、等级计数、额度查询;列表查询追加 `keyword` 的 `nickname/phone` 模糊条件。
+- `domain/vo/`:新增 `PartnerSubAccountVo`、`PartnerSubAccountPageVo`、`SubAccountQuotaVo`;`domain/dto/`:新增 `SubAccountCreateDto`。
+- 复用 `AccountProviderMapper.selectProviderByUserId`、`AccountUserMapper.selectAccountUserByPhone`、`AccountUserMapper.insertAccountUser`。
+
+前端(hugray-partner-miniapp):
+
+- `pages/sub-account/index.vue`:去模拟数据,接入列表、等级筛选、关键字搜索、分页加载。
+- `pages/sub-account/add.vue`:去模拟数据,接入额度与新增。
+- `api/index.js`:`getSubAccountList` 支持 `keyword` 入参(补齐注释)。
+
+不改动数据库结构,不自动提交或推送 Git。
+
+## 8. 验收与验证
+
+- 后端:非服务商、资格禁用、非法等级、非法手机号、手机号重复、额度为 0、跨服务商隔离(A 服务商看不到 B 的子账号)、空结果补 0 分页结构正确。核心逻辑可用 Mock 单测覆盖。
+- 前端:等级切换、关键字搜索(含取消/清空、与等级叠加)、分页加载、空态、失败提示、新增成功返回刷新、额度为 0 的等级提交被拒。
+- 验证结果区分静态检查、编译、自动化测试与真实联调;缺少运行环境时明确报告限制。
+
+## 9. 待确认结论
+
+1. 列表/额度/新增三个端点的入出参与分页结构按第 3 节执行(分页用 `R<PartnerSubAccountPageVo>{list,total,pageNum,pageSize}`,保持前端 `data.list` 不变)。
+2. 子账号写入口径按第 4 节执行(账号=手机号、无密码、绑定 supplier_id、生成推荐码)。
+3. 详情接口 `GET /partner/subAccount/{id}` 是否纳入本轮。
diff --git "a/doc/design/\346\234\215\345\212\241\345\225\206\347\253\257_\345\277\230\350\256\260\345\257\206\347\240\201_20261014.md" "b/doc/design/\346\234\215\345\212\241\345\225\206\347\253\257_\345\277\230\350\256\260\345\257\206\347\240\201_20261014.md"
new file mode 100644
index 0000000..335bf25
--- /dev/null
+++ "b/doc/design/\346\234\215\345\212\241\345\225\206\347\253\257_\345\277\230\350\256\260\345\257\206\347\240\201_20261014.md"
@@ -0,0 +1,194 @@
+# 服务商端小程序 忘记密码(重置密码)开发设计文档
+
+- 模块:`hugray-partner-miniapp`(前端)、`ruoyi-auth` / `ruoyi-user` / `ruoyi-api-system` / `ruoyi-gateway`(后端)
+- 日期:2026-10-14
+- 需求来源:“继续实现服务商小程序的忘记密码功能,包括页面的 ui 设计以及后端的业务实现”
+- 风格参照:`hugray-partner-miniapp/pages/login/index.vue`(同品牌登录页)
+- 改造目标文件:
+ - 前端:`hugray-partner-miniapp/pages/login/forgot.vue`、`hugray-partner-miniapp/pages.json`、`hugray-partner-miniapp/api/index.js`
+ - 后端:`ruoyi-auth`、`ruoyi-user`、`ruoyi-api-system`、网关白名单
+- 依赖既有能力(本次仅复用,不改):
+ - `SmsCodeService.sendCode / verifyAndConsume`(`ruoyi-auth`)
+ - `SmsSceneEnum.RESET`(已存在,`reset` = 重置密码)
+ - `SecurityUtils.encryptPassword`(BCrypt,`ruoyi-common-security`)
+
+---
+
+## 一、功能概述
+
+服务商端「忘记密码」= 通过**已注册手机号 + 短信验证码**校验身份后,为账号**设置新密码**。
+
+| 序号 | 项 | 说明 |
+| --- | --- | --- |
+| 1 | 入口 | 登录页「忘记密码」→ `uni.navigateTo('/pages/login/forgot')`(现状已具备) |
+| 2 | 前端页面 | 重做 `forgot.vue` 视觉,对齐服务商端登录页品牌风(渐变底 + 面板式输入 + 渐变胶囊按钮) |
+| 3 | 校验要素 | 登录手机号 + 短信验证码(一次性)+ 新密码两次一致 |
+| 4 | 后端接口 | 新增 `POST /miniapp/auth/password/reset`(服务内 `/password/reset`);下发验证码复用 `/miniapp/auth/sms/code`(`scene=reset`) |
+| 5 | 密码落库 | 由 `ruoyi-user` 侧 BCrypt 加密后**只更新 `account_user.password` 一列** |
+| 6 | 服务商身份 | **不额外强校验 `provider_flag`**:能收到该手机号短信即证明持有该手机号;重置后仍需通过服务商端登录接口(会强校验 `provider_flag='1'`)才能进入 |
+
+> 说明:忘记密码属于「账号找回」,本质是凭手机号验证身份改密码。若强校验 `provider_flag`,非服务商会员将无法在服务商端重置密码,且会暴露账号是否为服务商(枚举风险)。故此处不做 `provider_flag` 校验,身份门槛由「手机号 + 短信验证码」承担。
+
+---
+
+## 二、现状与差异点
+
+### 2.1 现状
+
+| 层 | 现状 |
+| --- | --- |
+| 前端页面 | `pages/login/forgot.vue` 已存在:手机号 + 验证码 + 新密码 + 确认密码 + 提交;风格为「白底 + 绿色验证码按钮 + 深蓝胶囊提交」,与登录页品牌风**不一致** |
+| 前端接口 | `api/index.js` 已埋桩:`sendSmsCode(phone)`(`scene='reset'`)→ `/miniapp/auth/sms/code`;`resetPassword({phone,code,password})` → `/miniapp/auth/password/reset` |
+| 后端接口 | **缺失**:`AccountAuthController` 无 `/password/reset` 端点 |
+| 后端短信 | `scene=reset` 走 `SmsSceneEnum.RESET`,`sendCode` 当前**不校验手机号是否已注册**;`verifyAndConsume` 对 reset **不放行通用码** |
+| 网关白名单 | `/miniapp/auth/sms/code` 已在白名单;`/miniapp/auth/password/reset` **未加白名单**(不加则报「令牌不能为空」) |
+
+### 2.2 差异点(决定本次改动)
+
+1. 新增后端重置密码端点与跨服务内部接口;
+2. 补齐 reset 场景的手机号「已注册」校验;
+3. 网关白名单补 `/miniapp/auth/password/reset`;
+4. 前端 `forgot.vue` 视觉重做 + `pages.json` 该页改 `navigationStyle: custom`。
+
+---
+
+## 三、视觉规格(对齐服务商端登录页品牌体系)
+
+换算基准:设计稿 375pt → 750rpx(pt × 2 = rpx)。
+
+| 元素 | 实现规格 |
+| --- | --- |
+| 页面底板 | 渐变 165°、rgba .8 叠白底:`#D3E4F8` 0% → `#BCD6F4` 50% → `#ABCCEC` 100% |
+| 顶部导航 | 自定义(`navigationStyle: custom`):返回箭头 + 标题「重置密码」,居中,`#0F172A` |
+| 顶部装饰 | 沿用登录页 CSS 装饰(微透十字 / 白色点阵)各 2~3 处,`pointer-events:none` |
+| 引导文案 | 主标题 34rpx / 600 `#0F172A`;副说明 24rpx `#64748B` |
+| 表单标签 | 26rpx / 500 / `#334155`,下间距 18rpx |
+| 输入框 | H88rpx / R16 / 描边 2rpx `#CBD5E1` / 底色 `#F8FAFC`,内嵌 44rpx 图标,左右内边距 28rpx |
+| 验证码行 | 输入框 `flex:1` + 「获取验证码」按钮 190rpx,品牌色描边/文字,倒计时期间置灰禁用 |
+| 密码可见性 | 右侧 44rpx 眼睛图标,品牌色 `#003E7E` |
+| 提交按钮 | H96rpx / R48,渐变 90° `#1E5EBC` → `#003E7E`,30rpx 600 白字,shadow `0 12rpx 28rpx rgba(0,62,126,.2)` |
+| 底部 Home 条 | 270×10rpx / R5 `rgba(15,23,42,.145)` |
+| 边距体系 | 内容区左右 56rpx,元素间距 40rpx,底部叠加安全区 32rpx |
+
+交互:
+
+- 获取验证码:校验手机号格式 → 调接口 → 60s 倒计时(`onUnload` 清理定时器);
+- 提交:本地校验(手机号 / 验证码非空 / 密码 ≥6 位 / 两次一致)→ 调接口 → 成功 toast → `navigateBack` 回登录页;
+- 全流程 `logging` 防重复提交。
+
+---
+
+## 四、接口设计(本次核心变更)
+
+### 4.1 接口一览
+
+| 接口 | 方法 | 网关路径 | 服务内路径 | 入参 | 返回 |
+| --- | --- | --- | --- | --- | --- |
+| 下发验证码(复用) | POST | `/miniapp/auth/sms/code` | `/sms/code` | `{ phone, scene:'reset' }` | `R.ok()` |
+| 重置密码(新增) | POST | `/miniapp/auth/password/reset` | `/password/reset` | `{ phone, code, password }` | `R.ok()` |
+
+> 网关路由 `ruoyi-miniapp-auth`(`Path=/miniapp/auth/**` + `StripPrefix=2`)已存在;**仅需把 `/miniapp/auth/password/reset` 加入白名单**。
+
+### 4.2 重置密码链路(时序)
+
+```
+POST /miniapp/auth/password/reset { phone, code, password }
+ 1. 入参校验(@NotBlank + 手机号正则 + 密码长度)
+ 2. 按手机号查询会员(Feign → GET /member/info/{phone})
+ 不存在 → 抛「该手机号未注册」
+ 3. 校验账号状态:停用 → 抛「账号已停用,请联系管理员」
+ 4. 校验并消费短信验证码(scene=reset,一次性)
+ 未获取/已失效 → 抛「验证码已失效,请重新获取」
+ 错误 → 抛「验证码错误」(累计 5 次作废)
+ 5. Feign 调 ruoyi-user 内部接口 POST /member/password/reset { phone, password }
+ → 服务内 BCrypt 加密后只更新 password 一列
+ 6. 返回 R.ok()
+```
+
+> 顺序说明:先查会员与状态、再消费验证码,与 `phoneCodeLogin` 的口径一致,避免无谓消费验证码(消费后即失效)。
+
+### 4.3 跨服务契约变更
+
+| 文件 | 变更 |
+| --- | --- |
+| `ruoyi-api-system` / `model/AccountResetPasswordRequest` | 新增:`{ phone, password }`,供内部调用传递待重置信息 |
+| `ruoyi-api-system` / `RemoteAccountUserService` | 新增 `@PostMapping("/member/password/reset")` |
+| `ruoyi-api-system` / `RemoteAccountUserFallbackFactory` | 补 `resetPassword` 降级实现 |
+
+### 4.4 短信场景补齐
+
+`SmsCodeService.sendCode` 增加 reset 场景的「手机号已注册」校验:
+
+```
+register → checkPhoneNotRegistered
+login → checkPhoneRegistered
+reset → checkPhoneRegistered // 本次新增
+common → 不校验
+```
+
+同步更新 `SmsCodeBody.scene` 的注释说明(原注释称 “reset/为空=通用下发(不校验注册状态)”,与实际不符)。
+
+---
+
+## 五、数据库设计
+
+**无 DDL 变更**(沿用 `account_user.password varchar`,BCrypt 密文;单列更新语句 `updateUserPassword`)。
+
+复用既有字段:`account_user.password`、`account_user.phone`、`account_user.status`、`account_user.del_flag`。
+
+---
+
+## 六、枚举与状态
+
+| 名称 | 取值 | 说明 |
+| --- | --- | --- |
+| `SmsSceneEnum.RESET` | `reset` | 重置密码场景(已存在,本次启用其手机号校验) |
+| `account_user.status` | `0` 正常 / `1` 停用 | 停用账号拒绝重置(与登录口径一致) |
+
+---
+
+## 七、影响范围
+
+| 类型 | 文件 | 变更 |
+| --- | --- | --- |
+| 前端页面 | `hugray-partner-miniapp/pages/login/forgot.vue` | 重做模板与样式,对齐品牌风;逻辑保持(倒计时 / 校验 / 提交) |
+| 前端配置 | `hugray-partner-miniapp/pages.json` | `pages/login/forgot` 改 `navigationStyle: custom` |
+| 前端接口 | `hugray-partner-miniapp/api/index.js` | 无需改(已有 `sendSmsCode` / `resetPassword` 桩,复核参数一致) |
+| 认证服务 | `ruoyi-auth`:新增 `ResetPasswordBody`、`PasswordResetService`;`AccountAuthController` 新增 `#resetPassword` | 重置密码编排 |
+| 认证服务 | `ruoyi-auth`:`SmsCodeService.sendCode` 补 reset 场景手机号校验 | 场景补齐 |
+| 会员服务 | `ruoyi-user`:`IAccountUserService` + impl 新增 `resetPasswordByPhone`;`AccountUserMapper` + XML 新增 `updateUserPassword`;`AccountMemberInnerController` 新增 `POST /member/password/reset` | 密码落库(服务内加密) |
+| 跨服务契约 | `ruoyi-api-system`:新增 `AccountResetPasswordRequest`;`RemoteAccountUserService` 加 `resetPassword`;`RemoteAccountUserFallbackFactory` 补降级 | Feign 契约扩展 |
+| 网关配置 | `doc/nacos/dev/ruoyi-gateway-dev.yml`(同步 Nacos) | `security.ignore.whites` 追加 `/miniapp/auth/password/reset` |
+| 数据库 | — | **无变更** |
+
+---
+
+## 八、验收清单
+
+1. 登录页「忘记密码」可跳转 `/pages/login/forgot`;✔
+2. 页面呈现浅蓝渐变底 + 装饰层 + 品牌风表单(面板式输入 + 渐变胶囊提交按钮);✔
+3. 手机号格式非法时点击「获取验证码」提示「请输入正确的手机号」,不发请求;✔
+4. 合法手机号点击「获取验证码」:接口成功 → 60s 倒计时 → 期间按钮禁用;✔
+5. 未注册手机号请求验证码 → 提示「该手机号未注册,请先注册」;✔
+6. 新密码 <6 位或两次不一致 → 本地拦截并提示;✔
+7. 正确手机号 + 正确验证码 + 新密码 → 提示「密码重置成功」并返回登录页;✔
+8. 用**新密码**在服务商端登录成功,用旧密码登录失败(提示「账号或密码错误」);✔
+9. 验证码错误累计 5 次 → 提示「验证码错误次数过多,请重新获取」;✔
+10. 验证码已消费后再提交 → 提示「验证码已失效,请重新获取」;✔
+11. 停用账号重置 → 提示「账号已停用,请联系管理员」;✔
+12. 未加白名单时请求 `/miniapp/auth/password/reset` 应报「令牌不能为空」——补白名单后不再出现。✔
+
+---
+
+## 九、待确认决策
+
+1. **重置凭据用手机号**(现状前端即为手机号):验证码发到手机,故以手机号为账号标识;服务商账号 `provider126` 对应手机号 `13800000126`。是否认可?
+2. **reset 场景是否放行通用验证码 `999999`**(`hugray.auth.universal-sms-code`):现状仅 login/register 放行。放行便于联调(无需读日志取码),但降低重置安全性。建议:**开发环境放行、生产关闭**(配置为空即关闭)。
+3. **验证码下发真实通道**:当前 `PlaceholderSmsSender` 仅打印日志,联调时验证码需从 `ruoyi-auth` 日志 `[SMS_SEND] placeholder send, phone=138****0126, code=xxxxxx` 读取。
+
+---
+
+## 十、AI 协作声明
+
+- 本文档经人工确认后方可生成/修改代码(遵循项目《AI 协作流程》条款);
+- 文档与代码同一批次提交,路径:`doc/design/服务商端_忘记密码_20261014.md`。
diff --git "a/doc/design/\346\234\215\345\212\241\345\225\206\347\253\257_\347\231\273\345\275\225_20261014.md" "b/doc/design/\346\234\215\345\212\241\345\225\206\347\253\257_\347\231\273\345\275\225_20261014.md"
new file mode 100644
index 0000000..af3bcf9
--- /dev/null
+++ "b/doc/design/\346\234\215\345\212\241\345\225\206\347\253\257_\347\231\273\345\275\225_20261014.md"
@@ -0,0 +1,172 @@
+# 服务商端小程序 登录(UI 重设计 + 专用登录接口)开发设计文档
+
+- 模块:`hugray-partner-miniapp`(前端)、`ruoyi-auth` / `ruoyi-user` / `ruoyi-api-system` / `ruoyi-gateway`(后端)
+- 日期:2026-10-14
+- 需求来源:
+ 1. “现在开始服务商小程序的开发,先把服务商小程序的登录页样式参考用户小程序的样式重新设计 ui 并开发”;
+ 2. “服务商小程序登录提示令牌不能为空,服务商只需要账号加密码登录就行,不需要其他参数,看是否重新写个接口区分开来”。
+- 风格参照:`hugray-user-miniapp/pages/login/index.vue` + `doc/design/用户端_登录注册_20261012.md` §3.5 视觉还原规格
+- 改造目标文件:
+ - 前端:`hugray-partner-miniapp/pages/login/index.vue`、`hugray-partner-miniapp/api/index.js`
+ - 后端:`ruoyi-auth`、`ruoyi-user`、`ruoyi-api-system`、网关白名单
+- 已确认决策(用户确认):
+ 1. **品牌区完整复刻用户端**:`欢迎来到` + 代谢300 Logo + 英文标语 + 医学双背书 + 产业4.0标语卡;
+ 2. **底板沿用浅蓝渐变**:165°、rgba .8 叠白底(`#D3E4F8` → `#BCD6F4` → `#ABCCEC`)+ 3 处微透十字 + 3 处白色点阵;
+ 3. **表单面板式平铺**:输入框直接铺在渐变底上,不使用白色卡片承载;
+ 4. **新接口路径** `/miniapp/auth/partner/login`(服务内 `/partner/login`),与用户端接口彻底区分;
+ 5. **强校验服务商身份**:仅 `provider_flag='1'` 的账号可登录服务商端;
+ 6. **合并错误提示**:账号不存在与密码错误统一返回「账号或密码错误」,防账号枚举。
+
+---
+
+## 一、功能概述
+
+本次包含两部分:
+
+| 序号 | 项 | 变化 |
+| --- | --- | --- |
+| 1 | 登录页视觉 | 由「深蓝导航栏 + 白底表单 + 通栏深蓝按钮」改为「用户端同款浅蓝渐变登录页(装饰层 + 品牌区 + 面板式表单 + 渐变胶囊按钮 + Home 条)」 |
+| 2 | 登录方式 | **不变**:账号 + 密码 |
+| 3 | 登录接口 | **变更**:由不存在且未白名单的 `/miniapp/auth/login` 改为新增的服务商专用接口 `/miniapp/auth/partner/login`,入参仅 `username` + `password` |
+| 4 | 服务商身份校验 | **新增**:按 username 查 `account_user`,校验 `provider_flag='1'` + 账号状态 + BCrypt 密码 |
+| 5 | 忘记密码入口 | **不变**:跳转 `/pages/login/forgot` |
+| 6 | 协议勾选与确认弹窗 | **不变**:逻辑保留,样式对齐用户端 |
+
+---
+
+## 二、报错根因(问题定位)
+
+服务商端登录报「令牌不能为空」,为**双重根因**:
+
+1. **接口不存在**:前端调用 `POST /miniapp/auth/login`,而 `ruoyi-auth` 的 `AccountAuthController` 仅有 `/sms/code`、`/phone/login`、`/phone/code-login`、`/account/register`、`/invite-qrcode`,**无用户名密码登录端点**。
+2. **白名单缺失**:`/miniapp/auth/login` 未登记在网关 `security.ignore.whites`,未携带 token 访问时被 `AuthFilter` 直接拦截,返回 `HTTP 401 + 令牌不能为空`。
+
+---
+
+## 三、现状与差异点
+
+### 3.1 现状
+
+`pages/login/index.vue` 当前为深蓝导航栏 + 白底表单风格,`pages.json` 中该页已配置 `"navigationStyle": "custom"`。
+
+### 3.2 与用户端的差异(决定本次实现差异)
+
+| 维度 | 用户端登录页 | 服务商端登录页 |
+| --- | --- | --- |
+| 登录方式 | 微信一键登录 / 手机验证登录 | **账号 + 密码** |
+| 登录接口 | `/miniapp/auth/phone/login`、`/miniapp/auth/phone/code-login` | **`/miniapp/auth/partner/login`** |
+| 底部操作区 | 两个渐变胶囊按钮 + 协议行 + 注册入口 + Home 条 | **账号输入 + 密码输入 + 忘记密码 + 协议行 + 登录按钮 + Home 条** |
+| 品牌区 | 完整(欢迎来到 + Logo + 标语 + 背书 + 标语卡) | 本次对齐为**同款** |
+| 顶部导航 | 无导航栏,内容区 `paddingTop = 状态栏 + 40px` | 同左(去掉原深蓝导航栏) |
+| 协议文案 | 《用户协议》《隐私与权限》 | **不变**(保持服务商端既有文案) |
+
+### 3.3 素材缺口与处理
+
+服务商端 `static/` 原仅有 `logo.png`(与用户端 `logo.png` 为同一「YiOUS」深蓝图,MD5 一致)。本次品牌区新增 4 个素材:
+
+| 素材 | 来源 | 用途 |
+| --- | --- | --- |
+| `metabolism-300-logo.png` | 复制自用户端 | 品牌区官方 Logo(440×152rpx) |
+| `medical-plus-icon.png` | 复制自用户端 | 医学双背书「严谨医学循证」 |
+| `check-icon.png` | 复制自用户端 | 医学双背书「传递健康价值」 |
+| `industry-4-0-slogan-card.png` | 复制自用户端 | 产业4.0核心标语卡 |
+
+> `logo.png` 保留不删(服务商端其他页面可能仍在引用)。
+
+---
+
+## 四、视觉规格(复用用户端已落地设计体系)
+
+换算基准:设计稿 375pt → 750rpx(pt × 2 = rpx)。
+
+| 元素 | 实现规格 |
+| --- | --- |
+| 页面底板 | 渐变 165°、rgba .8 叠白底:`#D3E4F8` 0% → `#BCD6F4` 50% → `#ABCCEC` 100% |
+| 微透十字 ×3 | 右上 160rpx(条宽 44)/ 中左 136rpx(条宽 36)/ 左下 200rpx(条宽 56) |
+| 白色点阵 ×3 | 左上 / 右上 / 右下,radial-gradient 平铺,步距 26rpx |
+| 内容区顶距 | `paddingTop = statusBarHeight + 40px`;左右 56rpx;元素间距 40rpx |
+| 欢迎语 | 「欢迎来到」52rpx / 500 / `#0F172A`,居中 |
+| 官方 Logo | `metabolism-300-logo.png` 440×152rpx |
+| 英译标语 | 「RESET METABOLISM & DELIVER HEALTH」22rpx / 600 / `#003E7E` / 字距 3rpx |
+| 医学双背书 | 图标 30rpx / 36.4×30rpx,项内 gap 12rpx、两项 gap 48rpx;文案 24rpx 600 `#1E293B` |
+| 标语卡 | `industry-4-0-slogan-card.png` 759×304rpx,上下负边距 -48rpx / -72rpx |
+| 表单标签 | 26rpx / 500 / `#334155`,下间距 18rpx |
+| 输入框 | H88rpx / R16 / 描边 2rpx `#CBD5E1` / 底色 `#F8FAFC`,内嵌 44rpx 图标(账号 / 盾牌),左右内边距 28rpx |
+| 忘记密码 | 右对齐,26rpx / 600 / `#1E5EBC` |
+| 登录按钮 | H96rpx / R48,渐变 90° `#1E5EBC` → `#003E7E`,30rpx 600 白字,shadow `0 12rpx 28rpx rgba(0,62,126,.2)` |
+| 协议勾选行 | 复选框 32rpx / R8(选中 `#003E7E`)+ 文案 26rpx(正文 `#64748B` / 链接 `#1E5EBC`) |
+| 底部 Home 条 | 270×10rpx / R5 `rgba(15,23,42,.145)` |
+| 边距体系 | 底部区左右 56rpx,叠加安全区 32rpx |
+| 协议确认弹窗 | 560rpx 宽 / R24,标题 34rpx 600 `#003E7E`,正文 28rpx `#64748B`,按钮行「取消 `#334155` / 我已阅读并同意 `#1E5EBC`」 |
+
+---
+
+## 五、接口设计(本次核心变更)
+
+### 5.1 接口一览
+
+| 接口 | 方法 | 网关路径 | 服务内路径 | 入参 | 返回 |
+| --- | --- | --- | --- | --- | --- |
+| 服务商登录(新增) | POST | `/miniapp/auth/partner/login` | `/partner/login` | `{ username, password }` | `{ access_token, expires_in }` |
+
+> 网关路由 `ruoyi-miniapp-auth`(`Path=/miniapp/auth/**` + `StripPrefix=2`)已存在,无需新增路由;**仅需把新路径加入白名单**。
+
+### 5.2 服务商身份模型(关键认知)
+
+服务商**不是独立用户体系**,而是「会员(`account_user`)的另一重身份」:
+
+- 登录账号 / 密码直接用 `account_user.username / password`(BCrypt 密文);
+- `account_provider` 表只存公司资质与权益配置,用 `user_id` 与账号 1:1 关联;
+- `account_user.provider_flag = '1'` 标识该账号为服务商。
+
+### 5.3 登录鉴权链路
+
+```
+POST /miniapp/auth/partner/login { username, password }
+ 1. 入参非空校验(@NotBlank)
+ 2. Feign 调 ruoyi-user 内部接口 GET /member/info/username/{username}
+ → AccountMember(含 password 密文、providerFlag、status)
+ 3. 账号不存在 → 抛「账号或密码错误」
+ 4. providerFlag != '1' → 抛「该账号不是服务商账号」
+ 5. status == '1'(停用) → 抛「账号已停用,请联系管理员」
+ 6. BCrypt matchesPassword(password, member.password) 失败 → 抛「账号或密码错误」
+ 7. 组装 LoginUser → TokenService.createToken → 返回 { access_token, expires_in }
+```
+
+### 5.4 跨服务契约变更
+
+`AccountMember`(`ruoyi-api-system`)新增两个字段:`password`(BCrypt 密文,仅供 auth 校验,且 AccountMember 全程不下发到 C 端)、`providerFlag`(服务商标识)。
+
+> 说明:`AccountMember` 仅用于内部登录链路,`AccountAuthController` 对外返回的是令牌 Map 或空 R,不会把 member 直接下发,故不存在密码泄露风险。
+
+---
+
+## 六、影响范围
+
+| 类型 | 文件 | 变更 |
+| --- | --- | --- |
+| 前端页面 | `hugray-partner-miniapp/pages/login/index.vue` | 重写模板与样式;登录收尾逻辑修正 token 字段(`data.access_token`)与入参简化 |
+| 前端接口 | `hugray-partner-miniapp/api/index.js` | `login()` 改指 `/miniapp/auth/partner/login`,入参简化为 `{ username, password }` |
+| 静态资源 | `hugray-partner-miniapp/static/*` | 新增 4 个图片素材(复制自用户端) |
+| 认证服务 | `ruoyi-auth` 新增 `PartnerLoginBody` / `PartnerLoginService` / `AccountAuthController#partnerLogin` | 新增服务商登录端点与编排逻辑 |
+| 会员服务 | `ruoyi-user`:`IAccountUserService` + impl 新增 `selectAccountUserByUsername`;`AccountMemberInnerController` 新增 `/info/username/{username}` 并补充 password/providerFlag 映射 | 提供按用户名查询内部能力 |
+| 跨服务契约 | `ruoyi-api-system`:`AccountMember` 加字段;`RemoteAccountUserService` 加 `getMemberByUsername`;`RemoteAccountUserFallbackFactory` 补降级 | Feign 契约扩展 |
+| 网关配置 | `doc/nacos/dev/ruoyi-gateway-dev.yml`(同步 Nacos 配置中心) | `security.ignore.whites` 追加 `/miniapp/auth/partner/login` |
+| 数据库 | — | **无变更** |
+
+---
+
+## 七、验收清单
+
+1. 登录页呈现浅蓝渐变底 + 3 十字 + 3 点阵装饰,风格与用户端一致;✔
+2. 品牌区完整(欢迎来到 + 代谢300 Logo + 英文标语 + 医学双背书 + 标语卡);✔
+3. 账号 / 密码输入框为面板式平铺(H88 / R16 / 浅底描边),带品牌图标;✔
+4. 「忘记密码」右对齐可点击,跳转 `/pages/login/forgot`;✔
+5. 协议勾选行 + 未勾选时弹确认弹窗,逻辑与现状一致;✔
+6. 登录按钮为渐变胶囊(`#1E5EBC → #003E7E`),点击可正常登录并 `reLaunch` 首页;✔
+7. 底部居中 Home 条;✔
+8. 页面无横向滚动 / 无内容溢出裁切。✔
+9. 服务商账号 + 正确密码 → 登录成功,`reLaunch` 首页;✔
+10. 非服务商账号 → 提示「该账号不是服务商账号」;✔
+11. 账号不存在或密码错误 → 统一提示「账号或密码错误」;✔
+12. 停用账号 → 提示「账号已停用,请联系管理员」。✔
diff --git "a/doc/design/\346\234\215\345\212\241\345\225\206\347\253\257_\351\246\226\351\241\265\346\246\202\350\247\210_20261014.md" "b/doc/design/\346\234\215\345\212\241\345\225\206\347\253\257_\351\246\226\351\241\265\346\246\202\350\247\210_20261014.md"
new file mode 100644
index 0000000..ae93bd9
--- /dev/null
+++ "b/doc/design/\346\234\215\345\212\241\345\225\206\347\253\257_\351\246\226\351\241\265\346\246\202\350\247\210_20261014.md"
@@ -0,0 +1,174 @@
+# 服务商端首页概览开发设计
+
+日期:2026-10-14
+状态:已确认,实施中(布局已确认;返回规范按 Result 语义落地)
+
+## 1. 功能概述与范围
+
+重设计服务商小程序首页,保留原型的业务指标,参考 `doc/琥歌瑞0928.pen` 的品牌语言,不照搬原型的表格式排版。
+
+工程沿用现有 uni-app、Vue 3、script setup 和 SCSS;后端沿用 ruoyi-user 的 Controller → Service → Mapper 分层。首页概览接口已存在,本次先复用并核验,不重复开发一套统计接口。
+
+交付范围:首页布局与样式、真实接口接入、统计口径必要修正、查看子账号、退出登录、加载与失败重试。子账号增删改查和月份切换不在本次范围内。
+
+## 2. UI 布局与样式
+
+### 2.1 设计依据
+
+已通过 pen 工具读取设计规范节点 PYopU 与色彩体系节点 E8QJf:
+
+- 品牌深蓝 #003E7E,次按钮及链接 #1E5EBC,科技蓝 #2D91FF。
+- 生机绿 #7CB342,数值强调绿 #2FA05E。
+- 页面底色 #F8FAFC,主标题 #0F172A,说明文字 #64748B。
+- 品牌浅蓝渐变 #D3E4F8 → #BCD6F4 → #ABCCEC。
+- 白色圆角卡片、轻描边和低透明度阴影。
+
+### 2.2 页面结构示意
+
+```text
+品牌浅蓝渐变头部
+ 代谢300 · 服务商工作台
+ 公司名称(可换行) 退出登录
+
+白色账号概览卡
+ 子账号概览 查看全部 →
+ 开通总数(大数字)
+ L1 人数 L2 人数 L3 人数
+
+月度经营区域 YYYY年MM月
+ 合计收益(主卡,大数字,环比说明)
+ 新增绑定(次卡,人数,环比说明)
+ 销售额 进货额 销售单数
+
+白色邀请概览卡
+ 本月新邀请 总人数
+ L0 L1 L2 L3
+
+统计说明与底部安全区
+```
+
+将原型中的大表格拆成不同阅读层级:收益为主要视觉焦点,账号等级与邀请等级采用紧凑分布行,交易指标用独立三列区域。首屏优先覆盖公司信息、账号概览和月度收益。
+
+### 2.3 适配规范
+
+- 以 750rpx 为设计基准,页边距 32rpx,卡片间距 24rpx,卡片圆角 28–32rpx。
+- 标题约 32rpx,正文 26–28rpx,说明 22–24rpx,主收益数字约 52rpx。
+- 公司名称允许换行,退出按钮不被挤压;点击区至少约 88rpx。
+- 金额由分转换为元,显示两位小数并添加千位分隔;超长金额可换行,避免三列溢出。
+- 遵循现有工程导航栏模式,使用原生状态栏和微信胶囊,不在页面伪造系统控件。
+- 底部使用 safe-area-inset-bottom;320px 窄屏、大数字和长公司名纳入验收。
+- 装饰优先使用渐变和轻量 CSS 图形,不新增无业务意义的图片。
+
+## 3. 接口设计
+
+### 3.1 首页概览
+
+- 请求:GET /miniapp/account/partner/overview。
+- 入参:无;不允许客户端传服务商 ID。
+- 当前 Controller 从 SecurityUtils.getUserId() 获取登录用户 ID。
+- 当前返回包装为项目既有 R<PartnerOverviewVo>,首页仅发起一次聚合请求。
+
+核心出参:
+
+| 字段 | 类型 | 含义 |
+| --- | --- | --- |
+| companyName | String | 服务商公司名称 |
+| subAccount.totalCount | Integer | 开通子账号总数 |
+| subAccount.levels | Array | L1/L2/L3 等级与人数 |
+| performance.monthEarning | Long | 本月子账号合计收益,分 |
+| performance.monthEarningTrend | Integer/null | 与上月收益相比的整数百分比 |
+| performance.monthNewBinding | Integer | 本月新增绑定数 |
+| performance.monthNewBindingTrend | Integer/null | 新增绑定环比百分比 |
+| performance.monthSalesAmount | Long | 本月销售额,分 |
+| performance.monthPurchaseAmount | Long | 本月进货额,分 |
+| performance.monthSalesCount | Integer | 本月销售记录数量 |
+| performance.monthNewInviteTotal | Integer | 本月新邀请会员总人数 |
+| performance.monthNewLevels | Array | C/L1/L2/L3 等级与人数;C 显示为 L0 |
+
+### 3.2 返回规范(Result 结论)
+
+人工确认:遵循 Result 规范。核查结论——工程唯一通用返回类为 `ruoyi-common-core` 的 `R<T>`,结构即 `{ code, msg, data }`,成功码 `Constants.SUCCESS = 200`;工程不存在名为 `Result` 的类,全局异常处理器统一返回同样为 `{ code, msg, data }` 的 `AjaxResult`。
+
+因此按 Result 规范语义(统一返回结构、薄 Controller、异常统一由全局处理器兜底)复用工程既有 `R<T>` 落地本接口,不新建第二套通用返回类,避免违反「全工程唯一」并保持前端 `request.js` 对 `code === 200` 的强校验合同不变。前端请求适配无需改动。
+
+### 3.3 页面关联操作
+
+- 查看子账号:跳转 /pages/sub-account/index。
+- 退出登录:确认弹窗 → 调用 POST /miniapp/auth/logout → 清理工程现有认证缓存 → reLaunch 至现有登录页。
+- 退出请求失败:展示失败提示并记录上下文,保留登录态供用户重试。
+- 首页刷新:首次进入、返回首页和下拉刷新按页面生命周期加载,避免并发重复请求。
+- 月份与后端一致性:实现时核对服务端与客户端时区;如存在跨月差异风险,接口补充统计月份字段并以服务端返回为准。
+
+## 4. 数据库设计与统计口径
+
+本次不新增业务表;已存在以下数据源:
+
+| 指标 | 数据源 | 归属与时间 |
+| --- | --- | --- |
+| 公司名称 | account_provider | user_id = 当前登录用户,del_flag = 0 |
+| 子账号总数与等级 | account_user | supplier_id = 当前登录用户,del_flag = 0 |
+| 新增绑定 | account_user | supplier_bind_time 落在本月/上月 |
+| 收益 | account_earning_record | user_id 关联名下子账号,create_time 归属月份 |
+| 销售额与单数 | account_sales_record | seller_id 关联名下子账号,sale_time 归属月份 |
+| 进货额 | account_purchase_order | user_id 关联名下子账号,purchase_time 归属月份 |
+| 新邀请 | account_user | 推荐人为服务商本人或名下子账号,referrer_bind_time 归属月份 |
+
+- 月份区间统一为 [月初, 下月月初),上月同理。
+- 开通数量采用当前未逻辑删除的已授权子账号数量;被停用但未删除的账号仍计入。
+- 邀请分布使用查询时的当前等级,C/null 显示 L0。
+- 收益是名下子账号收益汇总,不标注为服务商个人收益。
+- 销售单数沿用现有销售记录计数,需核实是否与业务订单一一对应。
+- 当前关系归属查询按现有 supplier_id/referrer_id 计算,不具备历史转移时点快照。
+- 实现前核实收益流水有效状态、进货订单有效状态、各金额字段单位和实际索引。仅修正有证据的错误,不臆造过滤状态。
+- 收益 SQL 已在 WHERE 补充上月月初至下月月初范围过滤(命中 account_earning_record 的 (user_id, create_time) 索引),减少无关历史数据聚合;TABLE 无 del_flag 字段,故不追加删除态过滤。本次不改数据库结构,无需 DDL 变更脚本。
+- 必要索引或字段调整必须提供 doc/sql 下变更与回滚脚本;目前未提出 DDL 变更。
+
+## 5. 业务流程
+
+```text
+进入首页
+ → 读取已有登录态
+ → 请求概览接口
+ → 服务端获取当前用户 ID
+ → 校验服务商档案与资格
+ → 核验账号标志/状态由鉴权层或业务层覆盖
+ → 确定统计月份边界
+ → Mapper 批量聚合名下数据
+ → Service 补零并计算环比
+ → 返回概览
+ → 页面格式化金额、等级和环比
+```
+
+环比计算:(本月值 - 上月值) / 上月值 × 100,取整数。上月为 0 返回 null,页面显示“暂无上月对比”;正值显示提升,负值显示下降,0 显示持平。趋势同时使用文案和颜色表达。
+
+## 6. 枚举、状态与异常
+
+- 服务商资格沿用 AccountProvider.STATUS_NORMAL/STATUS_DISABLED。
+- 等级复用工程现有 MemberLevelEnum,实施前核对编码,避免新增重复枚举。
+- 后端异常通过工程全局异常处理器返回,Controller 保持薄层。
+- 页面状态为加载中、已成功、请求失败;无数据的成功结果展示 0,失败不使用模拟数据替代。
+- 刷新失败保留已取得的数据并明确提示,首次失败显示重试入口。
+- 禁用服务商和未登录场景沿用现有认证错误处理,核实网关保护及服务端入口约束。
+
+## 7. 影响范围
+
+主要文件:
+
+- hugray-partner-miniapp/pages/index/index.vue:页面实现。
+- hugray-partner-miniapp/api/index.js:复用 getPartnerOverview/logout,必要时调整接口说明。
+- hugray-partner-miniapp/pages.json:仅按需设置导航栏与下拉刷新。
+- ruoyi-user 的 AccountPartnerController、AccountPartnerServiceImpl、AccountPartnerMapper 及对应 XML、PartnerOverviewVo:按核验结果做必要调整。
+- 现有认证缓存和请求封装:先读取,优先复用,不扩大重构范围。
+
+不改动登录页品牌设计,不扩展子账号管理功能,不自动提交或推送 Git。
+
+## 8. 验收与验证
+
+- 后端:核验无档案、禁用资格、无子账号、跨服务商隔离、月份边界、上月基数为 0、负向环比、空聚合补零。核心调整使用 Mock 单测,外部依赖不连接真实数据库。
+- 前端:核验金额单位、L0 映射、0 数据、正负环比、失败重试、重复加载保护、退出登录与导航。
+- 视觉:检查正常/长公司名、320px 窄屏、大金额、底部安全区与点击反馈。
+- 验证结果区分静态检查、编译、自动化测试和真实联调;缺少运行环境时明确报告限制。
+
+## 9. 确认结论
+
+已确认:页面结构按第 2.2 节落地;统计口径按第 4 节执行;返回规范按第 3.2 节 Result 结论落地(复用工程既有 R<T>,成功码 200,前端合同不变)。据此实施页面与必要后端调整,不再新建通用返回类。
diff --git "a/doc/design/\347\224\250\346\210\267\347\253\257_\344\270\252\344\272\272\344\270\255\345\277\203_20261013.md" "b/doc/design/\347\224\250\346\210\267\347\253\257_\344\270\252\344\272\272\344\270\255\345\277\203_20261013.md"
index cf76667..8ad8db4 100644
--- "a/doc/design/\347\224\250\346\210\267\347\253\257_\344\270\252\344\272\272\344\270\255\345\277\203_20261013.md"
+++ "b/doc/design/\347\224\250\346\210\267\347\253\257_\344\270\252\344\272\272\344\270\255\345\277\203_20261013.md"
@@ -18,7 +18,7 @@
| 姓名 `nickname` | 头部文本 | mock `辉煌G891` | 是(`account_user.nickname`;注册未采集姓名字段,落库为空,前端回退展示脱敏手机号) |
| 手机号 `phone`(脱敏) | 头部名称回退 | 无 | 是(`account_user.phone`,脱敏为 `133****5555`,不下发明文) |
| 会员等级 `memberLevel` | 等级胶囊 `C`/`L1~L4` | mock `L4` | 是(复用既有 `account_user.system_level`) |
-| 头像 `avatar` | 头部头像 | 相机图标占位 | 是(已存在字段,有值前端渲染图片,无值回退图标) |
+| 头像 `avatar` | 头部头像 | 相机图标占位(「我的资料」页已支持点击上传更换) | 是(已存在字段,有值前端渲染图片,无值回退图标) |
| 今日 / 本月 / 累计收益 | 收益玻璃卡三栏 | mock `10.00 / 400.00 / 128000.00` | 是(新增收益流水表,实时聚合) |
| 仓库订单角标数量 | 菜单角标 | mock `1` | 否(见 2.5,无数据来源,本期不入接口) |
diff --git "a/doc/design/\347\224\250\346\210\267\347\253\257_\344\274\201\344\270\232\344\270\252\344\275\223\350\256\244\350\257\201_20261013.md" "b/doc/design/\347\224\250\346\210\267\347\253\257_\344\274\201\344\270\232\344\270\252\344\275\223\350\256\244\350\257\201_20261013.md"
new file mode 100644
index 0000000..1efc3b2
--- /dev/null
+++ "b/doc/design/\347\224\250\346\210\267\347\253\257_\344\274\201\344\270\232\344\270\252\344\275\223\350\256\244\350\257\201_20261013.md"
@@ -0,0 +1,330 @@
+# 用户端小程序 企业/个体商户认证 开发设计文档
+
+- 模块:ruoyi-user(会员业务)+ ruoyi-gateway(路由/鉴权头注入)+ hugray-admin(后台主体认证审核)+ hugray-user-miniapp(前端)
+- 日期:2026-10-13
+- 需求来源:"继续实现企业、个体商户的认证,字段只做最基础的必填校验,根据原型设计ui稿并实现"
+- 原型:4 张 UI 稿(企业信息 / 法人信息 / 收款信息 / 提交审核 四步向导)
+- 关联文档:`doc/design/用户端_实名认证_20261013.md`(个人实名认证,本期复用其提交/审核链路范式)
+- 数据权威源:`192.168.110.111:3306/hugray-account`(实际表结构优先于本文档)
+
+---
+
+## 一、功能概述
+
+「我的资料」页(`pages/profile/edit.vue`)「企业/个体户认证」行当前点击仅弹占位 toast,本次改为**跳转到企业/个体认证四步向导页**:
+
+1. 新增 C 端页面 `pages/profile/company-certification.vue`,顶部为**四段式步骤条**(企业信息 / 法人信息 / 收款信息 / 提交审核),已完成打勾、当前高亮,底部分步表单 + 「下一步 / 提交资料」按钮(原型见 4 张 UI 稿);
+2. 页面**只做基础必填校验**(名称/代码/姓名/电话/邮箱/卡号/图片等非空),**不接入任何格式/三方核验**;
+3. 提交后落 `account_certification_apply`(`subject_type` 取 2 个体工商户 / 3 企业、`audit_status='0'` 待审核),并联动 `account_user.company_auth_status='1'`(企业认证中),使其出现在后台**主体认证审核列表**;
+4. 「我的资料」页「企业/个体户认证」徽标改为按后端新下发的 `companyAuthStatus` 显示真实状态(未认证 / 认证中 / 已认证 / 认证失败),与「实名认证」徽标**相互独立**;
+5. 复核进入时**回填**已提交资料与状态:审核中 → 直接进入第 4 步「资料审核中」占位页;已通过 → 展示已通过提示;已驳回 → 回到第 1 步空表单并提示驳回原因,可修改重提。
+
+后台「审」链路(列表 / 详情 / 通过 / 驳回)**已存在**,本次做两处增强:审核结果按主体类型分流回写 `auth_status` / `company_auth_status`;审核详情页补展示企业认证新增字段。
+
+---
+
+## 二、现状与关键约束
+
+### 2.1 数据模型已预留主体类型
+
+`account_certification_apply.subject_type`(`char(1)`)取值语义已预置:**1个人 2个体工商户 3企业**,个人实名走 `'1'`,企业/个体走 `'2'`/`'3'`,**无需改类型语义,只需补列**。
+
+### 2.2 现有表缺列,需补 DDL
+
+`account_certification_apply` 与 `account_certification` 现有企业相关列仅到「营业执照」为止(`company_name`、`credit_code`、`legal_person`、`registered_region`、`company_address`、`register_date`、`license_valid_period`、`license_image`)。原型所需的**营业执照有效期类型、法人身份证正反面、法人身份证有效期、法人身份证详细地址、负责人姓名/电话/邮箱、收款类型、银行卡照片、银行卡号、开户城市**共 13 列缺失,需补。
+
+### 2.3 个人 / 企业认证状态必须隔离
+
+`account_user` 当前只有一个 `auth_status`(个人实名认证状态),而个人中心需**两行独立徽标**(「实名认证」/「企业个体户认证」)。若企业认证复用 `auth_status`,提交企业认证会同时把「实名认证」徽标改成「认证中」,语义错误。故**新增 `account_user.company_auth_status`**,个人 / 企业互不覆盖(已确认)。
+
+### 2.4 C 端状态查询需按主体类型隔离
+
+现有 `selectLatestApplyByUserId` / `countPendingApplyByUserId` **不区分主体类型**,企业认证上线后会把企业申请当作个人申请回填/防重。本次为两者都加主体类型过滤:个人限 `subject_type='1'`,企业限 `subject_type in ('2','3')`。
+
+### 2.5 路径规划
+
+- 网关注入 `user_id`;`SecurityUtils.getUserId()` 取会员身份,**C 端接口不接受前端传 userId**(防水平越权)。
+- 在既有 C 端个人中心控制器 `AccountProfileController`(`@RequestMapping("/user")`,外部 `/miniapp/account/user/**`)上**新增**两条企业认证接口,与个人认证接口并列。
+- 与后台 `AccountCertificationApplyController`(外部 `/account/user/certification/**`)**前缀不同域**,无冲突;网关 `/miniapp/account/**` 路由已存在,**无需改网关**。
+
+### 2.6 主键与落库策略
+
+`account_certification_apply.id` 为 `bigint AUTO_INCREMENT`,沿用既有 `useGeneratedKeys="true" keyProperty="id"`;列按 `<if>` 动态拼,未填字段不落列。
+
+---
+
+## 三、接口设计
+
+统一响应体:C 端 `R<T>`(成功 `code=200`)。
+
+### 3.1 提交企业/个体认证(本次新增)
+
+- **外部路径**:`POST /miniapp/account/user/company-certification`(网关 `StripPrefix=2` → 服务内 `POST /user/company-certification`)
+- **鉴权**:需登录,未登录由 `AuthFilter` 直接 401;会员身份取自网关注入的 `user_id` 头,**不接受前端传 userId**。
+- **入参**:JSON(`CompanyCertificationApplyDto`),字段与校验见下表:
+
+| 步骤 | 字段 | 类型 | 必填 | 校验 | 说明(对应 DB 列) |
+| --- | --- | --- | --- | --- | --- |
+| 企业信息 | subjectType | String | 是 | `@NotBlank` + `@Pattern(^[23]$)` | 主体类型:2个体工商户 / 3企业(`subject_type`) |
+| 企业信息 | companyName | String | 是 | `@NotBlank`+`@Size(max=128)` | 企业/个体名称(`company_name`) |
+| 企业信息 | licenseImage | String | 是 | `@NotBlank`+`@Size(max=500)` | 营业执照图片地址(`license_image`) |
+| 企业信息 | creditCode | String | 是 | `@NotBlank`+`@Size(max=32)` | 统一社会信用代码(`credit_code`) |
+| 企业信息 | licenseValidType | String | 是 | `@NotBlank`+`@Pattern(^[12]$)` | 营业执照有效期类型:1长期 / 2短期(`license_valid_type`) |
+| 企业信息 | licenseValidPeriod | String | 短期必填 | `@Size(max=32)`;类型=2 时服务层必填 | 营业执照有效期起止(`license_valid_period`,存 `2024-01-01~2034-01-01`) |
+| 企业信息 | registeredRegion | String | 是 | `@NotBlank`+`@Size(max=128)` | 营业执照注册地区(`registered_region`) |
+| 企业信息 | companyAddress | String | 是 | `@NotBlank`+`@Size(max=255)` | 营业执照详细地址(`company_address`) |
+| 法人信息 | legalPerson | String | 是 | `@NotBlank`+`@Size(max=64)` | 法人姓名(`legal_person`) |
+| 法人信息 | legalIdCardFront | String | 是 | `@NotBlank`+`@Size(max=500)` | 法人身份证人像面图片(`legal_id_card_front`) |
+| 法人信息 | legalIdCardBack | String | 是 | `@NotBlank`+`@Size(max=500)` | 法人身份证国徽面图片(`legal_id_card_back`) |
+| 法人信息 | legalIdValidType | String | 是 | `@NotBlank`+`@Pattern(^[12]$)` | 法人身份证有效期类型(`legal_id_valid_type`) |
+| 法人信息 | legalIdValidPeriod | String | 短期必填 | `@Size(max=32)`;类型=2 时服务层必填 | 法人身份证有效期起止(`legal_id_valid_period`) |
+| 法人信息 | legalIdAddress | String | 是 | `@NotBlank`+`@Size(max=255)` | 法人身份证详细地址(`legal_id_address`) |
+| 法人信息 | contactName | String | 是 | `@NotBlank`+`@Size(max=64)` | 负责人姓名(`contact_name`) |
+| 法人信息 | contactPhone | String | 是 | `@NotBlank`+`@Size(max=20)` | 负责人联系电话(`contact_phone`) |
+| 法人信息 | contactEmail | String | 是 | `@NotBlank`+`@Size(max=128)` | 负责人邮箱(`contact_email`) |
+| 收款信息 | payeeType | String | 是 | `@NotBlank`+`@Size(max=16)` | 收款类型(`payee_type`) |
+| 收款信息 | bankCardImage | String | 是 | `@NotBlank`+`@Size(max=500)` | 银行卡号码面照片(`bank_card_image`) |
+| 收款信息 | bankCardNo | String | 是 | `@NotBlank`+`@Size(max=32)` | 银行卡号(`bank_card_no`) |
+| 收款信息 | bankCity | String | 是 | `@NotBlank`+`@Size(max=128)` | 开户城市(`bank_city`) |
+
+- **出参**:`R<Void>`(成功即 `code=200`)
+- **错误提示**:字段级 `@NotBlank`/`@Pattern` 提示 + 业务提示「短期有效期必须填写起止日期」/「已有企业认证申请正在审核中,请勿重复提交」/「主体类型不合法」。
+
+### 3.2 查询企业/个体认证状态(本次新增)
+
+- **外部路径**:`GET /miniapp/account/user/company-certification`(→ 服务内 `GET /user/company-certification`)
+- 取该会员**最近一次**企业/个体申请(`subject_type in ('2','3')`,`del_flag='0'`,`id desc limit 1`),出参 `CompanyCertificationStatusVo`:
+
+| 字段 | 说明 |
+| --- | --- |
+| subjectType / companyName / licenseImage / creditCode / licenseValidType / licenseValidPeriod / registeredRegion / companyAddress | 企业信息回填 |
+| legalPerson / legalIdCardFront / legalIdCardBack / legalIdValidType / legalIdValidPeriod / legalIdAddress | 法人信息回填 |
+| contactName / contactPhone / contactEmail | 负责人信息回填 |
+| payeeType / bankCardImage / bankCardNo / bankCity | 收款信息回填 |
+| auditStatus | 0待审核 1已通过 2已驳回;**为空表示从未提交** |
+| auditRemark | 驳回原因(`audit_status='2'` 时有值) |
+
+无申请时返回**空字段对象**(各字段为 null),前端按「未认证」处理,不返回 404。
+
+### 3.3 查询会员信息(本次扩展)
+
+- **外部路径**:`GET /miniapp/account/user/info`(既有)
+- **改动**:出参 `AccountUserInfoVo` 新增 `companyAuthStatus` 字段(`account_user.company_auth_status` 原值 0/1/2/3),供「我的资料」页「企业/个体户认证」徽标回显。
+
+### 3.4 后台审核链路(既有,增强)
+
+`GET /account/user/certification/list`、`GET /account/user/certification/detail/{id}`、`PUT /account/user/certification/audit`(权限 `member:certification:audit`)**沿用**。企业申请同链路进入列表(`subject_type='2'/'3'`、待审核在前)。增强点:
+
+- **审核分流**:通过 / 驳回时按 `apply.subjectType` 写回——个人写 `auth_status`,企业/个体写 `company_auth_status`(通过时同步 `account_user.subject_type`);
+- **详情展示**:`detail.vue` 补展示企业/个体新增字段(见 7.3)。
+
+---
+
+## 四、数据库设计
+
+库:`hugray-account`。新增 DDL 脚本:`hugray-server/sql/hugray_account_company_certification_20261013.sql`(沿用既有幂等过程 `hugray_add_column` 风格;与代码同一次提交)。
+
+### 4.1 `account_certification_apply` / `account_certification` 各新增 13 列
+
+| 列 | 类型 | 注释 |
+| --- | --- | --- |
+| license_valid_type | varchar(16) | 营业执照有效期类型(1长期 2短期) |
+| legal_id_card_front | varchar(500) | 法人身份证人像面图片 |
+| legal_id_card_back | varchar(500) | 法人身份证国徽面图片 |
+| legal_id_valid_type | varchar(16) | 法人身份证有效期类型(1长期 2短期) |
+| legal_id_valid_period | varchar(32) | 法人身份证有效期(长期/起止文本) |
+| legal_id_address | varchar(255) | 法人身份证详细地址 |
+| contact_name | varchar(64) | 负责人姓名 |
+| contact_phone | varchar(20) | 负责人联系电话 |
+| contact_email | varchar(128) | 负责人邮箱 |
+| payee_type | varchar(16) | 收款类型 |
+| bank_card_image | varchar(500) | 银行卡号码面照片 |
+| bank_card_no | varchar(32) | 银行卡号 |
+| bank_city | varchar(128) | 开户城市 |
+
+### 4.2 `account_user` 新增 1 列
+
+| 列 | 类型 | 注释 |
+| --- | --- | --- |
+| company_auth_status | char(1) DEFAULT '0' | 企业/个体认证状态(0未认证 1审核中 2已认证 3认证失败) |
+
+> `license_valid_period` 沿用既有 `varchar(32)`:`2024-01-01~2034-01-01` 共 21 字符,够用,不改类型。
+
+---
+
+## 五、流程设计
+
+### 5.1 前端四步向导(`pages/profile/company-certification.vue`)
+
+```
+STEP 1: onShow 校验登录态(ensureLogin),未登录跳登录页
+STEP 2: 调 GET /company-certification 拉取状态与资料
+STEP 3: 按 auditStatus 决定首屏:
+ '' 未认证 → 停在第 1 步空表单
+ '2' 已驳回 → 停在第 1 步并回填资料,展示驳回原因
+ '0' 审核中 → 直接进入第 4 步「资料审核中」占位页
+ '1' 已通过 → 第 4 步展示「认证已通过」
+STEP 4: 第 1~3 步表单填写,点「下一步」逐级校验当前步必填项,通过则 step+1,否则 toast 首个缺失项
+STEP 5: 第 3 步(收款信息)按钮为「提交资料」,点后整表单再校验一次,通过则 POST 提交
+STEP 6: 成功后进入第 4 步「您的资料正在审核中,请耐心等待」;失败提示后端信息
+STEP 7: 步骤条已完成的步骤打勾,可点已完成步骤回退修改(未提交前)
+```
+
+交互细节(对齐原型):
+- **有效期类型 = 长期** 时,其**起止日期行隐藏**且不参与校验;= 短期 时显示且必填(已确认)。
+- 「营业执照注册地区」「开户城市」用 `<picker mode="region">`(沿用 `pages/address/add.vue` 范式);「营业执照详细地址」「法人身份证详细地址」为文本输入。
+- 图片项(营业执照 / 法人身份证人像面 / 法人身份证国徽面 / 银行卡号码面)统一走 `uni.chooseImage` + `uploadAvatarFile`(`/file/upload`)拿到 url 后存本地表单,**均为必填**(已确认)。
+- 有效期起止日期用两个 `<picker mode="date">`(开始 / 截止)并列在同一行内展示,回填时拼为 `起~止` 文本;存储为 `license_valid_period` / `legal_id_valid_period` 文本。
+
+### 5.2 后端提交(`submitCompanyCertification`)
+
+```
+STEP 1: 校验 userId 非空(未登录由网关拦截,此处兜底),为空抛业务异常
+STEP 2: 校验 subjectType ∈ {2 个体工商户, 3 企业};非法抛「主体类型不合法」
+STEP 3: 有效期类型=短期(2) 时,对应起止日期必填(服务层兜底,防绕过 Controller)
+STEP 4: 防重闸门:该会员若存在 subject_type in ('2','3') 且 audit_status='0' 的未删除申请,
+ 抛「已有企业认证申请正在审核中,请勿重复提交」
+STEP 5: 组装申请快照:subject_type、各资料字段、audit_status='0'、create_by=登录用户名
+STEP 6: insertCertificationApply 落 account_certification_apply(自增回填 id)
+STEP 7: 联动 account_user.company_auth_status='1'(企业认证中,单列更新语句)
+STEP 8: STEP 6/7 同事务(@Transactional),任一失败整体回滚
+```
+
+### 5.3 审核分流(`auditCertificationApply` 增强)
+
+```
+STEP 1: 既有并发闸门(where audit_status='0')+ 驳回必填意见 不变
+STEP 2: 取 apply.subjectType 判断分支:
+ subject_type='1'(个人)→ 写 account_user.auth_status(2 通过 / 3 失败),行为同现状
+ subject_type∈{'2','3'} → 写 account_user.company_auth_status(2 通过 / 3 失败)
+ 且通过时同步 account_user.subject_type = apply.subjectType
+STEP 3: 通过分支继续 saveCertificationSnapshot(补全企业新增字段)
+```
+
+### 5.4 状态流转(端到端)
+
+```
+C 端提交 ─► account_certification_apply.audit_status='0' + account_user.company_auth_status='1'
+ │
+后台「通过」─► audit_status='1' + company_auth_status='2' + subject_type 同步 + 落 account_certification
+后台「驳回」─► audit_status='2' + company_auth_status='3' + 记审核意见
+```
+
+---
+
+## 六、枚举与状态
+
+| 名称 | 取值 | 说明 |
+| --- | --- | --- |
+| `subject_type` 主体类型 | `1` 个人 / `2` 个体工商户 / `3` 企业 | 企业向导页提交 `2`/`3`(页内选择);个人页提交 `1` |
+| `audit_status` 审核状态 | `0` 待审核 / `1` 已通过 / `2` 已驳回 | 提交置 `0`,审核改 `1`/`2` |
+| `auth_status` 个人认证状态 | `0` 未认证 / `1` 审核中 / `2` 已认证 / `3` 认证失败 | 个人实名专用 |
+| `company_auth_status` 企业认证状态 | `0` 未认证 / `1` 审核中 / `2` 已认证 / `3` 认证失败 | 企业/个体专用(本期新增) |
+| `license_valid_type` / `legal_id_valid_type` 有效期类型 | `1` 长期 / `2` 短期 | 长期隐藏起止日期;短期必填 |
+
+「我的资料」页徽标映射:`auth_status` →「实名认证」行;`company_auth_status` →「企业/个体户认证」行(`0`未认证、`1`认证中、`2`已认证、`3`认证失败)。
+
+---
+
+## 七、影响范围与文件清单
+
+### 7.1 新增(ruoyi-user)
+
+| 文件 | 说明 |
+| --- | --- |
+| `domain/dto/CompanyCertificationApplyDto.java` | 企业/个体认证提交入参(含校验注解与字段注释) |
+| `domain/vo/CompanyCertificationStatusVo.java` | 企业/个体认证状态与资料回填出参 |
+| `src/test/.../AccountCertificationApplyServiceImplTest.java` | 追加提交/审核分流用例(正常、重复提交拒绝、短期缺日期、状态分流) |
+
+### 7.2 修改(ruoyi-user)
+
+| 文件 | 改动 |
+| --- | --- |
+| `domain/AccountCertificationApply.java` | 新增 13 个企业/个体字段 + getter/setter + toString |
+| `domain/AccountCertification.java` | 同步新增 13 个字段 |
+| `mapper/AccountCertificationApplyMapper.java` | 新增 `selectLatestCompanyApplyByUserId`、`countPendingCompanyApplyByUserId`;`selectLatestApplyByUserId`→`selectLatestPersonalApplyByUserId`(加 `subject_type='1'`)、`countPendingApplyByUserId`→`countPendingPersonalApplyByUserId`(加 `subject_type='1'`) |
+| `resources/mapper/user/AccountCertificationApplyMapper.xml` | resultMap / `selectCertificationApplyVo` / 列表 / insert 补新增列;主体类型过滤调整;新增企业两条查询 |
+| `resources/mapper/user/AccountCertificationMapper.xml` | resultMap / `selectCertificationVo` / insert / update 补新增列 |
+| `mapper/AccountUserMapper.java` | 新增 `updateUserCompanyAuthStatus(AccountUser)` |
+| `resources/mapper/user/AccountUserMapper.xml` | 新增 `updateUserCompanyAuthStatus`(只写 `company_auth_status`,通过时带 `<if>` 同步 `subject_type`) |
+| `service/IAccountCertificationApplyService.java` | 新增 `submitCompanyCertification(Long, CompanyCertificationApplyDto)`、`getCompanyCertificationStatus(Long)` |
+| `service/impl/AccountCertificationApplyServiceImpl.java` | 常量补 `SUBJECT_INDIVIDUAL='2'`/`SUBJECT_COMPANY='3'`;实现企业提交与状态查询;`auditCertificationApply` 按主体类型分流;`saveCertificationSnapshot` 补字段 |
+| `controller/AccountProfileController.java` | 新增 `POST /user/company-certification`、`GET /user/company-certification` |
+| `domain/vo/AccountUserInfoVo.java` | 新增 `companyAuthStatus` 字段 |
+| `service/impl/AccountUserServiceImpl.java` | `getAccountUserInfo` 填充 `companyAuthStatus` |
+
+### 7.3 修改(后台 hugray-admin)
+
+| 文件 | 改动 |
+| --- | --- |
+| `views/member/certification/detail.vue` | 「主体资料」区块补展示:主体类型、营业执照有效期类型/起止、法人身份证人像/国徽面(`image-preview`)、法人身份证有效期类型/起止、法人身份证详细地址、负责人姓名/电话/邮箱、收款类型、银行卡照片(`image-preview`)、银行卡号、开户城市 |
+
+> 列表页 `index.vue` 的主体类型/主体名称列已由 `subject_type` 与 `companyName \|\| realName` 覆盖,**无需改动**。
+
+### 7.4 新增(前端 hugray-user-miniapp)
+
+| 文件 | 说明 |
+| --- | --- |
+| `pages/profile/company-certification.vue` | 企业/个体四步向导页(步骤条 + 分步表单 + 图片上传 + 地区/日期选择) |
+
+### 7.5 修改(前端 hugray-user-miniapp)
+
+| 文件 | 改动 |
+| --- | --- |
+| `pages.json` | 注册 `pages/profile/company-certification`(`navigationStyle: custom`) |
+| `api/index.js` | 新增 `getCompanyCertificationStatus()`(→ GET)、`submitCompanyCertification(data)`(→ POST) |
+| `pages/profile/edit.vue` | 「企业/个体户认证」行由 `handlePlaceholder` 改为 `uni.navigateTo` 跳向导页;`companyStatus` 改按 `/info` 返回的 `companyAuthStatus` 映射回显 |
+
+> 网关无需改动;无需新增菜单/权限。
+
+### 7.6 新增(DDL)
+
+| 文件 | 说明 |
+| --- | --- |
+| `hugray-server/sql/hugray_account_company_certification_20261013.sql` | 两张认证表各补 13 列 + `account_user` 补 `company_auth_status`(幂等,含回滚注释) |
+
+---
+
+## 八、边界与异常清单
+
+| # | 场景 | 处理 |
+| --- | --- | --- |
+| 1 | 未登录访问 | 网关 `AuthFilter` 返回 401,前端跳登录页;后端不做分支 |
+| 2 | `user_id` 头缺失/为空 | 服务内兜底判空,抛业务异常(不 NPE) |
+| 3 | 必填项为空 / 纯空格 | DTO `@NotBlank` 拦截,返回字段级中文提示;前端「下一步」逐级预校验并 toast 首个缺失项 |
+| 4 | 有效期类型=长期 | 起止日期行隐藏且不校验;短期时必填 |
+| 5 | 有效期类型=短期但未填日期 | 服务层兜底抛「短期有效期必须填写起止日期」 |
+| 6 | 图片未上传 | 按必填拦截(toast「请上传XX图片」) |
+| 7 | 主体类型非法(非 2/3) | 服务层抛「主体类型不合法」 |
+| 8 | 已有待审核企业申请再次提交 | 服务层企业防重闸门提示「已有企业认证申请正在审核中,请勿重复提交」 |
+| 9 | 个人 / 企业申请互相干扰 | 状态查询与防重均按 `subject_type` 隔离;认证状态分列 `auth_status` / `company_auth_status` |
+| 10 | 审核通过 / 驳回 | 按 `subject_type` 分流回写,通过时同步 `subject_type` 并落 `account_certification` |
+| 11 | 落库或状态联动失败 | 同一事务回滚,返回统一错误,不产生半更新 |
+| 12 | 越权 | 会员身份取自令牌注入头,不接受前端传 `userId`,天然防水平越权 |
+| 13 | 三方核验 / 格式校验 | **本期明确不做**,仅基础必填校验 |
+| 14 | 图片上传失败 | 沿用 `uploadAvatarFile` 错误处理,toast 提示并可重试 |
+
+---
+
+## 九、规范符合性说明
+
+- 分层:Controller 只取身份 + 转发;业务在 Service;SQL 在 Mapper;Controller 不直接调 Mapper。
+- 命名:`submitCompanyCertification` / `getCompanyCertificationStatus` / `selectLatestCompanyApplyByUserId` / `countPendingCompanyApplyByUserId` / `updateUserCompanyAuthStatus` 见名知意;DTO / VO / Entity 分离。
+- 注释:DTO / VO / Mapper / Service 方法具备 Javadoc;核心流程按 `// STEP x:` 分步注释;实体新增字段附列名与取值注释。
+- 魔法值:`subject_type`、`audit_status`、`auth_status`、`company_auth_status`、`*_valid_type` 取值抽为常量/枚举语义。
+- 事务:提交接口 `insert + 状态联动` 同事务;审核 `更新申请 + 状态分流 + 落档` 同事务。
+- 幂等/防重:以待审核企业申请为闸门,拒绝重复提交。
+- 数据库:新增列显式长度 + COMMENT;无 `SELECT *`;无 `${}` 拼接;无循环查库;DDL 与代码同次提交。
+- 单测:覆盖企业提交正常、短期缺日期拒绝、重复提交拒绝、审核分流(企业写 `company_auth_status`)。
+- 文档:本文档与代码同仓库、同分支、同节奏提交。
+
+---
+
+## 十、已确认决策
+
+1. **认证状态存储**:**已定** —— 新增 `account_user.company_auth_status`,个人 / 企业徽标各自独立,互不覆盖。
+2. **主体类型**:**已定** —— 「企业信息」步骤顶部增加「主体类型」选择项(企业 / 个体工商户),`subject_type` 存 3 / 2(比原型多一个字段)。
+3. **有效期校验**:**已定** —— 「有效期类型」选**长期**时**隐藏起止日期且不必填**;选短期时显示且必填。
+4. **图片必填**:**已定** —— 营业执照、法人身份证人像面/国徽面、银行卡号码面照片**均为必填**。
+5. **校验深度**:**已定** —— 仅基础必填校验,**不做格式/三方核验**。
+6. **审核入口**:**已定** —— 复用后台「主体认证审核」列表/详情/通过/驳回链路,列表无需改,详情补展示新增字段。
diff --git "a/doc/design/\347\224\250\346\210\267\347\253\257_\344\274\232\345\221\230\344\270\255\345\277\203_20261014.md" "b/doc/design/\347\224\250\346\210\267\347\253\257_\344\274\232\345\221\230\344\270\255\345\277\203_20261014.md"
new file mode 100644
index 0000000..3424287
--- /dev/null
+++ "b/doc/design/\347\224\250\346\210\267\347\253\257_\344\274\232\345\221\230\344\270\255\345\277\203_20261014.md"
@@ -0,0 +1,475 @@
+# 用户端小程序 个人中心-会员中心 接口 开发设计文档
+
+- 模块:ruoyi-user(会员业务)
+- 日期:2026-10-14
+- 需求来源:“这是个人中心-会员中心的原型,根据 pen 源文件的设计风格设计 UI 然后实现页面和后端的业务功能”
+- 关联前端:`hugray-user-miniapp`(新增 `pages/member/index.vue`)
+- 关联文档:`doc/design/用户端_个人中心_20261013.md`(会员等级 / 收益 / 登录取数范式)、`doc/design/用户端_收货地址_20261014.md`(C 端接口范式)
+- 数据权威源:`192.168.110.111:3306/hugray-account`(实际表结构优先于本文档)
+- 设计风格源:`doc/琥歌瑞0928.pen`(深蓝渐变卡 `#003E7E→#1E5EBC`、白玻璃卡、草绿主按钮 `#82C236→#639C1F`)
+
+---
+
+## 一、功能概述
+
+会员中心页(原型图一「会员中心」/ 图二「我的会员」)为 L1~L4 会员专属页面,包含两个 Tab:
+
+| Tab | 原型位置 | 展示内容 |
+| --- | --- | --- |
+| 销售统计 | 图一 | 深蓝卡(脱敏手机号 + 当前等级 + 等级详情);当月销售额 / 当月进货额 / 当月利润;当月利润额大数 + 环比;销售额折线图(按日 1~20 号);商品销售额比例饼图;销售用户排行列表 |
+| 我的仓库 | 图二 | 深蓝卡(同上);商品列表(商品图占位 + 名称 + 当前库存 + 最低需求库存 + 进货 / 仓库记录按钮);低库存警示(`当前库存` 红字 + 蓝色提示条) |
+
+展示规则(沿用已确认业务约定):`isRestrictedView = !isLoggedIn || memberLevel === 'C'`。**未登录或 C 普通用户**不展示会员中心入口;接口虽不校验等级,但前端受限态不进入该页。会员身份取自网关注入的登录态,天然防水平越权。
+
+**本期新增 5 个 C 端接口**(对应前端新增的会员中心接口):
+
+| # | 接口 | 用途 |
+| --- | --- | --- |
+| 1 | `GET /miniapp/account/user/member/sales-statistics` | 销售统计(三栏 + 趋势 + 比例 + 排行) |
+| 2 | `GET /miniapp/account/user/member/warehouses` | 我的仓库列表(含低库存标记) |
+| 3 | `POST /miniapp/account/user/member/purchase` | 进货(生成进货单 + 增库存 + 记流水) |
+| 4 | `GET /miniapp/account/user/member/warehouse-records` | 仓库变动记录(进货/库存流水) |
+| 5 | `GET /miniapp/account/user/member/purchase-orders` | 进货订单列表 |
+
+---
+
+## 二、现状与关键约束
+
+### 2.1 表结构现状
+
+| 表 | 现状 | 本次是否新增 |
+| --- | --- | --- |
+| `account_user` | 已有 `system_level`(会员等级);9 个业务统计列(`sell_amount_total` 等)为“待回填”占位,无写入方 | 否(只读) |
+| `account_warehouse` | 已有:`user_id`/`product_name`/`stock_qty`/`min_stock_qty`/`del_flag` 等,配套实体 + Mapper + XML 齐备 | 否(只读 + 更新库存) |
+| 会员销售流水 | **无** | 是 → `account_sales_record` |
+| 会员进货单 | **无** | 是 → `account_purchase_order` |
+| 云仓变动流水 | **无** | 是 → `account_warehouse_record` |
+
+### 2.2 已有接口缺口
+
+`ruoyi-user` 现有 C 端控制器 `AccountProfileController`(`/user`)仅覆盖个人中心 / 认证 / 邀请 / 收获地址;**无仓库、进货、销售统计相关接口**。`AccountWarehouseMapper` 已有 `selectWarehouseByUserId` / `insertWarehouse` / `updateWarehouse`,但**缺 Service 层与 C 端查询接口**。
+
+### 2.3 销售数据来源(已确认决策)
+
+后端无任何订单 / 交易模块(`ruoyi-product` 仅启动类,`hugray-order` 零表),故:
+
+- **销售统计采用“新建简化销售流水模型”**:`account_sales_record` 一条流水 = 一次销售事件(销售额 + 利润),本月三栏与趋势 / 比例 / 排行均由流水实时聚合。
+- **进货采用“真实进货”**:`POST .../purchase` 生成一条 `account_purchase_order`,同时增加 `account_warehouse.stock_qty` 并追加一条 `account_warehouse_record`;当月进货额由进货单实时聚合。
+- `account_user` 的 9 个“待回填”统计列**本期不回填**(避免双写口径不一致),页面数据一律由流水表实时聚合。
+
+### 2.4 金额单位约定
+
+沿用用户规范与既有约定:接口金额一律 **分(long)** 返回,前端 `formatYuan(fen, withThousands)` 格式化为元(两位小数,可带千分位)。
+
+### 2.5 当月利润口径
+
+`利润 = 销售额 − 进货成本`(按销售流水 `profit_amount` 字段逐条落库,聚合时 `SUM(profit_amount)`)。**说明**:销售流水写入方(订单模块)尚未就绪,本期利润随销售流水数据而来;无流水时为 0。
+
+---
+
+## 三、接口设计
+
+统一响应体:`R<T>`(`code=200` 成功)。前端 `utils/request.js` 按 `successCode=200` / `unauthorizedCode=401` 解包,调用方取 `res.data`。
+
+> 规范说明:用户规则中的“`Result<T>`、`code=0 成功`”为通用模板;本工程实际框架为 RuoYi `R`(200 成功)+ `@RestControllerAdvice` 全局异常。按“与现有工程保持一致”优先,采用框架既有约定。
+
+### 3.1 查询销售统计
+
+- **外部路径**:`GET /miniapp/account/user/member/sales-statistics`(网关 `StripPrefix=2` → 服务内 `/user/member/sales-statistics`)
+- **鉴权**:需登录(不在白名单,未登录由 `AuthFilter` 返回 401)
+- **入参**:无(会员身份取自网关注入的 `user_id` 头)
+- **出参**:`R<MemberSalesStatisticsVo>`
+
+```json
+{
+ "code": 200,
+ "msg": "操作成功",
+ "data": {
+ "phone": "136****6666",
+ "memberLevel": "L2",
+ "monthSalesAmount": 19210000,
+ "monthPurchaseAmount": 19210000,
+ "monthProfitAmount": 192100,
+ "monthSalesCount": 86,
+ "profitGrowthRate": 14,
+ "salesTrend": [
+ { "day": "1号", "amount": 300000 },
+ { "day": "5号", "amount": 420000 }
+ ],
+ "productSalesRatio": [
+ { "productName": "G&METS-V", "amount": 1245100 },
+ { "productName": "G&METS-II", "amount": 1245100 }
+ ],
+ "salesUserRanking": [
+ { "rank": 1, "nickname": "张三", "phone": "138****4561", "amount": 1645200 }
+ ]
+ }
+}
+```
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| phone | String | 当前会员手机号(**脱敏**) |
+| memberLevel | String | 会员等级 `C`/`L1~L4`(兜底 `C`) |
+| monthSalesAmount | Long | 当月销售额(分)= `SUM(account_sales_record.amount)` |
+| monthPurchaseAmount | Long | 当月进货额(分)= `SUM(account_purchase_order.total_amount)` |
+| monthProfitAmount | Long | 当月利润额(分)= `SUM(account_sales_record.profit_amount)`,可为负 |
+| monthSalesCount | Integer | 当月销售单数 = `COUNT(account_sales_record)` |
+| profitGrowthRate | Integer | 当月利润环比上月提升百分比(`Math.round`,无上月数据时返回 0) |
+| salesTrend[].day | String | 日标签(`1号`~`31号`),当月无销售当日补 0 |
+| salesTrend[].amount | Long | 当日销售额(分) |
+| productSalesRatio[].productName | String | 商品名称 |
+| productSalesRatio[].amount | Long | 该商品当月销售额(分) |
+| salesUserRanking[].rank | Integer | 名次(从 1 开始) |
+| salesUserRanking[].nickname | String | 购买人昵称(可空,前端回退脱敏手机号) |
+| salesUserRanking[].phone | String | 购买人手机号(**脱敏**) |
+| salesUserRanking[].amount | Long | 该用户当月购买额(分) |
+
+- **错误提示**:`获取销售统计失败`(会员不存在或查询异常)
+
+### 3.2 查询我的仓库列表
+
+- **外部路径**:`GET /miniapp/account/user/member/warehouses`
+- **鉴权**:需登录
+- **入参**:无
+- **出参**:`R<List<MemberWarehouseVo>>`
+
+```json
+{
+ "code": 200,
+ "msg": "操作成功",
+ "data": [
+ {
+ "warehouseId": 100,
+ "productName": "G&METS-II 超分子基团复合肽",
+ "productImage": null,
+ "stockQty": 100,
+ "minStockQty": 100,
+ "lowStock": false
+ }
+ ]
+}
+```
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| warehouseId | Long | 云仓行主键(进货时回传) |
+| productName | String | 商品名称 |
+| productImage | String | 商品图片(`account_warehouse` 无此列,本期恒为 null,前端用占位图) |
+| stockQty | Long | 当前库存 |
+| minStockQty | Long | 最低需求库存 |
+| lowStock | Boolean | 低库存标记:`stockQty < minStockQty` 时为 true(前端标红 + 蓝色提示条) |
+
+### 3.3 进货(生成进货单 + 增加库存)
+
+- **外部路径**:`POST /miniapp/account/user/member/purchase`
+- **鉴权**:需登录
+- **入参**(`MemberPurchaseDto`):
+
+```json
+{ "warehouseId": 100, "qty": 5 }
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| warehouseId | Long | 是 | 云仓行主键(须属于当前会员) |
+| qty | Long | 是 | 进货数量(正整数) |
+
+- **出参**:`R<Void>`
+- **事务**:同一事务内 ① 校验归属 → ② 落进货单 → ③ `stock_qty = stock_qty + qty` → ④ 追加仓库变动流水。
+- **幂等**:进货单 `order_no` 由服务端生成(`PG + yyyyMMddHHmmss + 4 位随机`),`UNIQUE KEY` 兜底。
+- **错误提示**:`商品不存在或不属于当前会员`、`进货数量必须大于 0`、`进货失败,请稍后重试`。
+
+### 3.4 查询仓库变动记录
+
+- **外部路径**:`GET /miniapp/account/user/member/warehouse-records`
+- **鉴权**:需登录
+- **入参**:`pageNum`(默认 1)、`pageSize`(默认 10)
+- **出参**:`TableDataInfo`(顶层 `rows` / `total`)
+
+```json
+{
+ "code": 200,
+ "msg": "查询成功",
+ "rows": [
+ {
+ "recordId": 1,
+ "productName": "G&METS-II 超分子基团复合肽",
+ "changeType": "1",
+ "changeTypeName": "进货入库",
+ "changeQty": 5,
+ "afterQty": 105,
+ "remark": "进货单 PG20261014xxxx",
+ "createTime": "2026-10-14 10:00:00"
+ }
+ ],
+ "total": 1
+}
+```
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| recordId | Long | 流水主键 |
+| productName | String | 商品名称(变动时快照) |
+| changeType | String | 变动类型 `1` 进货入库 / `2` 销售出库 / `3` 库存调整 |
+| changeTypeName | String | 变动类型中文名(由枚举下发) |
+| changeQty | Long | 变动数量(入库为正,出库为负) |
+| afterQty | Long | 变动后库存 |
+| remark | String | 备注(进货单号等) |
+| createTime | String | 变动时间(`yyyy-MM-dd HH:mm:ss`) |
+
+### 3.5 查询进货订单列表
+
+- **外部路径**:`GET /miniapp/account/user/member/purchase-orders`
+- **鉴权**:需登录
+- **入参**:`pageNum`(默认 1)、`pageSize`(默认 10)
+- **出参**:`TableDataInfo`(顶层 `rows` / `total`)
+
+```json
+{
+ "code": 200,
+ "msg": "查询成功",
+ "rows": [
+ {
+ "orderId": 1,
+ "orderNo": "PG2026101410301234",
+ "productName": "G&METS-II 超分子基团复合肽",
+ "qty": 5,
+ "unitPrice": 20000,
+ "totalAmount": 100000,
+ "status": "1",
+ "statusName": "已入库",
+ "createTime": "2026-10-14 10:30:12"
+ }
+ ],
+ "total": 1
+}
+```
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| orderId | Long | 进货单主键 |
+| orderNo | String | 进货单号 |
+| productName | String | 商品名称 |
+| qty | Long | 进货数量 |
+| unitPrice | Long | 进货单价(分);`account_warehouse` 无单价列,本期由前端进货弹窗按商品默认单价传入或取 0 |
+| totalAmount | Long | 进货金额(分)= `qty × unitPrice` |
+| status | String | 状态 `1` 已入库 |
+| statusName | String | 状态中文名 |
+| createTime | String | 下单时间 |
+
+---
+
+## 四、数据库设计
+
+库:`hugray-account`(权威库 `192.168.110.111:3306`)。脚本:`sql/hugray_account_member_center_20261014.sql`(与代码同一次提交,含变更 + 回滚)。
+
+### 4.1 新增会员销售流水表 `account_sales_record`
+
+```sql
+CREATE TABLE IF NOT EXISTS `account_sales_record` (
+ `record_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '销售流水ID',
+ `seller_id` bigint(20) NOT NULL COMMENT '销售会员ID(account_user.user_id)',
+ `buyer_id` bigint(20) DEFAULT NULL COMMENT '购买会员ID(account_user.user_id,散客为空)',
+ `buyer_name` varchar(64) DEFAULT NULL COMMENT '购买人昵称(销售排行展示用)',
+ `buyer_phone` varchar(20) DEFAULT NULL COMMENT '购买人手机号(下发脱敏)',
+ `product_name` varchar(128) DEFAULT NULL COMMENT '商品名称(比例图/排行展示用)',
+ `qty` bigint(20) NOT NULL DEFAULT 0 COMMENT '销售数量',
+ `amount` bigint(20) NOT NULL DEFAULT 0 COMMENT '销售额(单位:分)',
+ `profit_amount` bigint(20) NOT NULL DEFAULT 0 COMMENT '利润额(单位:分,可为负)',
+ `sale_time` datetime DEFAULT NULL COMMENT '销售时间(聚合口径)',
+ `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0存在 2删除)',
+ `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(64) DEFAULT '' COMMENT '更新者',
+ `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+ `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+ PRIMARY KEY (`record_id`),
+ KEY `idx_sales_seller_time` (`seller_id`, `sale_time`)
+) ENGINE=InnoDB AUTO_INCREMENT=100 DEFAULT CHARSET=utf8mb4 COMMENT='会员销售流水表';
+```
+
+### 4.2 新增会员进货单表 `account_purchase_order`
+
+```sql
+CREATE TABLE IF NOT EXISTS `account_purchase_order` (
+ `order_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '进货单ID',
+ `order_no` varchar(64) NOT NULL COMMENT '进货单号(幂等唯一)',
+ `user_id` bigint(20) NOT NULL COMMENT '进货会员ID(account_user.user_id)',
+ `warehouse_id` bigint(20) DEFAULT NULL COMMENT '云仓行ID(account_warehouse.id,快照)',
+ `product_name` varchar(128) DEFAULT NULL COMMENT '商品名称(快照)',
+ `qty` bigint(20) NOT NULL DEFAULT 0 COMMENT '进货数量',
+ `unit_price` bigint(20) NOT NULL DEFAULT 0 COMMENT '进货单价(单位:分)',
+ `total_amount` bigint(20) NOT NULL DEFAULT 0 COMMENT '进货金额(单位:分)= qty × unit_price',
+ `status` char(1) DEFAULT '1' COMMENT '状态(1已入库)',
+ `purchase_time` datetime DEFAULT NULL COMMENT '进货时间(聚合口径)',
+ `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0存在 2删除)',
+ `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(64) DEFAULT '' COMMENT '更新者',
+ `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+ `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+ PRIMARY KEY (`order_id`),
+ UNIQUE KEY `uk_purchase_order_no` (`order_no`),
+ KEY `idx_purchase_user_time` (`user_id`, `purchase_time`)
+) ENGINE=InnoDB AUTO_INCREMENT=100 DEFAULT CHARSET=utf8mb4 COMMENT='会员进货单表';
+```
+
+### 4.3 新增云仓变动流水表 `account_warehouse_record`
+
+```sql
+CREATE TABLE IF NOT EXISTS `account_warehouse_record` (
+ `record_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '流水ID',
+ `user_id` bigint(20) NOT NULL COMMENT '会员ID(account_user.user_id)',
+ `warehouse_id` bigint(20) DEFAULT NULL COMMENT '云仓行ID(account_warehouse.id)',
+ `product_name` varchar(128) DEFAULT NULL COMMENT '商品名称(快照)',
+ `change_type` char(1) NOT NULL COMMENT '变动类型(1进货入库 2销售出库 3库存调整)',
+ `change_qty` bigint(20) NOT NULL DEFAULT 0 COMMENT '变动数量(入库为正,出库为负)',
+ `after_qty` bigint(20) NOT NULL DEFAULT 0 COMMENT '变动后库存',
+ `biz_no` varchar(64) DEFAULT NULL COMMENT '业务单号(关联进货单等)',
+ `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0存在 2删除)',
+ `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
+ `create_time` datetime DEFAULT NULL COMMENT '变动时间',
+ `update_by` varchar(64) DEFAULT '' COMMENT '更新者',
+ `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+ `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+ PRIMARY KEY (`record_id`),
+ KEY `idx_whrecord_user_time` (`user_id`, `create_time`),
+ KEY `idx_whrecord_warehouse` (`warehouse_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=100 DEFAULT CHARSET=utf8mb4 COMMENT='会员云仓变动流水表';
+```
+
+**回滚**:
+
+```sql
+DROP TABLE IF EXISTS `account_warehouse_record`;
+DROP TABLE IF EXISTS `account_purchase_order`;
+DROP TABLE IF EXISTS `account_sales_record`;
+```
+
+---
+
+## 五、流程设计
+
+### 5.1 查询销售统计(`MemberCenterServiceImpl.getSalesStatistics`)
+
+```
+STEP 1: 校验 userId 非空,为空抛「获取销售统计失败」
+STEP 2: 查 account_user,取 phone(脱敏)与 system_level(normalize 兜底 C);不存在抛异常
+STEP 3: 聚合三栏(单条 SQL,本月口径):销售额 / 利润额 / 销售单数
+STEP 4: 聚合并月进货额(account_purchase_order 本月 SUM(total_amount))
+STEP 5: 计算利润环比:上月利润额为 0 时环比返回 0,否则 round((本月-上月)/上月 × 100)
+STEP 6: 查当月销售额趋势(按 DAY(sale_time) 分组)→ 补全当月 1~N 号缺省日(amount=0)
+STEP 7: 查当月商品销售额比例(按 product_name 分组 SUM(amount),降序)
+STEP 8: 查当月销售用户排行(按 buyer 分组 SUM(amount),降序取前 10,手机号脱敏)
+STEP 9: 组装并返回 MemberSalesStatisticsVo
+```
+
+### 5.2 进货(`MemberCenterServiceImpl.purchase`,`@Transactional`)
+
+```
+STEP 1: 校验 userId / warehouseId / qty(qty 必须 > 0)
+STEP 2: 按 warehouseId 查 account_warehouse(del_flag='0');为空或 user_id != 当前会员 → 抛「商品不存在或不属于当前会员」(防水平越权)
+STEP 3: 生成唯一进货单号 order_no(PG + 时间戳 + 随机)
+STEP 4: 落进货单(status='1' 已入库,purchase_time=sysdate)
+STEP 5: 更新库存 stock_qty = stock_qty + qty(SQL 内自增,避免读改写竞态)
+STEP 6: 追加云仓变动流水(change_type='1',change_qty=+qty,after_qty=自增后库存,biz_no=order_no)
+STEP 7: 提交事务
+```
+
+### 5.3 仓库列表 / 变动记录 / 进货订单
+
+均为只读查询,按当前会员过滤(`user_id = 当前会员`),记录 / 订单按时间倒序分页;列表接口低库存标记在 Service 组装(`stockQty < minStockQty`)。
+
+---
+
+## 六、枚举与状态
+
+| 名称 | 取值 | 说明 |
+| --- | --- | --- |
+| 会员等级 `memberLevel`(源 `system_level`) | `C` / `L1` / `L2` / `L3` / `L4` | 复用 `MemberLevelEnum` |
+| 云仓变动类型 `changeType` | `1` 进货入库 / `2` 销售出库 / `3` 库存调整 | 新增 `WarehouseChangeTypeEnum`(`code` + `desc` + `getByCode`) |
+| 进货单状态 `status` | `1` 已入库 | 新增 `PurchaseOrderStatusEnum`(`code` + `desc` + `getByCode`) |
+| 删除标志 `delFlag` | `0` 存在 / `2` 删除 | 复用现有语义 |
+
+---
+
+## 七、影响范围与文件清单
+
+### 新增(ruoyi-user)
+
+| 文件 | 说明 |
+| --- | --- |
+| `domain/AccountSalesRecord.java` | 销售流水实体 |
+| `domain/AccountPurchaseOrder.java` | 进货单实体 |
+| `domain/AccountWarehouseRecord.java` | 云仓变动流水实体 |
+| `domain/dto/MemberPurchaseDto.java` | 进货入参 |
+| `domain/vo/MemberSalesStatisticsVo.java` | 销售统计聚合出参 |
+| `domain/vo/SalesTrendItemVo.java` | 销售额趋势项 |
+| `domain/vo/ProductSalesRatioVo.java` | 商品销售额比例项 |
+| `domain/vo/SalesUserRankingVo.java` | 销售用户排行项 |
+| `domain/vo/MemberWarehouseVo.java` | 云仓列表项(含低库存标记) |
+| `domain/vo/WarehouseRecordVo.java` | 云仓变动记录项 |
+| `domain/vo/PurchaseOrderVo.java` | 进货订单项 |
+| `enums/WarehouseChangeTypeEnum.java` | 云仓变动类型枚举 |
+| `enums/PurchaseOrderStatusEnum.java` | 进货单状态枚举 |
+| `mapper/AccountSalesRecordMapper.java` + XML | 销售流水聚合查询 |
+| `mapper/AccountPurchaseOrderMapper.java` + XML | 进货单落库 + 聚合 + 分页 |
+| `mapper/AccountWarehouseRecordMapper.java` + XML | 变动流水落库 + 分页 |
+| `service/IAccountMemberCenterService.java` + `service/impl/AccountMemberCenterServiceImpl.java` | 会员中心业务 |
+| `controller/AccountMemberCenterController.java` | C 端 5 个接口 |
+
+### 修改
+
+| 文件 | 改动 |
+| --- | --- |
+| `mapper/AccountWarehouseMapper.java` + XML | 新增 `selectWarehouseById`(按主键查,含 `del_flag='0'`)与 `increaseStockQty`(`stock_qty = stock_qty + #{qty}` 原子自增) |
+| `sql/hugray_account_member_center_20261014.sql` | 新增三张表 DDL(变更 + 回滚) |
+| 前端 `hugray-user-miniapp/api/index.js` | 新增 5 个会员中心接口方法 |
+| 前端 `hugray-user-miniapp/pages.json` | 注册 `pages/member/index` 路由(custom 导航) |
+| 前端 `hugray-user-miniapp/pages/profile/index.vue` | 菜单 `member` 由「敬请期待」改为跳转会员中心页 |
+| 前端 `hugray-user-miniapp/pages/member/index.vue` | 新增会员中心页(销售统计 / 我的仓库) |
+
+> 网关无需改动:`/miniapp/account/**` → `ruoyi-user` 路由已存在。
+
+---
+
+## 八、边界与异常清单
+
+| # | 场景 | 处理 |
+| --- | --- | --- |
+| 1 | 未登录访问 | 网关 `AuthFilter` 返回 401,前端跳登录页;后端不做分支 |
+| 2 | `user_id` 头缺失/为空 | 服务内兜底判空,抛业务异常(不 NPE) |
+| 3 | 会员不存在 / 已逻辑删除 | 抛业务异常 |
+| 4 | 进货 `warehouseId` 不属于当前会员 | 抛「商品不存在或不属于当前会员」(防水平越权) |
+| 5 | 进货 `qty` ≤ 0 | 抛「进货数量必须大于 0」 |
+| 6 | 当月无销售流水 | 三栏为 0,趋势全为 0,比例/排行为空数组 |
+| 7 | 上月利润为 0 | 环比返回 0(不抛除零) |
+| 8 | 排行购买人手机号 | 下发**脱敏**值(`138****4561`) |
+| 9 | 进货并发 | 库存用 SQL 原子自增 `stock_qty = stock_qty + #{qty}`,避免读改写竞态 |
+| 10 | 进货单号重复 | `uk_purchase_order_no` 唯一索引兜底,生成时已带随机段 |
+
+---
+
+## 九、规范符合性说明
+
+- 分层:Controller 只取身份 + 转发;业务在 Service;SQL 在 Mapper;Controller 不直接调 Mapper。
+- 命名:`getSalesStatistics` / `purchase` / `increaseStockQty` 等见名知意;Entity / DTO / VO 分离。
+- 注释:实体类字段注释(取值与单位)、Javadoc、核心流程 `// STEP x:` 注释。
+- 魔法值:变动类型、进货单状态收口枚举;云仓变动 `change_qty` 正负语义在 Service 注释说明。
+- 异常:统一 `ServiceException` + 全局处理器;不散落 try-catch。
+- 数据库:字段显式长度 + COMMENT;聚合列有索引;无 `SELECT *`;无 `${}` 拼接;无循环查库。
+- 幂等:进货单 `order_no` 唯一索引;库存原子自增。
+- 单测:`getSalesStatistics`(无流水 / 有流水 / 上月为 0)、`purchase`(正常 / 非本人 / qty≤0)补单测,Mockito Mock Mapper。
+- 说明(与现有工程一致优先):`account_*` 系列主键沿用自增(与同库既有表一致),未采用雪花 ID,作为已知项记录。
+
+---
+
+## 十、已确认决策
+
+1. **销售统计数据来源**:**已定** —— 新建简化销售流水模型 `account_sales_record`,本月三栏 / 趋势 / 比例 / 排行实时聚合。
+2. **进货业务口径**:**已定** —— 真实进货:生成进货单 `account_purchase_order` + 增加 `account_warehouse.stock_qty` + 追加变动流水 `account_warehouse_record`。
+3. **金额单位**:**已定** —— 接口返回分(`long`),前端格式化两位小数。
+4. **`account_user` 统计列**:**已定** —— 本期不回填,页面数据一律由流水表实时聚合(避免双写口径不一致)。
+5. **当月利润口径**:**已定** —— `SUM(account_sales_record.profit_amount)`;写入方(订单模块)就绪前无流水时为 0。
+6. **等级详情**:本期为前端本地展示(等级说明),不新增后端接口。
diff --git "a/doc/design/\347\224\250\346\210\267\347\253\257_\345\256\236\345\220\215\350\256\244\350\257\201_20261013.md" "b/doc/design/\347\224\250\346\210\267\347\253\257_\345\256\236\345\220\215\350\256\244\350\257\201_20261013.md"
new file mode 100644
index 0000000..747e6ad
--- /dev/null
+++ "b/doc/design/\347\224\250\346\210\267\347\253\257_\345\256\236\345\220\215\350\256\244\350\257\201_20261013.md"
@@ -0,0 +1,305 @@
+# 用户端小程序 实名认证 开发设计文档
+
+- 模块:ruoyi-user(会员业务)+ ruoyi-gateway(路由/鉴权头注入)+ hugray-admin(后台主体认证审核)
+- 日期:2026-10-13
+- 需求来源:"点击实名认证跳转到实名认证页面,只做基础的身份证格式校验和姓名必填校验不做三方真实验证,提交后的数据需要到管理后台的主体认证审核列表"
+- 关联前端:`hugray-user-miniapp`(`pages/profile/edit.vue` 入口、新增 `pages/profile/certification.vue`)
+- 关联后台:`hugray-admin/src/views/member/certification/index.vue`(主体认证审核列表,菜单 2202)
+- 关联文档:`doc/design/用户端_我的资料_头像上传_20261013.md`(同类 C 端写接口范式)、`doc/design/用户端_我的资料_20261013.html`(本页 UI 参考)
+- 数据权威源:`192.168.110.111:3306/hugray-account`(实际表结构优先于本文档)
+
+---
+
+## 一、功能概述
+
+「我的资料」页(`pages/profile/edit.vue`)「实名认证」行当前点击仅弹占位 toast,本次改为**跳转到实名认证页**:
+
+1. 新增 C 端实名认证页 `pages/profile/certification.vue`(自定义导航栏 + 姓名 / 身份证号两个输入 + 底部「提交」按钮,见原型图);
+2. 页面**只做基础校验**:姓名必填、身份证号为 18 位标准格式;**不接入任何三方实名核验**;
+3. 提交后落 `account_certification_apply`(`subject_type='1'` 个人、`audit_status='0'` 待审核),并联动 `account_user.auth_status='1'`(审核中),使其出现在后台**主体认证审核列表**;
+4. 「我的资料」页实名认证徽标改为按后端下发的 `authStatus` 显示真实状态(未认证 / 认证中 / 已认证 / 认证失败),形成闭环。
+
+后台「审」链路(列表 / 详情 / 通过 / 驳回)**已存在且无需改动**:`AccountCertificationApplyController`(`/account/user/certification/**`,权限 `member:certification:*`)、`AccountCertificationApplyServiceImpl`(审核 + `auth_status` 联动 + 通过后落 `account_certification`)。本次只补 C 端"提交"这一缺口。
+
+---
+
+## 二、现状与关键约束
+
+### 2.1 缺口:只有"审"没有"增"
+
+`AccountCertificationApplyController` 类注释明确:*"本模块只有"审"没有"增删":申请由 C 端会员提交(不在本仓库范围)"*。C 端提交接口与页面均需**新建**。
+
+### 2.2 数据模型已就绪(无 DDL 变更)
+
+| 表 | 用途 | 关键列 |
+| --- | --- | --- |
+| `account_certification_apply` | 每次提交的申请(驳回重提多一条),**本次落这张表** | `id`(自增主键兼申请号)、`user_id`、`subject_type`、`real_name`、`id_card`、`audit_status`、`del_flag`、`create_by`、`create_time` |
+| `account_certification` | 当前生效认证资料,审核**通过**时由服务层落一份 | 审核通过后写入,本次不直接写 |
+| `account_user.auth_status` | 会员整体认证状态 | 0未认证 1审核中 2已认证 3认证失败 |
+
+> 状态联动口径(见 DDL 注释):**提交申请 → `auth_status='1'`;通过 → `'2'`;驳回 → `'3'`**。通过/驳回联动已由既有审核服务实现,本次只补"提交 → '1'"。
+
+### 2.3 主键策略
+
+`account_certification_apply.id` 为 `bigint AUTO_INCREMENT`(既有表,非雪花),故 insert 走 `useGeneratedKeys="true" keyProperty="id"`,与既有 `insertCertification` 一致。
+
+### 2.4 认证状态取值必须收口
+
+状态/类型取值语义已在多处存在,本次沿用既有字符串约定(与 `AccountCertificationApplyServiceImpl` 中的 `AUDIT_PASS="1"` 等常量同风格),在服务层抽为常量,不散落魔法值。
+
+### 2.5 路径规划
+
+- 网关注入 `user_id`;`SecurityUtils.getUserId()` 取会员身份,**C 端接口不接受前端传 userId**(防水平越权)。
+- C 端个人中心控制器 `AccountProfileController`(`@RequestMapping("/user")`,外部 `/miniapp/account/user/**`)已承载 `/info`、`/avatar`,本次**在其上新增** `POST /user/certification`(外部 `POST /miniapp/account/user/certification`)。
+- 与后台 `AccountCertificationApplyController`(外部 `/account/user/certification/**`)**前缀不同域**(`/miniapp/account` vs `/account`),无冲突。
+
+---
+
+## 三、接口设计
+
+统一响应体:C 端 `R<T>`(成功 `code=200`);后台 `AjaxResult` / `TableDataInfo`。
+
+### 3.1 提交实名认证(本次新增)
+
+- **外部路径**:`POST /miniapp/account/user/certification`(网关 `StripPrefix=2` → 服务内 `POST /user/certification`)
+- **鉴权**:需登录。路径不在网关白名单,未登录由 `AuthFilter` 直接返回 401,前端 `request.js` 自动跳登录页;**后端不做未登录分支**。
+- **入参**:JSON,会员身份取自网关注入的 `user_id` 头,**不接受前端传 userId**
+
+| 字段 | 类型 | 必填 | 校验 | 说明 |
+| --- | --- | --- | --- | --- |
+| realName | String | 是 | `@NotBlank` + `@Size(max=64)` | 姓名(对应 `real_name varchar(64)`) |
+| idCard | String | 是 | `@NotBlank` + 18 位格式 `@Pattern` + `@Size(max=32)` | 身份证号(对应 `id_card varchar(32)`) |
+
+- **出参**:`R<Void>`(成功即 `code=200`)
+- **错误提示**:`姓名不能为空` / `姓名过长` / `身份证号不能为空` / `身份证号格式不正确` / `已有实名认证申请正在审核中,请勿重复提交`
+
+身份证号基础格式正则(18 位:地区码 6 位 + 出生日期 + 顺序码 + 校验位;**只校验结构与日期范围,不校验校验码、不核验真实性**):
+
+```
+^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$
+```
+
+### 3.2 查询会员信息(本次扩展)
+
+- **外部路径**:`GET /miniapp/account/user/info`(既有)
+- **改动**:出参 `AccountUserInfoVo` 新增 `authStatus` 字段(`account_user.auth_status` 原值:0/1/2/3),供「我的资料」页显示实名认证真实状态。手机号仍只下发脱敏值。
+
+### 3.3 后台审核链路(既有,不改)
+
+`GET /account/user/certification/list`(`member:certification:list`)、`GET /account/user/certification/detail/{id}`、`PUT /account/user/certification/audit`(`member:certification:audit`)。C 端提交的申请将直接出现在该列表(`subject_type='1'` 个人、`audit_status='0'` 待审核,列表按待审核在前排序)。
+
+---
+
+## 四、数据库设计
+
+库:`hugray-account`。
+
+**无 DDL 变更**:`account_certification_apply` 与 `account_user.auth_status` 均已存在,本次仅插入 / 更新。
+
+---
+
+## 五、流程设计
+
+### 5.1 前端提交链路(`pages/profile/certification.vue`)
+
+```
+STEP 1: onShow 校验登录态(ensureLogin),未登录跳登录页
+STEP 2: 用户填写姓名 / 身份证号
+STEP 3: 点「提交」→ 本地校验:姓名非空(trim)且 ≤64;身份证号匹配 18 位正则
+STEP 4: 校验不通过则 toast 提示并中止,不发请求
+STEP 5: 调 submitCertification(realName, idCard) → POST /miniapp/account/user/certification
+STEP 6: 成功后 toast「提交成功,等待审核」并 navigateBack 回我的资料页;失败提示后端错误信息
+```
+
+### 5.2 后端提交(`AccountCertificationApplyServiceImpl.submitPersonalCertification`)
+
+```
+STEP 1: 校验 userId 非空(未登录由网关拦截,此处兜底),为空抛业务异常
+STEP 2: 防重复提交:该会员若存在 audit_status='0'(待审核)且未删除的申请,
+ 抛「已有实名认证申请正在审核中,请勿重复提交」
+STEP 3: 组装申请快照:subject_type='1'(个人)、real_name、id_card、
+ audit_status='0'、create_by=登录用户名
+STEP 4: insertCertificationApply 落 account_certification_apply(自增回填 id)
+STEP 5: 联动 account_user.auth_status='1'(审核中,调既有 updateUserAuthStatus 单列语句)
+STEP 6: 以上 STEP 4/5 同事务(@Transactional),任一失败整体回滚
+```
+
+### 5.3 状态流转(端到端)
+
+```
+C 端提交 ──► account_certification_apply.audit_status='0' + account_user.auth_status='1'
+ │
+后台「通过」──► audit_status='1' + auth_status='2' + 落 account_certification(既有逻辑)
+后台「驳回」──► audit_status='2' + auth_status='3' + 记审核意见(既有逻辑)
+```
+
+---
+
+## 六、枚举与状态
+
+| 名称 | 取值 | 说明 |
+| --- | --- | --- |
+| `subject_type` 主体类型 | `1` 个人 / `2` 个体工商户 / `3` 企业 | 本次固定提交 `1`(个人实名);企业认证入口本期仍为占位 |
+| `audit_status` 审核状态 | `0` 待审核 / `1` 已通过 / `2` 已驳回 | 提交置 `0`,审核改 `1`/`2` |
+| `auth_status` 会员认证状态 | `0` 未认证 / `1` 审核中 / `2` 已认证 / `3` 认证失败 | 提交置 `1`;通过置 `2`;驳回置 `3` |
+
+「我的资料」页徽标与 `auth_status` 映射:`0`→未认证、`1`→认证中、`2`→已认证、`3`→认证失败。
+
+---
+
+## 七、影响范围与文件清单
+
+### 新增(ruoyi-user)
+
+| 文件 | 说明 |
+| --- | --- |
+| `domain/dto/AccountCertificationApplyDto.java` | C 端实名认证入参(`realName` / `idCard`,含校验注解与字段注释) |
+| `src/test/.../AccountCertificationApplyServiceImplTest.java` | 提交服务单测(正常 / 待审核重复提交拒绝 / 状态联动) |
+
+### 修改(ruoyi-user)
+
+| 文件 | 改动 |
+| --- | --- |
+| `controller/AccountProfileController.java` | 注入 `IAccountCertificationApplyService`;新增 `POST /user/certification` |
+| `service/IAccountCertificationApplyService.java` | 新增 `submitPersonalCertification(Long userId, String realName, String idCard)` |
+| `service/impl/AccountCertificationApplyServiceImpl.java` | 实现提交(防重校验 + 落申请 + 联动 `auth_status='1'`,`@Transactional`) |
+| `mapper/AccountCertificationApplyMapper.java` | 新增 `insertCertificationApply(AccountCertificationApply)`、`countPendingApplyByUserId(Long userId)` |
+| `resources/mapper/user/AccountCertificationApplyMapper.xml` | 新增上述两条 SQL |
+| `domain/vo/AccountUserInfoVo.java` | 新增 `authStatus` 字段 |
+| `service/impl/AccountUserServiceImpl.java` | `getAccountUserInfo` 填充 `authStatus` |
+| `src/test/.../AccountUserServiceImplTest.java` | 补充 `authStatus` 下发断言 |
+
+### 新增(前端 hugray-user-miniapp)
+
+| 文件 | 说明 |
+| --- | --- |
+| `pages/profile/certification.vue` | 实名认证页(自定义导航栏 + 姓名/身份证号输入 + 底部提交;沿用 `edit.vue` 的导航与按钮风格) |
+
+### 修改(前端 hugray-user-miniapp)
+
+| 文件 | 改动 |
+| --- | --- |
+| `pages.json` | 注册 `pages/profile/certification`(`navigationStyle: custom`) |
+| `api/index.js` | 新增 `submitCertification(realName, idCard)`(→ `/miniapp/account/user/certification`) |
+| `pages/profile/edit.vue` | 「实名认证」行由 `handlePlaceholder` 改为 `uni.navigateTo` 跳认证页;`realNameStatus` 改为按 `/info` 返回的 `authStatus` 映射 |
+
+> 网关无需改动:`/miniapp/account/**` 路由已存在。
+
+---
+
+## 八、边界与异常清单
+
+| # | 场景 | 处理 |
+| --- | --- | --- |
+| 1 | 未登录访问 | 网关 `AuthFilter` 返回 401,前端跳登录页;后端不做分支 |
+| 2 | `user_id` 头缺失/为空 | 服务内兜底判空,抛业务异常(不 NPE) |
+| 3 | 姓名为空 / 纯空格 | DTO `@NotBlank` 拦截,提示「姓名不能为空」 |
+| 4 | 姓名超长(>64) | DTO `@Size` 拦截,提示「姓名过长」 |
+| 5 | 身份证号为空 | DTO `@NotBlank` 拦截,提示「身份证号不能为空」 |
+| 6 | 身份证号格式非法(非 18 位 / 含非法字符 / 非 X 结尾) | DTO `@Pattern` 拦截,提示「身份证号格式不正确」 |
+| 7 | 已有待审核申请再次提交 | 服务层 `countPendingApplyByUserId>0` 时抛「已有实名认证申请正在审核中,请勿重复提交」 |
+| 8 | 落库或状态联动失败 | 同一事务回滚,返回统一错误,不产生半更新 |
+| 9 | 越权 | 会员身份取自令牌注入头,不接受前端传 `userId`,天然防水平越权 |
+| 10 | 三方实名核验 | **本期明确不做**,仅基础格式校验 |
+
+---
+
+## 九、规范符合性说明
+
+- 分层:Controller 只取身份 + 转发,业务在 Service,SQL 在 Mapper;Controller 不直接调 Mapper。
+- 命名:`submitPersonalCertification` / `insertCertificationApply` / `countPendingApplyByUserId` 见名知意;DTO 与 Entity 分离。
+- 注释:DTO / Mapper 方法 / Service 方法具备 Javadoc;核心流程按 `// STEP x:` 分步注释;实体状态字段注明取值。
+- 魔法值:`subject_type`、`audit_status`、`auth_status` 取值抽为常量;错误提示集中。
+- 事务:提交接口 `insert + 联动更新` 同事务,保证一致性。
+- 幂等/防重:以待审核申请为闸门,拒绝重复提交。
+- 数据库:无 DDL;无 `SELECT *`;无 `${}` 拼接;无循环查库。
+- 单测:覆盖提交正常、重复提交拒绝、状态联动;Mockito Mock Mapper,不依赖真实库。
+
+---
+
+## 十、已确认决策
+
+1. **状态回显**:**已定** —— 扩展 `AccountUserInfoVo.authStatus`,「我的资料」页徽标显示真实状态(未认证/认证中/已认证/认证失败)。
+2. **重复提交**:**已定** —— 存在「待审核」申请时**拒绝并提示**,不新增重复单。
+3. **核验深度**:**已定** —— 仅姓名必填 + 身份证号 18 位格式校验,**不做三方实名核验**。
+4. **入口范围**:**已定** —— 本期只做「实名认证」(个人 `subject_type='1'`);「企业/个体户认证」入口维持占位。
+
+---
+
+## 十一、增量需求:认证状态回填与只读(2026-10-13 补充)
+
+### 11.1 需求描述
+
+进入实名认证页时,按当前认证状态**回填**该会员已提交的资料,并区分交互:
+
+| 当前状态 | 姓名 / 身份证号 | 底部按钮(文案 / 可点性) | 驳回原因 |
+| --- | --- | --- | --- |
+| 未认证(无申请) | 空,可编辑 | 「提交」/ 可点 | 不展示 |
+| 认证中(待审核) | 回填,**只读** | 「审核中」/ **置灰不可点** | 不展示 |
+| 认证成功(已通过) | 回填,**只读** | 「已认证」/ **置灰不可点** | 不展示 |
+| 认证失败(已驳回) | 回填,**可编辑** | 「重新提交」/ 可点 | **在身份证号下方展示** |
+
+> 已确认决策:认证成功同样置灰不可点(已认证无需重复提交);认证中 / 认证成功输入框只读不可编辑。
+
+### 11.2 接口设计(新增:查询当前认证状态)
+
+- **外部路径**:`GET /miniapp/account/user/certification`(网关 `StripPrefix=2` → 服务内 `GET /user/certification`)
+- 与既有 `POST /miniapp/account/user/certification`(提交)**同路径不同方法**,无需新增路由。
+- 鉴权:需登录;会员身份取自网关注入的登录态,**不接受前端传 userId**。
+
+取该会员**最近一次**申请(`account_certification_apply` 按 `id desc limit 1`,只取 `del_flag='0'`),出参 `AccountCertificationStatusVo`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| realName | String | 姓名(回填用) |
+| idCard | String | 身份证号(回填用) |
+| auditStatus | String | 0待审核 1已通过 2已驳回;**为空表示从未提交** |
+| auditRemark | String | 驳回原因(audit_status='2' 时有值) |
+
+无申请时返回**空字段对象**(各字段为 null),前端按「未认证」处理,不返回 404。
+
+### 11.3 前端状态映射
+
+`auditStatus` → 页面状态:`'0'`→认证中、`'1'`→认证成功、`'2'`→认证失败、空/缺省→未认证。
+
+- 只读 & 置灰条件:页面状态 ∈ {认证中, 认证成功};
+- 驳回原因展示条件:页面状态 = 认证失败 且 `auditRemark` 非空;
+- 按钮文案:未认证→「提交」、认证中→「审核中」、认证成功→「已认证」、认证失败→「重新提交」(未匹配兜底「提交」)。
+
+### 11.4 影响范围(增量文件清单)
+
+**新增(ruoyi-user)**
+
+| 文件 | 说明 |
+| --- | --- |
+| `domain/vo/AccountCertificationStatusVo.java` | C 端认证状态回填出参(realName / idCard / auditStatus / auditRemark) |
+
+**修改(ruoyi-user)**
+
+| 文件 | 改动 |
+| --- | --- |
+| `mapper/AccountCertificationApplyMapper.java` | 新增 `selectLatestApplyByUserId(Long userId)` |
+| `resources/mapper/user/AccountCertificationApplyMapper.xml` | 新增上述查询 SQL(取最近一条) |
+| `service/IAccountCertificationApplyService.java` | 新增 `getCertificationStatus(Long userId)` |
+| `service/impl/AccountCertificationApplyServiceImpl.java` | 实现查询:取最近一条申请映射为 VO,无则返回空对象 |
+| `controller/AccountProfileController.java` | 新增 `GET /user/certification` |
+| `src/test/.../AccountCertificationApplyServiceImplTest.java` | 补充查询用例(有申请回填 / 无申请空对象) |
+
+**修改(前端 hugray-user-miniapp)**
+
+| 文件 | 改动 |
+| --- | --- |
+| `api/index.js` | 新增 `getCertificationStatus()`(→ `GET /miniapp/account/user/certification`) |
+| `pages/profile/certification.vue` | 进入页面拉取状态并回填;认证中/成功输入框只读、按钮置灰且文案随状态变化(提交/审核中/已认证/重新提交);认证失败展示驳回原因 |
+
+> 无需 DDL、无需网关改动(`/miniapp/account/**` 路由已存在)。
+
+### 11.5 边界与异常(增量)
+
+| # | 场景 | 处理 |
+| --- | --- | --- |
+| 1 | 从未提交(查询返回空对象) | 页面按未认证处理,空表单可编辑可提交 |
+| 2 | 查询接口异常 | 静默降级为未认证(空表单),不阻塞页面 |
+| 3 | 认证中 / 认证成功 | 输入框 `disabled` 只读,按钮置灰且点击直接 return |
+| 4 | 认证失败但驳回原因为空 | 不展示驳回原因区域(仅可编辑重提) |
+| 5 | 认证失败后重新提交成功 | 回退上一页;再次进入时按新的「待审核」状态回填只读 |
diff --git "a/doc/design/\347\224\250\346\210\267\347\253\257_\346\210\221\347\232\204\350\265\204\346\226\231_\345\244\264\345\203\217\344\270\212\344\274\240_20261013.md" "b/doc/design/\347\224\250\346\210\267\347\253\257_\346\210\221\347\232\204\350\265\204\346\226\231_\345\244\264\345\203\217\344\270\212\344\274\240_20261013.md"
new file mode 100644
index 0000000..2be5b9e
--- /dev/null
+++ "b/doc/design/\347\224\250\346\210\267\347\253\257_\346\210\221\347\232\204\350\265\204\346\226\231_\345\244\264\345\203\217\344\270\212\344\274\240_20261013.md"
@@ -0,0 +1,229 @@
+# 用户端小程序 我的资料 头像上传 开发设计文档
+
+- 模块:ruoyi-user(会员业务)+ ruoyi-file(文件存储)+ ruoyi-gateway(路由/鉴权头注入)
+- 日期:2026-10-13
+- 需求来源:“实现头像上传功能”
+- 关联前端:`hugray-user-miniapp`(`pages/profile/edit.vue`)
+- 关联文档:`doc/design/用户端_个人中心_20261013.md`(个人中心 / 头像字段来源)、`doc/design/用户端_我的资料_20261013.html`(本页 UI 原型)
+- 数据权威源:`192.168.110.111:3306/hugray-account`(实际表结构优先于本文档)
+
+---
+
+## 一、功能概述
+
+「我的资料」页(`pages/profile/edit.vue`)头像行当前点击仅弹占位 toast,本次改为**真实上传**:点击头像 → 选图 → 上传文件 → 写回 `account_user.avatar` → 页面即时回显;个人中心页再次进入时同步展示新头像。
+
+采用**前端直传文件服务 + 会员服务只更新头像地址**两段式链路:
+
+1. 小程序 `uni.uploadFile` 直传网关 `POST /file/upload`(复用 `ruoyi-file` 现成上传能力),拿到图片可访问 URL;
+2. 小程序携 URL 调新增的 C 端接口 `POST /miniapp/account/user/avatar`,`ruoyi-user` **只更新 `avatar` 一列**。
+
+本次只新增**一个 C 端写接口**;文件上传复用既有 `/file/upload`,无需改动。
+
+---
+
+## 二、现状与关键约束
+
+### 2.1 文件服务已就绪,直接复用
+
+`ruoyi-file`(端口 9300)已提供上传入口 `SysFileController#upload`:
+
+- 服务内路径 `POST /upload`;网关路由 `/file/**` → `lb://ruoyi-file`(`StripPrefix=1`),**对外路径 `POST /file/upload`**。
+- 返回 `R<SysFile>{ name, url }`,`url` 即图片可访问地址。
+- 默认存储实现为 `LocalSysFileServiceImpl`(`@Primary`),落盘到 `file.path`,访问前缀由 `file.domain` + `file.prefix` 拼出(配置由 Nacos `ruoyi-file-dev.yml` 下发)。
+
+### 2.2 上传接口需登录态
+
+`/file/upload` **不在**网关白名单(`ignore.whites`)内,网关 `AuthFilter` 会校验令牌并注入 `user_id` 头。故小程序上传时必须携带 `Authorization: Bearer <token>`,与普通接口一致。
+
+### 2.3 前端请求封装仅支持 JSON
+
+`hugray-user-miniapp/utils/request.js` 固定 `Content-Type: application/json`,基于 `uni.request`,**不能直接传文件**。需新增一个基于 `uni.uploadFile` 的封装(单独注入令牌与错误解包),不污染现有 JSON 请求链路。
+
+### 2.4 `ruoyi-user` 不依赖 `ruoyi-api-system`
+
+`ruoyi-user/pom.xml` 未引入 `ruoyi-api-system`,**无法**直接使用 `RemoteFileService`(Feign)做服务端转存。本方案采用「前端直传」,故**无需新增依赖**。
+
+### 2.5 写头像必须走「单列更新语句」
+
+`AccountUserMapper` 既有约定:`insertAccountUser` / `updateAccountUser` 是共享入口、按 `<if>` 动态拼列,**严禁**把「只该由本模块显式改写的列」挂上去(参见 `updateUserStatus` / `updateUserAuthStatus` / `updateUserProviderFlag` 的注释)。因此头像更新**单开** `updateUserAvatar`,只写 `avatar` 一列,避免 `/account/user`(后台编辑)请求体带上 `avatar` 即改他人头像的口子。
+
+### 2.6 字段长度约束
+
+`account_user.avatar` 为 `varchar(255)`,服务端对传入 URL 做长度校验(> 255 直接拒绝),避免截断或 SQL 报错。
+
+---
+
+## 三、接口设计
+
+统一响应体:`R<T>`(`code=200` 成功),与 C 端 `/miniapp/**` 既有约定一致。
+
+### 3.1 上传图片文件(复用既有,无需改动)
+
+- **外部路径**:`POST /file/upload`(网关 `StripPrefix=1` → 服务内 `POST /upload`)
+- **鉴权**:需登录(携带 `Authorization: Bearer <token>`)
+- **请求**:`multipart/form-data`,表单字段名 `file`
+- **出参**:`R<SysFile>`
+
+```json
+{
+ "code": 200,
+ "msg": "操作成功",
+ "data": { "name": "2026/10/13/xxx.png", "url": "https://xxx/file/upload/2026/10/13/xxx.png" }
+}
+```
+
+### 3.2 更新当前会员头像(本次新增)
+
+- **外部路径**:`POST /miniapp/account/user/avatar`(网关 `StripPrefix=2` → 服务内 `POST /user/avatar`)
+- **鉴权**:需登录。路径不在网关白名单,未登录由 `AuthFilter` 直接返回 401,前端 `request.js` 自动跳登录页;**后端不做未登录分支**。
+- **入参**:`{ "avatar": "<图片URL>" }`(会员身份取自网关注入的 `user_id` 头,**不接受前端传 userId**)
+- **出参**:`R<Void>`(成功即 `code=200`)
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| avatar | String | 是 | 头像地址(来自 `/file/upload` 返回的 `url`);非空、长度 ≤ 255 |
+
+- **错误提示**:`头像地址不能为空` / `头像地址过长` / `更新头像失败`(会员不存在或更新异常)
+
+---
+
+## 四、数据库设计
+
+库:`hugray-account`(权威库 `192.168.110.111:3306`)。
+
+**无 DDL 变更**:`account_user.avatar varchar(255)` 已存在,本次仅写入该列。
+
+---
+
+## 五、流程设计
+
+### 5.1 前端上传链路(`pages/profile/edit.vue` → `handleAvatarChange`)
+
+```
+STEP 1: uni.chooseImage({ count:1, sizeType:['compressed'], sourceType:['album','camera'] }) 选图
+STEP 2: 上传前本地校验:临时文件大小 ≤ 5MB,否则提示「图片不能超过 5MB」并中止
+STEP 3: uploadFile('/file/upload', tempFilePath) 直传文件服务(携带 Bearer 令牌)
+STEP 4: 取响应 data.url;为空则提示「上传失败」
+STEP 5: updateAvatar(url) 调 POST /miniapp/account/user/avatar 写回
+STEP 6: 成功则更新本地 avatar 展示;失败提示且不改动已展示头像
+```
+
+### 5.2 后端更新头像(`AccountUserServiceImpl.updateAvatar`)
+
+```
+STEP 1: 校验 userId 非空(未登录由网关拦截,此处兜底),为空抛「更新头像失败」
+STEP 2: 校验 avatarUrl 非空,为空抛「头像地址不能为空」
+STEP 3: 校验 avatarUrl 长度 ≤ 255,超长抛「头像地址过长」
+STEP 4: 组装 AccountUser(userId + avatar + updateBy)调用 mapper.updateUserAvatar(只更 avatar 列)
+STEP 5: affected == 0(会员不存在/已删除)抛「更新头像失败」;否则返回
+```
+
+Mapper 单列更新语句(沿用 `updateUserStatus` 套路):
+
+```xml
+<!-- 头像更新专用:只动 avatar 一列,避免挂到共享的 updateAccountUser 上被后台编辑接口借道改头像 -->
+<update id="updateUserAvatar" parameterType="AccountUser">
+ update account_user
+ <set>
+ avatar = #{avatar},
+ <if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
+ update_time = sysdate()
+ </set>
+ where user_id = #{userId} and del_flag = '0'
+</update>
+```
+
+---
+
+## 六、枚举与状态
+
+| 名称 | 取值 | 说明 |
+| --- | --- | --- |
+| 允许图片类型 | `jpg` / `jpeg` / `png` / `webp` | 由文件服务既有校验 + 前端 `chooseImage` 共同约束 |
+| 图片大小上限 | 5 MB | 前端上传前本地校验(文件服务侧上限以其自身配置为准) |
+
+本次不涉及状态流转。
+
+---
+
+## 七、影响范围与文件清单
+
+### 新增(ruoyi-user)
+
+| 文件 | 说明 |
+| --- | --- |
+| `domain/dto/AccountUserAvatarDto.java` | 头像更新入参(`avatar`,含校验注解与字段注释) |
+
+### 修改(ruoyi-user)
+
+| 文件 | 改动 |
+| --- | --- |
+| `controller/AccountProfileController.java` | 新增 `POST /user/avatar` |
+| `service/IAccountUserService.java` | 新增 `updateAvatar(Long userId, String avatarUrl)` |
+| `service/impl/AccountUserServiceImpl.java` | 实现 `updateAvatar`(校验 + 单列更新) |
+| `mapper/AccountUserMapper.java` | 新增 `updateUserAvatar(AccountUser)` |
+| `resources/mapper/user/AccountUserMapper.xml` | 新增 `updateUserAvatar` 语句 |
+
+### 修改(前端 hugray-user-miniapp)
+
+| 文件 | 改动 |
+| --- | --- |
+| `utils/request.js` | 新增 `uploadFile(url, filePath, options)`:基于 `uni.uploadFile`,注入令牌、统一解包与错误提示,复用登录失效处理 |
+| `api/index.js` | 新增 `uploadAvatarFile(filePath)`(→ `/file/upload`)与 `updateAvatar(avatar)`(→ `/miniapp/account/user/avatar`) |
+| `pages/profile/edit.vue` | 头像行 `handlePlaceholder('头像更换')` 改为 `handleAvatarChange`(选图 → 上传 → 写回 → 回显) |
+
+### 修改(文档)
+
+| 文件 | 改动 |
+| --- | --- |
+| `doc/design/用户端_个人中心_20261013.md` | 头像字段「现状」由占位更新为「已支持上传」 |
+
+### 单测(ruoyi-user)
+
+| 文件 | 用例 |
+| --- | --- |
+| `AccountUserServiceImplTest.java` | `updateAvatar`:正常更新 / 空地址 / 超长地址 / 会员不存在(affected=0) |
+
+> 网关无需改动:`/miniapp/account/**`、`/file/**` 路由均已存在。
+
+---
+
+## 八、边界与异常清单
+
+| # | 场景 | 处理 |
+| --- | --- | --- |
+| 1 | 未登录访问 | 网关 `AuthFilter` 返回 401,前端跳登录页;后端不做分支 |
+| 2 | `user_id` 头缺失/为空 | 服务内兜底判空,抛「更新头像失败」(不 NPE) |
+| 3 | 上传的 `avatar` 为空 | 抛「头像地址不能为空」 |
+| 4 | `avatar` 长度 > 255 | 抛「头像地址过长」(列 `varchar(255)`) |
+| 5 | 用户取消选图 | `chooseImage` 成功回调不触发,静默中止(不提示、不请求) |
+| 6 | 图片超过 5MB | 前端本地提示「图片不能超过 5MB」,不发起上传 |
+| 7 | 上传失败(网络/文件服务异常) | 提示「上传失败」,不改动已展示头像 |
+| 8 | 写回失败(接口异常) | 提示后端错误信息,头像保持原值 |
+| 9 | 会员不存在 / 已逻辑删除 | `updateUserAvatar` 命中 `del_flag='0'` 过滤,affected=0 → 抛「更新头像失败」 |
+| 10 | 越权 | 会员身份取自令牌注入头,不接受前端传 `userId`,天然防水平越权 |
+| 11 | 旧头像文件 | **本期不清理**(已确认);后续可加定时清理 |
+| 12 | 并发重复上传 | 写回为整列覆盖,后写生效;无幂等诉求,不做去重 |
+
+---
+
+## 九、规范符合性说明
+
+- 分层:Controller 只做取身份 + 转发,业务在 Service,SQL 在 Mapper;Controller 不直接调 Mapper。
+- 命名:`updateAvatar` / `updateUserAvatar` / `uploadAvatarFile` 见名知意;DTO 与 Entity 分离。
+- 注释:DTO / Mapper 方法 / Service 方法具备 Javadoc;核心流程按 `// STEP x:` 分步注释。
+- 魔法值:图片大小上限等阈值抽为常量;无散落魔法值。
+- 异常:统一 `ServiceException` + 全局处理器,不散落 try-catch 拼返回值。
+- 日志:`@Slf4j` 占位符 + `[业务标记]` 前缀;头像 URL 非敏感,日志不打完整用户隐私。
+- 数据库:单列更新语句;无 DDL;无 `SELECT *`;无 `${}` 拼接;无循环查库。
+- 单测:`updateAvatar` 覆盖正常 / 空值 / 超长 / 会员不存在,Mockito Mock Mapper。
+
+---
+
+## 十、已确认决策
+
+1. **上传链路**:**已定** —— 前端 `uni.uploadFile` 直传 `/file/upload`,`ruoyi-user` 只更新 `avatar`(免加 `ruoyi-api-system` 依赖)。
+2. **存储介质**:**已定** —— 沿用 `ruoyi-file` 本地存储(`LocalSysFileServiceImpl`,`@Primary`)。
+3. **旧头像文件**:**已定** —— 本期不清理,仅覆盖 `avatar` 字段。
+4. **数据库**:**已定** —— 无 DDL 变更,复用既有 `account_user.avatar varchar(255)`。
diff --git "a/doc/design/\347\224\250\346\210\267\347\253\257_\346\210\221\347\232\204\351\202\200\350\257\267_20261013.md" "b/doc/design/\347\224\250\346\210\267\347\253\257_\346\210\221\347\232\204\351\202\200\350\257\267_20261013.md"
new file mode 100644
index 0000000..72918cc
--- /dev/null
+++ "b/doc/design/\347\224\250\346\210\267\347\253\257_\346\210\221\347\232\204\351\202\200\350\257\267_20261013.md"
@@ -0,0 +1,239 @@
+# 用户端小程序 我的邀请 开发设计文档
+
+- 模块:ruoyi-user(会员/邀请业务)+ ruoyi-auth(微信小程序码)+ hugray-user-miniapp(前端)
+- 日期:2026-10-13
+- 需求来源:"点击个人中心-我的邀请,进入我的邀请页面,需要根据pen源文件的设计风格设计ui然后实现页面和后端的业务功能"
+- 原型:3 张 UI 稿(邀请码 / 我邀请的 / 邀请我的)
+- 关联文档:`doc/design/用户端_个人中心_20261013.md`(入口所在页)、`doc/design/用户端_登录注册_20261012.md`(注册链路、扫码回填)
+- 数据权威源:`192.168.110.111:3306/hugray-account`(实际表结构优先于本文档)
+
+---
+
+## 一、功能概述
+
+个人中心「我的邀请码」行当前点击仅弹占位 toast,本次改为**跳转到「我的邀请」页**(`pages/profile/invite.vue`):
+
+1. 新增 C 端页面「我的邀请」,顶部为**三段式 Tab**(邀请码 / 我邀请的 / 邀请我的),自定义导航栏(返回箭头 + 居中标题「我的邀请」);
+2. **邀请码 Tab**:展示本人邀请码(`account_user.my_referral_code`)与**真实微信小程序码**,底部绿色「分享邀请码」按钮;
+3. **我邀请的 Tab**:按**绑定时间倒序**列出直接下级(头像、昵称、等级徽标、脱敏手机号、绑定日、合计消费);
+4. **邀请我的 Tab**:展示本人**上级**(推荐人)信息(大头像、昵称、等级徽标、脱敏手机号、绑定日);无上级时展示空态;
+5. **扫码链路**:小程序码 `scene = 邀请码`,扫码直达注册页并**自动填入邀请码**(补 `register.vue` 的 `onLoad` scene 解析);「分享邀请码」分享注册页链接(`?referralCode=xxx`),同样自动填入。
+
+> 无订单模块,故「合计消费」直接用预留列 `buy_amount_total`(决策见 2.2);
+> 等级徽标直接展示 `system_level` 原值,不扩展 `MemberLevelEnum`(决策见 2.3)。
+
+---
+
+## 二、现状与关键约束
+
+### 2.1 数据模型已预留,无需改表
+
+`account_user` 已具备本期所需列,**无需新增 DDL**:
+
+| 列 | 类型 | 用途 |
+| --- | --- | --- |
+| `referrer_id` | bigint | 上级(推荐人)会员ID,自连接 `account_user.user_id`;已有索引 `idx_account_user_referrer` |
+| `referrer_bind_time` | datetime | 绑定时间,列表/详情「绑定日」来源 |
+| `my_referral_code` | varchar(32) | 本人邀请码,唯一索引 `uk_account_user_referral_code` |
+| `system_level` | char(1) | 等级原值(C / L1~L4),等级徽标来源 |
+| `buy_amount_total` | decimal(16,2) | 购买金额合计(单位:元,现恒为 0,待订单模块回填) |
+
+### 2.2 「合计消费」复用预留列 `buy_amount_total`(已确认)
+
+无订单表,暂用 `account_user.buy_amount_total`(单位:元,与列定义一致,前端加 `¥` 前缀展示)。当前恒为 0,待订单模块上线后回填,**前端无需改动**。
+
+### 2.3 等级徽标直接展示 `system_level` 原值(已确认)
+
+`MemberLevelEnum` 仅到 L4,原型出现 L6(设计稿示意)。本期**不做 normalize**,将 `system_level` 原样下发(C / L1~L4 / 其它历史值),避免把非法值改写。前端空值兜底为 `C`。
+
+### 2.4 小程序码由 ruoyi-auth 生成(已确认「真实小程序码」)
+
+`wechat.miniapp` 配置与 `WeChatMiniappService`(access_token 获取、缓存)**均在 ruoyi-auth**;ruoyi-user 无 wechat 配置、无 `ruoyi-api-system` 依赖。为避免重复实现 access_token 逻辑(DRY),**小程序码生成放在 ruoyi-auth**,新增 C 端接口,入参邀请码、返回 base64 图片。
+
+### 2.5 扫码进入注册页需补 `scene` 解析(关键缺口)
+
+`pages/login/register.vue` **当前无 `onLoad`**(仅 `onUnload`),`form.referralCode` 仅支持手动输入。小程序码扫码后微信会把 `scene` 放入页面 `options`,需补 `onLoad(options)` 解码回填,否则「扫码自动填邀请码」不成立。
+
+### 2.6 邀请码恒定存在,上级可能为空
+
+`my_referral_code` 在注册时生成(`generateUniqueReferralCode`),C 端会员必有,故「邀请码」Tab 无须空态;而 `referrer_id` 可为空(命中通用推荐码注册、或历史数据),故「邀请我的」Tab 必须有空态。
+
+### 2.7 路径规划
+
+- 网关注入 `user_id`;`SecurityUtils.getUserId()` 取会员身份,**C 端接口不接受前端传 userId**(防水平越权)。
+- 会员侧三条接口挂在既有 `AccountProfileController`(`@RequestMapping("/user")`,外部 `/miniapp/account/user/**`),网关路由已存在,**无需改网关**。
+- 小程序码接口挂 ruoyi-auth 的 `AccountAuthController`(外部 `/miniapp/auth/invite-qrcode`),**不在网关白名单**,需登录态。
+
+### 2.8 手机号脱敏
+
+列表/详情中的手机号一律脱敏下发(复用 `AccountUserServiceImpl.maskPhone`,形如 `136****6666`),**不下发明文**。
+
+---
+
+## 三、接口设计
+
+统一响应体:C 端 `R<T>`(成功 `code=200`)。
+
+### 3.1 查询本人邀请码(本次新增 · ruoyi-user)
+
+- **外部路径**:`GET /miniapp/account/user/invite-code`(网关 `StripPrefix=2` → 服务内 `GET /user/invite-code`)
+- **鉴权**:需登录;会员身份取自登录态,不接受前端传 userId。
+- **入参**:无。
+- **出参** `InviteCodeVo`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| referralCode | String | 本人邀请码(`account_user.my_referral_code`) |
+
+### 3.2 生成微信小程序码(本次新增 · ruoyi-auth)
+
+- **外部路径**:`GET /miniapp/auth/invite-qrcode?code={邀请码}`(服务内 `GET /invite-qrcode`)
+- **鉴权**:需登录(不在网关白名单)。
+- **入参**:
+
+| 参数 | 类型 | 必填 | 校验 | 说明 |
+| --- | --- | --- | --- | --- |
+| code | String | 是 | `@NotBlank` + `@Pattern(^[A-Z0-9]{8}$)` | 邀请码,作为小程序码 `scene` |
+
+- **出参** `InviteQrcodeVo`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| qrcode | String | 小程序码 PNG 的 base64 DataURI(`data:image/png;base64,...`),前端直接作为 `<image src>` |
+
+- **缓存**:以邀请码为 key 缓存生成结果(Redis,TTL 30 天),避免重复消耗微信 `getUnlimited` 日配额。
+- **错误提示**:「邀请码格式不正确」/「邀请码二维码生成失败,请重试」。
+
+### 3.3 查询我邀请的(本次新增 · ruoyi-user)
+
+- **外部路径**:`GET /miniapp/account/user/invitees`(→ 服务内 `GET /user/invitees`)
+- **鉴权**:需登录;会员身份取自登录态。
+- **入参**:`pageNum`(默认 1)、`pageSize`(默认 10,与 `TableSupport` 默认一致)— 标准分页。
+- **查询口径**:`account_user` 中 `referrer_id = 当前用户ID` 且 `del_flag='0'`,**按 `referrer_bind_time` 倒序**(最新在最前;`referrer_bind_time` 为空的历史行排最后)。
+- **出参**:走统一分页 `TableDataInfo`,即 `{ code, msg, rows, total }`(**数据在顶层 `rows`,非 `data` 包裹**;`total` 为总数)。`rows` 元素 `AccountInviteeVo`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| userId | Long | 下级会员ID |
+| nickname | String | 昵称(为空前端回退脱敏手机号) |
+| avatar | String | 头像地址(为空前端回退默认图标) |
+| phone | String | 脱敏手机号(如 `136****6666`) |
+| systemLevel | String | 等级原值(C / L1~L4) |
+| bindDate | String | 绑定日(`yyyy-MM-dd`,源自 `referrer_bind_time`;为空不展示) |
+| buyAmountTotal | BigDecimal | 合计消费(元,源自 `buy_amount_total`) |
+
+### 3.4 查询邀请我的(上级信息)(本次新增 · ruoyi-user)
+
+- **外部路径**:`GET /miniapp/account/user/referrer`(→ 服务内 `GET /user/referrer`)
+- **鉴权**:需登录;会员身份取自登录态。
+- **入参**:无。
+- **查询口径**:取本人 `referrer_id` → 查该会员(`del_flag='0'`)。
+- **出参** `AccountReferrerVo`(无上级时各字段为 null,前端渲染空态):
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| userId | Long | 上级会员ID |
+| nickname | String | 昵称 |
+| avatar | String | 头像地址 |
+| phone | String | 脱敏手机号 |
+| systemLevel | String | 等级原值(C / L1~L4) |
+| bindDate | String | 绑定日(`yyyy-MM-dd`) |
+
+---
+
+## 四、数据库设计
+
+**本期无 DDL 变更**,仅复用 `account_user` 既有列(见 2.1)。查询 SQL 遵循规范:
+
+- 「我邀请的」按 `referrer_id` 过滤 + `referrer_bind_time` 排序,命中 `idx_account_user_referrer`;**不挂 join**(避免干扰 PageHelper 的 count SQL)。
+- 「邀请我的」按 `user_id` 主键查(复用 `selectAccountUserById`),不额外建方法。
+- 禁止 `SELECT *`:新增查询显式列出所需列。
+
+---
+
+## 五、微信小程序码设计(ruoyi-auth)
+
+### 5.1 复用与新增
+
+- 复用 `WeChatMiniappService.getAccessToken`(access_token 获取 + Redis 缓存 7000s)。当前为 `private`,在**同类内新增小程序码方法**即可调用,无需改可见性。
+- 当前 `doPost` 用 `BodyHandlers.ofString()` 仅适配 JSON;小程序码返回**二进制图片**,新增一个 `doPostForBytes`(`BodyHandlers.ofByteArray()`)。
+
+### 5.2 调用参数(`wxacode.getUnlimited`)
+
+```
+POST https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=ACCESS_TOKEN
+Body: {
+ "scene": "{邀请码}", // 8 位 A-Z0-9,符合 scene 字符集与长度限制
+ "page": "pages/login/register", // 扫码直达注册页
+ "check_path": false, // 跳过页面存在性校验(体验版/开发版可扫码)
+ "env_version": "release", // 正式版;体验/开发调试可改 trial/develop
+ "width": 430
+}
+```
+
+### 5.3 响应处理与缓存
+
+- 微信成功返回 PNG 字节流,失败返回 JSON(含 `errcode`/`errmsg`)。先按 `Content-Type` 判断:非图片 → 解析 JSON 记日志并抛业务异常。
+- 成功后将 PNG 转 base64 DataURI,按 `scene` 缓存到 Redis(TTL 30 天),命中缓存直接返回。
+- **运维注意**:`page` 需为已发布页面;正式环境扫码前,小程序需先发布含 `pages/login/register` 的版本。
+
+---
+
+## 六、前端页面设计(`pages/profile/invite.vue`)
+
+### 6.1 视觉令牌(对齐 `琥歌瑞0928.pen`)
+
+| 用途 | 取值 |
+| --- | --- |
+| 页面底色 | `#F8FAFC`(pen 根底色) |
+| 主色(草绿) | `#10B981`(pen 强调色);按钮渐变 `#34D399 → #10B981` |
+| 等级/状态徽标 | 实心绿胶囊:底 `#10B981`、字 `#FFFFFF`,圆角 6rpx(原型 L1/L6 徽标) |
+| 卡片 | 白底 `#FFFFFF`,圆角 16rpx,描边 `#E2E8F0`,阴影 `0 6rpx 16rpx rgba(15,23,42,0.06)` |
+| 主文本 / 次文本 / 弱文本 | `#0F172A` / `#64748B` / `#94A3B8` |
+| 导航 | 自定义,返回箭头黑色 `#0F172A`,标题居中 `#0F172A` 34rpx 加粗 |
+| Tab | 高亮 `#10B981` + 底部 4rpx 绿色指示条;未选中 `#64748B` |
+
+> 说明:个人中心页等级胶囊为金色渐变,本页**按原型用绿色胶囊**(更贴合 pen 的 `#10B981 / #ECFDF5` 状态徽标体系)。
+
+### 6.2 三 Tab 结构
+
+- **邀请码**:居中白色卡片,内为小程序码 `<image>`(base64);下方文案「我的邀请码」+ 邀请码原值(可点击复制);底部整宽绿色胶囊按钮「分享邀请码」(`open-type="share"`)。
+- **我邀请的**:列表,每行左为头像 + (昵称 + 等级徽标)+ 脱敏手机号;右侧为「合计消费:¥xxx」与「绑定日:xxxx-xx-xx」;按绑定时间倒序;空态「还没有邀请好友」。
+- **邀请我的**:居中大号头像 + 昵称 +(等级徽标)+ 脱敏手机号 +「绑定日:xxxx-xx-xx」;无上级时空态「暂无上级信息」。
+
+### 6.3 分享与扫码
+
+- 「分享邀请码」:`onShareAppMessage` 返回 `{ title, path: '/pages/login/register?referralCode={邀请码}', imageUrl: {小程序码} }`,分享小程序注册页。
+- 扫码进入:`wxacode.getUnlimited` 的 `scene` 落到 `pages/login/register` 的 `options.scene`,由 `register.vue` 的 `onLoad` 解码回填。
+
+---
+
+## 七、前端改动清单
+
+| 文件 | 改动 |
+| --- | --- |
+| `pages/profile/invite.vue` | **新增**:我的邀请页(三 Tab + 分享 + 空态) |
+| `pages.json` | **新增**页面注册 `pages/profile/invite`(`navigationStyle: "custom"`) |
+| `pages/profile/index.vue` | `handleMenuTap` 中 `invite` 分支由「敬请期待」改为 `uni.navigateTo({ url: '/pages/profile/invite' })` |
+| `api/index.js` | **新增** `getInviteCode` / `getInviteQrcode(code)` / `getInvitees(params)` / `getReferrer` |
+| `pages/login/register.vue` | **新增** `onLoad(options)`:读 `options.referralCode`(分享链接)与 `options.scene`(扫码,需 `decodeURIComponent`)回填 `form.referralCode` |
+
+---
+
+## 八、影响范围与测试要点
+
+- **影响面**:新增页面与接口,不改动既有表结构与既有接口出参;个人中心仅改一处菜单跳转。
+- **测试要点**:
+ 1. 邀请码 Tab:邀请码正确展示;小程序码可正常显示;分享卡片可拉起且 path 带推荐码;
+ 2. 我邀请的:按绑定时间倒序;脱敏手机号;合计消费展示(当前为 0);空态;
+ 3. 邀请我的:有上级/无上级两种状态;
+ 4. 扫码链路:扫描本人小程序码 → 进入注册页且推荐码已自动填入;手动输入仍可用;
+ 5. 边界:`referrer_bind_time` 为空的历史行排序不报错;未登录访问由网关 401 拦截。
+
+---
+
+## 九、待确认项
+
+1. 小程序码生成落在 **ruoyi-auth**(复用现成 WeChat 能力),会员侧接口落在 ruoyi-user;如希望统一前缀在 `/miniapp/account/**`,需在 ruoyi-user 补 wechat 配置与 WeChat 客户端(会引入重复实现),请确认取舍。
+2. 「我邀请的」是否**需要分页**(原型仅 4 条示意);本文默认分页(pageNum/pageSize)。
+3. 小程序码 `env_version` / `page` 取值(默认 `release` + `pages/login/register`)。
diff --git "a/doc/design/\347\224\250\346\210\267\347\253\257_\346\224\266\350\264\247\345\234\260\345\235\200_20261014.md" "b/doc/design/\347\224\250\346\210\267\347\253\257_\346\224\266\350\264\247\345\234\260\345\235\200_20261014.md"
new file mode 100644
index 0000000..3948c17
--- /dev/null
+++ "b/doc/design/\347\224\250\346\210\267\347\253\257_\346\224\266\350\264\247\345\234\260\345\235\200_20261014.md"
@@ -0,0 +1,276 @@
+# 用户端小程序 收货地址 开发设计文档
+
+- 模块:ruoyi-user(会员收货地址)+ hugray-user-miniapp(前端)
+- 日期:2026-10-14
+- 需求来源:"完善收货地址的后端功能,编辑地址的时候也要跳转到编辑页面,复用新增页面,注意管理后台的用户管理详情里面也涉及到收货地址展示,但是你只需要实现小程序侧的功能,只需要考虑到管理后台的问题"
+- 关联文档:`doc/design/用户端_个人中心_20261013.md`(入口所在页)
+- 数据权威源:`192.168.110.111:3306/hugray-account`(实际表结构优先于本文档)
+
+---
+
+## 一、功能概述
+
+会员中心「收货地址」当前为**半成品**:列表页用本地 mock 数据、编辑按钮弹「敬请期待」、新增页提交仅本地 toast 后返回,后端只有**管理后台侧**的改/删两个接口。
+
+本期只做**小程序(C 端)**闭环:
+
+1. **后端补齐 C 端收货地址接口**(列表 / 详情 / 新增 / 编辑 / 删除),落既有 `AccountProfileController`,会员身份取自登录态;
+2. **列表页接后端**(`pages/address/list.vue`):去掉 mock,进入/返回时实时拉取;
+3. **编辑跳转编辑页并复用新增页**(`pages/address/add.vue`):`onLoad` 读 `id`,有 id 则回填并切换为「编辑地址 / 保存」,无 id 则为「新增地址 / 提交」;
+4. **管理后台零改动**:`AccountUserController` 的 `PUT/DELETE /address`、`selectAccountUserDetail` 的地址展示**保持原样**,仅保证不被破坏。
+
+---
+
+## 二、现状与关键约束
+
+### 2.1 数据库与 Mapper 已齐备,**本期无 DDL**
+
+`account_address` 表与 `AccountAddress` 实体、`AccountAddressMapper`(接口 + XML)**已全部具备**:
+
+| 列 | 类型 | 说明 |
+| --- | --- | --- |
+| `id` | bigint | 主键(自增,见下方注意事项) |
+| `user_id` | bigint | 会员ID,索引 `idx_address_user_id` |
+| `consignee` | varchar(64) | 收货人 |
+| `consignee_phone` | varchar(20) | 收货人电话 |
+| `address` | varchar(255) | **单字段**存完整地址(无独立省/市/区列) |
+| `is_default` | char(1) | 是否默认(0否 1是) |
+| `del_flag` | char(1) | 逻辑删除(0存在 2删除) |
+
+现成 SQL:`selectAddressByUserId`(`del_flag='0'` 过滤 + 默认在前)、`insertAddress`、`updateAddress`(`where` 带 `user_id`)、`deleteAddressByIds`(批量逻辑删除)、`clearDefaultByUserId`。
+
+> 说明:表主键为 `AUTO_INCREMENT`,是早期建表遗留,**本期不动**(改主键策略影响面大,超出本次范围)。
+
+### 2.2 C 端落点:`AccountProfileController`
+
+- `@RequestMapping("/user")`,外部实际路径 `/miniapp/account/user/**`(网关 `StripPrefix=2`),路由已存在,**无需改网关**。
+- 会员身份取 `SecurityUtils.getUserId()`,**C 端不接受前端传 userId**(天然防水平越权);未登录由网关 401 拦截。
+
+### 2.3 路径必须避开 `/user/address`(关键约束)
+
+`AccountProfileController` 与后台的 `AccountUserController` **都是 `@RequestMapping("/user")`**,且同在一个服务内。后台已有:
+
+- `PUT /user/address`(改)
+- `DELETE /user/address/{ids}`(删)
+
+若 C 端沿用 `/user/address`,Spring 会因 **Ambiguous mapping 启动失败**。故 C 端统一用**复数 `/user/addresses`**:
+
+| C 端接口(服务内) | 后台既有(服务内) | 是否冲突 |
+| --- | --- | --- |
+| `GET /user/addresses` | 无 | 否 |
+| `GET /user/addresses/{id}` | `GET /user/detail/{userId}`(literal 不同) | 否 |
+| `POST /user/addresses` | `POST /user`、`POST /user/export`(段数/字面量不同) | 否 |
+| `PUT /user/addresses` | `PUT /user/address`(literal 不同) | 否 |
+| `DELETE /user/addresses/{id}` | `DELETE /user/address/{ids}`(literal 不同) | 否 |
+
+### 2.4 单字段 `address` 的格式约定(关键)
+
+`address` 是 varchar(255) 单字段,**没有独立的省/市/区列**。本期不改表,采用**空格分隔约定**:
+
+```
+address = [省, 市, 区, 详细地址].join(' ') // 例:四川省 成都市 武侯区 府城大道399号天府新谷9号楼
+```
+
+- **写入**:前端把 `region`(picker 固定返回三级)与 `detail` 用**单个空格**拼接。
+- **回填**:`address.split(' ')`,前 3 段为省/市/区,其余段用空格 rejoin 还原为详细地址。
+- **兜底**:段数 < 4(如后台手工录入的非约定格式)时,地区留空、整串填入详细地址,由用户重新选择地区(不报错)。
+
+### 2.5 「每会员至多一条默认地址」
+
+MySQL 无部分唯一索引,约束由**应用层在同一事务内**保证:置默认前先 `clearDefaultByUserId`,再写目标行。既有 `updateAccountAddress` 已实现,C 端编辑**直接复用**。新增同样需要此逻辑。
+
+### 2.6 归属校验(防越权)
+
+- **编辑**:`updateAddress` 的 `where` 自带 `user_id = #{userId}`,只要 Service 用登录态 userId,改别人的 id 会更新 0 行 → 既有逻辑抛异常回滚,**天然安全**。
+- **删除**:既有 `deleteAddressByIds` 仅按 `id` 逻辑删除、**不带 `user_id`**,C 端**不可直接复用**(会误删他人地址)。需先按 `selectAddressById` 查归属再删。
+- **详情**:同样需按归属校验。
+
+### 2.7 管理后台兼容(不得破坏)
+
+后台 `hugray-admin/src/views/member/user/detail.vue` 的「收货地址」Tab 依赖 `detail.addresses` 的字段:`id / userId / consignee / consigneePhone / address / isDefault`,并调用后台 `PUT /user/address`、`DELETE /user/address/{ids}`。
+
+**本期不修改 `AccountUserController`,也不修改 `selectAddressByUserId` 的查询口径与字段**,后台完全不受影响。
+
+### 2.8 手机号展示
+
+收货地址是**本人数据**,且编辑回填必须拿到明文手机号,故 C 端列表/详情**下发明文**(不再脱敏),列表页也**展示明文**(与后台详情页一致)。这是相对 mock「`185****0110`」的有意调整。
+
+### 2.9 电话校验口径
+
+后台地址弹窗的注释明确「收货电话可能是固话,写死 11 位手机号正则会挡住合法数据」。故 **后端不强制手机号正则**,只校验非空 + 长度(≤20);前端保留现有 `/^1[3-9]\d{9}$/`(原型设计),后端更宽松,不产生矛盾。
+
+---
+
+## 三、接口设计
+
+统一响应体:C 端 `R<T>`(成功 `code=200`,数据在 `data`)。
+
+### 3.1 查询本人收货地址列表(本次新增 · ruoyi-user)
+
+- **外部路径**:`GET /miniapp/account/user/addresses`(→ 服务内 `GET /user/addresses`)
+- **鉴权**:需登录;会员身份取自登录态。
+- **入参**:无(地址数量少,**不分页**)。
+- **出参**:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| id | Long | 地址ID |
+| consignee | String | 收货人 |
+| consigneePhone | String | 收货人电话(明文) |
+| address | String | 完整地址(空格分隔:省 市 区 详细) |
+| isDefault | String | 是否默认(`0`否 / `1`是) |
+
+- **排序**:默认地址在前,其余按 `id` 升序(复用现有 SQL)。
+
+### 3.2 查询单条收货地址详情(本次新增 · ruoyi-user)
+
+- **外部路径**:`GET /miniapp/account/user/addresses/{id}`(→ 服务内 `GET /user/addresses/{id}`)
+- **用途**:编辑页回填(列表页不再传递长文本参数)。
+- **鉴权**:需登录;**校验归属**,非本人地址抛业务异常「收货地址不存在或不属于该会员」。
+- **出参**:同 3.1 单条对象。
+
+### 3.3 新增收货地址(本次新增 · ruoyi-user)
+
+- **外部路径**:`POST /miniapp/account/user/addresses`(→ 服务内 `POST /user/addresses`)
+- **鉴权**:需登录;`userId` 由登录态注入(前端**不传** userId)。
+- **入参** `AccountAddressDto`:
+
+| 字段 | 类型 | 必填 | 校验 | 说明 |
+| --- | --- | --- | --- | --- |
+| consignee | String | 是 | `@NotBlank` + `@Size(max=64)` | 收货人 |
+| consigneePhone | String | 是 | `@NotBlank` + `@Size(max=20)` | 收货人电话(不强制手机号) |
+| address | String | 是 | `@NotBlank` + `@Size(max=255)` | 完整地址 |
+| isDefault | String | 否 | 值域 `0`/`1`,默认 `0` | 是否默认 |
+
+- **业务规则**(`@Transactional`):
+ 1. **是否默认完全以用户勾选为准**:`isDefault` 为空按 `0` 处理,后端**不做**「首条自动默认」的强制改写(该会员可能存在零条默认地址);
+ 2. 若为默认,先 `clearDefaultByUserId` 再插入(同事务,保证唯一默认)。
+- **出参**:`R<Void>`。
+
+### 3.4 编辑收货地址(本次新增 · ruoyi-user)
+
+- **外部路径**:`PUT /miniapp/account/user/addresses`(→ 服务内 `PUT /user/addresses`)
+- **鉴权**:需登录;`userId` 由登录态注入。
+- **入参** `AccountAddressDto`(多一个必填 `id`):
+
+| 字段 | 类型 | 必填 | 校验 | 说明 |
+| --- | --- | --- | --- | --- |
+| id | Long | 是 | `@NotNull` | 地址ID |
+| consignee | String | 是 | `@NotBlank` + `@Size(max=64)` | 收货人 |
+| consigneePhone | String | 是 | `@NotBlank` + `@Size(max=20)` | 收货人电话 |
+| address | String | 是 | `@NotBlank` + `@Size(max=255)` | 完整地址 |
+| isDefault | String | 否 | 值域 `0`/`1` | 是否默认 |
+
+- **业务规则**:复用既有 `updateAccountAddress`(事务内先清旧默认再更新;`where` 带 `user_id`,非本人更新 0 行抛异常回滚)。
+- **出参**:`R<Void>`。
+
+### 3.5 删除收货地址(本次新增 · ruoyi-user)
+
+- **外部路径**:`DELETE /miniapp/account/user/addresses/{id}`(→ 服务内 `DELETE /user/addresses/{id}`)
+- **鉴权**:需登录;**校验归属**后逻辑删除(`del_flag='2'`)。
+- **出参**:`R<Void>`。
+
+---
+
+## 四、数据库设计
+
+**本期无 DDL 变更**,不改表结构、不改后台查询口径。仅在 Mapper 增加一个查询方法:
+
+- `AccountAddress selectAddressById(Long id)`:`select ... from account_address where id = #{id} and del_flag = '0'`(显式列名,禁止 `SELECT *`)。
+- 服务层 `getAddressDetail` 与 `deleteMyAddress` 复用它做**归属校验**(DRY)。
+- 删除沿用既有 `deleteAddressByIds`(批量逻辑删除)。
+
+---
+
+## 五、后端改动清单(ruoyi-user)
+
+| 文件 | 改动 |
+| --- | --- |
+| `domain/dto/AccountAddressDto.java` | **新增**:C 端收货地址入参(id/consignee/consigneePhone/address/isDefault + 校验注解) |
+| `domain/vo/AccountAddressVo.java` | **新增**:C 端收货地址出参(id/consignee/consigneePhone/address/isDefault) |
+| `mapper/AccountAddressMapper.java` | **新增** `selectAddressById(Long id)` |
+| `resources/mapper/user/AccountAddressMapper.xml` | **新增** `selectAddressById` 语句 |
+| `service/IAccountUserService.java` | **新增** 4 个方法:`getAddressList` / `getAddressDetail` / `insertMyAddress` / `deleteMyAddress`(编辑复用既有 `updateAccountAddress`) |
+| `service/impl/AccountUserServiceImpl.java` | **实现** 上述 4 个方法(`insertMyAddress` 带 `@Transactional`;新增/删除做归属与默认约束处理) |
+| `controller/AccountProfileController.java` | **新增** 5 个 C 端接口(3.1~3.5),身份取 `SecurityUtils.getUserId()` |
+| `controller/AccountUserController.java` | **不改动**(后台兼容) |
+
+Service 方法签名(草案):
+
+```java
+/** C端:查询本人收货地址列表(默认在前,不分页) */
+List<AccountAddress> getAddressList(Long userId);
+
+/** C端:查询单条地址(校验归属,非本人抛异常)——供编辑页回填 */
+AccountAddress getAddressDetail(Long userId, Long id);
+
+/** C端:新增地址(事务内处理「首条自动默认」与默认唯一约束) */
+int insertMyAddress(AccountAddress accountAddress);
+
+/** C端:删除地址(校验归属后逻辑删除) */
+int deleteMyAddress(Long userId, Long id);
+```
+
+---
+
+## 六、前端页面设计
+
+### 6.1 列表页 `pages/address/list.vue`
+
+- **去 mock**:删除 `MOCK_ADDRESS_LIST`,改为 `onShow` 调用 `getAddressList()` 拉取(用 `onShow` 保证新增/编辑返回后自动刷新)。
+- **字段映射**:`item.consignee → 收货人`、`item.consigneePhone → 电话(明文)`、`item.address → 详细地址`、`item.isDefault === '1' → 「默认」标签`。
+- **编辑入口**:`handleEdit(item)` → `uni.navigateTo({ url: '/pages/address/add?id=' + item.id })`。
+- **删除入口(已确认)**:**列表页每条记录增加删除图标**。点击后 `uni.showModal` 二次确认,确认调 `deleteAddress(item.id)`,成功后 toast + 重新拉取列表;删除当前默认地址后按后端返回的最新列表状态渲染。
+- **空态 / 底部新增按钮**:样式不变。
+- **导航标题**:由 `/pages/address/list` 的「选择地址」保持不变(不在本次范围内)。
+
+### 6.2 新增/编辑页 `pages/address/add.vue`(复用双模式)
+
+- **`onLoad(options)`**:读取 `options.id`;有 id → 编辑模式(`isEdit = true`),调 `getAddressDetail(id)` 回填表单,`uni.setNavigationBarTitle({ title: '编辑地址' })`;无 id → 新增模式(标题「新增地址」)。
+- **回填映射**:
+ - `form.name = vo.consignee`,`form.phone = vo.consigneePhone`,`form.isDefault = vo.isDefault === '1'`;
+ - `form.address` 按 **2.4** 拆分:`tokens = address.split(' ')`;`tokens.length >= 4` 时 `form.region = tokens.slice(0,3)`、`form.detail = tokens.slice(3).join(' ')`;否则 `form.region = []`、`form.detail = address`。
+- **提交**:
+ - `address = [...form.region, form.detail.trim()].join(' ')`;
+ - 新增调 `createAddress({ consignee, consigneePhone, address, isDefault })`;
+ - 编辑调 `updateAddress({ id, consignee, consigneePhone, address, isDefault })`;
+ - 成功 toast 后 `navigateBack()`(列表页 `onShow` 自动刷新)。
+- **文案**:新增模式按钮「提交」、导航「新增地址」;编辑模式按钮「保存」、导航「编辑地址」。
+- **校验**:保留现有 `_validateForm()`(姓名/手机号/地区/详细地址 + 手机号正则)。
+
+---
+
+## 七、前端改动清单
+
+| 文件 | 改动 |
+| --- | --- |
+| `pages/address/list.vue` | 去 mock 接 `getAddressList()`;改为 `onShow` 刷新;`handleEdit` 跳转带 `id`;**每条增加删除图标 + 二次确认删除** |
+| `pages/address/add.vue` | 补 `onLoad(options)` 双模式;新增/编辑提交分别调接口;文案切换;地址拼接与拆分 |
+| `api/index.js` | import 补 `put`、`del`;**新增** `getAddressList` / `getAddressDetail(id)` / `createAddress(data)` / `updateAddress(data)` / `deleteAddress(id)` |
+| `pages.json` | **不改动**(复用 `pages/address/add`) |
+| `pages/profile/index.vue` | **不改动**(入口已指向 `/pages/address/list`) |
+
+---
+
+## 八、影响范围与测试要点
+
+- **影响面**:新增 C 端接口与前端联调,**不改表结构**、**不改后台侧接口与页面**;列表页由 mock 切真实数据。
+- **测试要点**:
+ 1. 列表:默认地址置顶、明文手机号、空态、返回后刷新;
+ 2. 新增:未勾选默认时 `isDefault` 落库为 `0`(不再强制首条为默认);勾选默认时原默认被顶掉(同会员仅一条默认);
+ 3. 编辑:回填正确(省市区与详细地址正确拆分)、保存后列表即时更新;
+ 4. 删除(列表页删除图标 + 二次确认):仅能删自己的地址;删默认地址后列表状态正确;
+ 5. 越权:构造他人 `id` 调编辑/详情/删除 → 统一抛「收货地址不存在或不属于该会员」,不产生写入;
+ 6. 校验:空值 / 超长(`consignee>64`、`address>255`、`phone>20`)被 `@Validated` 拦下;
+ 7. **后台回归**:会员详情「收货地址」Tab 展示与改/删仍正常。
+
+---
+
+## 九、已确认决策
+
+1. **列表页删除入口**:**已确认采用「列表页每条加删除图标」**(点击弹 `uni.showModal` 二次确认后删除)。后端 C 端删除接口(3.5)按此实现。
+2. **地址格式约定**:**已确认沿用单字段空格分隔**(2.4 约定),本期**不改表**,不加 `province/city/district/detail` 列。
+3. **管理后台**:**已确认零改动**(`AccountUserController` 与后台页面均不改,仅保证不被破坏)。
+4. **列表分页**:本期按不分页实现(地址数量少)。
+5. **电话校验**:后端仅校验非空 + 长度(允许固话),前端保留手机号正则。
+6. **默认地址取值(2026-10-14 修订)**:取消原「首条地址自动设为默认」,**完全以用户勾选为准**;未勾选时 `isDefault` 落库为 `0`,允许会员没有任何默认地址。
diff --git "a/doc/design/\347\224\250\346\210\267\347\253\257_\351\246\226\351\241\265\344\270\216\345\257\274\350\210\252_20261012.md" "b/doc/design/\347\224\250\346\210\267\347\253\257_\351\246\226\351\241\265\344\270\216\345\257\274\350\210\252_20261012.md"
index 9827380..f770a86 100644
--- "a/doc/design/\347\224\250\346\210\267\347\253\257_\351\246\226\351\241\265\344\270\216\345\257\274\350\210\252_20261012.md"
+++ "b/doc/design/\347\224\250\346\210\267\347\253\257_\351\246\226\351\241\265\344\270\216\345\257\274\350\210\252_20261012.md"
@@ -317,7 +317,7 @@
| 联系方式列表 | x20,列表项纵向间距 44 | `.info-list`(`padding: 140rpx 40rpx 0`) |
| 单行信息 | 标签列宽 110pt,右侧数值 | `.info-label` 16pt 600 `#1F2937`;`.info-value` 15pt `#6B7280`;固定标签列宽保证各行数值纵向对齐 |
| 平台客服按钮 | h30 R15,**pen 标准「微信按钮 WeChat」** 渐变(90°)`#07C160→#05A352` + 投影 `#07C16040` | `<button open-type="contact">`(微信客服会话)+ 内嵌 `/static/wechat-icon.png` + 白字 13pt 600;`::after { border: none }` 重置小程序默认边框 |
-| 热线文案 | `城市招商热线` / `合伙人招募` 均 `400-451-666` | 客服接口未就绪,先以文件内常量 `CITY_INVEST_HOTLINE` / `PARTNER_RECRUIT_HOTLINE` 维护,接口就绪后替换 |
+| 热线文案 | `城市招商热线` / `合伙人招募` 取自平台设置 | 进入页面调 `getContactInfo()`(`GET /miniapp/system/contact-info`)回填 `cityHotline` / `partnerRecruit`;后端未配置或请求失败时回退文件内常量 `CITY_INVEST_HOTLINE` / `PARTNER_RECRUIT_HOTLINE`(`400-451-666`)。未登录(无 token)时跳过请求直接用兜底常量,避免在公开页触发 401 跳登录 |
| 返回行为 | `uni.navigateBack()` | 无上级页面(如直接进入)时兜底 `uni.switchTab('/pages/profile/index')` |
### 9.7 选择地址页(`pages/address/list.vue`)
diff --git a/doc/nacos/dev/ruoyi-file-dev.yml b/doc/nacos/dev/ruoyi-file-dev.yml
new file mode 100644
index 0000000..518e0ec
--- /dev/null
+++ b/doc/nacos/dev/ruoyi-file-dev.yml
@@ -0,0 +1,34 @@
+# spring配置
+spring:
+ data:
+ redis:
+ host: localhost
+ port: 6379
+ password: <脱敏归档,真实值见配置中心>
+
+# 本地文件上传
+file:
+ domain: http://127.0.0.1:9300
+ path: D:/ruoyi/uploadPath
+ prefix: /statics
+
+# FastDFS配置
+fdfs:
+ domain: http://127.0.0.1
+ soTimeout: 3000
+ connectTimeout: 2000
+ trackerList: 127.0.0.1:22122
+
+# Minio配置
+minio:
+ url: http://127.0.0.1:9000
+ accessKey: minioadmin
+ secretKey: minioadmin
+ bucketName: test
+
+ # 防盗链配置
+referer:
+ # 防盗链开关
+ enabled: false
+ # 允许的域名列表
+ allowed-domains: localhost,127.0.0.1,ruoyi.vip,www.ruoyi.vip
diff --git a/doc/nacos/dev/ruoyi-gateway-dev.yml b/doc/nacos/dev/ruoyi-gateway-dev.yml
index e2f8350..b21b29e 100644
--- a/doc/nacos/dev/ruoyi-gateway-dev.yml
+++ b/doc/nacos/dev/ruoyi-gateway-dev.yml
@@ -107,6 +107,8 @@
- /miniapp/auth/phone/login
- /miniapp/auth/phone/code-login
- /miniapp/auth/account/register
+ - /miniapp/auth/partner/login
+ - /miniapp/auth/password/reset
- /*/v2/api-docs
- /*/v3/api-docs
- /csrf
diff --git a/hugray-admin/src/utils/member.js b/hugray-admin/src/utils/member.js
index 7bf602e..fecc208 100644
--- a/hugray-admin/src/utils/member.js
+++ b/hugray-admin/src/utils/member.js
@@ -14,6 +14,12 @@
{ value: '3', label: '企业' }
]
+// 有效期类型(营业执照有效期与法人身份证有效期共用一套取值)
+export const VALID_TYPES = [
+ { value: '1', label: '长期' },
+ { value: '2', label: '短期' }
+]
+
// 认证状态
export const AUTH_STATUS = [
{ value: '0', label: '未认证' },
diff --git a/hugray-admin/src/views/member/certification/detail.vue b/hugray-admin/src/views/member/certification/detail.vue
index 7a4ae23..4e6afb4 100644
--- a/hugray-admin/src/views/member/certification/detail.vue
+++ b/hugray-admin/src/views/member/certification/detail.vue
@@ -53,17 +53,48 @@
<el-descriptions :column="4" border>
<el-descriptions-item label="用户名称">{{ apply.realName || '-' }}</el-descriptions-item>
<el-descriptions-item label="用户身份证号" :span="3">{{ apply.idCard || '-' }}</el-descriptions-item>
+
+ <!-- 企业信息 -->
+ <el-descriptions-item label="主体类型">{{ labelOf(SUBJECT_TYPES, apply.subjectType) }}</el-descriptions-item>
<el-descriptions-item label="企业名称">{{ apply.companyName || '-' }}</el-descriptions-item>
<el-descriptions-item label="信用代码">{{ apply.creditCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="企业法人">{{ apply.legalPerson || '-' }}</el-descriptions-item>
<el-descriptions-item label="企业注册地区">{{ apply.registeredRegion || '-' }}</el-descriptions-item>
- <el-descriptions-item label="企业地址" :span="4">{{ apply.companyAddress || '-' }}</el-descriptions-item>
<el-descriptions-item label="注册日期">{{ apply.registerDate || '-' }}</el-descriptions-item>
- <el-descriptions-item label="营业执照有效期间" :span="3">{{ apply.licenseValidPeriod || '-' }}</el-descriptions-item>
+ <el-descriptions-item label="营业执照有效期类型">{{ labelOf(VALID_TYPES, apply.licenseValidType) }}</el-descriptions-item>
+ <el-descriptions-item label="营业执照有效期起止">{{ apply.licenseValidPeriod || '-' }}</el-descriptions-item>
+ <el-descriptions-item label="企业地址" :span="4">{{ apply.companyAddress || '-' }}</el-descriptions-item>
<el-descriptions-item label="营业执照" :span="4">
<!-- ImagePreview 是全局组件:内部按 VITE_APP_BASE_API 拼接 /profile 相对路径,
并自带预览与破图占位,不必在这里手拼地址 -->
<image-preview v-if="apply.licenseImage" :src="apply.licenseImage" width="120" height="120" />
+ <span v-else>-</span>
+ </el-descriptions-item>
+
+ <!-- 法人信息(营业执照有效期类型/起止已在企业信息段落展示) -->
+ <el-descriptions-item label="法人身份证有效期类型">{{ labelOf(VALID_TYPES, apply.legalIdValidType) }}</el-descriptions-item>
+ <el-descriptions-item label="法人身份证有效期起止">{{ apply.legalIdValidPeriod || '-' }}</el-descriptions-item>
+ <el-descriptions-item label="法人身份证详细地址" :span="2">{{ apply.legalIdAddress || '-' }}</el-descriptions-item>
+ <el-descriptions-item label="法人身份证人像面" :span="2">
+ <image-preview v-if="apply.legalIdCardFront" :src="apply.legalIdCardFront" width="120" height="120" />
+ <span v-else>-</span>
+ </el-descriptions-item>
+ <el-descriptions-item label="法人身份证国徽面" :span="2">
+ <image-preview v-if="apply.legalIdCardBack" :src="apply.legalIdCardBack" width="120" height="120" />
+ <span v-else>-</span>
+ </el-descriptions-item>
+
+ <!-- 负责人信息 -->
+ <el-descriptions-item label="负责人姓名">{{ apply.contactName || '-' }}</el-descriptions-item>
+ <el-descriptions-item label="负责人电话">{{ apply.contactPhone || '-' }}</el-descriptions-item>
+ <el-descriptions-item label="负责人邮箱" :span="2">{{ apply.contactEmail || '-' }}</el-descriptions-item>
+
+ <!-- 收款信息 -->
+ <el-descriptions-item label="收款类型">{{ apply.payeeType || '-' }}</el-descriptions-item>
+ <el-descriptions-item label="银行卡号">{{ apply.bankCardNo || '-' }}</el-descriptions-item>
+ <el-descriptions-item label="开户城市" :span="2">{{ apply.bankCity || '-' }}</el-descriptions-item>
+ <el-descriptions-item label="银行卡照片" :span="4">
+ <image-preview v-if="apply.bankCardImage" :src="apply.bankCardImage" width="120" height="120" />
<span v-else>-</span>
</el-descriptions-item>
</el-descriptions>
@@ -106,6 +137,7 @@
import { getCertificationApplyDetail, auditCertificationApply } from '@/api/user/certification'
import {
SUBJECT_TYPES,
+ VALID_TYPES,
AUTH_STATUS,
AUDIT_STATUS,
ACCOUNT_STATUS,
diff --git a/hugray-partner-miniapp/api/index.js b/hugray-partner-miniapp/api/index.js
index 41046e5..79adb47 100644
--- a/hugray-partner-miniapp/api/index.js
+++ b/hugray-partner-miniapp/api/index.js
@@ -10,11 +10,11 @@
// ======================== 认证相关 ========================
/**
- * 服务商登录
- * @param {Object} data { username, password, code, uuid }
+ * 服务商登录(账号 + 密码,专用接口,与用户端手机号登录区分)
+ * @param {Object} data { username, password }
*/
export function login(data) {
- return post('/miniapp/auth/login', data, { auth: false })
+ return post('/miniapp/auth/partner/login', data, { auth: false })
}
/**
@@ -61,7 +61,8 @@
/**
* 查询子账号列表(分页)
- * @param {Object} params { pageNum, pageSize, level }
+ * @param {Object} params { pageNum, pageSize, level, keyword }
+ * level:等级筛选(可空;L1/L2/L3);keyword:昵称/手机号搜索关键字(可空)
*/
export function getSubAccountList(params) {
return get('/miniapp/account/partner/subAccount/list', params)
diff --git a/hugray-partner-miniapp/pages.json b/hugray-partner-miniapp/pages.json
index b9dc653..207a93f 100644
--- a/hugray-partner-miniapp/pages.json
+++ b/hugray-partner-miniapp/pages.json
@@ -3,9 +3,10 @@
{
"path": "pages/index/index",
"style": {
- "navigationBarTitleText": "代谢300服务商",
- "navigationBarBackgroundColor": "#0b3f6f",
- "navigationBarTextStyle": "white"
+ "navigationStyle": "custom",
+ "enablePullDownRefresh": true,
+ "backgroundColor": "#f8fafc",
+ "backgroundTextStyle": "dark"
}
},
{
@@ -17,33 +18,34 @@
{
"path": "pages/login/forgot",
"style": {
- "navigationBarTitleText": "忘记密码",
- "navigationBarBackgroundColor": "#0b3f6f",
- "navigationBarTextStyle": "white"
+ "navigationStyle": "custom"
}
},
{
"path": "pages/sub-account/index",
"style": {
"navigationBarTitleText": "子账号管理",
- "navigationBarBackgroundColor": "#0b3f6f",
- "navigationBarTextStyle": "white"
+ "navigationBarBackgroundColor": "#d3e4f8",
+ "navigationBarTextStyle": "black",
+ "backgroundColor": "#f8fafc"
}
},
{
"path": "pages/sub-account/add",
"style": {
"navigationBarTitleText": "新增账号",
- "navigationBarBackgroundColor": "#0b3f6f",
- "navigationBarTextStyle": "white"
+ "navigationBarBackgroundColor": "#d3e4f8",
+ "navigationBarTextStyle": "black",
+ "backgroundColor": "#f8fafc"
}
},
{
"path": "pages/sub-account/detail",
"style": {
"navigationBarTitleText": "账号详情",
- "navigationBarBackgroundColor": "#0b3f6f",
- "navigationBarTextStyle": "white"
+ "navigationBarBackgroundColor": "#d3e4f8",
+ "navigationBarTextStyle": "black",
+ "backgroundColor": "#f8fafc"
}
}
],
diff --git a/hugray-partner-miniapp/pages/index/index.vue b/hugray-partner-miniapp/pages/index/index.vue
index 72f3d99..7c1a307 100644
--- a/hugray-partner-miniapp/pages/index/index.vue
+++ b/hugray-partner-miniapp/pages/index/index.vue
@@ -1,407 +1,804 @@
<template>
- <view class="partner-home-container">
- <!-- 顶部商户信息栏:包含公司全称与退出登录按钮 -->
- <view class="merchant-header-bar">
- <view class="company-name">{{ merchantInfo.companyName }}</view>
- <view class="logout-btn" @tap="_handleLogout">
- <text class="logout-text">退出登录</text>
+ <view class="page">
+ <!-- 品牌头部:浅蓝渐变 + 轻量装饰 -->
+ <view class="header" :style="{ paddingTop: headerTopPadding }">
+ <view class="deco-circle deco-circle--tr"></view>
+ <view class="deco-circle deco-circle--bl"></view>
+ <view class="deco-dots"></view>
+
+ <view class="brand-pill">
+ <text class="brand-dot"></text>
+ <text class="brand-text">代谢300 · 服务商工作台</text>
+ </view>
+
+ <view class="header-main">
+ <text class="company-name">{{ companyName || '服务商' }}</text>
+ <!-- 外层包裹撑大点击区,避免小按钮难以点中 -->
+ <view class="logout-wrap" @tap="_handleLogout">
+ <text class="logout-btn">退出登录</text>
+ </view>
</view>
</view>
- <!-- 卡片一:开通子账号统计卡片 -->
- <view class="dashboard-card">
- <view class="card-title-row">
- <view class="left-title-box">
- <text class="main-title">开通子账号数</text>
- <text class="total-count-num">{{ subAccountSummary.totalCount }}</text>
- </view>
- <view class="action-tag-btn" @tap="_navigateToSubAccounts">
- <text class="action-text">查看子账号</text>
- <!-- 采用 CSS 绘制右箭头,避免小程序模板将 > 转义成 > -->
- <view class="arrow-icon"></view>
+ <view class="content">
+ <!-- 首次加载中 -->
+ <view v-if="loading && !loaded" class="state-card">
+ <view class="loading-ring"></view>
+ <text class="state-text">数据加载中…</text>
+ </view>
+
+ <!-- 首次加载失败:提供重试入口 -->
+ <view v-else-if="errorMsg && !loaded" class="state-card">
+ <text class="state-title">数据加载失败</text>
+ <text class="state-text">{{ errorMsg }}</text>
+ <view class="retry-btn" @tap="_refresh">
+ <text class="retry-text">重新加载</text>
</view>
</view>
- <!-- 子账号各等级(L1 / L2 / L3)开通人数横向分布 -->
- <view class="level-stats-grid">
- <view
- v-for="item in subAccountSummary.levelList"
- :key="item.level"
- class="level-stat-item"
- >
- <view class="count-box">
- <text class="number-val">{{ item.count }}</text>
- <text class="unit-text">人</text>
+ <template v-else>
+ <!-- 账号概览 -->
+ <view class="card">
+ <view class="card-head">
+ <text class="card-title">子账号概览</text>
+ <view class="link" @tap="_goSubAccount">
+ <text class="link-text">查看全部</text>
+ <text class="link-arrow">›</text>
+ </view>
</view>
- <view class="level-badge">{{ item.level }}</view>
- </view>
- </view>
- </view>
- <!-- 卡片二:子账号业绩合计卡片 -->
- <view class="dashboard-card performance-card">
- <view class="card-title-row">
- <text class="main-title">子账号业绩合计</text>
- </view>
+ <view class="total-block">
+ <text class="total-label">已开通子账号总数</text>
+ <view class="total-value-row">
+ <text class="total-value">{{ subTotal }}</text>
+ <text class="total-unit">个</text>
+ </view>
+ </view>
- <!-- 核心汇总指标:双大卡片展示(当月合计收益 / 当月新增绑定) -->
- <view class="performance-highlight-row">
- <view
- v-for="(item, index) in performanceHighlights"
- :key="index"
- class="highlight-card"
- >
- <text class="metric-label">{{ item.label }}</text>
- <text class="metric-amount">{{ item.value }}</text>
- <text class="metric-compare">{{ item.trend }}</text>
+ <view class="level-row">
+ <view v-for="item in subLevels" :key="item.level" class="level-tile">
+ <view class="level-value">
+ <text class="level-count">{{ item.count }}</text>
+ <text class="level-unit">人</text>
+ </view>
+ <text class="level-badge">{{ item.level }}</text>
+ </view>
+ </view>
</view>
- </view>
- <!-- 细分统计指标网格:3 列平铺布局,数据驱动聚合渲染 -->
- <view class="metric-detail-grid">
- <view
- v-for="(metric, idx) in performanceDetailMetrics"
- :key="idx"
- class="metric-cell"
- >
- <text class="cell-label">{{ metric.label }}</text>
- <text class="cell-value">{{ metric.value }}</text>
+ <!-- 月度经营 -->
+ <view class="section-head">
+ <text class="section-title">月度经营</text>
+ <view class="month-pill">
+ <text class="month-text">{{ statMonth }}</text>
+ </view>
</view>
- </view>
+
+ <view class="perf-grid">
+ <view class="perf-card perf-card--earning">
+ <text class="perf-label">当月合计收益</text>
+ <text class="perf-amount">¥{{ earningText }}</text>
+ <text class="perf-trend" :class="'trend--' + earningTrend.dir">{{ earningTrend.text }}</text>
+ </view>
+ <view class="perf-card perf-card--binding">
+ <text class="perf-label">当月新增绑定</text>
+ <view class="perf-value-row">
+ <text class="perf-value">{{ bindingCount }}</text>
+ <text class="perf-value-unit">人</text>
+ </view>
+ <text class="perf-trend" :class="'trend--' + bindingTrend.dir">{{ bindingTrend.text }}</text>
+ </view>
+ </view>
+
+ <view class="metric-row">
+ <view class="metric-item">
+ <text class="metric-label">当月销售额</text>
+ <text class="metric-value">¥{{ salesText }}</text>
+ </view>
+ <view class="metric-item">
+ <text class="metric-label">当月进货额</text>
+ <text class="metric-value">¥{{ purchaseText }}</text>
+ </view>
+ <view class="metric-item">
+ <text class="metric-label">当月销售单数</text>
+ <text class="metric-value">{{ salesCount }}</text>
+ </view>
+ </view>
+
+ <!-- 本月新邀请 -->
+ <view class="card">
+ <view class="card-head">
+ <text class="card-title">本月新邀请</text>
+ <view class="invite-total">
+ <text class="invite-total-label">总人数</text>
+ <text class="invite-total-value">{{ inviteTotal }}</text>
+ </view>
+ </view>
+
+ <view class="level-row level-row--4">
+ <view v-for="item in inviteLevels" :key="item.level" class="level-tile">
+ <view class="level-value">
+ <text class="level-count">{{ item.count }}</text>
+ <text class="level-unit">人</text>
+ </view>
+ <text class="level-badge">{{ item.level }}</text>
+ </view>
+ </view>
+ </view>
+ </template>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
-import { onShow } from '@dcloudio/uni-app'
-import { getToken } from '@/utils/request.js'
-import config from '@/config/index.js'
+import { onLoad, onShow, onPullDownRefresh } from '@dcloudio/uni-app'
+import { getPartnerOverview, logout } from '@/api/index.js'
+import { clearToken } from '@/utils/request.js'
-// ======================== 数据定义区域 ========================
+/** 子账号等级固定顺序(后端仅返回 L1/L2/L3) */
+const SUB_LEVEL_ORDER = ['L1', 'L2', 'L3']
+/** 邀请等级固定顺序;L0 对应后端枚举值 C */
+const INVITE_LEVEL_ORDER = ['L0', 'L1', 'L2', 'L3']
+/** 后端等级编码到前端展示文案的映射 */
+const LEVEL_CODE_MAP = { C: 'L0' }
-// 商户基本信息
-const merchantInfo = ref({
- companyName: '成都市XX营销管理有限公司'
-})
+// ==================== 页面数据 ====================
+const loading = ref(false)
+/** 是否已成功加载过数据:控制首屏骨架/失败态与卡片展示 */
+const loaded = ref(false)
+const errorMsg = ref('')
+const companyName = ref('')
+const subTotal = ref(0)
+const subLevels = ref([])
+const statMonth = ref('')
+const earningText = ref('0.00')
+const earningTrend = ref({ text: '', dir: 'none' })
+const bindingCount = ref(0)
+const bindingTrend = ref({ text: '', dir: 'none' })
+const salesText = ref('0.00')
+const purchaseText = ref('0.00')
+const salesCount = ref(0)
+const inviteTotal = ref(0)
+const inviteLevels = ref([])
-// 开通子账号汇总数据
-const subAccountSummary = ref({
- totalCount: 20,
- levelList: [
- { level: 'L1', count: 32 },
- { level: 'L2', count: 12 },
- { level: 'L3', count: 8 }
- ]
-})
+/** 首屏是否已触发过加载,用于 onShow 区分「返回刷新」与「首次进入」 */
+let firstLoadDone = false
-// 核心大指标数据(当月收益与新增绑定)
-const performanceHighlights = ref([
- {
- label: '当月合计收益',
- value: '¥ 1921.00',
- trend: '较上月提升14%'
- },
- {
- label: '当月新增绑定',
- value: '258',
- trend: '较上月提升14%'
+// ==================== 自定义导航留白 ====================
+const _systemInfo = uni.getSystemInfoSync()
+const _statusBarHeight = _systemInfo.statusBarHeight || 0
+/** 导航内容区高度,默认 44px;微信端按胶囊按钮位置动态计算 */
+let _navContentHeight = 44
+// #ifdef MP-WEIXIN
+try {
+ const capsule = uni.getMenuButtonBoundingClientRect()
+ if (capsule && capsule.height && capsule.top > _statusBarHeight) {
+ _navContentHeight = (capsule.top - _statusBarHeight) * 2 + capsule.height
}
-])
+} catch (e) {
+ console.error('[PARTNER_INDEX] 获取微信胶囊信息失败,回退默认导航高度', e)
+}
+// #endif
+/** 头部内边距顶部:状态栏 + 导航内容区,避免被胶囊遮挡 */
+const headerTopPadding = _statusBarHeight + _navContentHeight + 'px'
-// 细分业绩指标列表(采用聚合配置驱动渲染,保证 DRY 原则)
-const performanceDetailMetrics = ref([
- { label: '当月总计销售额', value: '¥ 192541.00' },
- { label: '当月进货额', value: '¥ 192541.00' },
- { label: '当月销售单', value: '1234' },
- { label: '当月新邀请总数', value: '86' },
- { label: '当月新L0', value: '86' },
- { label: '当月新L1', value: '6' },
- { label: '当月新L2', value: '3' },
- { label: '当月新L3', value: '1' }
-])
+// ==================== 格式化工具 ====================
+/** 分转元:保留两位小数并添加千位分隔,支持负数 */
+const _formatAmount = (cents) => {
+ const yuan = Number(cents || 0) / 100
+ const fixed = Math.abs(yuan).toFixed(2)
+ const [intPart, decPart] = fixed.split('.')
+ const sign = yuan < 0 ? '-' : ''
+ return sign + intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + '.' + decPart
+}
-// ======================== 方法定义区域 ========================
+/** 环比文案与方向:null/undefined 表示上月无数据 */
+const _resolveTrend = (value) => {
+ if (value === null || value === undefined) {
+ return { text: '暂无上月对比', dir: 'none' }
+ }
+ const rate = Number(value)
+ if (rate > 0) {
+ return { text: '较上月提升 ' + rate + '%', dir: 'up' }
+ }
+ if (rate < 0) {
+ return { text: '较上月下降 ' + Math.abs(rate) + '%', dir: 'down' }
+ }
+ return { text: '较上月持平', dir: 'flat' }
+}
-/**
- * 退出登录私有处理函数
- */
+/** 按固定顺序组装等级分布,缺失等级补 0,保证等级行稳定 */
+const _buildLevels = (list, order) => {
+ const countMap = {}
+ ;(list || []).forEach((item) => {
+ const code = LEVEL_CODE_MAP[item.level] || item.level
+ countMap[code] = Number(item.count || 0)
+ })
+ return order.map((level) => ({ level, count: countMap[level] || 0 }))
+}
+
+/** 当前统计月份文案,与后端自然月口径一致 */
+const _currentMonthText = () => {
+ const now = new Date()
+ const month = String(now.getMonth() + 1).padStart(2, '0')
+ return now.getFullYear() + '年' + month + '月'
+}
+
+// ==================== 数据加载 ====================
+/** 将接口返回写入页面数据 */
+const _applyOverview = (data) => {
+ const source = data || {}
+ const subAccount = source.subAccount || {}
+ const performance = source.performance || {}
+
+ companyName.value = source.companyName || ''
+ subTotal.value = Number(subAccount.totalCount || 0)
+ subLevels.value = _buildLevels(subAccount.levels, SUB_LEVEL_ORDER)
+
+ earningText.value = _formatAmount(performance.monthEarning)
+ earningTrend.value = _resolveTrend(performance.monthEarningTrend)
+ bindingCount.value = Number(performance.monthNewBinding || 0)
+ bindingTrend.value = _resolveTrend(performance.monthNewBindingTrend)
+ salesText.value = _formatAmount(performance.monthSalesAmount)
+ purchaseText.value = _formatAmount(performance.monthPurchaseAmount)
+ salesCount.value = Number(performance.monthSalesCount || 0)
+
+ inviteTotal.value = Number(performance.monthNewInviteTotal || 0)
+ inviteLevels.value = _buildLevels(performance.monthNewLevels, INVITE_LEVEL_ORDER)
+
+ statMonth.value = _currentMonthText()
+}
+
+/** 加载概览:loading 期间直接返回,避免重复并发请求 */
+const _refresh = async () => {
+ if (loading.value) {
+ uni.stopPullDownRefresh()
+ return
+ }
+ loading.value = true
+ try {
+ const res = await getPartnerOverview()
+ _applyOverview(res && res.data)
+ loaded.value = true
+ errorMsg.value = ''
+ } catch (e) {
+ console.error('[PARTNER_INDEX] 加载服务商首页概览失败', e)
+ errorMsg.value = (e && e.message) || '网络异常,请稍后重试'
+ // 已有数据时保留旧数据,仅提示刷新失败
+ if (loaded.value) {
+ uni.showToast({ title: '刷新失败,已保留上次数据', icon: 'none' })
+ }
+ } finally {
+ loading.value = false
+ uni.stopPullDownRefresh()
+ }
+}
+
+// ==================== 交互 ====================
+/** 查看全部子账号 */
+const _goSubAccount = () => {
+ uni.navigateTo({ url: '/pages/sub-account/index' })
+}
+
+/** 退出登录:确认后调用接口,成功才清理登录态并回到登录页 */
const _handleLogout = () => {
uni.showModal({
- title: '提示',
- content: '确定要退出当前服务商账号吗?',
- success: (res) => {
- if (res.confirm) {
- // 执行登出逻辑与跳转
- uni.showToast({
- title: '已退出登录',
- icon: 'none'
- })
+ title: '退出登录',
+ content: '确定要退出当前账号吗?',
+ confirmColor: '#1e5ebc',
+ success: async (res) => {
+ if (!res.confirm) {
+ return
}
+ try {
+ await logout()
+ } catch (e) {
+ // 退出失败保留登录态,避免用户被困在当前账号
+ console.error('[PARTNER_INDEX] 退出登录接口调用失败', e)
+ uni.showToast({ title: '退出失败,请稍后重试', icon: 'none' })
+ return
+ }
+ clearToken()
+ uni.reLaunch({ url: '/pages/login/index' })
}
})
}
-/**
- * 跳转至查看子账号列表页面
- */
-const _navigateToSubAccounts = () => {
- uni.navigateTo({
- url: '/pages/sub-account/index',
- fail: () => {
- // 跳转失败时的降级提示
- uni.showToast({
- title: '页面跳转失败',
- icon: 'none'
- })
- }
- })
-}
+// ==================== 生命周期 ====================
+onLoad(() => {
+ _refresh()
+})
-/**
- * 校验登录态:本地无 token 时重定向至登录页
- */
-const _checkLoginStatus = () => {
- if (getToken()) return
- uni.reLaunch({ url: config.loginPage })
-}
-
-// 每次页面显示均校验登录态,避免退出登录后仍停留在首页
onShow(() => {
- _checkLoginStatus()
+ // 首次进入由 onLoad 触发的加载已在进行,避免重复请求
+ if (firstLoadDone) {
+ _refresh()
+ } else {
+ firstLoadDone = true
+ }
+})
+
+onPullDownRefresh(() => {
+ _refresh()
})
</script>
<style scoped lang="scss">
-/* 页面最外层容器:淡灰蓝背景,留出整洁边距 */
-.partner-home-container {
+/* ==================== 页面底板 ==================== */
+.page {
min-height: 100vh;
- background-color: #f7f9fc;
- padding: 30rpx 28rpx;
box-sizing: border-box;
+ background-color: #f8fafc;
}
-/* 顶部商户信息行 */
-.merchant-header-bar {
+/* ==================== 品牌头部 ==================== */
+.header {
+ position: relative;
display: flex;
- justify-content: space-between;
+ flex-direction: column;
+ overflow: hidden;
+ padding-left: 32rpx;
+ padding-right: 32rpx;
+ padding-bottom: 120rpx;
+ background-image: linear-gradient(165deg, #d3e4f8 0%, #bcd6f4 55%, #abccec 100%);
+ border-bottom-left-radius: 40rpx;
+ border-bottom-right-radius: 40rpx;
+}
+
+/* 头部装饰:柔光圆与点阵,纯 CSS 绘制 */
+.deco-circle {
+ position: absolute;
+ z-index: 0;
+ pointer-events: none;
+ border-radius: 50%;
+ background-color: rgba(255, 255, 255, 0.4);
+}
+
+.deco-circle--tr {
+ top: -80rpx;
+ right: -60rpx;
+ width: 280rpx;
+ height: 280rpx;
+}
+
+.deco-circle--bl {
+ bottom: -100rpx;
+ left: -80rpx;
+ width: 240rpx;
+ height: 240rpx;
+ background-color: rgba(255, 255, 255, 0.28);
+}
+
+.deco-dots {
+ position: absolute;
+ z-index: 0;
+ right: 40rpx;
+ bottom: 32rpx;
+ width: 120rpx;
+ height: 60rpx;
+ pointer-events: none;
+ background-image: radial-gradient(circle, rgba(255, 255, 255, 0.65) 3rpx, transparent 3rpx);
+ background-size: 24rpx 24rpx;
+}
+
+/* 品牌标识胶囊 */
+.brand-pill {
+ position: relative;
+ z-index: 1;
+ display: flex;
align-items: center;
- margin-bottom: 28rpx;
+ align-self: flex-start;
+ gap: 12rpx;
+ padding: 10rpx 22rpx;
+ border-radius: 999rpx;
+ background-color: rgba(255, 255, 255, 0.55);
+}
+
+.brand-dot {
+ width: 14rpx;
+ height: 14rpx;
+ border-radius: 50%;
+ background-color: #2d91ff;
+}
+
+.brand-text {
+ font-size: 24rpx;
+ font-weight: 600;
+ letter-spacing: 1rpx;
+ color: #003e7e;
+}
+
+/* 公司名称 + 退出登录 */
+.header-main {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20rpx;
+ margin-top: 24rpx;
}
.company-name {
- font-size: 32rpx;
- font-weight: 700;
- color: #0b3b68;
- line-height: 44rpx;
- letter-spacing: 0.5rpx;
-}
-
-/* 退出登录按钮样式 */
-.logout-btn {
- position: relative;
- display: flex;
- align-items: center;
- justify-content: center;
- background-color: #0b3b68;
- border-radius: 8rpx;
- padding: 10rpx 24rpx;
- box-shadow: 0 2rpx 6rpx rgba(11, 59, 104, 0.15);
-}
-
-.logout-text {
- font-size: 24rpx;
- font-weight: 600;
- color: #ffffff;
-}
-
-/* 通用右上角微标/小闪电标记 */
-.badge-icon {
- position: absolute;
- top: -6rpx;
- right: -6rpx;
- font-size: 18rpx;
- color: #40a9ff;
- line-height: 1;
-}
-
-/* 基础卡片样式容器 */
-.dashboard-card {
- background-color: #ffffff;
- border-radius: 16rpx;
- padding: 28rpx;
- margin-bottom: 24rpx;
- border: 1rpx solid #eef1f6;
- box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.02);
-}
-
-/* 卡片标题行 */
-.card-title-row {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 24rpx;
-}
-
-.left-title-box {
- display: flex;
- align-items: baseline;
-}
-
-.main-title {
- font-size: 30rpx;
- font-weight: 700;
- color: #1f2329;
-}
-
-.total-count-num {
+ flex: 1;
+ display: -webkit-box;
+ overflow: hidden;
font-size: 36rpx;
font-weight: 700;
- color: #0b3b68;
- margin-left: 20rpx;
+ line-height: 48rpx;
+ color: #0f172a;
+ word-break: break-all;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
}
-/* 查看子账号操作按钮 */
-.action-tag-btn {
- position: relative;
- display: flex;
- align-items: center;
+.logout-wrap {
+ flex-shrink: 0;
+ padding: 12rpx 0 12rpx 12rpx;
}
-.action-text {
- font-size: 24rpx;
- color: #a67c1e;
+.logout-btn {
+ display: block;
+ height: 64rpx;
+ padding: 0 28rpx;
+ font-size: 26rpx;
font-weight: 600;
+ line-height: 64rpx;
+ color: #1e5ebc;
+ background-color: #ffffff;
+ border-radius: 999rpx;
+ box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.08);
}
-.arrow-icon {
- width: 12rpx;
- height: 12rpx;
- border-top: 3rpx solid #a67c1e;
- border-right: 3rpx solid #a67c1e;
- transform: rotate(45deg);
- margin-left: 8rpx;
+/* ==================== 内容区(上浮覆盖头部) ==================== */
+.content {
+ position: relative;
+ z-index: 2;
+ margin-top: -80rpx;
+ padding: 0 32rpx calc(40rpx + env(safe-area-inset-bottom));
+}
+
+/* ==================== 卡片通用 ==================== */
+.card {
box-sizing: border-box;
+ margin-bottom: 24rpx;
+ padding: 32rpx;
+ background-color: #ffffff;
+ border-radius: 32rpx;
+ box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.06);
}
-/* 子账号等级卡片 3 列布局 */
-.level-stats-grid {
+.card-head {
display: flex;
- justify-content: space-between;
- gap: 18rpx;
-}
-
-.level-stat-item {
- flex: 1;
- background-color: #f5f6fa;
- border-radius: 12rpx;
- padding: 24rpx 0 20rpx;
- display: flex;
- flex-direction: column;
align-items: center;
- justify-content: center;
+ justify-content: space-between;
}
-.count-box {
+.card-title {
+ font-size: 30rpx;
+ font-weight: 700;
+ color: #0f172a;
+}
+
+.link {
+ display: flex;
+ align-items: center;
+ gap: 6rpx;
+ padding: 10rpx 0 10rpx 20rpx;
+}
+
+.link-text {
+ font-size: 24rpx;
+ font-weight: 600;
+ color: #2d91ff;
+}
+
+.link-arrow {
+ font-size: 30rpx;
+ line-height: 1;
+ color: #2d91ff;
+}
+
+/* ==================== 账号概览 ==================== */
+.total-block {
+ margin-top: 24rpx;
+ padding: 24rpx 28rpx;
+ border-radius: 24rpx;
+ background-image: linear-gradient(135deg, #eaf3ff 0%, #f5f9ff 100%);
+}
+
+.total-label {
+ font-size: 24rpx;
+ color: #64748b;
+}
+
+.total-value-row {
display: flex;
align-items: baseline;
- margin-bottom: 14rpx;
+ gap: 8rpx;
+ margin-top: 8rpx;
}
-.number-val {
- font-size: 40rpx;
+.total-value {
+ font-size: 68rpx;
font-weight: 700;
- color: #0b3b68;
- line-height: 1;
+ line-height: 1.1;
+ color: #003e7e;
}
-.unit-text {
+.total-unit {
font-size: 24rpx;
- font-weight: 600;
- color: #52c41a;
- margin-left: 4rpx;
+ color: #64748b;
}
-/* 等级胶囊指示标签(L1/L2/L3) */
-.level-badge {
- background-color: #67b72c;
- color: #ffffff;
- font-size: 22rpx;
- font-weight: 700;
- padding: 4rpx 22rpx;
- border-radius: 6rpx;
- line-height: 1.2;
-}
-
-/* 业绩合计大卡片 */
-.performance-card {
- padding-bottom: 32rpx;
-}
-
-/* 核心双指标左右大卡片 */
-.performance-highlight-row {
+/* ==================== 等级分布 ==================== */
+.level-row {
display: flex;
gap: 20rpx;
- margin-bottom: 36rpx;
+ margin-top: 24rpx;
}
-.highlight-card {
+.level-tile {
flex: 1;
- background-color: #f5f6fa;
- border-radius: 12rpx;
- padding: 24rpx 16rpx;
display: flex;
flex-direction: column;
align-items: center;
- text-align: center;
+ gap: 12rpx;
+ padding: 24rpx 12rpx;
+ border-radius: 24rpx;
+ background-color: #f8fafc;
+}
+
+.level-value {
+ display: flex;
+ align-items: baseline;
+ gap: 4rpx;
+}
+
+.level-count {
+ font-size: 40rpx;
+ font-weight: 700;
+ color: #1e5ebc;
+}
+
+.level-unit {
+ font-size: 22rpx;
+ color: #64748b;
+}
+
+.level-badge {
+ padding: 4rpx 18rpx;
+ font-size: 22rpx;
+ font-weight: 600;
+ color: #ffffff;
+ background-color: #2fa05e;
+ border-radius: 999rpx;
+}
+
+/* 四列分布略收紧,适配 320px 窄屏 */
+.level-row--4 {
+ gap: 16rpx;
+}
+
+.level-row--4 .level-tile {
+ padding: 22rpx 8rpx;
+}
+
+.level-row--4 .level-count {
+ font-size: 36rpx;
+}
+
+/* ==================== 月度经营 ==================== */
+.section-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin: 8rpx 8rpx 20rpx;
+}
+
+.section-title {
+ font-size: 30rpx;
+ font-weight: 700;
+ color: #0f172a;
+}
+
+.month-pill {
+ padding: 8rpx 22rpx;
+ border-radius: 999rpx;
+ background-color: rgba(45, 145, 255, 0.12);
+}
+
+.month-text {
+ font-size: 24rpx;
+ font-weight: 600;
+ color: #1e5ebc;
+}
+
+.perf-grid {
+ display: flex;
+ gap: 24rpx;
+ margin-bottom: 24rpx;
+}
+
+.perf-card {
+ flex: 1;
+ box-sizing: border-box;
+ padding: 28rpx 24rpx;
+ border-radius: 32rpx;
+ box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.06);
+}
+
+.perf-card--earning {
+ background-image: linear-gradient(150deg, #e3efff 0%, #f4f9ff 100%);
+}
+
+.perf-card--binding {
+ background-image: linear-gradient(150deg, #e8f6ec 0%, #f5fbf7 100%);
+}
+
+.perf-label {
+ font-size: 24rpx;
+ color: #64748b;
+}
+
+.perf-amount {
+ display: block;
+ margin-top: 12rpx;
+ font-size: 44rpx;
+ font-weight: 700;
+ line-height: 1.2;
+ color: #003e7e;
+ word-break: break-all;
+}
+
+.perf-value-row {
+ display: flex;
+ align-items: baseline;
+ gap: 6rpx;
+ margin-top: 12rpx;
+}
+
+.perf-value {
+ font-size: 44rpx;
+ font-weight: 700;
+ line-height: 1.2;
+ color: #2fa05e;
+}
+
+.perf-value-unit {
+ font-size: 24rpx;
+ color: #64748b;
+}
+
+.perf-trend {
+ display: block;
+ margin-top: 16rpx;
+ font-size: 22rpx;
+ font-weight: 600;
+}
+
+.trend--up {
+ color: #2fa05e;
+}
+
+.trend--down {
+ color: #e05a5a;
+}
+
+.trend--flat,
+.trend--none {
+ color: #94a3b8;
+}
+
+/* ==================== 交易指标三列 ==================== */
+.metric-row {
+ display: flex;
+ gap: 24rpx;
+ margin-bottom: 32rpx;
+}
+
+.metric-item {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 10rpx;
+ box-sizing: border-box;
+ padding: 24rpx 20rpx;
+ background-color: #ffffff;
+ border-radius: 28rpx;
+ box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.06);
}
.metric-label {
- font-size: 24rpx;
- color: #8f959e;
- margin-bottom: 10rpx;
-}
-
-.metric-amount {
- font-size: 38rpx;
- font-weight: 700;
- color: #0b3b68;
- line-height: 1.2;
- margin-bottom: 10rpx;
-}
-
-.metric-compare {
font-size: 22rpx;
- font-weight: 500;
- color: #0b3b68;
+ color: #64748b;
}
-/* 细分指标三列网格布局 */
-.metric-detail-grid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- row-gap: 32rpx;
- column-gap: 16rpx;
+.metric-value {
+ font-size: 28rpx;
+ font-weight: 700;
+ line-height: 1.3;
+ color: #0f172a;
+ word-break: break-all;
}
-.metric-cell {
+/* ==================== 本月新邀请 ==================== */
+.invite-total {
+ display: flex;
+ align-items: baseline;
+ gap: 10rpx;
+}
+
+.invite-total-label {
+ font-size: 24rpx;
+ color: #64748b;
+}
+
+.invite-total-value {
+ font-size: 36rpx;
+ font-weight: 700;
+ color: #2d91ff;
+}
+
+/* ==================== 说明与状态 ==================== */
+
+.state-card {
display: flex;
flex-direction: column;
align-items: center;
+ gap: 20rpx;
+ margin-top: 40rpx;
+ padding: 80rpx 32rpx;
+ background-color: #ffffff;
+ border-radius: 32rpx;
+ box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.06);
+}
+
+.loading-ring {
+ width: 56rpx;
+ height: 56rpx;
+ border: 6rpx solid rgba(45, 145, 255, 0.2);
+ border-top-color: #2d91ff;
+ border-radius: 50%;
+ animation: ring-rotate 0.9s linear infinite;
+}
+
+@keyframes ring-rotate {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.state-title {
+ font-size: 30rpx;
+ font-weight: 700;
+ color: #0f172a;
+}
+
+.state-text {
+ font-size: 24rpx;
+ line-height: 36rpx;
+ color: #64748b;
text-align: center;
}
-.cell-label {
- font-size: 24rpx;
- color: #8f959e;
- margin-bottom: 10rpx;
- line-height: 32rpx;
+.retry-btn {
+ height: 80rpx;
+ padding: 0 44rpx;
+ margin-top: 8rpx;
+ line-height: 80rpx;
+ background-image: linear-gradient(135deg, #2d91ff 0%, #1e5ebc 100%);
+ border-radius: 999rpx;
}
-.cell-value {
+.retry-text {
font-size: 28rpx;
- font-weight: 700;
- color: #0b3b68;
- line-height: 36rpx;
+ font-weight: 600;
+ color: #ffffff;
}
</style>
diff --git a/hugray-partner-miniapp/pages/login/forgot.vue b/hugray-partner-miniapp/pages/login/forgot.vue
index 328ef8a..ac14edb 100644
--- a/hugray-partner-miniapp/pages/login/forgot.vue
+++ b/hugray-partner-miniapp/pages/login/forgot.vue
@@ -1,6 +1,26 @@
<template>
<view class="reset-page">
- <view class="form-area">
+ <!-- 背景装饰层:2 处微透十字 + 2 处白色微点阵(置于内容之下,不拦截点击) -->
+ <view class="deco-cross deco-cross--tr"></view>
+ <view class="deco-cross deco-cross--ml"></view>
+ <view class="deco-dots deco-dots--lt"></view>
+ <view class="deco-dots deco-dots--rb"></view>
+
+ <!-- 自定义导航栏:返回箭头 + 居中标题 -->
+ <view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
+ <view class="nav-back" @tap="_handleBack">
+ <view class="nav-back-arrow"></view>
+ </view>
+ <text class="nav-title">重置密码</text>
+ </view>
+
+ <!-- 核心内容区:引导文案 + 表单 -->
+ <view class="content">
+ <view class="intro">
+ <text class="intro-title">重置登录密码</text>
+ <text class="intro-desc">请输入注册手机号,通过短信验证码验证身份后设置新密码</text>
+ </view>
+
<!-- 登录手机号 -->
<view class="field-block">
<text class="field-label">登录手机号</text>
@@ -11,8 +31,8 @@
class="field-input"
type="number"
maxlength="11"
- placeholder="请输入账号"
- placeholder-style="color:#b8bec7;font-size:28rpx"
+ placeholder="请输入手机号"
+ placeholder-style="color:#94A3B8;font-size:28rpx"
/>
</view>
</view>
@@ -29,7 +49,7 @@
type="number"
maxlength="6"
placeholder="请输入验证码"
- placeholder-style="color:#b8bec7;font-size:28rpx"
+ placeholder-style="color:#94A3B8;font-size:28rpx"
/>
</view>
<!-- 获取验证码:倒计时期间禁用 -->
@@ -53,8 +73,8 @@
class="field-input"
:password="!showPassword"
maxlength="20"
- placeholder="请输入密码"
- placeholder-style="color:#b8bec7;font-size:28rpx"
+ placeholder="请输入6-20位新密码"
+ placeholder-style="color:#94A3B8;font-size:28rpx"
/>
<!-- 密码可见性切换 -->
<view class="eye-wrap" @tap="showPassword = !showPassword">
@@ -66,9 +86,9 @@
</view>
</view>
- <!-- 再次输入新密码 -->
+ <!-- 确认新密码 -->
<view class="field-block">
- <text class="field-label">再次输入新密码</text>
+ <text class="field-label">确认新密码</text>
<view class="input-wrap">
<view class="field-icon icon-grid"></view>
<input
@@ -76,8 +96,8 @@
class="field-input"
:password="!showConfirmPassword"
maxlength="20"
- placeholder="请输入新密码"
- placeholder-style="color:#b8bec7;font-size:28rpx"
+ placeholder="请再次输入新密码"
+ placeholder-style="color:#94A3B8;font-size:28rpx"
/>
<view class="eye-wrap" @tap="showConfirmPassword = !showConfirmPassword">
<view
@@ -89,9 +109,16 @@
</view>
</view>
- <!-- 底部通栏提交按钮 -->
- <view class="bottom-bar">
- <view class="submit-btn" @tap="_handleSubmit">提交</view>
+ <!-- 底部提交区:渐变胶囊按钮 + Home 条 -->
+ <view class="bottom-area">
+ <view
+ class="submit-btn"
+ :class="{ 'submit-btn--disabled': submitting }"
+ @tap="_handleSubmit"
+ >
+ 确认重置
+ </view>
+ <view class="home-indicator"></view>
</view>
</view>
</template>
@@ -123,6 +150,12 @@
// 是否处于倒计时状态(用于禁用获取验证码按钮)
const counting = computed(() => countdown.value > 0)
+// 提交进行中标记,防止重复提交
+const submitting = ref(false)
+
+// 状态栏高度(px),用于自定义导航栏顶部留白
+const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
+
// 倒计时定时器句柄
let countdownTimer = null
@@ -130,6 +163,11 @@
/** 校验手机号格式 */
const _isValidPhone = (phone) => /^1[3-9]\d{9}$/.test(phone)
+
+/** 返回上一页(登录页) */
+const _handleBack = () => {
+ uni.navigateBack()
+}
/** 启动 60 秒倒计时,期间禁用「获取验证码」 */
const _startCountdown = () => {
@@ -164,19 +202,22 @@
const _validateForm = () => {
if (!_isValidPhone(form.phone)) return '请输入正确的手机号'
if (!form.code.trim()) return '请输入验证码'
- if (form.password.length < 6) return '新密码长度不能少于6位'
+ if (form.password.length < 6 || form.password.length > 20) return '新密码长度需为6-20位'
if (form.password !== form.confirmPassword) return '两次输入的密码不一致'
return ''
}
/** 提交重置密码:校验 → 调接口 → 返回登录页 */
const _handleSubmit = async () => {
+ if (submitting.value) return
+
const errorMsg = _validateForm()
if (errorMsg) {
uni.showToast({ title: errorMsg, icon: 'none' })
return
}
+ submitting.value = true
try {
await resetPassword({
phone: form.phone,
@@ -187,6 +228,8 @@
setTimeout(() => uni.navigateBack(), 1200)
} catch (e) {
uni.showToast({ title: e.message || '密码重置失败', icon: 'none' })
+ } finally {
+ submitting.value = false
}
}
@@ -197,39 +240,204 @@
</script>
<style scoped lang="scss">
-/* 页面最外层:纵向弹性布局,撑满整屏 */
+/* ==================== 页面底板 ==================== */
+
+/* 页面最外层:浅蓝品牌渐变底(165°,0.8 透明),纵向弹性布局撑满整屏 */
.reset-page {
+ position: relative;
display: flex;
flex-direction: column;
- height: 100vh;
+ min-height: 100vh;
+ box-sizing: border-box;
+ overflow: hidden;
background-color: #ffffff;
+ background-image: linear-gradient(
+ 165deg,
+ rgba(211, 228, 248, 0.8) 0%,
+ rgba(188, 214, 244, 0.8) 50%,
+ rgba(171, 204, 236, 0.8) 100%
+ );
}
-.form-area {
+/* ==================== 背景装饰层 ==================== */
+
+/* 微透十字:容器尺寸按设计稿,竖条(::before)/横条(::after)等宽居中交叉 */
+.deco-cross {
+ position: absolute;
+ z-index: 0;
+ pointer-events: none;
+
+ &::before,
+ &::after {
+ content: '';
+ position: absolute;
+ background-color: var(--cross-color);
+ border-radius: calc(var(--bar) / 2);
+ }
+
+ /* 竖条:水平居中 */
+ &::before {
+ left: var(--offset);
+ top: 0;
+ width: var(--bar);
+ height: 100%;
+ }
+
+ /* 横条:垂直居中 */
+ &::after {
+ left: 0;
+ top: var(--offset);
+ width: 100%;
+ height: var(--bar);
+ }
+}
+
+/* 右上十字:80×80pt,条宽 22 / R11,#FFFFFF35 */
+.deco-cross--tr {
+ top: 20rpx;
+ right: 10rpx;
+ width: 160rpx;
+ height: 160rpx;
+ --bar: 44rpx;
+ --offset: 58rpx;
+ --cross-color: rgba(255, 255, 255, 0.208);
+}
+
+/* 中左十字:68×68pt,条宽 18 / R9,#FFFFFF30 */
+.deco-cross--ml {
+ top: 41%;
+ left: -48rpx;
+ width: 136rpx;
+ height: 136rpx;
+ --bar: 36rpx;
+ --offset: 50rpx;
+ --cross-color: rgba(255, 255, 255, 0.188);
+}
+
+/*
+ * 白色微点阵:微点 3×3pt(6rpx),行内/行间 gap 10pt(20rpx) → 平铺步距 26rpx
+ */
+.deco-dots {
+ position: absolute;
+ z-index: 0;
+ pointer-events: none;
+ background-image: radial-gradient(circle, rgba(255, 255, 255, 0.333) 3rpx, transparent 3rpx);
+ background-size: 26rpx 26rpx;
+}
+
+/* 左上点阵(5 列 × 4 行) */
+.deco-dots--lt {
+ top: 22%;
+ left: 20rpx;
+ width: 110rpx;
+ height: 84rpx;
+}
+
+/* 右下点阵(3 列 × 3 行) */
+.deco-dots--rb {
+ bottom: 180rpx;
+ right: 40rpx;
+ width: 58rpx;
+ height: 58rpx;
+}
+
+/* ==================== 自定义导航栏 ==================== */
+
+.nav-bar {
+ position: relative;
+ z-index: 1;
+ height: 88rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding-left: 24rpx;
+ padding-right: 24rpx;
+}
+
+/* 返回按钮:居左,点击热区 64rpx */
+.nav-back {
+ position: absolute;
+ left: 24rpx;
+ bottom: 0;
+ width: 64rpx;
+ height: 88rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* 返回箭头:2rpx 描边旋转 45° 呈左向 V */
+.nav-back-arrow {
+ width: 22rpx;
+ height: 22rpx;
+ border-left: 4rpx solid #0f172a;
+ border-bottom: 4rpx solid #0f172a;
+ transform: rotate(45deg);
+ margin-left: 8rpx;
+}
+
+/* 导航标题:17pt 600 深板岩色 */
+.nav-title {
+ font-size: 34rpx;
+ font-weight: 600;
+ color: #0f172a;
+}
+
+/* ==================== 核心内容区 ==================== */
+
+/* 内容区:撑满剩余高度,左右 56rpx */
+.content {
+ position: relative;
+ z-index: 1;
flex: 1;
- padding: 30rpx 28rpx;
+ padding: 24rpx 56rpx 0;
+}
+
+/* 引导文案块 */
+.intro {
+ margin-bottom: 48rpx;
+}
+
+/* 主标题:34rpx 600 深板岩色 */
+.intro-title {
+ display: block;
+ font-size: 34rpx;
+ font-weight: 600;
+ color: #0f172a;
+}
+
+/* 副说明:24rpx 弱化色,行高 36rpx */
+.intro-desc {
+ display: block;
+ margin-top: 16rpx;
+ font-size: 24rpx;
+ line-height: 36rpx;
+ color: #64748b;
}
/* 单个输入项 */
.field-block {
- margin-bottom: 40rpx;
+ margin-bottom: 28rpx;
}
+/* 表单标签:26rpx 500 */
.field-label {
display: block;
- font-size: 28rpx;
- color: #1f2329;
- margin-bottom: 20rpx;
+ margin-bottom: 18rpx;
+ font-size: 26rpx;
+ font-weight: 500;
+ color: #334155;
}
-/* 带图标的输入框 */
+/* 带图标的输入框:R16 / H88,浅底 + 描边 */
.input-wrap {
display: flex;
align-items: center;
- height: 96rpx;
- padding: 0 24rpx;
- border: 1rpx solid #dcdfe6;
- border-radius: 8rpx;
+ height: 88rpx;
+ padding: 0 28rpx;
+ border: 2rpx solid #cbd5e1;
+ border-radius: 16rpx;
+ background-color: #f8fafc;
box-sizing: border-box;
}
@@ -245,7 +453,9 @@
margin-right: 20rpx;
}
+/* 品牌图标:内嵌 44rpx */
.field-icon {
+ position: relative;
width: 44rpx;
height: 44rpx;
margin-right: 20rpx;
@@ -255,7 +465,7 @@
background-size: 100% 100%;
}
-/* 账号/手机号图标:手机外框 + 底部听筒线 */
+/* 手机号图标:手机外框 + 底部听筒线(品牌色 #0b3f6f) */
.icon-phone {
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NCA0NCI+CiAgPHJlY3QgeD0iMTIuNSIgeT0iNS41IiB3aWR0aD0iMTkiIGhlaWdodD0iMzMiIHJ4PSI0LjUiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzBiM2Y2ZiIgc3Ryb2tlLXdpZHRoPSIzIi8+CiAgPGxpbmUgeDE9IjE4LjUiIHkxPSIzMi41IiB4Mj0iMjUuNSIgeTI9IjMyLjUiIHN0cm9rZT0iIzBiM2Y2ZiIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KPC9zdmc+Cg==');
}
@@ -265,7 +475,7 @@
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NCA0NCI+CiAgPGRlZnM+CiAgICA8Y2xpcFBhdGggaWQ9InNjIj4KICAgICAgPHBhdGggZD0iTTIyIDkgTDMzIDE3LjUgTDMzIDI1IEMzMyAzMS41IDI3LjUgMzYgMjIgMzggQzE2LjUgMzYgMTEgMzEuNSAxMSAyNSBMMTEgMTcuNSBaIi8+CiAgICA8L2NsaXBQYXRoPgogIDwvZGVmcz4KICA8cGF0aCBkPSJNMjIgNCBMMzggMTUgTDM4IDI1IEMzOCAzNCAzMCA0MCAyMiA0MiBDMTQgNDAgNiAzNCA2IDI1IEw2IDE1IFoiIGZpbGw9IiMwYjNmNmYiLz4KICA8ZyBjbGlwLXBhdGg9InVybCgjc2MpIj4KICAgIDxyZWN0IHg9IjExIiB5PSI5IiB3aWR0aD0iMjIiIGhlaWdodD0iMjkiIGZpbGw9IiNmZmZmZmYiLz4KICAgIDxyZWN0IHg9IjIyIiB5PSI5IiB3aWR0aD0iMTEiIGhlaWdodD0iMTQuNSIgZmlsbD0iIzBiM2Y2ZiIvPgogICAgPHJlY3QgeD0iMTEiIHk9IjIzLjUiIHdpZHRoPSIxMSIgaGVpZ2h0PSIxNC41IiBmaWxsPSIjMGIzZjZmIi8+CiAgPC9nPgo8L3N2Zz4K');
}
-/* 密码图标:2×2 宫格 */
+/* 密码图标:2×2 宫格(品牌色 #0b3f6f) */
.icon-grid {
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NCA0NCI+CiAgPHJlY3QgeD0iNiIgeT0iNiIgd2lkdGg9IjEzLjUiIGhlaWdodD0iMTMuNSIgcng9IjIuNSIgZmlsbD0iIzBiM2Y2ZiIvPgogIDxyZWN0IHg9IjI0LjUiIHk9IjYiIHdpZHRoPSIxMy41IiBoZWlnaHQ9IjEzLjUiIHJ4PSIyLjUiIGZpbGw9IiMwYjNmNmYiLz4KICA8cmVjdCB4PSI2IiB5PSIyNC41IiB3aWR0aD0iMTMuNSIgaGVpZ2h0PSIxMy41IiByeD0iMi41IiBmaWxsPSIjMGIzZjZmIi8+CiAgPHJlY3QgeD0iMjQuNSIgeT0iMjQuNSIgd2lkdGg9IjEzLjUiIGhlaWdodD0iMTMuNSIgcng9IjIuNSIgZmlsbD0iIzBiM2Y2ZiIvPgo8L3N2Zz4K');
}
@@ -273,27 +483,30 @@
.field-input {
flex: 1;
height: 100%;
- font-size: 28rpx;
- color: #1f2329;
min-width: 0;
+ font-size: 28rpx;
+ color: #1e293b;
}
-/* 获取验证码按钮:绿色实心,倒计时期间置灰不可点 */
+/* 获取验证码按钮:品牌色描边 + 品牌色文字,倒计时期间置灰不可点 */
.code-btn {
width: 190rpx;
- height: 96rpx;
+ height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
- border-radius: 8rpx;
- background-color: #8bc34a;
- color: #ffffff;
- font-size: 28rpx;
flex-shrink: 0;
+ border: 2rpx solid #1e5ebc;
+ border-radius: 16rpx;
+ background-color: #ffffff;
+ color: #1e5ebc;
+ font-size: 28rpx;
+ font-weight: 600;
}
.code-btn--disabled {
- background-color: #c3dd9d;
+ border-color: #cbd5e1;
+ color: #94a3b8;
}
/* 密码可见性切换区域 */
@@ -314,34 +527,52 @@
background-size: 100% 100%;
}
-/* 明文:睁眼 */
+/* 明文:睁眼(品牌色 #003E7E) */
.icon-eye-open {
- background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NCA0NCI+CiAgPHBhdGggZD0iTTMgMjIgQzguNSAxNCAxNSAxMC41IDIyIDEwLjUgQzI5IDEwLjUgMzUuNSAxNCA0MSAyMiBDMzUuNSAzMCAyOSAzMy41IDIyIDMzLjUgQzE1IDMzLjUgOC41IDMwIDMgMjIgWiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOGJjMzRhIiBzdHJva2Utd2lkdGg9IjIuNiIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgogIDxjaXJjbGUgY3g9IjIyIiBjeT0iMjIiIHI9IjQuNiIgZmlsbD0iIzhiYzM0YSIvPgo8L3N2Zz4K');
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NCA0NCI+PHBhdGggZD0iTTMgMjIgQzguNSAxNCAxNSAxMC41IDIyIDEwLjUgQzI5IDEwLjUgMzUuNSAxNCA0MSAyMiBDMzUuNSAzMCAyOSAzMy41IDIyIDMzLjUgQzE1IDMzLjUgOC41IDMwIDMgMjIgWiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAzRTdFIiBzdHJva2Utd2lkdGg9IjIuNiIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxjaXJjbGUgY3g9IjIyIiBjeT0iMjIiIHI9IjQuNiIgZmlsbD0iIzAwM0U3RSIvPjwvc3ZnPg==');
}
-/* 密文:闭眼加斜杠 */
+/* 密文:闭眼加斜杠(品牌色 #003E7E) */
.icon-eye-closed {
- background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NCA0NCI+CiAgPHBhdGggZD0iTTMgMjIgQzguNSAxNCAxNSAxMC41IDIyIDEwLjUgQzI5IDEwLjUgMzUuNSAxNCA0MSAyMiBDMzUuNSAzMCAyOSAzMy41IDIyIDMzLjUgQzE1IDMzLjUgOC41IDMwIDMgMjIgWiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOGJjMzRhIiBzdHJva2Utd2lkdGg9IjIuNiIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgogIDxjaXJjbGUgY3g9IjIyIiBjeT0iMjIiIHI9IjQuNiIgZmlsbD0iIzhiYzM0YSIvPgogIDxsaW5lIHgxPSI3LjUiIHkxPSIzNiIgeDI9IjM2LjUiIHkyPSI4IiBzdHJva2U9IiM4YmMzNGEiIHN0cm9rZS13aWR0aD0iMi44IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KPC9zdmc+Cg==');
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NCA0NCI+PHBhdGggZD0iTTMgMjIgQzguNSAxNCAxNSAxMC41IDIyIDEwLjUgQzI5IDEwLjUgMzUuNSAxNCA0MSAyMiBDMzUuNSAzMCAyOSAzMy41IDIyIDMzLjUgQzE1IDMzLjUgOC41IDMwIDMgMjIgWiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAzRTdFIiBzdHJva2Utd2lkdGg9IjIuNiIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxjaXJjbGUgY3g9IjIyIiBjeT0iMjIiIHI9IjQuNiIgZmlsbD0iIzAwM0U3RSIvPjxsaW5lIHgxPSI3LjUiIHkxPSIzNiIgeDI9IjM2LjUiIHkyPSI4IiBzdHJva2U9IiMwMDNFN0UiIHN0cm9rZS13aWR0aD0iMi44IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L3N2Zz4=');
}
-/* 底部按钮区:白底 + 左右留边距,底部叠加安全区(与登录页保持一致) */
-.bottom-bar {
- padding: 20rpx 32rpx;
- padding-bottom: calc(20rpx + constant(safe-area-inset-bottom));
- padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
- background-color: #ffffff;
+/* ==================== 底部操作区 ==================== */
+
+/* 底部操作区:左右与内容区同边距,底部叠加安全区 */
+.bottom-area {
+ position: relative;
+ z-index: 1;
+ padding: 32rpx 56rpx;
+ padding-bottom: calc(32rpx + constant(safe-area-inset-bottom));
+ padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
}
-/* 圆角胶囊提交按钮:实心品牌色,与登录页按钮样式一致 */
+/* 提交按钮:品牌主行动渐变,PILL R48 */
.submit-btn {
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 48rpx;
- background-color: #0b3f6f;
+ background-image: linear-gradient(90deg, #1e5ebc 0%, #003e7e 100%);
color: #ffffff;
- font-size: 32rpx;
+ font-size: 30rpx;
font-weight: 600;
+ box-shadow: 0 12rpx 28rpx rgba(0, 62, 126, 0.2);
+}
+
+/* 提交中置灰,避免重复提交 */
+.submit-btn--disabled {
+ opacity: 0.6;
+}
+
+/* 底部 Home 条:135×5pt → 270×10rpx,R5rpx */
+.home-indicator {
+ width: 270rpx;
+ height: 10rpx;
+ margin: 24rpx auto 0;
+ border-radius: 5rpx;
+ background-color: rgba(15, 23, 42, 0.145);
}
</style>
diff --git a/hugray-partner-miniapp/pages/login/index.vue b/hugray-partner-miniapp/pages/login/index.vue
index 550cb34..249d537 100644
--- a/hugray-partner-miniapp/pages/login/index.vue
+++ b/hugray-partner-miniapp/pages/login/index.vue
@@ -1,20 +1,41 @@
<template>
<view class="login-page">
- <!-- 自定义导航栏:深蓝底承载品牌 logo(导航栏高度需叠加状态栏高度) -->
- <view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
- <view class="nav-inner">
- <image class="brand-logo" src="/static/logo.png" mode="heightFix" />
+ <!-- 背景装饰层:3 处微透十字 + 3 处白色微点阵(置于内容之下,不拦截点击) -->
+ <view class="deco-cross deco-cross--tr"></view>
+ <view class="deco-cross deco-cross--ml"></view>
+ <view class="deco-cross deco-cross--bl"></view>
+ <view class="deco-dots deco-dots--lt"></view>
+ <view class="deco-dots deco-dots--rt"></view>
+ <view class="deco-dots deco-dots--rb"></view>
+
+ <!-- 核心内容区:欢迎语 + 官方 Logo + 英译标语 + 医学双背书 + 产业4.0标语卡 -->
+ <view class="content" :style="{ paddingTop: statusBarHeight + 40 + 'px' }">
+ <text class="welcome-title">欢迎来到</text>
+ <image class="brand-logo" src="/static/metabolism-300-logo.png" mode="widthFix" />
+ <text class="en-slogan">RESET METABOLISM & DELIVER HEALTH</text>
+
+ <!-- 医学价值双背书行:严谨医学循证 / 传递健康价值 -->
+ <view class="endorse-row">
+ <view class="endorse-item">
+ <image class="endorse-icon" src="/static/medical-plus-icon.png" mode="aspectFit" />
+ <text class="endorse-text">严谨医学循证</text>
+ </view>
+ <view class="endorse-item">
+ <image
+ class="endorse-icon endorse-icon--check"
+ src="/static/check-icon.png"
+ mode="aspectFit"
+ />
+ <text class="endorse-text">传递健康价值</text>
+ </view>
</view>
+
+ <!-- 产业4.0核心标语卡:整图呈现,图片自带透明留白(左右对称、上下需负边距补偿) -->
+ <image class="slogan-card" src="/static/industry-4-0-slogan-card.png" mode="widthFix" />
</view>
- <!-- 登录主体区域 -->
- <view class="login-body">
- <!-- 欢迎标题 -->
- <view class="welcome-block">
- <text class="welcome-title">欢迎使用</text>
- <text class="platform-title">代谢300服务商平台</text>
- </view>
-
+ <!-- 底部操作区:账号 + 密码 + 忘记密码 + 登录按钮 + 协议行 -->
+ <view class="bottom-area">
<!-- 账号输入 -->
<view class="field-block">
<text class="field-label">账号</text>
@@ -25,7 +46,7 @@
class="field-input"
type="text"
placeholder="请输入账号"
- placeholder-style="color:#b8bec7;font-size:28rpx"
+ placeholder-style="color:#94A3B8;font-size:28rpx"
/>
</view>
</view>
@@ -40,31 +61,36 @@
class="field-input"
password
placeholder="请输入密码"
- placeholder-style="color:#b8bec7;font-size:28rpx"
+ placeholder-style="color:#94A3B8;font-size:28rpx"
/>
</view>
</view>
- <!-- 协议勾选 -->
+ <!-- 忘记密码:右对齐弱化入口 -->
+ <view class="forgot-row" @tap="_handleForgot">忘记密码</view>
+
+ <!-- 登录提交按钮:品牌主行动渐变胶囊 -->
+ <view class="submit-btn" @tap="_handleLogin">登录</view>
+
+ <!-- 协议勾选行:复选框 + 协议文案 -->
<view class="agreement-row">
<view
class="checkbox"
:class="{ 'checkbox--checked': agreed }"
@tap="agreed = !agreed"
></view>
- <text class="agreement-text">我已阅读并同意</text>
- <text class="agreement-link" @tap.stop="_openAgreement('用户协议')">《用户协议》</text>
- <text class="agreement-text">、</text>
- <text class="agreement-link" @tap.stop="_openAgreement('隐私与权限')">《隐私与权限》</text>
+ <view class="agreement-words">
+ <text class="agreement-text">我已阅读并同意</text>
+ <text class="agreement-link" @tap.stop="_openAgreement('用户协议')">《用户协议》</text>
+ <text class="agreement-text">与</text>
+ <text class="agreement-link" @tap.stop="_openAgreement('隐私与权限协议')">
+ 《隐私与权限》
+ </text>
+ </view>
</view>
- <!-- 忘记密码 -->
- <view class="forgot-row" @tap="_handleForgot">忘记密码</view>
- </view>
-
- <!-- 底部通栏登录按钮 -->
- <view class="bottom-bar">
- <view class="login-btn" @tap="_handleLogin">登录</view>
+ <!-- 底部 Home 条 -->
+ <view class="home-indicator"></view>
</view>
<!-- 未勾选协议时的确认弹窗 -->
@@ -92,8 +118,8 @@
// 登录表单数据
const form = reactive({
- username: '',
- password: ''
+ username: 'provider126',
+ password: '123456'
})
// 是否已勾选同意用户协议
@@ -105,7 +131,7 @@
// 未勾选协议时确认弹窗的显示状态
const showAgreementModal = ref(false)
-// 状态栏高度(px),用于自定义导航栏顶部留白
+// 状态栏高度(px),用于顶部内容区留白
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
// ======================== 方法定义区域 ========================
@@ -147,14 +173,13 @@
logging.value = true
try {
- // 后端验证码功能未开启时,code / uuid 传空串占位
+ // 服务商专用登录接口:仅需账号 + 密码
const { data } = await login({
username: form.username.trim(),
- password: form.password,
- code: '',
- uuid: ''
+ password: form.password
})
- const token = (data && (data.token || data.accessToken)) || ''
+ // 后端 TokenService 返回字段为 access_token
+ const token = (data && (data.access_token || data.token)) || ''
if (!token) throw new Error('登录失败:未获取到登录凭证')
setToken(token)
@@ -178,84 +203,253 @@
</script>
<style scoped lang="scss">
-/* 页面最外层:纵向弹性布局,撑满整屏 */
+/* ==================== 页面底板 ==================== */
+
+/* 页面最外层:浅蓝品牌渐变底(165°,0.8 透明),纵向弹性布局撑满整屏 */
.login-page {
+ position: relative;
display: flex;
flex-direction: column;
- height: 100vh;
- background-color: #ffffff;
-}
-
-/* 自定义导航栏:深蓝底 + 品牌 logo(底色与 logo 背景 #0B3F6F 保持一致,使 logo 无缝融入) */
-.nav-bar {
- background-color: #0b3f6f;
-}
-
-/* 导航内容行:固定高度,与小程序右上角胶囊高度对齐 */
-.nav-inner {
- height: 88rpx;
- display: flex;
- align-items: center;
- padding-left: 28rpx;
+ min-height: 100vh;
box-sizing: border-box;
+ overflow: hidden;
+ background-color: #ffffff;
+ background-image: linear-gradient(
+ 165deg,
+ rgba(211, 228, 248, 0.8) 0%,
+ rgba(188, 214, 244, 0.8) 50%,
+ rgba(171, 204, 236, 0.8) 100%
+ );
}
-.brand-logo {
- height: 56rpx;
- display: block;
+/* ==================== 背景装饰层 ==================== */
+
+/* 微透十字:容器尺寸按设计稿,竖条(::before)/横条(::after)等宽居中交叉 */
+.deco-cross {
+ position: absolute;
+ z-index: 0;
+ pointer-events: none;
+
+ &::before,
+ &::after {
+ content: '';
+ position: absolute;
+ background-color: var(--cross-color);
+ border-radius: calc(var(--bar) / 2);
+ }
+
+ /* 竖条:水平居中 */
+ &::before {
+ left: var(--offset);
+ top: 0;
+ width: var(--bar);
+ height: 100%;
+ }
+
+ /* 横条:垂直居中 */
+ &::after {
+ left: 0;
+ top: var(--offset);
+ width: 100%;
+ height: var(--bar);
+ }
}
-.login-body {
- flex: 1;
- padding: 0 56rpx;
+/* 右上十字:80×80pt,条宽 22 / R11,#FFFFFF35 */
+.deco-cross--tr {
+ top: 20rpx;
+ right: 10rpx;
+ width: 160rpx;
+ height: 160rpx;
+ --bar: 44rpx;
+ --offset: 58rpx;
+ --cross-color: rgba(255, 255, 255, 0.208);
}
-/* 欢迎标题区 */
-.welcome-block {
+/* 中左十字:68×68pt,条宽 18 / R9,#FFFFFF30 */
+.deco-cross--ml {
+ top: 57.9%;
+ left: -48rpx;
+ width: 136rpx;
+ height: 136rpx;
+ --bar: 36rpx;
+ --offset: 50rpx;
+ --cross-color: rgba(255, 255, 255, 0.188);
+}
+
+/* 左下十字:100×100pt,条宽 28 / R14,#FFFFFF35 */
+.deco-cross--bl {
+ bottom: 4rpx;
+ left: -20rpx;
+ width: 200rpx;
+ height: 200rpx;
+ --bar: 56rpx;
+ --offset: 72rpx;
+ --cross-color: rgba(255, 255, 255, 0.208);
+}
+
+/*
+ * 白色微点阵:微点 3×3pt(6rpx),行内/行间 gap 10pt(20rpx) → 平铺步距 26rpx
+ * 说明:设计稿三处点阵均为不规则装饰形状,此处以平铺网格近似
+ */
+.deco-dots {
+ position: absolute;
+ z-index: 0;
+ pointer-events: none;
+ background-image: radial-gradient(circle, rgba(255, 255, 255, 0.333) 3rpx, transparent 3rpx);
+ background-size: 26rpx 26rpx;
+}
+
+/* 左上点阵(5 列 × 4 行) */
+.deco-dots--lt {
+ top: 24.6%;
+ left: 20rpx;
+ width: 110rpx;
+ height: 84rpx;
+}
+
+/* 右上点阵(3 列 × 6 行) */
+.deco-dots--rt {
+ top: 16%;
+ right: 72rpx;
+ width: 58rpx;
+ height: 136rpx;
+}
+
+/* 右下点阵(3 列 × 3 行) */
+.deco-dots--rb {
+ bottom: 62rpx;
+ right: 212rpx;
+ width: 58rpx;
+ height: 58rpx;
+}
+
+/* ==================== 核心内容区 ==================== */
+
+/* 内容区:元素间距 40rpx,整体水平居中,左右 56rpx */
+.content {
+ position: relative;
+ z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
- padding: 60rpx 0 40rpx;
+ gap: 40rpx;
+ padding-left: 56rpx;
+ padding-right: 56rpx;
}
+/* 欢迎语:26pt 500 深板岩色,居中 */
.welcome-title {
- font-size: 36rpx;
- font-weight: 700;
- color: #1f2329;
- line-height: 52rpx;
+ flex-shrink: 0;
+ font-size: 52rpx;
+ font-weight: 500;
+ line-height: 74rpx;
+ text-align: center;
+ color: #0f172a;
}
-.platform-title {
- font-size: 40rpx;
- font-weight: 700;
- color: #1f2329;
- line-height: 58rpx;
+/* 官方 Logo:220×76pt */
+.brand-logo {
+ flex-shrink: 0;
+ width: 440rpx;
+ height: 152rpx;
+}
+
+/* 英译标语:11pt 600 深海蓝,字间距 1.5pt,居中 */
+.en-slogan {
+ flex-shrink: 0;
+ font-size: 22rpx;
+ font-weight: 600;
+ letter-spacing: 3rpx;
+ text-align: center;
+ color: #003e7e;
+}
+
+/* 医学双背书行:占满整行,两项 gap 48rpx,居中 */
+.endorse-row {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 48rpx;
+ width: 100%;
+}
+
+/* 单项:图标 + 文案,gap 12rpx */
+.endorse-item {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+}
+
+/* 加号图标 15×15pt → 30rpx */
+.endorse-icon {
+ width: 30rpx;
+ height: 30rpx;
+ flex-shrink: 0;
+}
+
+/* 勾选图标 18.2×15pt → 36.4×30rpx */
+.endorse-icon--check {
+ width: 36.4rpx;
+ height: 30rpx;
+}
+
+/* 背书文案:12pt 600 正文强色 */
+.endorse-text {
+ font-size: 24rpx;
+ font-weight: 600;
+ color: #1e293b;
+}
+
+/* 产业4.0核心标语卡:整图呈现,上下负边距抵消图片透明留白 */
+.slogan-card {
+ flex-shrink: 0;
+ width: 759rpx;
+ height: 304rpx;
+ margin-top: -48rpx;
+ margin-bottom: -72rpx;
+}
+
+/* ==================== 底部操作区 ==================== */
+
+/* 底部操作区:margin-top 自动撑开贴近底部,左右与内容区同边距 */
+.bottom-area {
+ position: relative;
+ z-index: 1;
+ margin-top: auto;
+ padding: 0 56rpx;
+ padding-top: 40rpx;
+ padding-bottom: calc(32rpx + constant(safe-area-inset-bottom));
+ padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
}
/* 单个输入项 */
.field-block {
- margin-bottom: 36rpx;
+ margin-bottom: 28rpx;
}
+/* 表单标签:13pt 500 */
.field-label {
display: block;
- font-size: 28rpx;
- font-weight: 600;
- color: #1f2329;
margin-bottom: 18rpx;
+ font-size: 26rpx;
+ font-weight: 500;
+ color: #334155;
}
-/* 带图标的输入框 */
+/* 带图标的输入框:R16 / H88,浅底 + 描边 */
.input-wrap {
display: flex;
align-items: center;
- height: 96rpx;
- padding: 0 24rpx;
- border: 1rpx solid #dcdfe6;
- border-radius: 8rpx;
+ height: 88rpx;
+ padding: 0 28rpx;
+ border: 2rpx solid #cbd5e1;
+ border-radius: 16rpx;
+ background-color: #f8fafc;
box-sizing: border-box;
}
+/* 品牌图标:内嵌 44rpx */
.field-icon {
position: relative;
width: 44rpx;
@@ -264,7 +458,7 @@
flex-shrink: 0;
}
-/* 图标通用样式:统一用 SVG 背景图绘制,确保圆角/尖角按设计稿精确还原 */
+/* 图标通用:统一用 SVG 背景图绘制,确保形状按设计稿精确还原 */
.icon-user,
.icon-shield {
background-repeat: no-repeat;
@@ -285,40 +479,75 @@
.field-input {
flex: 1;
height: 100%;
+ min-width: 0;
font-size: 28rpx;
- color: #1f2329;
+ color: #1e293b;
}
-/* 协议勾选行 */
+/* 忘记密码:右对齐弱化入口 */
+.forgot-row {
+ margin-top: 4rpx;
+ text-align: right;
+ font-size: 26rpx;
+ font-weight: 600;
+ color: #1e5ebc;
+}
+
+/* 登录提交按钮:品牌主行动渐变,PILL R24 */
+.submit-btn {
+ height: 96rpx;
+ margin-top: 40rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 48rpx;
+ background-image: linear-gradient(90deg, #1e5ebc 0%, #003e7e 100%);
+ color: #ffffff;
+ font-size: 30rpx;
+ font-weight: 600;
+ box-shadow: 0 12rpx 28rpx rgba(0, 62, 126, 0.2);
+}
+
+/* 协议勾选行:复选框与文案 gap 16rpx */
.agreement-row {
display: flex;
align-items: center;
- flex-wrap: wrap;
- margin-top: 8rpx;
+ margin-top: 32rpx;
}
+/* 协议文案组合:内部字间距 2rpx */
+.agreement-words {
+ display: flex;
+ align-items: center;
+ flex: 1;
+ min-width: 0;
+ gap: 2rpx;
+ margin-left: 16rpx;
+}
+
+/* 勾选框:16×16pt → 32rpx,R4 → 8rpx */
.checkbox {
position: relative;
width: 32rpx;
height: 32rpx;
- border: 2rpx solid #c0c4cc;
- border-radius: 4rpx;
- margin-right: 14rpx;
flex-shrink: 0;
+ border: 2rpx solid #cbd5e1;
+ border-radius: 8rpx;
+ background-color: #ffffff;
box-sizing: border-box;
}
.checkbox--checked {
- background-color: #0b3b68;
- border-color: #0b3b68;
+ background-color: #003e7e;
+ border-color: #003e7e;
}
/* 勾选后的白色对勾 */
.checkbox--checked::after {
content: '';
position: absolute;
- left: 9rpx;
- top: 3rpx;
+ left: 10rpx;
+ top: 2rpx;
width: 8rpx;
height: 16rpx;
border-right: 3rpx solid #ffffff;
@@ -326,90 +555,68 @@
transform: rotate(45deg);
}
+/* 协议文案 */
.agreement-text {
- font-size: 24rpx;
- color: #1f2329;
+ font-size: 26rpx;
+ color: #64748b;
}
.agreement-link {
- font-size: 24rpx;
- color: #1a73e8;
-}
-
-/* 忘记密码 */
-.forgot-row {
- text-align: center;
- margin-top: 44rpx;
- font-size: 28rpx;
- color: #1f2329;
-}
-
-/* 底部按钮区:白底 + 左右留边距,底部叠加安全区 */
-.bottom-bar {
- padding: 20rpx 32rpx;
- padding-bottom: calc(20rpx + constant(safe-area-inset-bottom));
- padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
- background-color: #ffffff;
-}
-
-/* 圆角胶囊登录按钮:实心品牌色,独立成块 */
-.login-btn {
- height: 96rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 48rpx;
- background-color: #0b3f6f;
- color: #ffffff;
- font-size: 32rpx;
+ font-size: 26rpx;
font-weight: 600;
+ color: #1e5ebc;
+}
+
+/* 底部 Home 条:135×5pt → 270×10rpx,R5rpx */
+.home-indicator {
+ width: 270rpx;
+ height: 10rpx;
+ margin: 24rpx auto 0;
+ border-radius: 5rpx;
+ background-color: rgba(15, 23, 42, 0.145);
}
/* ======================== 未勾选协议确认弹窗 ======================== */
-/* 遮罩层:全屏半透明,居中承载弹窗 */
.modal-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
- background-color: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
+ background-color: rgba(15, 23, 42, 0.45);
}
.modal-box {
width: 560rpx;
- background-color: #ffffff;
- border-radius: 16rpx;
overflow: hidden;
+ border-radius: 24rpx;
+ background-color: #ffffff;
}
-/* 标题:绿色居中,下方带分隔线 */
.modal-title {
display: block;
padding: 40rpx 32rpx 30rpx;
font-size: 34rpx;
font-weight: 600;
- color: #8bc34a;
text-align: center;
- border-bottom: 1rpx solid #ececec;
+ color: #003e7e;
+ border-bottom: 2rpx solid #e2e8f0;
}
-/* 正文:灰色居中的提示文案 */
.modal-content {
display: block;
padding: 44rpx 48rpx;
font-size: 28rpx;
line-height: 42rpx;
- color: #9aa1ab;
text-align: center;
+ color: #64748b;
}
-/* 按钮行:取消 / 我已阅读并同意 各占一半 */
.modal-actions {
display: flex;
align-items: center;
@@ -424,10 +631,10 @@
}
.modal-btn--cancel {
- color: #1f2329;
+ color: #334155;
}
.modal-btn--confirm {
- color: #8bc34a;
+ color: #1e5ebc;
}
</style>
diff --git a/hugray-partner-miniapp/pages/sub-account/add.vue b/hugray-partner-miniapp/pages/sub-account/add.vue
index ee2c190..264060b 100644
--- a/hugray-partner-miniapp/pages/sub-account/add.vue
+++ b/hugray-partner-miniapp/pages/sub-account/add.vue
@@ -1,46 +1,60 @@
<template>
<view class="add-account-page">
- <view class="form-area">
- <!-- 等级选择 -->
- <view class="form-item">
- <text class="form-label">等级</text>
- <picker
- mode="selector"
- :range="levelLabels"
- :value="levelIndex"
- @change="_handleLevelChange"
- >
- <view class="select-box">
- <text
- class="select-text"
- :class="{ 'select-text--placeholder': !form.level }"
- >
- {{ form.level || '请选择' }}
- </text>
- <!-- 纯 CSS 下拉箭头 -->
- <view class="chevron-icon"></view>
+ <scroll-view class="form-scroll" scroll-y>
+ <view class="form-area">
+ <!-- 额度卡片:按等级展示剩余可开通数量 -->
+ <view class="quota-card">
+ <view class="section-heading">
+ <text class="section-title">可用账号额度</text>
+ <text class="section-caption">剩余可开通</text>
</view>
- </picker>
- <text class="quota-tip">{{ quotaTip }}</text>
- </view>
+ <view class="quota-grid">
+ <view v-for="level in levelValues" :key="level" class="quota-cell" :class="'quota-cell--' + level">
+ <text class="quota-level">{{ level }}</text>
+ <view class="quota-value">
+ <text class="quota-number">{{ quota[level] || 0 }}</text>
+ <text class="quota-unit">个</text>
+ </view>
+ </view>
+ </view>
+ </view>
- <!-- 登录手机号输入 -->
- <view class="form-item">
- <text class="form-label">登录手机号</text>
- <input
- v-model="form.phone"
- class="input-box"
- type="number"
- maxlength="11"
- placeholder="请输入账户"
- placeholder-style="color:#b8bec7;font-size:28rpx"
- />
+ <view class="form-card">
+ <view class="section-heading form-heading">
+ <text class="section-title">账号信息</text>
+ <text class="section-caption">填写开通信息</text>
+ </view>
+ <view class="form-item">
+ <text class="form-label">账号等级 <text class="required-mark">*</text></text>
+ <picker mode="selector" :range="levelLabels" :value="levelIndex" @change="_handleLevelChange">
+ <view class="select-box">
+ <text class="select-text" :class="{ 'select-text--placeholder': !form.level }">
+ {{ form.level ? form.level + ' 子账号' : '请选择账号等级' }}
+ </text>
+ <view class="chevron-icon"></view>
+ </view>
+ </picker>
+ <text class="field-tip">{{ form.level ? '该等级剩余可开通 ' + (quota[form.level] || 0) + ' 个账号' : '请根据可用额度选择账号等级' }}</text>
+ </view>
+ <view class="form-item form-item--last">
+ <text class="form-label">登录手机号 <text class="required-mark">*</text></text>
+ <input
+ v-model="form.phone"
+ class="input-box"
+ type="number"
+ maxlength="11"
+ placeholder="请输入 11 位手机号"
+ placeholder-style="color:#94a3b8;font-size:28rpx"
+ />
+ <text class="field-tip">手机号将作为子账号的登录账号</text>
+ </view>
+ </view>
</view>
- </view>
+ </scroll-view>
- <!-- 底部提交按钮 -->
+ <!-- 底部操作区保留安全区间距 -->
<view class="bottom-bar">
- <view class="submit-btn" @tap="_handleSubmit">新增</view>
+ <view class="submit-btn" hover-class="submit-btn--pressed" @tap="_handleSubmit">新增账号</view>
</view>
</view>
</template>
@@ -50,70 +64,49 @@
import { onLoad } from '@dcloudio/uni-app'
import { getSubAccountQuota, createSubAccount } from '@/api/index.js'
-// ======================== 数据定义区域 ========================
-
-// 表单数据
+// 表单与额度数据
const form = reactive({
level: '',
phone: ''
})
-
-// 各等级剩余可开通数量
const quota = ref({ L1: 0, L2: 0, L3: 0 })
-
-// 可选等级值列表(与 levelLabels 下标一一对应)
const levelValues = ['L1', 'L2', 'L3']
-
-// 当前选中的等级下标
const levelIndex = ref(0)
-
-// 等级选择器的展示文案(带剩余数量提示)
const levelLabels = computed(() =>
levelValues.map((level) => level + '(剩余' + (quota.value[level] || 0) + '个)')
)
-// 剩余额度提示文案
-const quotaTip = computed(
- () => `当前剩余可用:${quota.value.L1}个L1 ${quota.value.L2}个L2 ${quota.value.L3}个L3`
-)
-
-// ======================== 方法定义区域 ========================
-
-/**
- * 加载各等级剩余可开通额度
- * 后端接口就绪前以本地模拟数据兜底,保证页面可视
- */
+/** 加载各等级剩余额度,失败时提示并保留默认值。 */
const _loadQuota = async () => {
try {
const { data } = await getSubAccountQuota()
quota.value = data || quota.value
} catch (e) {
- quota.value = { L1: 6, L2: 8, L3: 3 }
+ uni.showToast({ title: e.message || '额度加载失败', icon: 'none' })
}
}
-/** 切换等级 */
+/** 切换等级。 */
const _handleLevelChange = (e) => {
const index = Number(e.detail.value)
levelIndex.value = index
form.level = levelValues[index]
}
-/** 校验表单,返回校验失败原因(通过则返回空串) */
+/** 校验表单,返回校验失败原因(通过则返回空串)。 */
const _validateForm = () => {
if (!form.level) return '请选择等级'
if (!/^1[3-9]\d{9}$/.test(form.phone)) return '请输入正确的手机号'
return ''
}
-/** 提交新增子账号 */
+/** 提交新增子账号。 */
const _handleSubmit = async () => {
const errorMsg = _validateForm()
if (errorMsg) {
uni.showToast({ title: errorMsg, icon: 'none' })
return
}
-
try {
await createSubAccount({ level: form.level, phone: form.phone })
uni.showToast({ title: '新增成功', icon: 'success' })
@@ -129,100 +122,189 @@
</script>
<style scoped lang="scss">
-/* 页面最外层:纵向弹性布局,撑满整屏 */
+/* 中性底色与独立滚动区,底部操作始终可见。 */
.add-account-page {
display: flex;
flex-direction: column;
height: 100vh;
- background-color: #ffffff;
+ background: #f8fafc;
+ color: #0f172a;
+}
+
+.form-scroll {
+ flex: 1;
+ min-height: 0;
+ height: 0;
}
.form-area {
- flex: 1;
- padding: 30rpx 28rpx;
+ padding: 28rpx 32rpx 40rpx;
}
-/* 表单项容器 */
+.quota-card,
+.form-card {
+ padding: 32rpx;
+ border: 1rpx solid #e9eff6;
+ border-radius: 28rpx;
+ background: #ffffff;
+ box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.04);
+}
+
+.form-card {
+ margin-top: 24rpx;
+}
+
+.section-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16rpx;
+ margin-bottom: 28rpx;
+}
+
+.section-title {
+ font-size: 30rpx;
+ font-weight: 600;
+}
+
+.section-caption {
+ color: #94a3b8;
+ font-size: 23rpx;
+}
+
+.quota-grid {
+ display: flex;
+ gap: 16rpx;
+}
+
+.quota-cell {
+ flex: 1;
+ min-width: 0;
+ padding: 24rpx 12rpx;
+ border-radius: 20rpx;
+ text-align: center;
+ background: #eaf3ff;
+ color: #1e5ebc;
+}
+
+.quota-cell--L2 {
+ background: #edf7e7;
+ color: #4d8227;
+}
+
+.quota-cell--L3 {
+ background: #fff4e0;
+ color: #a57622;
+}
+
+.quota-level {
+ font-size: 24rpx;
+ font-weight: 600;
+}
+
+.quota-value {
+ margin-top: 12rpx;
+}
+
+.quota-number {
+ font-size: 42rpx;
+ font-weight: 700;
+ line-height: 1.2;
+}
+
+.quota-unit {
+ margin-left: 6rpx;
+ font-size: 22rpx;
+}
+
+.form-heading {
+ padding-bottom: 24rpx;
+ border-bottom: 1rpx solid #f1f5f9;
+}
+
.form-item {
- margin-bottom: 40rpx;
+ margin-bottom: 32rpx;
+}
+
+.form-item--last {
+ margin-bottom: 0;
}
.form-label {
display: block;
- font-size: 28rpx;
- color: #1f2329;
- margin-bottom: 20rpx;
+ margin-bottom: 18rpx;
+ font-size: 27rpx;
+ font-weight: 500;
}
-/* 下拉选择框 */
+.required-mark {
+ margin-left: 6rpx;
+ color: #2d91ff;
+}
+
+.select-box,
+.input-box {
+ height: 92rpx;
+ padding: 0 24rpx;
+ border: 1rpx solid #e9eff6;
+ border-radius: 20rpx;
+ background: #f8fafc;
+ box-sizing: border-box;
+ color: #0f172a;
+ font-size: 28rpx;
+}
+
.select-box {
display: flex;
align-items: center;
justify-content: space-between;
- height: 88rpx;
- padding: 0 24rpx;
- border: 1rpx solid #dcdfe6;
- border-radius: 8rpx;
- box-sizing: border-box;
-}
-
-.select-text {
- font-size: 28rpx;
- color: #1f2329;
}
.select-text--placeholder {
- color: #b8bec7;
+ color: #94a3b8;
}
-/* 纯 CSS 下拉箭头(向下) */
.chevron-icon {
- width: 16rpx;
- height: 16rpx;
- border-right: 3rpx solid #9aa1ab;
- border-bottom: 3rpx solid #9aa1ab;
- transform: rotate(45deg);
- margin-right: 6rpx;
flex-shrink: 0;
+ width: 14rpx;
+ height: 14rpx;
+ margin: -6rpx 6rpx 0 16rpx;
+ border-right: 3rpx solid #94a3b8;
+ border-bottom: 3rpx solid #94a3b8;
+ transform: rotate(45deg);
}
-/* 剩余额度提示 */
-.quota-tip {
+.field-tip {
display: block;
- margin-top: 16rpx;
- font-size: 24rpx;
- color: #1a73e8;
+ margin-top: 14rpx;
+ color: #64748b;
+ font-size: 23rpx;
+ line-height: 1.6;
}
-/* 手机号输入框 */
-.input-box {
- height: 88rpx;
- padding: 0 24rpx;
- border: 1rpx solid #dcdfe6;
- border-radius: 8rpx;
- font-size: 28rpx;
- color: #1f2329;
- box-sizing: border-box;
-}
-
-/* 底部按钮区 */
.bottom-bar {
- padding: 20rpx 28rpx;
+ flex-shrink: 0;
+ padding: 20rpx 32rpx;
padding-bottom: calc(20rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
- background-color: #ffffff;
- border-top: 1rpx solid #f0f2f5;
+ border-top: 1rpx solid #f1f5f9;
+ background: #ffffff;
}
.submit-btn {
- height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
- background-color: #0b3b68;
+ height: 96rpx;
+ border-radius: 999rpx;
+ background: linear-gradient(105deg, #2d91ff, #003e7e);
color: #ffffff;
font-size: 30rpx;
font-weight: 600;
- border-radius: 8rpx;
+ box-shadow: 0 8rpx 20rpx rgba(30, 94, 188, 0.16);
+}
+
+.submit-btn--pressed {
+ opacity: 0.85;
}
</style>
diff --git a/hugray-partner-miniapp/pages/sub-account/detail.vue b/hugray-partner-miniapp/pages/sub-account/detail.vue
index c37d8d9..b7e1462 100644
--- a/hugray-partner-miniapp/pages/sub-account/detail.vue
+++ b/hugray-partner-miniapp/pages/sub-account/detail.vue
@@ -1,18 +1,18 @@
<template>
<view class="detail-page">
- <!-- 顶部子账号信息卡 -->
+ <!-- 顶部账号资料卡 -->
<view class="profile-card">
- <view class="profile-left">
- <text class="profile-name">{{ detail.name }}</text>
- <text class="profile-phone">{{ detail.phone }}</text>
- <view class="profile-level">
- <text class="level-label">当前等级</text>
- <text class="level-value">{{ detail.level }}</text>
+ <view class="profile-avatar">{{ detail.name.slice(0, 1) }}</view>
+ <view class="profile-info">
+ <view class="profile-name-row">
+ <text class="profile-name">{{ detail.name }}</text>
+ <text class="level-pill" :class="`level-pill--${detail.level}`">{{ detail.level }}</text>
</view>
+ <text class="profile-phone">{{ detail.phone }}</text>
</view>
</view>
- <!-- Tab 切换栏 -->
+ <!-- 品牌蓝胶囊 Tab -->
<view class="tab-bar">
<view
v-for="tab in tabs"
@@ -22,62 +22,48 @@
@tap="_handleTabChange(tab.key)"
>
<text class="tab-text">{{ tab.label }}</text>
- <view v-if="activeTab === tab.key" class="tab-underline"></view>
</view>
</view>
- <!-- 销售统计内容区 -->
<view v-if="activeTab === 'sales'" class="sales-content">
- <!-- 当月利润高亮卡 -->
+ <!-- 利润高亮卡 -->
<view class="card profit-card">
<view class="profit-main">
- <text class="profit-amount">¥{{ detail.monthProfit }}</text>
<text class="profit-label">当月利润</text>
+ <text class="profit-amount">¥{{ detail.monthProfit }}</text>
</view>
<text class="profit-trend">{{ detail.profitTrend }}</text>
</view>
- <!-- 汇总指标:2 列网格,数据驱动渲染 -->
+ <!-- 汇总指标:标签在上,数值在下 -->
<view class="card summary-grid">
- <view
- v-for="(item, index) in detail.summary"
- :key="index"
- class="summary-cell"
- >
+ <view v-for="(item, index) in detail.summary" :key="index" class="summary-cell">
<text class="summary-label">{{ item.label }}</text>
<text class="summary-value">{{ item.value }}</text>
</view>
</view>
- <!-- 销售额折线图(秋云 uCharts) -->
<view class="card chart-card">
- <text class="chart-title">销售额</text>
+ <view class="chart-title-row">
+ <view class="chart-mark"></view>
+ <text class="chart-title">销售额</text>
+ </view>
<view class="chart-box chart-box--line">
- <qiun-data-charts
- type="line"
- :chartData="lineChartData"
- :opts="lineChartOpts"
- />
+ <qiun-data-charts type="line" :chartData="lineChartData" :opts="lineChartOpts" />
</view>
</view>
- <!-- 商品销售额比例:饼图 + 图例(秋云 uCharts) -->
<view class="card chart-card">
- <text class="chart-title">商品销售额比例</text>
+ <view class="chart-title-row">
+ <view class="chart-mark"></view>
+ <text class="chart-title">商品销售额比例</text>
+ </view>
<view class="pie-row">
<view class="chart-box chart-box--pie">
- <qiun-data-charts
- type="pie"
- :chartData="pieChartData"
- :opts="pieChartOpts"
- />
+ <qiun-data-charts type="pie" :chartData="pieChartData" :opts="pieChartOpts" />
</view>
<view class="pie-legend">
- <view
- v-for="(item, index) in detail.salesRatio"
- :key="index"
- class="legend-item"
- >
+ <view v-for="(item, index) in detail.salesRatio" :key="index" class="legend-item">
<view class="legend-color" :style="{ backgroundColor: item.color }"></view>
<text class="legend-name">{{ item.name }}</text>
<text class="legend-amount">{{ item.amount }}</text>
@@ -86,71 +72,58 @@
</view>
</view>
- <!-- 销售用户排行 -->
<view class="card chart-card">
- <text class="chart-title">销售用户排行</text>
+ <view class="chart-title-row">
+ <view class="chart-mark"></view>
+ <text class="chart-title">销售用户排行</text>
+ </view>
<view class="ranking-list">
- <view
- v-for="(item, index) in detail.salesRanking"
- :key="index"
- class="ranking-item"
- >
- <view class="rank-badge">{{ item.rank }}</view>
- <text class="rank-name">{{ item.name }}</text>
- <text class="rank-phone">{{ item.phone }}</text>
+ <view v-for="(item, index) in detail.salesRanking" :key="index" class="ranking-item">
+ <view class="rank-badge" :class="{ 'rank-badge--top': item.rank <= 3 }">{{ item.rank }}</view>
+ <view class="rank-person">
+ <text class="rank-name">{{ item.name }}</text>
+ <text class="rank-phone">{{ item.phone }}</text>
+ </view>
<text class="rank-amount">{{ item.amount }}</text>
</view>
</view>
</view>
</view>
- <!-- 他的仓库:库存列表,低于最低需求库存的商品前置告警条 -->
+ <!-- 仓库商品独立白卡,告警条与商品信息同卡展示 -->
<view v-if="activeTab === 'stock'" class="list-content">
- <template v-for="(item, index) in detail.warehouseList" :key="index">
+ <view v-for="(item, index) in detail.warehouseList" :key="index" class="warehouse-card">
<view v-if="item.currentStock < item.minStock" class="stock-warning">
<view class="warning-icon">!</view>
<text class="warning-text">已低于最低需求库存,无法销售该商品</text>
</view>
- <view class="warehouse-item">
- <view class="goods-thumb">
- <view class="thumb-icon"></view>
- </view>
+ <view class="warehouse-body">
+ <view class="goods-category">商品</view>
<text class="goods-name">{{ item.name }}</text>
<view class="goods-stock">
<view class="stock-row">
- <text class="stock-label">当前库存:</text>
- <text
- class="stock-value"
- :class="{ 'stock-value--danger': item.currentStock < item.minStock }"
- >{{ item.currentStock }}</text>
+ <text class="stock-label">当前库存</text>
+ <text class="stock-value" :class="{ 'stock-value--danger': item.currentStock < item.minStock }">{{ item.currentStock }}</text>
</view>
<view class="stock-row">
- <text class="stock-label">最低需求库存:</text>
+ <text class="stock-label">最低需求库存</text>
<text class="stock-value">{{ item.minStock }}</text>
</view>
</view>
</view>
- </template>
+ </view>
</view>
- <!-- 他的邀请:邀请人数汇总 + 邀请用户列表 -->
+ <!-- 邀请汇总与邀请记录 -->
<view v-if="activeTab === 'invite'" class="list-content">
- <view class="invite-summary">
- <view
- v-for="(item, index) in detail.inviteSummary"
- :key="index"
- class="invite-summary-item"
- >
+ <view class="card invite-summary">
+ <view v-for="(item, index) in detail.inviteSummary" :key="index" class="invite-summary-item">
<text class="invite-summary-label">{{ item.label }}</text>
<text class="invite-summary-value">{{ item.value }}人</text>
</view>
</view>
- <view
- v-for="(item, index) in detail.inviteList"
- :key="index"
- class="invite-item"
- >
- <view class="invite-avatar"></view>
+ <view v-for="(item, index) in detail.inviteList" :key="index" class="invite-item">
+ <view class="invite-avatar">{{ item.name.slice(0, 1) }}</view>
<view class="invite-main">
<view class="invite-name-row">
<text class="invite-name">{{ item.name }}</text>
@@ -159,10 +132,8 @@
<text class="invite-phone">{{ item.phone }}</text>
</view>
<view class="invite-right">
- <view class="invite-consume">
- <text class="invite-consume-label">合计消费:</text>
- <text class="invite-consume-value">{{ item.totalConsume }}</text>
- </view>
+ <text class="invite-consume-label">合计消费</text>
+ <text class="invite-consume-value">{{ item.totalConsume }}</text>
<text class="invite-bind">绑定日:{{ item.bindDate }}</text>
</view>
</view>
@@ -176,18 +147,14 @@
import { getSubAccountDetail } from '@/api/index.js'
// ======================== 数据定义区域 ========================
-
-// Tab 列表
const tabs = [
{ key: 'sales', label: '销售统计' },
{ key: 'stock', label: '他的仓库' },
{ key: 'invite', label: '他的邀请' }
]
-// 当前选中的 Tab
const activeTab = ref('sales')
-// 子账号详情数据
const detail = ref({
name: '张三',
phone: '13612346666',
@@ -219,19 +186,16 @@
{ rank: 4, name: '李七', phone: '138****4564', amount: '12450.00' },
{ rank: 5, name: '周八', phone: '138****4565', amount: '11260.00' }
],
- // 他的仓库:currentStock < minStock 时页面自动显示告警条并将库存标红
warehouseList: [
{ name: 'G&METS-II 超分子基团复合肽', currentStock: 100, minStock: 100 },
{ name: 'G&METS-II 超分子基团复合肽', currentStock: 98, minStock: 100 }
],
- // 他的邀请:按等级统计的邀请人数
inviteSummary: [
{ label: '共', value: '36' },
{ label: 'L0:', value: '25' },
{ label: 'L1:', value: '5' },
{ label: 'L2:', value: '5' }
],
- // 他的邀请:邀请用户列表
inviteList: [
{ name: '张三', level: 'L1', phone: '136****6666', totalConsume: '¥ 450', bindDate: '2026-09-03' },
{ name: '张三', level: 'L1', phone: '136****6666', totalConsume: '¥ 450', bindDate: '2026-09-03' },
@@ -240,74 +204,37 @@
})
// ======================== 图表数据(秋云 uCharts) ========================
-
-// 折线图数据:x 轴为日期,系列为销售额
const lineChartData = computed(() => ({
categories: detail.value.salesTrend.map((item) => item.day),
- series: [
- {
- name: '销售额',
- data: detail.value.salesTrend.map((item) => item.value)
- }
- ]
+ series: [{ name: '销售额', data: detail.value.salesTrend.map((item) => item.value) }]
}))
-// 折线图配置:隐藏图例、虚线网格,y 轴上限按数据取整到千位
const lineChartOpts = computed(() => {
const values = detail.value.salesTrend.map((item) => item.value)
const maxValue = Math.max(1000, Math.ceil(Math.max(...values) / 1000) * 1000)
return {
- color: ['#4c7ff0'],
+ color: ['#2d91ff'],
legend: { show: false },
dataLabel: false,
dataPointShape: true,
- xAxis: { disableGrid: true, fontColor: '#9aa1ab', fontSize: 11 },
- yAxis: {
- gridType: 'dash',
- dashLength: 4,
- gridColor: '#eef1f5',
- fontColor: '#9aa1ab',
- fontSize: 11,
- data: [{ min: 0, max: maxValue }]
- },
- extra: {
- line: { type: 'straight', width: 2 }
- }
+ xAxis: { disableGrid: true, fontColor: '#94a3b8', fontSize: 11 },
+ yAxis: { gridType: 'dash', dashLength: 4, gridColor: '#e9eff6', fontColor: '#94a3b8', fontSize: 11, data: [{ min: 0, max: maxValue, axisLine: false }] },
+ extra: { line: { type: 'straight', width: 2 } }
}
})
-// 饼图数据:各商品销售额占比
const pieChartData = computed(() => ({
- series: detail.value.salesRatio.map((item) => ({
- name: item.name,
- data: item.value
- }))
+ series: detail.value.salesRatio.map((item) => ({ name: item.name, data: item.value }))
}))
-// 饼图配置:隐藏自带图例与数据标签(由页面自绘图例),白色描边分隔扇形
const pieChartOpts = computed(() => ({
color: detail.value.salesRatio.map((item) => item.color),
legend: { show: false },
dataLabel: false,
- extra: {
- pie: {
- activeOpacity: 0.5,
- activeRadius: 10,
- offsetAngle: 0,
- border: true,
- borderWidth: 3,
- borderColor: '#FFFFFF'
- }
- }
+ extra: { pie: { activeOpacity: 0.5, activeRadius: 10, offsetAngle: 0, border: true, borderWidth: 3, borderColor: '#FFFFFF' } }
}))
-// ======================== 方法定义区域 ========================
-
-/**
- * 加载子账号详情
- * @param {String} id 子账号 ID
- * 后端接口就绪前保留初始模拟数据,保证页面可视
- */
+// 加载详情数据,接口异常时沿用初始数据
const _loadDetail = async (id) => {
try {
const { data } = await getSubAccountDetail(id)
@@ -320,13 +247,10 @@
}
}
-/** 切换 Tab */
const _handleTabChange = (key) => {
if (activeTab.value === key) return
activeTab.value = key
}
-
-// ======================== 页面生命周期 ========================
onLoad((options) => {
const id = options && options.id
@@ -336,554 +260,195 @@
</script>
<style scoped lang="scss">
-/* 页面容器:淡灰蓝背景 */
+/* 页面采用浅灰蓝底色,内容卡片统一使用白色边界 */
.detail-page {
min-height: 100vh;
- background-color: #f7f9fc;
- padding: 24rpx 28rpx 60rpx;
box-sizing: border-box;
+ padding: 24rpx 32rpx calc(72rpx + env(safe-area-inset-bottom));
+ background: #f8fafc;
+ color: #0f172a;
}
-/* 顶部信息卡:深蓝背景 */
+.profile-card,
+.card,
+.warehouse-card,
+.invite-item {
+ border: 1rpx solid #e9eff6;
+ border-radius: 28rpx;
+ background: #ffffff;
+ box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.04);
+}
+
.profile-card {
display: flex;
align-items: center;
- justify-content: space-between;
- padding: 32rpx 28rpx;
- background-color: #0b3b68;
- border-radius: 16rpx;
+ padding: 32rpx;
}
-.profile-left {
- display: flex;
- flex-direction: column;
-}
-
-.profile-name {
- font-size: 34rpx;
- font-weight: 700;
- color: #ffffff;
- line-height: 46rpx;
-}
-
-.profile-phone {
- font-size: 26rpx;
- color: rgba(255, 255, 255, 0.8);
- margin-top: 8rpx;
-}
-
-.profile-level {
- display: flex;
- align-items: baseline;
- margin-top: 20rpx;
-}
-
-.level-label {
- font-size: 24rpx;
- color: rgba(255, 255, 255, 0.8);
- margin-right: 12rpx;
-}
-
-.level-value {
- font-size: 40rpx;
- font-weight: 700;
- color: #ffffff;
-}
-
-/* 等级详情按钮:绿色圆角 */
-.level-detail-btn {
- padding: 14rpx 28rpx;
- background-color: #67b72c;
- color: #ffffff;
- font-size: 26rpx;
- font-weight: 600;
- border-radius: 8rpx;
-}
-
-/* Tab 栏 */
-.tab-bar {
- display: flex;
- align-items: center;
- margin-top: 24rpx;
- background-color: #ffffff;
- border-radius: 16rpx 16rpx 0 0;
- padding: 0 12rpx;
-}
-
-.tab-item {
- position: relative;
- flex: 1;
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 26rpx 0;
-}
-
-.tab-text {
- font-size: 28rpx;
- color: #6b7280;
-}
-
-.tab-item--active .tab-text {
- color: #1f2329;
- font-weight: 600;
-}
-
-/* 选中 Tab 的绿色下划线 */
-.tab-underline {
- position: absolute;
- bottom: 8rpx;
- width: 56rpx;
- height: 6rpx;
- border-radius: 4rpx;
- background-color: #67b72c;
-}
-
-/* 通用卡片 */
-.card {
- background-color: #ffffff;
- border-radius: 16rpx;
- padding: 28rpx;
- margin-bottom: 20rpx;
-}
-
-.sales-content {
- padding-top: 20rpx;
-}
-
-/* 当月利润卡 */
-.profit-card {
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-
-.profit-main {
- display: flex;
- flex-direction: column;
-}
-
-.profit-amount {
- font-size: 42rpx;
- font-weight: 700;
- color: #1f2329;
- line-height: 54rpx;
-}
-
-.profit-label {
- font-size: 24rpx;
- color: #8f959e;
- margin-top: 8rpx;
-}
-
-.profit-trend {
- font-size: 24rpx;
- color: #1a73e8;
-}
-
-/* 汇总指标 2 列网格 */
-.summary-grid {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- row-gap: 32rpx;
- column-gap: 16rpx;
-}
-
-.summary-cell {
- display: flex;
- align-items: center;
-}
-
-.summary-label {
- font-size: 26rpx;
- color: #8f959e;
-}
-
-.summary-value {
- font-size: 28rpx;
- font-weight: 700;
- color: #1f2329;
- margin-left: 16rpx;
-}
-
-/* 图表卡片 */
-.chart-card {
- display: flex;
- flex-direction: column;
-}
-
-.chart-title {
- font-size: 28rpx;
- font-weight: 600;
- color: #1f2329;
- text-align: center;
- margin-bottom: 24rpx;
-}
-
-/* 图表容器:必须给定高度,uCharts 组件按父容器尺寸绘制 */
-.chart-box {
- width: 100%;
-}
-
-.chart-box--line {
- height: 220px;
-}
-
-.chart-box--pie {
- width: 160px;
- height: 160px;
- flex-shrink: 0;
-}
-
-/* 饼图行:左饼图右图例 */
-.pie-row {
- display: flex;
- align-items: center;
-}
-
-.pie-legend {
- flex: 1;
- margin-left: 24rpx;
- display: flex;
- flex-direction: column;
- gap: 20rpx;
-}
-
-.legend-item {
- display: flex;
- align-items: center;
-}
-
-.legend-color {
- width: 24rpx;
- height: 24rpx;
- border-radius: 4rpx;
- margin-right: 14rpx;
- flex-shrink: 0;
-}
-
-.legend-name {
- font-size: 24rpx;
- color: #1f2329;
- flex: 1;
-}
-
-.legend-amount {
- font-size: 24rpx;
- color: #8f959e;
-}
-
-/* 销售用户排行 */
-.ranking-list {
- display: flex;
- flex-direction: column;
- gap: 24rpx;
-}
-
-.ranking-item {
- display: flex;
- align-items: center;
-}
-
-.rank-badge {
- width: 44rpx;
- height: 44rpx;
- border-radius: 50%;
- border: 1rpx solid #dcdfe6;
+.profile-avatar,
+.invite-avatar {
display: flex;
align-items: center;
justify-content: center;
- font-size: 24rpx;
- color: #6b7280;
flex-shrink: 0;
-}
-
-.rank-name {
- font-size: 28rpx;
- color: #1f2329;
- margin-left: 20rpx;
-}
-
-.rank-phone {
- font-size: 26rpx;
- color: #8f959e;
- margin-left: 20rpx;
- flex: 1;
-}
-
-.rank-amount {
- font-size: 28rpx;
- color: #1f2329;
-}
-
-/* ==================== 列表型 Tab 容器 ==================== */
-/* 仓库/邀请内容:白底通铺,承接 Tab 栏(外层已有页面 padding,此处仅留内容内边距) */
-.list-content {
- background-color: #ffffff;
- padding: 0 24rpx;
-}
-
-/* ==================== 他的仓库 ==================== */
-/* 库存告警条:深蓝渐变底 + 左侧感叹号 */
-.stock-warning {
- display: flex;
- align-items: center;
- padding: 14rpx 20rpx;
- border-radius: 8rpx;
- margin-top: 20rpx;
- background: linear-gradient(90deg, #0b3b68 0%, #1a5f9e 100%);
-}
-
-.warning-icon {
- width: 30rpx;
- height: 30rpx;
- border-radius: 50%;
- background-color: #3f8fd6;
color: #ffffff;
- font-size: 22rpx;
font-weight: 700;
- text-align: center;
- line-height: 30rpx;
- margin-right: 14rpx;
- flex-shrink: 0;
+ background: linear-gradient(135deg, #2d91ff, #003e7e);
+ border-radius: 50%;
}
-.warning-text {
- font-size: 24rpx;
- color: #ffffff;
-}
-
-/* 单个商品行 */
-.warehouse-item {
- display: flex;
- align-items: center;
- padding: 24rpx 0;
- border-bottom: 1rpx solid #f0f2f5;
-}
-
-/* 商品图占位:灰底 + CSS 图片图标 */
-.goods-thumb {
+.profile-avatar {
width: 96rpx;
height: 96rpx;
- background-color: #f2f3f5;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
+ font-size: 38rpx;
}
-.thumb-icon {
- position: relative;
- width: 40rpx;
- height: 34rpx;
- border: 3rpx solid #c0c4cc;
- border-radius: 4rpx;
- box-sizing: border-box;
+.profile-info {
+ min-width: 0;
+ margin-left: 24rpx;
}
-/* 图片图标内的“山”三角 */
-.thumb-icon::before {
- content: '';
- position: absolute;
- left: 4rpx;
- bottom: 3rpx;
- width: 0;
- height: 0;
- border-left: 9rpx solid transparent;
- border-right: 9rpx solid transparent;
- border-bottom: 11rpx solid #c0c4cc;
-}
-
-/* 图片图标内的“太阳”圆点 */
-.thumb-icon::after {
- content: '';
- position: absolute;
- top: 4rpx;
- right: 5rpx;
- width: 7rpx;
- height: 7rpx;
- border-radius: 50%;
- background-color: #c0c4cc;
-}
-
-/* 商品名:占据中间自适应空间 */
-.goods-name {
- flex: 1;
- font-size: 26rpx;
- color: #1f2329;
- line-height: 36rpx;
- margin: 0 16rpx;
-}
-
-/* 库存信息:右对齐两行 */
-.goods-stock {
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- flex-shrink: 0;
-}
-
-.stock-row {
- display: flex;
- align-items: center;
-}
-
-.stock-row + .stock-row {
- margin-top: 10rpx;
-}
-
-.stock-label {
- font-size: 24rpx;
- color: #6b7280;
-}
-
-.stock-value {
- font-size: 24rpx;
- color: #1f2329;
- margin-left: 4rpx;
-}
-
-/* 库存低于最低需求:红色高亮 */
-.stock-value--danger {
- color: #e54d42;
-}
-
-/* ==================== 他的邀请 ==================== */
-/* 人数汇总行 */
-.invite-summary {
- display: flex;
- align-items: center;
- padding: 24rpx 0;
- border-bottom: 1rpx solid #f0f2f5;
-}
-
-.invite-summary-item {
- flex: 1;
- display: flex;
- align-items: center;
-}
-
-.invite-summary-label {
- font-size: 24rpx;
- color: #8f959e;
-}
-
-.invite-summary-value {
- font-size: 24rpx;
- font-weight: 700;
- color: #1f2329;
- margin-left: 6rpx;
-}
-
-/* 邀请用户行 */
-.invite-item {
- display: flex;
- align-items: center;
- padding: 28rpx 0;
- border-bottom: 1rpx solid #f0f2f5;
-}
-
-/* 头像占位:灰底 + CSS 人形(头 + 肩) */
-.invite-avatar {
- position: relative;
- width: 72rpx;
- height: 72rpx;
- border-radius: 50%;
- background-color: #e9ebee;
- flex-shrink: 0;
- overflow: hidden;
-}
-
-.invite-avatar::before {
- content: '';
- position: absolute;
- top: 16rpx;
- left: 50%;
- transform: translateX(-50%);
- width: 24rpx;
- height: 24rpx;
- border-radius: 50%;
- background-color: #c0c4cc;
-}
-
-.invite-avatar::after {
- content: '';
- position: absolute;
- bottom: 0;
- left: 50%;
- transform: translateX(-50%);
- width: 46rpx;
- height: 24rpx;
- border-radius: 24rpx 24rpx 0 0;
- background-color: #c0c4cc;
-}
-
-/* 中列:姓名 + 等级 + 手机号 */
-.invite-main {
- flex: 1;
- display: flex;
- flex-direction: column;
- margin-left: 20rpx;
-}
-
+.profile-name-row,
+.chart-title-row,
.invite-name-row {
display: flex;
align-items: center;
}
-.invite-name {
- font-size: 28rpx;
- color: #1f2329;
+.profile-name {
+ overflow: hidden;
+ max-width: 360rpx;
+ font-size: 36rpx;
+ font-weight: 700;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-/* 等级胶囊:绿底白字 */
+.profile-phone,
+.invite-phone,
+.summary-label,
+.profit-label,
+.stock-label,
+.invite-summary-label,
+.invite-consume-label,
+.invite-bind {
+ color: #64748b;
+}
+
+.profile-phone {
+ display: block;
+ margin-top: 10rpx;
+ font-size: 26rpx;
+}
+
+.level-pill,
.invite-level {
- margin-left: 12rpx;
- padding: 2rpx 12rpx;
- font-size: 20rpx;
- color: #ffffff;
- background-color: #67b72c;
- border-radius: 6rpx;
+ margin-left: 14rpx;
+ padding: 6rpx 16rpx;
+ border-radius: 999rpx;
+ font-size: 22rpx;
+ font-weight: 600;
}
-.invite-phone {
- font-size: 24rpx;
- color: #8f959e;
- margin-top: 12rpx;
-}
+.level-pill--L1 { color: #1d6fd1; background: #e3f1ff; }
+.level-pill--L2 { color: #27824f; background: #e2f6e9; }
+.level-pill--L3 { color: #a06b00; background: #fff1c9; }
-/* 右列:消费额 + 绑定日 */
-.invite-right {
+.tab-bar {
display: flex;
- flex-direction: column;
- align-items: flex-end;
- flex-shrink: 0;
+ gap: 12rpx;
+ margin: 24rpx 0;
+ padding: 8rpx;
+ border-radius: 999rpx;
+ background: #e8f2ff;
}
-.invite-consume {
+.tab-item {
+ flex: 1;
+ padding: 18rpx 8rpx;
+ text-align: center;
+ border-radius: 999rpx;
+}
+
+.tab-item--active {
+ background: linear-gradient(135deg, #2d91ff, #003e7e);
+ box-shadow: 0 6rpx 14rpx rgba(45, 145, 255, 0.22);
+}
+
+.tab-text { color: #64748b; font-size: 26rpx; }
+.tab-item--active .tab-text { color: #ffffff; font-weight: 600; }
+.sales-content { display: flex; flex-direction: column; }
+.card { box-sizing: border-box; margin-bottom: 20rpx; padding: 28rpx; }
+
+.profit-card {
display: flex;
align-items: center;
+ justify-content: space-between;
+ min-height: 168rpx;
+ color: #ffffff;
+ border: 0;
+ background: linear-gradient(135deg, #2d91ff, #003e7e);
}
-.invite-consume-label {
- font-size: 24rpx;
- color: #8f959e;
-}
+.profit-main { display: flex; flex-direction: column; }
+.profit-label { color: rgba(255, 255, 255, 0.8); font-size: 24rpx; }
+.profit-amount { margin-top: 10rpx; font-size: 48rpx; font-weight: 700; line-height: 1.1; }
+.profit-trend { padding: 10rpx 16rpx; color: #dff4ff; font-size: 24rpx; border: 1rpx solid rgba(255, 255, 255, 0.4); border-radius: 999rpx; }
-.invite-consume-value {
- font-size: 24rpx;
- color: #1f2329;
-}
+.summary-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28rpx 20rpx; }
+.summary-cell { min-width: 0; }
+.summary-label { display: block; overflow: hidden; font-size: 24rpx; text-overflow: ellipsis; white-space: nowrap; }
+.summary-value { display: block; overflow: hidden; margin-top: 10rpx; color: #0f172a; font-size: 32rpx; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
-.invite-bind {
- font-size: 22rpx;
- color: #b0b6bd;
- margin-top: 12rpx;
-}
+.chart-title-row { margin-bottom: 24rpx; }
+.chart-mark { width: 8rpx; height: 30rpx; margin-right: 12rpx; border-radius: 8rpx; background: #2d91ff; }
+.chart-title { color: #0f172a; font-size: 30rpx; font-weight: 700; }
+.chart-box { width: 100%; }
+.chart-box--line { height: 220px; }
+.pie-row { display: flex; flex-direction: column; align-items: center; }
+.chart-box--pie { width: 180px; height: 180px; }
+.pie-legend { display: flex; flex-wrap: wrap; justify-content: center; width: 100%; gap: 16rpx 24rpx; margin-top: 16rpx; }
+.legend-item { display: flex; align-items: center; max-width: 100%; }
+.legend-color { width: 18rpx; height: 18rpx; margin-right: 8rpx; border-radius: 50%; }
+.legend-name { max-width: 150rpx; overflow: hidden; color: #0f172a; font-size: 22rpx; text-overflow: ellipsis; white-space: nowrap; }
+.legend-amount { margin-left: 8rpx; color: #64748b; font-size: 22rpx; }
-/* 列表最后一行去掉分隔线 */
-.list-content > .invite-item:last-child,
-.list-content > .warehouse-item:last-child {
- border-bottom: none;
-}
+.ranking-list { display: flex; flex-direction: column; gap: 18rpx; }
+.ranking-item { display: flex; align-items: center; min-width: 0; padding: 14rpx 0; }
+.rank-badge { display: flex; align-items: center; justify-content: center; width: 44rpx; height: 44rpx; flex-shrink: 0; color: #64748b; font-size: 22rpx; border-radius: 12rpx; background: #f1f5f9; }
+.rank-badge--top { color: #ffffff; background: linear-gradient(135deg, #2d91ff, #003e7e); }
+.rank-person { display: flex; align-items: center; min-width: 0; flex: 1; margin-left: 18rpx; }
+.rank-name { flex-shrink: 0; color: #0f172a; font-size: 28rpx; }
+.rank-phone { overflow: hidden; margin-left: 18rpx; color: #64748b; font-size: 24rpx; text-overflow: ellipsis; white-space: nowrap; }
+.rank-amount { margin-left: 16rpx; color: #0f172a; font-size: 28rpx; font-weight: 600; }
+
+.list-content { display: flex; flex-direction: column; gap: 20rpx; }
+.warehouse-card { overflow: hidden; }
+.stock-warning { display: flex; align-items: center; padding: 16rpx 22rpx; background: #fff4e6; }
+.warning-icon { display: flex; align-items: center; justify-content: center; width: 30rpx; height: 30rpx; margin-right: 12rpx; color: #ffffff; font-size: 22rpx; font-weight: 700; border-radius: 50%; background: #f59e0b; }
+.warning-text { color: #b45309; font-size: 23rpx; }
+.warehouse-body { padding: 26rpx; }
+.goods-category { display: inline-flex; padding: 6rpx 14rpx; color: #1d6fd1; font-size: 21rpx; border-radius: 8rpx; background: #e8f2ff; }
+.goods-name { display: block; margin-top: 16rpx; color: #0f172a; font-size: 28rpx; font-weight: 600; line-height: 1.5; }
+.goods-stock { display: flex; justify-content: space-between; margin-top: 24rpx; }
+.stock-row { display: flex; align-items: baseline; min-width: 0; }
+.stock-row + .stock-row { margin-left: 20rpx; }
+.stock-label { font-size: 22rpx; white-space: nowrap; }
+.stock-value { margin-left: 8rpx; color: #0f172a; font-size: 28rpx; font-weight: 700; }
+.stock-value--danger { color: #dc2626; }
+
+.invite-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8rpx; margin-bottom: 0; }
+.invite-summary-item { min-width: 0; text-align: center; }
+.invite-summary-label { display: block; font-size: 23rpx; }
+.invite-summary-value { display: block; margin-top: 8rpx; color: #0f172a; font-size: 30rpx; font-weight: 700; }
+.invite-item { display: flex; align-items: center; min-width: 0; padding: 24rpx; }
+.invite-avatar { width: 72rpx; height: 72rpx; font-size: 28rpx; }
+.invite-main { min-width: 0; flex: 1; margin-left: 18rpx; }
+.invite-name { color: #0f172a; font-size: 28rpx; }
+.invite-level { padding: 4rpx 12rpx; color: #1d6fd1; background: #e3f1ff; }
+.invite-phone { display: block; margin-top: 10rpx; font-size: 23rpx; }
+.invite-right { display: flex; flex-direction: column; align-items: flex-end; flex-shrink: 0; margin-left: 16rpx; }
+.invite-consume-label { font-size: 22rpx; }
+.invite-consume-value { margin-top: 4rpx; color: #0f172a; font-size: 27rpx; font-weight: 600; }
+.invite-bind { margin-top: 10rpx; font-size: 21rpx; white-space: nowrap; }
</style>
diff --git a/hugray-partner-miniapp/pages/sub-account/index.vue b/hugray-partner-miniapp/pages/sub-account/index.vue
index ca1a357..cbfab07 100644
--- a/hugray-partner-miniapp/pages/sub-account/index.vue
+++ b/hugray-partner-miniapp/pages/sub-account/index.vue
@@ -1,6 +1,5 @@
<template>
<view class="sub-account-page">
- <!-- 顶部筛选栏:等级筛选标签 + 搜索入口 -->
<view class="filter-bar">
<view class="level-tabs">
<view
@@ -13,40 +12,80 @@
{{ tab.label }}
</view>
</view>
- <view class="search-icon" @tap="_handleSearch"></view>
+ <view
+ class="search-button"
+ :class="{ 'search-button--active': searchVisible }"
+ aria-label="搜索账号"
+ @tap="_handleSearch"
+ >
+ <view class="search-icon"></view>
+ </view>
</view>
- <!-- 子账号列表:无数据时展示空态 -->
- <scroll-view class="account-list" scroll-y>
- <view v-if="subAccountList.length === 0" class="empty-tip">
- <text class="empty-text">暂无子账号</text>
- </view>
-
- <view
- v-for="item in subAccountList"
- :key="item.id"
- class="account-item"
- @tap="_handleViewDetail(item)"
- >
- <!-- 头像占位:纯 CSS 绘制的人像剪影 -->
- <view class="avatar"></view>
-
- <view class="item-info">
- <view class="item-top">
- <text class="account-name">{{ item.name }}</text>
- <view class="level-badge">{{ item.level }}</view>
- </view>
- <text class="account-phone">{{ item.phone }}</text>
+ <!-- 页内搜索条:点顶部搜索图标展开,回车或点「搜索」生效 -->
+ <view v-if="searchVisible" class="search-bar">
+ <view class="search-input-wrap">
+ <view class="search-icon search-icon--sm"></view>
+ <input
+ class="search-input"
+ v-model="keywordInput"
+ type="text"
+ placeholder="搜索昵称或手机号"
+ placeholder-class="search-placeholder"
+ confirm-type="search"
+ :focus="searchVisible"
+ @confirm="_handleSearchConfirm"
+ />
+ <view v-if="keywordInput.length > 0" class="search-clear" @tap="_handleClearInput">
+ <text class="search-clear-symbol">×</text>
</view>
+ </view>
+ <view class="search-action" @tap="_handleSearchConfirm">
+ <text>搜索</text>
+ </view>
+ <view class="search-action search-action--ghost" @tap="_handleSearchCancel">
+ <text>取消</text>
+ </view>
+ </view>
- <!-- 纯 CSS 右箭头,避免 > 被小程序模板转义 -->
- <view class="arrow-icon"></view>
+ <scroll-view class="account-list" scroll-y @scrolltolower="_handleLoadMore">
+ <view class="list-content">
+ <view class="list-heading">
+ <text class="list-title">{{ searchKeyword ? '搜索结果' : (activeLevel ? activeLevel + ' 子账号' : '全部子账号') }}</text>
+ <text class="list-count">共 {{ total }} 个</text>
+ </view>
+ <view v-if="subAccountList.length === 0" class="empty-tip">
+ <view class="empty-symbol">—</view>
+ <text class="empty-title">{{ isLoading ? '正在加载账号' : (searchKeyword ? '未找到相关子账号' : '暂无子账号') }}</text>
+ <text class="empty-text">{{ isLoading ? '请稍候' : (searchKeyword ? '换个昵称或手机号试试' : '新增子账号,开启业务协作') }}</text>
+ </view>
+ <view v-else class="account-card">
+ <view
+ v-for="item in subAccountList"
+ :key="item.userId"
+ class="account-item"
+ @tap="_handleViewDetail(item)"
+ >
+ <view class="avatar">{{ (item.nickname || item.phone || '账').slice(0, 1) }}</view>
+ <view class="item-info">
+ <view class="item-top">
+ <text class="account-name">{{ item.nickname || item.phone }}</text>
+ <view class="level-badge" :class="'level-badge--' + item.systemLevel">{{ item.systemLevel }}</view>
+ </view>
+ <text class="account-phone">{{ item.phone }}</text>
+ </view>
+ <view class="arrow-icon"></view>
+ </view>
+ </view>
+ <text v-if="subAccountList.length" class="list-footer">{{ isLoading ? '正在加载…' : subAccountList.length < total ? '上滑加载更多账号' : '已展示全部账号' }}</text>
</view>
</scroll-view>
- <!-- 底部新增账号按钮 -->
<view class="bottom-bar">
- <view class="add-btn" @tap="_handleAddAccount">新增账号</view>
+ <view class="add-btn" @tap="_handleAddAccount">
+ <text class="add-symbol">+</text>
+ <text>新增账号</text>
+ </view>
</view>
</view>
</template>
@@ -56,80 +95,104 @@
import { onLoad } from '@dcloudio/uni-app'
import { getSubAccountList } from '@/api/index.js'
-// ======================== 数据定义区域 ========================
-
-// 等级筛选标签,value 为空表示「全部」
-const levelTabs = ref([
+const levelTabs = [
{ label: '全部', value: '' },
{ label: 'L1', value: 'L1' },
{ label: 'L2', value: 'L2' },
{ label: 'L3', value: 'L3' }
-])
-
-// 当前选中的等级
+]
const activeLevel = ref('')
-
-// 子账号列表数据
const subAccountList = ref([])
+const pageNum = ref(1)
+const pageSize = 10
+const total = ref(0)
+const isLoading = ref(false)
+const searchVisible = ref(false) // 搜索条是否展开
+const keywordInput = ref('') // 输入框实时值(未提交)
+const searchKeyword = ref('') // 已生效的关键字(参与请求)
-// ======================== 方法定义区域 ========================
-
-/**
- * 加载子账号列表
- * 后端接口就绪前以本地模拟数据兜底,保证页面可视
- */
+/** 加载账号列表:第一页覆盖,后续页追加。 */
const _loadSubAccountList = async () => {
+ if (isLoading.value) return
+ isLoading.value = true
try {
- const { data } = await getSubAccountList({ level: activeLevel.value })
- subAccountList.value = (data && data.list) || []
+ const { data } = await getSubAccountList({
+ pageNum: pageNum.value,
+ pageSize,
+ level: activeLevel.value,
+ keyword: searchKeyword.value
+ })
+ const list = (data && data.list) || []
+ subAccountList.value = pageNum.value === 1 ? list : subAccountList.value.concat(list)
+ total.value = (data && data.total) || 0
} catch (e) {
- subAccountList.value = _getMockList(activeLevel.value)
+ if (pageNum.value === 1) subAccountList.value = []
+ uni.showToast({ title: e.message || '加载失败', icon: 'none' })
+ } finally {
+ isLoading.value = false
}
}
-/**
- * 按等级过滤模拟数据(私有工具方法)
- * @param {String} level 等级筛选值,空串表示全部
- */
-const _getMockList = (level) => {
- const mockList = [
- { id: 1, name: '张三', level: 'L1', phone: '13612346666' },
- { id: 2, name: '李四', level: 'L1', phone: '13612346666' },
- { id: 3, name: '王五', level: 'L2', phone: '13612346666' },
- { id: 4, name: '赵六', level: 'L3', phone: '13612346666' }
- ]
- return level ? mockList.filter((item) => item.level === level) : mockList
-}
-
-/** 切换等级筛选 */
-const _handleLevelChange = (level) => {
- if (activeLevel.value === level) return
- activeLevel.value = level
+/** 请求进行时禁止递增页码,避免连续触底跳过分页。 */
+const _handleLoadMore = () => {
+ if (isLoading.value || subAccountList.value.length >= total.value) return
+ pageNum.value += 1
_loadSubAccountList()
}
-/** 搜索入口(搜索页待开发) */
-const _handleSearch = () => {
- uni.showToast({ title: '搜索功能开发中', icon: 'none' })
+/** 切换等级并重新加载第一页(保留当前搜索关键字)。 */
+const _handleLevelChange = (level) => {
+ if (isLoading.value || activeLevel.value === level) return
+ activeLevel.value = level
+ pageNum.value = 1
+ _loadSubAccountList()
}
-/** 查看子账号详情 */
+/** 点搜索图标:展开搜索条并回填已生效关键字;再次点击等同取消。 */
+const _handleSearch = () => {
+ if (searchVisible.value) {
+ _handleSearchCancel()
+ return
+ }
+ keywordInput.value = searchKeyword.value
+ searchVisible.value = true
+}
+
+/** 确认搜索:应用关键字并回到第一页(关键字未变化则不重复请求)。 */
+const _handleSearchConfirm = () => {
+ const next = keywordInput.value.trim()
+ if (next === searchKeyword.value) return
+ searchKeyword.value = next
+ pageNum.value = 1
+ _loadSubAccountList()
+}
+
+/** 取消搜索:清空关键字并恢复全部列表。 */
+const _handleSearchCancel = () => {
+ keywordInput.value = ''
+ searchVisible.value = false
+ if (!searchKeyword.value) return
+ searchKeyword.value = ''
+ pageNum.value = 1
+ _loadSubAccountList()
+}
+
+/** 清空输入框(不触发请求,等用户点搜索)。 */
+const _handleClearInput = () => {
+ keywordInput.value = ''
+}
+
const _handleViewDetail = (item) => {
uni.navigateTo({
- url: '/pages/sub-account/detail?id=' + item.id,
- fail: () => {
- uni.showToast({ title: '子账号详情开发中', icon: 'none' })
- }
+ url: '/pages/sub-account/detail?id=' + item.userId,
+ fail: () => uni.showToast({ title: '子账号详情开发中', icon: 'none' })
})
}
-/** 新增子账号 */
const _handleAddAccount = () => {
uni.navigateTo({
url: '/pages/sub-account/add',
- fail: () => {
- uni.showToast({ title: '新增账号功能开发中', icon: 'none' })
- }
+ fail: () => uni.showToast({ title: '新增账号功能开发中', icon: 'none' })
})
}
@@ -139,65 +202,69 @@
</script>
<style scoped lang="scss">
-/* 页面最外层:纵向弹性布局,撑满整屏 */
+/* 中性底色与白色卡片,内容区保留独立滚动。 */
.sub-account-page {
display: flex;
flex-direction: column;
height: 100vh;
- background-color: #ffffff;
+ overflow: hidden;
+ background: #f8fafc;
+ color: #0f172a;
}
-/* 顶部筛选栏 */
.filter-bar {
+ position: relative;
+ z-index: 1;
display: flex;
align-items: center;
- justify-content: space-between;
- padding: 24rpx 28rpx;
- background-color: #ffffff;
+ gap: 20rpx;
+ flex-shrink: 0;
+ margin: 24rpx 32rpx 0;
+ padding: 16rpx;
+ border: 1rpx solid #e9eff6;
+ border-radius: 28rpx;
+ background: #ffffff;
+ box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.04);
}
.level-tabs {
display: flex;
- align-items: center;
- gap: 16rpx;
+ flex: 1;
+ gap: 8rpx;
+ min-width: 0;
}
-/* 等级筛选标签:默认灰底,选中深蓝底白字 */
.level-tab {
- min-width: 96rpx;
- height: 56rpx;
- padding: 0 24rpx;
display: flex;
+ flex: 1;
align-items: center;
justify-content: center;
- background-color: #eef1f5;
- border-radius: 8rpx;
- font-size: 26rpx;
- color: #6b7280;
- box-sizing: border-box;
+ height: 68rpx;
+ border-radius: 20rpx;
+ font-size: 27rpx;
+ font-weight: 600;
+ color: #64748b;
}
.level-tab--active {
- background-color: #0b3b68;
- color: #ffffff;
- font-weight: 600;
+ background: #eaf3ff;
+ color: #1e5ebc;
}
-/* 搜索图标:纯 CSS 绘制的放大镜 */
+.search-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 68rpx;
+ height: 68rpx;
+ border-left: 1rpx solid #edf2f8;
+}
+
.search-icon {
position: relative;
- width: 44rpx;
- height: 44rpx;
-}
-
-.search-icon::before {
- content: '';
- position: absolute;
- top: 4rpx;
- left: 4rpx;
width: 28rpx;
height: 28rpx;
- border: 3rpx solid #1f2329;
+ border: 3rpx solid #64748b;
border-radius: 50%;
box-sizing: border-box;
}
@@ -205,82 +272,171 @@
.search-icon::after {
content: '';
position: absolute;
- bottom: 6rpx;
- right: 4rpx;
+ right: -10rpx;
+ bottom: -6rpx;
width: 14rpx;
height: 3rpx;
- background-color: #1f2329;
border-radius: 2rpx;
+ background: #64748b;
transform: rotate(45deg);
}
-/* 列表区域:占据筛选栏与底部按钮之间的剩余空间 */
+/* 搜索条展开时图标高亮为主题蓝 */
+.search-button--active .search-icon {
+ border-color: #1e5ebc;
+}
+
+.search-button--active .search-icon::after {
+ background: #1e5ebc;
+}
+
+/* 页内搜索条:与筛选栏同一卡片风格 */
+.search-bar {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+ flex-shrink: 0;
+ margin: 20rpx 32rpx 0;
+ padding: 12rpx 16rpx;
+ border: 1rpx solid #e9eff6;
+ border-radius: 24rpx;
+ background: #ffffff;
+ box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.04);
+}
+
+.search-input-wrap {
+ display: flex;
+ align-items: center;
+ gap: 14rpx;
+ flex: 1;
+ min-width: 0;
+ height: 68rpx;
+ padding: 0 20rpx;
+ border-radius: 20rpx;
+ background: #f4f7fb;
+}
+
+/* 复用放大镜图标,缩到输入框内尺寸 */
+.search-icon--sm {
+ flex-shrink: 0;
+ width: 22rpx;
+ height: 22rpx;
+ border-width: 3rpx;
+}
+
+.search-icon--sm::after {
+ right: -8rpx;
+ bottom: -5rpx;
+ width: 12rpx;
+}
+
+.search-input {
+ flex: 1;
+ min-width: 0;
+ height: 68rpx;
+ font-size: 27rpx;
+ color: #0f172a;
+}
+
+.search-placeholder {
+ color: #a9b6c6;
+}
+
+.search-clear {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 36rpx;
+ height: 36rpx;
+ border-radius: 50%;
+ background: #d5dde8;
+}
+
+.search-clear-symbol {
+ color: #ffffff;
+ font-size: 28rpx;
+ line-height: 1;
+}
+
+.search-action {
+ flex-shrink: 0;
+ padding: 0 10rpx;
+ font-size: 27rpx;
+ font-weight: 600;
+ color: #1e5ebc;
+}
+
+.search-action--ghost {
+ color: #94a3b8;
+ font-weight: 500;
+}
+
.account-list {
flex: 1;
- background-color: #ffffff;
+ height: 0;
+ min-height: 0;
}
-/* 空态提示 */
-.empty-tip {
+.list-content {
+ padding: 32rpx;
+}
+
+.list-heading {
display: flex;
- justify-content: center;
- padding-top: 200rpx;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 22rpx;
}
-.empty-text {
+.list-title {
font-size: 28rpx;
- color: #9aa1ab;
+ font-weight: 600;
}
-/* 子账号列表项 */
+.list-count {
+ color: #94a3b8;
+ font-size: 24rpx;
+}
+
+.account-card {
+ overflow: hidden;
+ border: 1rpx solid #e9eff6;
+ border-radius: 28rpx;
+ background: #ffffff;
+ box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.03);
+}
+
.account-item {
display: flex;
align-items: center;
- padding: 28rpx;
- border-bottom: 1rpx solid #f0f2f5;
+ gap: 24rpx;
+ margin: 0 28rpx;
+ padding: 30rpx 0;
+ border-bottom: 1rpx solid #f1f5f9;
}
-/* 头像占位:灰底圆角 + CSS 人像剪影 */
+.account-item:last-child {
+ border-bottom: 0;
+}
+
.avatar {
- position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
width: 88rpx;
height: 88rpx;
- border-radius: 16rpx;
- background-color: #eceff3;
- overflow: hidden;
- flex-shrink: 0;
+ border-radius: 28rpx;
+ background: linear-gradient(135deg, #eaf3ff, #dcecff);
+ color: #1e5ebc;
+ font-size: 34rpx;
+ font-weight: 600;
}
-.avatar::before {
- /* 头部 */
- content: '';
- position: absolute;
- top: 16rpx;
- left: 50%;
- width: 30rpx;
- height: 30rpx;
- border-radius: 50%;
- background-color: #c7ccd6;
- transform: translateX(-50%);
-}
-
-.avatar::after {
- /* 身体 */
- content: '';
- position: absolute;
- bottom: 8rpx;
- left: 50%;
- width: 52rpx;
- height: 32rpx;
- border-radius: 26rpx 26rpx 0 0;
- background-color: #c7ccd6;
- transform: translateX(-50%);
-}
-
-/* 列表项文字信息 */
.item-info {
flex: 1;
- margin-left: 24rpx;
+ min-width: 0;
display: flex;
flex-direction: column;
}
@@ -288,62 +444,122 @@
.item-top {
display: flex;
align-items: center;
+ gap: 14rpx;
margin-bottom: 12rpx;
}
.account-name {
- font-size: 30rpx;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 29rpx;
font-weight: 600;
- color: #1f2329;
}
-/* 等级胶囊标签 */
.level-badge {
- margin-left: 16rpx;
- padding: 2rpx 18rpx;
- background-color: #67b72c;
- color: #ffffff;
- font-size: 22rpx;
+ flex-shrink: 0;
+ padding: 4rpx 14rpx;
+ border-radius: 10rpx;
+ background: #eaf3ff;
+ color: #1e5ebc;
+ font-size: 21rpx;
font-weight: 700;
- border-radius: 6rpx;
- line-height: 1.4;
+}
+
+.level-badge--L2 {
+ background: #edf7e7;
+ color: #4d8227;
+}
+
+.level-badge--L3 {
+ background: #fff4e0;
+ color: #a57622;
}
.account-phone {
- font-size: 26rpx;
- color: #8f959e;
+ font-size: 25rpx;
+ color: #64748b;
+ letter-spacing: 1rpx;
}
-/* 列表项右侧箭头 */
.arrow-icon {
- width: 14rpx;
- height: 14rpx;
- border-top: 3rpx solid #c0c4cc;
- border-right: 3rpx solid #c0c4cc;
- transform: rotate(45deg);
- margin-left: 16rpx;
- box-sizing: border-box;
flex-shrink: 0;
+ width: 12rpx;
+ height: 12rpx;
+ border-top: 3rpx solid #cbd5e1;
+ border-right: 3rpx solid #cbd5e1;
+ transform: rotate(45deg);
}
-/* 底部按钮区 */
-.bottom-bar {
- padding: 20rpx 28rpx;
- padding-bottom: calc(20rpx + constant(safe-area-inset-bottom));
- padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
- background-color: #ffffff;
- border-top: 1rpx solid #f0f2f5;
+.list-footer {
+ display: block;
+ padding: 32rpx 0;
+ text-align: center;
+ font-size: 23rpx;
+ color: #94a3b8;
}
-.add-btn {
- height: 88rpx;
+.empty-tip {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 100rpx 24rpx;
+}
+
+.empty-symbol {
display: flex;
align-items: center;
justify-content: center;
- background-color: #0b3b68;
+ width: 112rpx;
+ height: 112rpx;
+ margin-bottom: 28rpx;
+ border-radius: 36rpx;
+ background: #eaf3ff;
+ color: #82afd9;
+ font-size: 48rpx;
+}
+
+.empty-title {
+ font-size: 29rpx;
+ font-weight: 600;
+ color: #475569;
+}
+
+.empty-text {
+ margin-top: 14rpx;
+ font-size: 25rpx;
+ color: #94a3b8;
+}
+
+/* 底部安全区与列表分离,避免按钮遮挡最后一条账号。 */
+.bottom-bar {
+ flex-shrink: 0;
+ padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
+ border-top: 1rpx solid #edf2f8;
+ background: #ffffff;
+}
+
+.add-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 14rpx;
+ height: 96rpx;
+ border-radius: 999rpx;
+ background: linear-gradient(105deg, #2d91ff, #003e7e);
+ box-shadow: 0 8rpx 20rpx rgba(30, 94, 188, 0.16);
color: #ffffff;
font-size: 30rpx;
font-weight: 600;
- border-radius: 8rpx;
+}
+
+.add-btn:active {
+ opacity: 0.88;
+}
+
+.add-symbol {
+ font-size: 38rpx;
+ font-weight: 400;
+ line-height: 1;
}
</style>
diff --git a/hugray-partner-miniapp/static/check-icon.png b/hugray-partner-miniapp/static/check-icon.png
new file mode 100644
index 0000000..3d224f4
--- /dev/null
+++ b/hugray-partner-miniapp/static/check-icon.png
Binary files differ
diff --git a/hugray-partner-miniapp/static/industry-4-0-slogan-card.png b/hugray-partner-miniapp/static/industry-4-0-slogan-card.png
new file mode 100644
index 0000000..fcb3b43
--- /dev/null
+++ b/hugray-partner-miniapp/static/industry-4-0-slogan-card.png
Binary files differ
diff --git a/hugray-partner-miniapp/static/medical-plus-icon.png b/hugray-partner-miniapp/static/medical-plus-icon.png
new file mode 100644
index 0000000..fd475dc
--- /dev/null
+++ b/hugray-partner-miniapp/static/medical-plus-icon.png
Binary files differ
diff --git a/hugray-partner-miniapp/static/metabolism-300-logo.png b/hugray-partner-miniapp/static/metabolism-300-logo.png
new file mode 100644
index 0000000..3a4072e
--- /dev/null
+++ b/hugray-partner-miniapp/static/metabolism-300-logo.png
Binary files differ
diff --git a/hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/RemoteAccountUserService.java b/hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/RemoteAccountUserService.java
index 6cf5d4e..7476945 100644
--- a/hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/RemoteAccountUserService.java
+++ b/hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/RemoteAccountUserService.java
@@ -12,6 +12,7 @@
import com.ruoyi.system.api.domain.AccountMember;
import com.ruoyi.system.api.factory.RemoteAccountUserFallbackFactory;
import com.ruoyi.system.api.model.AccountRegisterRequest;
+import com.ruoyi.system.api.model.AccountResetPasswordRequest;
/**
* C端会员服务(ruoyi-user)
@@ -32,6 +33,18 @@
public R<AccountMember> getMemberByPhone(@PathVariable("phone") String phone, @RequestHeader(SecurityConstants.FROM_SOURCE) String source);
/**
+ * 通过用户名查询C端会员信息(服务商端账号密码登录使用)
+ *
+ * 返回结果含 password 密文与 providerFlag,供认证服务校验密码与服务商身份。
+ *
+ * @param userName 用户名
+ * @param source 请求来源
+ * @return 会员信息(不存在时 data 为空)
+ */
+ @GetMapping("/member/info/username/{userName}")
+ public R<AccountMember> getMemberByUsername(@PathVariable("userName") String userName, @RequestHeader(SecurityConstants.FROM_SOURCE) String source);
+
+ /**
* 注册C端会员
*
* @param request 注册请求(手机号、推荐码)
@@ -40,4 +53,17 @@
*/
@PostMapping("/member/register")
public R<AccountMember> registerMember(@RequestBody AccountRegisterRequest request, @RequestHeader(SecurityConstants.FROM_SOURCE) String source);
+
+ /**
+ * 重置C端会员密码(忘记密码)
+ *
+ * 认证服务侧已完成手机号、账号状态与验证码校验,此处仅按手机号落库新密码
+ * (用户服务内 BCrypt 加密,只更新 password 一列)。
+ *
+ * @param request 重置请求(手机号、新密码)
+ * @param source 请求来源
+ * @return 处理结果
+ */
+ @PostMapping("/member/password/reset")
+ public R<Boolean> resetPassword(@RequestBody AccountResetPasswordRequest request, @RequestHeader(SecurityConstants.FROM_SOURCE) String source);
}
diff --git a/hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/domain/AccountMember.java b/hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/domain/AccountMember.java
index 75cfd80..15b2886 100644
--- a/hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/domain/AccountMember.java
+++ b/hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/domain/AccountMember.java
@@ -40,6 +40,17 @@
/** 状态(0正常 1停用) */
private String status;
+ /** 服务商标识(0非服务商 1服务商,对应 account_user.provider_flag) */
+ private String providerFlag;
+
+ /**
+ * 登录密码(BCrypt 密文,对应 account_user.password)
+ *
+ * 仅用于认证服务(ruoyi-auth)核对密码,本契约对象全程不下发到 C 端,
+ * 故不存在密文外泄风险。
+ */
+ private String password;
+
public Long getUserId()
{
return userId;
@@ -120,9 +131,30 @@
this.status = status;
}
+ public String getProviderFlag()
+ {
+ return providerFlag;
+ }
+
+ public void setProviderFlag(String providerFlag)
+ {
+ this.providerFlag = providerFlag;
+ }
+
+ public String getPassword()
+ {
+ return password;
+ }
+
+ public void setPassword(String password)
+ {
+ this.password = password;
+ }
+
@Override
public String toString()
{
+ // password 不参与打印,避免密文进入日志
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
.append("userId", getUserId())
.append("userName", getUserName())
@@ -132,6 +164,7 @@
.append("myReferralCode", getMyReferralCode())
.append("referrerId", getReferrerId())
.append("status", getStatus())
+ .append("providerFlag", getProviderFlag())
.toString();
}
}
diff --git a/hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/factory/RemoteAccountUserFallbackFactory.java b/hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/factory/RemoteAccountUserFallbackFactory.java
index d4b0bfd..4661033 100644
--- a/hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/factory/RemoteAccountUserFallbackFactory.java
+++ b/hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/factory/RemoteAccountUserFallbackFactory.java
@@ -8,6 +8,7 @@
import com.ruoyi.system.api.RemoteAccountUserService;
import com.ruoyi.system.api.domain.AccountMember;
import com.ruoyi.system.api.model.AccountRegisterRequest;
+import com.ruoyi.system.api.model.AccountResetPasswordRequest;
/**
* C端会员服务降级处理
@@ -32,10 +33,22 @@
}
@Override
+ public R<AccountMember> getMemberByUsername(String userName, String source)
+ {
+ return R.fail("获取会员信息失败:" + throwable.getMessage());
+ }
+
+ @Override
public R<AccountMember> registerMember(AccountRegisterRequest request, String source)
{
return R.fail("注册会员失败:" + throwable.getMessage());
}
+
+ @Override
+ public R<Boolean> resetPassword(AccountResetPasswordRequest request, String source)
+ {
+ return R.fail("重置密码失败:" + throwable.getMessage());
+ }
};
}
}
diff --git a/hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/model/AccountResetPasswordRequest.java b/hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/model/AccountResetPasswordRequest.java
new file mode 100644
index 0000000..132b6ce
--- /dev/null
+++ b/hugray-server/ruoyi-api/ruoyi-api-system/src/main/java/com/ruoyi/system/api/model/AccountResetPasswordRequest.java
@@ -0,0 +1,61 @@
+package com.ruoyi.system.api.model;
+
+import java.io.Serializable;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+
+/**
+ * C端会员重置密码请求对象(跨服务契约)
+ *
+ * 认证服务(ruoyi-auth)完成手机号查询、状态校验与验证码消费后,
+ * 通过该对象将手机号与新密码传递给用户服务(ruoyi-user),由其 BCrypt 加密后落库。
+ *
+ * @author hugray
+ */
+public class AccountResetPasswordRequest implements Serializable
+{
+ private static final long serialVersionUID = 1L;
+
+ /** 手机号(账号标识) */
+ @NotBlank(message = "请输入手机号")
+ @Pattern(regexp = "^1[3-9]\\d{9}$", message = "请输入正确手机号")
+ private String phone;
+
+ /** 新密码(明文,落库前由用户服务 BCrypt 加密) */
+ @NotBlank(message = "请输入新密码")
+ private String password;
+
+ public String getPhone()
+ {
+ return phone;
+ }
+
+ public void setPhone(String phone)
+ {
+ this.phone = phone;
+ }
+
+ public String getPassword()
+ {
+ return password;
+ }
+
+ public void setPassword(String password)
+ {
+ this.password = password;
+ }
+
+ /**
+ * 密码字段脱敏输出,避免明文密码落入日志
+ */
+ @Override
+ public String toString()
+ {
+ return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
+ .append("phone", getPhone())
+ .append("password", "******")
+ .toString();
+ }
+}
diff --git a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/AccountAuthController.java b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/AccountAuthController.java
index 468e1e7..a058733 100644
--- a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/AccountAuthController.java
+++ b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/AccountAuthController.java
@@ -2,21 +2,29 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
+import com.ruoyi.auth.domain.vo.InviteQrcodeVo;
import com.ruoyi.auth.form.AccountRegisterBody;
+import com.ruoyi.auth.form.InviteQrcodeQuery;
+import com.ruoyi.auth.form.PartnerLoginBody;
import com.ruoyi.auth.form.PhoneCodeLoginBody;
import com.ruoyi.auth.form.PhoneLoginBody;
+import com.ruoyi.auth.form.ResetPasswordBody;
import com.ruoyi.auth.form.SmsCodeBody;
import com.ruoyi.auth.service.AccountLoginService;
+import com.ruoyi.auth.service.PartnerLoginService;
+import com.ruoyi.auth.service.PasswordResetService;
import com.ruoyi.auth.service.SmsCodeService;
+import com.ruoyi.auth.service.WeChatMiniappService;
import com.ruoyi.common.core.domain.R;
/**
* 用户端/服务商端小程序认证控制器
*
- * 网关侧统一前缀为 /miniapp/auth(StripPrefix=2),故服务内路径为 /sms/code、/phone/login、/account/register。
+ * 网关侧统一前缀为 /miniapp/auth(StripPrefix=2),故服务内路径为 /sms/code、/phone/login、/account/register、/partner/login。
*
* @author hugray
*/
@@ -28,6 +36,15 @@
@Autowired
private AccountLoginService accountLoginService;
+
+ @Autowired
+ private PartnerLoginService partnerLoginService;
+
+ @Autowired
+ private PasswordResetService passwordResetService;
+
+ @Autowired
+ private WeChatMiniappService weChatMiniappService;
/**
* 下发短信验证码
@@ -58,6 +75,17 @@
}
/**
+ * 服务商端账号密码登录
+ *
+ * 仅需账号 + 密码;校验通过后强校验服务商身份(provider_flag='1')。
+ */
+ @PostMapping("/partner/login")
+ public R<?> partnerLogin(@Validated @RequestBody PartnerLoginBody body)
+ {
+ return R.ok(partnerLoginService.partnerLogin(body.getUsername(), body.getPassword()));
+ }
+
+ /**
* C端会员注册
*/
@PostMapping("/account/register")
@@ -66,4 +94,32 @@
accountLoginService.accountRegister(body.getPhone(), body.getCode(), body.getReferralCode());
return R.ok();
}
+
+ /**
+ * 重置密码(忘记密码)
+ *
+ * 凭手机号 + 短信验证码(scene=reset)校验身份后设置新密码。
+ */
+ @PostMapping("/password/reset")
+ public R<?> resetPassword(@Validated @RequestBody ResetPasswordBody body)
+ {
+ passwordResetService.resetPassword(body.getPhone(), body.getCode(), body.getPassword());
+ return R.ok();
+ }
+
+ /**
+ * 生成邀请码小程序码
+ *
+ * 以邀请码作为 scene 调用微信 getUnlimited 接口,扫码直达注册页并自动填入邀请码。
+ *
+ * @param query 邀请码入参(code)
+ * @return 小程序码 base64 DataURI
+ */
+ @GetMapping("/invite-qrcode")
+ public R<InviteQrcodeVo> inviteQrcode(@Validated InviteQrcodeQuery query)
+ {
+ InviteQrcodeVo vo = new InviteQrcodeVo();
+ vo.setQrcode(weChatMiniappService.getUnlimitedQrcode(query.getCode()));
+ return R.ok(vo);
+ }
}
diff --git a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/TokenController.java b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/TokenController.java
index 407d131..f5d14b0 100644
--- a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/TokenController.java
+++ b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/TokenController.java
@@ -41,9 +41,31 @@
return R.ok(tokenService.createToken(userInfo));
}
+ /**
+ * 登出(后台管理端使用 DELETE)
+ */
@DeleteMapping("logout")
public R<?> logout(HttpServletRequest request)
{
+ return doLogout(request);
+ }
+
+ /**
+ * 登出(小程序端使用 POST,网关 /miniapp/auth/logout 剥前缀后落到本路径)
+ * 与 DELETE 版本共用同一套登出逻辑,仅方法不同
+ */
+ @PostMapping("logout")
+ public R<?> miniappLogout(HttpServletRequest request)
+ {
+ return doLogout(request);
+ }
+
+ /**
+ * 登出公共逻辑:删除 Redis 登录态缓存并记录退出日志
+ * @param request 当前请求(用于从请求头提取 token)
+ */
+ private R<?> doLogout(HttpServletRequest request)
+ {
String token = SecurityUtils.getToken(request);
if (StringUtils.isNotEmpty(token))
{
diff --git a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/domain/vo/InviteQrcodeVo.java b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/domain/vo/InviteQrcodeVo.java
new file mode 100644
index 0000000..5dc28d6
--- /dev/null
+++ b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/domain/vo/InviteQrcodeVo.java
@@ -0,0 +1,22 @@
+package com.ruoyi.auth.domain.vo;
+
+/**
+ * 邀请码小程序码展示对象
+ *
+ * @author hugray
+ */
+public class InviteQrcodeVo
+{
+ /** 小程序码 PNG 的 base64 DataURI(data:image/png;base64,...),前端直接作为 image src */
+ private String qrcode;
+
+ public String getQrcode()
+ {
+ return qrcode;
+ }
+
+ public void setQrcode(String qrcode)
+ {
+ this.qrcode = qrcode;
+ }
+}
diff --git a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/InviteQrcodeQuery.java b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/InviteQrcodeQuery.java
new file mode 100644
index 0000000..99535af
--- /dev/null
+++ b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/InviteQrcodeQuery.java
@@ -0,0 +1,27 @@
+package com.ruoyi.auth.form;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+
+/**
+ * 邀请码小程序码查询入参
+ *
+ * @author hugray
+ */
+public class InviteQrcodeQuery
+{
+ /** 邀请码,作为小程序码 scene(8 位 A-Z0-9) */
+ @NotBlank(message = "请输入邀请码")
+ @Pattern(regexp = "^[A-Z0-9]{8}$", message = "邀请码格式不正确")
+ private String code;
+
+ public String getCode()
+ {
+ return code;
+ }
+
+ public void setCode(String code)
+ {
+ this.code = code;
+ }
+}
diff --git a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/PartnerLoginBody.java b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/PartnerLoginBody.java
new file mode 100644
index 0000000..f81a0f3
--- /dev/null
+++ b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/PartnerLoginBody.java
@@ -0,0 +1,41 @@
+package com.ruoyi.auth.form;
+
+import jakarta.validation.constraints.NotBlank;
+
+/**
+ * 服务商端账号密码登录对象
+ *
+ * 服务商端仅需账号 + 密码,不涉及验证码 / 微信授权等参数。
+ *
+ * @author hugray
+ */
+public class PartnerLoginBody
+{
+ /** 登录账号(对应 account_user.username) */
+ @NotBlank(message = "请输入账号")
+ private String username;
+
+ /** 登录密码(明文,服务端 BCrypt 校验) */
+ @NotBlank(message = "请输入密码")
+ private String password;
+
+ public String getUsername()
+ {
+ return username;
+ }
+
+ public void setUsername(String username)
+ {
+ this.username = username;
+ }
+
+ public String getPassword()
+ {
+ return password;
+ }
+
+ public void setPassword(String password)
+ {
+ this.password = password;
+ }
+}
diff --git a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/ResetPasswordBody.java b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/ResetPasswordBody.java
new file mode 100644
index 0000000..d20bbf9
--- /dev/null
+++ b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/ResetPasswordBody.java
@@ -0,0 +1,59 @@
+package com.ruoyi.auth.form;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+
+/**
+ * 重置密码对象(忘记密码)
+ *
+ * 校验要素:登录手机号 + 短信验证码 + 新密码。
+ *
+ * @author hugray
+ */
+public class ResetPasswordBody
+{
+ /** 手机号(账号标识) */
+ @NotBlank(message = "请输入手机号")
+ @Pattern(regexp = "^1[3-9]\\d{9}$", message = "请输入正确手机号")
+ private String phone;
+
+ /** 短信验证码 */
+ @NotBlank(message = "请输入验证码")
+ private String code;
+
+ /** 新密码(明文,服务端 BCrypt 加密落库) */
+ @NotBlank(message = "请输入新密码")
+ @Size(min = 6, max = 20, message = "新密码长度需为6-20位")
+ private String password;
+
+ public String getPhone()
+ {
+ return phone;
+ }
+
+ public void setPhone(String phone)
+ {
+ this.phone = phone;
+ }
+
+ public String getCode()
+ {
+ return code;
+ }
+
+ public void setCode(String code)
+ {
+ this.code = code;
+ }
+
+ public String getPassword()
+ {
+ return password;
+ }
+
+ public void setPassword(String password)
+ {
+ this.password = password;
+ }
+}
diff --git a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/SmsCodeBody.java b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/SmsCodeBody.java
index c31e817..d34202b 100644
--- a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/SmsCodeBody.java
+++ b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/form/SmsCodeBody.java
@@ -19,7 +19,7 @@
* 验证码场景
*
* register=注册(校验手机号未注册);login=验证码登录(校验手机号已注册);
- * reset/为空=通用下发(不校验注册状态)。
+ * reset=重置密码(校验手机号已注册);其它/为空=通用下发(不校验注册状态)。
*/
private String scene;
diff --git a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/AccountLoginService.java b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/AccountLoginService.java
index 0c91616..395f509 100644
--- a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/AccountLoginService.java
+++ b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/AccountLoginService.java
@@ -15,12 +15,9 @@
import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.core.utils.StringUtils;
import com.ruoyi.common.redis.service.RedisService;
-import com.ruoyi.common.security.service.TokenService;
import com.ruoyi.system.api.RemoteAccountUserService;
import com.ruoyi.system.api.domain.AccountMember;
-import com.ruoyi.system.api.domain.SysUser;
import com.ruoyi.system.api.model.AccountRegisterRequest;
-import com.ruoyi.system.api.model.LoginUser;
/**
* C端(用户端小程序)登录与注册编排服务
@@ -51,7 +48,7 @@
private RemoteAccountUserService remoteAccountUserService;
@Autowired
- private TokenService tokenService;
+ private AccountTokenIssuer accountTokenIssuer;
/**
* 手机号一键登录
@@ -214,31 +211,7 @@
*/
private Map<String, Object> issueToken(AccountMember member, String scene)
{
- LoginUser loginUser = buildLoginUser(member);
- log.info("[ACCOUNT_LOGIN] {} success, userId={}", scene, member.getUserId());
- return tokenService.createToken(loginUser);
- }
-
- /**
- * 组装登录用户对象(以 SysUser 作为令牌载体,兼容 TokenService)
- *
- * @param member C端会员信息
- * @return 登录用户对象
- */
- private LoginUser buildLoginUser(AccountMember member)
- {
- SysUser sysUser = new SysUser();
- sysUser.setUserId(member.getUserId());
- // TokenService 依赖 userName 生成令牌,缺失时回退为手机号
- sysUser.setUserName(StringUtils.isNotEmpty(member.getUserName()) ? member.getUserName() : member.getPhone());
- sysUser.setNickName(member.getNickname());
- sysUser.setPhonenumber(member.getPhone());
- sysUser.setAvatar(member.getAvatar());
- sysUser.setStatus(member.getStatus());
-
- LoginUser loginUser = new LoginUser();
- loginUser.setSysUser(sysUser);
- return loginUser;
+ return accountTokenIssuer.issue(member, scene);
}
/**
diff --git a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/AccountTokenIssuer.java b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/AccountTokenIssuer.java
new file mode 100644
index 0000000..72f5656
--- /dev/null
+++ b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/AccountTokenIssuer.java
@@ -0,0 +1,65 @@
+package com.ruoyi.auth.service;
+
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import com.ruoyi.common.core.utils.StringUtils;
+import com.ruoyi.common.security.service.TokenService;
+import com.ruoyi.system.api.domain.AccountMember;
+import com.ruoyi.system.api.domain.SysUser;
+import com.ruoyi.system.api.model.LoginUser;
+
+/**
+ * 小程序会员令牌签发组件
+ *
+ * 用户端与服务商端登录共用同一套「以 AccountMember 组装 LoginUser 并签发令牌」逻辑,
+ * 抽离于此避免重复实现。
+ *
+ * @author hugray
+ */
+@Component
+public class AccountTokenIssuer
+{
+ private static final Logger log = LoggerFactory.getLogger(AccountTokenIssuer.class);
+
+ @Autowired
+ private TokenService tokenService;
+
+ /**
+ * 组装登录态并签发令牌
+ *
+ * @param member C端会员信息
+ * @param scene 登录场景(仅用于日志标记)
+ * @return 令牌信息(access_token、expires_in)
+ */
+ public Map<String, Object> issue(AccountMember member, String scene)
+ {
+ LoginUser loginUser = buildLoginUser(member);
+ log.info("[ACCOUNT_LOGIN] {} success, userId={}", scene, member.getUserId());
+ return tokenService.createToken(loginUser);
+ }
+
+ /**
+ * 组装登录用户对象(以 SysUser 作为令牌载体,兼容 TokenService)
+ *
+ * @param member C端会员信息
+ * @return 登录用户对象
+ */
+ private LoginUser buildLoginUser(AccountMember member)
+ {
+ SysUser sysUser = new SysUser();
+ sysUser.setUserId(member.getUserId());
+ // TokenService 依赖 userName 生成令牌,缺失时回退为手机号
+ sysUser.setUserName(StringUtils.isNotEmpty(member.getUserName()) ? member.getUserName() : member.getPhone());
+ sysUser.setNickName(member.getNickname());
+ sysUser.setPhonenumber(member.getPhone());
+ sysUser.setAvatar(member.getAvatar());
+ sysUser.setStatus(member.getStatus());
+
+ LoginUser loginUser = new LoginUser();
+ loginUser.setSysUser(sysUser);
+ return loginUser;
+ }
+}
diff --git a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/PartnerLoginService.java b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/PartnerLoginService.java
new file mode 100644
index 0000000..e31e267
--- /dev/null
+++ b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/PartnerLoginService.java
@@ -0,0 +1,95 @@
+package com.ruoyi.auth.service;
+
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import com.ruoyi.common.core.constant.SecurityConstants;
+import com.ruoyi.common.core.domain.R;
+import com.ruoyi.common.core.enums.UserStatus;
+import com.ruoyi.common.core.exception.ServiceException;
+import com.ruoyi.common.core.utils.StringUtils;
+import com.ruoyi.common.security.utils.SecurityUtils;
+import com.ruoyi.system.api.RemoteAccountUserService;
+import com.ruoyi.system.api.domain.AccountMember;
+
+/**
+ * 服务商端小程序登录编排服务
+ *
+ * 服务商并非独立用户体系,而是「会员(account_user)的另一重身份」:
+ * 登录账号/密码直接复用 account_user.username / password(BCrypt),
+ * 并以 account_user.provider_flag = '1' 判定服务商身份。
+ *
+ * @author hugray
+ */
+@Service
+public class PartnerLoginService
+{
+ private static final Logger log = LoggerFactory.getLogger(PartnerLoginService.class);
+
+ /** 服务商标识:account_user.provider_flag = '1' */
+ private static final String PROVIDER_FLAG_YES = "1";
+
+ @Autowired
+ private RemoteAccountUserService remoteAccountUserService;
+
+ @Autowired
+ private AccountTokenIssuer accountTokenIssuer;
+
+ /**
+ * 服务商端账号密码登录
+ *
+ * @param username 登录账号
+ * @param password 登录密码(明文)
+ * @return 令牌信息(access_token、expires_in)
+ */
+ public Map<String, Object> partnerLogin(String username, String password)
+ {
+ // STEP 1: 按账号查询会员;不存在与密码错误统一提示,防账号枚举
+ AccountMember member = getMemberByUsername(username);
+ if (StringUtils.isNull(member))
+ {
+ throw new ServiceException("账号或密码错误");
+ }
+
+ // STEP 2: 强校验服务商身份(非服务商账号禁止登录服务商端)
+ if (!PROVIDER_FLAG_YES.equals(member.getProviderFlag()))
+ {
+ throw new ServiceException("该账号不是服务商账号");
+ }
+
+ // STEP 3: 校验账号状态(停用账号拒绝登录)
+ if (UserStatus.DISABLE.getCode().equals(member.getStatus()))
+ {
+ throw new ServiceException("账号已停用,请联系管理员");
+ }
+
+ // STEP 4: BCrypt 校验密码;账号无密码或密码不匹配同样按「账号或密码错误」处理
+ if (StringUtils.isEmpty(member.getPassword())
+ || !SecurityUtils.matchesPassword(password, member.getPassword()))
+ {
+ throw new ServiceException("账号或密码错误");
+ }
+
+ // STEP 5: 组装登录态并签发令牌
+ return accountTokenIssuer.issue(member, "partner account-login");
+ }
+
+ /**
+ * 按用户名查询会员,调用失败时抛出业务异常
+ *
+ * @param username 登录账号
+ * @return 会员信息(不存在时返回 null)
+ */
+ private AccountMember getMemberByUsername(String username)
+ {
+ R<AccountMember> memberResult = remoteAccountUserService.getMemberByUsername(username, SecurityConstants.INNER);
+ if (R.FAIL == memberResult.getCode())
+ {
+ log.error("[PARTNER_LOGIN] query member failed, username={}, msg={}", username, memberResult.getMsg());
+ throw new ServiceException("登录失败,请稍后重试");
+ }
+ return memberResult.getData();
+ }
+}
diff --git a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/PasswordResetService.java b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/PasswordResetService.java
new file mode 100644
index 0000000..53e5de1
--- /dev/null
+++ b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/PasswordResetService.java
@@ -0,0 +1,126 @@
+package com.ruoyi.auth.service;
+
+import java.util.regex.Pattern;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import com.ruoyi.auth.enums.SmsSceneEnum;
+import com.ruoyi.common.core.constant.SecurityConstants;
+import com.ruoyi.common.core.domain.R;
+import com.ruoyi.common.core.enums.UserStatus;
+import com.ruoyi.common.core.exception.ServiceException;
+import com.ruoyi.common.core.utils.StringUtils;
+import com.ruoyi.system.api.RemoteAccountUserService;
+import com.ruoyi.system.api.domain.AccountMember;
+import com.ruoyi.system.api.model.AccountResetPasswordRequest;
+
+/**
+ * 忘记密码(重置密码)编排服务
+ *
+ * 身份校验:登录手机号 + 短信验证码(scene=reset);校验通过后由 ruoyi-user 侧 BCrypt 加密落库。
+ *
+ * @author hugray
+ */
+@Service
+public class PasswordResetService
+{
+ private static final Logger log = LoggerFactory.getLogger(PasswordResetService.class);
+
+ /** 手机号正则 */
+ private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$");
+
+ @Autowired
+ private SmsCodeService smsCodeService;
+
+ @Autowired
+ private RemoteAccountUserService remoteAccountUserService;
+
+ /**
+ * 重置密码
+ *
+ * @param phone 手机号(账号标识)
+ * @param code 短信验证码
+ * @param password 新密码(明文)
+ */
+ public void resetPassword(String phone, String code, String password)
+ {
+ // STEP 1: 入参兜底校验(Controller 已校验,此处做服务层兜底)
+ if (StringUtils.isEmpty(phone) || !PHONE_PATTERN.matcher(phone).matches())
+ {
+ throw new ServiceException("请输入正确手机号");
+ }
+ if (StringUtils.isEmpty(code))
+ {
+ throw new ServiceException("请输入验证码");
+ }
+ if (StringUtils.isEmpty(password))
+ {
+ throw new ServiceException("请输入新密码");
+ }
+
+ // STEP 2: 先查询会员与状态,避免无谓消费验证码(与 phoneCodeLogin 口径一致)
+ AccountMember member = getRegisteredMember(phone);
+ checkMemberStatus(member);
+
+ // STEP 3: 校验并消费短信验证码(scene=reset,一次性)
+ smsCodeService.verifyAndConsume(phone, SmsSceneEnum.RESET.getCode(), code);
+
+ // STEP 4: 调用 ruoyi-user 内部接口落库(服务内 BCrypt 加密,只更新 password 一列)
+ AccountResetPasswordRequest request = new AccountResetPasswordRequest();
+ request.setPhone(phone);
+ request.setPassword(password);
+ R<Boolean> resetResult = remoteAccountUserService.resetPassword(request, SecurityConstants.INNER);
+ if (R.FAIL == resetResult.getCode())
+ {
+ throw new ServiceException(resetResult.getMsg());
+ }
+ log.info("[PASSWORD_RESET] reset success, phone={}", maskPhone(phone));
+ }
+
+ /**
+ * 按手机号查询会员,不存在时抛出「未注册」业务异常
+ *
+ * @param phone 手机号
+ * @return 会员信息
+ */
+ private AccountMember getRegisteredMember(String phone)
+ {
+ R<AccountMember> memberResult = remoteAccountUserService.getMemberByPhone(phone, SecurityConstants.INNER);
+ if (R.FAIL == memberResult.getCode())
+ {
+ throw new ServiceException(memberResult.getMsg());
+ }
+ AccountMember member = memberResult.getData();
+ if (StringUtils.isNull(member))
+ {
+ throw new ServiceException("该手机号未注册,请先注册");
+ }
+ return member;
+ }
+
+ /**
+ * 校验会员状态,停用账号拒绝重置密码
+ *
+ * @param member C端会员信息
+ */
+ private void checkMemberStatus(AccountMember member)
+ {
+ if (UserStatus.DISABLE.getCode().equals(member.getStatus()))
+ {
+ throw new ServiceException("账号已停用,请联系管理员");
+ }
+ }
+
+ /**
+ * 手机号脱敏,形如 138****0000
+ */
+ private String maskPhone(String phone)
+ {
+ if (StringUtils.isEmpty(phone) || phone.length() < 7)
+ {
+ return phone;
+ }
+ return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
+ }
+}
diff --git a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/SmsCodeService.java b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/SmsCodeService.java
index 233d89e..7aa07d0 100644
--- a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/SmsCodeService.java
+++ b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/SmsCodeService.java
@@ -55,7 +55,7 @@
* 下发短信验证码
*
* @param phone 手机号
- * @param scene 场景编码(register/reset/为空)
+ * @param scene 场景编码(register=注册 / login=登录 / reset=重置密码 / 其它=通用下发)
*/
public void sendCode(String phone, String scene)
{
@@ -67,12 +67,12 @@
SmsSceneEnum sceneEnum = SmsSceneEnum.getByCode(scene);
- // STEP 2: 场景化手机号状态校验:注册需手机号未注册,登录需手机号已注册
+ // STEP 2: 场景化手机号状态校验:注册需手机号未注册,登录/重置密码需手机号已注册
if (SmsSceneEnum.REGISTER == sceneEnum)
{
checkPhoneNotRegistered(phone);
}
- else if (SmsSceneEnum.LOGIN == sceneEnum)
+ else if (SmsSceneEnum.LOGIN == sceneEnum || SmsSceneEnum.RESET == sceneEnum)
{
checkPhoneRegistered(phone);
}
@@ -114,7 +114,7 @@
{
SmsSceneEnum sceneEnum = SmsSceneEnum.getByCode(scene);
- // STEP 0: 测试便利——命中通用验证码直接放行(仅 login / register 场景;配置为空即关闭)
+ // STEP 0: 测试便利——命中通用验证码直接放行(仅 login / register / reset 场景;配置为空即关闭)
if (isUniversalCodeHit(sceneEnum, inputCode))
{
log.warn("[SMS_VERIFY] universal code hit, phone={}, scene={}", maskPhone(phone), sceneEnum.getCode());
@@ -146,7 +146,7 @@
/**
* 判定是否命中通用验证码(测试便利)
*
- * 仅 login / register 场景生效;命中即直接放行,不读缓存、不计数、不消费。
+ * 仅 login / register / reset 场景生效;命中即直接放行,不读缓存、不计数、不消费。
*
* @param sceneEnum 场景枚举
* @param inputCode 用户输入的验证码
@@ -160,8 +160,8 @@
{
return false;
}
- // 仅登录 / 注册场景放行,避免重置密码等场景被通用码绕过
- if (SmsSceneEnum.LOGIN != sceneEnum && SmsSceneEnum.REGISTER != sceneEnum)
+ // 仅登录 / 注册 / 重置密码场景放行,避免其它场景被通用码绕过
+ if (SmsSceneEnum.LOGIN != sceneEnum && SmsSceneEnum.REGISTER != sceneEnum && SmsSceneEnum.RESET != sceneEnum)
{
return false;
}
diff --git a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/WeChatMiniappService.java b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/WeChatMiniappService.java
index 6e84452..43de14e 100644
--- a/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/WeChatMiniappService.java
+++ b/hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/WeChatMiniappService.java
@@ -4,7 +4,9 @@
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
import java.time.Duration;
+import java.util.Base64;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -47,6 +49,21 @@
/** 换取失败统一提示 */
private static final String PHONE_FAIL_MESSAGE = "微信手机号获取失败,请重试";
+
+ /** 获取不限量小程序码路径 */
+ private static final String QRCODE_PATH = "/wxa/getwxacodeunlimit";
+
+ /** 小程序码扫码直达页面 */
+ private static final String QRCODE_PAGE = "pages/login/register";
+
+ /** 小程序码图片宽度(px) */
+ private static final int QRCODE_WIDTH = 430;
+
+ /** 小程序码缓存时长(秒),30 天,避免重复消耗微信 getUnlimited 日配额 */
+ private static final long QRCODE_CACHE_SECONDS = 30L * 24 * 60 * 60;
+
+ /** 小程序码生成失败统一提示 */
+ private static final String QRCODE_FAIL_MESSAGE = "邀请码二维码生成失败,请重试";
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
@@ -91,6 +108,97 @@
throw new ServiceException(PHONE_FAIL_MESSAGE);
}
return phoneNumber;
+ }
+
+ /**
+ * 生成不限量小程序码(wxacode.getUnlimited)
+ *
+ * 以邀请码作为 scene,扫码直达注册页并由前端自动回填邀请码。
+ * 结果按 scene 缓存 30 天,命中缓存直接返回,避免重复消耗微信接口日配额。
+ *
+ * @param scene 场景值,即邀请码(8 位 A-Z0-9)
+ * @return PNG 图片的 base64 DataURI(data:image/png;base64,...)
+ */
+ public String getUnlimitedQrcode(String scene)
+ {
+ // STEP 1: 命中缓存直接返回
+ String cacheKey = CacheConstants.WECHAT_QRCODE_KEY + scene;
+ String cached = redisService.getCacheObject(cacheKey);
+ if (StringUtils.isNotEmpty(cached))
+ {
+ return cached;
+ }
+
+ // STEP 2: 校验配置是否完整
+ String appid = weChatMiniappProperties.getAppid();
+ String secret = weChatMiniappProperties.getSecret();
+ if (StringUtils.isEmpty(appid) || StringUtils.isEmpty(secret))
+ {
+ log.error("[WECHAT] miniapp appid/secret 未配置");
+ throw new ServiceException(QRCODE_FAIL_MESSAGE);
+ }
+
+ // STEP 3: 获取全局 access_token
+ String accessToken = getAccessToken(appid, secret);
+
+ // STEP 4: 调用微信生成小程序码(scene 为邀请码,已由入参正则限定为 A-Z0-9,可安全拼接)
+ String url = WECHAT_API_HOST + QRCODE_PATH + "?access_token=" + accessToken;
+ String requestBody = "{\"scene\":\"" + scene + "\",\"page\":\"" + QRCODE_PAGE
+ + "\",\"check_path\":false,\"env_version\":\"release\",\"width\":" + QRCODE_WIDTH + "}";
+ HttpResponse<byte[]> response = doPostForBytes(url, requestBody);
+
+ // STEP 5: 按响应类型解析(图片转 base64,JSON 则说明失败)
+ String dataUri = parseQrcodeResponse(response, scene);
+
+ // STEP 6: 缓存生成结果并返回
+ redisService.setCacheObject(cacheKey, dataUri, QRCODE_CACHE_SECONDS, TimeUnit.SECONDS);
+ return dataUri;
+ }
+
+ /**
+ * 解析小程序码响应
+ *
+ * 微信成功返回 PNG 字节流(Content-Type: image/*),失败返回 JSON(含 errcode/errmsg)。
+ *
+ * @param response 微信响应
+ * @param scene 邀请码(仅用于日志)
+ * @return PNG 的 base64 DataURI
+ */
+ private String parseQrcodeResponse(HttpResponse<byte[]> response, String scene)
+ {
+ String contentType = response.headers().firstValue("Content-Type").orElse("");
+ byte[] body = response.body();
+
+ // 图片响应:PNG 二进制转 base64 DataURI
+ if (contentType.startsWith("image/"))
+ {
+ if (body == null || body.length == 0)
+ {
+ log.error("[WECHAT] getUnlimitedQrcode empty image, scene={}", scene);
+ throw new ServiceException(QRCODE_FAIL_MESSAGE);
+ }
+ return "data:image/png;base64," + Base64.getEncoder().encodeToString(body);
+ }
+
+ // 非图片响应:微信以 JSON 返回错误码,解析并记日志
+ Integer errcode = null;
+ String errmsg = null;
+ try
+ {
+ String text = body == null ? null : new String(body, StandardCharsets.UTF_8);
+ JSONObject json = JSON.parseObject(text);
+ if (StringUtils.isNotNull(json))
+ {
+ errcode = json.getInteger("errcode");
+ errmsg = json.getString("errmsg");
+ }
+ }
+ catch (Exception e)
+ {
+ log.error("[WECHAT] getUnlimitedQrcode parse error, scene={}", scene, e);
+ }
+ log.error("[WECHAT] getUnlimitedQrcode failed, scene={}, errcode={}, errmsg={}", scene, errcode, errmsg);
+ throw new ServiceException(QRCODE_FAIL_MESSAGE);
}
/**
@@ -177,4 +285,36 @@
throw new ServiceException(PHONE_FAIL_MESSAGE);
}
}
+
+ /**
+ * 发送 HTTP POST 请求并按字节数组接收响应(用于小程序码等二进制接口)
+ *
+ * @param url 请求地址
+ * @param body 请求体 JSON,可为空
+ * @return 原始响应(含状态头与字节体)
+ */
+ private HttpResponse<byte[]> doPostForBytes(String url, String body)
+ {
+ try
+ {
+ HttpRequest.Builder builder = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .timeout(Duration.ofSeconds(WECHAT_REQUEST_TIMEOUT_SECONDS))
+ .header("Content-Type", "application/json");
+ if (StringUtils.isEmpty(body))
+ {
+ builder.POST(HttpRequest.BodyPublishers.noBody());
+ }
+ else
+ {
+ builder.POST(HttpRequest.BodyPublishers.ofString(body));
+ }
+ return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray());
+ }
+ catch (Exception e)
+ {
+ log.error("[WECHAT] request failed, url={}", url, e);
+ throw new ServiceException(QRCODE_FAIL_MESSAGE);
+ }
+ }
}
diff --git a/hugray-server/ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/constant/CacheConstants.java b/hugray-server/ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/constant/CacheConstants.java
index 2d99829..1493d8d 100644
--- a/hugray-server/ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/constant/CacheConstants.java
+++ b/hugray-server/ruoyi-common/ruoyi-common-core/src/main/java/com/ruoyi/common/core/constant/CacheConstants.java
@@ -81,4 +81,9 @@
* 微信小程序全局 access_token redis key
*/
public static final String WECHAT_ACCESS_TOKEN_KEY = "wechat_miniapp_token:";
+
+ /**
+ * 微信小程序码 redis key(按 scene 缓存生成的 base64 图片)
+ */
+ public static final String WECHAT_QRCODE_KEY = "wechat_miniapp_qrcode:";
}
diff --git a/hugray-server/ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/controller/SysContactInfoController.java b/hugray-server/ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/controller/SysContactInfoController.java
new file mode 100644
index 0000000..541a8dc
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/controller/SysContactInfoController.java
@@ -0,0 +1,52 @@
+package com.ruoyi.system.controller;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.ruoyi.common.core.domain.R;
+import com.ruoyi.system.domain.SysPlatformSetting;
+import com.ruoyi.system.domain.vo.ContactInfoVo;
+import com.ruoyi.system.service.ISysPlatformSettingService;
+
+/**
+ * C端「联系我们」联系方式 查询接口
+ *
+ * 数据源自平台设置单行表 sys_platform_setting(由管理后台「平台设置」维护),
+ * 此处只读暴露其中的联系方式(招商热线 / 合伙人招募)供小程序「联系我们」页回填。
+ *
+ * 网关路由 /miniapp/system/** 经 StripPrefix=2 剥掉前两段后转发到本服务,
+ * 故外部实际路径为 /miniapp/system/contact-info;与后台 /system/platform 不冲突。
+ *
+ * 未配置权限注解:这是 C 端只读接口,由网关统一鉴权,不走后台菜单权限体系。
+ *
+ * @author ruoyi
+ */
+@RestController
+@RequestMapping("/contact-info")
+public class SysContactInfoController
+{
+ @Autowired
+ private ISysPlatformSettingService platformSettingService;
+
+ /**
+ * 查询联系方式(招商热线 / 合伙人招募)
+ *
+ * 平台设置从未保存过时(表无行)返回两个字段均为 null 的对象,
+ * 由小程序端回退到默认文案,不做 500 处理。
+ *
+ * @return 联系方式(cityHotline / partnerRecruit)
+ */
+ @GetMapping
+ public R<ContactInfoVo> getContactInfo()
+ {
+ ContactInfoVo vo = new ContactInfoVo();
+ SysPlatformSetting setting = platformSettingService.selectPlatformSetting();
+ if (setting != null)
+ {
+ vo.setCityHotline(setting.getCityHotline());
+ vo.setPartnerRecruit(setting.getPartnerRecruit());
+ }
+ return R.ok(vo);
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ContactInfoVo.java b/hugray-server/ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ContactInfoVo.java
new file mode 100644
index 0000000..312b339
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ContactInfoVo.java
@@ -0,0 +1,42 @@
+package com.ruoyi.system.domain.vo;
+
+import java.io.Serializable;
+
+/**
+ * C端「联系我们」联系方式 出参
+ *
+ * 只暴露联系方式两项,不返回平台设置中的其它内部参数(如订单取消时长、售后天数等),
+ * 避免把仅后端使用的配置泄露给小程序端。
+ *
+ * @author ruoyi
+ */
+public class ContactInfoVo implements Serializable
+{
+ private static final long serialVersionUID = 1L;
+
+ /** 城市招商热线(取自平台设置 city_hotline;未配置为 null) */
+ private String cityHotline;
+
+ /** 合伙人招募(取自平台设置 partner_recruit;未配置为 null) */
+ private String partnerRecruit;
+
+ public String getCityHotline()
+ {
+ return cityHotline;
+ }
+
+ public void setCityHotline(String cityHotline)
+ {
+ this.cityHotline = cityHotline;
+ }
+
+ public String getPartnerRecruit()
+ {
+ return partnerRecruit;
+ }
+
+ public void setPartnerRecruit(String partnerRecruit)
+ {
+ this.partnerRecruit = partnerRecruit;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountMemberController.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountMemberController.java
new file mode 100644
index 0000000..4ef0cc9
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountMemberController.java
@@ -0,0 +1,110 @@
+package com.ruoyi.user.controller;
+
+import java.util.List;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.ruoyi.common.core.domain.R;
+import com.ruoyi.common.core.web.controller.BaseController;
+import com.ruoyi.common.core.web.page.TableDataInfo;
+import com.ruoyi.common.security.utils.SecurityUtils;
+import com.ruoyi.user.domain.dto.MemberPurchaseDto;
+import com.ruoyi.user.domain.vo.MemberSalesStatisticsVo;
+import com.ruoyi.user.domain.vo.MemberWarehouseVo;
+import com.ruoyi.user.domain.vo.PurchaseOrderVo;
+import com.ruoyi.user.domain.vo.WarehouseRecordVo;
+import com.ruoyi.user.service.IAccountMemberCenterService;
+
+/**
+ * C端会员 个人中心-会员中心接口
+ *
+ * 网关前缀 /miniapp/account 由 StripPrefix=2 剥掉,故此处映射为 /user/member,
+ * 外部实际路径为 /miniapp/account/user/member/**。
+ *
+ * 会员身份取自网关注入的登录态(SecurityUtils.getUserId),不接受前端传 userId,
+ * 天然防止水平越权;未登录请求由网关直接以 401 拦截,故此处不处理未登录分支。
+ *
+ * @author hugray
+ */
+@RestController
+@RequestMapping("/user/member")
+public class AccountMemberController extends BaseController
+{
+ @Autowired
+ private IAccountMemberCenterService accountMemberCenterService;
+
+ /**
+ * 查询当前登录会员的销售统计
+ *
+ * 返回当月销售额 / 进货额 / 利润(单位分)、利润环比、销售额趋势、商品销售额比例与销售用户排行。
+ *
+ * @return 销售统计聚合对象
+ */
+ @GetMapping("/sales-statistics")
+ public R<MemberSalesStatisticsVo> salesStatistics()
+ {
+ return R.ok(accountMemberCenterService.getSalesStatistics(SecurityUtils.getUserId()));
+ }
+
+ /**
+ * 查询当前登录会员的云仓列表
+ *
+ * 含当前库存、最低需求库存与低库存标记;商品图片本期为空,由前端使用占位图。
+ *
+ * @return 云仓列表
+ */
+ @GetMapping("/warehouses")
+ public R<List<MemberWarehouseVo>> warehouses()
+ {
+ return R.ok(accountMemberCenterService.getWarehouseList(SecurityUtils.getUserId()));
+ }
+
+ /**
+ * 进货:生成进货单并增加对应云仓库存
+ *
+ * 云仓归属由服务端校验(非本会员商品拒绝),库存原子自增,同一事务内追加变动流水。
+ *
+ * @param dto 进货入参(warehouseId、qty 必填)
+ * @return 操作结果
+ */
+ @PostMapping("/purchase")
+ public R<Void> purchase(@Validated @RequestBody MemberPurchaseDto dto)
+ {
+ accountMemberCenterService.purchase(SecurityUtils.getUserId(), dto);
+ return R.ok();
+ }
+
+ /**
+ * 分页查询当前登录会员的云仓变动记录
+ *
+ * 分页参数 pageNum 默认 1、pageSize 默认 10,按变动时间倒序。
+ *
+ * @return 分页数据(rows 为变动记录,total 为总数)
+ */
+ @GetMapping("/warehouse-records")
+ public TableDataInfo warehouseRecords()
+ {
+ startPage();
+ List<WarehouseRecordVo> list = accountMemberCenterService.getWarehouseRecordList(SecurityUtils.getUserId());
+ return getDataTable(list);
+ }
+
+ /**
+ * 分页查询当前登录会员的进货订单
+ *
+ * 分页参数 pageNum 默认 1、pageSize 默认 10,按下单时间倒序。
+ *
+ * @return 分页数据(rows 为进货订单,total 为总数)
+ */
+ @GetMapping("/purchase-orders")
+ public TableDataInfo purchaseOrders()
+ {
+ startPage();
+ List<PurchaseOrderVo> list = accountMemberCenterService.getPurchaseOrderList(SecurityUtils.getUserId());
+ return getDataTable(list);
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountMemberInnerController.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountMemberInnerController.java
index 080e3be..8744062 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountMemberInnerController.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountMemberInnerController.java
@@ -13,6 +13,7 @@
import com.ruoyi.common.security.annotation.InnerAuth;
import com.ruoyi.system.api.domain.AccountMember;
import com.ruoyi.system.api.model.AccountRegisterRequest;
+import com.ruoyi.system.api.model.AccountResetPasswordRequest;
import com.ruoyi.user.domain.AccountUser;
import com.ruoyi.user.service.IAccountUserService;
@@ -50,6 +51,26 @@
}
/**
+ * 按用户名查询会员(服务商端账号密码登录使用)
+ *
+ * 返回结果含 password 密文与 providerFlag,供认证服务校验密码与服务商身份。
+ *
+ * @param userName 用户名
+ * @return 会员信息(不存在时 data 为空)
+ */
+ @InnerAuth
+ @GetMapping("/info/username/{userName}")
+ public R<AccountMember> infoByUsername(@PathVariable("userName") String userName)
+ {
+ AccountUser accountUser = accountUserService.selectAccountUserByUsername(userName);
+ if (StringUtils.isNull(accountUser))
+ {
+ return R.ok();
+ }
+ return R.ok(toAccountMember(accountUser));
+ }
+
+ /**
* 注册C端会员
*
* @param request 注册请求(手机号、推荐码)
@@ -64,7 +85,27 @@
}
/**
+ * 重置C端会员密码(忘记密码)
+ *
+ * 认证服务完成手机号、账号状态与验证码校验后调用;此处 BCrypt 加密新密码并只更新 password 一列。
+ *
+ * @param request 重置请求(手机号、新密码)
+ * @return 处理结果
+ */
+ @InnerAuth
+ @PostMapping("/password/reset")
+ public R<Boolean> resetPassword(@Validated @RequestBody AccountResetPasswordRequest request)
+ {
+ accountUserService.resetPasswordByPhone(request.getPhone(), request.getPassword());
+ return R.ok(Boolean.TRUE);
+ }
+
+ /**
* 会员实体转换为跨服务契约对象
+ *
+ * 一并回传 password 密文与 providerFlag:前者供认证服务核对密码(沿用 RuoYi
+ * 登录时下发密文、由 auth 侧 matchesPassword 的范式),后者供其判定服务商身份。
+ * 该对象仅经 @InnerAuth 内部接口流转,不对 C 端下发。
*/
private AccountMember toAccountMember(AccountUser accountUser)
{
@@ -77,6 +118,8 @@
member.setMyReferralCode(accountUser.getMyReferralCode());
member.setReferrerId(accountUser.getReferrerId());
member.setStatus(accountUser.getStatus());
+ member.setProviderFlag(accountUser.getProviderFlag());
+ member.setPassword(accountUser.getPassword());
return member;
}
}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountPartnerController.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountPartnerController.java
new file mode 100644
index 0000000..47b1bee
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountPartnerController.java
@@ -0,0 +1,92 @@
+package com.ruoyi.user.controller;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import com.ruoyi.common.core.domain.R;
+import com.ruoyi.common.core.web.controller.BaseController;
+import com.ruoyi.common.security.utils.SecurityUtils;
+import com.ruoyi.user.domain.dto.SubAccountCreateDto;
+import com.ruoyi.user.domain.vo.PartnerOverviewVo;
+import com.ruoyi.user.domain.vo.PartnerSubAccountPageVo;
+import com.ruoyi.user.domain.vo.SubAccountQuotaVo;
+import com.ruoyi.user.service.IAccountPartnerService;
+
+/**
+ * 服务商端 首页概览 + 子账号管理接口
+ *
+ * 网关前缀 /miniapp/account 由 StripPrefix=2 剥掉,故此处映射为 /partner,
+ * 外部实际路径为 /miniapp/account/partner/...。
+ *
+ * 服务商身份取自网关注入的登录态(SecurityUtils.getUserId),不接受前端传 userId,
+ * 天然防止水平越权;未登录请求由网关直接以 401 拦截,故此处不处理未登录分支。
+ *
+ * @author hugray
+ */
+@RestController
+@RequestMapping("/partner")
+public class AccountPartnerController extends BaseController
+{
+ @Autowired
+ private IAccountPartnerService accountPartnerService;
+
+ /**
+ * 服务商首页概览
+ *
+ * 返回公司名、开通子账号数(总数 + L1/L2/L3 分布)与子账号业绩合计
+ * (当月收益、当月新增绑定、当月销售额/单数、当月进货额、当月新邀请分布)。
+ *
+ * @return 首页概览聚合对象
+ */
+ @GetMapping("/overview")
+ public R<PartnerOverviewVo> overview()
+ {
+ return R.ok(accountPartnerService.getOverview(SecurityUtils.getUserId()));
+ }
+
+ /**
+ * 分页查询名下子账号列表
+ *
+ * 分页参数 pageNum/pageSize 由 startPage() 从请求中读取;level 为空或非 L1/L2/L3 时返回全部;
+ * keyword 为空时不搜索,非空时按昵称/手机号模糊匹配,可与 level 叠加。
+ *
+ * @param level 等级筛选(可空;L1/L2/L3)
+ * @param keyword 昵称/手机号搜索关键字(可空)
+ * @return 分页结果(list/total/pageNum/pageSize)
+ */
+ @GetMapping("/subAccount/list")
+ public R<PartnerSubAccountPageVo> subAccountList(@RequestParam(required = false) String level,
+ @RequestParam(required = false) String keyword)
+ {
+ startPage();
+ return R.ok(accountPartnerService.listSubAccounts(SecurityUtils.getUserId(), level, keyword));
+ }
+
+ /**
+ * 查询各等级剩余可开通额度(新增账号页顶部提示)
+ *
+ * @return L1/L2/L3 剩余额度
+ */
+ @GetMapping("/subAccount/quota")
+ public R<SubAccountQuotaVo> subAccountQuota()
+ {
+ return R.ok(accountPartnerService.getSubAccountQuota(SecurityUtils.getUserId()));
+ }
+
+ /**
+ * 新增子账号
+ *
+ * @param dto 新增入参(等级 + 手机号)
+ * @return 新子账号的会员ID
+ */
+ @PostMapping("/subAccount")
+ public R<Long> createSubAccount(@Validated @RequestBody SubAccountCreateDto dto)
+ {
+ return R.ok(accountPartnerService.createSubAccount(SecurityUtils.getUserId(), dto));
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountProfileController.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountProfileController.java
index c2f4ea8..9257e08 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountProfileController.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountProfileController.java
@@ -1,12 +1,35 @@
package com.ruoyi.user.controller;
+import java.util.ArrayList;
+import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.core.domain.R;
+import com.ruoyi.common.core.exception.ServiceException;
+import com.ruoyi.common.core.web.controller.BaseController;
+import com.ruoyi.common.core.web.page.TableDataInfo;
import com.ruoyi.common.security.utils.SecurityUtils;
+import com.ruoyi.user.domain.AccountAddress;
+import com.ruoyi.user.domain.dto.AccountAddressDto;
+import com.ruoyi.user.domain.dto.AccountCertificationApplyDto;
+import com.ruoyi.user.domain.dto.AccountUserAvatarDto;
+import com.ruoyi.user.domain.dto.CompanyCertificationApplyDto;
+import com.ruoyi.user.domain.vo.AccountAddressVo;
+import com.ruoyi.user.domain.vo.AccountCertificationStatusVo;
+import com.ruoyi.user.domain.vo.AccountInviteeVo;
+import com.ruoyi.user.domain.vo.AccountReferrerVo;
import com.ruoyi.user.domain.vo.AccountUserInfoVo;
+import com.ruoyi.user.domain.vo.CompanyCertificationStatusVo;
+import com.ruoyi.user.domain.vo.InviteCodeVo;
+import com.ruoyi.user.service.IAccountCertificationApplyService;
import com.ruoyi.user.service.IAccountUserService;
/**
@@ -22,10 +45,13 @@
*/
@RestController
@RequestMapping("/user")
-public class AccountProfileController
+public class AccountProfileController extends BaseController
{
@Autowired
private IAccountUserService accountUserService;
+
+ @Autowired
+ private IAccountCertificationApplyService accountCertificationApplyService;
/**
* 查询当前登录会员的个人中心信息
@@ -39,4 +65,250 @@
{
return R.ok(accountUserService.getAccountUserInfo(SecurityUtils.getUserId()));
}
+
+ /**
+ * 更新当前登录会员头像
+ *
+ * 头像文件由小程序直传文件服务(POST /file/upload)后,将返回的 url 通过本接口写回;
+ * 会员身份取自登录态,不接受前端传入 userId。
+ *
+ * @param dto 头像更新入参(仅含 avatar)
+ * @return 操作结果
+ */
+ @PostMapping("/avatar")
+ public R<Void> updateAvatar(@Validated @RequestBody AccountUserAvatarDto dto)
+ {
+ accountUserService.updateAvatar(SecurityUtils.getUserId(), dto.getAvatar());
+ return R.ok();
+ }
+
+ /**
+ * 查询当前登录会员的实名认证状态
+ *
+ * 供实名认证页进入时回填:姓名、身份证号、审核状态(0待审核/1已通过/2已驳回)与驳回原因。
+ * 从未提交过申请时返回各字段为空的对象,前端按「未认证」渲染空表单。
+ * 会员身份取自登录态,不接受前端传入 userId。
+ *
+ * @return 认证状态(realName / idCard / auditStatus / auditRemark)
+ */
+ @GetMapping("/certification")
+ public R<AccountCertificationStatusVo> certification()
+ {
+ return R.ok(accountCertificationApplyService.getCertificationStatus(SecurityUtils.getUserId()));
+ }
+
+ /**
+ * 提交实名认证(个人主体)
+ *
+ * 只做基础的姓名必填与身份证号格式校验(@Validated 拦下非法入参),不做三方实名核验;
+ * 提交后落一条待审核的认证申请,出现在管理后台「主体认证审核」列表。
+ * 会员身份取自登录态,不接受前端传入 userId;已有待审核申请时拒绝重复提交。
+ *
+ * @param dto 认证申请入参(仅含 realName、idCard)
+ * @return 操作结果
+ */
+ @PostMapping("/certification")
+ public R<Void> submitCertification(@Validated @RequestBody AccountCertificationApplyDto dto)
+ {
+ accountCertificationApplyService.submitPersonalCertification(
+ SecurityUtils.getUserId(), dto.getRealName(), dto.getIdCard());
+ return R.ok();
+ }
+
+ /**
+ * 查询当前登录会员的企业/个体认证状态
+ *
+ * 供企业认证向导页进入时回填:整份企业资料 + 审核状态(0待审核/1已通过/2已驳回)与驳回原因。
+ * 从未提交过申请时返回各字段为空的对象,前端按「未认证」渲染空表单。
+ * 会员身份取自登录态,不接受前端传入 userId。
+ *
+ * @return 企业/个体认证状态(整份资料 + auditStatus / auditRemark)
+ */
+ @GetMapping("/company-certification")
+ public R<CompanyCertificationStatusVo> companyCertification()
+ {
+ return R.ok(accountCertificationApplyService.getCompanyCertificationStatus(SecurityUtils.getUserId()));
+ }
+
+ /**
+ * 提交企业/个体工商户认证(主体类型 2/3)
+ *
+ * 只做最基础的必填校验(@Validated 拦下空值与非法枚举),不做格式与三方核验,真实性由后台人工审核把关;
+ * 提交后落一条待审核的认证申请,出现在管理后台「主体认证审核」列表。
+ * 会员身份取自登录态,不接受前端传入 userId;已有待审核的企业申请时拒绝重复提交。
+ *
+ * @param dto 企业认证申请入参
+ * @return 操作结果
+ */
+ @PostMapping("/company-certification")
+ public R<Void> submitCompanyCertification(@Validated @RequestBody CompanyCertificationApplyDto dto)
+ {
+ accountCertificationApplyService.submitCompanyCertification(SecurityUtils.getUserId(), dto);
+ return R.ok();
+ }
+
+ /**
+ * 查询本人邀请码
+ *
+ * 供「我的邀请」页「邀请码」Tab 展示;会员身份取自登录态,不接受前端传入 userId。
+ *
+ * @return 邀请码展示对象
+ */
+ @GetMapping("/invite-code")
+ public R<InviteCodeVo> inviteCode()
+ {
+ return R.ok(accountUserService.getInviteCode(SecurityUtils.getUserId()));
+ }
+
+ /**
+ * 查询本人直接下级(被邀请人)列表
+ *
+ * 分页返回(pageNum 默认 1、pageSize 默认 10),按绑定时间倒序;
+ * 会员身份取自登录态,不接受前端传入 userId。
+ *
+ * @return 分页数据(rows 为 AccountInviteeVo 列表,total 为总数)
+ */
+ @GetMapping("/invitees")
+ public TableDataInfo invitees()
+ {
+ startPage();
+ List<AccountInviteeVo> list = accountUserService.getInvitees(SecurityUtils.getUserId());
+ return getDataTable(list);
+ }
+
+ /**
+ * 查询本人上级(推荐人)信息
+ *
+ * 供「我的邀请」页「邀请我的」Tab 展示;无上级时返回各字段为 null 的对象;
+ * 会员身份取自登录态,不接受前端传入 userId。
+ *
+ * @return 上级展示对象
+ */
+ @GetMapping("/referrer")
+ public R<AccountReferrerVo> referrer()
+ {
+ return R.ok(accountUserService.getReferrer(SecurityUtils.getUserId()));
+ }
+
+ /**
+ * 查询本人收货地址列表
+ *
+ * 默认地址排前,其余按 id 升序;地址数量少,不分页。
+ * 会员身份取自登录态,不接受前端传入 userId。
+ *
+ * @return 地址列表(无地址返回空集合)
+ */
+ @GetMapping("/addresses")
+ public R<List<AccountAddressVo>> addressList()
+ {
+ List<AccountAddress> list = accountUserService.getAddressList(SecurityUtils.getUserId());
+ return R.ok(toAddressVoList(list));
+ }
+
+ /**
+ * 查询本人单条收货地址详情
+ *
+ * 供编辑页回填;校验归属,非本人地址抛业务异常。
+ * 会员身份取自登录态,不接受前端传入 userId。
+ *
+ * @param id 地址ID
+ * @return 地址详情
+ */
+ @GetMapping("/addresses/{id}")
+ public R<AccountAddressVo> addressDetail(@PathVariable("id") Long id)
+ {
+ AccountAddress address = accountUserService.getAddressDetail(SecurityUtils.getUserId(), id);
+ return R.ok(toAddressVo(address));
+ }
+
+ /**
+ * 新增本人收货地址
+ *
+ * 首条地址自动设为默认;会员身份取自登录态,不接受前端传入 userId。
+ *
+ * @param dto 地址入参
+ * @return 操作结果
+ */
+ @PostMapping("/addresses")
+ public R<Void> createAddress(@Validated @RequestBody AccountAddressDto dto)
+ {
+ accountUserService.insertMyAddress(buildAddress(dto, SecurityUtils.getUserId()));
+ return R.ok();
+ }
+
+ /**
+ * 编辑本人收货地址
+ *
+ * 复用后台改地址事务逻辑(含默认唯一约束);会员身份取自登录态。
+ *
+ * @param dto 地址入参(必含 id)
+ * @return 操作结果
+ */
+ @PutMapping("/addresses")
+ public R<Void> updateAddress(@Validated @RequestBody AccountAddressDto dto)
+ {
+ if (dto.getId() == null)
+ {
+ throw new ServiceException("地址ID不能为空");
+ }
+ accountUserService.updateAccountAddress(buildAddress(dto, SecurityUtils.getUserId()));
+ return R.ok();
+ }
+
+ /**
+ * 删除本人收货地址
+ *
+ * 校验归属后逻辑删除;会员身份取自登录态,不接受前端传入 userId。
+ *
+ * @param id 地址ID
+ * @return 操作结果
+ */
+ @DeleteMapping("/addresses/{id}")
+ public R<Void> deleteAddress(@PathVariable("id") Long id)
+ {
+ accountUserService.deleteMyAddress(SecurityUtils.getUserId(), id);
+ return R.ok();
+ }
+
+ /**
+ * DTO 转实体(userId 由登录态注入,不接受前端传值)
+ */
+ private AccountAddress buildAddress(AccountAddressDto dto, Long userId)
+ {
+ AccountAddress address = new AccountAddress();
+ address.setId(dto.getId());
+ address.setUserId(userId);
+ address.setConsignee(dto.getConsignee());
+ address.setConsigneePhone(dto.getConsigneePhone());
+ address.setAddress(dto.getAddress());
+ address.setIsDefault(dto.getIsDefault());
+ return address;
+ }
+
+ /**
+ * 实体转出参 VO(列表)
+ */
+ private List<AccountAddressVo> toAddressVoList(List<AccountAddress> list)
+ {
+ List<AccountAddressVo> voList = new ArrayList<>(list.size());
+ for (AccountAddress address : list)
+ {
+ voList.add(toAddressVo(address));
+ }
+ return voList;
+ }
+
+ /**
+ * 实体转出参 VO(单条)
+ */
+ private AccountAddressVo toAddressVo(AccountAddress address)
+ {
+ AccountAddressVo vo = new AccountAddressVo();
+ vo.setId(address.getId());
+ vo.setConsignee(address.getConsignee());
+ vo.setConsigneePhone(address.getConsigneePhone());
+ vo.setAddress(address.getAddress());
+ vo.setIsDefault(address.getIsDefault());
+ return vo;
+ }
}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertification.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertification.java
index d999530..1e53002 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertification.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertification.java
@@ -52,6 +52,45 @@
/** 营业执照图片地址 */
private String licenseImage;
+ /** 营业执照有效期类型(1长期 2短期;长期时 licenseValidPeriod 为空) */
+ private String licenseValidType;
+
+ /** 法人身份证人像面图片 */
+ private String legalIdCardFront;
+
+ /** 法人身份证国徽面图片 */
+ private String legalIdCardBack;
+
+ /** 法人身份证有效期类型(1长期 2短期) */
+ private String legalIdValidType;
+
+ /** 法人身份证有效期(存文本,如 2024-01-01~2034-01-01) */
+ private String legalIdValidPeriod;
+
+ /** 法人身份证详细地址 */
+ private String legalIdAddress;
+
+ /** 负责人姓名 */
+ private String contactName;
+
+ /** 负责人联系电话 */
+ private String contactPhone;
+
+ /** 负责人邮箱 */
+ private String contactEmail;
+
+ /** 收款类型 */
+ private String payeeType;
+
+ /** 银行卡号码面照片 */
+ private String bankCardImage;
+
+ /** 银行卡号 */
+ private String bankCardNo;
+
+ /** 开户城市 */
+ private String bankCity;
+
/** 删除标志(0存在 2删除) */
private String delFlag;
@@ -175,6 +214,136 @@
this.licenseImage = licenseImage;
}
+ public String getLicenseValidType()
+ {
+ return licenseValidType;
+ }
+
+ public void setLicenseValidType(String licenseValidType)
+ {
+ this.licenseValidType = licenseValidType;
+ }
+
+ public String getLegalIdCardFront()
+ {
+ return legalIdCardFront;
+ }
+
+ public void setLegalIdCardFront(String legalIdCardFront)
+ {
+ this.legalIdCardFront = legalIdCardFront;
+ }
+
+ public String getLegalIdCardBack()
+ {
+ return legalIdCardBack;
+ }
+
+ public void setLegalIdCardBack(String legalIdCardBack)
+ {
+ this.legalIdCardBack = legalIdCardBack;
+ }
+
+ public String getLegalIdValidType()
+ {
+ return legalIdValidType;
+ }
+
+ public void setLegalIdValidType(String legalIdValidType)
+ {
+ this.legalIdValidType = legalIdValidType;
+ }
+
+ public String getLegalIdValidPeriod()
+ {
+ return legalIdValidPeriod;
+ }
+
+ public void setLegalIdValidPeriod(String legalIdValidPeriod)
+ {
+ this.legalIdValidPeriod = legalIdValidPeriod;
+ }
+
+ public String getLegalIdAddress()
+ {
+ return legalIdAddress;
+ }
+
+ public void setLegalIdAddress(String legalIdAddress)
+ {
+ this.legalIdAddress = legalIdAddress;
+ }
+
+ public String getContactName()
+ {
+ return contactName;
+ }
+
+ public void setContactName(String contactName)
+ {
+ this.contactName = contactName;
+ }
+
+ public String getContactPhone()
+ {
+ return contactPhone;
+ }
+
+ public void setContactPhone(String contactPhone)
+ {
+ this.contactPhone = contactPhone;
+ }
+
+ public String getContactEmail()
+ {
+ return contactEmail;
+ }
+
+ public void setContactEmail(String contactEmail)
+ {
+ this.contactEmail = contactEmail;
+ }
+
+ public String getPayeeType()
+ {
+ return payeeType;
+ }
+
+ public void setPayeeType(String payeeType)
+ {
+ this.payeeType = payeeType;
+ }
+
+ public String getBankCardImage()
+ {
+ return bankCardImage;
+ }
+
+ public void setBankCardImage(String bankCardImage)
+ {
+ this.bankCardImage = bankCardImage;
+ }
+
+ public String getBankCardNo()
+ {
+ return bankCardNo;
+ }
+
+ public void setBankCardNo(String bankCardNo)
+ {
+ this.bankCardNo = bankCardNo;
+ }
+
+ public String getBankCity()
+ {
+ return bankCity;
+ }
+
+ public void setBankCity(String bankCity)
+ {
+ this.bankCity = bankCity;
+ }
+
public String getDelFlag()
{
return delFlag;
@@ -200,6 +369,19 @@
.append("registerDate", getRegisterDate())
.append("licenseValidPeriod", getLicenseValidPeriod())
.append("licenseImage", getLicenseImage())
+ .append("licenseValidType", getLicenseValidType())
+ .append("legalIdCardFront", getLegalIdCardFront())
+ .append("legalIdCardBack", getLegalIdCardBack())
+ .append("legalIdValidType", getLegalIdValidType())
+ .append("legalIdValidPeriod", getLegalIdValidPeriod())
+ .append("legalIdAddress", getLegalIdAddress())
+ .append("contactName", getContactName())
+ .append("contactPhone", getContactPhone())
+ .append("contactEmail", getContactEmail())
+ .append("payeeType", getPayeeType())
+ .append("bankCardImage", getBankCardImage())
+ .append("bankCardNo", getBankCardNo())
+ .append("bankCity", getBankCity())
.append("delFlag", getDelFlag())
.append("createBy", getCreateBy())
.append("createTime", getCreateTime())
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertificationApply.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertificationApply.java
index fa35531..bc8b06c 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertificationApply.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountCertificationApply.java
@@ -56,11 +56,50 @@
@JsonFormat(pattern = "yyyy-MM-dd")
private Date registerDate;
- /** 营业执照有效期间 */
+ /** 营业执照有效期间(存文本,如 2024-01-01~2034-01-01) */
private String licenseValidPeriod;
/** 营业执照图片 */
private String licenseImage;
+
+ /** 营业执照有效期类型(1长期 2短期;长期时 licenseValidPeriod 为空) */
+ private String licenseValidType;
+
+ /** 法人身份证人像面图片 */
+ private String legalIdCardFront;
+
+ /** 法人身份证国徽面图片 */
+ private String legalIdCardBack;
+
+ /** 法人身份证有效期类型(1长期 2短期) */
+ private String legalIdValidType;
+
+ /** 法人身份证有效期(存文本,如 2024-01-01~2034-01-01) */
+ private String legalIdValidPeriod;
+
+ /** 法人身份证详细地址 */
+ private String legalIdAddress;
+
+ /** 负责人姓名 */
+ private String contactName;
+
+ /** 负责人联系电话 */
+ private String contactPhone;
+
+ /** 负责人邮箱 */
+ private String contactEmail;
+
+ /** 收款类型 */
+ private String payeeType;
+
+ /** 银行卡号码面照片 */
+ private String bankCardImage;
+
+ /** 银行卡号 */
+ private String bankCardNo;
+
+ /** 开户城市 */
+ private String bankCity;
/** 审核状态(0待审核 1已通过 2已驳回) */
private String auditStatus;
@@ -208,6 +247,136 @@
this.licenseImage = licenseImage;
}
+ public String getLicenseValidType()
+ {
+ return licenseValidType;
+ }
+
+ public void setLicenseValidType(String licenseValidType)
+ {
+ this.licenseValidType = licenseValidType;
+ }
+
+ public String getLegalIdCardFront()
+ {
+ return legalIdCardFront;
+ }
+
+ public void setLegalIdCardFront(String legalIdCardFront)
+ {
+ this.legalIdCardFront = legalIdCardFront;
+ }
+
+ public String getLegalIdCardBack()
+ {
+ return legalIdCardBack;
+ }
+
+ public void setLegalIdCardBack(String legalIdCardBack)
+ {
+ this.legalIdCardBack = legalIdCardBack;
+ }
+
+ public String getLegalIdValidType()
+ {
+ return legalIdValidType;
+ }
+
+ public void setLegalIdValidType(String legalIdValidType)
+ {
+ this.legalIdValidType = legalIdValidType;
+ }
+
+ public String getLegalIdValidPeriod()
+ {
+ return legalIdValidPeriod;
+ }
+
+ public void setLegalIdValidPeriod(String legalIdValidPeriod)
+ {
+ this.legalIdValidPeriod = legalIdValidPeriod;
+ }
+
+ public String getLegalIdAddress()
+ {
+ return legalIdAddress;
+ }
+
+ public void setLegalIdAddress(String legalIdAddress)
+ {
+ this.legalIdAddress = legalIdAddress;
+ }
+
+ public String getContactName()
+ {
+ return contactName;
+ }
+
+ public void setContactName(String contactName)
+ {
+ this.contactName = contactName;
+ }
+
+ public String getContactPhone()
+ {
+ return contactPhone;
+ }
+
+ public void setContactPhone(String contactPhone)
+ {
+ this.contactPhone = contactPhone;
+ }
+
+ public String getContactEmail()
+ {
+ return contactEmail;
+ }
+
+ public void setContactEmail(String contactEmail)
+ {
+ this.contactEmail = contactEmail;
+ }
+
+ public String getPayeeType()
+ {
+ return payeeType;
+ }
+
+ public void setPayeeType(String payeeType)
+ {
+ this.payeeType = payeeType;
+ }
+
+ public String getBankCardImage()
+ {
+ return bankCardImage;
+ }
+
+ public void setBankCardImage(String bankCardImage)
+ {
+ this.bankCardImage = bankCardImage;
+ }
+
+ public String getBankCardNo()
+ {
+ return bankCardNo;
+ }
+
+ public void setBankCardNo(String bankCardNo)
+ {
+ this.bankCardNo = bankCardNo;
+ }
+
+ public String getBankCity()
+ {
+ return bankCity;
+ }
+
+ public void setBankCity(String bankCity)
+ {
+ this.bankCity = bankCity;
+ }
+
public String getAuditStatus()
{
return auditStatus;
@@ -274,6 +443,19 @@
.append("registerDate", getRegisterDate())
.append("licenseValidPeriod", getLicenseValidPeriod())
.append("licenseImage", getLicenseImage())
+ .append("licenseValidType", getLicenseValidType())
+ .append("legalIdCardFront", getLegalIdCardFront())
+ .append("legalIdCardBack", getLegalIdCardBack())
+ .append("legalIdValidType", getLegalIdValidType())
+ .append("legalIdValidPeriod", getLegalIdValidPeriod())
+ .append("legalIdAddress", getLegalIdAddress())
+ .append("contactName", getContactName())
+ .append("contactPhone", getContactPhone())
+ .append("contactEmail", getContactEmail())
+ .append("payeeType", getPayeeType())
+ .append("bankCardImage", getBankCardImage())
+ .append("bankCardNo", getBankCardNo())
+ .append("bankCity", getBankCity())
.append("auditStatus", getAuditStatus())
.append("auditBy", getAuditBy())
.append("auditTime", getAuditTime())
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountPurchaseOrder.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountPurchaseOrder.java
new file mode 100644
index 0000000..13f39df
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountPurchaseOrder.java
@@ -0,0 +1,186 @@
+package com.ruoyi.user.domain;
+
+import java.util.Date;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.ruoyi.common.core.web.domain.BaseEntity;
+
+/**
+ * 会员进货单 account_purchase_order
+ *
+ * 一条进货单 = 一次进货入库;落单同时增加 account_warehouse.stock_qty 并追加云仓变动流水。
+ * 当月进货额由本表按 purchase_time 实时聚合。
+ *
+ * @author hugray
+ */
+public class AccountPurchaseOrder extends BaseEntity
+{
+ private static final long serialVersionUID = 1L;
+
+ /** 进货单ID */
+ private Long orderId;
+
+ /** 进货单号(幂等唯一,服务端生成) */
+ private String orderNo;
+
+ /** 进货会员ID(account_user.user_id) */
+ private Long userId;
+
+ /** 云仓行ID(account_warehouse.id,快照) */
+ private Long warehouseId;
+
+ /** 商品名称(快照) */
+ private String productName;
+
+ /** 进货数量 */
+ private Long qty;
+
+ /** 进货单价(单位:分) */
+ private Long unitPrice;
+
+ /** 进货金额(单位:分)= qty × unitPrice */
+ private Long totalAmount;
+
+ /** 状态(1已入库,见 PurchaseOrderStatusEnum) */
+ private String status;
+
+ /** 进货时间(聚合口径) */
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private Date purchaseTime;
+
+ /** 删除标志(0存在 2删除) */
+ private String delFlag;
+
+ public Long getOrderId()
+ {
+ return orderId;
+ }
+
+ public void setOrderId(Long orderId)
+ {
+ this.orderId = orderId;
+ }
+
+ public String getOrderNo()
+ {
+ return orderNo;
+ }
+
+ public void setOrderNo(String orderNo)
+ {
+ this.orderNo = orderNo;
+ }
+
+ public Long getUserId()
+ {
+ return userId;
+ }
+
+ public void setUserId(Long userId)
+ {
+ this.userId = userId;
+ }
+
+ public Long getWarehouseId()
+ {
+ return warehouseId;
+ }
+
+ public void setWarehouseId(Long warehouseId)
+ {
+ this.warehouseId = warehouseId;
+ }
+
+ public String getProductName()
+ {
+ return productName;
+ }
+
+ public void setProductName(String productName)
+ {
+ this.productName = productName;
+ }
+
+ public Long getQty()
+ {
+ return qty;
+ }
+
+ public void setQty(Long qty)
+ {
+ this.qty = qty;
+ }
+
+ public Long getUnitPrice()
+ {
+ return unitPrice;
+ }
+
+ public void setUnitPrice(Long unitPrice)
+ {
+ this.unitPrice = unitPrice;
+ }
+
+ public Long getTotalAmount()
+ {
+ return totalAmount;
+ }
+
+ public void setTotalAmount(Long totalAmount)
+ {
+ this.totalAmount = totalAmount;
+ }
+
+ public String getStatus()
+ {
+ return status;
+ }
+
+ public void setStatus(String status)
+ {
+ this.status = status;
+ }
+
+ public Date getPurchaseTime()
+ {
+ return purchaseTime;
+ }
+
+ public void setPurchaseTime(Date purchaseTime)
+ {
+ this.purchaseTime = purchaseTime;
+ }
+
+ public String getDelFlag()
+ {
+ return delFlag;
+ }
+
+ public void setDelFlag(String delFlag)
+ {
+ this.delFlag = delFlag;
+ }
+
+ @Override
+ public String toString() {
+ return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
+ .append("orderId", getOrderId())
+ .append("orderNo", getOrderNo())
+ .append("userId", getUserId())
+ .append("warehouseId", getWarehouseId())
+ .append("productName", getProductName())
+ .append("qty", getQty())
+ .append("unitPrice", getUnitPrice())
+ .append("totalAmount", getTotalAmount())
+ .append("status", getStatus())
+ .append("purchaseTime", getPurchaseTime())
+ .append("delFlag", getDelFlag())
+ .append("createBy", getCreateBy())
+ .append("createTime", getCreateTime())
+ .append("updateBy", getUpdateBy())
+ .append("updateTime", getUpdateTime())
+ .append("remark", getRemark())
+ .toString();
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountSalesRecord.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountSalesRecord.java
new file mode 100644
index 0000000..80850d7
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountSalesRecord.java
@@ -0,0 +1,186 @@
+package com.ruoyi.user.domain;
+
+import java.util.Date;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.ruoyi.common.core.web.domain.BaseEntity;
+
+/**
+ * 会员销售流水 account_sales_record
+ *
+ * 一条流水 = 一次销售事件。当月销售额 / 利润额 / 销售单数、销售额趋势、商品销售额比例、
+ * 销售用户排行均由本表按 sale_time 实时聚合。
+ *
+ * @author hugray
+ */
+public class AccountSalesRecord extends BaseEntity
+{
+ private static final long serialVersionUID = 1L;
+
+ /** 销售流水ID */
+ private Long recordId;
+
+ /** 销售会员ID(account_user.user_id) */
+ private Long sellerId;
+
+ /** 购买会员ID(account_user.user_id,散客为空) */
+ private Long buyerId;
+
+ /** 购买人昵称(销售排行展示用) */
+ private String buyerName;
+
+ /** 购买人手机号(下发前脱敏) */
+ private String buyerPhone;
+
+ /** 商品名称(比例图 / 排行展示用) */
+ private String productName;
+
+ /** 销售数量 */
+ private Long qty;
+
+ /** 销售额(单位:分) */
+ private Long amount;
+
+ /** 利润额(单位:分,可为负) */
+ private Long profitAmount;
+
+ /** 销售时间(聚合口径) */
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private Date saleTime;
+
+ /** 删除标志(0存在 2删除) */
+ private String delFlag;
+
+ public Long getRecordId()
+ {
+ return recordId;
+ }
+
+ public void setRecordId(Long recordId)
+ {
+ this.recordId = recordId;
+ }
+
+ public Long getSellerId()
+ {
+ return sellerId;
+ }
+
+ public void setSellerId(Long sellerId)
+ {
+ this.sellerId = sellerId;
+ }
+
+ public Long getBuyerId()
+ {
+ return buyerId;
+ }
+
+ public void setBuyerId(Long buyerId)
+ {
+ this.buyerId = buyerId;
+ }
+
+ public String getBuyerName()
+ {
+ return buyerName;
+ }
+
+ public void setBuyerName(String buyerName)
+ {
+ this.buyerName = buyerName;
+ }
+
+ public String getBuyerPhone()
+ {
+ return buyerPhone;
+ }
+
+ public void setBuyerPhone(String buyerPhone)
+ {
+ this.buyerPhone = buyerPhone;
+ }
+
+ public String getProductName()
+ {
+ return productName;
+ }
+
+ public void setProductName(String productName)
+ {
+ this.productName = productName;
+ }
+
+ public Long getQty()
+ {
+ return qty;
+ }
+
+ public void setQty(Long qty)
+ {
+ this.qty = qty;
+ }
+
+ public Long getAmount()
+ {
+ return amount;
+ }
+
+ public void setAmount(Long amount)
+ {
+ this.amount = amount;
+ }
+
+ public Long getProfitAmount()
+ {
+ return profitAmount;
+ }
+
+ public void setProfitAmount(Long profitAmount)
+ {
+ this.profitAmount = profitAmount;
+ }
+
+ public Date getSaleTime()
+ {
+ return saleTime;
+ }
+
+ public void setSaleTime(Date saleTime)
+ {
+ this.saleTime = saleTime;
+ }
+
+ public String getDelFlag()
+ {
+ return delFlag;
+ }
+
+ public void setDelFlag(String delFlag)
+ {
+ this.delFlag = delFlag;
+ }
+
+ @Override
+ public String toString() {
+ return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
+ .append("recordId", getRecordId())
+ .append("sellerId", getSellerId())
+ .append("buyerId", getBuyerId())
+ .append("buyerName", getBuyerName())
+ .append("buyerPhone", getBuyerPhone())
+ .append("productName", getProductName())
+ .append("qty", getQty())
+ .append("amount", getAmount())
+ .append("profitAmount", getProfitAmount())
+ .append("saleTime", getSaleTime())
+ .append("delFlag", getDelFlag())
+ .append("createBy", getCreateBy())
+ .append("createTime", getCreateTime())
+ .append("updateBy", getUpdateBy())
+ .append("updateTime", getUpdateTime())
+ .append("remark", getRemark())
+ .toString();
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountUser.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountUser.java
index 67c8676..274d76f 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountUser.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountUser.java
@@ -101,6 +101,16 @@
@Excel(name = "认证状态", readConverterExp = "0=未认证,1=审核中,2=已认证,3=认证失败")
private String authStatus;
+ /**
+ * 企业/个体认证状态(0未认证 1审核中 2已认证 3认证失败),与个人 authStatus 相互独立。
+ *
+ * 刻意不加 @Excel:避免会员列表导出与个人认证状态混淆。
+ * 也刻意不进 insertAccountUser / updateAccountUser 的动态列——那两个是共享入口,
+ * 挂上去等于请求体里带个 companyAuthStatus 就能自造认证状态,绕过审核。
+ * 该列只能由 updateUserCompanyAuthStatus 改写,唯一调用方是企业认证审核流转。
+ */
+ private String companyAuthStatus;
+
/** 所属城市 */
@Excel(name = "所属城市")
private String city;
@@ -292,6 +302,16 @@
public void setAuthStatus(String authStatus)
{
this.authStatus = authStatus;
+ }
+
+ public String getCompanyAuthStatus()
+ {
+ return companyAuthStatus;
+ }
+
+ public void setCompanyAuthStatus(String companyAuthStatus)
+ {
+ this.companyAuthStatus = companyAuthStatus;
}
public String getCity()
@@ -535,6 +555,7 @@
.append("systemLevel", getSystemLevel())
.append("subjectType", getSubjectType())
.append("authStatus", getAuthStatus())
+ .append("companyAuthStatus", getCompanyAuthStatus())
.append("city", getCity())
.append("referrerId", getReferrerId())
.append("referrerBindTime", getReferrerBindTime())
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountWarehouseRecord.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountWarehouseRecord.java
new file mode 100644
index 0000000..8a54102
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/AccountWarehouseRecord.java
@@ -0,0 +1,163 @@
+package com.ruoyi.user.domain;
+
+import java.util.Date;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+import com.ruoyi.common.core.web.domain.BaseEntity;
+
+/**
+ * 会员云仓变动流水 account_warehouse_record
+ *
+ * 每次库存变动(进货入库 / 销售出库 / 库存调整)追加一条流水,供「仓库记录」倒序展示。
+ *
+ * @author hugray
+ */
+public class AccountWarehouseRecord extends BaseEntity
+{
+ private static final long serialVersionUID = 1L;
+
+ /** 流水ID */
+ private Long recordId;
+
+ /** 会员ID(account_user.user_id) */
+ private Long userId;
+
+ /** 云仓行ID(account_warehouse.id) */
+ private Long warehouseId;
+
+ /** 商品名称(快照) */
+ private String productName;
+
+ /** 变动类型(1进货入库 2销售出库 3库存调整,见 WarehouseChangeTypeEnum) */
+ private String changeType;
+
+ /** 变动数量(入库为正,出库为负) */
+ private Long changeQty;
+
+ /** 变动后库存 */
+ private Long afterQty;
+
+ /** 业务单号(关联进货单等,便于溯源) */
+ private String bizNo;
+
+ /** 删除标志(0存在 2删除) */
+ private String delFlag;
+
+ public Long getRecordId()
+ {
+ return recordId;
+ }
+
+ public void setRecordId(Long recordId)
+ {
+ this.recordId = recordId;
+ }
+
+ public Long getUserId()
+ {
+ return userId;
+ }
+
+ public void setUserId(Long userId)
+ {
+ this.userId = userId;
+ }
+
+ public Long getWarehouseId()
+ {
+ return warehouseId;
+ }
+
+ public void setWarehouseId(Long warehouseId)
+ {
+ this.warehouseId = warehouseId;
+ }
+
+ public String getProductName()
+ {
+ return productName;
+ }
+
+ public void setProductName(String productName)
+ {
+ this.productName = productName;
+ }
+
+ public String getChangeType()
+ {
+ return changeType;
+ }
+
+ public void setChangeType(String changeType)
+ {
+ this.changeType = changeType;
+ }
+
+ public Long getChangeQty()
+ {
+ return changeQty;
+ }
+
+ public void setChangeQty(Long changeQty)
+ {
+ this.changeQty = changeQty;
+ }
+
+ public Long getAfterQty()
+ {
+ return afterQty;
+ }
+
+ public void setAfterQty(Long afterQty)
+ {
+ this.afterQty = afterQty;
+ }
+
+ public String getBizNo()
+ {
+ return bizNo;
+ }
+
+ public void setBizNo(String bizNo)
+ {
+ this.bizNo = bizNo;
+ }
+
+ public String getDelFlag()
+ {
+ return delFlag;
+ }
+
+ public void setDelFlag(String delFlag)
+ {
+ this.delFlag = delFlag;
+ }
+
+ /**
+ * 变动时间:直接复用父类 createTime(流水创建即变动发生),此处仅做展示别名。
+ */
+ public Date getChangeTime()
+ {
+ return getCreateTime();
+ }
+
+ @Override
+ public String toString() {
+ return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
+ .append("recordId", getRecordId())
+ .append("userId", getUserId())
+ .append("warehouseId", getWarehouseId())
+ .append("productName", getProductName())
+ .append("changeType", getChangeType())
+ .append("changeQty", getChangeQty())
+ .append("afterQty", getAfterQty())
+ .append("bizNo", getBizNo())
+ .append("delFlag", getDelFlag())
+ .append("createBy", getCreateBy())
+ .append("createTime", getCreateTime())
+ .append("updateBy", getUpdateBy())
+ .append("updateTime", getUpdateTime())
+ .append("remark", getRemark())
+ .toString();
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountAddressDto.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountAddressDto.java
new file mode 100644
index 0000000..a34d774
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountAddressDto.java
@@ -0,0 +1,88 @@
+package com.ruoyi.user.domain.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+/**
+ * C端会员 收货地址 新增/编辑入参
+ *
+ * 会员身份取自登录态(网关注入的 user_id 头),不接受前端传入 userId,天然防水平越权。
+ * 编辑场景需携带 id(由 Controller 人工校验非空);新增场景 id 为空。
+ *
+ * address 为单字段完整地址,约定格式:省 市 区 详细地址(空格分隔),详见设计文档。
+ *
+ * @author hugray
+ */
+public class AccountAddressDto
+{
+ /** 地址ID(新增时为空;编辑时必填,由 Controller 校验) */
+ private Long id;
+
+ /** 收货人;必填,长度不超过 64(对应 account_address.consignee varchar(64)) */
+ @NotBlank(message = "收货人不能为空")
+ @Size(max = 64, message = "收货人姓名过长")
+ private String consignee;
+
+ /** 收货人电话;必填,长度不超过 20;不强制手机号(允许固话) */
+ @NotBlank(message = "收货人电话不能为空")
+ @Size(max = 20, message = "收货人电话过长")
+ private String consigneePhone;
+
+ /** 完整地址(约定格式:省 市 区 详细地址);必填,长度不超过 255 */
+ @NotBlank(message = "收货地址不能为空")
+ @Size(max = 255, message = "收货地址过长")
+ private String address;
+
+ /** 是否默认(0否 1是);非必填,为空时按 0 处理 */
+ private String isDefault;
+
+ public Long getId()
+ {
+ return id;
+ }
+
+ public void setId(Long id)
+ {
+ this.id = id;
+ }
+
+ public String getConsignee()
+ {
+ return consignee;
+ }
+
+ public void setConsignee(String consignee)
+ {
+ this.consignee = consignee;
+ }
+
+ public String getConsigneePhone()
+ {
+ return consigneePhone;
+ }
+
+ public void setConsigneePhone(String consigneePhone)
+ {
+ this.consigneePhone = consigneePhone;
+ }
+
+ public String getAddress()
+ {
+ return address;
+ }
+
+ public void setAddress(String address)
+ {
+ this.address = address;
+ }
+
+ public String getIsDefault()
+ {
+ return isDefault;
+ }
+
+ public void setIsDefault(String isDefault)
+ {
+ this.isDefault = isDefault;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountCertificationApplyDto.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountCertificationApplyDto.java
new file mode 100644
index 0000000..d5381e7
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountCertificationApplyDto.java
@@ -0,0 +1,57 @@
+package com.ruoyi.user.domain.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+
+/**
+ * C端会员 主体认证申请提交入参(当前仅个人主体)
+ *
+ * 只承载「姓名」与「身份证号」两个字段:会员身份取自登录态(网关注入的 user_id 头),
+ * 不接受前端传入 userId,天然防水平越权;主体类型由服务端固定为「个人」,
+ * 不接受前端传入,避免越权提交企业主体绕开后续资料要求。
+ *
+ * 本品只做基础格式校验(姓名必填、身份证号结构校验),不做三方实名核验。
+ *
+ * @author hugray
+ */
+public class AccountCertificationApplyDto
+{
+ /** 姓名;必填,长度不超过 64(对应 account_certification_apply.real_name varchar(64)) */
+ @NotBlank(message = "姓名不能为空")
+ @Size(max = 64, message = "姓名过长")
+ private String realName;
+
+ /**
+ * 身份证号;必填,18 位。
+ *
+ * 正则只校验结构:6 位地址码 + 8 位出生日期(年份 18/19/20 开头,月 01~12,日 01~31)
+ * + 3 位顺序码 + 1 位校验位(数字或 X),不校验最后一位校验码的数学正确性,
+ * 也不做真实身份核验(后续由后台人工审核把关)。
+ */
+ @NotBlank(message = "身份证号不能为空")
+ @Size(max = 32, message = "身份证号过长")
+ @Pattern(regexp = "^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[\\dXx]$",
+ message = "身份证号格式不正确")
+ private String idCard;
+
+ public String getRealName()
+ {
+ return realName;
+ }
+
+ public void setRealName(String realName)
+ {
+ this.realName = realName;
+ }
+
+ public String getIdCard()
+ {
+ return idCard;
+ }
+
+ public void setIdCard(String idCard)
+ {
+ this.idCard = idCard;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountUserAvatarDto.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountUserAvatarDto.java
new file mode 100644
index 0000000..62729ac
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountUserAvatarDto.java
@@ -0,0 +1,32 @@
+package com.ruoyi.user.domain.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+/**
+ * C端会员 头像更新入参
+ *
+ * 仅承载「头像地址」一个字段:会员身份取自登录态(网关注入的 user_id 头),
+ * 不接受前端传入 userId,天然防水平越权。
+ *
+ * 头像文件由小程序直传文件服务(POST /file/upload)后,将返回的 url 通过本入参写回。
+ *
+ * @author hugray
+ */
+public class AccountUserAvatarDto
+{
+ /** 头像地址(来自文件服务上传结果的 url);必填,长度不超过 255(对应 account_user.avatar varchar(255)) */
+ @NotBlank(message = "头像地址不能为空")
+ @Size(max = 255, message = "头像地址过长")
+ private String avatar;
+
+ public String getAvatar()
+ {
+ return avatar;
+ }
+
+ public void setAvatar(String avatar)
+ {
+ this.avatar = avatar;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/CompanyCertificationApplyDto.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/CompanyCertificationApplyDto.java
new file mode 100644
index 0000000..df1af91
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/CompanyCertificationApplyDto.java
@@ -0,0 +1,334 @@
+package com.ruoyi.user.domain.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+
+/**
+ * C端会员 企业/个体工商户认证申请提交入参
+ *
+ * 会员身份取自登录态(网关注入的 user_id 头),不接受前端传入 userId,天然防水平越权。
+ *
+ * 本期只做最基础的必填校验(各字段非空 / 枚举取值合法),不做任何格式核验与三方核验,
+ * 真实性由后台人工审核把关。
+ *
+ * 有效期字段的服务端兜底:「有效期类型」为「长期」时起止日期允许为空;为「短期」时
+ * 服务层会强制要求填写(见 AccountCertificationApplyServiceImpl#submitCompanyCertification)。
+ *
+ * @author hugray
+ */
+public class CompanyCertificationApplyDto
+{
+ /** 主体类型;必填,取 2个体工商户 / 3企业(对应 account_certification_apply.subject_type) */
+ @NotBlank(message = "请选择主体类型")
+ @Pattern(regexp = "^[23]$", message = "主体类型不合法")
+ private String subjectType;
+
+ /** 企业/个体名称;必填 */
+ @NotBlank(message = "请输入企业/个体名称")
+ @Size(max = 128, message = "企业/个体名称过长")
+ private String companyName;
+
+ /** 营业执照图片地址;必填 */
+ @NotBlank(message = "请上传营业执照")
+ @Size(max = 500, message = "营业执照图片地址过长")
+ private String licenseImage;
+
+ /** 统一社会信用代码;必填 */
+ @NotBlank(message = "请输入统一社会信用代码")
+ @Size(max = 32, message = "统一社会信用代码过长")
+ private String creditCode;
+
+ /** 营业执照有效期类型;必填,取 1长期 / 2短期 */
+ @NotBlank(message = "请选择营业执照有效期类型")
+ @Pattern(regexp = "^[12]$", message = "营业执照有效期类型不合法")
+ private String licenseValidType;
+
+ /** 营业执照有效期起止(文本);类型为「短期」时服务层必填 */
+ @Size(max = 32, message = "营业执照有效期过长")
+ private String licenseValidPeriod;
+
+ /** 营业执照注册地区;必填 */
+ @NotBlank(message = "请选择营业执照注册地区")
+ @Size(max = 128, message = "营业执照注册地区过长")
+ private String registeredRegion;
+
+ /** 营业执照详细地址;必填 */
+ @NotBlank(message = "请输入营业执照详细地址")
+ @Size(max = 255, message = "营业执照详细地址过长")
+ private String companyAddress;
+
+ /** 法人姓名;必填 */
+ @NotBlank(message = "请输入法人姓名")
+ @Size(max = 64, message = "法人姓名过长")
+ private String legalPerson;
+
+ /** 法人身份证人像面图片地址;必填 */
+ @NotBlank(message = "请上传法人身份证人像面")
+ @Size(max = 500, message = "法人身份证人像面图片地址过长")
+ private String legalIdCardFront;
+
+ /** 法人身份证国徽面图片地址;必填 */
+ @NotBlank(message = "请上传法人身份证国徽面")
+ @Size(max = 500, message = "法人身份证国徽面图片地址过长")
+ private String legalIdCardBack;
+
+ /** 法人身份证有效期类型;必填,取 1长期 / 2短期 */
+ @NotBlank(message = "请选择法人身份证有效期类型")
+ @Pattern(regexp = "^[12]$", message = "法人身份证有效期类型不合法")
+ private String legalIdValidType;
+
+ /** 法人身份证有效期起止(文本);类型为「短期」时服务层必填 */
+ @Size(max = 32, message = "法人身份证有效期过长")
+ private String legalIdValidPeriod;
+
+ /** 法人身份证详细地址;必填 */
+ @NotBlank(message = "请输入法人身份证详细地址")
+ @Size(max = 255, message = "法人身份证详细地址过长")
+ private String legalIdAddress;
+
+ /** 负责人姓名;必填 */
+ @NotBlank(message = "请输入负责人姓名")
+ @Size(max = 64, message = "负责人姓名过长")
+ private String contactName;
+
+ /** 负责人联系电话;必填(本期只校验非空,不校验格式) */
+ @NotBlank(message = "请输入负责人联系电话")
+ @Size(max = 20, message = "负责人联系电话过长")
+ private String contactPhone;
+
+ /** 负责人邮箱;必填(本期只校验非空,不校验格式) */
+ @NotBlank(message = "请输入负责人邮箱")
+ @Size(max = 128, message = "负责人邮箱过长")
+ private String contactEmail;
+
+ /** 收款类型;必填 */
+ @NotBlank(message = "请选择收款类型")
+ @Size(max = 16, message = "收款类型过长")
+ private String payeeType;
+
+ /** 银行卡号码面照片地址;必填 */
+ @NotBlank(message = "请上传银行卡号码面照片")
+ @Size(max = 500, message = "银行卡号码面照片地址过长")
+ private String bankCardImage;
+
+ /** 银行卡号;必填 */
+ @NotBlank(message = "请输入银行卡号")
+ @Size(max = 32, message = "银行卡号过长")
+ private String bankCardNo;
+
+ /** 开户城市;必填 */
+ @NotBlank(message = "请选择开户城市")
+ @Size(max = 128, message = "开户城市过长")
+ private String bankCity;
+
+ public String getSubjectType()
+ {
+ return subjectType;
+ }
+
+ public void setSubjectType(String subjectType)
+ {
+ this.subjectType = subjectType;
+ }
+
+ public String getCompanyName()
+ {
+ return companyName;
+ }
+
+ public void setCompanyName(String companyName)
+ {
+ this.companyName = companyName;
+ }
+
+ public String getLicenseImage()
+ {
+ return licenseImage;
+ }
+
+ public void setLicenseImage(String licenseImage)
+ {
+ this.licenseImage = licenseImage;
+ }
+
+ public String getCreditCode()
+ {
+ return creditCode;
+ }
+
+ public void setCreditCode(String creditCode)
+ {
+ this.creditCode = creditCode;
+ }
+
+ public String getLicenseValidType()
+ {
+ return licenseValidType;
+ }
+
+ public void setLicenseValidType(String licenseValidType)
+ {
+ this.licenseValidType = licenseValidType;
+ }
+
+ public String getLicenseValidPeriod()
+ {
+ return licenseValidPeriod;
+ }
+
+ public void setLicenseValidPeriod(String licenseValidPeriod)
+ {
+ this.licenseValidPeriod = licenseValidPeriod;
+ }
+
+ public String getRegisteredRegion()
+ {
+ return registeredRegion;
+ }
+
+ public void setRegisteredRegion(String registeredRegion)
+ {
+ this.registeredRegion = registeredRegion;
+ }
+
+ public String getCompanyAddress()
+ {
+ return companyAddress;
+ }
+
+ public void setCompanyAddress(String companyAddress)
+ {
+ this.companyAddress = companyAddress;
+ }
+
+ public String getLegalPerson()
+ {
+ return legalPerson;
+ }
+
+ public void setLegalPerson(String legalPerson)
+ {
+ this.legalPerson = legalPerson;
+ }
+
+ public String getLegalIdCardFront()
+ {
+ return legalIdCardFront;
+ }
+
+ public void setLegalIdCardFront(String legalIdCardFront)
+ {
+ this.legalIdCardFront = legalIdCardFront;
+ }
+
+ public String getLegalIdCardBack()
+ {
+ return legalIdCardBack;
+ }
+
+ public void setLegalIdCardBack(String legalIdCardBack)
+ {
+ this.legalIdCardBack = legalIdCardBack;
+ }
+
+ public String getLegalIdValidType()
+ {
+ return legalIdValidType;
+ }
+
+ public void setLegalIdValidType(String legalIdValidType)
+ {
+ this.legalIdValidType = legalIdValidType;
+ }
+
+ public String getLegalIdValidPeriod()
+ {
+ return legalIdValidPeriod;
+ }
+
+ public void setLegalIdValidPeriod(String legalIdValidPeriod)
+ {
+ this.legalIdValidPeriod = legalIdValidPeriod;
+ }
+
+ public String getLegalIdAddress()
+ {
+ return legalIdAddress;
+ }
+
+ public void setLegalIdAddress(String legalIdAddress)
+ {
+ this.legalIdAddress = legalIdAddress;
+ }
+
+ public String getContactName()
+ {
+ return contactName;
+ }
+
+ public void setContactName(String contactName)
+ {
+ this.contactName = contactName;
+ }
+
+ public String getContactPhone()
+ {
+ return contactPhone;
+ }
+
+ public void setContactPhone(String contactPhone)
+ {
+ this.contactPhone = contactPhone;
+ }
+
+ public String getContactEmail()
+ {
+ return contactEmail;
+ }
+
+ public void setContactEmail(String contactEmail)
+ {
+ this.contactEmail = contactEmail;
+ }
+
+ public String getPayeeType()
+ {
+ return payeeType;
+ }
+
+ public void setPayeeType(String payeeType)
+ {
+ this.payeeType = payeeType;
+ }
+
+ public String getBankCardImage()
+ {
+ return bankCardImage;
+ }
+
+ public void setBankCardImage(String bankCardImage)
+ {
+ this.bankCardImage = bankCardImage;
+ }
+
+ public String getBankCardNo()
+ {
+ return bankCardNo;
+ }
+
+ public void setBankCardNo(String bankCardNo)
+ {
+ this.bankCardNo = bankCardNo;
+ }
+
+ public String getBankCity()
+ {
+ return bankCity;
+ }
+
+ public void setBankCity(String bankCity)
+ {
+ this.bankCity = bankCity;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/MemberPurchaseDto.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/MemberPurchaseDto.java
new file mode 100644
index 0000000..896a8e8
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/MemberPurchaseDto.java
@@ -0,0 +1,54 @@
+package com.ruoyi.user.domain.dto;
+
+import jakarta.validation.constraints.NotNull;
+
+/**
+ * 会员进货入参
+ *
+ * 会员身份取自登录态,不接受前端传入 userId;warehouseId 须属于当前会员(服务端校验归属)。
+ *
+ * @author hugray
+ */
+public class MemberPurchaseDto
+{
+ /** 云仓行ID(account_warehouse.id,须属于当前会员) */
+ @NotNull(message = "进货商品不能为空")
+ private Long warehouseId;
+
+ /** 进货数量(正整数) */
+ @NotNull(message = "进货数量不能为空")
+ private Long qty;
+
+ /** 进货单价(单位:分;可选,缺省按 0 计) */
+ private Long unitPrice;
+
+ public Long getWarehouseId()
+ {
+ return warehouseId;
+ }
+
+ public void setWarehouseId(Long warehouseId)
+ {
+ this.warehouseId = warehouseId;
+ }
+
+ public Long getQty()
+ {
+ return qty;
+ }
+
+ public void setQty(Long qty)
+ {
+ this.qty = qty;
+ }
+
+ public Long getUnitPrice()
+ {
+ return unitPrice;
+ }
+
+ public void setUnitPrice(Long unitPrice)
+ {
+ this.unitPrice = unitPrice;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/SubAccountCreateDto.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/SubAccountCreateDto.java
new file mode 100644
index 0000000..651c284
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/SubAccountCreateDto.java
@@ -0,0 +1,44 @@
+package com.ruoyi.user.domain.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+
+/**
+ * 新增子账号入参
+ *
+ * 服务商身份取自登录态,不接受前端传 userId;等级与手机号仅做格式校验,
+ * 等级取值合法性、手机号唯一性与剩余额度在服务层校验。
+ *
+ * @author hugray
+ */
+public class SubAccountCreateDto
+{
+ /** 等级(L1/L2/L3,取值合法性由服务层校验) */
+ @NotBlank(message = "请选择等级")
+ private String level;
+
+ /** 登录手机号(子账号即手机号账号,后续走验证码登录) */
+ @NotBlank(message = "请输入正确的手机号")
+ @Pattern(regexp = "^1[3-9]\\d{9}$", message = "请输入正确的手机号")
+ private String phone;
+
+ public String getLevel()
+ {
+ return level;
+ }
+
+ public void setLevel(String level)
+ {
+ this.level = level;
+ }
+
+ public String getPhone()
+ {
+ return phone;
+ }
+
+ public void setPhone(String phone)
+ {
+ this.phone = phone;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountAddressVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountAddressVo.java
new file mode 100644
index 0000000..af2c62f
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountAddressVo.java
@@ -0,0 +1,77 @@
+package com.ruoyi.user.domain.vo;
+
+/**
+ * C端会员 收货地址 VO(列表 / 详情 / 编辑回填用)
+ *
+ * 仅暴露页面所需字段;收货地址为本人数据,电话下发明文(编辑回填需要)。
+ * 会员身份由服务端从登录态解析,不接受前端传入。
+ *
+ * @author hugray
+ */
+public class AccountAddressVo
+{
+ /** 地址ID */
+ private Long id;
+
+ /** 收货人 */
+ private String consignee;
+
+ /** 收货人电话(明文,本人数据) */
+ private String consigneePhone;
+
+ /** 完整地址(空格分隔:省 市 区 详细地址) */
+ private String address;
+
+ /** 是否默认(0否 1是) */
+ private String isDefault;
+
+ public Long getId()
+ {
+ return id;
+ }
+
+ public void setId(Long id)
+ {
+ this.id = id;
+ }
+
+ public String getConsignee()
+ {
+ return consignee;
+ }
+
+ public void setConsignee(String consignee)
+ {
+ this.consignee = consignee;
+ }
+
+ public String getConsigneePhone()
+ {
+ return consigneePhone;
+ }
+
+ public void setConsigneePhone(String consigneePhone)
+ {
+ this.consigneePhone = consigneePhone;
+ }
+
+ public String getAddress()
+ {
+ return address;
+ }
+
+ public void setAddress(String address)
+ {
+ this.address = address;
+ }
+
+ public String getIsDefault()
+ {
+ return isDefault;
+ }
+
+ public void setIsDefault(String isDefault)
+ {
+ this.isDefault = isDefault;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountCertificationStatusVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountCertificationStatusVo.java
new file mode 100644
index 0000000..7849194
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountCertificationStatusVo.java
@@ -0,0 +1,66 @@
+package com.ruoyi.user.domain.vo;
+
+/**
+ * C端 实名认证状态回填 展示对象
+ *
+ * 取该会员最近一次认证申请的快照,供实名认证页进入时回填与交互:
+ * 认证中 / 认证成功时姓名与身份证号只读、提交按钮置灰;认证失败时在身份证号下方展示驳回原因。
+ *
+ * auditStatus 为空表示该会员从未提交过认证申请,前端按「未认证」处理(空表单可编辑)。
+ *
+ * @author hugray
+ */
+public class AccountCertificationStatusVo
+{
+ /** 姓名(回填用) */
+ private String realName;
+
+ /** 身份证号(回填用) */
+ private String idCard;
+
+ /** 审核状态(0待审核 1已通过 2已驳回;为空表示从未提交) */
+ private String auditStatus;
+
+ /** 驳回原因(审核状态为「已驳回」时有值) */
+ private String auditRemark;
+
+ public String getRealName()
+ {
+ return realName;
+ }
+
+ public void setRealName(String realName)
+ {
+ this.realName = realName;
+ }
+
+ public String getIdCard()
+ {
+ return idCard;
+ }
+
+ public void setIdCard(String idCard)
+ {
+ this.idCard = idCard;
+ }
+
+ public String getAuditStatus()
+ {
+ return auditStatus;
+ }
+
+ public void setAuditStatus(String auditStatus)
+ {
+ this.auditStatus = auditStatus;
+ }
+
+ public String getAuditRemark()
+ {
+ return auditRemark;
+ }
+
+ public void setAuditRemark(String auditRemark)
+ {
+ this.auditRemark = auditRemark;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountInviteeVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountInviteeVo.java
new file mode 100644
index 0000000..f55875e
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountInviteeVo.java
@@ -0,0 +1,104 @@
+package com.ruoyi.user.domain.vo;
+
+import java.math.BigDecimal;
+
+/**
+ * 被邀请人 VO(我的邀请页「我邀请的」列表元素)
+ *
+ * 仅暴露页面所需字段;手机号仅下发脱敏值(如 136****6666),不下发明文。
+ *
+ * @author hugray
+ */
+public class AccountInviteeVo
+{
+ /** 下级会员ID */
+ private Long userId;
+
+ /** 昵称(注册未采集,可为空;为空时前端回退展示脱敏手机号) */
+ private String nickname;
+
+ /** 头像地址(可为空,为空时前端回退默认图标) */
+ private String avatar;
+
+ /** 手机号(脱敏,形如 136****6666;仅供展示,不下发明文) */
+ private String phone;
+
+ /** 等级原值(C / L1~L4,源自 account_user.system_level;本期不做 normalize,原样下发) */
+ private String systemLevel;
+
+ /** 绑定日(yyyy-MM-dd,源自 referrer_bind_time;为空表示未记录,前端不展示) */
+ private String bindDate;
+
+ /** 合计消费(单位:元,源自 account_user.buy_amount_total;现恒为 0,待订单模块回填) */
+ private BigDecimal buyAmountTotal;
+
+ public Long getUserId()
+ {
+ return userId;
+ }
+
+ public void setUserId(Long userId)
+ {
+ this.userId = userId;
+ }
+
+ public String getNickname()
+ {
+ return nickname;
+ }
+
+ public void setNickname(String nickname)
+ {
+ this.nickname = nickname;
+ }
+
+ public String getAvatar()
+ {
+ return avatar;
+ }
+
+ public void setAvatar(String avatar)
+ {
+ this.avatar = avatar;
+ }
+
+ public String getPhone()
+ {
+ return phone;
+ }
+
+ public void setPhone(String phone)
+ {
+ this.phone = phone;
+ }
+
+ public String getSystemLevel()
+ {
+ return systemLevel;
+ }
+
+ public void setSystemLevel(String systemLevel)
+ {
+ this.systemLevel = systemLevel;
+ }
+
+ public String getBindDate()
+ {
+ return bindDate;
+ }
+
+ public void setBindDate(String bindDate)
+ {
+ this.bindDate = bindDate;
+ }
+
+ public BigDecimal getBuyAmountTotal()
+ {
+ return buyAmountTotal;
+ }
+
+ public void setBuyAmountTotal(BigDecimal buyAmountTotal)
+ {
+ this.buyAmountTotal = buyAmountTotal;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountReferrerVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountReferrerVo.java
new file mode 100644
index 0000000..edbd005
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountReferrerVo.java
@@ -0,0 +1,89 @@
+package com.ruoyi.user.domain.vo;
+
+/**
+ * 上级(推荐人)VO(我的邀请页「邀请我的」Tab 展示用)
+ *
+ * 无上级时各字段均为 null,由前端渲染空态;手机号仅下发脱敏值,不下发明文。
+ *
+ * @author hugray
+ */
+public class AccountReferrerVo
+{
+ /** 上级会员ID */
+ private Long userId;
+
+ /** 昵称(可为空) */
+ private String nickname;
+
+ /** 头像地址(可为空,为空时前端回退默认图标) */
+ private String avatar;
+
+ /** 手机号(脱敏,形如 136****6666;仅供展示,不下发明文) */
+ private String phone;
+
+ /** 等级原值(C / L1~L4,源自 account_user.system_level;本期不做 normalize,原样下发) */
+ private String systemLevel;
+
+ /** 绑定日(yyyy-MM-dd,源自 referrer_bind_time;为空表示未记录,前端不展示) */
+ private String bindDate;
+
+ public Long getUserId()
+ {
+ return userId;
+ }
+
+ public void setUserId(Long userId)
+ {
+ this.userId = userId;
+ }
+
+ public String getNickname()
+ {
+ return nickname;
+ }
+
+ public void setNickname(String nickname)
+ {
+ this.nickname = nickname;
+ }
+
+ public String getAvatar()
+ {
+ return avatar;
+ }
+
+ public void setAvatar(String avatar)
+ {
+ this.avatar = avatar;
+ }
+
+ public String getPhone()
+ {
+ return phone;
+ }
+
+ public void setPhone(String phone)
+ {
+ this.phone = phone;
+ }
+
+ public String getSystemLevel()
+ {
+ return systemLevel;
+ }
+
+ public void setSystemLevel(String systemLevel)
+ {
+ this.systemLevel = systemLevel;
+ }
+
+ public String getBindDate()
+ {
+ return bindDate;
+ }
+
+ public void setBindDate(String bindDate)
+ {
+ this.bindDate = bindDate;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountUserInfoVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountUserInfoVo.java
index 25bd635..eaf30ab 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountUserInfoVo.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountUserInfoVo.java
@@ -25,6 +25,15 @@
/** 会员等级(对外展示字段,取值 C普通用户 / L1~L4会员,源自 account_user.system_level) */
private String memberLevel;
+ /** 认证状态(0未认证 1审核中 2已认证 3认证失败,源自 account_user.auth_status;用于个人中心「实名认证」行徽标回显) */
+ private String authStatus;
+
+ /**
+ * 企业/个体认证状态(0未认证 1审核中 2已认证 3认证失败,源自 account_user.company_auth_status),
+ * 与 authStatus 相互独立;用于个人中心「企业/个体户认证」行徽标回显。
+ */
+ private String companyAuthStatus;
+
/** 收益汇总(今日 / 本月 / 累计,单位:分) */
private EarningSummaryVo earnings;
@@ -78,6 +87,26 @@
this.memberLevel = memberLevel;
}
+ public String getAuthStatus()
+ {
+ return authStatus;
+ }
+
+ public void setAuthStatus(String authStatus)
+ {
+ this.authStatus = authStatus;
+ }
+
+ public String getCompanyAuthStatus()
+ {
+ return companyAuthStatus;
+ }
+
+ public void setCompanyAuthStatus(String companyAuthStatus)
+ {
+ this.companyAuthStatus = companyAuthStatus;
+ }
+
public EarningSummaryVo getEarnings()
{
return earnings;
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/CompanyCertificationStatusVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/CompanyCertificationStatusVo.java
new file mode 100644
index 0000000..2e6c4f8
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/CompanyCertificationStatusVo.java
@@ -0,0 +1,314 @@
+package com.ruoyi.user.domain.vo;
+
+/**
+ * C端 企业/个体认证状态回填 展示对象
+ *
+ * 取该会员最近一次企业/个体认证申请(subject_type ∈ {2,3})的快照,供企业认证向导页进入时回填:
+ * 已提交的整份资料 + 审核状态 + 驳回原因。审核中时前端直接进入「资料审核中」占位页,
+ * 已驳回时回填资料并展示驳回原因,可修改重提。
+ *
+ * auditStatus 为空表示该会员从未提交过企业/个体认证申请,前端按「未认证」处理(空表单可编辑)。
+ *
+ * @author hugray
+ */
+public class CompanyCertificationStatusVo
+{
+ /** 主体类型(2个体工商户 3企业;回填用) */
+ private String subjectType;
+
+ /** 企业/个体名称(回填用) */
+ private String companyName;
+
+ /** 营业执照图片地址(回填用) */
+ private String licenseImage;
+
+ /** 统一社会信用代码(回填用) */
+ private String creditCode;
+
+ /** 营业执照有效期类型(1长期 2短期) */
+ private String licenseValidType;
+
+ /** 营业执照有效期起止(文本) */
+ private String licenseValidPeriod;
+
+ /** 营业执照注册地区(回填用) */
+ private String registeredRegion;
+
+ /** 营业执照详细地址(回填用) */
+ private String companyAddress;
+
+ /** 法人姓名(回填用) */
+ private String legalPerson;
+
+ /** 法人身份证人像面图片地址(回填用) */
+ private String legalIdCardFront;
+
+ /** 法人身份证国徽面图片地址(回填用) */
+ private String legalIdCardBack;
+
+ /** 法人身份证有效期类型(1长期 2短期) */
+ private String legalIdValidType;
+
+ /** 法人身份证有效期起止(文本) */
+ private String legalIdValidPeriod;
+
+ /** 法人身份证详细地址(回填用) */
+ private String legalIdAddress;
+
+ /** 负责人姓名(回填用) */
+ private String contactName;
+
+ /** 负责人联系电话(回填用) */
+ private String contactPhone;
+
+ /** 负责人邮箱(回填用) */
+ private String contactEmail;
+
+ /** 收款类型(回填用) */
+ private String payeeType;
+
+ /** 银行卡号码面照片地址(回填用) */
+ private String bankCardImage;
+
+ /** 银行卡号(回填用) */
+ private String bankCardNo;
+
+ /** 开户城市(回填用) */
+ private String bankCity;
+
+ /** 审核状态(0待审核 1已通过 2已驳回;为空表示从未提交) */
+ private String auditStatus;
+
+ /** 驳回原因(审核状态为「已驳回」时有值) */
+ private String auditRemark;
+
+ public String getSubjectType()
+ {
+ return subjectType;
+ }
+
+ public void setSubjectType(String subjectType)
+ {
+ this.subjectType = subjectType;
+ }
+
+ public String getCompanyName()
+ {
+ return companyName;
+ }
+
+ public void setCompanyName(String companyName)
+ {
+ this.companyName = companyName;
+ }
+
+ public String getLicenseImage()
+ {
+ return licenseImage;
+ }
+
+ public void setLicenseImage(String licenseImage)
+ {
+ this.licenseImage = licenseImage;
+ }
+
+ public String getCreditCode()
+ {
+ return creditCode;
+ }
+
+ public void setCreditCode(String creditCode)
+ {
+ this.creditCode = creditCode;
+ }
+
+ public String getLicenseValidType()
+ {
+ return licenseValidType;
+ }
+
+ public void setLicenseValidType(String licenseValidType)
+ {
+ this.licenseValidType = licenseValidType;
+ }
+
+ public String getLicenseValidPeriod()
+ {
+ return licenseValidPeriod;
+ }
+
+ public void setLicenseValidPeriod(String licenseValidPeriod)
+ {
+ this.licenseValidPeriod = licenseValidPeriod;
+ }
+
+ public String getRegisteredRegion()
+ {
+ return registeredRegion;
+ }
+
+ public void setRegisteredRegion(String registeredRegion)
+ {
+ this.registeredRegion = registeredRegion;
+ }
+
+ public String getCompanyAddress()
+ {
+ return companyAddress;
+ }
+
+ public void setCompanyAddress(String companyAddress)
+ {
+ this.companyAddress = companyAddress;
+ }
+
+ public String getLegalPerson()
+ {
+ return legalPerson;
+ }
+
+ public void setLegalPerson(String legalPerson)
+ {
+ this.legalPerson = legalPerson;
+ }
+
+ public String getLegalIdCardFront()
+ {
+ return legalIdCardFront;
+ }
+
+ public void setLegalIdCardFront(String legalIdCardFront)
+ {
+ this.legalIdCardFront = legalIdCardFront;
+ }
+
+ public String getLegalIdCardBack()
+ {
+ return legalIdCardBack;
+ }
+
+ public void setLegalIdCardBack(String legalIdCardBack)
+ {
+ this.legalIdCardBack = legalIdCardBack;
+ }
+
+ public String getLegalIdValidType()
+ {
+ return legalIdValidType;
+ }
+
+ public void setLegalIdValidType(String legalIdValidType)
+ {
+ this.legalIdValidType = legalIdValidType;
+ }
+
+ public String getLegalIdValidPeriod()
+ {
+ return legalIdValidPeriod;
+ }
+
+ public void setLegalIdValidPeriod(String legalIdValidPeriod)
+ {
+ this.legalIdValidPeriod = legalIdValidPeriod;
+ }
+
+ public String getLegalIdAddress()
+ {
+ return legalIdAddress;
+ }
+
+ public void setLegalIdAddress(String legalIdAddress)
+ {
+ this.legalIdAddress = legalIdAddress;
+ }
+
+ public String getContactName()
+ {
+ return contactName;
+ }
+
+ public void setContactName(String contactName)
+ {
+ this.contactName = contactName;
+ }
+
+ public String getContactPhone()
+ {
+ return contactPhone;
+ }
+
+ public void setContactPhone(String contactPhone)
+ {
+ this.contactPhone = contactPhone;
+ }
+
+ public String getContactEmail()
+ {
+ return contactEmail;
+ }
+
+ public void setContactEmail(String contactEmail)
+ {
+ this.contactEmail = contactEmail;
+ }
+
+ public String getPayeeType()
+ {
+ return payeeType;
+ }
+
+ public void setPayeeType(String payeeType)
+ {
+ this.payeeType = payeeType;
+ }
+
+ public String getBankCardImage()
+ {
+ return bankCardImage;
+ }
+
+ public void setBankCardImage(String bankCardImage)
+ {
+ this.bankCardImage = bankCardImage;
+ }
+
+ public String getBankCardNo()
+ {
+ return bankCardNo;
+ }
+
+ public void setBankCardNo(String bankCardNo)
+ {
+ this.bankCardNo = bankCardNo;
+ }
+
+ public String getBankCity()
+ {
+ return bankCity;
+ }
+
+ public void setBankCity(String bankCity)
+ {
+ this.bankCity = bankCity;
+ }
+
+ public String getAuditStatus()
+ {
+ return auditStatus;
+ }
+
+ public void setAuditStatus(String auditStatus)
+ {
+ this.auditStatus = auditStatus;
+ }
+
+ public String getAuditRemark()
+ {
+ return auditRemark;
+ }
+
+ public void setAuditRemark(String auditRemark)
+ {
+ this.auditRemark = auditRemark;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/InviteCodeVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/InviteCodeVo.java
new file mode 100644
index 0000000..223c7ec
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/InviteCodeVo.java
@@ -0,0 +1,24 @@
+package com.ruoyi.user.domain.vo;
+
+/**
+ * 邀请码 VO(我的邀请页「邀请码」Tab 展示用)
+ *
+ * 仅下发本人邀请码原值;会员身份由服务端从登录态解析,不接受前端传入。
+ *
+ * @author hugray
+ */
+public class InviteCodeVo
+{
+ /** 本人邀请码(源自 account_user.my_referral_code,注册时生成,C 端会员必有) */
+ private String referralCode;
+
+ public String getReferralCode()
+ {
+ return referralCode;
+ }
+
+ public void setReferralCode(String referralCode)
+ {
+ this.referralCode = referralCode;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/MemberSalesStatisticsVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/MemberSalesStatisticsVo.java
new file mode 100644
index 0000000..e74a6c5
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/MemberSalesStatisticsVo.java
@@ -0,0 +1,144 @@
+package com.ruoyi.user.domain.vo;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 会员销售统计 VO(会员中心「销售统计」Tab 聚合出参)
+ *
+ * 金额单位一律「分」;无销售流水时三栏为 0、趋势全为 0、比例与排行为空数组。
+ *
+ * @author hugray
+ */
+public class MemberSalesStatisticsVo
+{
+ /** 当前会员手机号(脱敏,形如 136****6666) */
+ private String phone;
+
+ /** 会员等级(C / L1~L4) */
+ private String memberLevel;
+
+ /** 当月销售额(分)= SUM(account_sales_record.amount) */
+ private Long monthSalesAmount;
+
+ /** 当月进货额(分)= SUM(account_purchase_order.total_amount) */
+ private Long monthPurchaseAmount;
+
+ /** 当月利润额(分)= SUM(account_sales_record.profit_amount),可为负 */
+ private Long monthProfitAmount;
+
+ /** 当月销售单数 = COUNT(account_sales_record) */
+ private Integer monthSalesCount;
+
+ /** 当月利润环比上月提升百分比(无上月数据时为 0) */
+ private Integer profitGrowthRate;
+
+ /** 当月销售额趋势(按日补零,1 号到当日) */
+ private List<SalesTrendItemVo> salesTrend = new ArrayList<>();
+
+ /** 当月商品销售额比例 */
+ private List<ProductSalesRatioVo> productSalesRatio = new ArrayList<>();
+
+ /** 当月销售用户排行(Top 10) */
+ private List<SalesUserRankingVo> salesUserRanking = new ArrayList<>();
+
+ public String getPhone()
+ {
+ return phone;
+ }
+
+ public void setPhone(String phone)
+ {
+ this.phone = phone;
+ }
+
+ public String getMemberLevel()
+ {
+ return memberLevel;
+ }
+
+ public void setMemberLevel(String memberLevel)
+ {
+ this.memberLevel = memberLevel;
+ }
+
+ public Long getMonthSalesAmount()
+ {
+ return monthSalesAmount;
+ }
+
+ public void setMonthSalesAmount(Long monthSalesAmount)
+ {
+ this.monthSalesAmount = monthSalesAmount;
+ }
+
+ public Long getMonthPurchaseAmount()
+ {
+ return monthPurchaseAmount;
+ }
+
+ public void setMonthPurchaseAmount(Long monthPurchaseAmount)
+ {
+ this.monthPurchaseAmount = monthPurchaseAmount;
+ }
+
+ public Long getMonthProfitAmount()
+ {
+ return monthProfitAmount;
+ }
+
+ public void setMonthProfitAmount(Long monthProfitAmount)
+ {
+ this.monthProfitAmount = monthProfitAmount;
+ }
+
+ public Integer getMonthSalesCount()
+ {
+ return monthSalesCount;
+ }
+
+ public void setMonthSalesCount(Integer monthSalesCount)
+ {
+ this.monthSalesCount = monthSalesCount;
+ }
+
+ public Integer getProfitGrowthRate()
+ {
+ return profitGrowthRate;
+ }
+
+ public void setProfitGrowthRate(Integer profitGrowthRate)
+ {
+ this.profitGrowthRate = profitGrowthRate;
+ }
+
+ public List<SalesTrendItemVo> getSalesTrend()
+ {
+ return salesTrend;
+ }
+
+ public void setSalesTrend(List<SalesTrendItemVo> salesTrend)
+ {
+ this.salesTrend = salesTrend;
+ }
+
+ public List<ProductSalesRatioVo> getProductSalesRatio()
+ {
+ return productSalesRatio;
+ }
+
+ public void setProductSalesRatio(List<ProductSalesRatioVo> productSalesRatio)
+ {
+ this.productSalesRatio = productSalesRatio;
+ }
+
+ public List<SalesUserRankingVo> getSalesUserRanking()
+ {
+ return salesUserRanking;
+ }
+
+ public void setSalesUserRanking(List<SalesUserRankingVo> salesUserRanking)
+ {
+ this.salesUserRanking = salesUserRanking;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/MemberWarehouseVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/MemberWarehouseVo.java
new file mode 100644
index 0000000..23df1ce
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/MemberWarehouseVo.java
@@ -0,0 +1,89 @@
+package com.ruoyi.user.domain.vo;
+
+/**
+ * 我的仓库列表项 VO(会员中心「我的仓库」Tab)
+ *
+ * 商品图片列在 account_warehouse 不存在,本期恒为 null,由前端使用占位图。
+ *
+ * @author hugray
+ */
+public class MemberWarehouseVo
+{
+ /** 云仓行主键(进货时回传) */
+ private Long warehouseId;
+
+ /** 商品名称 */
+ private String productName;
+
+ /** 商品图片(本期恒为 null,前端用占位图) */
+ private String productImage;
+
+ /** 当前库存 */
+ private Long stockQty;
+
+ /** 最低需求库存 */
+ private Long minStockQty;
+
+ /** 低库存标记:stockQty < minStockQty 时为 true(前端标红 + 提示条) */
+ private Boolean lowStock;
+
+ public Long getWarehouseId()
+ {
+ return warehouseId;
+ }
+
+ public void setWarehouseId(Long warehouseId)
+ {
+ this.warehouseId = warehouseId;
+ }
+
+ public String getProductName()
+ {
+ return productName;
+ }
+
+ public void setProductName(String productName)
+ {
+ this.productName = productName;
+ }
+
+ public String getProductImage()
+ {
+ return productImage;
+ }
+
+ public void setProductImage(String productImage)
+ {
+ this.productImage = productImage;
+ }
+
+ public Long getStockQty()
+ {
+ return stockQty;
+ }
+
+ public void setStockQty(Long stockQty)
+ {
+ this.stockQty = stockQty;
+ }
+
+ public Long getMinStockQty()
+ {
+ return minStockQty;
+ }
+
+ public void setMinStockQty(Long minStockQty)
+ {
+ this.minStockQty = minStockQty;
+ }
+
+ public Boolean getLowStock()
+ {
+ return lowStock;
+ }
+
+ public void setLowStock(Boolean lowStock)
+ {
+ this.lowStock = lowStock;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerEarningStatVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerEarningStatVo.java
new file mode 100644
index 0000000..c8844e2
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerEarningStatVo.java
@@ -0,0 +1,37 @@
+package com.ruoyi.user.domain.vo;
+
+/**
+ * 服务商 子账号收益合计载体 VO(服务端内部聚合用,不直接对外返回)
+ *
+ * 由一条 SQL 同时聚合当月与上月收益合计,用于首页「当月合计收益」及其环比。
+ *
+ * @author hugray
+ */
+public class PartnerEarningStatVo
+{
+ /** 当月收益合计(分) */
+ private Long monthAmount;
+
+ /** 上月收益合计(分,用于环比) */
+ private Long prevMonthAmount;
+
+ public Long getMonthAmount()
+ {
+ return monthAmount;
+ }
+
+ public void setMonthAmount(Long monthAmount)
+ {
+ this.monthAmount = monthAmount;
+ }
+
+ public Long getPrevMonthAmount()
+ {
+ return prevMonthAmount;
+ }
+
+ public void setPrevMonthAmount(Long prevMonthAmount)
+ {
+ this.prevMonthAmount = prevMonthAmount;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerInviteStatVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerInviteStatVo.java
new file mode 100644
index 0000000..f70d870
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerInviteStatVo.java
@@ -0,0 +1,77 @@
+package com.ruoyi.user.domain.vo;
+
+/**
+ * 服务商 当月新邀请统计载体 VO(服务端内部聚合用,不直接对外返回)
+ *
+ * 统计口径:推荐人 ∈ 本服务商体系(服务商本人 + 名下全部子账号),
+ * 且被邀请会员的 referrer_bind_time 落在当月;等级按真实 system_level 计数(C 展示为 L0)。
+ *
+ * @author hugray
+ */
+public class PartnerInviteStatVo
+{
+ /** 当月新邀请总数 */
+ private Integer invitedTotal;
+
+ /** 新邀请会员中 system_level = 'C'(含空值)的人数,前端展示为 L0 */
+ private Integer l0;
+
+ /** 新邀请会员中 L1 人数 */
+ private Integer l1;
+
+ /** 新邀请会员中 L2 人数 */
+ private Integer l2;
+
+ /** 新邀请会员中 L3 人数 */
+ private Integer l3;
+
+ public Integer getInvitedTotal()
+ {
+ return invitedTotal;
+ }
+
+ public void setInvitedTotal(Integer invitedTotal)
+ {
+ this.invitedTotal = invitedTotal;
+ }
+
+ public Integer getL0()
+ {
+ return l0;
+ }
+
+ public void setL0(Integer l0)
+ {
+ this.l0 = l0;
+ }
+
+ public Integer getL1()
+ {
+ return l1;
+ }
+
+ public void setL1(Integer l1)
+ {
+ this.l1 = l1;
+ }
+
+ public Integer getL2()
+ {
+ return l2;
+ }
+
+ public void setL2(Integer l2)
+ {
+ this.l2 = l2;
+ }
+
+ public Integer getL3()
+ {
+ return l3;
+ }
+
+ public void setL3(Integer l3)
+ {
+ this.l3 = l3;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerOverviewVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerOverviewVo.java
new file mode 100644
index 0000000..b7ba639
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerOverviewVo.java
@@ -0,0 +1,251 @@
+package com.ruoyi.user.domain.vo;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 服务商首页概览 VO(小程序首页「经营驾驶舱」聚合出参)
+ *
+ * 由顶部公司名 + 两张卡片数据组成,一次性返回给前端,避免多次请求。
+ * 金额单位一律「分」,由前端负责格式化为元展示;计数缺省一律为 0,环比无基数时为 null。
+ *
+ * @author hugray
+ */
+public class PartnerOverviewVo
+{
+ /** 服务商公司全称(取 account_provider.company_name) */
+ private String companyName;
+
+ /** 卡片一:开通子账号数概览 */
+ private SubAccountSummary subAccount;
+
+ /** 卡片二:子账号业绩合计 */
+ private PerformanceSummary performance;
+
+ public String getCompanyName()
+ {
+ return companyName;
+ }
+
+ public void setCompanyName(String companyName)
+ {
+ this.companyName = companyName;
+ }
+
+ public SubAccountSummary getSubAccount()
+ {
+ return subAccount;
+ }
+
+ public void setSubAccount(SubAccountSummary subAccount)
+ {
+ this.subAccount = subAccount;
+ }
+
+ public PerformanceSummary getPerformance()
+ {
+ return performance;
+ }
+
+ public void setPerformance(PerformanceSummary performance)
+ {
+ this.performance = performance;
+ }
+
+ /**
+ * 卡片一「开通子账号数」
+ */
+ public static class SubAccountSummary
+ {
+ /** 子账号总数 */
+ private Integer totalCount = 0;
+
+ /** L1/L2/L3 等级分布(固定三段,无授权补 0) */
+ private List<LevelCountVo> levels = new ArrayList<>();
+
+ public Integer getTotalCount()
+ {
+ return totalCount;
+ }
+
+ public void setTotalCount(Integer totalCount)
+ {
+ this.totalCount = totalCount;
+ }
+
+ public List<LevelCountVo> getLevels()
+ {
+ return levels;
+ }
+
+ public void setLevels(List<LevelCountVo> levels)
+ {
+ this.levels = levels;
+ }
+ }
+
+ /**
+ * 卡片二「子账号业绩合计」
+ */
+ public static class PerformanceSummary
+ {
+ /** 当月合计收益(分)= 名下子账号当月 account_earning_record.amount 合计 */
+ private Long monthEarning = 0L;
+
+ /** 当月收益环比上月百分比(上月为 0 时为 null,前端隐藏) */
+ private Integer monthEarningTrend;
+
+ /** 当月新增绑定(个)= 名下 supplier_bind_time 落在当月的子账号数 */
+ private Integer monthNewBinding = 0;
+
+ /** 当月新增绑定环比上月百分比(上月为 0 时为 null) */
+ private Integer monthNewBindingTrend;
+
+ /** 当月总计销售额(分)= 名下子账号当月 account_sales_record.amount 合计 */
+ private Long monthSalesAmount = 0L;
+
+ /** 当月进货额(分)= 名下子账号当月 account_purchase_order.total_amount 合计 */
+ private Long monthPurchaseAmount = 0L;
+
+ /** 当月销售单数 = 名下子账号当月 account_sales_record 记录数 */
+ private Integer monthSalesCount = 0;
+
+ /** 当月新邀请总数(人)= 本服务商体系为推荐人新绑定推荐关系的会员数 */
+ private Integer monthNewInviteTotal = 0;
+
+ /** 新邀请会员等级分布(C/L1/L2/L3,固定四段补 0;C 前端渲染为 L0) */
+ private List<LevelCountVo> monthNewLevels = new ArrayList<>();
+
+ public Long getMonthEarning()
+ {
+ return monthEarning;
+ }
+
+ public void setMonthEarning(Long monthEarning)
+ {
+ this.monthEarning = monthEarning;
+ }
+
+ public Integer getMonthEarningTrend()
+ {
+ return monthEarningTrend;
+ }
+
+ public void setMonthEarningTrend(Integer monthEarningTrend)
+ {
+ this.monthEarningTrend = monthEarningTrend;
+ }
+
+ public Integer getMonthNewBinding()
+ {
+ return monthNewBinding;
+ }
+
+ public void setMonthNewBinding(Integer monthNewBinding)
+ {
+ this.monthNewBinding = monthNewBinding;
+ }
+
+ public Integer getMonthNewBindingTrend()
+ {
+ return monthNewBindingTrend;
+ }
+
+ public void setMonthNewBindingTrend(Integer monthNewBindingTrend)
+ {
+ this.monthNewBindingTrend = monthNewBindingTrend;
+ }
+
+ public Long getMonthSalesAmount()
+ {
+ return monthSalesAmount;
+ }
+
+ public void setMonthSalesAmount(Long monthSalesAmount)
+ {
+ this.monthSalesAmount = monthSalesAmount;
+ }
+
+ public Long getMonthPurchaseAmount()
+ {
+ return monthPurchaseAmount;
+ }
+
+ public void setMonthPurchaseAmount(Long monthPurchaseAmount)
+ {
+ this.monthPurchaseAmount = monthPurchaseAmount;
+ }
+
+ public Integer getMonthSalesCount()
+ {
+ return monthSalesCount;
+ }
+
+ public void setMonthSalesCount(Integer monthSalesCount)
+ {
+ this.monthSalesCount = monthSalesCount;
+ }
+
+ public Integer getMonthNewInviteTotal()
+ {
+ return monthNewInviteTotal;
+ }
+
+ public void setMonthNewInviteTotal(Integer monthNewInviteTotal)
+ {
+ this.monthNewInviteTotal = monthNewInviteTotal;
+ }
+
+ public List<LevelCountVo> getMonthNewLevels()
+ {
+ return monthNewLevels;
+ }
+
+ public void setMonthNewLevels(List<LevelCountVo> monthNewLevels)
+ {
+ this.monthNewLevels = monthNewLevels;
+ }
+ }
+
+ /**
+ * 等级计数(等级编码 + 人数)
+ */
+ public static class LevelCountVo
+ {
+ /** 等级编码(真实值:C / L1 / L2 / L3;展示层把 C 映射为 L0) */
+ private String level;
+
+ /** 该等级人数 */
+ private Integer count = 0;
+
+ public LevelCountVo()
+ {
+ }
+
+ public LevelCountVo(String level, Integer count)
+ {
+ this.level = level;
+ this.count = count;
+ }
+
+ public String getLevel()
+ {
+ return level;
+ }
+
+ public void setLevel(String level)
+ {
+ this.level = level;
+ }
+
+ public Integer getCount()
+ {
+ return count;
+ }
+
+ public void setCount(Integer count)
+ {
+ this.count = count;
+ }
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerSalesStatVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerSalesStatVo.java
new file mode 100644
index 0000000..a401f8a
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerSalesStatVo.java
@@ -0,0 +1,37 @@
+package com.ruoyi.user.domain.vo;
+
+/**
+ * 服务商 子账号销售统计载体 VO(服务端内部聚合用,不直接对外返回)
+ *
+ * 由一条 SQL 同时聚合当月子账号销售额与销售单数。
+ *
+ * @author hugray
+ */
+public class PartnerSalesStatVo
+{
+ /** 当月销售额合计(分) */
+ private Long salesAmount;
+
+ /** 当月销售单数 */
+ private Integer salesCount;
+
+ public Long getSalesAmount()
+ {
+ return salesAmount;
+ }
+
+ public void setSalesAmount(Long salesAmount)
+ {
+ this.salesAmount = salesAmount;
+ }
+
+ public Integer getSalesCount()
+ {
+ return salesCount;
+ }
+
+ public void setSalesCount(Integer salesCount)
+ {
+ this.salesCount = salesCount;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerSubAccountPageVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerSubAccountPageVo.java
new file mode 100644
index 0000000..15c3c9c
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerSubAccountPageVo.java
@@ -0,0 +1,67 @@
+package com.ruoyi.user.domain.vo;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 子账号分页出参 VO
+ *
+ * 按工程分页约定落地为 { list, total, pageNum, pageSize },
+ * 不用 ruoyi 的 TableDataInfo(其字段为 rows),以保持前端 data.list 读取方式不变。
+ *
+ * @author hugray
+ */
+public class PartnerSubAccountPageVo
+{
+ /** 总条数 */
+ private Long total = 0L;
+
+ /** 当前页列表 */
+ private List<PartnerSubAccountVo> list = new ArrayList<>();
+
+ /** 当前页码 */
+ private Integer pageNum = 1;
+
+ /** 每页条数 */
+ private Integer pageSize = 10;
+
+ public Long getTotal()
+ {
+ return total;
+ }
+
+ public void setTotal(Long total)
+ {
+ this.total = total;
+ }
+
+ public List<PartnerSubAccountVo> getList()
+ {
+ return list;
+ }
+
+ public void setList(List<PartnerSubAccountVo> list)
+ {
+ this.list = list;
+ }
+
+ public Integer getPageNum()
+ {
+ return pageNum;
+ }
+
+ public void setPageNum(Integer pageNum)
+ {
+ this.pageNum = pageNum;
+ }
+
+ public Integer getPageSize()
+ {
+ return pageSize;
+ }
+
+ public void setPageSize(Integer pageSize)
+ {
+ this.pageSize = pageSize;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerSubAccountStatVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerSubAccountStatVo.java
new file mode 100644
index 0000000..07a4c9a
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerSubAccountStatVo.java
@@ -0,0 +1,89 @@
+package com.ruoyi.user.domain.vo;
+
+/**
+ * 服务商 子账号统计载体 VO(服务端内部聚合用,不直接对外返回)
+ *
+ * 由一条 SQL 同时聚合:子账号总数、L1/L2/L3 分布、当月与上月新增绑定数。
+ *
+ * @author hugray
+ */
+public class PartnerSubAccountStatVo
+{
+ /** 子账号总数 */
+ private Integer total;
+
+ /** L1 子账号数 */
+ private Integer l1;
+
+ /** L2 子账号数 */
+ private Integer l2;
+
+ /** L3 子账号数 */
+ private Integer l3;
+
+ /** 当月新增绑定数(supplier_bind_time 落在当月) */
+ private Integer monthNew;
+
+ /** 上月新增绑定数(用于环比) */
+ private Integer prevMonthNew;
+
+ public Integer getTotal()
+ {
+ return total;
+ }
+
+ public void setTotal(Integer total)
+ {
+ this.total = total;
+ }
+
+ public Integer getL1()
+ {
+ return l1;
+ }
+
+ public void setL1(Integer l1)
+ {
+ this.l1 = l1;
+ }
+
+ public Integer getL2()
+ {
+ return l2;
+ }
+
+ public void setL2(Integer l2)
+ {
+ this.l2 = l2;
+ }
+
+ public Integer getL3()
+ {
+ return l3;
+ }
+
+ public void setL3(Integer l3)
+ {
+ this.l3 = l3;
+ }
+
+ public Integer getMonthNew()
+ {
+ return monthNew;
+ }
+
+ public void setMonthNew(Integer monthNew)
+ {
+ this.monthNew = monthNew;
+ }
+
+ public Integer getPrevMonthNew()
+ {
+ return prevMonthNew;
+ }
+
+ public void setPrevMonthNew(Integer prevMonthNew)
+ {
+ this.prevMonthNew = prevMonthNew;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerSubAccountVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerSubAccountVo.java
new file mode 100644
index 0000000..c63be7f
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PartnerSubAccountVo.java
@@ -0,0 +1,77 @@
+package com.ruoyi.user.domain.vo;
+
+/**
+ * 子账号列表项 VO(服务商端「子账号管理」列表)
+ *
+ * 仅暴露列表展示所需的最小字段;昵称可能为空(子账号尚未完善资料),
+ * 前端在昵称为空时回退展示手机号。
+ *
+ * @author hugray
+ */
+public class PartnerSubAccountVo
+{
+ /** 子账号会员ID(account_user.user_id) */
+ private Long userId;
+
+ /** 昵称(可为空,前端回退展示手机号) */
+ private String nickname;
+
+ /** 登录手机号 */
+ private String phone;
+
+ /** 系统等级(L1/L2/L3) */
+ private String systemLevel;
+
+ /** 头像地址(可为空,前端用占位图) */
+ private String avatar;
+
+ public Long getUserId()
+ {
+ return userId;
+ }
+
+ public void setUserId(Long userId)
+ {
+ this.userId = userId;
+ }
+
+ public String getNickname()
+ {
+ return nickname;
+ }
+
+ public void setNickname(String nickname)
+ {
+ this.nickname = nickname;
+ }
+
+ public String getPhone()
+ {
+ return phone;
+ }
+
+ public void setPhone(String phone)
+ {
+ this.phone = phone;
+ }
+
+ public String getSystemLevel()
+ {
+ return systemLevel;
+ }
+
+ public void setSystemLevel(String systemLevel)
+ {
+ this.systemLevel = systemLevel;
+ }
+
+ public String getAvatar()
+ {
+ return avatar;
+ }
+
+ public void setAvatar(String avatar)
+ {
+ this.avatar = avatar;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/ProductSalesRatioVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/ProductSalesRatioVo.java
new file mode 100644
index 0000000..8ab2a64
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/ProductSalesRatioVo.java
@@ -0,0 +1,35 @@
+package com.ruoyi.user.domain.vo;
+
+/**
+ * 商品销售额比例项 VO(销售统计饼图数据点)
+ *
+ * @author hugray
+ */
+public class ProductSalesRatioVo
+{
+ /** 商品名称 */
+ private String productName;
+
+ /** 该商品当月销售额(分) */
+ private Long amount;
+
+ public String getProductName()
+ {
+ return productName;
+ }
+
+ public void setProductName(String productName)
+ {
+ this.productName = productName;
+ }
+
+ public Long getAmount()
+ {
+ return amount;
+ }
+
+ public void setAmount(Long amount)
+ {
+ this.amount = amount;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PurchaseOrderVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PurchaseOrderVo.java
new file mode 100644
index 0000000..33c6c16
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/PurchaseOrderVo.java
@@ -0,0 +1,130 @@
+package com.ruoyi.user.domain.vo;
+
+import java.util.Date;
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+/**
+ * 进货订单项 VO(「进货订单」列表)
+ *
+ * @author hugray
+ */
+public class PurchaseOrderVo
+{
+ /** 进货单主键 */
+ private Long orderId;
+
+ /** 进货单号 */
+ private String orderNo;
+
+ /** 商品名称 */
+ private String productName;
+
+ /** 进货数量 */
+ private Long qty;
+
+ /** 进货单价(分) */
+ private Long unitPrice;
+
+ /** 进货金额(分)= qty × unitPrice */
+ private Long totalAmount;
+
+ /** 状态(1已入库) */
+ private String status;
+
+ /** 状态中文名 */
+ private String statusName;
+
+ /** 下单时间 */
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private Date createTime;
+
+ public Long getOrderId()
+ {
+ return orderId;
+ }
+
+ public void setOrderId(Long orderId)
+ {
+ this.orderId = orderId;
+ }
+
+ public String getOrderNo()
+ {
+ return orderNo;
+ }
+
+ public void setOrderNo(String orderNo)
+ {
+ this.orderNo = orderNo;
+ }
+
+ public String getProductName()
+ {
+ return productName;
+ }
+
+ public void setProductName(String productName)
+ {
+ this.productName = productName;
+ }
+
+ public Long getQty()
+ {
+ return qty;
+ }
+
+ public void setQty(Long qty)
+ {
+ this.qty = qty;
+ }
+
+ public Long getUnitPrice()
+ {
+ return unitPrice;
+ }
+
+ public void setUnitPrice(Long unitPrice)
+ {
+ this.unitPrice = unitPrice;
+ }
+
+ public Long getTotalAmount()
+ {
+ return totalAmount;
+ }
+
+ public void setTotalAmount(Long totalAmount)
+ {
+ this.totalAmount = totalAmount;
+ }
+
+ public String getStatus()
+ {
+ return status;
+ }
+
+ public void setStatus(String status)
+ {
+ this.status = status;
+ }
+
+ public String getStatusName()
+ {
+ return statusName;
+ }
+
+ public void setStatusName(String statusName)
+ {
+ this.statusName = statusName;
+ }
+
+ public Date getCreateTime()
+ {
+ return createTime;
+ }
+
+ public void setCreateTime(Date createTime)
+ {
+ this.createTime = createTime;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/SalesMonthSummaryVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/SalesMonthSummaryVo.java
new file mode 100644
index 0000000..fb6ada8
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/SalesMonthSummaryVo.java
@@ -0,0 +1,63 @@
+package com.ruoyi.user.domain.vo;
+
+/**
+ * 销售月汇总 VO(服务端内部聚合载体,不直接对外返回)
+ *
+ * 单条 SQL 同时聚合本月销售额 / 利润额 / 单数与上月利润额,避免多次数据库往返。
+ *
+ * @author hugray
+ */
+public class SalesMonthSummaryVo
+{
+ /** 本月销售额(分) */
+ private Long salesAmount;
+
+ /** 本月利润额(分,可为负) */
+ private Long profitAmount;
+
+ /** 本月销售单数 */
+ private Integer salesCount;
+
+ /** 上月利润额(分,用于环比计算) */
+ private Long prevMonthProfit;
+
+ public Long getSalesAmount()
+ {
+ return salesAmount;
+ }
+
+ public void setSalesAmount(Long salesAmount)
+ {
+ this.salesAmount = salesAmount;
+ }
+
+ public Long getProfitAmount()
+ {
+ return profitAmount;
+ }
+
+ public void setProfitAmount(Long profitAmount)
+ {
+ this.profitAmount = profitAmount;
+ }
+
+ public Integer getSalesCount()
+ {
+ return salesCount;
+ }
+
+ public void setSalesCount(Integer salesCount)
+ {
+ this.salesCount = salesCount;
+ }
+
+ public Long getPrevMonthProfit()
+ {
+ return prevMonthProfit;
+ }
+
+ public void setPrevMonthProfit(Long prevMonthProfit)
+ {
+ this.prevMonthProfit = prevMonthProfit;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/SalesTrendItemVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/SalesTrendItemVo.java
new file mode 100644
index 0000000..26a7bcd
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/SalesTrendItemVo.java
@@ -0,0 +1,58 @@
+package com.ruoyi.user.domain.vo;
+
+/**
+ * 销售额趋势项 VO(销售统计折线图数据点)
+ *
+ * @author hugray
+ */
+public class SalesTrendItemVo
+{
+ /** 日标签(对外展示,形如 1号 / 5号) */
+ private String day;
+
+ /** 月内第几天(1~31,仅用于服务端补零对齐,不对外使用) */
+ private Integer dayOfMonth;
+
+ /** 当日销售额(分) */
+ private Long amount;
+
+ public SalesTrendItemVo()
+ {
+ }
+
+ public SalesTrendItemVo(String day, Long amount)
+ {
+ this.day = day;
+ this.amount = amount;
+ }
+
+ public String getDay()
+ {
+ return day;
+ }
+
+ public void setDay(String day)
+ {
+ this.day = day;
+ }
+
+ public Integer getDayOfMonth()
+ {
+ return dayOfMonth;
+ }
+
+ public void setDayOfMonth(Integer dayOfMonth)
+ {
+ this.dayOfMonth = dayOfMonth;
+ }
+
+ public Long getAmount()
+ {
+ return amount;
+ }
+
+ public void setAmount(Long amount)
+ {
+ this.amount = amount;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/SalesUserRankingVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/SalesUserRankingVo.java
new file mode 100644
index 0000000..8499d36
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/SalesUserRankingVo.java
@@ -0,0 +1,63 @@
+package com.ruoyi.user.domain.vo;
+
+/**
+ * 销售用户排行项 VO(销售统计排行列表)
+ *
+ * 手机号下发前已脱敏;昵称可为空,前端回退展示脱敏手机号。
+ *
+ * @author hugray
+ */
+public class SalesUserRankingVo
+{
+ /** 名次(从 1 开始) */
+ private Integer rank;
+
+ /** 购买人昵称(可为空) */
+ private String nickname;
+
+ /** 购买人手机号(脱敏,形如 138****4561) */
+ private String phone;
+
+ /** 该用户当月购买额(分) */
+ private Long amount;
+
+ public Integer getRank()
+ {
+ return rank;
+ }
+
+ public void setRank(Integer rank)
+ {
+ this.rank = rank;
+ }
+
+ public String getNickname()
+ {
+ return nickname;
+ }
+
+ public void setNickname(String nickname)
+ {
+ this.nickname = nickname;
+ }
+
+ public String getPhone()
+ {
+ return phone;
+ }
+
+ public void setPhone(String phone)
+ {
+ this.phone = phone;
+ }
+
+ public Long getAmount()
+ {
+ return amount;
+ }
+
+ public void setAmount(Long amount)
+ {
+ this.amount = amount;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/SubAccountQuotaVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/SubAccountQuotaVo.java
new file mode 100644
index 0000000..69abdea
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/SubAccountQuotaVo.java
@@ -0,0 +1,57 @@
+package com.ruoyi.user.domain.vo;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * 子账号剩余可开通额度 VO(服务商端「新增账号」页顶部提示)
+ *
+ * 字段 JSON 名固定为大写 L1/L2/L3(前端直接以 data.L1 读取),
+ * 故 Java 字段小写并用 @JsonProperty 显式改名,避免依赖 Jackson 的 getter 推断。
+ * 剩余额度 = account_provider 权益 − 已开通数,下限 0。
+ *
+ * @author hugray
+ */
+public class SubAccountQuotaVo
+{
+ /** L1 剩余可开通数 */
+ @JsonProperty("L1")
+ private Integer l1 = 0;
+
+ /** L2 剩余可开通数 */
+ @JsonProperty("L2")
+ private Integer l2 = 0;
+
+ /** L3 剩余可开通数 */
+ @JsonProperty("L3")
+ private Integer l3 = 0;
+
+ public Integer getL1()
+ {
+ return l1;
+ }
+
+ public void setL1(Integer l1)
+ {
+ this.l1 = l1;
+ }
+
+ public Integer getL2()
+ {
+ return l2;
+ }
+
+ public void setL2(Integer l2)
+ {
+ this.l2 = l2;
+ }
+
+ public Integer getL3()
+ {
+ return l3;
+ }
+
+ public void setL3(Integer l3)
+ {
+ this.l3 = l3;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/WarehouseRecordVo.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/WarehouseRecordVo.java
new file mode 100644
index 0000000..ab26840
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/WarehouseRecordVo.java
@@ -0,0 +1,117 @@
+package com.ruoyi.user.domain.vo;
+
+import java.util.Date;
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+/**
+ * 云仓变动记录项 VO(「仓库记录」弹层列表)
+ *
+ * @author hugray
+ */
+public class WarehouseRecordVo
+{
+ /** 流水主键 */
+ private Long recordId;
+
+ /** 商品名称(变动时快照) */
+ private String productName;
+
+ /** 变动类型(1进货入库 2销售出库 3库存调整) */
+ private String changeType;
+
+ /** 变动类型中文名(由枚举下发) */
+ private String changeTypeName;
+
+ /** 变动数量(入库为正,出库为负) */
+ private Long changeQty;
+
+ /** 变动后库存 */
+ private Long afterQty;
+
+ /** 备注(进货单号等) */
+ private String remark;
+
+ /** 变动时间 */
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private Date createTime;
+
+ public Long getRecordId()
+ {
+ return recordId;
+ }
+
+ public void setRecordId(Long recordId)
+ {
+ this.recordId = recordId;
+ }
+
+ public String getProductName()
+ {
+ return productName;
+ }
+
+ public void setProductName(String productName)
+ {
+ this.productName = productName;
+ }
+
+ public String getChangeType()
+ {
+ return changeType;
+ }
+
+ public void setChangeType(String changeType)
+ {
+ this.changeType = changeType;
+ }
+
+ public String getChangeTypeName()
+ {
+ return changeTypeName;
+ }
+
+ public void setChangeTypeName(String changeTypeName)
+ {
+ this.changeTypeName = changeTypeName;
+ }
+
+ public Long getChangeQty()
+ {
+ return changeQty;
+ }
+
+ public void setChangeQty(Long changeQty)
+ {
+ this.changeQty = changeQty;
+ }
+
+ public Long getAfterQty()
+ {
+ return afterQty;
+ }
+
+ public void setAfterQty(Long afterQty)
+ {
+ this.afterQty = afterQty;
+ }
+
+ public String getRemark()
+ {
+ return remark;
+ }
+
+ public void setRemark(String remark)
+ {
+ this.remark = remark;
+ }
+
+ public Date getCreateTime()
+ {
+ return createTime;
+ }
+
+ public void setCreateTime(Date createTime)
+ {
+ this.createTime = createTime;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/enums/PurchaseOrderStatusEnum.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/enums/PurchaseOrderStatusEnum.java
new file mode 100644
index 0000000..340bd35
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/enums/PurchaseOrderStatusEnum.java
@@ -0,0 +1,66 @@
+package com.ruoyi.user.enums;
+
+/**
+ * 进货单状态枚举
+ *
+ * 与 account_purchase_order.status 字段一一对应。本期进货即入库,仅一种终态。
+ *
+ * @author hugray
+ */
+public enum PurchaseOrderStatusEnum
+{
+ /** 已入库 */
+ STOCKED("1", "已入库");
+
+ /** 状态编码(落库值) */
+ private final String code;
+
+ /** 状态描述 */
+ private final String desc;
+
+ PurchaseOrderStatusEnum(String code, String desc)
+ {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ public String getCode()
+ {
+ return code;
+ }
+
+ public String getDesc()
+ {
+ return desc;
+ }
+
+ /**
+ * 按编码获取枚举
+ *
+ * @param code 状态编码
+ * @return 命中返回枚举;未命中返回 null
+ */
+ public static PurchaseOrderStatusEnum getByCode(String code)
+ {
+ for (PurchaseOrderStatusEnum status : values())
+ {
+ if (status.code.equals(code))
+ {
+ return status;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 按编码取描述,未命中返回空字符串,避免前端展示 null
+ *
+ * @param code 状态编码
+ * @return 状态描述
+ */
+ public static String descOf(String code)
+ {
+ PurchaseOrderStatusEnum status = getByCode(code);
+ return status == null ? "" : status.getDesc();
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/enums/WarehouseChangeTypeEnum.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/enums/WarehouseChangeTypeEnum.java
new file mode 100644
index 0000000..740af6c
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/enums/WarehouseChangeTypeEnum.java
@@ -0,0 +1,70 @@
+package com.ruoyi.user.enums;
+
+/**
+ * 云仓变动类型枚举
+ *
+ * 与 account_warehouse_record.change_type 字段一一对应。
+ *
+ * @author hugray
+ */
+public enum WarehouseChangeTypeEnum
+{
+ /** 进货入库(数量为正) */
+ PURCHASE_IN("1", "进货入库"),
+ /** 销售出库(数量为负) */
+ SALE_OUT("2", "销售出库"),
+ /** 库存调整(可正可负) */
+ ADJUST("3", "库存调整");
+
+ /** 类型编码(落库值) */
+ private final String code;
+
+ /** 类型描述 */
+ private final String desc;
+
+ WarehouseChangeTypeEnum(String code, String desc)
+ {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ public String getCode()
+ {
+ return code;
+ }
+
+ public String getDesc()
+ {
+ return desc;
+ }
+
+ /**
+ * 按编码获取枚举
+ *
+ * @param code 类型编码
+ * @return 命中返回枚举;未命中返回 null
+ */
+ public static WarehouseChangeTypeEnum getByCode(String code)
+ {
+ for (WarehouseChangeTypeEnum type : values())
+ {
+ if (type.code.equals(code))
+ {
+ return type;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 按编码取描述,未命中返回空字符串,避免前端展示 null
+ *
+ * @param code 类型编码
+ * @return 类型描述
+ */
+ public static String descOf(String code)
+ {
+ WarehouseChangeTypeEnum type = getByCode(code);
+ return type == null ? "" : type.getDesc();
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountAddressMapper.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountAddressMapper.java
index 6f8c35f..8bd9b9e 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountAddressMapper.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountAddressMapper.java
@@ -16,6 +16,13 @@
public List<AccountAddress> selectAddressByUserId(Long userId);
/**
+ * 根据主键查询单条收货地址(仅查未删除)
+ *
+ * C端详情与删除前的归属校验复用;查不到返回 null。
+ */
+ public AccountAddress selectAddressById(Long id);
+
+ /**
* 新增收货地址
*/
public int insertAddress(AccountAddress accountAddress);
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountCertificationApplyMapper.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountCertificationApplyMapper.java
index 12411ef..d2ad5e7 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountCertificationApplyMapper.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountCertificationApplyMapper.java
@@ -25,4 +25,37 @@
* 审核(只更新审核结果 4 列,条件带 audit_status='0' 作并发闸门)
*/
public int auditCertificationApply(AccountCertificationApply apply);
+
+ /**
+ * 新增认证申请(C端会员提交,回填自增主键作为申请号)
+ */
+ public int insertCertificationApply(AccountCertificationApply apply);
+
+ /**
+ * 统计该会员待审核的「个人」认证申请数(>0 即视为重复提交,用于提交前防重)
+ *
+ * 限 subject_type='1':个人与企业认证各自独立防重,互不干扰。
+ */
+ public int countPendingPersonalApplyByUserId(Long userId);
+
+ /**
+ * 统计该会员待审核的「企业/个体」认证申请数(>0 即视为重复提交,用于提交前防重)
+ *
+ * 限 subject_type in ('2','3')。
+ */
+ public int countPendingCompanyApplyByUserId(Long userId);
+
+ /**
+ * 查询该会员最近一次「个人」认证申请(C端进入认证页时回填资料与状态;无申请返回 null)
+ *
+ * 限 subject_type='1'。
+ */
+ public AccountCertificationApply selectLatestPersonalApplyByUserId(Long userId);
+
+ /**
+ * 查询该会员最近一次「企业/个体」认证申请(C端进入企业认证向导页时回填;无申请返回 null)
+ *
+ * 限 subject_type in ('2','3')。
+ */
+ public AccountCertificationApply selectLatestCompanyApplyByUserId(Long userId);
}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountPartnerMapper.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountPartnerMapper.java
new file mode 100644
index 0000000..3483432
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountPartnerMapper.java
@@ -0,0 +1,120 @@
+package com.ruoyi.user.mapper;
+
+import java.util.Date;
+import java.util.List;
+import org.apache.ibatis.annotations.Param;
+import com.ruoyi.user.domain.vo.PartnerEarningStatVo;
+import com.ruoyi.user.domain.vo.PartnerInviteStatVo;
+import com.ruoyi.user.domain.vo.PartnerSalesStatVo;
+import com.ruoyi.user.domain.vo.PartnerSubAccountStatVo;
+import com.ruoyi.user.domain.vo.PartnerSubAccountVo;
+
+/**
+ * 服务商首页概览 数据层
+ *
+ * 全部聚合均以 account_user.supplier_id = #{providerUserId} 圈定「名下子账号」范围,
+ * 命中既有索引 idx_account_user_supplier / idx_earning_user_time /
+ * idx_sales_seller_time / idx_purchase_user_time;
+ * 时间区间由 Service 侧算好传入,避免 SQL 内做日期方言运算。
+ *
+ * @author hugray
+ */
+public interface AccountPartnerMapper
+{
+ /**
+ * 聚合子账号总数 / L1-L3 等级分布 / 当月与上月新增绑定数(单条 SQL 取回,避免多次数据库往返)
+ *
+ * 三者同源 account_user(同 where 条件),故合并为一条 SQL。
+ *
+ * @param providerUserId 服务商账号ID
+ * @param monthStart 当月起始(含)
+ * @param nextMonthStart 次月起始(不含)
+ * @param prevMonthStart 上月起始(含)
+ * @return 子账号统计(无数据时各栏为 0)
+ */
+ public PartnerSubAccountStatVo selectSubAccountStat(@Param("providerUserId") Long providerUserId,
+ @Param("monthStart") Date monthStart,
+ @Param("nextMonthStart") Date nextMonthStart,
+ @Param("prevMonthStart") Date prevMonthStart);
+
+ /**
+ * 聚合名下子账号当月与上月收益合计(来自 account_earning_record)
+ *
+ * @param providerUserId 服务商账号ID
+ * @param monthStart 当月起始(含)
+ * @param nextMonthStart 次月起始(不含)
+ * @param prevMonthStart 上月起始(含)
+ * @return 收益合计(单位:分;无流水时各栏为 0)
+ */
+ public PartnerEarningStatVo selectEarningMonthStat(@Param("providerUserId") Long providerUserId,
+ @Param("monthStart") Date monthStart,
+ @Param("nextMonthStart") Date nextMonthStart,
+ @Param("prevMonthStart") Date prevMonthStart);
+
+ /**
+ * 聚合名下子账号当月销售额与销售单数(来自 account_sales_record)
+ *
+ * @param providerUserId 服务商账号ID
+ * @param monthStart 当月起始(含)
+ * @param nextMonthStart 次月起始(不含)
+ * @return 销售额(分)与单数(无流水时各栏为 0)
+ */
+ public PartnerSalesStatVo selectSalesMonthStat(@Param("providerUserId") Long providerUserId,
+ @Param("monthStart") Date monthStart,
+ @Param("nextMonthStart") Date nextMonthStart);
+
+ /**
+ * 聚合名下子账号当月进货额(来自 account_purchase_order)
+ *
+ * @param providerUserId 服务商账号ID
+ * @param monthStart 当月起始(含)
+ * @param nextMonthStart 次月起始(不含)
+ * @return 进货额(单位:分;无流水时为 0)
+ */
+ public Long selectPurchaseMonthAmount(@Param("providerUserId") Long providerUserId,
+ @Param("monthStart") Date monthStart,
+ @Param("nextMonthStart") Date nextMonthStart);
+
+ /**
+ * 聚合当月新邀请总数与等级分布(推荐人 ∈ 本服务商体系)
+ *
+ * 推荐人范围含服务商本人与名下全部子账号;时间按被邀请会员的 referrer_bind_time 归月。
+ *
+ * @param providerUserId 服务商账号ID
+ * @param monthStart 当月起始(含)
+ * @param nextMonthStart 次月起始(不含)
+ * @return 新邀请总数与 C/L1/L2/L3 分布(无数据时各栏为 0)
+ */
+ public PartnerInviteStatVo selectInviteMonthStat(@Param("providerUserId") Long providerUserId,
+ @Param("monthStart") Date monthStart,
+ @Param("nextMonthStart") Date nextMonthStart);
+
+ /**
+ * 查询名下子账号列表(供「子账号管理」分页使用)
+ *
+ * 范围:supplier_id = 当前服务商、del_flag = '0'、system_level ∈ {L1,L2,L3};
+ * level 非空时再按等级过滤(仅接受 L1/L2/L3,非法值由服务层归一为全部);
+ * keyword 非空时按昵称/手机号模糊匹配(通配符已由服务层转义);
+ * 按 user_id 倒序(新开通在前)。分页由 Controller 侧 startPage() 织入,语句本身不挂 join,
+ * 避免干扰 PageHelper 的 count SQL。
+ *
+ * @param providerUserId 服务商账号ID
+ * @param level 等级筛选(可空;L1/L2/L3)
+ * @param keyword 昵称/手机号搜索关键字(可空;已转义 like 通配符)
+ * @return 子账号列表(分页)
+ */
+ public List<PartnerSubAccountVo> selectSubAccountList(@Param("providerUserId") Long providerUserId,
+ @Param("level") String level,
+ @Param("keyword") String keyword);
+
+ /**
+ * 统计名下子账号各等级已开通数(供剩余额度计算)
+ *
+ * 与列表同一口径(supplier_id + del_flag = '0' + system_level ∈ {L1,L2,L3}),
+ * 只为取 l1/l2/l3 计数,故不涉及月份区间列。
+ *
+ * @param providerUserId 服务商账号ID
+ * @return 各等级已开通数(无数据时各栏为 0)
+ */
+ public PartnerSubAccountStatVo selectSubAccountLevelCount(@Param("providerUserId") Long providerUserId);
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountProviderMapper.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountProviderMapper.java
index 4aa4cd9..9a4c53c 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountProviderMapper.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountProviderMapper.java
@@ -22,6 +22,14 @@
public AccountProviderVo selectProviderById(Long id);
/**
+ * 根据关联账号ID查询服务商档案(小程序首页用:取公司名与资格状态)
+ *
+ * @param userId 服务商账号ID(account_user.user_id)
+ * @return 服务商档案(无档案返回 null)
+ */
+ public AccountProvider selectProviderByUserId(Long userId);
+
+ /**
* 新增服务商配置(user_id 由调用方先建账号后回填)
*/
public int insertProvider(AccountProvider provider);
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountPurchaseOrderMapper.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountPurchaseOrderMapper.java
new file mode 100644
index 0000000..0d658bf
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountPurchaseOrderMapper.java
@@ -0,0 +1,42 @@
+package com.ruoyi.user.mapper;
+
+import java.util.Date;
+import java.util.List;
+import org.apache.ibatis.annotations.Param;
+import com.ruoyi.user.domain.AccountPurchaseOrder;
+
+/**
+ * 会员进货单 数据层
+ *
+ * @author hugray
+ */
+public interface AccountPurchaseOrderMapper
+{
+ /**
+ * 新增进货单(落单即入库,order_no 唯一索引兜底幂等)
+ *
+ * @param accountPurchaseOrder 进货单
+ * @return 影响行数
+ */
+ public int insertPurchaseOrder(AccountPurchaseOrder accountPurchaseOrder);
+
+ /**
+ * 聚合本月进货额
+ *
+ * @param userId 会员ID
+ * @param monthStart 本月起始(含)
+ * @param nextMonthStart 次月起始(不含)
+ * @return 本月进货额(单位:分;无进货时为 0)
+ */
+ public Long selectMonthPurchaseAmount(@Param("userId") Long userId,
+ @Param("monthStart") Date monthStart,
+ @Param("nextMonthStart") Date nextMonthStart);
+
+ /**
+ * 查询指定会员的进货单列表(按进货时间倒序,配合 startPage 分页)
+ *
+ * @param userId 会员ID
+ * @return 进货单列表
+ */
+ public List<AccountPurchaseOrder> selectPurchaseOrderList(@Param("userId") Long userId);
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountSalesRecordMapper.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountSalesRecordMapper.java
new file mode 100644
index 0000000..e6fd7c2
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountSalesRecordMapper.java
@@ -0,0 +1,72 @@
+package com.ruoyi.user.mapper;
+
+import java.util.Date;
+import java.util.List;
+import org.apache.ibatis.annotations.Param;
+import com.ruoyi.user.domain.vo.ProductSalesRatioVo;
+import com.ruoyi.user.domain.vo.SalesMonthSummaryVo;
+import com.ruoyi.user.domain.vo.SalesTrendItemVo;
+import com.ruoyi.user.domain.vo.SalesUserRankingVo;
+
+/**
+ * 会员销售流水 数据层
+ *
+ * 全部为聚合查询,命中 idx_sales_seller_time(seller_id, sale_time)联合索引;
+ * 时间区间由 Service 传入月初 / 次月初 / 上月初,避免 SQL 内做日期方言运算。
+ *
+ * @author hugray
+ */
+public interface AccountSalesRecordMapper
+{
+ /**
+ * 聚合本月销售额 / 利润额 / 单数与上月利润额(单条 SQL 取回,避免多次数据库往返)
+ *
+ * @param sellerId 销售会员ID
+ * @param monthStart 本月起始(含)
+ * @param nextMonthStart 次月起始(不含,用于划定本月上界)
+ * @param prevMonthStart 上月起始(含,用于上月利润环比口径)
+ * @return 月汇总(单位:分;无流水时各栏为 0)
+ */
+ public SalesMonthSummaryVo selectMonthSummary(@Param("sellerId") Long sellerId,
+ @Param("monthStart") Date monthStart,
+ @Param("nextMonthStart") Date nextMonthStart,
+ @Param("prevMonthStart") Date prevMonthStart);
+
+ /**
+ * 按日聚合本月销售额(趋势折线图原始数据,缺省日由 Service 补零)
+ *
+ * @param sellerId 销售会员ID
+ * @param monthStart 本月起始(含)
+ * @param nextMonthStart 次月起始(不含)
+ * @return 按日升序的趋势行(仅含当月有销售的日期)
+ */
+ public List<SalesTrendItemVo> selectDailyTrend(@Param("sellerId") Long sellerId,
+ @Param("monthStart") Date monthStart,
+ @Param("nextMonthStart") Date nextMonthStart);
+
+ /**
+ * 按商品聚合本月销售额(比例饼图数据,降序)
+ *
+ * @param sellerId 销售会员ID
+ * @param monthStart 本月起始(含)
+ * @param nextMonthStart 次月起始(不含)
+ * @return 商品销售额列表(降序)
+ */
+ public List<ProductSalesRatioVo> selectProductSalesRatio(@Param("sellerId") Long sellerId,
+ @Param("monthStart") Date monthStart,
+ @Param("nextMonthStart") Date nextMonthStart);
+
+ /**
+ * 按购买人聚合本月购买额(销售排行,降序取前 10)
+ *
+ * 手机号在 Service 下发前脱敏;由 SQL 侧 LIMIT 控制取数规模。
+ *
+ * @param sellerId 销售会员ID
+ * @param monthStart 本月起始(含)
+ * @param nextMonthStart 次月起始(不含)
+ * @return 排行列表(降序,最多 10 条;rank 由 Service 填充)
+ */
+ public List<SalesUserRankingVo> selectSalesUserRanking(@Param("sellerId") Long sellerId,
+ @Param("monthStart") Date monthStart,
+ @Param("nextMonthStart") Date nextMonthStart);
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountUserMapper.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountUserMapper.java
index 6405a90..ef0b1e8 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountUserMapper.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountUserMapper.java
@@ -80,6 +80,24 @@
public int updateUserAuthStatus(AccountUser accountUser);
/**
+ * 修改企业/个体认证状态(只更新 company_auth_status 列,企业认证审核通过/驳回时回写)
+ *
+ * 与 updateUserAuthStatus 的区别:个人认证写 auth_status,企业/个体认证写 company_auth_status,
+ * 两者互不覆盖。审核通过时一并把 subject_type 同步为 2/3(由 XML 的 <if> 控制),
+ * 驳回则不动 subject_type——避免审核未通过就把会员标成企业主体。
+ */
+ public int updateUserCompanyAuthStatus(AccountUser accountUser);
+
+ /**
+ * 修改会员头像(只更新 avatar 一列)
+ *
+ * 刻意单开:avatar 若挂到共享的 updateAccountUser 上,后台编辑接口(/account/user)
+ * 的请求体带个 avatar 就能改任意会员头像。与 updateUserStatus / updateUserAuthStatus 同一套路,
+ * 头像只能由会员本人在 C 端接口里改写。
+ */
+ public int updateUserAvatar(AccountUser accountUser);
+
+ /**
* 修改服务商标识(只更新 provider_flag 列,服务商建档时置 '1')
*
* 刻意不做进 insertAccountUser / updateAccountUser 的动态列:那两个是共享入口,
@@ -104,6 +122,22 @@
public List<MemberLevelCountVo> selectSubordinateLevelCounts(Long userId);
/**
+ * 重置会员密码(只更新 password 一列)
+ *
+ * 忘记密码场景专用:认证服务完成身份校验后,按手机号定位的会员在此写入新密码密文。
+ * 单列语句,确保重置流程不会顺带改动其它资料。调用方负责传入 BCrypt 加密后的密文。
+ */
+ public int updateUserPassword(AccountUser accountUser);
+
+ /**
+ * 查询某会员的直接下级(被邀请人)列表
+ *
+ * 按 referrer_id 过滤(命中 idx_account_user_referrer)+ referrer_bind_time 倒序;
+ * 不挂 join,避免干扰 PageHelper 的 count SQL。
+ */
+ public List<AccountUser> selectInvitees(Long referrerId);
+
+ /**
* 搜索可绑定为城市公司档案关联用户的会员(限 L4,最多 20 条)
*
* keyword 与 keywordId 由服务层同时算好:keywordId 仅在 keyword 为纯数字时非空,
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountWarehouseMapper.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountWarehouseMapper.java
index ccbf199..d1369c4 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountWarehouseMapper.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountWarehouseMapper.java
@@ -1,6 +1,7 @@
package com.ruoyi.user.mapper;
import java.util.List;
+import org.apache.ibatis.annotations.Param;
import com.ruoyi.user.domain.AccountWarehouse;
/**
@@ -16,6 +17,27 @@
public List<AccountWarehouse> selectWarehouseByUserId(Long userId);
/**
+ * 按主键查询云仓库存(仅未删除行)
+ *
+ * 供进货前校验归属使用:取回行的 userId 与当前会员比对,防范水平越权。
+ *
+ * @param id 云仓行主键
+ * @return 云仓库存(不存在或已删除返回 null)
+ */
+ public AccountWarehouse selectWarehouseById(@Param("id") Long id);
+
+ /**
+ * 库存原子自增(进货入库)
+ *
+ * 用 SQL 内 `stock_qty = stock_qty + #{qty}` 自增,避免"读-改-写"竞态导致并发丢更新。
+ *
+ * @param id 云仓行主键
+ * @param qty 增加数量(正数)
+ * @return 影响行数
+ */
+ public int increaseStockQty(@Param("id") Long id, @Param("qty") Long qty);
+
+ /**
* 新增云仓库存
*/
public int insertWarehouse(AccountWarehouse accountWarehouse);
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountWarehouseRecordMapper.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountWarehouseRecordMapper.java
new file mode 100644
index 0000000..d89198c
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountWarehouseRecordMapper.java
@@ -0,0 +1,29 @@
+package com.ruoyi.user.mapper;
+
+import java.util.List;
+import org.apache.ibatis.annotations.Param;
+import com.ruoyi.user.domain.AccountWarehouseRecord;
+
+/**
+ * 会员云仓变动流水 数据层
+ *
+ * @author hugray
+ */
+public interface AccountWarehouseRecordMapper
+{
+ /**
+ * 新增云仓变动流水(进货入库 / 销售出库 / 库存调整)
+ *
+ * @param accountWarehouseRecord 变动流水
+ * @return 影响行数
+ */
+ public int insertWarehouseRecord(AccountWarehouseRecord accountWarehouseRecord);
+
+ /**
+ * 查询指定会员的云仓变动流水(按变动时间倒序,配合 startPage 分页)
+ *
+ * @param userId 会员ID
+ * @return 变动流水列表
+ */
+ public List<AccountWarehouseRecord> selectWarehouseRecordList(@Param("userId") Long userId);
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountCertificationApplyService.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountCertificationApplyService.java
index 0340896..cdd2a59 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountCertificationApplyService.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountCertificationApplyService.java
@@ -1,8 +1,11 @@
package com.ruoyi.user.service;
import java.util.List;
+import com.ruoyi.user.domain.dto.CompanyCertificationApplyDto;
import com.ruoyi.user.domain.vo.AccountCertificationApplyDetailVo;
import com.ruoyi.user.domain.vo.AccountCertificationApplyVo;
+import com.ruoyi.user.domain.vo.AccountCertificationStatusVo;
+import com.ruoyi.user.domain.vo.CompanyCertificationStatusVo;
/**
* 会员主体认证申请 业务层
@@ -30,4 +33,48 @@
* @return 影响行数
*/
public int auditCertificationApply(Long id, String auditStatus, String auditRemark);
+
+ /**
+ * C端会员提交个人主体认证申请
+ *
+ * 只做基础校验(姓名非空、身份证号非空),不做三方实名核验;提交后落一条
+ * account_certification_apply(个人主体、待审核),并把会员认证状态联动为「审核中」。
+ *
+ * @param userId 会员ID(取自登录态,非前端传入)
+ * @param realName 姓名
+ * @param idCard 身份证号
+ */
+ public void submitPersonalCertification(Long userId, String realName, String idCard);
+
+ /**
+ * 查询该会员当前认证状态(C端进入认证页回填用)
+ *
+ * 取最近一次申请快照映射为出参;从未提交过申请时返回各字段为空的出参对象(不返回 null)。
+ *
+ * @param userId 会员ID(取自登录态,非前端传入)
+ * @return 认证状态出参(realName / idCard / auditStatus / auditRemark)
+ */
+ public AccountCertificationStatusVo getCertificationStatus(Long userId);
+
+ /**
+ * C端会员提交企业/个体工商户认证申请
+ *
+ * 只做基础必填校验(各字段非空 / 枚举取值合法),不做格式与三方核验;提交后落一条
+ * account_certification_apply(主体类型取入参 2/3、待审核),并把会员企业认证状态联动为「审核中」。
+ *
+ * @param userId 会员ID(取自登录态,非前端传入)
+ * @param dto 企业/个体认证资料(含基础必填校验)
+ */
+ public void submitCompanyCertification(Long userId, CompanyCertificationApplyDto dto);
+
+ /**
+ * 查询该会员当前企业/个体认证状态(C端进入向导页回填用)
+ *
+ * 取最近一次企业/个体申请(subject_type ∈ {2,3})快照映射为出参;从未提交过时
+ * 返回各字段为空的出参对象(不返回 null),前端据此渲染空表单。
+ *
+ * @param userId 会员ID(取自登录态,非前端传入)
+ * @return 企业/个体认证状态出参(整份资料 + auditStatus + auditRemark)
+ */
+ public CompanyCertificationStatusVo getCompanyCertificationStatus(Long userId);
}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountMemberCenterService.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountMemberCenterService.java
new file mode 100644
index 0000000..cb9c5b4
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountMemberCenterService.java
@@ -0,0 +1,59 @@
+package com.ruoyi.user.service;
+
+import java.util.List;
+import com.ruoyi.user.domain.dto.MemberPurchaseDto;
+import com.ruoyi.user.domain.vo.MemberSalesStatisticsVo;
+import com.ruoyi.user.domain.vo.MemberWarehouseVo;
+import com.ruoyi.user.domain.vo.PurchaseOrderVo;
+import com.ruoyi.user.domain.vo.WarehouseRecordVo;
+
+/**
+ * 会员中心 业务层
+ *
+ * 覆盖「销售统计」与「我的仓库」两个 Tab:统计聚合、仓库列表、进货入库、变动记录、进货订单。
+ * 会员身份一律由调用方(Controller)从登录态解析后传入 userId,本层不接受前端直传。
+ *
+ * @author hugray
+ */
+public interface IAccountMemberCenterService
+{
+ /**
+ * 查询当前会员的销售统计(当月三栏 + 环比 + 趋势 + 比例 + 排行)
+ *
+ * @param userId 会员ID
+ * @return 销售统计聚合出参
+ */
+ public MemberSalesStatisticsVo getSalesStatistics(Long userId);
+
+ /**
+ * 查询当前会员的云仓列表(含低库存标记)
+ *
+ * @param userId 会员ID
+ * @return 云仓列表
+ */
+ public List<MemberWarehouseVo> getWarehouseList(Long userId);
+
+ /**
+ * 进货:生成进货单 + 增加库存 + 追加变动流水(同一事务)
+ *
+ * @param userId 会员ID
+ * @param dto 进货入参(warehouseId 须属于当前会员)
+ */
+ public void purchase(Long userId, MemberPurchaseDto dto);
+
+ /**
+ * 查询当前会员的云仓变动记录(按变动时间倒序,配合 startPage 分页)
+ *
+ * @param userId 会员ID
+ * @return 变动记录列表
+ */
+ public List<WarehouseRecordVo> getWarehouseRecordList(Long userId);
+
+ /**
+ * 查询当前会员的进货订单(按下单时间倒序,配合 startPage 分页)
+ *
+ * @param userId 会员ID
+ * @return 进货订单列表
+ */
+ public List<PurchaseOrderVo> getPurchaseOrderList(Long userId);
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountPartnerService.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountPartnerService.java
new file mode 100644
index 0000000..6a03a10
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountPartnerService.java
@@ -0,0 +1,58 @@
+package com.ruoyi.user.service;
+
+import com.ruoyi.user.domain.dto.SubAccountCreateDto;
+import com.ruoyi.user.domain.vo.PartnerOverviewVo;
+import com.ruoyi.user.domain.vo.PartnerSubAccountPageVo;
+import com.ruoyi.user.domain.vo.SubAccountQuotaVo;
+
+/**
+ * 服务商端 业务层(首页概览 + 子账号管理)
+ *
+ * @author hugray
+ */
+public interface IAccountPartnerService
+{
+ /**
+ * 获取服务商首页概览(公司名 + 子账号分布 + 业绩合计)
+ *
+ * 身份取登录态,不接受前端传 userId;非服务商或资格被禁用时抛业务异常。
+ *
+ * @param userId 服务商账号ID(来自登录态)
+ * @return 首页概览聚合对象
+ */
+ public PartnerOverviewVo getOverview(Long userId);
+
+ /**
+ * 分页查询名下子账号列表
+ *
+ * 分页由 Controller 侧 startPage() 织入;非服务商或资格被禁用时抛业务异常。
+ * level 仅接受 L1/L2/L3,其余值(含空)按「全部」处理;
+ * keyword 非空时按昵称/手机号模糊匹配(通配符由服务层转义),可与 level 叠加。
+ *
+ * @param userId 服务商账号ID(来自登录态)
+ * @param level 等级筛选(可空)
+ * @param keyword 昵称/手机号搜索关键字(可空)
+ * @return 分页结果(list/total/pageNum/pageSize)
+ */
+ public PartnerSubAccountPageVo listSubAccounts(Long userId, String level, String keyword);
+
+ /**
+ * 查询各等级剩余可开通额度(权益 − 已开通数,下限 0)
+ *
+ * @param userId 服务商账号ID(来自登录态)
+ * @return L1/L2/L3 剩余额度
+ */
+ public SubAccountQuotaVo getSubAccountQuota(Long userId);
+
+ /**
+ * 新增子账号
+ *
+ * 校验链:登录态 → 服务商存在且未禁用 → 等级合法 → 手机号全局未注册 → 该等级剩余额度 > 0;
+ * 写入沿用 C 端注册口径(账号=手机号、无密码、status 正常、绑定 supplier_id、生成唯一推荐码)。
+ *
+ * @param userId 服务商账号ID(来自登录态)
+ * @param dto 新增入参(等级 + 手机号)
+ * @return 新子账号的会员ID
+ */
+ public Long createSubAccount(Long userId, SubAccountCreateDto dto);
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountUserService.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountUserService.java
index 9691fa2..15d02b6 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountUserService.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountUserService.java
@@ -5,7 +5,10 @@
import com.ruoyi.user.domain.AccountUser;
import com.ruoyi.user.domain.vo.AccountUserDetailVo;
import com.ruoyi.user.domain.vo.MemberOptionVo;
+import com.ruoyi.user.domain.vo.AccountInviteeVo;
+import com.ruoyi.user.domain.vo.AccountReferrerVo;
import com.ruoyi.user.domain.vo.AccountUserInfoVo;
+import com.ruoyi.user.domain.vo.InviteCodeVo;
/**
* 会员用户 业务层
@@ -23,6 +26,14 @@
* 根据手机号查询会员
*/
public AccountUser selectAccountUserByPhone(String phone);
+
+ /**
+ * 根据用户名查询会员(服务商端账号密码登录使用)
+ *
+ * @param userName 用户名
+ * @return 会员对象(不存在时返回 null)
+ */
+ public AccountUser selectAccountUserByUsername(String userName);
/**
* 查询会员列表
@@ -97,6 +108,44 @@
public int deleteAccountAddressByIds(Long[] ids);
/**
+ * C端:查询本人收货地址列表(默认地址在前,不分页)
+ *
+ * @param userId 会员ID(取自登录态,非前端传入)
+ * @return 地址列表(无地址返回空集合)
+ */
+ public List<AccountAddress> getAddressList(Long userId);
+
+ /**
+ * C端:查询单条收货地址(编辑页回填用)
+ *
+ * 校验归属:地址不存在或不属于该会员时抛业务异常。
+ *
+ * @param userId 会员ID(取自登录态)
+ * @param id 地址ID
+ * @return 地址对象
+ */
+ public AccountAddress getAddressDetail(Long userId, Long id);
+
+ /**
+ * C端:新增本人收货地址
+ *
+ * 事务内处理「首条地址自动默认」与「每会员至多一条默认」约束。
+ *
+ * @param accountAddress 地址入参(userId 由服务端注入)
+ * @return 影响行数
+ */
+ public int insertMyAddress(AccountAddress accountAddress);
+
+ /**
+ * C端:删除本人收货地址(校验归属后逻辑删除)
+ *
+ * @param userId 会员ID(取自登录态)
+ * @param id 地址ID
+ * @return 影响行数
+ */
+ public int deleteMyAddress(Long userId, Long id);
+
+ /**
* C端会员注册
*
* 校验手机号未注册、推荐码有效后,生成唯一推荐码并落库(无密码)。
@@ -108,10 +157,58 @@
public AccountUser registerMember(String phone, String referralCode);
/**
+ * 按手机号重置会员密码(忘记密码)
+ *
+ * 由认证服务完成身份校验(手机号 + 短信验证码)后调用;本方法内 BCrypt 加密新密码,
+ * 且只更新 password 一列。手机号不存在或账号已删除时抛业务异常。
+ *
+ * @param phone 手机号(账号标识)
+ * @param rawPassword 新密码明文(落库前加密)
+ */
+ public void resetPasswordByPhone(String phone, String rawPassword);
+
+ /**
* 查询C端会员个人中心信息(昵称 / 头像 / 会员等级 / 收益汇总)
*
* @param userId 会员ID(取自登录态,非前端传入)
* @return 会员信息展示对象;会员不存在时抛业务异常
*/
public AccountUserInfoVo getAccountUserInfo(Long userId);
+
+ /**
+ * 更新C端会员头像
+ *
+ * 头像文件由小程序直传文件服务后,将返回的 url 写回 account_user.avatar;
+ * 只更新 avatar 一列,不触碰其它资料。
+ *
+ * @param userId 会员ID(取自登录态,非前端传入)
+ * @param avatarUrl 头像地址(来自文件服务上传结果)
+ */
+ public void updateAvatar(Long userId, String avatarUrl);
+
+ /**
+ * 查询本人邀请码(我的邀请页「邀请码」Tab)
+ *
+ * @param userId 会员ID(取自登录态,非前端传入)
+ * @return 邀请码展示对象;会员不存在时抛业务异常
+ */
+ public InviteCodeVo getInviteCode(Long userId);
+
+ /**
+ * 查询本人直接下级(被邀请人)列表(我的邀请页「我邀请的」Tab)
+ *
+ * 按绑定时间倒序(最新在前);手机号脱敏下发;配合 Controller 的分页生效。
+ *
+ * @param userId 会员ID(取自登录态,非前端传入)
+ * @return 被邀请人列表(无下级时返回空集合)
+ */
+ public List<AccountInviteeVo> getInvitees(Long userId);
+
+ /**
+ * 查询本人上级(推荐人)信息(我的邀请页「邀请我的」Tab)
+ *
+ * @param userId 会员ID(取自登录态,非前端传入)
+ * @return 上级展示对象;无上级时返回各字段为 null 的对象(前端渲染空态)
+ */
+ public AccountReferrerVo getReferrer(Long userId);
}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountCertificationApplyServiceImpl.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountCertificationApplyServiceImpl.java
index ed932dd..379c627 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountCertificationApplyServiceImpl.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountCertificationApplyServiceImpl.java
@@ -10,8 +10,11 @@
import com.ruoyi.user.domain.AccountCertification;
import com.ruoyi.user.domain.AccountCertificationApply;
import com.ruoyi.user.domain.AccountUser;
+import com.ruoyi.user.domain.dto.CompanyCertificationApplyDto;
import com.ruoyi.user.domain.vo.AccountCertificationApplyDetailVo;
import com.ruoyi.user.domain.vo.AccountCertificationApplyVo;
+import com.ruoyi.user.domain.vo.AccountCertificationStatusVo;
+import com.ruoyi.user.domain.vo.CompanyCertificationStatusVo;
import com.ruoyi.user.mapper.AccountCertificationApplyMapper;
import com.ruoyi.user.mapper.AccountCertificationMapper;
import com.ruoyi.user.mapper.AccountUserMapper;
@@ -31,11 +34,32 @@
/** 审核结果:驳回 */
private static final String AUDIT_REJECT = "2";
+ /** 审核状态:待审核(提交后的初始状态) */
+ private static final String AUDIT_PENDING = "0";
+
+ /** account_user.auth_status:审核中 */
+ private static final String AUTH_AUDITING = "1";
+
/** account_user.auth_status:已认证 */
private static final String AUTH_VERIFIED = "2";
/** account_user.auth_status:认证失败 */
private static final String AUTH_FAILED = "3";
+
+ /** 主体类型:个人(C端实名认证固定为该类型) */
+ private static final String SUBJECT_PERSONAL = "1";
+
+ /** 主体类型:个体工商户 */
+ private static final String SUBJECT_INDIVIDUAL = "2";
+
+ /** 主体类型:企业 */
+ private static final String SUBJECT_COMPANY = "3";
+
+ /** 有效期类型:长期(此时起止日期可空) */
+ private static final String VALID_TYPE_LONG_TERM = "1";
+
+ /** 有效期类型:短期(此时起止日期必填) */
+ private static final String VALID_TYPE_SHORT_TERM = "2";
@Autowired
private AccountCertificationApplyMapper certificationApplyMapper;
@@ -102,18 +126,221 @@
throw new ServiceException("该申请已审核,不能重复处理");
}
- // 同步到 account_user.auth_status:会员列表的「认证状态」列取的就是它
+ // 同步认证状态到 account_user:个人写 auth_status,企业/个体写 company_auth_status,两列互不覆盖。
AccountUser user = new AccountUser();
user.setUserId(apply.getUserId());
- user.setAuthStatus(pass ? AUTH_VERIFIED : AUTH_FAILED);
user.setUpdateBy(operator);
- accountUserMapper.updateUserAuthStatus(user);
+ if (isCompanySubject(apply.getSubjectType()))
+ {
+ user.setCompanyAuthStatus(pass ? AUTH_VERIFIED : AUTH_FAILED);
+ // 审核通过时才把主体类型落为 2/3;驳回不动,避免未通过审核就被标成企业主体
+ if (pass)
+ {
+ user.setSubjectType(apply.getSubjectType());
+ }
+ accountUserMapper.updateUserCompanyAuthStatus(user);
+ }
+ else
+ {
+ user.setAuthStatus(pass ? AUTH_VERIFIED : AUTH_FAILED);
+ accountUserMapper.updateUserAuthStatus(user);
+ }
if (pass)
{
saveCertificationSnapshot(apply, operator);
}
return 1;
+ }
+
+ @Override
+ @Transactional
+ public void submitPersonalCertification(Long userId, String realName, String idCard)
+ {
+ // STEP 1: 校验会员ID(登录态缺失时直接失败,避免无归属写入)
+ if (StringUtils.isNull(userId))
+ {
+ throw new ServiceException("提交实名认证失败");
+ }
+ // STEP 2: 姓名/身份证号二次校验:Controller 已用 @Validated 拦过一遍,
+ // 这里是服务层兜底,防止绕过 Controller 直接调用服务时落脏数据
+ if (StringUtils.isEmpty(realName) || StringUtils.isEmpty(idCard))
+ {
+ throw new ServiceException("姓名与身份证号不能为空");
+ }
+ // STEP 3: 防重闸门——该会员已有待审核申请时拒绝,避免后台审核列表堆积重复单
+ if (certificationApplyMapper.countPendingPersonalApplyByUserId(userId) > 0)
+ {
+ throw new ServiceException("已有实名认证申请正在审核中,请勿重复提交");
+ }
+ // STEP 4: 组装申请快照并落库(主体类型固定「个人」;审核状态置「待审核」;
+ // 身份证号统一转大写,兼容末位小写 x)
+ String operator = SecurityUtils.getUsername();
+ AccountCertificationApply apply = new AccountCertificationApply();
+ apply.setUserId(userId);
+ apply.setSubjectType(SUBJECT_PERSONAL);
+ apply.setRealName(realName.trim());
+ apply.setIdCard(idCard.trim().toUpperCase());
+ apply.setAuditStatus(AUDIT_PENDING);
+ apply.setCreateBy(operator);
+ if (certificationApplyMapper.insertCertificationApply(apply) <= 0)
+ {
+ throw new ServiceException("提交实名认证失败,请稍后重试");
+ }
+ // STEP 5: 联动 account_user.auth_status 为「审核中」,个人中心与会员列表据此显示
+ AccountUser user = new AccountUser();
+ user.setUserId(userId);
+ user.setAuthStatus(AUTH_AUDITING);
+ user.setUpdateBy(operator);
+ accountUserMapper.updateUserAuthStatus(user);
+ }
+
+ @Override
+ public AccountCertificationStatusVo getCertificationStatus(Long userId)
+ {
+ AccountCertificationStatusVo vo = new AccountCertificationStatusVo();
+ // 登录态缺失视为从未提交:返回空对象,前端按「未认证」渲染空表单(不返回 null)
+ if (StringUtils.isNull(userId))
+ {
+ return vo;
+ }
+ AccountCertificationApply apply = certificationApplyMapper.selectLatestPersonalApplyByUserId(userId);
+ if (StringUtils.isNull(apply))
+ {
+ return vo;
+ }
+ // 回填最近一次申请的资料与状态:认证失败时前端据 auditRemark 展示驳回原因
+ vo.setRealName(apply.getRealName());
+ vo.setIdCard(apply.getIdCard());
+ vo.setAuditStatus(apply.getAuditStatus());
+ vo.setAuditRemark(apply.getAuditRemark());
+ return vo;
+ }
+
+ @Override
+ @Transactional
+ public void submitCompanyCertification(Long userId, CompanyCertificationApplyDto dto)
+ {
+ // STEP 1: 校验会员ID(登录态缺失时直接失败,避免无归属写入)
+ if (StringUtils.isNull(userId))
+ {
+ throw new ServiceException("提交企业认证失败");
+ }
+ // STEP 2: 入参兜底:Controller 已用 @Validated 拦过,这里防止绕过 Controller 直接落脏数据
+ if (StringUtils.isNull(dto))
+ {
+ throw new ServiceException("认证资料不能为空");
+ }
+ // STEP 3: 有效期必填兜底——「短期」必须填起止日期,「长期」允许为空(前端此时隐藏该输入)
+ checkValidPeriod(dto.getLicenseValidType(), dto.getLicenseValidPeriod(), "营业执照");
+ checkValidPeriod(dto.getLegalIdValidType(), dto.getLegalIdValidPeriod(), "法人身份证");
+ // STEP 4: 防重闸门——该会员已有待审核的企业申请时拒绝,避免后台审核列表堆积重复单
+ if (certificationApplyMapper.countPendingCompanyApplyByUserId(userId) > 0)
+ {
+ throw new ServiceException("已有企业认证申请正在审核中,请勿重复提交");
+ }
+ // STEP 5: 组装申请快照(主体类型取入参 2/3;审核状态置「待审核」)
+ String operator = SecurityUtils.getUsername();
+ AccountCertificationApply apply = new AccountCertificationApply();
+ apply.setUserId(userId);
+ apply.setSubjectType(dto.getSubjectType());
+ apply.setCompanyName(dto.getCompanyName().trim());
+ apply.setLicenseImage(dto.getLicenseImage());
+ apply.setCreditCode(dto.getCreditCode().trim());
+ apply.setLicenseValidType(dto.getLicenseValidType());
+ apply.setLicenseValidPeriod(dto.getLicenseValidPeriod());
+ apply.setRegisteredRegion(dto.getRegisteredRegion());
+ apply.setCompanyAddress(dto.getCompanyAddress());
+ apply.setLegalPerson(dto.getLegalPerson().trim());
+ apply.setLegalIdCardFront(dto.getLegalIdCardFront());
+ apply.setLegalIdCardBack(dto.getLegalIdCardBack());
+ apply.setLegalIdValidType(dto.getLegalIdValidType());
+ apply.setLegalIdValidPeriod(dto.getLegalIdValidPeriod());
+ apply.setLegalIdAddress(dto.getLegalIdAddress());
+ apply.setContactName(dto.getContactName().trim());
+ apply.setContactPhone(dto.getContactPhone().trim());
+ apply.setContactEmail(dto.getContactEmail().trim());
+ apply.setPayeeType(dto.getPayeeType());
+ apply.setBankCardImage(dto.getBankCardImage());
+ apply.setBankCardNo(dto.getBankCardNo().trim());
+ apply.setBankCity(dto.getBankCity());
+ apply.setAuditStatus(AUDIT_PENDING);
+ apply.setCreateBy(operator);
+ // STEP 6: 落库
+ if (certificationApplyMapper.insertCertificationApply(apply) <= 0)
+ {
+ throw new ServiceException("提交企业认证失败,请稍后重试");
+ }
+ // STEP 7: 联动 account_user.company_auth_status 为「审核中」(与个人 auth_status 相互独立)
+ AccountUser user = new AccountUser();
+ user.setUserId(userId);
+ user.setCompanyAuthStatus(AUTH_AUDITING);
+ user.setUpdateBy(operator);
+ accountUserMapper.updateUserCompanyAuthStatus(user);
+ }
+
+ @Override
+ public CompanyCertificationStatusVo getCompanyCertificationStatus(Long userId)
+ {
+ CompanyCertificationStatusVo vo = new CompanyCertificationStatusVo();
+ // 登录态缺失视为从未提交:返回空对象,前端按「未认证」渲染空表单(不返回 null)
+ if (StringUtils.isNull(userId))
+ {
+ return vo;
+ }
+ AccountCertificationApply apply = certificationApplyMapper.selectLatestCompanyApplyByUserId(userId);
+ if (StringUtils.isNull(apply))
+ {
+ return vo;
+ }
+ // 一次性回填整份资料与审核状态:审核中进占位页、已驳回展示原因并允许修改重提
+ vo.setSubjectType(apply.getSubjectType());
+ vo.setCompanyName(apply.getCompanyName());
+ vo.setLicenseImage(apply.getLicenseImage());
+ vo.setCreditCode(apply.getCreditCode());
+ vo.setLicenseValidType(apply.getLicenseValidType());
+ vo.setLicenseValidPeriod(apply.getLicenseValidPeriod());
+ vo.setRegisteredRegion(apply.getRegisteredRegion());
+ vo.setCompanyAddress(apply.getCompanyAddress());
+ vo.setLegalPerson(apply.getLegalPerson());
+ vo.setLegalIdCardFront(apply.getLegalIdCardFront());
+ vo.setLegalIdCardBack(apply.getLegalIdCardBack());
+ vo.setLegalIdValidType(apply.getLegalIdValidType());
+ vo.setLegalIdValidPeriod(apply.getLegalIdValidPeriod());
+ vo.setLegalIdAddress(apply.getLegalIdAddress());
+ vo.setContactName(apply.getContactName());
+ vo.setContactPhone(apply.getContactPhone());
+ vo.setContactEmail(apply.getContactEmail());
+ vo.setPayeeType(apply.getPayeeType());
+ vo.setBankCardImage(apply.getBankCardImage());
+ vo.setBankCardNo(apply.getBankCardNo());
+ vo.setBankCity(apply.getBankCity());
+ vo.setAuditStatus(apply.getAuditStatus());
+ vo.setAuditRemark(apply.getAuditRemark());
+ return vo;
+ }
+
+ /**
+ * 判定主体类型是否为企业/个体工商户(subject_type ∈ {2,3})。
+ */
+ private boolean isCompanySubject(String subjectType)
+ {
+ return SUBJECT_INDIVIDUAL.equals(subjectType) || SUBJECT_COMPANY.equals(subjectType);
+ }
+
+ /**
+ * 有效期必填兜底:类型为「短期」时,起止日期不得为空。
+ *
+ * @param validType 有效期类型(1长期 2短期)
+ * @param validPeriod 有效期起止(文本)
+ * @param label 字段中文名,用于拼接错误提示
+ */
+ private void checkValidPeriod(String validType, String validPeriod, String label)
+ {
+ if (VALID_TYPE_SHORT_TERM.equals(validType) && StringUtils.isEmpty(validPeriod))
+ {
+ throw new ServiceException("请选择" + label + "有效期起止日期");
+ }
}
/**
@@ -144,6 +371,19 @@
certification.setRegisterDate(apply.getRegisterDate());
certification.setLicenseValidPeriod(apply.getLicenseValidPeriod());
certification.setLicenseImage(apply.getLicenseImage());
+ certification.setLicenseValidType(apply.getLicenseValidType());
+ certification.setLegalIdCardFront(apply.getLegalIdCardFront());
+ certification.setLegalIdCardBack(apply.getLegalIdCardBack());
+ certification.setLegalIdValidType(apply.getLegalIdValidType());
+ certification.setLegalIdValidPeriod(apply.getLegalIdValidPeriod());
+ certification.setLegalIdAddress(apply.getLegalIdAddress());
+ certification.setContactName(apply.getContactName());
+ certification.setContactPhone(apply.getContactPhone());
+ certification.setContactEmail(apply.getContactEmail());
+ certification.setPayeeType(apply.getPayeeType());
+ certification.setBankCardImage(apply.getBankCardImage());
+ certification.setBankCardNo(apply.getBankCardNo());
+ certification.setBankCity(apply.getBankCity());
certification.setCreateBy(operator);
certification.setRemark("由认证申请(申请号 " + apply.getId() + ")审核通过生成");
certificationMapper.insertCertification(certification);
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountMemberCenterServiceImpl.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountMemberCenterServiceImpl.java
new file mode 100644
index 0000000..ca25661
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountMemberCenterServiceImpl.java
@@ -0,0 +1,387 @@
+package com.ruoyi.user.service.impl;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ThreadLocalRandom;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import com.ruoyi.common.core.exception.ServiceException;
+import com.ruoyi.user.domain.AccountPurchaseOrder;
+import com.ruoyi.user.domain.AccountUser;
+import com.ruoyi.user.domain.AccountWarehouse;
+import com.ruoyi.user.domain.AccountWarehouseRecord;
+import com.ruoyi.user.domain.dto.MemberPurchaseDto;
+import com.ruoyi.user.domain.vo.MemberSalesStatisticsVo;
+import com.ruoyi.user.domain.vo.MemberWarehouseVo;
+import com.ruoyi.user.domain.vo.ProductSalesRatioVo;
+import com.ruoyi.user.domain.vo.PurchaseOrderVo;
+import com.ruoyi.user.domain.vo.SalesMonthSummaryVo;
+import com.ruoyi.user.domain.vo.SalesTrendItemVo;
+import com.ruoyi.user.domain.vo.SalesUserRankingVo;
+import com.ruoyi.user.domain.vo.WarehouseRecordVo;
+import com.ruoyi.user.enums.MemberLevelEnum;
+import com.ruoyi.user.enums.PurchaseOrderStatusEnum;
+import com.ruoyi.user.enums.WarehouseChangeTypeEnum;
+import com.ruoyi.user.mapper.AccountPurchaseOrderMapper;
+import com.ruoyi.user.mapper.AccountSalesRecordMapper;
+import com.ruoyi.user.mapper.AccountUserMapper;
+import com.ruoyi.user.mapper.AccountWarehouseMapper;
+import com.ruoyi.user.mapper.AccountWarehouseRecordMapper;
+import com.ruoyi.user.service.IAccountMemberCenterService;
+
+/**
+ * 会员中心 业务实现
+ *
+ * @author hugray
+ */
+@Service
+public class AccountMemberCenterServiceImpl implements IAccountMemberCenterService
+{
+ /** 进货单号前缀 */
+ private static final String PURCHASE_ORDER_PREFIX = "PG";
+
+ /** 进货单号随机段上界(4 位) */
+ private static final int ORDER_NO_RANDOM_BOUND = 10000;
+
+ /** 销售排行取前 N 名 */
+ private static final int RANKING_LIMIT = 10;
+
+ @Autowired
+ private AccountUserMapper accountUserMapper;
+
+ @Autowired
+ private AccountSalesRecordMapper accountSalesRecordMapper;
+
+ @Autowired
+ private AccountPurchaseOrderMapper accountPurchaseOrderMapper;
+
+ @Autowired
+ private AccountWarehouseMapper accountWarehouseMapper;
+
+ @Autowired
+ private AccountWarehouseRecordMapper accountWarehouseRecordMapper;
+
+ @Override
+ public MemberSalesStatisticsVo getSalesStatistics(Long userId)
+ {
+ // STEP 1: 校验会员身份(网关注入的登录态)
+ if (userId == null)
+ {
+ throw new ServiceException("获取销售统计失败");
+ }
+
+ // STEP 2: 取会员资料(手机号脱敏 + 等级归一化兜底)
+ AccountUser user = accountUserMapper.selectAccountUserById(userId);
+ if (user == null)
+ {
+ throw new ServiceException("获取销售统计失败");
+ }
+
+ // STEP 3: 计算本月 / 上月时间边界(在 Java 侧算好,避免 SQL 日期方言差异)
+ LocalDate today = LocalDate.now();
+ LocalDate monthStartDate = today.withDayOfMonth(1);
+ Date monthStart = toDate(monthStartDate);
+ Date nextMonthStart = toDate(monthStartDate.plusMonths(1));
+ Date prevMonthStart = toDate(monthStartDate.minusMonths(1));
+
+ // STEP 4: 聚合本月销售额 / 利润额 / 单数与上月利润
+ SalesMonthSummaryVo summary = accountSalesRecordMapper.selectMonthSummary(
+ userId, monthStart, nextMonthStart, prevMonthStart);
+ if (summary == null)
+ {
+ summary = new SalesMonthSummaryVo();
+ }
+
+ // STEP 5: 聚合本月进货额(来自进货单)
+ Long monthPurchaseAmount = accountPurchaseOrderMapper.selectMonthPurchaseAmount(
+ userId, monthStart, nextMonthStart);
+
+ // STEP 6: 组装出参
+ MemberSalesStatisticsVo vo = new MemberSalesStatisticsVo();
+ vo.setPhone(maskPhone(user.getPhone()));
+ vo.setMemberLevel(MemberLevelEnum.normalize(user.getSystemLevel()));
+ vo.setMonthSalesAmount(nullToZero(summary.getSalesAmount()));
+ vo.setMonthPurchaseAmount(nullToZero(monthPurchaseAmount));
+ vo.setMonthProfitAmount(nullToZero(summary.getProfitAmount()));
+ vo.setMonthSalesCount(summary.getSalesCount() == null ? 0 : summary.getSalesCount());
+ vo.setProfitGrowthRate(calcGrowthRate(summary.getProfitAmount(), summary.getPrevMonthProfit()));
+
+ // STEP 7: 趋势折线(当月 1 号到当日,缺省日补 0)
+ vo.setSalesTrend(buildSalesTrend(userId, monthStart, nextMonthStart, today));
+
+ // STEP 8: 商品销售额比例
+ List<ProductSalesRatioVo> ratioList = accountSalesRecordMapper.selectProductSalesRatio(
+ userId, monthStart, nextMonthStart);
+ vo.setProductSalesRatio(ratioList == null ? new ArrayList<>() : ratioList);
+
+ // STEP 9: 销售用户排行(脱敏 + 填名次)
+ vo.setSalesUserRanking(buildRanking(userId, monthStart, nextMonthStart));
+ return vo;
+ }
+
+ @Override
+ public List<MemberWarehouseVo> getWarehouseList(Long userId)
+ {
+ if (userId == null)
+ {
+ throw new ServiceException("获取仓库列表失败");
+ }
+ List<AccountWarehouse> warehouses = accountWarehouseMapper.selectWarehouseByUserId(userId);
+ List<MemberWarehouseVo> voList = new ArrayList<>();
+ if (warehouses == null)
+ {
+ return voList;
+ }
+ for (AccountWarehouse warehouse : warehouses)
+ {
+ MemberWarehouseVo vo = new MemberWarehouseVo();
+ vo.setWarehouseId(warehouse.getId());
+ vo.setProductName(warehouse.getProductName());
+ // 商品图片列在 account_warehouse 不存在,恒为 null,由前端使用占位图
+ vo.setProductImage(null);
+ vo.setStockQty(nullToZero(warehouse.getStockQty()));
+ vo.setMinStockQty(nullToZero(warehouse.getMinStockQty()));
+ vo.setLowStock(isLowStock(warehouse.getStockQty(), warehouse.getMinStockQty()));
+ voList.add(vo);
+ }
+ return voList;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void purchase(Long userId, MemberPurchaseDto dto)
+ {
+ // STEP 1: 基础校验
+ if (userId == null || dto == null || dto.getWarehouseId() == null)
+ {
+ throw new ServiceException("进货失败,请稍后重试");
+ }
+ if (dto.getQty() == null || dto.getQty() <= 0)
+ {
+ throw new ServiceException("进货数量必须大于 0");
+ }
+
+ // STEP 2: 校验云仓归属(防水平越权:非本会员商品直接拒绝)
+ AccountWarehouse warehouse = accountWarehouseMapper.selectWarehouseById(dto.getWarehouseId());
+ if (warehouse == null || !userId.equals(warehouse.getUserId()))
+ {
+ throw new ServiceException("商品不存在或不属于当前会员");
+ }
+
+ // STEP 3: 生成唯一进货单号(时间戳 + 随机段,UNIQUE KEY 兜底幂等)
+ String orderNo = generatePurchaseOrderNo();
+ Long qty = dto.getQty();
+ long unitPrice = dto.getUnitPrice() == null || dto.getUnitPrice() < 0 ? 0L : dto.getUnitPrice();
+
+ // STEP 4: 落进货单(status=已入库)
+ AccountPurchaseOrder order = new AccountPurchaseOrder();
+ order.setOrderNo(orderNo);
+ order.setUserId(userId);
+ order.setWarehouseId(warehouse.getId());
+ order.setProductName(warehouse.getProductName());
+ order.setQty(qty);
+ order.setUnitPrice(unitPrice);
+ order.setTotalAmount(qty * unitPrice);
+ order.setStatus(PurchaseOrderStatusEnum.STOCKED.getCode());
+ accountPurchaseOrderMapper.insertPurchaseOrder(order);
+
+ // STEP 5: 库存原子自增(SQL 内自增,避免并发读改写丢更新)
+ accountWarehouseMapper.increaseStockQty(warehouse.getId(), qty);
+
+ // STEP 6: 追加云仓变动流水(进货入库,数量为正)
+ long afterQty = nullToZero(warehouse.getStockQty()) + qty;
+ AccountWarehouseRecord record = new AccountWarehouseRecord();
+ record.setUserId(userId);
+ record.setWarehouseId(warehouse.getId());
+ record.setProductName(warehouse.getProductName());
+ record.setChangeType(WarehouseChangeTypeEnum.PURCHASE_IN.getCode());
+ record.setChangeQty(qty);
+ record.setAfterQty(afterQty);
+ record.setBizNo(orderNo);
+ record.setRemark("进货单 " + orderNo);
+ accountWarehouseRecordMapper.insertWarehouseRecord(record);
+ // STEP 7: 事务提交(由 Spring 统一提交)
+ }
+
+ @Override
+ public List<WarehouseRecordVo> getWarehouseRecordList(Long userId)
+ {
+ if (userId == null)
+ {
+ throw new ServiceException("获取仓库记录失败");
+ }
+ List<AccountWarehouseRecord> records = accountWarehouseRecordMapper.selectWarehouseRecordList(userId);
+ List<WarehouseRecordVo> voList = new ArrayList<>();
+ if (records == null)
+ {
+ return voList;
+ }
+ for (AccountWarehouseRecord record : records)
+ {
+ WarehouseRecordVo vo = new WarehouseRecordVo();
+ vo.setRecordId(record.getRecordId());
+ vo.setProductName(record.getProductName());
+ vo.setChangeType(record.getChangeType());
+ vo.setChangeTypeName(WarehouseChangeTypeEnum.descOf(record.getChangeType()));
+ vo.setChangeQty(nullToZero(record.getChangeQty()));
+ vo.setAfterQty(nullToZero(record.getAfterQty()));
+ vo.setRemark(record.getRemark());
+ vo.setCreateTime(record.getCreateTime());
+ voList.add(vo);
+ }
+ return voList;
+ }
+
+ @Override
+ public List<PurchaseOrderVo> getPurchaseOrderList(Long userId)
+ {
+ if (userId == null)
+ {
+ throw new ServiceException("获取进货订单失败");
+ }
+ List<AccountPurchaseOrder> orders = accountPurchaseOrderMapper.selectPurchaseOrderList(userId);
+ List<PurchaseOrderVo> voList = new ArrayList<>();
+ if (orders == null)
+ {
+ return voList;
+ }
+ for (AccountPurchaseOrder order : orders)
+ {
+ PurchaseOrderVo vo = new PurchaseOrderVo();
+ vo.setOrderId(order.getOrderId());
+ vo.setOrderNo(order.getOrderNo());
+ vo.setProductName(order.getProductName());
+ vo.setQty(nullToZero(order.getQty()));
+ vo.setUnitPrice(nullToZero(order.getUnitPrice()));
+ vo.setTotalAmount(nullToZero(order.getTotalAmount()));
+ vo.setStatus(order.getStatus());
+ vo.setStatusName(PurchaseOrderStatusEnum.descOf(order.getStatus()));
+ vo.setCreateTime(order.getCreateTime());
+ voList.add(vo);
+ }
+ return voList;
+ }
+
+ /**
+ * 组装销售额趋势:把「有销售的日期」铺满当月 1 号至当日,缺省日补 0
+ */
+ private List<SalesTrendItemVo> buildSalesTrend(Long userId, Date monthStart, Date nextMonthStart, LocalDate today)
+ {
+ List<SalesTrendItemVo> rawList = accountSalesRecordMapper.selectDailyTrend(userId, monthStart, nextMonthStart);
+ Map<Integer, Long> amountByDay = new HashMap<>();
+ if (rawList != null)
+ {
+ for (SalesTrendItemVo item : rawList)
+ {
+ amountByDay.put(item.getDayOfMonth(), nullToZero(item.getAmount()));
+ }
+ }
+ List<SalesTrendItemVo> result = new ArrayList<>();
+ int maxDay = today.getDayOfMonth();
+ for (int day = 1; day <= maxDay; day++)
+ {
+ Long amount = amountByDay.get(day);
+ result.add(new SalesTrendItemVo(day + "号", amount == null ? 0L : amount));
+ }
+ return result;
+ }
+
+ /**
+ * 组装销售用户排行:手机号脱敏 + 依次填名次
+ */
+ private List<SalesUserRankingVo> buildRanking(Long userId, Date monthStart, Date nextMonthStart)
+ {
+ List<SalesUserRankingVo> rankingList = accountSalesRecordMapper.selectSalesUserRanking(
+ userId, monthStart, nextMonthStart);
+ List<SalesUserRankingVo> result = new ArrayList<>();
+ if (rankingList == null)
+ {
+ return result;
+ }
+ int rank = 1;
+ for (SalesUserRankingVo item : rankingList)
+ {
+ if (rank > RANKING_LIMIT)
+ {
+ break;
+ }
+ item.setRank(rank);
+ item.setPhone(maskPhone(item.getPhone()));
+ item.setAmount(nullToZero(item.getAmount()));
+ result.add(item);
+ rank++;
+ }
+ return result;
+ }
+
+ /**
+ * 计算利润环比:上月利润为 0 时返回 0(不抛除零),否则四舍五入为整数百分比
+ */
+ private Integer calcGrowthRate(Long current, Long previous)
+ {
+ long prev = nullToZero(previous);
+ if (prev == 0L)
+ {
+ return 0;
+ }
+ long cur = nullToZero(current);
+ return (int) Math.round((cur - prev) * 100.0 / prev);
+ }
+
+ /**
+ * 低库存判定:库存存在且低于最低需求库存
+ */
+ private boolean isLowStock(Long stockQty, Long minStockQty)
+ {
+ if (stockQty == null || minStockQty == null)
+ {
+ return false;
+ }
+ return stockQty < minStockQty;
+ }
+
+ /**
+ * 手机号脱敏:长度不足 7 位原样返回,否则保留前 3 位与后 4 位
+ */
+ private String maskPhone(String phone)
+ {
+ if (phone == null || phone.length() < 7)
+ {
+ return phone;
+ }
+ return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
+ }
+
+ /**
+ * 生成进货单号:PG + yyyyMMddHHmmss + 4 位随机,唯一索引兜底
+ */
+ private String generatePurchaseOrderNo()
+ {
+ String time = DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(java.time.LocalDateTime.now());
+ String random = String.format("%04d", ThreadLocalRandom.current().nextInt(ORDER_NO_RANDOM_BOUND));
+ return PURCHASE_ORDER_PREFIX + time + random;
+ }
+
+ /**
+ * LocalDate 转 java.util.Date(按系统时区当天零点)
+ */
+ private Date toDate(LocalDate date)
+ {
+ return Date.from(date.atStartOfDay(ZoneId.systemDefault()).toInstant());
+ }
+
+ /**
+ * Long 空值归零
+ */
+ private long nullToZero(Long value)
+ {
+ return value == null ? 0L : value;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountPartnerServiceImpl.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountPartnerServiceImpl.java
new file mode 100644
index 0000000..cc6faf5
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountPartnerServiceImpl.java
@@ -0,0 +1,433 @@
+package com.ruoyi.user.service.impl;
+
+import java.security.SecureRandom;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import com.github.pagehelper.PageInfo;
+import com.ruoyi.common.core.exception.ServiceException;
+import com.ruoyi.common.core.utils.StringUtils;
+import com.ruoyi.common.security.utils.SecurityUtils;
+import com.ruoyi.user.constant.ReferralConstants;
+import com.ruoyi.user.domain.AccountProvider;
+import com.ruoyi.user.domain.AccountUser;
+import com.ruoyi.user.domain.dto.SubAccountCreateDto;
+import com.ruoyi.user.domain.vo.PartnerEarningStatVo;
+import com.ruoyi.user.domain.vo.PartnerInviteStatVo;
+import com.ruoyi.user.domain.vo.PartnerOverviewVo;
+import com.ruoyi.user.domain.vo.PartnerSalesStatVo;
+import com.ruoyi.user.domain.vo.PartnerSubAccountPageVo;
+import com.ruoyi.user.domain.vo.PartnerSubAccountStatVo;
+import com.ruoyi.user.domain.vo.PartnerSubAccountVo;
+import com.ruoyi.user.domain.vo.SubAccountQuotaVo;
+import com.ruoyi.user.mapper.AccountPartnerMapper;
+import com.ruoyi.user.mapper.AccountProviderMapper;
+import com.ruoyi.user.mapper.AccountUserMapper;
+import com.ruoyi.user.service.IAccountPartnerService;
+
+/**
+ * 服务商首页概览 业务实现
+ *
+ * @author hugray
+ */
+@Service
+public class AccountPartnerServiceImpl implements IAccountPartnerService
+{
+ /** 子账号分布展示的等级(固定三段,无授权补 0) */
+ private static final String[] SUB_ACCOUNT_LEVELS = { "L1", "L2", "L3" };
+
+ /** 新邀请分布展示的等级(固定四段,C 由前端渲染为 L0) */
+ private static final String[] INVITE_LEVELS = { "C", "L1", "L2", "L3" };
+
+ /** 推荐码随机源(SecureRandom 线程安全,避免每次创建) */
+ private static final SecureRandom RANDOM = new SecureRandom();
+
+ @Autowired
+ private AccountProviderMapper accountProviderMapper;
+
+ @Autowired
+ private AccountPartnerMapper accountPartnerMapper;
+
+ @Autowired
+ private AccountUserMapper accountUserMapper;
+
+ @Override
+ public PartnerOverviewVo getOverview(Long userId)
+ {
+ // STEP 1: 校验登录态与服务商身份/资格
+ AccountProvider provider = loadProviderOrThrow(userId);
+
+ // STEP 2: 计算当月 / 上月时间边界(Java 侧算好,避免 SQL 日期方言差异)
+ LocalDate monthStartDate = LocalDate.now().withDayOfMonth(1);
+ Date monthStart = toDate(monthStartDate);
+ Date nextMonthStart = toDate(monthStartDate.plusMonths(1));
+ Date prevMonthStart = toDate(monthStartDate.minusMonths(1));
+
+ // STEP 3: 子账号总数 / 等级分布 / 当月与上月新增绑定(同源,单条 SQL)
+ PartnerSubAccountStatVo subStat = accountPartnerMapper.selectSubAccountStat(
+ userId, monthStart, nextMonthStart, prevMonthStart);
+
+ // STEP 4: 名下子账号当月与上月收益合计
+ PartnerEarningStatVo earningStat = accountPartnerMapper.selectEarningMonthStat(
+ userId, monthStart, nextMonthStart, prevMonthStart);
+
+ // STEP 5: 名下子账号当月销售额与销售单数
+ PartnerSalesStatVo salesStat = accountPartnerMapper.selectSalesMonthStat(
+ userId, monthStart, nextMonthStart);
+
+ // STEP 6: 名下子账号当月进货额
+ Long monthPurchaseAmount = accountPartnerMapper.selectPurchaseMonthAmount(
+ userId, monthStart, nextMonthStart);
+
+ // STEP 7: 当月新邀请总数与等级分布
+ PartnerInviteStatVo inviteStat = accountPartnerMapper.selectInviteMonthStat(
+ userId, monthStart, nextMonthStart);
+
+ // STEP 8: 组装出参(缺省一律补 0;环比无基数返回 null)
+ PartnerOverviewVo vo = new PartnerOverviewVo();
+ vo.setCompanyName(provider.getCompanyName());
+ vo.setSubAccount(buildSubAccountSummary(subStat));
+ vo.setPerformance(buildPerformanceSummary(earningStat, salesStat, monthPurchaseAmount, subStat, inviteStat));
+ return vo;
+ }
+
+ @Override
+ public PartnerSubAccountPageVo listSubAccounts(Long userId, String level, String keyword)
+ {
+ // STEP 1: 校验登录态与服务商身份/资格
+ loadProviderOrThrow(userId);
+
+ // STEP 2: 等级筛选归一化(仅接受 L1/L2/L3,其余值按「全部」处理)
+ String normalizedLevel = normalizeSubLevelFilter(level);
+
+ // STEP 3: 搜索关键字归一化(去空格 + 转义 like 通配符,空串按「不过滤」处理)
+ String normalizedKeyword = normalizeKeyword(keyword);
+
+ // STEP 4: 分页查询(分页 limit 由 Controller 侧 startPage() 织入,语句本身不挂 join)
+ List<PartnerSubAccountVo> list = accountPartnerMapper.selectSubAccountList(userId, normalizedLevel, normalizedKeyword);
+
+ // STEP 5: 从 PageHelper 结果取总数与分页参数,组装 {list,total,pageNum,pageSize}
+ PageInfo<PartnerSubAccountVo> pageInfo = new PageInfo<>(list);
+ PartnerSubAccountPageVo vo = new PartnerSubAccountPageVo();
+ vo.setList(list);
+ vo.setTotal(pageInfo.getTotal());
+ vo.setPageNum(pageInfo.getPageNum());
+ vo.setPageSize(pageInfo.getPageSize());
+ return vo;
+ }
+
+ @Override
+ public SubAccountQuotaVo getSubAccountQuota(Long userId)
+ {
+ // STEP 1: 校验登录态与服务商身份/资格
+ AccountProvider provider = loadProviderOrThrow(userId);
+
+ // STEP 2: 统计名下各等级已开通数
+ PartnerSubAccountStatVo used = accountPartnerMapper.selectSubAccountLevelCount(userId);
+
+ // STEP 3: 剩余额度 = 权益 − 已开通数(下限 0)
+ SubAccountQuotaVo vo = new SubAccountQuotaVo();
+ vo.setL1(calcRemainQuota(quotaOf(provider, "L1"), usedOf(used, "L1")));
+ vo.setL2(calcRemainQuota(quotaOf(provider, "L2"), usedOf(used, "L2")));
+ vo.setL3(calcRemainQuota(quotaOf(provider, "L3"), usedOf(used, "L3")));
+ return vo;
+ }
+
+ @Override
+ public Long createSubAccount(Long userId, SubAccountCreateDto dto)
+ {
+ // STEP 1: 校验登录态与服务商身份/资格
+ AccountProvider provider = loadProviderOrThrow(userId);
+
+ // STEP 2: 等级合法性校验(仅接受 L1/L2/L3)
+ String level = normalizeSubLevelFilter(dto.getLevel());
+ if (level == null)
+ {
+ throw new ServiceException("请选择等级");
+ }
+
+ // STEP 3: 手机号全局唯一性校验(子账号即手机号账号,走验证码登录)
+ String phone = dto.getPhone();
+ if (StringUtils.isNotNull(accountUserMapper.selectAccountUserByPhone(phone)))
+ {
+ throw new ServiceException("手机号已注册");
+ }
+
+ // STEP 4: 该等级剩余额度校验(权益 − 已开通数 > 0 才允许开通)
+ PartnerSubAccountStatVo used = accountPartnerMapper.selectSubAccountLevelCount(userId);
+ if (calcRemainQuota(quotaOf(provider, level), usedOf(used, level)) <= 0)
+ {
+ throw new ServiceException("该等级剩余额度不足");
+ }
+
+ // STEP 5: 组装子账号并写入(沿用 C 端注册口径:账号=手机号、无密码、状态正常、绑定供货关系)
+ AccountUser subAccount = new AccountUser();
+ subAccount.setUserName(phone);
+ subAccount.setPhone(phone);
+ subAccount.setStatus(AccountUser.STATUS_NORMAL);
+ subAccount.setSystemLevel(level);
+ subAccount.setSupplierId(userId);
+ subAccount.setSupplierBindTime(new Date());
+ subAccount.setMyReferralCode(generateUniqueReferralCode());
+ subAccount.setCreateBy(SecurityUtils.getUsername());
+ if (accountUserMapper.insertAccountUser(subAccount) <= 0)
+ {
+ throw new ServiceException("新增失败,请稍后重试");
+ }
+
+ // STEP 6: 返回新子账号会员ID
+ return subAccount.getUserId();
+ }
+
+ /**
+ * 加载服务商并校验资格(登录态为空 / 非服务商 / 资格禁用一律抛业务异常)
+ *
+ * 首页概览、子账号列表、额度、新增共用同一身份校验口径,故抽为私有方法。
+ *
+ * @param userId 服务商账号ID(来自登录态)
+ * @return 服务商配置
+ */
+ private AccountProvider loadProviderOrThrow(Long userId)
+ {
+ if (userId == null)
+ {
+ throw new ServiceException("账号未登录,请重新登录");
+ }
+ AccountProvider provider = accountProviderMapper.selectProviderByUserId(userId);
+ if (provider == null)
+ {
+ throw new ServiceException("当前账号不是服务商");
+ }
+ if (AccountProvider.STATUS_DISABLED.equals(provider.getStatus()))
+ {
+ throw new ServiceException("服务商资格已禁用,请联系管理员");
+ }
+ return provider;
+ }
+
+ /**
+ * 等级筛选归一化:仅接受 L1/L2/L3,空值或非法值统一返回 null(表示「全部」)
+ */
+ private String normalizeSubLevelFilter(String level)
+ {
+ if (StringUtils.isEmpty(level))
+ {
+ return null;
+ }
+ for (String subLevel : SUB_ACCOUNT_LEVELS)
+ {
+ if (subLevel.equals(level))
+ {
+ return level;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 搜索关键字归一化:去首尾空格,空串返回 null(表示不过滤)
+ *
+ * 转义 like 通配符(\ % _),保证用户输入按字面匹配,避免 % 或 _ 扩大匹配范围;
+ * 与 XML 中 escape '\' 配套使用。
+ *
+ * @param keyword 原始搜索关键字(可空)
+ * @return 转义后的关键字,空串返回 null
+ */
+ private String normalizeKeyword(String keyword)
+ {
+ String trimmed = StringUtils.trim(keyword);
+ if (StringUtils.isEmpty(trimmed))
+ {
+ return null;
+ }
+ return trimmed.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
+ }
+
+ /**
+ * 取指定等级的服务商权益额度(仅 L1/L2/L3)
+ */
+ private Integer quotaOf(AccountProvider provider, String level)
+ {
+ switch (level)
+ {
+ case "L1":
+ return provider.getL1Quota();
+ case "L2":
+ return provider.getL2Quota();
+ case "L3":
+ return provider.getL3Quota();
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * 取指定等级的已开通数(仅 L1/L2/L3;统计对象为空时返回 null,由空值归零兜底)
+ */
+ private Integer usedOf(PartnerSubAccountStatVo stat, String level)
+ {
+ if (stat == null)
+ {
+ return null;
+ }
+ switch (level)
+ {
+ case "L1":
+ return stat.getL1();
+ case "L2":
+ return stat.getL2();
+ case "L3":
+ return stat.getL3();
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * 计算剩余额度:权益 − 已开通数,下限 0(任一入参为空按 0 处理)
+ */
+ private int calcRemainQuota(Integer quota, Integer used)
+ {
+ return Math.max(nullToZero(quota) - nullToZero(used), 0);
+ }
+
+ /**
+ * 生成库内唯一的推荐码(8 位大写字母数字,冲突重试,超限抛业务异常)
+ *
+ * 与 C 端注册同口径,子账号开通时也需生成。
+ */
+ private String generateUniqueReferralCode()
+ {
+ String chars = ReferralConstants.CODE_CHARS;
+ for (int retry = 0; retry < ReferralConstants.GEN_MAX_RETRY; retry++)
+ {
+ StringBuilder sb = new StringBuilder(ReferralConstants.CODE_LENGTH);
+ for (int i = 0; i < ReferralConstants.CODE_LENGTH; i++)
+ {
+ sb.append(chars.charAt(RANDOM.nextInt(chars.length())));
+ }
+ String code = sb.toString();
+ if (StringUtils.isNull(accountUserMapper.selectAccountUserByReferralCode(code)))
+ {
+ return code;
+ }
+ }
+ throw new ServiceException("新增失败,请稍后重试");
+ }
+
+ /**
+ * 组装卡片一「开通子账号数」:总数 + L1/L2/L3 分布(固定三段补 0)
+ */
+ private PartnerOverviewVo.SubAccountSummary buildSubAccountSummary(PartnerSubAccountStatVo stat)
+ {
+ PartnerOverviewVo.SubAccountSummary summary = new PartnerOverviewVo.SubAccountSummary();
+ if (stat == null)
+ {
+ summary.setLevels(buildLevelList(SUB_ACCOUNT_LEVELS, 0, 0, 0));
+ return summary;
+ }
+ summary.setTotalCount(nullToZero(stat.getTotal()));
+ summary.setLevels(buildLevelList(SUB_ACCOUNT_LEVELS,
+ nullToZero(stat.getL1()), nullToZero(stat.getL2()), nullToZero(stat.getL3()), 0));
+ return summary;
+ }
+
+ /**
+ * 组装卡片二「子账号业绩合计」:双大卡 + 8 项细分指标
+ */
+ private PartnerOverviewVo.PerformanceSummary buildPerformanceSummary(PartnerEarningStatVo earningStat,
+ PartnerSalesStatVo salesStat,
+ Long monthPurchaseAmount,
+ PartnerSubAccountStatVo subStat,
+ PartnerInviteStatVo inviteStat)
+ {
+ PartnerOverviewVo.PerformanceSummary summary = new PartnerOverviewVo.PerformanceSummary();
+
+ // 大卡一:当月合计收益 + 环比
+ long monthEarning = earningStat == null ? 0L : nullToZero(earningStat.getMonthAmount());
+ long prevMonthEarning = earningStat == null ? 0L : nullToZero(earningStat.getPrevMonthAmount());
+ summary.setMonthEarning(monthEarning);
+ summary.setMonthEarningTrend(calcGrowthRate(monthEarning, prevMonthEarning));
+
+ // 大卡二:当月新增绑定 + 环比
+ int monthNewBinding = subStat == null ? 0 : nullToZero(subStat.getMonthNew());
+ int prevMonthNewBinding = subStat == null ? 0 : nullToZero(subStat.getPrevMonthNew());
+ summary.setMonthNewBinding(monthNewBinding);
+ summary.setMonthNewBindingTrend(calcGrowthRate(monthNewBinding, prevMonthNewBinding));
+
+ // 细分:当月总计销售额 / 当月销售单 / 当月进货额
+ summary.setMonthSalesAmount(salesStat == null ? 0L : nullToZero(salesStat.getSalesAmount()));
+ summary.setMonthSalesCount(salesStat == null ? 0 : nullToZero(salesStat.getSalesCount()));
+ summary.setMonthPurchaseAmount(nullToZero(monthPurchaseAmount));
+
+ // 细分:当月新邀请总数 + 等级分布
+ summary.setMonthNewInviteTotal(inviteStat == null ? 0 : nullToZero(inviteStat.getInvitedTotal()));
+ if (inviteStat == null)
+ {
+ summary.setMonthNewLevels(buildLevelList(INVITE_LEVELS, 0, 0, 0, 0));
+ }
+ else
+ {
+ summary.setMonthNewLevels(buildLevelList(INVITE_LEVELS,
+ nullToZero(inviteStat.getL0()), nullToZero(inviteStat.getL1()),
+ nullToZero(inviteStat.getL2()), nullToZero(inviteStat.getL3())));
+ }
+ return summary;
+ }
+
+ /**
+ * 按固定等级顺序组装等级计数列表(数量按位置一一对应)
+ *
+ * @param levels 等级顺序(如 L1/L2/L3 或 C/L1/L2/L3)
+ * @param counts 与 levels 等长的数量数组
+ */
+ private List<PartnerOverviewVo.LevelCountVo> buildLevelList(String[] levels, int... counts)
+ {
+ List<PartnerOverviewVo.LevelCountVo> list = new ArrayList<>();
+ for (int i = 0; i < levels.length; i++)
+ {
+ list.add(new PartnerOverviewVo.LevelCountVo(levels[i], i < counts.length ? counts[i] : 0));
+ }
+ return list;
+ }
+
+ /**
+ * 计算环比百分比;上月基数为 0 时返回 null(前端隐藏环比)
+ */
+ private Integer calcGrowthRate(long current, long previous)
+ {
+ if (previous == 0L)
+ {
+ return null;
+ }
+ return (int) Math.round((current - previous) * 100.0 / previous);
+ }
+
+ /**
+ * LocalDate 转 java.util.Date(按系统时区当天零点)
+ */
+ private Date toDate(LocalDate date)
+ {
+ return Date.from(date.atStartOfDay(ZoneId.systemDefault()).toInstant());
+ }
+
+ /**
+ * Long 空值归零
+ */
+ private long nullToZero(Long value)
+ {
+ return value == null ? 0L : value;
+ }
+
+ /**
+ * Integer 空值归零
+ */
+ private int nullToZero(Integer value)
+ {
+ return value == null ? 0 : value;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountUserServiceImpl.java b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountUserServiceImpl.java
index 47b2cec..dd3f888 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountUserServiceImpl.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountUserServiceImpl.java
@@ -1,5 +1,6 @@
package com.ruoyi.user.service.impl;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.security.SecureRandom;
@@ -13,14 +14,16 @@
import com.ruoyi.common.core.constant.UserConstants;
import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.core.utils.DateUtils;
-import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.core.utils.StringUtils;
import com.ruoyi.common.security.utils.SecurityUtils;
import com.ruoyi.user.domain.AccountAddress;
import com.ruoyi.user.config.UniversalCodeProperties;
import com.ruoyi.user.constant.ReferralConstants;
import com.ruoyi.user.domain.AccountUser;
+import com.ruoyi.user.domain.vo.AccountInviteeVo;
+import com.ruoyi.user.domain.vo.AccountReferrerVo;
import com.ruoyi.user.domain.vo.AccountUserDetailVo;
+import com.ruoyi.user.domain.vo.InviteCodeVo;
import com.ruoyi.user.domain.vo.MemberOptionVo;
import com.ruoyi.user.mapper.AccountAddressMapper;
import com.ruoyi.user.mapper.AccountAgreementMapper;
@@ -67,6 +70,12 @@
/** 推荐码随机源(SecureRandom 线程安全,避免每次创建) */
private static final SecureRandom RANDOM = new SecureRandom();
+ /** 头像地址最大长度(对应 account_user.avatar varchar(255),超长直接拒绝避免截断) */
+ private static final int AVATAR_MAX_LENGTH = 255;
+
+ /** 认证状态:未认证(auth_status 为空时的兜底值) */
+ private static final String AUTH_STATUS_NONE = "0";
+
@Override
public AccountUser selectAccountUserById(Long userId)
{
@@ -77,6 +86,12 @@
public AccountUser selectAccountUserByPhone(String phone)
{
return accountUserMapper.selectAccountUserByPhone(phone);
+ }
+
+ @Override
+ public AccountUser selectAccountUserByUsername(String userName)
+ {
+ return accountUserMapper.selectAccountUserByUsername(userName);
}
@Override
@@ -288,6 +303,67 @@
return accountAddressMapper.deleteAddressByIds(ids);
}
+ @Override
+ public List<AccountAddress> getAddressList(Long userId)
+ {
+ return accountAddressMapper.selectAddressByUserId(userId);
+ }
+
+ @Override
+ public AccountAddress getAddressDetail(Long userId, Long id)
+ {
+ // 编辑页回填:需校验归属,防止拿到他人地址
+ return queryOwnedAddress(userId, id);
+ }
+
+ @Override
+ @Transactional
+ public int insertMyAddress(AccountAddress accountAddress)
+ {
+ // STEP 1: 会员身份防御性校验(正常由登录态注入,必非空)
+ if (accountAddress.getUserId() == null)
+ {
+ throw new ServiceException("会员身份缺失");
+ }
+ // STEP 2: 是否默认完全以用户勾选为准,不做「首条自动默认」的强制改写;
+ // 为空时明确兜底为 0,避免落库为 null 影响前端判定
+ if (!"1".equals(accountAddress.getIsDefault()))
+ {
+ accountAddress.setIsDefault("0");
+ }
+ // STEP 3: 置为默认前先清掉旧默认,保证"每会员至多一条默认"(与插入同事务)
+ if ("1".equals(accountAddress.getIsDefault()))
+ {
+ accountAddressMapper.clearDefaultByUserId(accountAddress.getUserId());
+ }
+ // STEP 4: 落库
+ return accountAddressMapper.insertAddress(accountAddress);
+ }
+
+ @Override
+ @Transactional
+ public int deleteMyAddress(Long userId, Long id)
+ {
+ // 校验归属:非本人地址直接抛异常,避免越权删除(deleteAddressByIds 本身不带 user_id)
+ queryOwnedAddress(userId, id);
+ return accountAddressMapper.deleteAddressByIds(new Long[]{ id });
+ }
+
+ /**
+ * 查询地址并校验归属
+ *
+ * 地址不存在或不属于该会员时统一抛业务异常(避免暴露他人地址是否存在)。
+ */
+ private AccountAddress queryOwnedAddress(Long userId, Long id)
+ {
+ AccountAddress address = accountAddressMapper.selectAddressById(id);
+ if (address == null || !Objects.equals(address.getUserId(), userId))
+ {
+ throw new ServiceException("收货地址不存在或不属于该会员");
+ }
+ return address;
+ }
+
private Date resolveBindTime(Long oldId, Long newId, Date oldTime, Date now)
{
@@ -338,6 +414,37 @@
}
@Override
+ public void resetPasswordByPhone(String phone, String rawPassword)
+ {
+ // STEP 1: 入参兜底校验(认证服务已校验,此处做服务层兜底)
+ if (StringUtils.isEmpty(phone))
+ {
+ throw new ServiceException("请输入手机号");
+ }
+ if (StringUtils.isEmpty(rawPassword))
+ {
+ throw new ServiceException("请输入新密码");
+ }
+ // STEP 2: 按手机号定位会员(逻辑删除已在 SQL 中过滤)
+ AccountUser accountUser = accountUserMapper.selectAccountUserByPhone(phone);
+ if (StringUtils.isNull(accountUser))
+ {
+ throw new ServiceException("该手机号未注册,请先注册");
+ }
+ // STEP 3: 构造单列更新对象,密码 BCrypt 加密后落库(不触碰其它资料)
+ AccountUser update = new AccountUser();
+ update.setUserId(accountUser.getUserId());
+ update.setPassword(SecurityUtils.encryptPassword(rawPassword));
+ update.setUpdateBy(phone);
+ // STEP 4: 只更新 password 一列;影响行数为 0 说明会员不存在或已删除
+ if (accountUserMapper.updateUserPassword(update) == 0)
+ {
+ throw new ServiceException("密码重置失败,请稍后重试");
+ }
+ log.info("[MEMBER_RESET_PWD] reset success, phone={}", maskPhone(phone));
+ }
+
+ @Override
public AccountUserInfoVo getAccountUserInfo(Long userId)
{
// STEP 1: 校验会员ID(登录态缺失时直接失败,避免无归属查询)
@@ -364,11 +471,142 @@
vo.setPhone(maskPhone(accountUser.getPhone()));
vo.setAvatar(accountUser.getAvatar());
vo.setMemberLevel(MemberLevelEnum.normalize(accountUser.getSystemLevel()));
+ // 认证状态为空(历史数据)时兜底为「未认证」,避免前端徽标无值可判
+ vo.setAuthStatus(StringUtils.isEmpty(accountUser.getAuthStatus())
+ ? AUTH_STATUS_NONE : accountUser.getAuthStatus());
+ // 企业/个体认证状态同样兜底为「未认证」(与个人认证状态相互独立)
+ vo.setCompanyAuthStatus(StringUtils.isEmpty(accountUser.getCompanyAuthStatus())
+ ? AUTH_STATUS_NONE : accountUser.getCompanyAuthStatus());
vo.setEarnings(earnings);
// STEP 5: 返回个人中心展示数据
return vo;
}
+ @Override
+ public void updateAvatar(Long userId, String avatarUrl)
+ {
+ // STEP 1: 校验会员ID(登录态缺失时直接失败,避免无归属更新)
+ if (StringUtils.isNull(userId))
+ {
+ throw new ServiceException("更新头像失败");
+ }
+ // STEP 2: 校验头像地址非空
+ if (StringUtils.isEmpty(avatarUrl))
+ {
+ throw new ServiceException("头像地址不能为空");
+ }
+ // STEP 3: 校验长度,超出列长直接拒绝,避免落库截断
+ if (avatarUrl.length() > AVATAR_MAX_LENGTH)
+ {
+ throw new ServiceException("头像地址过长");
+ }
+ // STEP 4: 只更新 avatar 一列(单列语句,不触碰其它资料)
+ AccountUser update = new AccountUser();
+ update.setUserId(userId);
+ update.setAvatar(avatarUrl);
+ update.setUpdateBy(SecurityUtils.getUsername());
+ // STEP 5: 影响行数为 0 说明会员不存在或已删除(SQL 自带 del_flag='0' 过滤)
+ if (accountUserMapper.updateUserAvatar(update) == 0)
+ {
+ throw new ServiceException("更新头像失败");
+ }
+ }
+
+ @Override
+ public InviteCodeVo getInviteCode(Long userId)
+ {
+ // STEP 1: 校验会员ID(登录态缺失时直接失败,避免无归属查询)
+ if (StringUtils.isNull(userId))
+ {
+ throw new ServiceException("获取邀请码失败");
+ }
+ // STEP 2: 按会员ID查询会员本人(逻辑删除已在 SQL 中过滤)
+ AccountUser accountUser = accountUserMapper.selectAccountUserById(userId);
+ if (StringUtils.isNull(accountUser))
+ {
+ throw new ServiceException("获取邀请码失败");
+ }
+ // STEP 3: 组装展示对象(邀请码注册时生成,C 端会员必有)
+ InviteCodeVo vo = new InviteCodeVo();
+ vo.setReferralCode(accountUser.getMyReferralCode());
+ return vo;
+ }
+
+ @Override
+ public List<AccountInviteeVo> getInvitees(Long userId)
+ {
+ // STEP 1: 校验会员ID(登录态缺失时直接失败,避免无归属查询)
+ if (StringUtils.isNull(userId))
+ {
+ throw new ServiceException("获取邀请列表失败");
+ }
+ // STEP 2: 按 referrer_id 查询直接下级,SQL 已按绑定时间倒序(配合 Controller 分页)
+ List<AccountUser> invitees = accountUserMapper.selectInvitees(userId);
+ // STEP 3: 逐条组装展示对象;手机号脱敏、绑定日格式化,等级原值直出
+ List<AccountInviteeVo> result = new ArrayList<>(invitees.size());
+ for (AccountUser invitee : invitees)
+ {
+ AccountInviteeVo vo = new AccountInviteeVo();
+ vo.setUserId(invitee.getUserId());
+ vo.setNickname(invitee.getNickname());
+ vo.setAvatar(invitee.getAvatar());
+ vo.setPhone(maskPhone(invitee.getPhone()));
+ vo.setSystemLevel(invitee.getSystemLevel());
+ vo.setBindDate(formatBindDate(invitee.getReferrerBindTime()));
+ vo.setBuyAmountTotal(invitee.getBuyAmountTotal());
+ result.add(vo);
+ }
+ return result;
+ }
+
+ @Override
+ public AccountReferrerVo getReferrer(Long userId)
+ {
+ // STEP 1: 校验会员ID(登录态缺失时直接失败,避免无归属查询)
+ if (StringUtils.isNull(userId))
+ {
+ throw new ServiceException("获取上级信息失败");
+ }
+ // STEP 2: 查询会员本人,取其 referrer_id 与绑定时间
+ AccountUser self = accountUserMapper.selectAccountUserById(userId);
+ if (StringUtils.isNull(self))
+ {
+ throw new ServiceException("获取上级信息失败");
+ }
+ AccountReferrerVo vo = new AccountReferrerVo();
+ Long referrerId = self.getReferrerId();
+ // STEP 3: 无上级(命中通用推荐码注册或历史数据)时返回空对象,前端渲染空态
+ if (StringUtils.isNull(referrerId))
+ {
+ return vo;
+ }
+ // STEP 4: 按主键查上级;上级已被逻辑删除时同样返回空对象
+ AccountUser referrer = accountUserMapper.selectAccountUserById(referrerId);
+ if (StringUtils.isNull(referrer))
+ {
+ return vo;
+ }
+ // STEP 5: 组装展示对象;绑定时间取本人记录上的 referrer_bind_time
+ vo.setUserId(referrer.getUserId());
+ vo.setNickname(referrer.getNickname());
+ vo.setAvatar(referrer.getAvatar());
+ vo.setPhone(maskPhone(referrer.getPhone()));
+ vo.setSystemLevel(referrer.getSystemLevel());
+ vo.setBindDate(formatBindDate(self.getReferrerBindTime()));
+ return vo;
+ }
+
+ /**
+ * 绑定时间格式化(yyyy-MM-dd)
+ *
+ * @param bindTime 绑定时间,可为空
+ * @return 格式化后的日期字符串;入参为空时返回 null
+ */
+ private String formatBindDate(Date bindTime)
+ {
+ return StringUtils.isNull(bindTime) ? null : DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD, bindTime);
+ }
+
/**
* 判定是否为通用推荐码
*
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountAddressMapper.xml b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountAddressMapper.xml
index 72be901..2b956e8 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountAddressMapper.xml
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountAddressMapper.xml
@@ -32,6 +32,12 @@
order by is_default desc, id asc
</select>
+ <!-- 按主键查单条(C端详情/删除归属校验用);显式列名,避免 SELECT * -->
+ <select id="selectAddressById" parameterType="Long" resultMap="AccountAddressResult">
+ <include refid="selectAddressVo"/>
+ where id = #{id} and del_flag = '0'
+ </select>
+
<insert id="insertAddress" parameterType="AccountAddress" useGeneratedKeys="true" keyProperty="id">
insert into account_address(
<if test="userId != null">user_id,</if>
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationApplyMapper.xml b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationApplyMapper.xml
index 951a55c..0734737 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationApplyMapper.xml
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationApplyMapper.xml
@@ -18,6 +18,19 @@
<result property="registerDate" column="register_date" />
<result property="licenseValidPeriod" column="license_valid_period" />
<result property="licenseImage" column="license_image" />
+ <result property="licenseValidType" column="license_valid_type" />
+ <result property="legalIdCardFront" column="legal_id_card_front" />
+ <result property="legalIdCardBack" column="legal_id_card_back" />
+ <result property="legalIdValidType" column="legal_id_valid_type" />
+ <result property="legalIdValidPeriod" column="legal_id_valid_period"/>
+ <result property="legalIdAddress" column="legal_id_address" />
+ <result property="contactName" column="contact_name" />
+ <result property="contactPhone" column="contact_phone" />
+ <result property="contactEmail" column="contact_email" />
+ <result property="payeeType" column="payee_type" />
+ <result property="bankCardImage" column="bank_card_image" />
+ <result property="bankCardNo" column="bank_card_no" />
+ <result property="bankCity" column="bank_city" />
<result property="auditStatus" column="audit_status" />
<result property="auditBy" column="audit_by" />
<result property="auditTime" column="audit_time" />
@@ -38,6 +51,9 @@
<sql id="selectCertificationApplyVo">
select id, user_id, subject_type, real_name, id_card, company_name, credit_code, legal_person,
registered_region, company_address, register_date, license_valid_period, license_image,
+ license_valid_type, legal_id_card_front, legal_id_card_back, legal_id_valid_type,
+ legal_id_valid_period, legal_id_address, contact_name, contact_phone, contact_email,
+ payee_type, bank_card_image, bank_card_no, bank_city,
audit_status, audit_by, audit_time, audit_remark,
del_flag, create_by, create_time, update_by, update_time, remark
from account_certification_apply
@@ -53,6 +69,9 @@
select a.id, a.user_id, a.subject_type, a.real_name, a.id_card, a.company_name, a.credit_code,
a.legal_person, a.registered_region, a.company_address, a.register_date,
a.license_valid_period, a.license_image,
+ a.license_valid_type, a.legal_id_card_front, a.legal_id_card_back, a.legal_id_valid_type,
+ a.legal_id_valid_period, a.legal_id_address, a.contact_name, a.contact_phone, a.contact_email,
+ a.payee_type, a.bank_card_image, a.bank_card_no, a.bank_city,
a.audit_status, a.audit_by, a.audit_time, a.audit_remark,
a.del_flag, a.create_by, a.create_time, a.update_by, a.update_time, a.remark,
u.phone, u.system_level
@@ -113,4 +132,111 @@
where id = #{id} and del_flag = '0' and audit_status = '0'
</update>
+ <!--
+ C端会员提交认证申请:把资料整份快照落库,审核结果/审核人/审核时间此时为空,
+ 由后台审核时回写。id 为自增主键,用 useGeneratedKeys 回填后作为申请号,
+ 后台列表「申请号」列展示的就是它。
+ 各列均带 <if>:个人主体只填姓名与身份证号,企业字段留空即不落列。
+ -->
+ <insert id="insertCertificationApply" parameterType="AccountCertificationApply" useGeneratedKeys="true" keyProperty="id">
+ insert into account_certification_apply(
+ <if test="userId != null">user_id,</if>
+ <if test="subjectType != null">subject_type,</if>
+ <if test="realName != null">real_name,</if>
+ <if test="idCard != null">id_card,</if>
+ <if test="companyName != null">company_name,</if>
+ <if test="creditCode != null">credit_code,</if>
+ <if test="legalPerson != null">legal_person,</if>
+ <if test="registeredRegion != null">registered_region,</if>
+ <if test="companyAddress != null">company_address,</if>
+ <if test="registerDate != null">register_date,</if>
+ <if test="licenseValidPeriod != null">license_valid_period,</if>
+ <if test="licenseImage != null">license_image,</if>
+ <if test="licenseValidType != null">license_valid_type,</if>
+ <if test="legalIdCardFront != null">legal_id_card_front,</if>
+ <if test="legalIdCardBack != null">legal_id_card_back,</if>
+ <if test="legalIdValidType != null">legal_id_valid_type,</if>
+ <if test="legalIdValidPeriod != null">legal_id_valid_period,</if>
+ <if test="legalIdAddress != null">legal_id_address,</if>
+ <if test="contactName != null">contact_name,</if>
+ <if test="contactPhone != null">contact_phone,</if>
+ <if test="contactEmail != null">contact_email,</if>
+ <if test="payeeType != null">payee_type,</if>
+ <if test="bankCardImage != null">bank_card_image,</if>
+ <if test="bankCardNo != null">bank_card_no,</if>
+ <if test="bankCity != null">bank_city,</if>
+ <if test="auditStatus != null">audit_status,</if>
+ <if test="remark != null">remark,</if>
+ <if test="createBy != null and createBy != ''">create_by,</if>
+ create_time
+ )values(
+ <if test="userId != null">#{userId},</if>
+ <if test="subjectType != null">#{subjectType},</if>
+ <if test="realName != null">#{realName},</if>
+ <if test="idCard != null">#{idCard},</if>
+ <if test="companyName != null">#{companyName},</if>
+ <if test="creditCode != null">#{creditCode},</if>
+ <if test="legalPerson != null">#{legalPerson},</if>
+ <if test="registeredRegion != null">#{registeredRegion},</if>
+ <if test="companyAddress != null">#{companyAddress},</if>
+ <if test="registerDate != null">#{registerDate},</if>
+ <if test="licenseValidPeriod != null">#{licenseValidPeriod},</if>
+ <if test="licenseImage != null">#{licenseImage},</if>
+ <if test="licenseValidType != null">#{licenseValidType},</if>
+ <if test="legalIdCardFront != null">#{legalIdCardFront},</if>
+ <if test="legalIdCardBack != null">#{legalIdCardBack},</if>
+ <if test="legalIdValidType != null">#{legalIdValidType},</if>
+ <if test="legalIdValidPeriod != null">#{legalIdValidPeriod},</if>
+ <if test="legalIdAddress != null">#{legalIdAddress},</if>
+ <if test="contactName != null">#{contactName},</if>
+ <if test="contactPhone != null">#{contactPhone},</if>
+ <if test="contactEmail != null">#{contactEmail},</if>
+ <if test="payeeType != null">#{payeeType},</if>
+ <if test="bankCardImage != null">#{bankCardImage},</if>
+ <if test="bankCardNo != null">#{bankCardNo},</if>
+ <if test="bankCity != null">#{bankCity},</if>
+ <if test="auditStatus != null">#{auditStatus},</if>
+ <if test="remark != null">#{remark},</if>
+ <if test="createBy != null and createBy != ''">#{createBy},</if>
+ sysdate()
+ )
+ </insert>
+
+ <!--
+ 统计该会员「待审核」的申请数:C端提交前用它做防重闸门。
+ 只数 audit_status='0' 的行——已通过(auth_status 已是 '2')或已驳回
+ (会员要改资料重提)都不该拦住新的提交,否则会员被驳回一次就再也交不了。
+ 个人与企业/个体按主体类型隔离:subject_type='1' 为个人,个人与企业的待审互不干扰。
+ -->
+ <select id="countPendingPersonalApplyByUserId" parameterType="Long" resultType="int">
+ select count(1) from account_certification_apply
+ where del_flag = '0' and audit_status = '0' and subject_type = '1' and user_id = #{userId}
+ </select>
+
+ <!-- 企业/个体待审核申请数:subject_type in ('2','3') -->
+ <select id="countPendingCompanyApplyByUserId" parameterType="Long" resultType="int">
+ select count(1) from account_certification_apply
+ where del_flag = '0' and audit_status = '0' and subject_type in ('2','3') and user_id = #{userId}
+ </select>
+
+ <!--
+ C端进入认证页时回填:取该会员最近一次个人认证申请(按主键倒序取一条)。
+ 驳回后重提会多一条,最近一条即当前应展示的状态与资料;
+ 此处不过滤 audit_status——未提交/待审/通过/驳回都需要能回填。
+ -->
+ <select id="selectLatestPersonalApplyByUserId" parameterType="Long" resultMap="AccountCertificationApplyResult">
+ <include refid="selectCertificationApplyVo"/>
+ where user_id = #{userId} and subject_type = '1' and del_flag = '0'
+ order by id desc
+ limit 1
+ </select>
+
+ <!-- 企业/个体最近一次认证申请:subject_type in ('2','3') -->
+ <select id="selectLatestCompanyApplyByUserId" parameterType="Long" resultMap="AccountCertificationApplyResult">
+ <include refid="selectCertificationApplyVo"/>
+ where user_id = #{userId} and subject_type in ('2','3') and del_flag = '0'
+ order by id desc
+ limit 1
+ </select>
+
</mapper>
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationMapper.xml b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationMapper.xml
index 82b0ef7..94efa62 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationMapper.xml
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountCertificationMapper.xml
@@ -17,6 +17,19 @@
<result property="registerDate" column="register_date" />
<result property="licenseValidPeriod" column="license_valid_period" />
<result property="licenseImage" column="license_image" />
+ <result property="licenseValidType" column="license_valid_type" />
+ <result property="legalIdCardFront" column="legal_id_card_front" />
+ <result property="legalIdCardBack" column="legal_id_card_back" />
+ <result property="legalIdValidType" column="legal_id_valid_type" />
+ <result property="legalIdValidPeriod" column="legal_id_valid_period"/>
+ <result property="legalIdAddress" column="legal_id_address" />
+ <result property="contactName" column="contact_name" />
+ <result property="contactPhone" column="contact_phone" />
+ <result property="contactEmail" column="contact_email" />
+ <result property="payeeType" column="payee_type" />
+ <result property="bankCardImage" column="bank_card_image" />
+ <result property="bankCardNo" column="bank_card_no" />
+ <result property="bankCity" column="bank_city" />
<result property="delFlag" column="del_flag" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
@@ -28,6 +41,9 @@
<sql id="selectCertificationVo">
select id, user_id, real_name, id_card, company_name, credit_code, legal_person,
registered_region, company_address, register_date, license_valid_period, license_image,
+ license_valid_type, legal_id_card_front, legal_id_card_back, legal_id_valid_type,
+ legal_id_valid_period, legal_id_address, contact_name, contact_phone, contact_email,
+ payee_type, bank_card_image, bank_card_no, bank_city,
del_flag, create_by, create_time, update_by, update_time, remark
from account_certification
</sql>
@@ -55,6 +71,19 @@
<if test="registerDate != null">register_date,</if>
<if test="licenseValidPeriod != null">license_valid_period,</if>
<if test="licenseImage != null">license_image,</if>
+ <if test="licenseValidType != null">license_valid_type,</if>
+ <if test="legalIdCardFront != null">legal_id_card_front,</if>
+ <if test="legalIdCardBack != null">legal_id_card_back,</if>
+ <if test="legalIdValidType != null">legal_id_valid_type,</if>
+ <if test="legalIdValidPeriod != null">legal_id_valid_period,</if>
+ <if test="legalIdAddress != null">legal_id_address,</if>
+ <if test="contactName != null">contact_name,</if>
+ <if test="contactPhone != null">contact_phone,</if>
+ <if test="contactEmail != null">contact_email,</if>
+ <if test="payeeType != null">payee_type,</if>
+ <if test="bankCardImage != null">bank_card_image,</if>
+ <if test="bankCardNo != null">bank_card_no,</if>
+ <if test="bankCity != null">bank_city,</if>
<if test="remark != null">remark,</if>
<if test="createBy != null and createBy != ''">create_by,</if>
create_time
@@ -70,6 +99,19 @@
<if test="registerDate != null">#{registerDate},</if>
<if test="licenseValidPeriod != null">#{licenseValidPeriod},</if>
<if test="licenseImage != null">#{licenseImage},</if>
+ <if test="licenseValidType != null">#{licenseValidType},</if>
+ <if test="legalIdCardFront != null">#{legalIdCardFront},</if>
+ <if test="legalIdCardBack != null">#{legalIdCardBack},</if>
+ <if test="legalIdValidType != null">#{legalIdValidType},</if>
+ <if test="legalIdValidPeriod != null">#{legalIdValidPeriod},</if>
+ <if test="legalIdAddress != null">#{legalIdAddress},</if>
+ <if test="contactName != null">#{contactName},</if>
+ <if test="contactPhone != null">#{contactPhone},</if>
+ <if test="contactEmail != null">#{contactEmail},</if>
+ <if test="payeeType != null">#{payeeType},</if>
+ <if test="bankCardImage != null">#{bankCardImage},</if>
+ <if test="bankCardNo != null">#{bankCardNo},</if>
+ <if test="bankCity != null">#{bankCity},</if>
<if test="remark != null">#{remark},</if>
<if test="createBy != null and createBy != ''">#{createBy},</if>
sysdate()
@@ -89,6 +131,19 @@
<if test="registerDate != null">register_date = #{registerDate},</if>
<if test="licenseValidPeriod != null">license_valid_period = #{licenseValidPeriod},</if>
<if test="licenseImage != null">license_image = #{licenseImage},</if>
+ <if test="licenseValidType != null">license_valid_type = #{licenseValidType},</if>
+ <if test="legalIdCardFront != null">legal_id_card_front = #{legalIdCardFront},</if>
+ <if test="legalIdCardBack != null">legal_id_card_back = #{legalIdCardBack},</if>
+ <if test="legalIdValidType != null">legal_id_valid_type = #{legalIdValidType},</if>
+ <if test="legalIdValidPeriod != null">legal_id_valid_period = #{legalIdValidPeriod},</if>
+ <if test="legalIdAddress != null">legal_id_address = #{legalIdAddress},</if>
+ <if test="contactName != null">contact_name = #{contactName},</if>
+ <if test="contactPhone != null">contact_phone = #{contactPhone},</if>
+ <if test="contactEmail != null">contact_email = #{contactEmail},</if>
+ <if test="payeeType != null">payee_type = #{payeeType},</if>
+ <if test="bankCardImage != null">bank_card_image = #{bankCardImage},</if>
+ <if test="bankCardNo != null">bank_card_no = #{bankCardNo},</if>
+ <if test="bankCity != null">bank_city = #{bankCity},</if>
<if test="remark != null">remark = #{remark},</if>
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
update_time = sysdate()
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountPartnerMapper.xml b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountPartnerMapper.xml
new file mode 100644
index 0000000..0c51a5c
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountPartnerMapper.xml
@@ -0,0 +1,159 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.user.mapper.AccountPartnerMapper">
+
+ <resultMap type="com.ruoyi.user.domain.vo.PartnerSubAccountStatVo" id="PartnerSubAccountStatResult">
+ <result property="total" column="total" />
+ <result property="l1" column="l1" />
+ <result property="l2" column="l2" />
+ <result property="l3" column="l3" />
+ <result property="monthNew" column="month_new" />
+ <result property="prevMonthNew" column="prev_month_new" />
+ </resultMap>
+
+ <resultMap type="com.ruoyi.user.domain.vo.PartnerEarningStatVo" id="PartnerEarningStatResult">
+ <result property="monthAmount" column="month_amount" />
+ <result property="prevMonthAmount" column="prev_month_amount" />
+ </resultMap>
+
+ <resultMap type="com.ruoyi.user.domain.vo.PartnerSalesStatVo" id="PartnerSalesStatResult">
+ <result property="salesAmount" column="sales_amount" />
+ <result property="salesCount" column="sales_count" />
+ </resultMap>
+
+ <resultMap type="com.ruoyi.user.domain.vo.PartnerInviteStatVo" id="PartnerInviteStatResult">
+ <result property="invitedTotal" column="invited_total" />
+ <result property="l0" column="l0" />
+ <result property="l1" column="l1" />
+ <result property="l2" column="l2" />
+ <result property="l3" column="l3" />
+ </resultMap>
+
+ <resultMap type="com.ruoyi.user.domain.vo.PartnerSubAccountVo" id="PartnerSubAccountResult">
+ <id property="userId" column="user_id" />
+ <result property="nickname" column="nickname" />
+ <result property="phone" column="phone" />
+ <result property="systemLevel" column="system_level" />
+ <result property="avatar" column="avatar" />
+ </resultMap>
+
+ <!--
+ 子账号总数 + L1/L2/L3 分布 + 当月/上月新增绑定,三者同源 account_user(同一 where),
+ 合并为一条 SQL;命中 idx_account_user_supplier。
+ 已授权数只数 del_flag = '0':口径是「发出去多少」,不是「还剩多少有效」。
+ 新增绑定按 supplier_bind_time 归属月份,区间 [monthStart, nextMonthStart)。
+ -->
+ <select id="selectSubAccountStat" resultMap="PartnerSubAccountStatResult">
+ select
+ count(1) as total,
+ ifnull(sum(case when system_level = 'L1' then 1 else 0 end), 0) as l1,
+ ifnull(sum(case when system_level = 'L2' then 1 else 0 end), 0) as l2,
+ ifnull(sum(case when system_level = 'L3' then 1 else 0 end), 0) as l3,
+ ifnull(sum(case when supplier_bind_time >= #{monthStart} and supplier_bind_time < #{nextMonthStart} then 1 else 0 end), 0) as month_new,
+ ifnull(sum(case when supplier_bind_time >= #{prevMonthStart} and supplier_bind_time < #{monthStart} then 1 else 0 end), 0) as prev_month_new
+ from account_user
+ where del_flag = '0' and supplier_id = #{providerUserId}
+ </select>
+
+ <!--
+ 名下子账号当月/上月收益合计:join 回 account_user 以 supplier_id 圈定范围。
+ 时间按收益流水 create_time 归属月份,区间 [monthStart, nextMonthStart)。
+ -->
+ <select id="selectEarningMonthStat" resultMap="PartnerEarningStatResult">
+ select
+ ifnull(sum(case when r.create_time >= #{monthStart} and r.create_time < #{nextMonthStart} then r.amount end), 0) as month_amount,
+ ifnull(sum(case when r.create_time >= #{prevMonthStart} and r.create_time < #{monthStart} then r.amount end), 0) as prev_month_amount
+ from account_earning_record r
+ join account_user u on u.user_id = r.user_id and u.del_flag = '0'
+ where u.supplier_id = #{providerUserId}
+ <!-- 仅聚合上月月初至下月月初区间,命中 (user_id, create_time) 索引,避免扫描全部历史流水 -->
+ and r.create_time >= #{prevMonthStart}
+ and r.create_time < #{nextMonthStart}
+ </select>
+
+ <!--
+ 名下子账号当月销售额与销售单数:seller_id 即子账号,join account_user 按 supplier_id 圈定范围。
+ 时间按 sale_time 归属月份,区间 [monthStart, nextMonthStart);命中 idx_sales_seller_time。
+ -->
+ <select id="selectSalesMonthStat" resultMap="PartnerSalesStatResult">
+ select
+ ifnull(sum(r.amount), 0) as sales_amount,
+ count(1) as sales_count
+ from account_sales_record r
+ join account_user u on u.user_id = r.seller_id and u.del_flag = '0'
+ where r.del_flag = '0' and u.supplier_id = #{providerUserId}
+ and r.sale_time >= #{monthStart} and r.sale_time < #{nextMonthStart}
+ </select>
+
+ <!--
+ 名下子账号当月进货额:user_id 即子账号,join account_user 按 supplier_id 圈定范围。
+ 时间按 purchase_time 归属月份,区间 [monthStart, nextMonthStart);命中 idx_purchase_user_time。
+ -->
+ <select id="selectPurchaseMonthAmount" resultType="java.lang.Long">
+ select ifnull(sum(p.total_amount), 0)
+ from account_purchase_order p
+ join account_user u on u.user_id = p.user_id and u.del_flag = '0'
+ where p.del_flag = '0' and u.supplier_id = #{providerUserId}
+ and p.purchase_time >= #{monthStart} and p.purchase_time < #{nextMonthStart}
+ </select>
+
+ <!--
+ 当月新邀请总数与等级分布:推荐人 ∈ 本服务商体系(服务商本人 + 名下全部子账号)。
+ 时间按被邀请会员的 referrer_bind_time 归属月份;命中 idx_account_user_referrer。
+ C(含空值)计入 L0 列,展示层再把 C 渲染为 L0。
+ -->
+ <select id="selectInviteMonthStat" resultMap="PartnerInviteStatResult">
+ select
+ count(1) as invited_total,
+ ifnull(sum(case when u.system_level = 'C' or u.system_level is null then 1 else 0 end), 0) as l0,
+ ifnull(sum(case when u.system_level = 'L1' then 1 else 0 end), 0) as l1,
+ ifnull(sum(case when u.system_level = 'L2' then 1 else 0 end), 0) as l2,
+ ifnull(sum(case when u.system_level = 'L3' then 1 else 0 end), 0) as l3
+ from account_user u
+ where u.del_flag = '0'
+ and u.referrer_bind_time >= #{monthStart} and u.referrer_bind_time < #{nextMonthStart}
+ and (u.referrer_id = #{providerUserId}
+ or u.referrer_id in (select user_id from account_user
+ where del_flag = '0' and supplier_id = #{providerUserId}))
+ </select>
+
+ <!--
+ 子账号列表(子账号管理页):只取展示所需列,不挂 join,命中 idx_account_user_supplier。
+ 等级筛选为可选等值条件;关键字按昵称/手机号模糊匹配(用户输入的通配符已在服务层转义,
+ 此处用 escape '\' 保证按字面匹配);分页 limit 由 PageHelper 织入,故此处不写。
+ -->
+ <select id="selectSubAccountList" resultMap="PartnerSubAccountResult">
+ select user_id, nickname, phone, system_level, avatar
+ from account_user
+ where del_flag = '0'
+ and supplier_id = #{providerUserId}
+ and system_level in ('L1', 'L2', 'L3')
+ <if test="level != null and level != ''">
+ and system_level = #{level}
+ </if>
+ <if test="keyword != null and keyword != ''">
+ and (nickname like concat('%', #{keyword}, '%') escape '\\'
+ or phone like concat('%', #{keyword}, '%') escape '\\')
+ </if>
+ order by user_id desc
+ </select>
+
+ <!--
+ 各等级已开通数(剩余额度 = 权益 − 已开通数):与列表同一口径,只取 l1/l2/l3 计数。
+ 复用 PartnerSubAccountStatResult 映射;未查出的 month_new/prev_month_new 保持 null,由服务层归零。
+ -->
+ <select id="selectSubAccountLevelCount" resultMap="PartnerSubAccountStatResult">
+ select
+ count(1) as total,
+ ifnull(sum(case when system_level = 'L1' then 1 else 0 end), 0) as l1,
+ ifnull(sum(case when system_level = 'L2' then 1 else 0 end), 0) as l2,
+ ifnull(sum(case when system_level = 'L3' then 1 else 0 end), 0) as l3
+ from account_user
+ where del_flag = '0'
+ and supplier_id = #{providerUserId}
+ and system_level in ('L1', 'L2', 'L3')
+ </select>
+
+</mapper>
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountProviderMapper.xml b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountProviderMapper.xml
index 95d0e5e..ed14e17 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountProviderMapper.xml
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountProviderMapper.xml
@@ -115,6 +115,17 @@
where p.id = #{id} and p.del_flag = '0'
</select>
+ <!--
+ 小程序按关联账号取服务商档案:首页概览取公司名与资格状态,子账号额度取 L1/L2/L3 权益。
+ 权益三列必须查出,否则剩余额度会恒为 0(quota 取不到 → 按 0 兜底)。
+ 1:1 关系由服务层保证,正常一个 user_id 至多一行。
+ -->
+ <select id="selectProviderByUserId" parameterType="Long" resultMap="AccountProviderResult">
+ select id, user_id, company_name, l1_quota, l2_quota, l3_quota, status, del_flag
+ from account_provider
+ where user_id = #{userId} and del_flag = '0'
+ </select>
+
<!-- 权益数量不传即落库默认 0,故走 <if> 即可,不必在服务层兜底补 0 -->
<insert id="insertProvider" parameterType="AccountProvider" useGeneratedKeys="true" keyProperty="id">
insert into account_provider(
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountPurchaseOrderMapper.xml b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountPurchaseOrderMapper.xml
new file mode 100644
index 0000000..50b5155
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountPurchaseOrderMapper.xml
@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.user.mapper.AccountPurchaseOrderMapper">
+
+ <resultMap type="com.ruoyi.user.domain.AccountPurchaseOrder" id="AccountPurchaseOrderResult">
+ <id property="orderId" column="order_id" />
+ <result property="orderNo" column="order_no" />
+ <result property="userId" column="user_id" />
+ <result property="warehouseId" column="warehouse_id" />
+ <result property="productName" column="product_name" />
+ <result property="qty" column="qty" />
+ <result property="unitPrice" column="unit_price" />
+ <result property="totalAmount" column="total_amount" />
+ <result property="status" column="status" />
+ <result property="purchaseTime" column="purchase_time" />
+ <result property="delFlag" column="del_flag" />
+ <result property="createBy" column="create_by" />
+ <result property="createTime" column="create_time" />
+ <result property="updateBy" column="update_by" />
+ <result property="updateTime" column="update_time" />
+ <result property="remark" column="remark" />
+ </resultMap>
+
+ <sql id="selectPurchaseOrderVo">
+ select order_id, order_no, user_id, warehouse_id, product_name, qty, unit_price,
+ total_amount, status, purchase_time, del_flag,
+ create_by, create_time, update_by, update_time, remark
+ from account_purchase_order
+ </sql>
+
+ <!-- 落单即入库:purchase_time 即聚合口径时间,create_time 记录写入时间 -->
+ <insert id="insertPurchaseOrder" parameterType="com.ruoyi.user.domain.AccountPurchaseOrder" useGeneratedKeys="true" keyProperty="orderId">
+ insert into account_purchase_order(
+ order_no,
+ user_id,
+ <if test="warehouseId != null">warehouse_id,</if>
+ <if test="productName != null">product_name,</if>
+ qty,
+ unit_price,
+ total_amount,
+ status,
+ purchase_time,
+ del_flag,
+ create_time
+ )values(
+ #{orderNo},
+ #{userId},
+ <if test="warehouseId != null">#{warehouseId},</if>
+ <if test="productName != null">#{productName},</if>
+ #{qty},
+ #{unitPrice},
+ #{totalAmount},
+ #{status},
+ sysdate(),
+ '0',
+ sysdate()
+ )
+ </insert>
+
+ <!-- 本月进货额区间 [monthStart, nextMonthStart),命中 idx_purchase_user_time -->
+ <select id="selectMonthPurchaseAmount" resultType="java.lang.Long">
+ select ifnull(sum(total_amount), 0)
+ from account_purchase_order
+ where del_flag = '0' and user_id = #{userId}
+ and purchase_time >= #{monthStart} and purchase_time < #{nextMonthStart}
+ </select>
+
+ <select id="selectPurchaseOrderList" parameterType="Long" resultMap="AccountPurchaseOrderResult">
+ <include refid="selectPurchaseOrderVo"/>
+ where del_flag = '0' and user_id = #{userId}
+ order by order_id desc
+ </select>
+
+</mapper>
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountSalesRecordMapper.xml b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountSalesRecordMapper.xml
new file mode 100644
index 0000000..eda2339
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountSalesRecordMapper.xml
@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.user.mapper.AccountSalesRecordMapper">
+
+ <resultMap type="com.ruoyi.user.domain.vo.SalesMonthSummaryVo" id="SalesMonthSummaryResult">
+ <result property="salesAmount" column="sales_amount" />
+ <result property="profitAmount" column="profit_amount" />
+ <result property="salesCount" column="sales_count" />
+ <result property="prevMonthProfit" column="prev_month_profit" />
+ </resultMap>
+
+ <resultMap type="com.ruoyi.user.domain.vo.SalesTrendItemVo" id="SalesTrendResult">
+ <result property="dayOfMonth" column="day_of_month" />
+ <result property="amount" column="amount" />
+ </resultMap>
+
+ <resultMap type="com.ruoyi.user.domain.vo.ProductSalesRatioVo" id="ProductSalesRatioResult">
+ <result property="productName" column="product_name" />
+ <result property="amount" column="amount" />
+ </resultMap>
+
+ <resultMap type="com.ruoyi.user.domain.vo.SalesUserRankingVo" id="SalesUserRankingResult">
+ <result property="nickname" column="nickname" />
+ <result property="phone" column="phone" />
+ <result property="amount" column="amount" />
+ </resultMap>
+
+ <!-- 单条 SQL 聚合三栏 + 上月利润:本月区间 [monthStart, nextMonthStart),上月利润区间 [prevMonthStart, monthStart) -->
+ <select id="selectMonthSummary" resultMap="SalesMonthSummaryResult">
+ select
+ ifnull(sum(case when sale_time >= #{monthStart} and sale_time < #{nextMonthStart} then amount end), 0) as sales_amount,
+ ifnull(sum(case when sale_time >= #{monthStart} and sale_time < #{nextMonthStart} then profit_amount end), 0) as profit_amount,
+ ifnull(sum(case when sale_time >= #{monthStart} and sale_time < #{nextMonthStart} then 1 else 0 end), 0) as sales_count,
+ ifnull(sum(case when sale_time >= #{prevMonthStart} and sale_time < #{monthStart} then profit_amount end), 0) as prev_month_profit
+ from account_sales_record
+ where del_flag = '0' and seller_id = #{sellerId}
+ </select>
+
+ <select id="selectDailyTrend" resultMap="SalesTrendResult">
+ select
+ day(sale_time) as day_of_month,
+ ifnull(sum(amount), 0) as amount
+ from account_sales_record
+ where del_flag = '0' and seller_id = #{sellerId}
+ and sale_time >= #{monthStart} and sale_time < #{nextMonthStart}
+ group by day(sale_time)
+ order by day_of_month asc
+ </select>
+
+ <select id="selectProductSalesRatio" resultMap="ProductSalesRatioResult">
+ select
+ ifnull(product_name, '') as product_name,
+ ifnull(sum(amount), 0) as amount
+ from account_sales_record
+ where del_flag = '0' and seller_id = #{sellerId}
+ and sale_time >= #{monthStart} and sale_time < #{nextMonthStart}
+ group by product_name
+ order by amount desc
+ </select>
+
+ <!-- 排行取前 10,控制单次返回规模 -->
+ <select id="selectSalesUserRanking" resultMap="SalesUserRankingResult">
+ select
+ buyer_name as nickname,
+ buyer_phone as phone,
+ ifnull(sum(amount), 0) as amount
+ from account_sales_record
+ where del_flag = '0' and seller_id = #{sellerId}
+ and sale_time >= #{monthStart} and sale_time < #{nextMonthStart}
+ group by buyer_id, buyer_name, buyer_phone
+ order by amount desc
+ limit 10
+ </select>
+
+</mapper>
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountUserMapper.xml b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountUserMapper.xml
index a209c1b..e7505cc 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountUserMapper.xml
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountUserMapper.xml
@@ -17,6 +17,7 @@
<result property="systemLevel" column="system_level" />
<result property="subjectType" column="subject_type" />
<result property="authStatus" column="auth_status" />
+ <result property="companyAuthStatus" column="company_auth_status" />
<result property="city" column="city" />
<result property="referrerId" column="referrer_id" />
<result property="referrerBindTime" column="referrer_bind_time" />
@@ -53,7 +54,7 @@
<sql id="selectAccountUserVo">
select user_id, username, phone, password, nickname, avatar, gender, birthday, status,
- system_level, subject_type, auth_status, city,
+ system_level, subject_type, auth_status, company_auth_status, city,
referrer_id, referrer_bind_time, supplier_id, supplier_bind_time,
buy_order_count, buy_amount_total, sell_order_count, sell_amount_total,
purchase_order_count, purchase_amount_total, sell_profit_total,
@@ -106,6 +107,16 @@
<select id="selectAccountUserByPhone" parameterType="String" resultMap="AccountUserResult">
<include refid="selectAccountUserVo"/>
where del_flag = '0' and phone = #{phone}
+ </select>
+
+ <!--
+ 按推荐码查询会员:注册时定位推荐人 + 生成推荐码时库内查重。
+ 命中唯一索引 uk_account_user_referral_code;del_flag = '0' 保证推荐人未被逻辑删除,
+ 与 selectAccountUserByPhone/ByUsername 口径一致。
+ -->
+ <select id="selectAccountUserByReferralCode" parameterType="String" resultMap="AccountUserResult">
+ <include refid="selectAccountUserVo"/>
+ where del_flag = '0' and my_referral_code = #{referralCode}
</select>
<!--
@@ -245,6 +256,38 @@
</update>
<!--
+ 企业/个体认证审核通过/驳回后回写,只动 company_auth_status 一列;
+ 与 updateUserAuthStatus 分开是因为企业认证状态与个人认证状态是两列,不能互相覆盖。
+ 审核通过时服务层把 subjectType 一起带上(=2/3),此处 <if> 才写 subject_type;
+ 驳回时服务层不传 subjectType,列保持不变——否则会把未通过审核的会员标成企业主体。
+ -->
+ <update id="updateUserCompanyAuthStatus" parameterType="AccountUser">
+ update account_user
+ <set>
+ company_auth_status = #{companyAuthStatus},
+ <if test="subjectType != null and subjectType != ''">subject_type = #{subjectType},</if>
+ <if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
+ update_time = sysdate()
+ </set>
+ where user_id = #{userId} and del_flag = '0'
+ </update>
+
+ <!--
+ 头像更新专用:只动 avatar 一列。绝不能挂到共享的 updateAccountUser 上——
+ 那是后台编辑接口(/account/user)的入口,挂上去等于请求体带个 avatar 就能改任意会员头像。
+ 与 updateUserStatus / updateUserAuthStatus 同一套路,头像只能由会员本人在 C 端接口里改写。
+ -->
+ <update id="updateUserAvatar" parameterType="AccountUser">
+ update account_user
+ <set>
+ avatar = #{avatar},
+ <if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
+ update_time = sysdate()
+ </set>
+ where user_id = #{userId} and del_flag = '0'
+ </update>
+
+ <!--
服务商标识专用:与 updateUserAuthStatus 同一个套路——别的模块要给会员打标识,
就单开一条只写该列的语句,绝不挂到共享的 insertAccountUser / updateAccountUser 上
(那两个按 <if> 拼列,挂上去等于 /account/user 的请求体里带个 providerFlag 就能自己打标)。
@@ -290,6 +333,20 @@
order by system_level
</select>
+ <!--
+ 忘记密码专用:只写 password 一列的语句,绝不挂到共享的 updateAccountUser 上。
+ 密文由服务层 BCrypt 加密后传入;重置流程不触碰其它资料。
+ -->
+ <update id="updateUserPassword" parameterType="AccountUser">
+ update account_user
+ <set>
+ password = #{password},
+ <if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
+ update_time = sysdate()
+ </set>
+ where user_id = #{userId} and del_flag = '0'
+ </update>
+
<!-- 逻辑删除:保留行,仅置删除标志 -->
<update id="deleteAccountUserById" parameterType="Long">
update account_user set del_flag = '2' where user_id = #{userId}
@@ -302,6 +359,18 @@
</foreach>
</update>
+ <!--
+ 「我邀请的」列表:按 referrer_id 过滤 + referrer_bind_time 倒序,命中 idx_account_user_referrer。
+ 显式列出所需列(禁 SELECT *);不挂 join,避免干扰 PageHelper 的 count SQL。
+ referrer_bind_time 为空的历史行,MySQL 中 NULL 视为最小值,倒序时自然排在最后。
+ -->
+ <select id="selectInvitees" parameterType="Long" resultMap="AccountUserResult">
+ select user_id, nickname, avatar, phone, system_level, referrer_bind_time, buy_amount_total
+ from account_user
+ where del_flag = '0' and referrer_id = #{referrerId}
+ order by referrer_bind_time desc
+ </select>
+
<resultMap type="MemberOptionVo" id="MemberOptionVoResult">
<id property="userId" column="user_id" />
<result property="memberName" column="member_name" />
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountWarehouseMapper.xml b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountWarehouseMapper.xml
index a95689d..fa6cb16 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountWarehouseMapper.xml
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountWarehouseMapper.xml
@@ -30,6 +30,19 @@
order by id asc
</select>
+ <select id="selectWarehouseById" parameterType="Long" resultMap="AccountWarehouseResult">
+ <include refid="selectWarehouseVo"/>
+ where del_flag = '0' and id = #{id}
+ </select>
+
+ <!-- 库存原子自增:非空校验兜底,避免库存为 NULL 时自增结果为 NULL -->
+ <update id="increaseStockQty">
+ update account_warehouse
+ set stock_qty = ifnull(stock_qty, 0) + #{qty},
+ update_time = sysdate()
+ where id = #{id} and del_flag = '0'
+ </update>
+
<insert id="insertWarehouse" parameterType="AccountWarehouse" useGeneratedKeys="true" keyProperty="id">
insert into account_warehouse(
<if test="userId != null">user_id,</if>
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountWarehouseRecordMapper.xml b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountWarehouseRecordMapper.xml
new file mode 100644
index 0000000..8ebbc91
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountWarehouseRecordMapper.xml
@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.user.mapper.AccountWarehouseRecordMapper">
+
+ <resultMap type="com.ruoyi.user.domain.AccountWarehouseRecord" id="AccountWarehouseRecordResult">
+ <id property="recordId" column="record_id" />
+ <result property="userId" column="user_id" />
+ <result property="warehouseId" column="warehouse_id" />
+ <result property="productName" column="product_name" />
+ <result property="changeType" column="change_type" />
+ <result property="changeQty" column="change_qty" />
+ <result property="afterQty" column="after_qty" />
+ <result property="bizNo" column="biz_no" />
+ <result property="delFlag" column="del_flag" />
+ <result property="createBy" column="create_by" />
+ <result property="createTime" column="create_time" />
+ <result property="updateBy" column="update_by" />
+ <result property="updateTime" column="update_time" />
+ <result property="remark" column="remark" />
+ </resultMap>
+
+ <sql id="selectWarehouseRecordVo">
+ select record_id, user_id, warehouse_id, product_name, change_type, change_qty,
+ after_qty, biz_no, del_flag, create_by, create_time, update_by, update_time, remark
+ from account_warehouse_record
+ </sql>
+
+ <!-- 变动时间即 create_time,入库时统一落 sysdate(),避免与业务时间口径漂移 -->
+ <insert id="insertWarehouseRecord" parameterType="com.ruoyi.user.domain.AccountWarehouseRecord" useGeneratedKeys="true" keyProperty="recordId">
+ insert into account_warehouse_record(
+ user_id,
+ <if test="warehouseId != null">warehouse_id,</if>
+ <if test="productName != null">product_name,</if>
+ change_type,
+ change_qty,
+ after_qty,
+ <if test="bizNo != null">biz_no,</if>
+ <if test="remark != null">remark,</if>
+ del_flag,
+ create_time
+ )values(
+ #{userId},
+ <if test="warehouseId != null">#{warehouseId},</if>
+ <if test="productName != null">#{productName},</if>
+ #{changeType},
+ #{changeQty},
+ #{afterQty},
+ <if test="bizNo != null">#{bizNo},</if>
+ <if test="remark != null">#{remark},</if>
+ '0',
+ sysdate()
+ )
+ </insert>
+
+ <select id="selectWarehouseRecordList" parameterType="Long" resultMap="AccountWarehouseRecordResult">
+ <include refid="selectWarehouseRecordVo"/>
+ where del_flag = '0' and user_id = #{userId}
+ order by record_id desc
+ </select>
+
+</mapper>
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountCertificationApplyServiceImplTest.java b/hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountCertificationApplyServiceImplTest.java
new file mode 100644
index 0000000..afc1492
--- /dev/null
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountCertificationApplyServiceImplTest.java
@@ -0,0 +1,292 @@
+package com.ruoyi.user.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import com.ruoyi.common.core.exception.ServiceException;
+import com.ruoyi.user.domain.AccountCertificationApply;
+import com.ruoyi.user.domain.AccountUser;
+import com.ruoyi.user.domain.dto.CompanyCertificationApplyDto;
+import com.ruoyi.user.domain.vo.AccountCertificationStatusVo;
+import com.ruoyi.user.domain.vo.CompanyCertificationStatusVo;
+import com.ruoyi.user.mapper.AccountCertificationApplyMapper;
+import com.ruoyi.user.mapper.AccountCertificationMapper;
+import com.ruoyi.user.mapper.AccountUserMapper;
+
+/**
+ * 会员主体认证申请 服务单元测试
+ *
+ * 覆盖 C 端个人主体认证提交:正常提交(落待审核申请 + 联动认证状态为审核中)、
+ * 待审核申请存在时防重拒绝、以及会员ID/姓名/身份证号缺失时的入参校验;
+ * 另覆盖认证状态查询:有申请时回填资料与驳回原因、无申请/无登录态时返回空对象。
+ *
+ * @author hugray
+ */
+@ExtendWith(MockitoExtension.class)
+class AccountCertificationApplyServiceImplTest
+{
+ private static final Long USER_ID = 66L;
+
+ private static final String REAL_NAME = "张三";
+
+ private static final String ID_CARD = "110101199003071234";
+
+ @Mock
+ private AccountCertificationApplyMapper certificationApplyMapper;
+
+ @Mock
+ private AccountCertificationMapper certificationMapper;
+
+ @Mock
+ private AccountUserMapper accountUserMapper;
+
+ @InjectMocks
+ private AccountCertificationApplyServiceImpl certificationApplyService;
+
+ @Test
+ @DisplayName("正常提交应落一条待审核的个人申请并把认证状态置为审核中")
+ void submitPersonalCertification_shouldInsertPendingApplyAndSetAuditing()
+ {
+ when(certificationApplyMapper.countPendingPersonalApplyByUserId(USER_ID)).thenReturn(0);
+ when(certificationApplyMapper.insertCertificationApply(any(AccountCertificationApply.class))).thenReturn(1);
+
+ certificationApplyService.submitPersonalCertification(USER_ID, " 张三 ", "11010119900307123x");
+
+ // 申请快照:个人主体、待审核、姓名去空白、身份证号末位小写 x 统一转大写
+ ArgumentCaptor<AccountCertificationApply> applyCaptor = ArgumentCaptor.forClass(AccountCertificationApply.class);
+ verify(certificationApplyMapper).insertCertificationApply(applyCaptor.capture());
+ AccountCertificationApply saved = applyCaptor.getValue();
+ assertEquals(USER_ID, saved.getUserId());
+ assertEquals("1", saved.getSubjectType());
+ assertEquals("0", saved.getAuditStatus());
+ assertEquals(REAL_NAME, saved.getRealName());
+ assertEquals("11010119900307123X", saved.getIdCard());
+
+ // 状态联动:account_user.auth_status = '1'(审核中)
+ ArgumentCaptor<AccountUser> userCaptor = ArgumentCaptor.forClass(AccountUser.class);
+ verify(accountUserMapper).updateUserAuthStatus(userCaptor.capture());
+ assertEquals(USER_ID, userCaptor.getValue().getUserId());
+ assertEquals("1", userCaptor.getValue().getAuthStatus());
+ }
+
+ @Test
+ @DisplayName("已有待审核申请时应拒绝重复提交且不落库")
+ void submitPersonalCertification_shouldThrow_whenPendingApplyExists()
+ {
+ when(certificationApplyMapper.countPendingPersonalApplyByUserId(USER_ID)).thenReturn(1);
+
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> certificationApplyService.submitPersonalCertification(USER_ID, REAL_NAME, ID_CARD));
+
+ assertEquals("已有实名认证申请正在审核中,请勿重复提交", ex.getMessage());
+ verify(certificationApplyMapper, never()).insertCertificationApply(any(AccountCertificationApply.class));
+ }
+
+ @Test
+ @DisplayName("会员ID为空时应抛出提交失败")
+ void submitPersonalCertification_shouldThrow_whenUserIdNull()
+ {
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> certificationApplyService.submitPersonalCertification(null, REAL_NAME, ID_CARD));
+
+ assertEquals("提交实名认证失败", ex.getMessage());
+ verify(certificationApplyMapper, never()).insertCertificationApply(any(AccountCertificationApply.class));
+ }
+
+ @Test
+ @DisplayName("姓名为空时应抛出提示且不落库")
+ void submitPersonalCertification_shouldThrow_whenRealNameBlank()
+ {
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> certificationApplyService.submitPersonalCertification(USER_ID, "", ID_CARD));
+
+ assertEquals("姓名与身份证号不能为空", ex.getMessage());
+ verify(certificationApplyMapper, never()).insertCertificationApply(any(AccountCertificationApply.class));
+ }
+
+ @Test
+ @DisplayName("身份证号为空时应抛出提示且不落库")
+ void submitPersonalCertification_shouldThrow_whenIdCardBlank()
+ {
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> certificationApplyService.submitPersonalCertification(USER_ID, REAL_NAME, null));
+
+ assertEquals("姓名与身份证号不能为空", ex.getMessage());
+ verify(certificationApplyMapper, never()).insertCertificationApply(any(AccountCertificationApply.class));
+ }
+
+ @Test
+ @DisplayName("查询认证状态应回填最近一次申请的资料与驳回原因")
+ void getCertificationStatus_shouldFillFromLatestApply()
+ {
+ AccountCertificationApply apply = new AccountCertificationApply();
+ apply.setRealName(REAL_NAME);
+ apply.setIdCard(ID_CARD);
+ apply.setAuditStatus("2");
+ apply.setAuditRemark("身份证照片不清晰,请重新提交");
+ when(certificationApplyMapper.selectLatestPersonalApplyByUserId(USER_ID)).thenReturn(apply);
+
+ AccountCertificationStatusVo vo = certificationApplyService.getCertificationStatus(USER_ID);
+
+ assertEquals(REAL_NAME, vo.getRealName());
+ assertEquals(ID_CARD, vo.getIdCard());
+ assertEquals("2", vo.getAuditStatus());
+ assertEquals("身份证照片不清晰,请重新提交", vo.getAuditRemark());
+ }
+
+ @Test
+ @DisplayName("从未提交时查询认证状态应返回空对象而非 null")
+ void getCertificationStatus_shouldReturnEmpty_whenNoApply()
+ {
+ when(certificationApplyMapper.selectLatestPersonalApplyByUserId(USER_ID)).thenReturn(null);
+
+ AccountCertificationStatusVo vo = certificationApplyService.getCertificationStatus(USER_ID);
+
+ assertNull(vo.getRealName());
+ assertNull(vo.getIdCard());
+ assertNull(vo.getAuditStatus());
+ assertNull(vo.getAuditRemark());
+ }
+
+ @Test
+ @DisplayName("会员ID为空时查询认证状态应返回空对象且不查库")
+ void getCertificationStatus_shouldReturnEmpty_whenUserIdNull()
+ {
+ AccountCertificationStatusVo vo = certificationApplyService.getCertificationStatus(null);
+
+ assertNull(vo.getAuditStatus());
+ verify(certificationApplyMapper, never()).selectLatestPersonalApplyByUserId(any());
+ }
+
+ @Test
+ @DisplayName("正常提交应落一条待审核的企业申请并把企业认证状态置为审核中")
+ void submitCompanyCertification_shouldInsertPendingApplyAndSetCompanyAuditing()
+ {
+ when(certificationApplyMapper.countPendingCompanyApplyByUserId(USER_ID)).thenReturn(0);
+ when(certificationApplyMapper.insertCertificationApply(any(AccountCertificationApply.class))).thenReturn(1);
+
+ certificationApplyService.submitCompanyCertification(USER_ID, buildCompanyDto("3", "1", null));
+
+ // 申请快照:企业主体(3)、待审核、无有效期(长期)
+ ArgumentCaptor<AccountCertificationApply> applyCaptor = ArgumentCaptor.forClass(AccountCertificationApply.class);
+ verify(certificationApplyMapper).insertCertificationApply(applyCaptor.capture());
+ AccountCertificationApply saved = applyCaptor.getValue();
+ assertEquals(USER_ID, saved.getUserId());
+ assertEquals("3", saved.getSubjectType());
+ assertEquals("0", saved.getAuditStatus());
+ assertEquals("测试企业有限公司", saved.getCompanyName());
+
+ // 状态联动:account_user.company_auth_status = '1'(审核中),且不动个人 auth_status
+ ArgumentCaptor<AccountUser> userCaptor = ArgumentCaptor.forClass(AccountUser.class);
+ verify(accountUserMapper).updateUserCompanyAuthStatus(userCaptor.capture());
+ assertEquals("1", userCaptor.getValue().getCompanyAuthStatus());
+ verify(accountUserMapper, never()).updateUserAuthStatus(any(AccountUser.class));
+ }
+
+ @Test
+ @DisplayName("有效期选「短期」但未填起止日期时应抛出提示且不落库")
+ void submitCompanyCertification_shouldThrow_whenShortTermPeriodMissing()
+ {
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> certificationApplyService.submitCompanyCertification(USER_ID, buildCompanyDto("3", "2", null)));
+
+ assertEquals("请选择营业执照有效期起止日期", ex.getMessage());
+ verify(certificationApplyMapper, never()).insertCertificationApply(any(AccountCertificationApply.class));
+ }
+
+ @Test
+ @DisplayName("已有待审核的企业申请时应拒绝重复提交且不落库")
+ void submitCompanyCertification_shouldThrow_whenPendingApplyExists()
+ {
+ when(certificationApplyMapper.countPendingCompanyApplyByUserId(USER_ID)).thenReturn(1);
+
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> certificationApplyService.submitCompanyCertification(USER_ID, buildCompanyDto("2", "1", null)));
+
+ assertEquals("已有企业认证申请正在审核中,请勿重复提交", ex.getMessage());
+ verify(certificationApplyMapper, never()).insertCertificationApply(any(AccountCertificationApply.class));
+ }
+
+ @Test
+ @DisplayName("会员ID为空时提交企业认证应抛出提交失败")
+ void submitCompanyCertification_shouldThrow_whenUserIdNull()
+ {
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> certificationApplyService.submitCompanyCertification(null, buildCompanyDto("3", "1", null)));
+
+ assertEquals("提交企业认证失败", ex.getMessage());
+ verify(certificationApplyMapper, never()).insertCertificationApply(any(AccountCertificationApply.class));
+ }
+
+ @Test
+ @DisplayName("查询企业认证状态应回填最近一次申请的资料与驳回原因")
+ void getCompanyCertificationStatus_shouldFillFromLatestApply()
+ {
+ AccountCertificationApply apply = new AccountCertificationApply();
+ apply.setSubjectType("3");
+ apply.setCompanyName("测试企业有限公司");
+ apply.setAuditStatus("2");
+ apply.setAuditRemark("营业执照照片不清晰,请重新提交");
+ when(certificationApplyMapper.selectLatestCompanyApplyByUserId(USER_ID)).thenReturn(apply);
+
+ CompanyCertificationStatusVo vo = certificationApplyService.getCompanyCertificationStatus(USER_ID);
+
+ assertEquals("3", vo.getSubjectType());
+ assertEquals("测试企业有限公司", vo.getCompanyName());
+ assertEquals("2", vo.getAuditStatus());
+ assertEquals("营业执照照片不清晰,请重新提交", vo.getAuditRemark());
+ }
+
+ @Test
+ @DisplayName("从未提交企业认证时查询状态应返回空对象而非 null")
+ void getCompanyCertificationStatus_shouldReturnEmpty_whenNoApply()
+ {
+ when(certificationApplyMapper.selectLatestCompanyApplyByUserId(USER_ID)).thenReturn(null);
+
+ CompanyCertificationStatusVo vo = certificationApplyService.getCompanyCertificationStatus(USER_ID);
+
+ assertNull(vo.getCompanyName());
+ assertNull(vo.getAuditStatus());
+ assertNull(vo.getAuditRemark());
+ }
+
+ /** 构造一份字段齐全的企业认证入参:validType 控制营业执照有效期类型,licensePeriod 为起止日期 */
+ private CompanyCertificationApplyDto buildCompanyDto(String subjectType, String validType, String licensePeriod)
+ {
+ CompanyCertificationApplyDto dto = new CompanyCertificationApplyDto();
+ dto.setSubjectType(subjectType);
+ dto.setCompanyName("测试企业有限公司");
+ dto.setLicenseImage("/upload/license.png");
+ dto.setCreditCode("91310000MA1FL0XXXX");
+ dto.setLicenseValidType(validType);
+ dto.setLicenseValidPeriod(licensePeriod);
+ dto.setRegisteredRegion("上海市 上海市 浦东新区");
+ dto.setCompanyAddress("浦东新区世纪大道 1 号");
+ dto.setLegalPerson("李四");
+ dto.setLegalIdCardFront("/upload/id-front.png");
+ dto.setLegalIdCardBack("/upload/id-back.png");
+ dto.setLegalIdValidType("1");
+ dto.setLegalIdValidPeriod(null);
+ dto.setLegalIdAddress("浦东新区世纪大道 1 号");
+ dto.setContactName("王五");
+ dto.setContactPhone("13800000000");
+ dto.setContactEmail("test@example.com");
+ dto.setPayeeType("1");
+ dto.setBankCardImage("/upload/bank.png");
+ dto.setBankCardNo("6222020000000000000");
+ dto.setBankCity("上海市");
+ return dto;
+ }
+}
diff --git a/hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountUserServiceImplTest.java b/hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountUserServiceImplTest.java
index e638e85..5aff6da 100644
--- a/hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountUserServiceImplTest.java
+++ b/hugray-server/ruoyi-modules/ruoyi-user/src/test/java/com/ruoyi/user/service/impl/AccountUserServiceImplTest.java
@@ -140,6 +140,7 @@
member.setPhone("13355555555");
member.setAvatar("https://cdn.example.com/a.png");
member.setSystemLevel(MemberLevelEnum.L3.getCode());
+ member.setAuthStatus("2");
EarningSummaryVo summary = new EarningSummaryVo(1000L, 40000L, 12800000L);
when(accountUserMapper.selectAccountUserById(66L)).thenReturn(member);
when(accountEarningRecordMapper.selectEarningSummary(66L)).thenReturn(summary);
@@ -151,6 +152,8 @@
assertEquals("133****5555", vo.getPhone());
assertEquals("https://cdn.example.com/a.png", vo.getAvatar());
assertEquals(MemberLevelEnum.L3.getCode(), vo.getMemberLevel());
+ // 认证状态原样下发,供个人中心「实名认证」行徽标回显
+ assertEquals("2", vo.getAuthStatus());
assertEquals(1000L, vo.getEarnings().getToday());
assertEquals(40000L, vo.getEarnings().getMonth());
assertEquals(12800000L, vo.getEarnings().getTotal());
@@ -209,6 +212,21 @@
assertFallbackLevel("L9", MemberLevelEnum.C.getCode());
}
+ @Test
+ @DisplayName("认证状态为空时应兜底为未认证(0)")
+ void getAccountUserInfo_shouldFallbackNoneAuthStatus_whenBlank()
+ {
+ AccountUser member = new AccountUser();
+ member.setUserId(66L);
+ member.setSystemLevel(MemberLevelEnum.C.getCode());
+ when(accountUserMapper.selectAccountUserById(66L)).thenReturn(member);
+ when(accountEarningRecordMapper.selectEarningSummary(66L)).thenReturn(EarningSummaryVo.zero());
+
+ AccountUserInfoVo vo = accountUserService.getAccountUserInfo(66L);
+
+ assertEquals("0", vo.getAuthStatus());
+ }
+
/**
* 校验不同等级取值下的等级兜底结果
*
@@ -227,4 +245,64 @@
assertEquals(expectedLevel, vo.getMemberLevel());
}
+
+ @Test
+ @DisplayName("更新头像时应只写入 avatar 列")
+ void updateAvatar_shouldUpdateAvatar_whenValid()
+ {
+ when(accountUserMapper.updateUserAvatar(any(AccountUser.class))).thenReturn(1);
+
+ accountUserService.updateAvatar(66L, "https://cdn.example.com/new.png");
+
+ ArgumentCaptor<AccountUser> captor = ArgumentCaptor.forClass(AccountUser.class);
+ verify(accountUserMapper).updateUserAvatar(captor.capture());
+ assertEquals(66L, captor.getValue().getUserId());
+ assertEquals("https://cdn.example.com/new.png", captor.getValue().getAvatar());
+ }
+
+ @Test
+ @DisplayName("头像地址为空时应抛出提示且不落库")
+ void updateAvatar_shouldThrow_whenAvatarBlank()
+ {
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> accountUserService.updateAvatar(66L, ""));
+
+ assertEquals("头像地址不能为空", ex.getMessage());
+ verify(accountUserMapper, never()).updateUserAvatar(any(AccountUser.class));
+ }
+
+ @Test
+ @DisplayName("头像地址超长时应抛出提示且不落库")
+ void updateAvatar_shouldThrow_whenAvatarTooLong()
+ {
+ String longUrl = "a".repeat(256);
+
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> accountUserService.updateAvatar(66L, longUrl));
+
+ assertEquals("头像地址过长", ex.getMessage());
+ verify(accountUserMapper, never()).updateUserAvatar(any(AccountUser.class));
+ }
+
+ @Test
+ @DisplayName("会员不存在(影响行数为 0)时应抛出更新失败")
+ void updateAvatar_shouldThrow_whenMemberNotFound()
+ {
+ when(accountUserMapper.updateUserAvatar(any(AccountUser.class))).thenReturn(0);
+
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> accountUserService.updateAvatar(66L, "https://cdn.example.com/new.png"));
+
+ assertEquals("更新头像失败", ex.getMessage());
+ }
+
+ @Test
+ @DisplayName("会员ID为空时应抛出更新失败")
+ void updateAvatar_shouldThrow_whenUserIdNull()
+ {
+ ServiceException ex = assertThrows(ServiceException.class,
+ () -> accountUserService.updateAvatar(null, "https://cdn.example.com/new.png"));
+
+ assertEquals("更新头像失败", ex.getMessage());
+ }
}
diff --git a/hugray-server/sql/hugray_account_company_certification_20261013.sql b/hugray-server/sql/hugray_account_company_certification_20261013.sql
new file mode 100644
index 0000000..eeac27f
--- /dev/null
+++ b/hugray-server/sql/hugray_account_company_certification_20261013.sql
@@ -0,0 +1,113 @@
+-- ----------------------------
+-- 企业/个体工商户认证:认证资料表扩充
+--
+-- 目标库 hugray-account(库名含连字符,任何引用都必须用反引号)。
+-- 本脚本为扩充脚本,两张认证表已有数据,一律用 ALTER,绝不 DROP。
+--
+-- 背景:原型「企业/个体认证」四步向导(企业信息 / 法人信息 / 收款信息 / 提交审核)
+-- 所需字段中,营业执照有效期类型、法人身份证正反面、法人身份证有效期、法人身份证详细地址、
+-- 负责人姓名/电话/邮箱、收款类型、银行卡照片、银行卡号、开户城市共 13 列缺失,本次补齐。
+--
+-- 涉及表:
+-- account_certification_apply 存"每次提交的申请",提交企业认证时 subject_type 取 2/3
+-- account_certification 存"当前生效"的认证资料,审核通过时落一份
+-- account_user 新增 company_auth_status,企业/个体认证状态独立于 auth_status
+--
+-- 【枚举取值】
+-- 主体类型 subject_type :1个人 2个体工商户 3企业
+-- 有效期类型 license_valid_type :1长期 2短期
+-- 有效期类型 legal_id_valid_type :1长期 2短期
+-- 企业认证状态 company_auth_status :0未认证 1审核中 2已认证 3认证失败
+--
+-- 【幂等说明】复用 hugray_add_column 临时存储过程做存在性判断,脚本可安全重复执行。
+--
+-- 【回滚语句】(如需回滚,在 hugray-account 库执行)
+-- ALTER TABLE `account_certification_apply` DROP COLUMN `license_valid_type`,
+-- DROP COLUMN `legal_id_card_front`, DROP COLUMN `legal_id_card_back`,
+-- DROP COLUMN `legal_id_valid_type`, DROP COLUMN `legal_id_valid_period`,
+-- DROP COLUMN `legal_id_address`, DROP COLUMN `contact_name`, DROP COLUMN `contact_phone`,
+-- DROP COLUMN `contact_email`, DROP COLUMN `payee_type`, DROP COLUMN `bank_card_image`,
+-- DROP COLUMN `bank_card_no`, DROP COLUMN `bank_city`;
+-- ALTER TABLE `account_certification` DROP COLUMN `license_valid_type`,
+-- DROP COLUMN `legal_id_card_front`, DROP COLUMN `legal_id_card_back`,
+-- DROP COLUMN `legal_id_valid_type`, DROP COLUMN `legal_id_valid_period`,
+-- DROP COLUMN `legal_id_address`, DROP COLUMN `contact_name`, DROP COLUMN `contact_phone`,
+-- DROP COLUMN `contact_email`, DROP COLUMN `payee_type`, DROP COLUMN `bank_card_image`,
+-- DROP COLUMN `bank_card_no`, DROP COLUMN `bank_city`;
+-- ALTER TABLE `account_user` DROP COLUMN `company_auth_status`;
+-- ----------------------------
+
+USE `hugray-account`;
+
+-- ---------- 幂等辅助过程 ----------
+DROP PROCEDURE IF EXISTS `hugray_add_column`;
+DROP PROCEDURE IF EXISTS `hugray_add_index`;
+
+DELIMITER $$
+CREATE PROCEDURE `hugray_add_column`(IN p_table VARCHAR(64), IN p_column VARCHAR(64), IN p_definition TEXT)
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = p_table
+ AND COLUMN_NAME = p_column) THEN
+ SET @ddl = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition);
+ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+ END IF;
+END$$
+
+CREATE PROCEDURE `hugray_add_index`(IN p_table VARCHAR(64), IN p_index VARCHAR(64), IN p_columns TEXT)
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = p_table
+ AND INDEX_NAME = p_index) THEN
+ SET @ddl = CONCAT('ALTER TABLE `', p_table, '` ADD INDEX `', p_index, '` (', p_columns, ')');
+ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+ END IF;
+END$$
+DELIMITER ;
+
+-- ---------- 1. account_certification_apply:企业/个体认证字段 ----------
+CALL `hugray_add_column`('account_certification_apply', 'license_valid_type', "varchar(16) DEFAULT NULL COMMENT '营业执照有效期类型(1长期 2短期)'");
+CALL `hugray_add_column`('account_certification_apply', 'legal_id_card_front', "varchar(500) DEFAULT NULL COMMENT '法人身份证人像面图片'");
+CALL `hugray_add_column`('account_certification_apply', 'legal_id_card_back', "varchar(500) DEFAULT NULL COMMENT '法人身份证国徽面图片'");
+CALL `hugray_add_column`('account_certification_apply', 'legal_id_valid_type', "varchar(16) DEFAULT NULL COMMENT '法人身份证有效期类型(1长期 2短期)'");
+CALL `hugray_add_column`('account_certification_apply', 'legal_id_valid_period',"varchar(32) DEFAULT NULL COMMENT '法人身份证有效期(存文本,如 2024-01-01~2034-01-01)'");
+CALL `hugray_add_column`('account_certification_apply', 'legal_id_address', "varchar(255) DEFAULT NULL COMMENT '法人身份证详细地址'");
+CALL `hugray_add_column`('account_certification_apply', 'contact_name', "varchar(64) DEFAULT NULL COMMENT '负责人姓名'");
+CALL `hugray_add_column`('account_certification_apply', 'contact_phone', "varchar(20) DEFAULT NULL COMMENT '负责人联系电话'");
+CALL `hugray_add_column`('account_certification_apply', 'contact_email', "varchar(128) DEFAULT NULL COMMENT '负责人邮箱'");
+CALL `hugray_add_column`('account_certification_apply', 'payee_type', "varchar(16) DEFAULT NULL COMMENT '收款类型'");
+CALL `hugray_add_column`('account_certification_apply', 'bank_card_image', "varchar(500) DEFAULT NULL COMMENT '银行卡号码面照片'");
+CALL `hugray_add_column`('account_certification_apply', 'bank_card_no', "varchar(32) DEFAULT NULL COMMENT '银行卡号'");
+CALL `hugray_add_column`('account_certification_apply', 'bank_city', "varchar(128) DEFAULT NULL COMMENT '开户城市'");
+
+-- ---------- 2. account_certification:企业/个体认证字段(与申请表同构) ----------
+CALL `hugray_add_column`('account_certification', 'license_valid_type', "varchar(16) DEFAULT NULL COMMENT '营业执照有效期类型(1长期 2短期)'");
+CALL `hugray_add_column`('account_certification', 'legal_id_card_front', "varchar(500) DEFAULT NULL COMMENT '法人身份证人像面图片'");
+CALL `hugray_add_column`('account_certification', 'legal_id_card_back', "varchar(500) DEFAULT NULL COMMENT '法人身份证国徽面图片'");
+CALL `hugray_add_column`('account_certification', 'legal_id_valid_type', "varchar(16) DEFAULT NULL COMMENT '法人身份证有效期类型(1长期 2短期)'");
+CALL `hugray_add_column`('account_certification', 'legal_id_valid_period',"varchar(32) DEFAULT NULL COMMENT '法人身份证有效期(存文本,如 2024-01-01~2034-01-01)'");
+CALL `hugray_add_column`('account_certification', 'legal_id_address', "varchar(255) DEFAULT NULL COMMENT '法人身份证详细地址'");
+CALL `hugray_add_column`('account_certification', 'contact_name', "varchar(64) DEFAULT NULL COMMENT '负责人姓名'");
+CALL `hugray_add_column`('account_certification', 'contact_phone', "varchar(20) DEFAULT NULL COMMENT '负责人联系电话'");
+CALL `hugray_add_column`('account_certification', 'contact_email', "varchar(128) DEFAULT NULL COMMENT '负责人邮箱'");
+CALL `hugray_add_column`('account_certification', 'payee_type', "varchar(16) DEFAULT NULL COMMENT '收款类型'");
+CALL `hugray_add_column`('account_certification', 'bank_card_image', "varchar(500) DEFAULT NULL COMMENT '银行卡号码面照片'");
+CALL `hugray_add_column`('account_certification', 'bank_card_no', "varchar(32) DEFAULT NULL COMMENT '银行卡号'");
+CALL `hugray_add_column`('account_certification', 'bank_city', "varchar(128) DEFAULT NULL COMMENT '开户城市'");
+
+-- ---------- 3. account_user:企业/个体认证状态(独立于 auth_status) ----------
+-- 个人中心需两行独立徽标(实名认证 / 企业个体户认证),故企业状态单独一列,互不覆盖。
+CALL `hugray_add_column`('account_user', 'company_auth_status', "char(1) DEFAULT '0' COMMENT '企业/个体认证状态(0未认证 1审核中 2已认证 3认证失败)'");
+CALL `hugray_add_index`('account_user', 'idx_account_user_company_auth', '`company_auth_status`');
+
+-- ---------- 4. 清理辅助过程 ----------
+DROP PROCEDURE IF EXISTS `hugray_add_column`;
+DROP PROCEDURE IF EXISTS `hugray_add_index`;
+
+-- ---------- 5. 结果核对 ----------
+SELECT `COLUMN_NAME`, `COLUMN_TYPE`, `COLUMN_DEFAULT`, `COLUMN_COMMENT`
+FROM INFORMATION_SCHEMA.COLUMNS
+WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'account_certification_apply'
+ORDER BY `ORDINAL_POSITION`;
diff --git a/hugray-server/sql/hugray_account_member_center_20261014.sql b/hugray-server/sql/hugray_account_member_center_20261014.sql
new file mode 100644
index 0000000..65e614d
--- /dev/null
+++ b/hugray-server/sql/hugray_account_member_center_20261014.sql
@@ -0,0 +1,96 @@
+-- ----------------------------
+-- C 端会员 个人中心-会员中心 相关变更(ruoyi-user 用户服务)
+--
+-- 需求:个人中心「会员中心」页需展示「销售统计」(当月销售额/进货额/利润、销售额趋势、
+-- 商品销售额比例、销售用户排行)与「我的仓库」(库存列表、低库存警示、进货、仓库记录)。
+-- 变更内容:
+-- 1. 新增「会员销售流水表」account_sales_record,销售三栏/趋势/比例/排行由其按时间实时聚合;
+-- 2. 新增「会员进货单表」account_purchase_order,进货入库留痕,当月进货额由其聚合;
+-- 3. 新增「会员云仓变动流水表」account_warehouse_record,记录每次库存变动(进货入库等)。
+--
+-- 说明(按权威库 192.168.110.111 的既有结构对齐):
+-- account_warehouse 已有库存列,本脚本不改动该表;进货仅在其上做 stock_qty 原子自增。
+-- account_user 的 9 个业务统计列本期不回填,页面数据一律由流水表实时聚合,避免双写口径不一致。
+--
+-- 目标库 hugray-account 由使用方预先创建(utf8mb4)。
+-- 注意:库名含连字符,任何引用都必须用反引号。
+-- ----------------------------
+
+USE `hugray-account`;
+
+-- 变更 1:新增会员销售流水表(一条流水 = 一次销售事件,含销售额与利润额)
+CREATE TABLE IF NOT EXISTS `account_sales_record` (
+ `record_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '销售流水ID',
+ `seller_id` bigint(20) NOT NULL COMMENT '销售会员ID(account_user.user_id)',
+ `buyer_id` bigint(20) DEFAULT NULL COMMENT '购买会员ID(account_user.user_id,散客为空)',
+ `buyer_name` varchar(64) DEFAULT NULL COMMENT '购买人昵称(销售排行展示用)',
+ `buyer_phone` varchar(20) DEFAULT NULL COMMENT '购买人手机号(下发脱敏)',
+ `product_name` varchar(128) DEFAULT NULL COMMENT '商品名称(比例图/排行展示用)',
+ `qty` bigint(20) NOT NULL DEFAULT 0 COMMENT '销售数量',
+ `amount` bigint(20) NOT NULL DEFAULT 0 COMMENT '销售额(单位:分)',
+ `profit_amount` bigint(20) NOT NULL DEFAULT 0 COMMENT '利润额(单位:分,可为负)',
+ `sale_time` datetime DEFAULT NULL COMMENT '销售时间(聚合口径)',
+ `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0存在 2删除)',
+ `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(64) DEFAULT '' COMMENT '更新者',
+ `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+ `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+ PRIMARY KEY (`record_id`),
+ -- 覆盖「按销售会员 + 时间区间」的聚合,避免全表扫描
+ KEY `idx_sales_seller_time` (`seller_id`, `sale_time`)
+) ENGINE=InnoDB AUTO_INCREMENT=100 DEFAULT CHARSET=utf8mb4 COMMENT='会员销售流水表';
+
+-- 变更 2:新增会员进货单表(生成进货单即增加云仓库存,下单时间即聚合口径)
+CREATE TABLE IF NOT EXISTS `account_purchase_order` (
+ `order_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '进货单ID',
+ `order_no` varchar(64) NOT NULL COMMENT '进货单号(幂等唯一)',
+ `user_id` bigint(20) NOT NULL COMMENT '进货会员ID(account_user.user_id)',
+ `warehouse_id` bigint(20) DEFAULT NULL COMMENT '云仓行ID(account_warehouse.id,快照)',
+ `product_name` varchar(128) DEFAULT NULL COMMENT '商品名称(快照)',
+ `qty` bigint(20) NOT NULL DEFAULT 0 COMMENT '进货数量',
+ `unit_price` bigint(20) NOT NULL DEFAULT 0 COMMENT '进货单价(单位:分)',
+ `total_amount` bigint(20) NOT NULL DEFAULT 0 COMMENT '进货金额(单位:分)= qty × unit_price',
+ `status` char(1) DEFAULT '1' COMMENT '状态(1已入库)',
+ `purchase_time` datetime DEFAULT NULL COMMENT '进货时间(聚合口径)',
+ `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0存在 2删除)',
+ `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(64) DEFAULT '' COMMENT '更新者',
+ `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+ `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+ PRIMARY KEY (`order_id`),
+ -- 业务幂等:进货单号全局唯一,重复入库被数据库兜底拦截
+ UNIQUE KEY `uk_purchase_order_no` (`order_no`),
+ -- 覆盖「按会员 + 时间区间」的进货额聚合
+ KEY `idx_purchase_user_time` (`user_id`, `purchase_time`)
+) ENGINE=InnoDB AUTO_INCREMENT=100 DEFAULT CHARSET=utf8mb4 COMMENT='会员进货单表';
+
+-- 变更 3:新增会员云仓变动流水表(进货入库/销售出库等库存变动留痕)
+CREATE TABLE IF NOT EXISTS `account_warehouse_record` (
+ `record_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '流水ID',
+ `user_id` bigint(20) NOT NULL COMMENT '会员ID(account_user.user_id)',
+ `warehouse_id` bigint(20) DEFAULT NULL COMMENT '云仓行ID(account_warehouse.id)',
+ `product_name` varchar(128) DEFAULT NULL COMMENT '商品名称(快照)',
+ `change_type` char(1) NOT NULL COMMENT '变动类型(1进货入库 2销售出库 3库存调整)',
+ `change_qty` bigint(20) NOT NULL DEFAULT 0 COMMENT '变动数量(入库为正,出库为负)',
+ `after_qty` bigint(20) NOT NULL DEFAULT 0 COMMENT '变动后库存',
+ `biz_no` varchar(64) DEFAULT NULL COMMENT '业务单号(关联进货单等)',
+ `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0存在 2删除)',
+ `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
+ `create_time` datetime DEFAULT NULL COMMENT '变动时间',
+ `update_by` varchar(64) DEFAULT '' COMMENT '更新者',
+ `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+ `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+ PRIMARY KEY (`record_id`),
+ -- 覆盖「按会员 + 时间倒序」的流水分页
+ KEY `idx_whrecord_user_time` (`user_id`, `create_time`),
+ KEY `idx_whrecord_warehouse` (`warehouse_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=100 DEFAULT CHARSET=utf8mb4 COMMENT='会员云仓变动流水表';
+
+-- ----------------------------
+-- 回滚语句(如需回滚,按依赖倒序执行)
+-- ----------------------------
+-- DROP TABLE IF EXISTS `account_warehouse_record`;
+-- DROP TABLE IF EXISTS `account_purchase_order`;
+-- DROP TABLE IF EXISTS `account_sales_record`;
diff --git a/hugray-user-miniapp/api/index.js b/hugray-user-miniapp/api/index.js
index cc7e5ec..45baca9 100644
--- a/hugray-user-miniapp/api/index.js
+++ b/hugray-user-miniapp/api/index.js
@@ -7,7 +7,7 @@
* /miniapp/product/** -> ruoyi-product 商品/订单
* /miniapp/system/** -> ruoyi-system 系统信息
*/
-import { get, post } from '@/utils/request.js'
+import { get, post, put, del, uploadFile } from '@/utils/request.js'
// ======================== 认证相关 ========================
@@ -109,6 +109,191 @@
return get('/miniapp/account/user/stock')
}
+/**
+ * 查询当前会员的销售统计(会员中心「销售统计」Tab)
+ * 返回 data 含 phone / memberLevel、三栏金额(单位:分)、利润环比 profitGrowthRate(%)、
+ * 销售额趋势 salesTrend、商品销售额比例 productSalesRatio、销售用户排行 salesUserRanking
+ */
+export function getMemberSalesStatistics() {
+ return get('/miniapp/account/user/member/sales-statistics')
+}
+
+/**
+ * 查询当前会员的云仓列表(「我的仓库」Tab)
+ * 返回 data 为数组,元素含 warehouseId / productName / stockQty / minStockQty / lowStock
+ */
+export function getMemberWarehouses() {
+ return get('/miniapp/account/user/member/warehouses')
+}
+
+/**
+ * 会员进货(生成进货单并增加云仓库存,会员身份由网关注入)
+ * @param {Object} data { warehouseId, qty, unitPrice } unitPrice 单位为分,选填
+ */
+export function memberPurchase(data) {
+ return post('/miniapp/account/user/member/purchase', data)
+}
+
+/**
+ * 查询当前会员的云仓变动记录(「仓库记录」弹层)
+ * @param {Object} params { pageNum, pageSize }
+ * 说明:后端返回 TableDataInfo,数据在顶层 rows(非 data 包裹),总数为 total
+ */
+export function getMemberWarehouseRecords(params) {
+ return get('/miniapp/account/user/member/warehouse-records', params)
+}
+
+/**
+ * 查询当前会员的进货订单(「进货订单」Tab)
+ * @param {Object} params { pageNum, pageSize }
+ * 说明:后端返回 TableDataInfo,数据在顶层 rows(非 data 包裹),总数为 total
+ */
+export function getMemberPurchaseOrders(params) {
+ return get('/miniapp/account/user/member/purchase-orders', params)
+}
+
+/**
+ * 上传头像文件到文件服务,返回 { data: { name, url } }
+ * 说明:走网关 /file/** -> ruoyi-file,需登录态(网关白名单不含 /file/upload)
+ * @param {String} filePath 本地临时文件路径(uni.chooseImage 返回)
+ */
+export function uploadAvatarFile(filePath) {
+ return uploadFile('/file/upload', filePath)
+}
+
+/**
+ * 更新当前登录会员头像(需登录态,userId 由网关注入,前端只传头像地址)
+ * @param {String} avatar 头像地址(来自 uploadAvatarFile 返回的 url)
+ */
+export function updateAvatar(avatar) {
+ return post('/miniapp/account/user/avatar', { avatar })
+}
+
+/**
+ * 查询当前会员实名认证状态(进入认证页回填资料与状态用)
+ * 返回 { realName, idCard, auditStatus, auditRemark }:
+ * auditStatus 0待审核 1已通过 2已驳回;为空表示从未提交
+ */
+export function getCertificationStatus() {
+ return get('/miniapp/account/user/certification')
+}
+
+/**
+ * 提交实名认证(个人主体,只做基础格式校验,不做三方实名核验)
+ * 说明:会员身份由网关注入,前端只传姓名与身份证号;提交后进入后台「主体认证审核」待审
+ * @param {String} realName 姓名
+ * @param {String} idCard 身份证号
+ */
+export function submitCertification(realName, idCard) {
+ return post('/miniapp/account/user/certification', { realName, idCard })
+}
+
+/**
+ * 查询当前会员企业/个体认证状态与资料(进入向导页回填用)
+ * 返回最近一次企业/个体申请快照(subject_type ∈ {2,3}):
+ * 21 个资料字段 + auditStatus(0待审核 1已通过 2已驳回,为空表示从未提交)+ auditRemark(驳回原因)
+ */
+export function getCompanyCertificationStatus() {
+ return get('/miniapp/account/user/company-certification')
+}
+
+/**
+ * 提交企业/个体认证(只做基础必填校验,不做格式/三方核验)
+ * 说明:会员身份由网关注入,前端不传 userId;提交后进入后台「主体认证审核」待审队列
+ * @param {Object} data 21 个字段(见 CompanyCertificationApplyDto),如 subjectType/companyName/licenseImage 等
+ */
+export function submitCompanyCertification(data) {
+ return post('/miniapp/account/user/company-certification', data)
+}
+
+// ======================== 我的邀请 ========================
+
+/**
+ * 查询本人邀请码(「我的邀请」页邀请码 Tab)
+ * 返回 { referralCode }:邀请码注册时生成,C 端会员必有
+ */
+export function getInviteCode() {
+ return get('/miniapp/account/user/invite-code')
+}
+
+/**
+ * 查询本人邀请码小程序码(扫码直达注册页并自动填入邀请码)
+ * @param {String} code 邀请码(8 位 A-Z0-9),作为小程序码 scene
+ * 返回 { qrcode }:PNG 的 base64 DataURI(data:image/png;base64,...),可直接作为 image src
+ */
+export function getInviteQrcode(code) {
+ return get('/miniapp/auth/invite-qrcode', { code })
+}
+
+/**
+ * 查询我邀请的(直接下级)列表(分页,按绑定时间倒序)
+ * @param {Object} params { pageNum, pageSize }
+ * 说明:后端返回 TableDataInfo,数据在顶层 rows(非 data 包裹),总数为 total
+ */
+export function getInvitees(params) {
+ return get('/miniapp/account/user/invitees', params)
+}
+
+/**
+ * 查询邀请我的(上级/推荐人)信息
+ * 返回 { userId, nickname, avatar, phone, systemLevel, bindDate };无上级时各字段为 null
+ */
+export function getReferrer() {
+ return get('/miniapp/account/user/referrer')
+}
+
+// ======================== 收货地址 ========================
+
+/**
+ * 查询本人收货地址列表(默认地址在前,不分页)
+ * 返回 data 为数组,元素含 id / consignee / consigneePhone / address / isDefault
+ */
+export function getAddressList() {
+ return get('/miniapp/account/user/addresses')
+}
+
+/**
+ * 查询单条收货地址详情(编辑页回填用)
+ * @param {String|Number} id 地址ID
+ */
+export function getAddressDetail(id) {
+ return get('/miniapp/account/user/addresses/' + id)
+}
+
+/**
+ * 新增收货地址(会员身份由网关注入,前端不传 userId)
+ * @param {Object} data { consignee, consigneePhone, address, isDefault }
+ */
+export function createAddress(data) {
+ return post('/miniapp/account/user/addresses', data)
+}
+
+/**
+ * 编辑收货地址(会员身份由网关注入)
+ * @param {Object} data { id, consignee, consigneePhone, address, isDefault }
+ */
+export function updateAddress(data) {
+ return put('/miniapp/account/user/addresses', data)
+}
+
+/**
+ * 删除收货地址(后端校验归属)
+ * @param {String|Number} id 地址ID
+ */
+export function deleteAddress(id) {
+ return del('/miniapp/account/user/addresses/' + id)
+}
+
+// ======================== 系统信息 ========================
+
+/**
+ * 查询联系方式(「联系我们」页回填用)
+ * 返回 data 含 { cityHotline, partnerRecruit },未配置时对应字段为 null
+ */
+export function getContactInfo() {
+ return get('/miniapp/system/contact-info')
+}
+
// ======================== 订单相关 ========================
/**
diff --git a/hugray-user-miniapp/pages.json b/hugray-user-miniapp/pages.json
index fdb3bde..1d95863 100644
--- a/hugray-user-miniapp/pages.json
+++ b/hugray-user-miniapp/pages.json
@@ -37,6 +37,34 @@
}
},
{
+ "path": "pages/profile/certification",
+ // 导航栏含更多 / 帮助图标,需自定义导航栏承载
+ "style": {
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "pages/profile/company-certification",
+ // 顶部为四段式步骤条,需自定义导航栏承载
+ "style": {
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "pages/profile/invite",
+ // 顶部为三段式 Tab + 居中标题,需自定义导航栏承载
+ "style": {
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "pages/member/index",
+ // 顶部为深蓝会员卡 + 三段式 Tab,需自定义导航栏承载
+ "style": {
+ "navigationStyle": "custom"
+ }
+ },
+ {
"path": "pages/contact/index",
// 顶部为深蓝底品牌区,与白底原生导航冲突,故使用自定义导航栏
"style": {
diff --git a/hugray-user-miniapp/pages/address/add.vue b/hugray-user-miniapp/pages/address/add.vue
index 1c2a00b..bf9eab0 100644
--- a/hugray-user-miniapp/pages/address/add.vue
+++ b/hugray-user-miniapp/pages/address/add.vue
@@ -72,17 +72,19 @@
<!-- 底部固定提交栏:尺寸/圆角与地址列表页「新增地址」按钮一致 -->
<view class="submit-bar">
- <view class="submit-btn" @tap="_handleSubmit">提交</view>
+ <view class="submit-btn" @tap="_handleSubmit">{{ isEdit ? '保存' : '提交' }}</view>
</view>
</view>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import { getAddressDetail, createAddress, updateAddress } from '@/api/index.js'
// ======================== 数据定义区域 ========================
-// 新增地址表单
+// 地址表单(新增/编辑共用)
const form = reactive({
name: '',
phone: '',
@@ -97,7 +99,48 @@
// 地区文本:拼接为「省 市 区」,为空时由模板展示占位文案
const regionText = computed(() => form.region.join(' '))
+// 是否编辑模式(onLoad 带 id 时为编辑,复用新增页)
+const isEdit = ref(false)
+
+// 编辑模式下的地址ID
+const addressId = ref(null)
+
// ======================== 方法定义区域 ========================
+
+/** 拉取地址详情并回填表单(编辑模式) */
+const _loadAddressDetail = async (id) => {
+ try {
+ const res = await getAddressDetail(id)
+ const data = res.data || {}
+ form.name = data.consignee || ''
+ form.phone = data.consigneePhone || ''
+ form.isDefault = data.isDefault === '1'
+ // 拆分单字段 address:约定前 3 段为省/市/区,其余段 rejoin 还原详细地址;
+ // 段数 < 4(历史/后台手工录入的非约定格式)时地区留空、整串填入详细地址
+ const tokens = (data.address || '').split(' ')
+ if (tokens.length >= 4) {
+ form.region = tokens.slice(0, 3)
+ form.detail = tokens.slice(3).join(' ')
+ } else {
+ form.region = []
+ form.detail = data.address || ''
+ }
+ regionValue.value = [...form.region]
+ } catch (e) {
+ console.error('[ADDRESS_DETAIL] 加载地址详情失败', e)
+ uni.showToast({ title: e.message || '加载失败', icon: 'none' })
+ }
+}
+
+// 页面加载:带 id → 编辑模式(回填表单 + 切换标题);不带 id → 新增模式
+onLoad((options) => {
+ const id = options && options.id
+ if (!id) return
+ isEdit.value = true
+ addressId.value = id
+ uni.setNavigationBarTitle({ title: '编辑地址' })
+ _loadAddressDetail(id)
+})
/** 切换「设为默认」 */
const _handleDefaultChange = (e) => {
@@ -121,16 +164,32 @@
return ''
}
-/** 提交:校验通过后保存并返回地址列表页(地址新增接口未就绪,暂以本地成功反馈占位) */
-const _handleSubmit = () => {
+/** 提交:校验通过后按模式调用新增/编辑接口,成功返回列表页(列表页 onShow 自动刷新) */
+const _handleSubmit = async () => {
const errorMsg = _validateForm()
if (errorMsg) {
uni.showToast({ title: errorMsg, icon: 'none' })
return
}
-
- uni.showToast({ title: '保存成功', icon: 'none' })
- setTimeout(() => uni.navigateBack(), 600)
+ // 单字段 address 约定:省 市 区 详细地址(空格分隔)
+ const payload = {
+ consignee: form.name.trim(),
+ consigneePhone: form.phone.trim(),
+ address: [...form.region, form.detail.trim()].join(' '),
+ isDefault: form.isDefault ? '1' : '0'
+ }
+ try {
+ if (isEdit.value) {
+ await updateAddress({ ...payload, id: addressId.value })
+ } else {
+ await createAddress(payload)
+ }
+ uni.showToast({ title: '保存成功', icon: 'none' })
+ setTimeout(() => uni.navigateBack(), 600)
+ } catch (e) {
+ console.error('[ADDRESS_SAVE] 保存收货地址失败', e)
+ uni.showToast({ title: e.message || '保存失败', icon: 'none' })
+ }
}
</script>
diff --git a/hugray-user-miniapp/pages/address/list.vue b/hugray-user-miniapp/pages/address/list.vue
index d8332a1..4718dd1 100644
--- a/hugray-user-miniapp/pages/address/list.vue
+++ b/hugray-user-miniapp/pages/address/list.vue
@@ -1,19 +1,23 @@
<template>
<view class="page">
- <!-- 有数据:渲染地址列表,右侧为编辑入口 -->
+ <!-- 有数据:渲染地址列表,右侧为编辑/删除入口 -->
<view v-if="addressList.length">
<view v-for="item in addressList" :key="item.id" class="addr-item">
<view class="addr-main">
<!-- 姓名 + 手机号 -->
<view class="addr-head">
- <text class="addr-name">{{ item.name }}</text>
- <text class="addr-phone">{{ item.phone }}</text>
+ <text class="addr-name">{{ item.consignee }}</text>
+ <text class="addr-phone">{{ item.consigneePhone }}</text>
</view>
<!-- 默认地址标记 -->
- <view v-if="item.isDefault" class="addr-tag">默认</view>
- <text class="addr-detail">{{ item.detail }}</text>
+ <view v-if="item.isDefault === '1'" class="addr-tag">默认</view>
+ <text class="addr-detail">{{ item.address }}</text>
</view>
- <view class="icon icon-edit" @tap="handleEdit(item)"></view>
+ <!-- 操作区:编辑 / 删除 -->
+ <view class="addr-actions">
+ <view class="icon icon-edit" @tap="handleEdit(item)"></view>
+ <view class="icon icon-delete" @tap="handleDelete(item)"></view>
+ </view>
</view>
</view>
@@ -38,35 +42,52 @@
<script setup>
import { ref } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
+import { getAddressList, deleteAddress } from '@/api/index.js'
+import { ensureLogin } from '@/utils/request.js'
-/** 收货地址列表:地址接口未就绪,先本地 mock(联调后由收货地址列表接口下发);手机号按规范脱敏展示 */
-const MOCK_ADDRESS_LIST = [
- {
- id: '1',
- name: '张三',
- phone: '185****0110',
- detail: '四川省成都市武侯区府城大道399号天府新谷9号楼2单元1005a',
- isDefault: true
- },
- {
- id: '2',
- name: '李四',
- phone: '185****0110',
- detail: '四川省成都市武侯区府城大道399号天府新谷9号楼2单元1005a'
- },
- {
- id: '3',
- name: '王五',
- phone: '185****0110',
- detail: '四川省成都市武侯区府城大道399号天府新谷9号楼2单元1005a'
+/** 收货地址列表(来自后端 /miniapp/account/user/addresses) */
+const addressList = ref([])
+
+/** 拉取本人收货地址列表(默认地址在前) */
+const _loadAddressList = async () => {
+ try {
+ const res = await getAddressList()
+ addressList.value = res.data || []
+ } catch (e) {
+ console.error('[ADDRESS_LIST] 加载收货地址失败', e)
+ uni.showToast({ title: e.message || '加载失败', icon: 'none' })
}
-]
+}
-const addressList = ref(MOCK_ADDRESS_LIST)
+/** onShow:从新增/编辑页返回时自动刷新列表 */
+onShow(() => {
+ if (!ensureLogin()) return
+ _loadAddressList()
+})
-/** 编辑地址:编辑页未实现,暂为占位反馈 */
-const handleEdit = () => {
- uni.showToast({ title: '敬请期待', icon: 'none' })
+/** 编辑地址:跳转新增页并携带 id,复用为编辑模式 */
+const handleEdit = (item) => {
+ uni.navigateTo({ url: '/pages/address/add?id=' + item.id })
+}
+
+/** 删除地址:二次确认后删除并刷新列表 */
+const handleDelete = (item) => {
+ uni.showModal({
+ title: '提示',
+ content: '确定要删除该收货地址吗?',
+ success: async (res) => {
+ if (!res.confirm) return
+ try {
+ await deleteAddress(item.id)
+ uni.showToast({ title: '删除成功', icon: 'none' })
+ _loadAddressList()
+ } catch (e) {
+ console.error('[ADDRESS_DELETE] 删除收货地址失败', e)
+ uni.showToast({ title: e.message || '删除失败', icon: 'none' })
+ }
+ }
+ })
}
/** 新增地址:跳转到新增地址页 */
@@ -96,6 +117,15 @@
.addr-main {
flex: 1;
min-width: 0;
+}
+
+/* 操作区:编辑 + 删除图标并排 */
+.addr-actions {
+ display: flex;
+ align-items: center;
+ gap: 28rpx;
+ flex-shrink: 0;
+ margin-left: 24rpx;
}
.addr-head {
@@ -212,6 +242,7 @@
/* ==================== 图标(内联 base64 SVG) ==================== */
.icon-edit,
+.icon-delete,
.icon-plus,
.icon-empty {
background-repeat: no-repeat;
@@ -224,10 +255,17 @@
width: 44rpx;
height: 44rpx;
flex-shrink: 0;
- margin-left: 24rpx;
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMUU1RUJDIiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTIgM0g1YTIgMiAwIDAgMC0yIDJ2MTRhMiAyIDAgMCAwIDIgMmgxNGEyIDIgMCAwIDAgMi0ydi03Ii8+PHBhdGggZD0iTTE4LjM3NSAyLjYyNWExIDEgMCAwIDEgMyAzbC05LjAxMyA5LjAxNGEyIDIgMCAwIDEtLjg1My41MDVsLTIuODczLjg0YS41LjUgMCAwIDEtLjYyLS42MmwuODQtMi44NzNhMiAyIDAgMCAxIC41MDYtLjg1MnoiLz48L3N2Zz4=');
}
+/* 删除:垃圾桶(#EF4444) */
+.icon-delete {
+ width: 44rpx;
+ height: 44rpx;
+ flex-shrink: 0;
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjRUY0NDQ0IiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMyA2aDE4Ii8+PHBhdGggZD0iTTggNlY0YTEgMSAwIDAgMSAxLTFoNmExIDEgMCAwIDEgMSAxdjIiLz48cGF0aCBkPSJNMTkgNmwtMSAxNGEyIDIgMCAwIDEtMiAySDhhMiAyIDAgMCAxLTItMkw1IDYiLz48cGF0aCBkPSJNMTAgMTF2NiIvPjxwYXRoIGQ9Ik0xNCAxMXY2Ii8+PC9zdmc+');
+}
+
/* 新增:白色加号 */
.icon-plus {
width: 34rpx;
diff --git a/hugray-user-miniapp/pages/contact/index.vue b/hugray-user-miniapp/pages/contact/index.vue
index 65f0bfa..600d865 100644
--- a/hugray-user-miniapp/pages/contact/index.vue
+++ b/hugray-user-miniapp/pages/contact/index.vue
@@ -18,7 +18,7 @@
<!-- 联系方式列表:热线为纯展示文案,平台客服为绿色胶囊按钮 -->
<view class="info-list">
- <view v-for="item in CONTACT_LIST" :key="item.key" class="info-row">
+ <view v-for="item in contactList" :key="item.key" class="info-row">
<text class="info-label">{{ item.label }}</text>
<!-- 有 value 渲染文案,否则渲染「联系客服」按钮 -->
<text v-if="item.value" class="info-value">{{ item.value }}</text>
@@ -32,19 +32,52 @@
</template>
<script setup>
+import { ref } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import { getToken } from '@/utils/request.js'
+import { getContactInfo } from '@/api/index.js'
+
/** 状态栏高度:自定义导航栏下用于顶部留白 */
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
-/** 招商 / 合伙人热线:客服接口未就绪,先以常量维护(接口就绪后替换) */
-const CITY_INVEST_HOTLINE = '400-451-666'
-const PARTNER_RECRUIT_HOTLINE = '400-451-666'
+/** 招商 / 合伙人热线兜底文案:后端未配置或请求失败时使用 */
+const CITY_INVEST_HOTLINE = '-'
+const PARTNER_RECRUIT_HOTLINE = '-'
/** 联系方式列表:value 为空表示该项渲染为客服按钮 */
-const CONTACT_LIST = [
+const contactList = ref([
{ key: 'invest', label: '城市招商热线', value: CITY_INVEST_HOTLINE },
{ key: 'partner', label: '合伙人招募', value: PARTNER_RECRUIT_HOTLINE },
{ key: 'service', label: '平台客服' }
-]
+])
+
+/** 单项回填:后端有值才覆盖,为空则保留兜底文案 */
+const applyHotline = (key, value) => {
+ if (!value) return
+ const item = contactList.value.find((it) => it.key === key)
+ if (item) item.value = value
+}
+
+/**
+ * 拉取平台联系方式并回填
+ * 未登录(无 token)时跳过请求,直接使用兜底文案,避免在此页触发 401 跳登录;
+ * 请求失败仅记录日志,不影响页面正常展示
+ */
+const loadContactInfo = async () => {
+ if (!getToken()) return
+ try {
+ const res = await getContactInfo()
+ const data = res.data || {}
+ applyHotline('invest', data.cityHotline)
+ applyHotline('partner', data.partnerRecruit)
+ } catch (e) {
+ console.error('[CONTACT_INFO] 获取联系方式失败,使用默认文案', e)
+ }
+}
+
+onLoad(() => {
+ loadContactInfo()
+})
/** 返回上一页:无上级页面(如直接进入)时回到个人中心 */
const handleBack = () => {
diff --git a/hugray-user-miniapp/pages/login/register.vue b/hugray-user-miniapp/pages/login/register.vue
index c02f96d..62353d8 100644
--- a/hugray-user-miniapp/pages/login/register.vue
+++ b/hugray-user-miniapp/pages/login/register.vue
@@ -100,7 +100,7 @@
<script setup>
import { ref, reactive, computed } from 'vue'
-import { onUnload } from '@dcloudio/uni-app'
+import { onLoad, onUnload } from '@dcloudio/uni-app'
import { sendSmsCode, register } from '@/api/index.js'
// ======================== 数据定义区域 ========================
@@ -211,6 +211,23 @@
uni.navigateBack()
}
+// 页面加载时解析邀请码并回填:
+// 1)分享链接进入 -> options.referralCode
+// 2)扫描小程序码进入 -> options.scene(微信下发的 scene 为 URL 编码,需解码)
+// 统一转大写以匹配后端邀请码字符集(8 位 A-Z0-9),用户后续仍可手动修改
+onLoad((options) => {
+ const raw = (options && (options.referralCode || options.scene)) || ''
+ if (!raw) return
+ let code = raw
+ try {
+ code = decodeURIComponent(raw)
+ } catch (e) {
+ // 非法编码时按原值处理,不阻塞页面
+ console.error('[REGISTER] 邀请码解码失败', e)
+ }
+ form.referralCode = code.trim().toUpperCase()
+})
+
// 页面卸载时清理倒计时,避免定时器泄漏
onUnload(() => {
if (countdownTimer) clearInterval(countdownTimer)
diff --git a/hugray-user-miniapp/pages/member/index.vue b/hugray-user-miniapp/pages/member/index.vue
new file mode 100644
index 0000000..94b3b7d
--- /dev/null
+++ b/hugray-user-miniapp/pages/member/index.vue
@@ -0,0 +1,1394 @@
+<template>
+ <view class="page">
+ <!-- 自定义导航栏:状态栏留白由行内 paddingTop 撑开 -->
+ <view class="nav" :style="{ paddingTop: statusBarHeight + 'px' }">
+ <view class="nav-bar">
+ <view class="nav-back" @tap="handleBack">
+ <view class="icon-back"></view>
+ </view>
+ <text class="nav-title">会员中心</text>
+ </view>
+ </view>
+
+ <scroll-view class="scroll" scroll-y>
+ <!-- 会员信息深蓝卡:脱敏手机号 + 当前等级 + 等级详情入口 -->
+ <view class="member-card">
+ <view class="member-left">
+ <text class="member-phone">{{ memberPhone || '—' }}</text>
+ <view class="member-level-line">
+ <text class="member-level-label">当前等级</text>
+ <text class="member-level-value">{{ memberLevel }}</text>
+ </view>
+ </view>
+ <view class="level-detail-btn" @tap="handleLevelDetail">
+ <text class="level-detail-text">等级详情</text>
+ </view>
+ </view>
+
+ <!-- 三段式 Tab:销售统计 / 我的仓库 / 进货订单 -->
+ <view class="tabs">
+ <view
+ v-for="tab in TAB_LIST"
+ :key="tab.key"
+ class="tab-item"
+ @tap="handleTabChange(tab.key)"
+ >
+ <text class="tab-text" :class="{ 'tab-text--active': activeTab === tab.key }">
+ {{ tab.label }}
+ </text>
+ <view v-if="activeTab === tab.key" class="tab-indicator"></view>
+ </view>
+ </view>
+
+ <!-- ==================== Tab1:销售统计 ==================== -->
+ <view v-if="activeTab === 'sales'" class="panel">
+ <!-- 当月数据汇总:三栏 + 利润环比 -->
+ <view class="stat-card">
+ <view class="stat-head">
+ <text class="stat-head-label">当月销售</text>
+ <text class="stat-head-growth">{{ growthRateText }}</text>
+ </view>
+ <text class="stat-amount">¥{{ formatYuan(stats.monthSalesAmount, true) }}</text>
+ <text class="stat-sub">当月利润 ¥{{ formatYuan(stats.monthProfitAmount, true) }}</text>
+ <view class="stat-cols">
+ <view class="stat-col">
+ <text class="stat-col-label">当月销售额</text>
+ <text class="stat-col-value">¥{{ formatYuan(stats.monthSalesAmount, true) }}</text>
+ </view>
+ <view class="stat-col">
+ <text class="stat-col-label">当月进货额</text>
+ <text class="stat-col-value">¥{{ formatYuan(stats.monthPurchaseAmount, true) }}</text>
+ </view>
+ <view class="stat-col">
+ <text class="stat-col-label">销售单数</text>
+ <text class="stat-col-value">{{ stats.monthSalesCount || 0 }}</text>
+ </view>
+ </view>
+ </view>
+
+ <!-- 销售额折线图(当月 1 号至当日) -->
+ <view class="chart-card">
+ <text class="chart-title">销售额</text>
+ <view class="chart-box">
+ <qiun-data-charts
+ v-if="hasTrend"
+ type="line"
+ :chartData="trendChartData"
+ :opts="TREND_OPTS"
+ />
+ <view v-else class="chart-empty">
+ <text class="chart-empty-text">暂无销售数据</text>
+ </view>
+ </view>
+ </view>
+
+ <!-- 商品销售额比例:环形图 + 图例 -->
+ <view class="chart-card">
+ <text class="chart-title">商品销售额比例</text>
+ <view v-if="ratioList.length > 0" class="ratio-wrap">
+ <view class="ratio-chart">
+ <qiun-data-charts type="ring" :chartData="ratioChartData" :opts="RATIO_OPTS" />
+ </view>
+ <view class="ratio-legend">
+ <view
+ v-for="(item, index) in ratioList"
+ :key="item.productName"
+ class="legend-row"
+ >
+ <view class="legend-dot" :style="{ backgroundColor: legendColor(index) }"></view>
+ <text class="legend-name">{{ item.productName }}</text>
+ <text class="legend-value">¥{{ formatYuan(item.amount, true) }}</text>
+ </view>
+ </view>
+ </view>
+ <view v-else class="chart-empty">
+ <text class="chart-empty-text">暂无销售数据</text>
+ </view>
+ </view>
+
+ <!-- 销售用户排行(Top 10) -->
+ <view class="chart-card">
+ <text class="chart-title">销售用户排行</text>
+ <view v-if="rankingList.length > 0" class="rank-list">
+ <view v-for="item in rankingList" :key="item.rank" class="rank-row">
+ <view class="rank-index">
+ <text class="rank-index-text">{{ item.rank }}</text>
+ </view>
+ <text class="rank-name">{{ item.nickname || '会员' }}</text>
+ <text class="rank-phone">{{ item.phone || '—' }}</text>
+ <text class="rank-amount">¥{{ formatYuan(item.amount, true) }}</text>
+ </view>
+ </view>
+ <view v-else class="chart-empty">
+ <text class="chart-empty-text">暂无排行数据</text>
+ </view>
+ </view>
+ </view>
+
+ <!-- ==================== Tab2:我的仓库 ==================== -->
+ <view v-else-if="activeTab === 'warehouse'" class="panel">
+ <view v-if="warehouseList.length > 0" class="warehouse-list">
+ <view
+ v-for="item in warehouseList"
+ :key="item.warehouseId"
+ class="warehouse-card"
+ >
+ <!-- 低库存警示条:低于最低需求库存时置顶展示 -->
+ <view v-if="item.lowStock" class="low-stock-bar">
+ <view class="low-stock-icon"></view>
+ <text class="low-stock-bar-text">已低于最低需求库存,无法销售该商品,请及时补充</text>
+ </view>
+ <view class="warehouse-row">
+ <!-- 商品图:后端暂未下发图片字段,回退占位图 -->
+ <view class="product-thumb">
+ <image
+ v-if="item.productImage"
+ class="product-thumb-img"
+ :src="item.productImage"
+ mode="aspectFill"
+ />
+ <view v-else class="icon icon-image"></view>
+ </view>
+ <view class="warehouse-main">
+ <text class="product-name">{{ item.productName }}</text>
+ <view class="warehouse-stock">
+ <text class="stock-label">当前库存:</text>
+ <text class="stock-value" :class="{ 'stock-value--low': item.lowStock }">
+ {{ item.stockQty }}
+ </text>
+ <text class="stock-label stock-label--gap">最低需求库存:</text>
+ <text class="stock-value">{{ item.minStockQty }}</text>
+ </view>
+ <view class="warehouse-actions">
+ <view class="btn-green" @tap="handleOpenPurchase(item)">
+ <text class="btn-green-text">进货</text>
+ </view>
+ <view class="btn-green" @tap="handleOpenRecords(item)">
+ <text class="btn-green-text">仓库记录</text>
+ </view>
+ </view>
+ </view>
+ </view>
+ </view>
+ </view>
+ <view v-else-if="!warehouseLoading" class="empty">
+ <text class="empty-title">暂无云仓商品</text>
+ <text class="empty-desc">开通云仓后可在此查看库存与进货</text>
+ </view>
+ </view>
+
+ <!-- ==================== Tab3:进货订单 ==================== -->
+ <view v-else class="panel">
+ <view v-if="orderList.length > 0" class="order-list">
+ <view v-for="item in orderList" :key="item.orderId" class="order-card">
+ <view class="order-head">
+ <text class="order-no">单号:{{ item.orderNo }}</text>
+ <text class="order-status">{{ item.statusName }}</text>
+ </view>
+ <text class="order-product">{{ item.productName }}</text>
+ <view class="order-foot">
+ <text class="order-qty">数量 ×{{ item.qty }}</text>
+ <text class="order-amount">¥{{ formatYuan(item.totalAmount, true) }}</text>
+ </view>
+ <text class="order-time">{{ item.createTime }}</text>
+ </view>
+ </view>
+ <view v-else-if="!orderLoading" class="empty">
+ <text class="empty-title">暂无进货订单</text>
+ <text class="empty-desc">在「我的仓库」进货后此处可查看</text>
+ </view>
+ </view>
+ </scroll-view>
+
+ <!-- ==================== 进货弹窗 ==================== -->
+ <view v-if="purchaseVisible" class="mask" @tap="closePurchase">
+ <view class="dialog" @tap.stop>
+ <text class="dialog-title">进货</text>
+ <text class="dialog-product">{{ purchaseTarget.productName }}</text>
+ <view class="form-row">
+ <text class="form-label">进货数量</text>
+ <input
+ class="form-input"
+ type="number"
+ placeholder="请输入进货数量"
+ placeholder-class="form-placeholder"
+ v-model="purchaseQty"
+ />
+ </view>
+ <view class="form-row">
+ <text class="form-label">进货单价(元)</text>
+ <input
+ class="form-input"
+ type="digit"
+ placeholder="选填,默认 0"
+ placeholder-class="form-placeholder"
+ v-model="purchaseUnitPrice"
+ />
+ </view>
+ <view class="dialog-actions">
+ <view class="btn-plain" @tap="closePurchase">
+ <text class="btn-plain-text">取消</text>
+ </view>
+ <view class="btn-green btn-green--fill" @tap="handleSubmitPurchase">
+ <text class="btn-green-text">确认进货</text>
+ </view>
+ </view>
+ </view>
+ </view>
+
+ <!-- ==================== 仓库记录弹窗 ==================== -->
+ <view v-if="recordsVisible" class="mask" @tap="closeRecords">
+ <view class="dialog dialog--records" @tap.stop>
+ <text class="dialog-title">仓库记录</text>
+ <text class="dialog-sub">{{ recordsProductName }}</text>
+ <scroll-view class="record-scroll" scroll-y>
+ <view v-for="item in warehouseRecords" :key="item.recordId" class="record-row">
+ <view class="record-line1">
+ <text class="record-product">{{ item.productName }}</text>
+ <text
+ class="record-change"
+ :class="item.changeQty < 0 ? 'record-change--out' : 'record-change--in'"
+ >
+ {{ item.changeQty > 0 ? '+' : '' }}{{ item.changeQty }}
+ </text>
+ </view>
+ <view class="record-line2">
+ <text class="record-type">{{ item.changeTypeName }}</text>
+ <text class="record-after">结存 {{ item.afterQty }}</text>
+ <text class="record-time">{{ item.createTime }}</text>
+ </view>
+ </view>
+ <view v-if="warehouseRecords.length === 0" class="record-empty">
+ <text class="record-empty-text">暂无记录</text>
+ </view>
+ </scroll-view>
+ <view class="btn-green btn-green--fill" @tap="closeRecords">
+ <text class="btn-green-text">关闭</text>
+ </view>
+ </view>
+ </view>
+ </view>
+</template>
+
+<script setup>
+import { computed, ref } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import { ensureLogin } from '@/utils/request.js'
+import { formatYuan } from '@/utils/format.js'
+import {
+ getMemberSalesStatistics,
+ getMemberWarehouses,
+ memberPurchase,
+ getMemberWarehouseRecords,
+ getMemberPurchaseOrders
+} from '@/api/index.js'
+import qiunDataCharts from '@/uni_modules/qiun-data-charts/components/qiun-data-charts/qiun-data-charts.vue'
+
+/** 状态栏高度:自定义导航栏下用于顶部留白 */
+const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
+
+/** 三段式 Tab 配置:key 仅前端切换用 */
+const TAB_LIST = [
+ { key: 'sales', label: '销售统计' },
+ { key: 'warehouse', label: '我的仓库' },
+ { key: 'orders', label: '进货订单' }
+]
+
+/** 图表/图例配色(与设计稿一致:蓝 / 青 / 草绿),按序循环使用 */
+const LEGEND_COLORS = ['#2F86E8', '#2FB5B8', '#7CC142']
+
+/** 折线图样式:隐藏图例、虚线网格、直线折线 */
+const TREND_OPTS = {
+ color: ['#1E5EBC'],
+ padding: [15, 10, 0, 5],
+ legend: { show: false },
+ // X 轴标签由 trendChartData 自行等间距抽稀,此处不再用 labelCount(避免 uCharts 强制补末尾标签造成相邻)
+ xAxis: { disableGrid: true, fontSize: 10, fontColor: '#94A3B8' },
+ yAxis: {
+ gridType: 'dash',
+ dashLength: 4,
+ splitNumber: 4,
+ gridColor: '#E2E8F0',
+ fontColor: '#94A3B8',
+ // axisLine:false 去掉左侧 Y 轴竖线,仅保留刻度文字
+ data: [{ min: 0, axisLine: false }]
+ },
+ extra: { line: { type: 'straight', width: 2, activeType: 'hollow' } }
+}
+
+/** 环形图样式:内部留空、不显示数据标签(图例由页面自绘) */
+const RATIO_OPTS = {
+ color: LEGEND_COLORS,
+ padding: [5, 5, 5, 5],
+ legend: { show: false },
+ dataLabel: false,
+ extra: { ring: { ringWidth: 36, activeOpacity: 0.5, activeRadius: 10 } }
+}
+
+/** 销售统计出参默认值:接口未返回时页面展示全零,避免 NaN */
+const DEFAULT_STATS = {
+ phone: '',
+ memberLevel: 'C',
+ monthSalesAmount: 0,
+ monthPurchaseAmount: 0,
+ monthProfitAmount: 0,
+ monthSalesCount: 0,
+ profitGrowthRate: 0,
+ salesTrend: [],
+ productSalesRatio: [],
+ salesUserRanking: []
+}
+
+/** 当前 Tab,默认「销售统计」 */
+const activeTab = ref('sales')
+
+/** 销售统计聚合数据 */
+const stats = ref({ ...DEFAULT_STATS })
+
+/** 会员脱敏手机号 */
+const memberPhone = computed(() => stats.value.phone)
+
+/** 会员等级:缺省兜底 C,与后端 MemberLevelEnum.normalize 保持一致 */
+const memberLevel = computed(() => stats.value.memberLevel || 'C')
+
+/** 商品销售额比例列表 */
+const ratioList = computed(() => stats.value.productSalesRatio || [])
+
+/** 销售用户排行列表 */
+const rankingList = computed(() => stats.value.salesUserRanking || [])
+
+/** 是否存在销售趋势数据(无数据时不渲染图表,改展示空态) */
+const hasTrend = computed(() => (stats.value.salesTrend || []).length > 0)
+
+/** 利润环比文案:正数为提升、负数为下降 */
+const growthRateText = computed(() => {
+ const rate = stats.value.profitGrowthRate || 0
+ return rate >= 0 ? `上月提升 ${rate}%` : `上月下降 ${Math.abs(rate)}%`
+})
+
+/** 折线图数据:分类为「N号」,金额由分转为元 */
+/** 折线图 X 轴最多显示的标签数 */
+const TREND_MAX_LABELS = 6
+
+const trendChartData = computed(() => {
+ const trend = stats.value.salesTrend || []
+ const total = trend.length
+ // 等间距抽稀:以最后一天为锚点向前按固定 step 取标签,保证间距一致且始终包含最新日期
+ const step = Math.max(1, Math.ceil((total - 1) / (TREND_MAX_LABELS - 1)))
+ return {
+ categories: trend.map((item, index) => ((total - 1 - index) % step === 0 ? item.day : '')),
+ series: [{ name: '销售额', data: trend.map((item) => Number(formatYuan(item.amount))) }]
+ }
+})
+
+/** 环形图数据:uCharts 单系列 data 数组承载各商品金额(元) */
+const ratioChartData = computed(() => ({
+ series: [
+ {
+ data: ratioList.value.map((item) => ({
+ name: item.productName,
+ value: Number(formatYuan(item.amount))
+ }))
+ }
+ ]
+}))
+
+/** 我的仓库列表 */
+const warehouseList = ref([])
+/** 我的仓库加载中标志 */
+const warehouseLoading = ref(false)
+
+/** 进货订单列表 */
+const orderList = ref([])
+/** 进货订单加载中标志 */
+const orderLoading = ref(false)
+
+/** 进货弹窗显隐 */
+const purchaseVisible = ref(false)
+/** 进货目标商品(含 warehouseId / productName) */
+const purchaseTarget = ref({})
+/** 进货数量输入值 */
+const purchaseQty = ref('')
+/** 进货单价输入值(元) */
+const purchaseUnitPrice = ref('')
+
+/** 仓库记录弹窗显隐 */
+const recordsVisible = ref(false)
+/** 仓库记录列表 */
+const warehouseRecords = ref([])
+/** 当前查看记录的商品名(弹窗副标题) */
+const recordsProductName = ref('')
+
+/** 图例色:按序循环取色,与环形图分段色保持一致 */
+const legendColor = (index) => LEGEND_COLORS[index % LEGEND_COLORS.length]
+
+/** 拉取销售统计(含手机号 / 等级 / 三栏金额 / 趋势 / 比例 / 排行) */
+const loadStatistics = async () => {
+ try {
+ const res = await getMemberSalesStatistics()
+ stats.value = { ...DEFAULT_STATS, ...(res.data || {}) }
+ } catch (error) {
+ console.error('[MEMBER] 获取销售统计失败', error)
+ }
+}
+
+/** 拉取云仓列表 */
+const loadWarehouses = async () => {
+ warehouseLoading.value = true
+ try {
+ const res = await getMemberWarehouses()
+ warehouseList.value = res.data || []
+ } catch (error) {
+ console.error('[MEMBER] 获取云仓列表失败', error)
+ warehouseList.value = []
+ } finally {
+ warehouseLoading.value = false
+ }
+}
+
+/** 拉取进货订单(分页接口,取前 20 条) */
+const loadOrders = async () => {
+ orderLoading.value = true
+ try {
+ const res = await getMemberPurchaseOrders({ pageNum: 1, pageSize: 20 })
+ // 分页接口返回 TableDataInfo:数据在顶层 rows(非 data 包裹)
+ orderList.value = res.rows || []
+ } catch (error) {
+ console.error('[MEMBER] 获取进货订单失败', error)
+ orderList.value = []
+ } finally {
+ orderLoading.value = false
+ }
+}
+
+/** Tab 切换:进入「我的仓库」「进货订单」时按需懒加载 */
+const handleTabChange = (key) => {
+ if (activeTab.value === key) return
+ activeTab.value = key
+ if (key === 'warehouse' && warehouseList.value.length === 0) {
+ loadWarehouses()
+ }
+ if (key === 'orders' && orderList.value.length === 0) {
+ loadOrders()
+ }
+}
+
+/** 打开进货弹窗:清空上一次输入 */
+const handleOpenPurchase = (item) => {
+ purchaseTarget.value = item
+ purchaseQty.value = ''
+ purchaseUnitPrice.value = ''
+ purchaseVisible.value = true
+}
+
+/** 关闭进货弹窗 */
+const closePurchase = () => {
+ purchaseVisible.value = false
+}
+
+/** 提交进货:校验数量后调用接口,成功后刷新云仓列表 */
+const handleSubmitPurchase = async () => {
+ const qty = Number(purchaseQty.value)
+ if (!Number.isInteger(qty) || qty <= 0) {
+ uni.showToast({ title: '请输入正确的进货数量', icon: 'none' })
+ return
+ }
+ // 单价选填:元 → 分,未填或非法则不传(后端按 0 计)
+ const unitPriceYuan = Number(purchaseUnitPrice.value)
+ const unitPrice =
+ Number.isFinite(unitPriceYuan) && unitPriceYuan > 0 ? Math.round(unitPriceYuan * 100) : undefined
+ try {
+ await memberPurchase({ warehouseId: purchaseTarget.value.warehouseId, qty, unitPrice })
+ uni.showToast({ title: '进货成功', icon: 'success' })
+ purchaseVisible.value = false
+ loadWarehouses()
+ } catch (error) {
+ console.error('[MEMBER] 进货失败', error)
+ uni.showToast({ title: error.message || '进货失败', icon: 'none' })
+ }
+}
+
+/** 打开仓库记录弹窗:拉取流水并按当前商品名筛选 */
+const handleOpenRecords = async (item) => {
+ recordsProductName.value = item.productName
+ warehouseRecords.value = []
+ recordsVisible.value = true
+ try {
+ const res = await getMemberWarehouseRecords({ pageNum: 1, pageSize: 50 })
+ const rows = res.rows || []
+ // 记录接口按会员维度下发,这里按商品名筛选,展示该商品的变动明细
+ warehouseRecords.value = rows.filter((record) => record.productName === item.productName)
+ } catch (error) {
+ console.error('[MEMBER] 获取仓库记录失败', error)
+ }
+}
+
+/** 关闭仓库记录弹窗 */
+const closeRecords = () => {
+ recordsVisible.value = false
+}
+
+/** 等级详情:后端暂未提供等级规则接口,先给出占位反馈 */
+const handleLevelDetail = () => {
+ uni.showToast({ title: '等级详情敬请期待', icon: 'none' })
+}
+
+/** 返回:优先返回上一页,无上一页时回个人中心(Tab 页) */
+const handleBack = () => {
+ const pages = getCurrentPages()
+ if (pages.length > 1) {
+ uni.navigateBack()
+ return
+ }
+ uni.switchTab({ url: '/pages/profile/index' })
+}
+
+/** 页面加载:校验登录态后拉取销售统计(仓库与订单按 Tab 懒加载) */
+onLoad(() => {
+ if (!ensureLogin()) return
+ loadStatistics()
+})
+</script>
+
+<style scoped lang="scss">
+.page {
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ background-color: #f8fafc;
+}
+
+/* ==================== 自定义导航栏 ==================== */
+
+.nav {
+ flex-shrink: 0;
+ background-color: #ffffff;
+}
+
+.nav-bar {
+ position: relative;
+ height: 88rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.nav-back {
+ position: absolute;
+ left: 16rpx;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 64rpx;
+ height: 64rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* 纯 CSS 左向箭头:左 + 下边框旋转 45° */
+.icon-back {
+ width: 22rpx;
+ height: 22rpx;
+ border-left: 4rpx solid #0f172a;
+ border-bottom: 4rpx solid #0f172a;
+ transform: rotate(45deg);
+}
+
+.nav-title {
+ font-size: 34rpx;
+ font-weight: 700;
+ line-height: 1;
+ letter-spacing: 1rpx;
+ color: #0f172a;
+}
+
+.scroll {
+ flex: 1;
+ overflow: hidden;
+}
+
+/* ==================== 会员信息深蓝卡 ==================== */
+
+.member-card {
+ margin: 24rpx 32rpx 0;
+ padding: 40rpx 32rpx;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ box-sizing: border-box;
+ border-radius: 24rpx;
+ /* 深蓝渐变:与设计稿 #003E7E → #1E5EBC 一致 */
+ background: linear-gradient(135deg, #003e7e 0%, #1e5ebc 100%);
+ box-shadow: 0 12rpx 32rpx rgba(0, 62, 126, 0.24);
+}
+
+.member-left {
+ display: flex;
+ flex-direction: column;
+ gap: 20rpx;
+}
+
+.member-phone {
+ font-size: 28rpx;
+ line-height: 1;
+ letter-spacing: 1rpx;
+ color: rgba(255, 255, 255, 0.88);
+}
+
+.member-level-line {
+ display: flex;
+ align-items: baseline;
+ gap: 16rpx;
+}
+
+.member-level-label {
+ font-size: 28rpx;
+ font-weight: 500;
+ line-height: 1;
+ color: #ffffff;
+}
+
+.member-level-value {
+ font-size: 56rpx;
+ font-weight: 800;
+ line-height: 1;
+ letter-spacing: 2rpx;
+ color: #ffffff;
+}
+
+/* 草绿主按钮:等级详情 */
+.level-detail-btn {
+ flex-shrink: 0;
+ height: 64rpx;
+ display: flex;
+ align-items: center;
+ padding: 0 30rpx;
+ box-sizing: border-box;
+ border-radius: 12rpx;
+ background: linear-gradient(135deg, #82c236 0%, #639c1f 100%);
+ box-shadow: 0 6rpx 16rpx rgba(99, 156, 31, 0.32);
+}
+
+.level-detail-text {
+ font-size: 26rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #ffffff;
+}
+
+/* ==================== 三段式 Tab ==================== */
+
+.tabs {
+ margin-top: 24rpx;
+ display: flex;
+ align-items: center;
+ background-color: #ffffff;
+ border-bottom: 2rpx solid #e2e8f0;
+}
+
+.tab-item {
+ flex: 1;
+ height: 88rpx;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.tab-text {
+ font-size: 30rpx;
+ line-height: 1;
+ color: #64748b;
+}
+
+.tab-text--active {
+ font-weight: 600;
+ color: #639c1f;
+}
+
+/* 选中态底部指示条(草绿) */
+.tab-indicator {
+ position: absolute;
+ bottom: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 96rpx;
+ height: 6rpx;
+ border-radius: 6rpx;
+ background-color: #82c236;
+}
+
+.panel {
+ padding: 24rpx 32rpx 60rpx;
+ display: flex;
+ flex-direction: column;
+ gap: 24rpx;
+}
+
+/* ==================== 通用卡片 ==================== */
+
+.stat-card,
+.chart-card {
+ padding: 28rpx;
+ box-sizing: border-box;
+ border-radius: 20rpx;
+ background-color: #ffffff;
+ border: 2rpx solid #e2e8f0;
+ box-shadow: 0 6rpx 16rpx rgba(15, 23, 42, 0.05);
+}
+
+/* ==================== Tab1:销售统计 ==================== */
+
+.stat-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.stat-head-label {
+ font-size: 26rpx;
+ line-height: 1;
+ color: #64748b;
+}
+
+.stat-head-growth {
+ font-size: 24rpx;
+ line-height: 1;
+ color: #e11d48;
+}
+
+.stat-amount {
+ display: block;
+ margin-top: 20rpx;
+ font-size: 52rpx;
+ font-weight: 800;
+ line-height: 1;
+ color: #0f172a;
+}
+
+.stat-sub {
+ display: block;
+ margin-top: 14rpx;
+ font-size: 24rpx;
+ line-height: 1;
+ color: #94a3b8;
+}
+
+.stat-cols {
+ margin-top: 28rpx;
+ padding-top: 28rpx;
+ display: flex;
+ align-items: center;
+ border-top: 2rpx solid #f1f5f9;
+}
+
+.stat-col {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12rpx;
+}
+
+.stat-col-label {
+ font-size: 24rpx;
+ line-height: 1;
+ color: #64748b;
+}
+
+.stat-col-value {
+ font-size: 30rpx;
+ font-weight: 700;
+ line-height: 1;
+ color: #0f172a;
+}
+
+.chart-title {
+ display: block;
+ font-size: 28rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #0f172a;
+ text-align: center;
+}
+
+.chart-box {
+ margin-top: 20rpx;
+ height: 400rpx;
+}
+
+/* 图表空态 */
+.chart-empty {
+ margin-top: 20rpx;
+ height: 200rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.chart-empty-text {
+ font-size: 26rpx;
+ line-height: 1;
+ color: #94a3b8;
+}
+
+/* 环形图 + 图例:左图右文 */
+.ratio-wrap {
+ margin-top: 20rpx;
+ display: flex;
+ align-items: center;
+}
+
+.ratio-chart {
+ width: 260rpx;
+ height: 260rpx;
+ flex-shrink: 0;
+}
+
+.ratio-legend {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 20rpx;
+}
+
+.legend-row {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+}
+
+.legend-dot {
+ flex-shrink: 0;
+ width: 16rpx;
+ height: 16rpx;
+ border-radius: 4rpx;
+}
+
+.legend-name {
+ flex: 1;
+ min-width: 0;
+ font-size: 24rpx;
+ line-height: 1;
+ color: #475569;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.legend-value {
+ flex-shrink: 0;
+ font-size: 24rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #0f172a;
+}
+
+/* 销售用户排行 */
+.rank-list {
+ margin-top: 12rpx;
+ display: flex;
+ flex-direction: column;
+}
+
+.rank-row {
+ height: 92rpx;
+ display: flex;
+ align-items: center;
+ gap: 20rpx;
+ border-bottom: 2rpx solid #f1f5f9;
+}
+
+.rank-row:last-child {
+ border-bottom: none;
+}
+
+.rank-index {
+ flex-shrink: 0;
+ width: 44rpx;
+ height: 44rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ background-color: #e8f1fc;
+}
+
+.rank-index-text {
+ font-size: 24rpx;
+ font-weight: 700;
+ line-height: 1;
+ color: #1e5ebc;
+}
+
+.rank-name {
+ flex-shrink: 0;
+ max-width: 160rpx;
+ font-size: 28rpx;
+ font-weight: 500;
+ line-height: 1;
+ color: #0f172a;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.rank-phone {
+ flex: 1;
+ min-width: 0;
+ font-size: 26rpx;
+ line-height: 1;
+ color: #94a3b8;
+}
+
+.rank-amount {
+ flex-shrink: 0;
+ font-size: 28rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #0f172a;
+}
+
+/* ==================== Tab2:我的仓库 ==================== */
+
+.warehouse-list {
+ display: flex;
+ flex-direction: column;
+ gap: 24rpx;
+}
+
+.warehouse-card {
+ border-radius: 20rpx;
+ overflow: hidden;
+ background-color: #ffffff;
+ border: 2rpx solid #e2e8f0;
+ box-shadow: 0 6rpx 16rpx rgba(15, 23, 42, 0.05);
+}
+
+/* 低库存警示条:蓝底白字 */
+.low-stock-bar {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+ padding: 18rpx 24rpx;
+ background-color: #2f86e8;
+}
+
+.low-stock-icon {
+ flex-shrink: 0;
+ width: 32rpx;
+ height: 32rpx;
+ border-radius: 50%;
+ border: 3rpx solid #ffffff;
+ box-sizing: border-box;
+}
+
+.low-stock-bar-text {
+ flex: 1;
+ font-size: 24rpx;
+ line-height: 1.3;
+ color: #ffffff;
+}
+
+.warehouse-row {
+ padding: 24rpx;
+ display: flex;
+ align-items: flex-start;
+ gap: 24rpx;
+}
+
+/* 商品图占位(#F1F5F9 底 + 图片图标) */
+.product-thumb {
+ flex-shrink: 0;
+ width: 160rpx;
+ height: 160rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 12rpx;
+ overflow: hidden;
+ background-color: #f1f5f9;
+}
+
+.product-thumb-img {
+ width: 100%;
+ height: 100%;
+}
+
+.warehouse-main {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 16rpx;
+}
+
+.product-name {
+ font-size: 28rpx;
+ font-weight: 600;
+ line-height: 1.35;
+ color: #0f172a;
+}
+
+.warehouse-stock {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.stock-label {
+ font-size: 24rpx;
+ line-height: 1;
+ color: #64748b;
+}
+
+.stock-label--gap {
+ margin-left: 24rpx;
+}
+
+.stock-value {
+ margin-left: 6rpx;
+ font-size: 26rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #0f172a;
+}
+
+/* 低库存数值标红 */
+.stock-value--low {
+ color: #ef4444;
+}
+
+.warehouse-actions {
+ display: flex;
+ align-items: center;
+ gap: 20rpx;
+}
+
+/* 草绿按钮:进货 / 仓库记录 */
+.btn-green {
+ height: 64rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 36rpx;
+ box-sizing: border-box;
+ border-radius: 10rpx;
+ background: linear-gradient(135deg, #82c236 0%, #639c1f 100%);
+ box-shadow: 0 6rpx 16rpx rgba(99, 156, 31, 0.26);
+}
+
+.btn-green--fill {
+ flex: 1;
+}
+
+.btn-green-text {
+ font-size: 26rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #ffffff;
+}
+
+/* ==================== Tab3:进货订单 ==================== */
+
+.order-list {
+ display: flex;
+ flex-direction: column;
+ gap: 24rpx;
+}
+
+.order-card {
+ padding: 28rpx;
+ display: flex;
+ flex-direction: column;
+ gap: 16rpx;
+ border-radius: 20rpx;
+ background-color: #ffffff;
+ border: 2rpx solid #e2e8f0;
+ box-shadow: 0 6rpx 16rpx rgba(15, 23, 42, 0.05);
+}
+
+.order-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.order-no {
+ font-size: 24rpx;
+ line-height: 1;
+ color: #94a3b8;
+}
+
+.order-status {
+ font-size: 24rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #639c1f;
+}
+
+.order-product {
+ font-size: 28rpx;
+ font-weight: 600;
+ line-height: 1.35;
+ color: #0f172a;
+}
+
+.order-foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.order-qty {
+ font-size: 26rpx;
+ line-height: 1;
+ color: #64748b;
+}
+
+.order-amount {
+ font-size: 32rpx;
+ font-weight: 700;
+ line-height: 1;
+ color: #0f172a;
+}
+
+.order-time {
+ font-size: 22rpx;
+ line-height: 1;
+ color: #cbd5e1;
+}
+
+/* ==================== 空态 ==================== */
+
+.empty {
+ padding: 140rpx 0 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16rpx;
+}
+
+.empty-title {
+ font-size: 30rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #475569;
+}
+
+.empty-desc {
+ font-size: 26rpx;
+ line-height: 1;
+ color: #94a3b8;
+}
+
+/* ==================== 弹窗(进货 / 仓库记录) ==================== */
+
+.mask {
+ position: fixed;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 60rpx;
+ box-sizing: border-box;
+ background-color: rgba(15, 23, 42, 0.45);
+}
+
+.dialog {
+ width: 100%;
+ padding: 40rpx 32rpx;
+ box-sizing: border-box;
+ border-radius: 24rpx;
+ background-color: #ffffff;
+ display: flex;
+ flex-direction: column;
+ gap: 24rpx;
+}
+
+.dialog-title {
+ font-size: 34rpx;
+ font-weight: 700;
+ line-height: 1;
+ color: #0f172a;
+ text-align: center;
+}
+
+.dialog-product {
+ font-size: 26rpx;
+ line-height: 1.3;
+ color: #64748b;
+ text-align: center;
+}
+
+.dialog-sub {
+ font-size: 24rpx;
+ line-height: 1;
+ color: #94a3b8;
+ text-align: center;
+}
+
+.form-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24rpx;
+ height: 88rpx;
+ padding: 0 24rpx;
+ box-sizing: border-box;
+ border-radius: 12rpx;
+ background-color: #f8fafc;
+}
+
+.form-label {
+ flex-shrink: 0;
+ font-size: 28rpx;
+ line-height: 1;
+ color: #334155;
+}
+
+.form-input {
+ flex: 1;
+ height: 88rpx;
+ font-size: 28rpx;
+ line-height: 88rpx;
+ text-align: right;
+ color: #0f172a;
+}
+
+.form-placeholder {
+ color: #cbd5e1;
+}
+
+.dialog-actions {
+ margin-top: 8rpx;
+ display: flex;
+ align-items: center;
+ gap: 24rpx;
+}
+
+.btn-plain {
+ flex: 1;
+ height: 88rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ border-radius: 44rpx;
+ background-color: #f1f5f9;
+}
+
+.btn-plain-text {
+ font-size: 28rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #475569;
+}
+
+/* 弹窗内草绿按钮:撑满、圆角胶囊 */
+.dialog-actions .btn-green,
+.dialog .btn-green--fill {
+ height: 88rpx;
+ border-radius: 44rpx;
+}
+
+/* 仓库记录弹窗:列表可滚动 */
+.dialog--records {
+ max-height: 76vh;
+}
+
+.record-scroll {
+ max-height: 640rpx;
+}
+
+.record-row {
+ padding: 20rpx 0;
+ display: flex;
+ flex-direction: column;
+ gap: 12rpx;
+ border-bottom: 2rpx solid #f1f5f9;
+}
+
+.record-line1,
+.record-line2 {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16rpx;
+}
+
+.record-product {
+ flex: 1;
+ min-width: 0;
+ font-size: 26rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #0f172a;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.record-change {
+ flex-shrink: 0;
+ font-size: 28rpx;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.record-change--in {
+ color: #639c1f;
+}
+
+.record-change--out {
+ color: #ef4444;
+}
+
+.record-type {
+ font-size: 24rpx;
+ line-height: 1;
+ color: #64748b;
+}
+
+.record-after,
+.record-time {
+ font-size: 22rpx;
+ line-height: 1;
+ color: #94a3b8;
+}
+
+.record-empty {
+ padding: 60rpx 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.record-empty-text {
+ font-size: 26rpx;
+ line-height: 1;
+ color: #94a3b8;
+}
+
+/* ==================== 图标(内联 base64 SVG) ==================== */
+
+.icon {
+ flex-shrink: 0;
+ background-repeat: no-repeat;
+ background-position: center center;
+ background-size: 100% 100%;
+}
+
+/* 商品图占位图标(#CBD5E1) */
+.icon-image {
+ width: 64rpx;
+ height: 64rpx;
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjQ0JENUUxIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHJlY3Qgd2lkdGg9IjE4IiBoZWlnaHQ9IjE4IiB4PSIzIiB5PSIzIiByeD0iMiIgcnk9IjIiLz48Y2lyY2xlIGN4PSI5IiBjeT0iOSIgcj0iMiIvPjxwYXRoIGQ9Im0yMSAxNS0zLjA4Ni0zLjA4NmEyIDIgMCAwIDAtMi44MjggMEw2IDIxIi8+PC9zdmc+');
+}
+</style>
diff --git a/hugray-user-miniapp/pages/profile/certification.vue b/hugray-user-miniapp/pages/profile/certification.vue
new file mode 100644
index 0000000..2688d4e
--- /dev/null
+++ b/hugray-user-miniapp/pages/profile/certification.vue
@@ -0,0 +1,390 @@
+<template>
+ <view class="page">
+ <!-- 自定义导航栏:状态栏留白由行内 paddingTop 撑开 -->
+ <view class="nav" :style="{ paddingTop: statusBarHeight + 'px' }">
+ <view class="nav-bar">
+ <!-- 返回 -->
+ <view class="nav-back" @tap="handleBack">
+ <view class="icon-back"></view>
+ </view>
+ <text class="nav-title">实名认证</text>
+ <!-- 更多 / 帮助(本期占位) -->
+ <view class="nav-actions">
+ <view class="nav-action" @tap="handlePlaceholder('更多')">
+ <view class="icon-more">
+ <view class="dot"></view>
+ <view class="dot"></view>
+ <view class="dot"></view>
+ </view>
+ </view>
+ <view class="nav-action" @tap="handlePlaceholder('帮助')">
+ <view class="icon-help"><text class="icon-help-text">?</text></view>
+ </view>
+ </view>
+ </view>
+ </view>
+
+ <view class="body">
+ <!-- 姓名 -->
+ <view class="field">
+ <text class="field-label">姓名</text>
+ <input
+ class="field-input"
+ :class="{ 'field-input--readonly': readonly }"
+ v-model="realName"
+ type="text"
+ placeholder="请输入姓名"
+ placeholder-class="field-placeholder"
+ maxlength="64"
+ :disabled="readonly"
+ />
+ </view>
+
+ <!-- 身份证号 -->
+ <view class="field">
+ <text class="field-label">身份证号</text>
+ <input
+ class="field-input"
+ :class="{ 'field-input--readonly': readonly }"
+ v-model="idCard"
+ type="idcard"
+ placeholder="请输入身份证号"
+ placeholder-class="field-placeholder"
+ maxlength="18"
+ :disabled="readonly"
+ />
+ <!-- 认证失败:紧贴身份证号下方展示驳回原因 -->
+ <view v-if="showRejectReason" class="reject-tip">
+ <text class="reject-tip-text">驳回原因:{{ rejectReason }}</text>
+ </view>
+ </view>
+ </view>
+
+ <!-- 底部提交 -->
+ <view class="footer">
+ <view class="btn-submit" :class="{ 'btn-submit--disabled': submitDisabled }" @tap="handleSubmit">
+ <text class="btn-submit-text">{{ submitText }}</text>
+ </view>
+ </view>
+ </view>
+</template>
+
+<script setup>
+import { computed, ref } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
+import { ensureLogin } from '@/utils/request.js'
+import { getCertificationStatus, submitCertification } from '@/api/index.js'
+
+/** 状态栏高度:自定义导航栏下用于顶部留白 */
+const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
+
+/**
+ * 身份证号格式(与后端 AccountCertificationApplyDto 保持一致)
+ * 只校验结构:6 位地址码 + 8 位出生日期 + 3 位顺序码 + 1 位校验位(数字或 X),
+ * 不校验末位校验码,也不做三方实名核验。
+ */
+const ID_CARD_PATTERN = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/
+
+/** 姓名 */
+const realName = ref('')
+
+/** 身份证号 */
+const idCard = ref('')
+
+/** 提交中标记:防止重复点击造成重复提交 */
+const submitting = ref(false)
+
+/**
+ * 认证审核状态(取自最近一次申请,进入页面时回填)
+ * '0' 待审核 / '1' 已通过 / '2' 已驳回 / '' 从未提交(未认证)
+ */
+const auditStatus = ref('')
+
+/** 驳回原因:仅审核状态为「已驳回」时有值 */
+const rejectReason = ref('')
+
+/** 表单只读:认证中 / 认证成功时姓名与身份证号不可编辑 */
+const readonly = computed(() => auditStatus.value === '0' || auditStatus.value === '1')
+
+/** 提交按钮置灰:只读状态或正在提交时不可点 */
+const submitDisabled = computed(() => readonly.value || submitting.value)
+
+/** 是否展示驳回原因:认证失败且后端返回了驳回原因 */
+const showRejectReason = computed(() => auditStatus.value === '2' && !!rejectReason.value)
+
+/**
+ * 按钮文案字典:按审核状态区分
+ * '' 未认证→提交 / '0' 认证中→审核中 / '1' 认证成功→已认证 / '2' 认证失败→重新提交
+ */
+const SUBMIT_TEXT_MAP = {
+ '': '提交',
+ '0': '审核中',
+ '1': '已认证',
+ '2': '重新提交'
+}
+
+/** 底部按钮文案:随认证状态变化,未匹配时兜底「提交」 */
+const submitText = computed(() => SUBMIT_TEXT_MAP[auditStatus.value] || '提交')
+
+/** 返回上一页:无上级页面(如直接进入)时回到个人中心 */
+const handleBack = () => {
+ const pages = getCurrentPages()
+ if (pages.length > 1) {
+ uni.navigateBack()
+ return
+ }
+ uni.switchTab({ url: '/pages/profile/index' })
+}
+
+/** 占位交互:更多 / 帮助本期不接后端 */
+const handlePlaceholder = (name) => {
+ uni.showToast({ title: `${name}功能开发中`, icon: 'none' })
+}
+
+/**
+ * 拉取当前认证状态并回填表单
+ * 认证中 / 认证成功回填姓名与身份证号(只读);认证失败回填资料并展示驳回原因;
+ * 从未提交(auditStatus 为空)保持空表单可编辑。查询失败静默降级为「未认证」,不阻断用户填报。
+ */
+const loadCertificationStatus = async () => {
+ try {
+ // request 返回完整响应体 { code, msg, data },业务数据在 data 字段
+ const res = await getCertificationStatus()
+ const data = res.data || {}
+ // 从未提交时后端返回空字段对象,此处保持空值即为「未认证」
+ auditStatus.value = data.auditStatus || ''
+ realName.value = data.realName || ''
+ idCard.value = data.idCard || ''
+ rejectReason.value = data.auditRemark || ''
+ } catch (error) {
+ console.error('[CERTIFICATION] 查询认证状态失败', error)
+ auditStatus.value = ''
+ rejectReason.value = ''
+ }
+}
+
+/**
+ * 提交实名认证:本地基础校验(姓名必填、身份证号格式)通过后调接口
+ * 成功提示等待审核并返回上一页;失败展示后端返回的业务提示
+ */
+const handleSubmit = async () => {
+ // STEP 0: 只读 / 提交中状态直接拦截,避免重复提交
+ if (submitDisabled.value) return
+ const name = realName.value.trim()
+ const card = idCard.value.trim()
+ // STEP 1: 姓名必填校验
+ if (!name) {
+ uni.showToast({ title: '请输入姓名', icon: 'none' })
+ return
+ }
+ // STEP 2: 身份证号格式校验
+ if (!ID_CARD_PATTERN.test(card)) {
+ uni.showToast({ title: '身份证号格式不正确', icon: 'none' })
+ return
+ }
+ // STEP 3: 锁定提交态并提交到后端,落后台「主体认证审核」待审队列
+ submitting.value = true
+ uni.showLoading({ title: '提交中', mask: true })
+ try {
+ await submitCertification(name, card)
+ uni.hideLoading()
+ uni.showToast({ title: '提交成功,等待审核', icon: 'none' })
+ setTimeout(() => uni.navigateBack(), 1500)
+ } catch (error) {
+ uni.hideLoading()
+ console.error('[CERTIFICATION] 提交实名认证失败', error)
+ uni.showToast({ title: (error && error.message) || '提交失败,请重试', icon: 'none' })
+ } finally {
+ submitting.value = false
+ }
+}
+
+// 进入页面校验登录态并拉取认证状态回填(未登录跳登录页)
+onShow(() => {
+ if (!ensureLogin()) return
+ loadCertificationStatus()
+})
+</script>
+
+<style scoped lang="scss">
+.page {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ background-color: #ffffff;
+}
+
+/* ==================== 自定义导航栏 ==================== */
+
+.nav {
+ flex-shrink: 0;
+ background-color: #ffffff;
+}
+
+.nav-bar {
+ position: relative;
+ height: 88rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.nav-back {
+ position: absolute;
+ left: 16rpx;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 64rpx;
+ height: 64rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* 纯 CSS 左向箭头:左 + 下边框旋转 45° */
+.icon-back {
+ width: 22rpx;
+ height: 22rpx;
+ border-left: 4rpx solid #1f2937;
+ border-bottom: 4rpx solid #1f2937;
+ transform: rotate(45deg);
+}
+
+.nav-title {
+ font-size: 34rpx;
+ font-weight: 700;
+ line-height: 1;
+ letter-spacing: 1rpx;
+ color: #0f172a;
+}
+
+.nav-actions {
+ position: absolute;
+ right: 28rpx;
+ top: 50%;
+ transform: translateY(-50%);
+ display: flex;
+ align-items: center;
+ gap: 32rpx;
+}
+
+.nav-action {
+ width: 56rpx;
+ height: 56rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* 更多:三点 */
+.icon-more {
+ display: flex;
+ align-items: center;
+ gap: 6rpx;
+}
+
+.icon-more .dot {
+ width: 8rpx;
+ height: 8rpx;
+ border-radius: 50%;
+ background: #334155;
+}
+
+/* 帮助:圆圈问号 */
+.icon-help {
+ width: 36rpx;
+ height: 36rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ border: 3rpx solid #334155;
+ border-radius: 50%;
+}
+
+.icon-help-text {
+ font-size: 22rpx;
+ font-weight: 700;
+ line-height: 1;
+ color: #334155;
+}
+
+/* ==================== 表单 ==================== */
+
+.body {
+ flex: 1;
+ padding: 24rpx 32rpx 0;
+}
+
+.field {
+ margin-bottom: 40rpx;
+}
+
+.field-label {
+ display: block;
+ font-size: 30rpx;
+ line-height: 1;
+ color: #1f2937;
+}
+
+.field-input {
+ height: 88rpx;
+ margin-top: 8rpx;
+ font-size: 30rpx;
+ color: #1f2937;
+ border-bottom: 2rpx solid #e2e8f0;
+}
+
+.field-placeholder {
+ font-size: 30rpx;
+ color: #cbd5e1;
+}
+
+/* 只读态:认证中 / 认证成功时置灰文字,视觉上表达不可编辑 */
+.field-input--readonly {
+ color: #9ca3af;
+}
+
+/* ==================== 驳回原因 ==================== */
+
+.reject-tip {
+ margin-top: 12rpx;
+}
+
+.reject-tip-text {
+ font-size: 26rpx;
+ line-height: 1.4;
+ color: #ef4444;
+}
+
+/* ==================== 底部提交 ==================== */
+
+.footer {
+ /* 内容不足一屏时按钮自动贴底 */
+ margin-top: auto;
+ padding: 32rpx 48rpx calc(44rpx + env(safe-area-inset-bottom));
+}
+
+.btn-submit {
+ width: 100%;
+ height: 96rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 12rpx;
+ background: linear-gradient(90deg, #82c236 0%, #639c1f 100%);
+ box-shadow: 0 16rpx 40rpx rgba(99, 156, 31, 0.28);
+}
+
+.btn-submit--disabled {
+ opacity: 0.6;
+}
+
+.btn-submit-text {
+ font-size: 32rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #ffffff;
+}
+</style>
diff --git a/hugray-user-miniapp/pages/profile/company-certification.vue b/hugray-user-miniapp/pages/profile/company-certification.vue
new file mode 100644
index 0000000..62c1b8a
--- /dev/null
+++ b/hugray-user-miniapp/pages/profile/company-certification.vue
@@ -0,0 +1,1038 @@
+<template>
+ <view class="page">
+ <!-- 自定义导航栏:状态栏留白由行内 paddingTop 撑开 -->
+ <view class="nav" :style="{ paddingTop: statusBarHeight + 'px' }">
+ <view class="nav-bar">
+ <!-- 返回 -->
+ <view class="nav-back" @tap="handleBack">
+ <view class="icon-back"></view>
+ </view>
+ <text class="nav-title">企业/个体户认证</text>
+ </view>
+ </view>
+
+ <!-- 四段式步骤条:已完成打勾、当前高亮(提交后锁定不可点) -->
+ <view class="steps">
+ <view v-for="(label, idx) in STEP_LABELS" :key="idx" class="step" @tap="handleStepTap(idx)">
+ <view class="step-dot" :class="{ 'step-dot--done': idx < step, 'step-dot--active': idx === step }">
+ <text v-if="idx < step" class="step-dot-check">✓</text>
+ <text v-else class="step-dot-num">{{ idx + 1 }}</text>
+ </view>
+ <text class="step-label" :class="{ 'step-label--done': idx < step, 'step-label--active': idx === step }">{{ label }}</text>
+ <view v-if="idx < STEP_LABELS.length - 1" class="step-line" :class="{ 'step-line--done': idx < step }"></view>
+ </view>
+ </view>
+
+ <scroll-view class="body" scroll-y>
+ <!-- 第 4 步:提交审核 / 结果占位 -->
+ <view v-if="step === 3" class="result">
+ <view class="result-icon" :class="resultState === 'passed' ? 'result-icon--done' : 'result-icon--audit'">
+ <text class="result-icon-text">{{ resultState === 'passed' ? '✓' : '···' }}</text>
+ </view>
+ <text class="result-title">{{ resultInfo.title }}</text>
+ <text class="result-tip">{{ resultInfo.tip }}</text>
+ </view>
+
+ <!-- 第 1 步:企业信息 -->
+ <view v-else-if="step === 0" class="form">
+ <!-- 认证失败:展示驳回原因,可修改后重提 -->
+ <view v-if="showRejectReason" class="reject-tip">
+ <text class="reject-tip-text">驳回原因:{{ rejectReason }}</text>
+ </view>
+
+ <!-- 主体类型 -->
+ <view class="field">
+ <text class="field-label">主体类型</text>
+ <picker :range="SUBJECT_TYPE_LABELS" @change="(e) => handleSelectChange('subjectType', SUBJECT_TYPE_VALUES, e)">
+ <view class="field-select">
+ <text :class="form.subjectType ? 'field-select-text' : 'field-select-placeholder'">{{ subjectTypeLabel || '请选择主体类型' }}</text>
+ <view class="icon-chevron"></view>
+ </view>
+ </picker>
+ </view>
+
+ <!-- 企业/个体名称 -->
+ <view class="field">
+ <text class="field-label">企业/个体名称</text>
+ <input class="field-input" v-model="form.companyName" type="text" placeholder="请输入企业/个体名称" :placeholder-style="PLACEHOLDER_STYLE" maxlength="128" />
+ </view>
+
+ <!-- 营业执照 -->
+ <view class="field">
+ <text class="field-label">营业执照</text>
+ <view class="upload" @tap="handleChooseImage('licenseImage', '营业执照')">
+ <image v-if="form.licenseImage" class="upload-img" :src="form.licenseImage" mode="aspectFill" />
+ <view v-else class="upload-empty">
+ <view class="icon-camera"></view>
+ <text class="upload-empty-text">上传营业执照</text>
+ </view>
+ </view>
+ </view>
+
+ <!-- 统一社会信用代码 -->
+ <view class="field">
+ <text class="field-label">统一社会信用代码</text>
+ <input class="field-input" v-model="form.creditCode" type="text" placeholder="请输入统一社会信用代码" :placeholder-style="PLACEHOLDER_STYLE" maxlength="32" />
+ </view>
+
+ <!-- 营业执照有效期类型:长期时隐藏起止日期 -->
+ <view class="field">
+ <text class="field-label">营业执照有效期类型</text>
+ <picker :range="VALID_TYPE_LABELS" @change="(e) => handleSelectChange('licenseValidType', VALID_TYPE_VALUES, e)">
+ <view class="field-select">
+ <text :class="form.licenseValidType ? 'field-select-text' : 'field-select-placeholder'">{{ labelOf(VALID_TYPE_VALUES, VALID_TYPE_LABELS, form.licenseValidType) || '请选择有效期类型' }}</text>
+ <view class="icon-chevron"></view>
+ </view>
+ </picker>
+ </view>
+
+ <!-- 营业执照有效期起止:仅短期展示且必填 -->
+ <view v-if="form.licenseValidType === '2'" class="field">
+ <text class="field-label">营业执照有效期</text>
+ <view class="date-range">
+ <picker mode="date" :value="form.licenseValidStart" @change="(e) => handleDateChange('licenseValidStart', e)">
+ <view class="date-value" :class="{ 'date-value--placeholder': !form.licenseValidStart }">{{ form.licenseValidStart || '开始日期' }}</view>
+ </picker>
+ <text class="date-sep">~</text>
+ <picker mode="date" :value="form.licenseValidEnd" @change="(e) => handleDateChange('licenseValidEnd', e)">
+ <view class="date-value" :class="{ 'date-value--placeholder': !form.licenseValidEnd }">{{ form.licenseValidEnd || '截止日期' }}</view>
+ </picker>
+ </view>
+ </view>
+
+ <!-- 营业执照注册地区 -->
+ <view class="field">
+ <text class="field-label">营业执照注册地区</text>
+ <picker mode="region" :value="companyRegionValue" @change="(e) => handleRegionChange('registeredRegion', 'company', e)">
+ <view class="field-select">
+ <text :class="form.registeredRegion ? 'field-select-text' : 'field-select-placeholder'">{{ form.registeredRegion || '请选择注册地区' }}</text>
+ <view class="icon-chevron"></view>
+ </view>
+ </picker>
+ </view>
+
+ <!-- 营业执照详细地址 -->
+ <view class="field">
+ <text class="field-label">营业执照详细地址</text>
+ <input class="field-input" v-model="form.companyAddress" type="text" placeholder="请输入营业执照详细地址" :placeholder-style="PLACEHOLDER_STYLE" maxlength="255" />
+ </view>
+ </view>
+
+ <!-- 第 2 步:法人信息 -->
+ <view v-else-if="step === 1" class="form">
+ <!-- 法人姓名 -->
+ <view class="field">
+ <text class="field-label">法人姓名</text>
+ <input class="field-input" v-model="form.legalPerson" type="text" placeholder="请输入法人姓名" :placeholder-style="PLACEHOLDER_STYLE" maxlength="64" />
+ </view>
+
+ <!-- 法人身份证人像面 / 国徽面 -->
+ <view class="field">
+ <text class="field-label">法人身份证</text>
+ <view class="upload-row">
+ <view class="upload upload--half" @tap="handleChooseImage('legalIdCardFront', '法人身份证人像面')">
+ <image v-if="form.legalIdCardFront" class="upload-img" :src="form.legalIdCardFront" mode="aspectFill" />
+ <view v-else class="upload-empty">
+ <view class="icon-camera"></view>
+ <text class="upload-empty-text">人像面</text>
+ </view>
+ </view>
+ <view class="upload upload--half" @tap="handleChooseImage('legalIdCardBack', '法人身份证国徽面')">
+ <image v-if="form.legalIdCardBack" class="upload-img" :src="form.legalIdCardBack" mode="aspectFill" />
+ <view v-else class="upload-empty">
+ <view class="icon-camera"></view>
+ <text class="upload-empty-text">国徽面</text>
+ </view>
+ </view>
+ </view>
+ </view>
+
+ <!-- 法人身份证有效期类型 -->
+ <view class="field">
+ <text class="field-label">法人身份证有效期类型</text>
+ <picker :range="VALID_TYPE_LABELS" @change="(e) => handleSelectChange('legalIdValidType', VALID_TYPE_VALUES, e)">
+ <view class="field-select">
+ <text :class="form.legalIdValidType ? 'field-select-text' : 'field-select-placeholder'">{{ labelOf(VALID_TYPE_VALUES, VALID_TYPE_LABELS, form.legalIdValidType) || '请选择有效期类型' }}</text>
+ <view class="icon-chevron"></view>
+ </view>
+ </picker>
+ </view>
+
+ <!-- 法人身份证有效期起止:仅短期展示且必填 -->
+ <view v-if="form.legalIdValidType === '2'" class="field">
+ <text class="field-label">法人身份证有效期</text>
+ <view class="date-range">
+ <picker mode="date" :value="form.legalIdValidStart" @change="(e) => handleDateChange('legalIdValidStart', e)">
+ <view class="date-value" :class="{ 'date-value--placeholder': !form.legalIdValidStart }">{{ form.legalIdValidStart || '开始日期' }}</view>
+ </picker>
+ <text class="date-sep">~</text>
+ <picker mode="date" :value="form.legalIdValidEnd" @change="(e) => handleDateChange('legalIdValidEnd', e)">
+ <view class="date-value" :class="{ 'date-value--placeholder': !form.legalIdValidEnd }">{{ form.legalIdValidEnd || '截止日期' }}</view>
+ </picker>
+ </view>
+ </view>
+
+ <!-- 法人身份证详细地址 -->
+ <view class="field">
+ <text class="field-label">法人身份证详细地址</text>
+ <input class="field-input" v-model="form.legalIdAddress" type="text" placeholder="请输入法人身份证详细地址" :placeholder-style="PLACEHOLDER_STYLE" maxlength="255" />
+ </view>
+
+ <!-- 负责人姓名 -->
+ <view class="field">
+ <text class="field-label">负责人姓名</text>
+ <input class="field-input" v-model="form.contactName" type="text" placeholder="请输入负责人姓名" :placeholder-style="PLACEHOLDER_STYLE" maxlength="64" />
+ </view>
+
+ <!-- 负责人联系电话 -->
+ <view class="field">
+ <text class="field-label">负责人联系电话</text>
+ <input class="field-input" v-model="form.contactPhone" type="number" placeholder="请输入负责人联系电话" :placeholder-style="PLACEHOLDER_STYLE" maxlength="20" />
+ </view>
+
+ <!-- 负责人邮箱 -->
+ <view class="field">
+ <text class="field-label">负责人邮箱</text>
+ <input class="field-input" v-model="form.contactEmail" type="text" placeholder="请输入负责人邮箱" :placeholder-style="PLACEHOLDER_STYLE" maxlength="128" />
+ </view>
+ </view>
+
+ <!-- 第 3 步:收款信息 -->
+ <view v-else class="form">
+ <!-- 收款类型 -->
+ <view class="field">
+ <text class="field-label">收款类型</text>
+ <picker :range="PAYEE_TYPE_OPTIONS" @change="(e) => handlePayeeTypeChange(e)">
+ <view class="field-select">
+ <text :class="form.payeeType ? 'field-select-text' : 'field-select-placeholder'">{{ form.payeeType || '请选择收款类型' }}</text>
+ <view class="icon-chevron"></view>
+ </view>
+ </picker>
+ </view>
+
+ <!-- 银行卡号码面照片 -->
+ <view class="field">
+ <text class="field-label">银行卡号码面照片</text>
+ <view class="upload" @tap="handleChooseImage('bankCardImage', '银行卡号码面照片')">
+ <image v-if="form.bankCardImage" class="upload-img" :src="form.bankCardImage" mode="aspectFill" />
+ <view v-else class="upload-empty">
+ <view class="icon-camera"></view>
+ <text class="upload-empty-text">上传银行卡照片</text>
+ </view>
+ </view>
+ </view>
+
+ <!-- 银行卡号 -->
+ <view class="field">
+ <text class="field-label">银行卡号</text>
+ <input class="field-input" v-model="form.bankCardNo" type="number" placeholder="请输入银行卡号" :placeholder-style="PLACEHOLDER_STYLE" maxlength="32" />
+ </view>
+
+ <!-- 开户城市 -->
+ <view class="field">
+ <text class="field-label">开户城市</text>
+ <picker mode="region" :value="bankRegionValue" @change="(e) => handleRegionChange('bankCity', 'bank', e)">
+ <view class="field-select">
+ <text :class="form.bankCity ? 'field-select-text' : 'field-select-placeholder'">{{ form.bankCity || '请选择开户城市' }}</text>
+ <view class="icon-chevron"></view>
+ </view>
+ </picker>
+ </view>
+ </view>
+ </scroll-view>
+
+ <!-- 底部按钮:前两步「下一步」,第 3 步「提交资料」;结果页隐藏 -->
+ <view v-if="step < 3" class="footer">
+ <view class="btn-submit" :class="{ 'btn-submit--disabled': submitting }" @tap="handlePrimary">
+ <text class="btn-submit-text">{{ step === 2 ? '提交资料' : '下一步' }}</text>
+ </view>
+ </view>
+ </view>
+</template>
+
+<script setup>
+import { computed, reactive, ref } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
+import { ensureLogin } from '@/utils/request.js'
+import { getCompanyCertificationStatus, submitCompanyCertification, uploadAvatarFile } from '@/api/index.js'
+
+/** 状态栏高度:自定义导航栏下用于顶部留白 */
+const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
+
+/** 图片大小上限:5MB(选图后前置校验,超限直接拒绝,避免无效上传) */
+const IMAGE_MAX_SIZE = 5 * 1024 * 1024
+
+/** 输入框占位色:与 picker/日期占位色统一(小程序 scoped 下 placeholder-class 不生效,改用行内样式) */
+const PLACEHOLDER_STYLE = 'color:#94a3b8;font-size:30rpx'
+
+/** 步骤标题:与原型四步一一对应 */
+const STEP_LABELS = ['企业信息', '法人信息', '收款信息', '提交审核']
+
+/** 主体类型:值 2个体工商户 / 3企业(与后端 subject_type 语义一致) */
+const SUBJECT_TYPE_VALUES = ['2', '3']
+const SUBJECT_TYPE_LABELS = ['个体工商户', '企业']
+
+/** 有效期类型:值 1长期 / 2短期;长期隐藏起止日期且不校验 */
+const VALID_TYPE_VALUES = ['1', '2']
+const VALID_TYPE_LABELS = ['长期', '短期']
+
+/** 收款类型选项:本期仅基础必填,直接存中文文本 */
+const PAYEE_TYPE_OPTIONS = ['对公账户', '对私账户']
+
+/** 结果页文案:审核中 / 已通过 */
+const RESULT_MAP = {
+ auditing: { title: '资料审核中', tip: '您的资料正在审核中,请耐心等待' },
+ passed: { title: '认证已通过', tip: '您的企业/个体户认证已通过审核' }
+}
+
+/** 当前步骤:0~2 为表单步骤,3 为提交审核结果页 */
+const step = ref(0)
+
+/** 结果页类型:'auditing' 审核中 / 'passed' 已通过 */
+const resultState = ref('')
+
+/** 提交中标记:防止重复点击造成重复提交 */
+const submitting = ref(false)
+
+/** 审核状态(0待审核 1已通过 2已驳回;空表示从未提交) */
+const auditStatus = ref('')
+
+/** 驳回原因:仅审核状态为「已驳回」时有值 */
+const rejectReason = ref('')
+
+/** 全部表单字段(有效期起止拆为起/止两个值,提交时再拼接为文本) */
+const form = reactive({
+ subjectType: '',
+ companyName: '',
+ licenseImage: '',
+ creditCode: '',
+ licenseValidType: '',
+ licenseValidStart: '',
+ licenseValidEnd: '',
+ registeredRegion: '',
+ companyAddress: '',
+ legalPerson: '',
+ legalIdCardFront: '',
+ legalIdCardBack: '',
+ legalIdValidType: '',
+ legalIdValidStart: '',
+ legalIdValidEnd: '',
+ legalIdAddress: '',
+ contactName: '',
+ contactPhone: '',
+ contactEmail: '',
+ payeeType: '',
+ bankCardImage: '',
+ bankCardNo: '',
+ bankCity: ''
+})
+
+/** 地区选择器回显值:picker 的 value 需与三级数组保持一致 */
+const companyRegionValue = ref([])
+const bankRegionValue = ref([])
+
+/** 值 → 文案:按同下标映射,未命中返回空串 */
+const labelOf = (values, labels, value) => {
+ const index = values.indexOf(value)
+ return index > -1 ? labels[index] : ''
+}
+
+/** 解析「起~止」文本为起止两个日期;长期或未填时均返回空 */
+const parseValidPeriod = (period) => {
+ if (!period) return { start: '', end: '' }
+ const [start = '', end = ''] = period.split('~')
+ return { start, end }
+}
+
+/** 拼接「起~止」文本:任一项缺失则返回空(长期提交空值) */
+const buildValidPeriod = (start, end) => (start && end ? `${start}~${end}` : '')
+
+/** 是否展示驳回原因 */
+const showRejectReason = computed(() => auditStatus.value === '2' && !!rejectReason.value)
+
+/** 是否已提交(结果页):锁定时不可回退修改 */
+const locked = computed(() => step.value === 3)
+
+/** 结果页文案对象 */
+const resultInfo = computed(() => RESULT_MAP[resultState.value] || RESULT_MAP.auditing)
+
+/** 主体类型文案(用于选择项回显) */
+const subjectTypeLabel = computed(() => labelOf(SUBJECT_TYPE_VALUES, SUBJECT_TYPE_LABELS, form.subjectType))
+
+/** 返回上一页:无上级页面(如直接进入)时回到个人中心 */
+const handleBack = () => {
+ const pages = getCurrentPages()
+ if (pages.length > 1) {
+ uni.navigateBack()
+ return
+ }
+ uni.switchTab({ url: '/pages/profile/index' })
+}
+
+/** 点击步骤条:已提交(结果页)不可回退,仅允许回退到已完成的步骤 */
+const handleStepTap = (idx) => {
+ if (locked.value) return
+ if (idx < step.value) step.value = idx
+}
+
+/** 通用下拉选择回写(主体类型 / 有效期类型) */
+const handleSelectChange = (field, values, e) => {
+ form[field] = values[e.detail.value] || ''
+}
+
+/** 收款类型选择(直接存中文文本) */
+const handlePayeeTypeChange = (e) => {
+ form.payeeType = PAYEE_TYPE_OPTIONS[e.detail.value] || ''
+}
+
+/** 地区选择:回写文本并按空格拆分为数组用于 picker 回显 */
+const handleRegionChange = (field, target, e) => {
+ const region = e.detail.value || []
+ form[field] = region.join(' ')
+ if (target === 'company') {
+ companyRegionValue.value = region
+ } else {
+ bankRegionValue.value = region
+ }
+}
+
+/** 日期选择回写(有效期起 / 止) */
+const handleDateChange = (field, e) => {
+ form[field] = e.detail.value
+}
+
+/** 图片来源选项:相册上传 / 拍照上传,供用户按场景选择 */
+const IMAGE_SOURCE_OPTIONS = [
+ { label: '拍照', sourceType: ['camera'] },
+ { label: '从相册选择', sourceType: ['album'] }
+]
+
+/**
+ * 选图并上传:先选来源(相册/拍照)-> 选图 -> 大小校验 -> 上传文件服务 -> 写回表单字段
+ * @param {String} field 表单字段名
+ * @param {String} label 字段中文名(用于失败提示)
+ */
+const handleChooseImage = (field, label) => {
+ uni.showActionSheet({
+ itemList: IMAGE_SOURCE_OPTIONS.map((item) => item.label),
+ success: (sheetRes) => {
+ const option = IMAGE_SOURCE_OPTIONS[sheetRes.tapIndex]
+ if (option) chooseAndUploadImage(field, label, option.sourceType)
+ }
+ })
+}
+
+/**
+ * 按指定来源选图并上传(相册 / 拍照)
+ * @param {String} field 表单字段名
+ * @param {String} label 字段中文名(用于失败提示)
+ * @param {Array} sourceType 图片来源:['camera'] 拍照 / ['album'] 相册
+ */
+const chooseAndUploadImage = (field, label, sourceType) => {
+ uni.chooseImage({
+ count: 1,
+ sizeType: ['compressed'],
+ sourceType,
+ success: async (res) => {
+ const filePath = (res.tempFilePaths || [])[0]
+ const file = (res.tempFiles || [])[0]
+ if (!filePath) return
+ // 前置大小校验:超限直接提示,避免无效上传
+ if (file && file.size > IMAGE_MAX_SIZE) {
+ uni.showToast({ title: '图片不能超过5MB', icon: 'none' })
+ return
+ }
+ uni.showLoading({ title: '上传中', mask: true })
+ try {
+ const uploadRes = await uploadAvatarFile(filePath)
+ const url = uploadRes.data && uploadRes.data.url
+ uni.hideLoading()
+ if (!url) {
+ uni.showToast({ title: '上传失败,请重试', icon: 'none' })
+ return
+ }
+ form[field] = url
+ } catch (error) {
+ uni.hideLoading()
+ console.error(`[COMPANY_CERT] 上传${label}失败`, error)
+ uni.showToast({ title: '上传失败,请重试', icon: 'none' })
+ }
+ }
+ })
+}
+
+/** 单步必填校验:返回失败提示(通过返回空串) */
+const validateStep = (idx) => {
+ // 第 1 步:企业信息
+ if (idx === 0) {
+ if (!form.subjectType) return '请选择主体类型'
+ if (!form.companyName.trim()) return '请输入企业/个体名称'
+ if (!form.licenseImage) return '请上传营业执照'
+ if (!form.creditCode.trim()) return '请输入统一社会信用代码'
+ if (!form.licenseValidType) return '请选择营业执照有效期类型'
+ // 短期必须填写起止日期;长期隐藏且不校验
+ if (form.licenseValidType === '2' && (!form.licenseValidStart || !form.licenseValidEnd)) {
+ return '请选择营业执照有效期起止日期'
+ }
+ if (!form.registeredRegion) return '请选择营业执照注册地区'
+ if (!form.companyAddress.trim()) return '请输入营业执照详细地址'
+ return ''
+ }
+ // 第 2 步:法人信息
+ if (idx === 1) {
+ if (!form.legalPerson.trim()) return '请输入法人姓名'
+ if (!form.legalIdCardFront) return '请上传法人身份证人像面'
+ if (!form.legalIdCardBack) return '请上传法人身份证国徽面'
+ if (!form.legalIdValidType) return '请选择法人身份证有效期类型'
+ if (form.legalIdValidType === '2' && (!form.legalIdValidStart || !form.legalIdValidEnd)) {
+ return '请选择法人身份证有效期起止日期'
+ }
+ if (!form.legalIdAddress.trim()) return '请输入法人身份证详细地址'
+ if (!form.contactName.trim()) return '请输入负责人姓名'
+ if (!form.contactPhone.trim()) return '请输入负责人联系电话'
+ if (!form.contactEmail.trim()) return '请输入负责人邮箱'
+ return ''
+ }
+ // 第 3 步:收款信息
+ if (idx === 2) {
+ if (!form.payeeType) return '请选择收款类型'
+ if (!form.bankCardImage) return '请上传银行卡号码面照片'
+ if (!form.bankCardNo.trim()) return '请输入银行卡号'
+ if (!form.bankCity) return '请选择开户城市'
+ return ''
+ }
+ return ''
+}
+
+/** 整表单校验:按步骤顺序返回首个缺失项提示 */
+const validateAll = () => validateStep(0) || validateStep(1) || validateStep(2)
+
+/** 组装提交入参(字段名与后端 CompanyCertificationApplyDto 一致) */
+const buildPayload = () => ({
+ subjectType: form.subjectType,
+ companyName: form.companyName.trim(),
+ licenseImage: form.licenseImage,
+ creditCode: form.creditCode.trim(),
+ licenseValidType: form.licenseValidType,
+ licenseValidPeriod: form.licenseValidType === '2' ? buildValidPeriod(form.licenseValidStart, form.licenseValidEnd) : '',
+ registeredRegion: form.registeredRegion,
+ companyAddress: form.companyAddress.trim(),
+ legalPerson: form.legalPerson.trim(),
+ legalIdCardFront: form.legalIdCardFront,
+ legalIdCardBack: form.legalIdCardBack,
+ legalIdValidType: form.legalIdValidType,
+ legalIdValidPeriod: form.legalIdValidType === '2' ? buildValidPeriod(form.legalIdValidStart, form.legalIdValidEnd) : '',
+ legalIdAddress: form.legalIdAddress.trim(),
+ contactName: form.contactName.trim(),
+ contactPhone: form.contactPhone.trim(),
+ contactEmail: form.contactEmail.trim(),
+ payeeType: form.payeeType,
+ bankCardImage: form.bankCardImage,
+ bankCardNo: form.bankCardNo.trim(),
+ bankCity: form.bankCity
+})
+
+/**
+ * 拉取企业认证状态并回填表单
+ * 从未提交 → 停在第 1 步空表单;已驳回 → 停在第 1 步并展示驳回原因;
+ * 审核中 / 已通过 → 直接进入第 4 步结果页(提交后不可再编辑)。
+ * 查询失败静默降级为「未认证」,不阻断用户填报。
+ */
+const loadCompanyCertificationStatus = async () => {
+ try {
+ const res = await getCompanyCertificationStatus()
+ const data = res.data || {}
+ auditStatus.value = data.auditStatus || ''
+ rejectReason.value = data.auditRemark || ''
+ // 回填企业信息
+ form.subjectType = data.subjectType || ''
+ form.companyName = data.companyName || ''
+ form.licenseImage = data.licenseImage || ''
+ form.creditCode = data.creditCode || ''
+ form.licenseValidType = data.licenseValidType || ''
+ form.registeredRegion = data.registeredRegion || ''
+ form.companyAddress = data.companyAddress || ''
+ // 回填法人信息
+ form.legalPerson = data.legalPerson || ''
+ form.legalIdCardFront = data.legalIdCardFront || ''
+ form.legalIdCardBack = data.legalIdCardBack || ''
+ form.legalIdValidType = data.legalIdValidType || ''
+ form.legalIdAddress = data.legalIdAddress || ''
+ form.contactName = data.contactName || ''
+ form.contactPhone = data.contactPhone || ''
+ form.contactEmail = data.contactEmail || ''
+ // 回填收款信息
+ form.payeeType = data.payeeType || ''
+ form.bankCardImage = data.bankCardImage || ''
+ form.bankCardNo = data.bankCardNo || ''
+ form.bankCity = data.bankCity || ''
+ // 有效期文本拆回起止日期
+ const licensePeriod = parseValidPeriod(data.licenseValidPeriod)
+ form.licenseValidStart = licensePeriod.start
+ form.licenseValidEnd = licensePeriod.end
+ const legalPeriod = parseValidPeriod(data.legalIdValidPeriod)
+ form.legalIdValidStart = legalPeriod.start
+ form.legalIdValidEnd = legalPeriod.end
+ // 地区文本按空格拆回三级数组,供 picker 回显
+ if (form.registeredRegion) companyRegionValue.value = form.registeredRegion.split(' ')
+ if (form.bankCity) bankRegionValue.value = form.bankCity.split(' ')
+ // 决定首屏:审核中 / 已通过锁定在结果页
+ if (auditStatus.value === '0') {
+ resultState.value = 'auditing'
+ step.value = 3
+ } else if (auditStatus.value === '1') {
+ resultState.value = 'passed'
+ step.value = 3
+ } else {
+ step.value = 0
+ }
+ } catch (error) {
+ console.error('[COMPANY_CERT] 查询企业认证状态失败', error)
+ auditStatus.value = ''
+ rejectReason.value = ''
+ step.value = 0
+ }
+}
+
+/** 主按钮:第 3 步提交,前两步逐级校验后进入下一步 */
+const handlePrimary = () => {
+ if (submitting.value) return
+ if (step.value === 2) {
+ handleSubmit()
+ return
+ }
+ const errorMsg = validateStep(step.value)
+ if (errorMsg) {
+ uni.showToast({ title: errorMsg, icon: 'none' })
+ return
+ }
+ step.value += 1
+}
+
+/** 提交企业/个体认证:整表单校验通过后调接口,成功后进入结果页 */
+const handleSubmit = async () => {
+ const errorMsg = validateAll()
+ if (errorMsg) {
+ uni.showToast({ title: errorMsg, icon: 'none' })
+ return
+ }
+ submitting.value = true
+ uni.showLoading({ title: '提交中', mask: true })
+ try {
+ await submitCompanyCertification(buildPayload())
+ uni.hideLoading()
+ auditStatus.value = '0'
+ resultState.value = 'auditing'
+ step.value = 3
+ uni.showToast({ title: '提交成功,等待审核', icon: 'none' })
+ } catch (error) {
+ uni.hideLoading()
+ console.error('[COMPANY_CERT] 提交企业认证失败', error)
+ uni.showToast({ title: (error && error.message) || '提交失败,请重试', icon: 'none' })
+ } finally {
+ submitting.value = false
+ }
+}
+
+// 进入页面校验登录态并拉取认证状态回填(未登录跳登录页)
+// 注意:选图/拍照(chooseImage)返回时会再次触发 onShow,若重复拉取会覆盖用户已填内容,
+// 故用 hasLoadedStatus 保证状态回填仅在首次显示时执行一次
+let hasLoadedStatus = false
+onShow(() => {
+ if (!ensureLogin()) return
+ if (hasLoadedStatus) return
+ hasLoadedStatus = true
+ loadCompanyCertificationStatus()
+})
+</script>
+
+<style scoped lang="scss">
+.page {
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ background-color: #ffffff;
+}
+
+/* ==================== 自定义导航栏 ==================== */
+
+.nav {
+ flex-shrink: 0;
+ background-color: #ffffff;
+}
+
+.nav-bar {
+ position: relative;
+ height: 88rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.nav-back {
+ position: absolute;
+ left: 16rpx;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 64rpx;
+ height: 64rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* 纯 CSS 左向箭头:左 + 下边框旋转 45° */
+.icon-back {
+ width: 22rpx;
+ height: 22rpx;
+ border-left: 4rpx solid #1f2937;
+ border-bottom: 4rpx solid #1f2937;
+ transform: rotate(45deg);
+}
+
+.nav-title {
+ font-size: 34rpx;
+ font-weight: 700;
+ line-height: 1;
+ letter-spacing: 1rpx;
+ color: #0f172a;
+}
+
+/* ==================== 四段式步骤条 ==================== */
+
+.steps {
+ flex-shrink: 0;
+ display: flex;
+ align-items: flex-start;
+ padding: 24rpx 32rpx 28rpx;
+ border-bottom: 2rpx solid #f1f5f9;
+}
+
+.step {
+ position: relative;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.step-dot {
+ position: relative;
+ z-index: 1;
+ width: 44rpx;
+ height: 44rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ background: #e2e8f0;
+}
+
+/* 已完成:草绿实心 + 打勾 */
+.step-dot--done {
+ background: #82c236;
+}
+
+/* 当前:草绿渐变高亮 */
+.step-dot--active {
+ background: linear-gradient(90deg, #82c236 0%, #639c1f 100%);
+}
+
+.step-dot-check {
+ font-size: 24rpx;
+ font-weight: 700;
+ line-height: 1;
+ color: #ffffff;
+}
+
+.step-dot-num {
+ font-size: 24rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #94a3b8;
+}
+
+.step-dot--active .step-dot-num {
+ color: #ffffff;
+}
+
+/* 连接线:从当前步骤圆心延伸到下一步骤圆心 */
+.step-line {
+ position: absolute;
+ top: 21rpx;
+ left: 50%;
+ width: 100%;
+ height: 4rpx;
+ background: #e2e8f0;
+}
+
+.step-line--done {
+ background: #82c236;
+}
+
+.step-label {
+ margin-top: 12rpx;
+ font-size: 24rpx;
+ line-height: 1;
+ color: #94a3b8;
+}
+
+.step-label--active {
+ font-weight: 600;
+ color: #639c1f;
+}
+
+.step-label--done {
+ color: #334155;
+}
+
+/* ==================== 内容区 ==================== */
+
+.body {
+ flex: 1;
+ min-height: 0;
+}
+
+.form {
+ padding: 32rpx 32rpx 40rpx;
+}
+
+/* ==================== 表单字段 ==================== */
+
+.field {
+ margin-bottom: 40rpx;
+}
+
+.field-label {
+ display: block;
+ font-size: 28rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #334155;
+}
+
+.field-input {
+ height: 88rpx;
+ margin-top: 8rpx;
+ font-size: 30rpx;
+ color: #1f2937;
+ border-bottom: 2rpx solid #e2e8f0;
+}
+
+/* 下拉选择行:文本 + 右箭头 */
+.field-select {
+ height: 88rpx;
+ margin-top: 8rpx;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 2rpx solid #e2e8f0;
+}
+
+.field-select-text {
+ font-size: 30rpx;
+ color: #1f2937;
+}
+
+.field-select-placeholder {
+ font-size: 30rpx;
+ color: #94a3b8;
+}
+
+.icon-chevron {
+ width: 32rpx;
+ height: 32rpx;
+ flex-shrink: 0;
+ background-repeat: no-repeat;
+ background-position: center center;
+ background-size: 100% 100%;
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTRBM0I4IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0ibTkgMTggNi02LTYtNiIvPjwvc3ZnPg==');
+}
+
+/* ==================== 有效期起止日期 ==================== */
+
+.date-range {
+ height: 88rpx;
+ margin-top: 8rpx;
+ display: flex;
+ align-items: center;
+ border-bottom: 2rpx solid #e2e8f0;
+}
+
+.date-range picker {
+ flex: 1;
+}
+
+.date-value {
+ font-size: 30rpx;
+ color: #1f2937;
+}
+
+.date-value--placeholder {
+ color: #94a3b8;
+}
+
+.date-sep {
+ flex-shrink: 0;
+ padding: 0 20rpx;
+ font-size: 30rpx;
+ color: #94a3b8;
+}
+
+/* ==================== 图片上传 ==================== */
+
+.upload {
+ width: 100%;
+ height: 260rpx;
+ margin-top: 16rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ border: 2rpx dashed #cbd5e1;
+ border-radius: 12rpx;
+ background: #f8fafc;
+ overflow: hidden;
+}
+
+/* 法人身份证正反面并排 */
+.upload-row {
+ display: flex;
+ gap: 24rpx;
+ margin-top: 16rpx;
+}
+
+.upload--half {
+ flex: 1;
+ width: auto;
+ height: 220rpx;
+ margin-top: 0;
+}
+
+.upload-img {
+ width: 100%;
+ height: 100%;
+}
+
+.upload-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16rpx;
+}
+
+.upload-empty-text {
+ font-size: 26rpx;
+ color: #94a3b8;
+}
+
+.icon-camera {
+ width: 48rpx;
+ height: 48rpx;
+ background-repeat: no-repeat;
+ background-position: center center;
+ background-size: 100% 100%;
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTRBM0I4IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTE0LjUgNGgtNUw3IDdINGEyIDIgMCAwIDAtMiAydjlhMiAyIDAgMCAwIDIgMmgxNmEyIDIgMCAwIDAgMi0yVjlhMiAyIDAgMCAwLTItMmgtM2wtMi41LTN6Ii8+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMyIgcj0iMyIvPjwvc3ZnPg==');
+}
+
+/* ==================== 驳回原因 ==================== */
+
+.reject-tip {
+ margin-bottom: 32rpx;
+ padding: 20rpx 24rpx;
+ border-radius: 12rpx;
+ background: #fef2f2;
+}
+
+.reject-tip-text {
+ font-size: 26rpx;
+ line-height: 1.5;
+ color: #ef4444;
+}
+
+/* ==================== 结果页 ==================== */
+
+.result {
+ padding: 140rpx 64rpx 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.result-icon {
+ width: 140rpx;
+ height: 140rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+}
+
+.result-icon--audit {
+ background: #eff6ff;
+}
+
+.result-icon--done {
+ background: #f7fee7;
+}
+
+.result-icon-text {
+ font-size: 40rpx;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.result-icon--audit .result-icon-text {
+ color: #1e5ebc;
+}
+
+.result-icon--done .result-icon-text {
+ color: #4d7c0f;
+}
+
+.result-title {
+ margin-top: 40rpx;
+ font-size: 34rpx;
+ font-weight: 700;
+ line-height: 1;
+ color: #0f172a;
+}
+
+.result-tip {
+ margin-top: 20rpx;
+ font-size: 28rpx;
+ line-height: 1.5;
+ text-align: center;
+ color: #94a3b8;
+}
+
+/* ==================== 底部提交 ==================== */
+
+.footer {
+ flex-shrink: 0;
+ padding: 24rpx 48rpx calc(44rpx + env(safe-area-inset-bottom));
+}
+
+.btn-submit {
+ width: 100%;
+ height: 96rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 12rpx;
+ background: linear-gradient(90deg, #82c236 0%, #639c1f 100%);
+ box-shadow: 0 16rpx 40rpx rgba(99, 156, 31, 0.28);
+}
+
+.btn-submit--disabled {
+ opacity: 0.6;
+}
+
+.btn-submit-text {
+ font-size: 32rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #ffffff;
+}
+</style>
diff --git a/hugray-user-miniapp/pages/profile/edit.vue b/hugray-user-miniapp/pages/profile/edit.vue
index 9cf013f..f6ae495 100644
--- a/hugray-user-miniapp/pages/profile/edit.vue
+++ b/hugray-user-miniapp/pages/profile/edit.vue
@@ -26,8 +26,8 @@
<view class="body">
<view class="list">
- <!-- 头像:点击更换(本期占位) -->
- <view class="row" @tap="handlePlaceholder('头像更换')">
+ <!-- 头像:点击更换 -->
+ <view class="row" @tap="handleAvatarChange">
<text class="row-label">头像</text>
<view class="row-right">
<view class="avatar">
@@ -45,7 +45,7 @@
</view>
<!-- 实名认证 -->
- <view class="row" @tap="handlePlaceholder('实名认证')">
+ <view class="row" @tap="handleRealNameCertification">
<text class="row-label">实名认证</text>
<view class="row-right">
<view class="badge" :class="realNameBadge.cls">
@@ -56,7 +56,7 @@
</view>
<!-- 企业 / 个体户认证 -->
- <view class="row" @tap="handlePlaceholder('企业/个体户认证')">
+ <view class="row" @tap="handleCompanyCertification">
<text class="row-label">企业/个体户认证</text>
<view class="row-right">
<view class="badge" :class="companyBadge.cls">
@@ -82,10 +82,13 @@
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { clearToken, ensureLogin } from '@/utils/request.js'
-import { getMemberInfo, logout } from '@/api/index.js'
+import { getMemberInfo, logout, uploadAvatarFile, updateAvatar } from '@/api/index.js'
/** 状态栏高度:自定义导航栏下用于顶部留白 */
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
+
+/** 头像文件大小上限:5MB(选图后前置校验,超限直接拒绝,避免无效上传) */
+const AVATAR_MAX_SIZE = 5 * 1024 * 1024
/** 用户头像:登录后由 getMemberInfo 下发,为空时回退相机占位 */
const avatar = ref('')
@@ -94,8 +97,7 @@
const phone = ref('')
/**
- * 认证状态字典:key 为后端下发的状态枚举,值含徽标文案与样式类
- * 本期为原型,两处认证均取 none(未认证)占位,后端接入后直接替换 ref 初值即可
+ * 认证状态字典:key 为本地徽标状态,值含徽标文案与样式类
*/
const AUTH_STATUS_MAP = {
none: { text: '未认证', cls: 'badge--none' },
@@ -104,10 +106,21 @@
done: { text: '已认证', cls: 'badge--done' }
}
-/** 实名认证状态(本期占位) */
+/**
+ * 后端认证状态码 → 本地徽标状态(0未认证 1审核中 2已认证 3认证失败)
+ * 个人中心「实名认证」行据此回显真实状态
+ */
+const AUTH_STATUS_CODE_MAP = {
+ '0': 'none',
+ '1': 'ing',
+ '2': 'done',
+ '3': 'fail'
+}
+
+/** 实名认证状态:由会员信息接口下发的 authStatus 映射,缺省「未认证」 */
const realNameStatus = ref('none')
-/** 企业 / 个体户认证状态(本期占位) */
+/** 企业 / 个体户认证状态:由会员信息接口下发的 companyAuthStatus 映射,缺省「未认证」 */
const companyStatus = ref('none')
/** 实名认证徽标:状态缺省兜底「未认证」 */
@@ -126,9 +139,72 @@
uni.switchTab({ url: '/pages/profile/index' })
}
-/** 占位交互:头像更换 / 实名认证 / 企业认证 / 注销 / 更多 / 帮助本期均不接后端 */
+/** 占位交互:注销 / 更多 / 帮助本期均不接后端 */
const handlePlaceholder = (name) => {
uni.showToast({ title: `${name}功能开发中`, icon: 'none' })
+}
+
+/** 跳转实名认证页(个人主体) */
+const handleRealNameCertification = () => {
+ uni.navigateTo({ url: '/pages/profile/certification' })
+}
+
+/** 跳转企业/个体认证向导页(企业 / 个体工商户主体) */
+const handleCompanyCertification = () => {
+ uni.navigateTo({ url: '/pages/profile/company-certification' })
+}
+
+/**
+ * 更换头像入口:先弹出选择方式(相册 / 拍照 / 取消)
+ * 说明:取消按钮由系统 ActionSheet 自带,用户取消时不触发 success,无需额外处理
+ */
+const handleAvatarChange = () => {
+ uni.showActionSheet({
+ itemList: ['从相册选择', '拍照上传'],
+ success: (res) => {
+ // tapIndex 0=相册 1=拍照
+ chooseAndUploadAvatar(res.tapIndex === 1 ? 'camera' : 'album')
+ }
+ })
+}
+
+/**
+ * 选图并上传:选图 -> 大小校验 -> 上传文件服务 -> 写回会员资料 -> 本地即时回显
+ * @param {String} sourceType 图片来源:'album' 相册 / 'camera' 拍照
+ */
+const chooseAndUploadAvatar = (sourceType) => {
+ uni.chooseImage({
+ count: 1,
+ sizeType: ['compressed'],
+ sourceType: [sourceType],
+ success: async (res) => {
+ const filePath = (res.tempFilePaths || [])[0]
+ const file = (res.tempFiles || [])[0]
+ if (!filePath) return
+ // 前置大小校验:超限直接提示,避免无效上传
+ if (file && file.size > AVATAR_MAX_SIZE) {
+ uni.showToast({ title: '图片不能超过5MB', icon: 'none' })
+ return
+ }
+ try {
+ // STEP 1: 上传文件到文件服务,拿到可访问 url
+ const uploadRes = await uploadAvatarFile(filePath)
+ const url = uploadRes.data && uploadRes.data.url
+ if (!url) {
+ uni.showToast({ title: '上传失败,请重试', icon: 'none' })
+ return
+ }
+ // STEP 2: 把 url 写回会员资料
+ await updateAvatar(url)
+ // STEP 3: 本地即时回显,无需再拉接口
+ avatar.value = url
+ uni.showToast({ title: '头像已更新', icon: 'success' })
+ } catch (error) {
+ console.error('[PROFILE_EDIT] 更新头像失败', error)
+ uni.showToast({ title: (error && error.message) || '更新头像失败', icon: 'none' })
+ }
+ }
+ })
}
/** 退出登录:二次确认后调用退出接口并清理本地登录态,回到个人中心 */
@@ -152,17 +228,23 @@
})
}
-/** 拉取当前会员信息(头像 / 脱敏手机号),失败时静默降级为空 */
+/** 拉取当前会员信息(头像 / 脱敏手机号 / 实名认证状态),失败时静默降级为空 */
const loadMemberInfo = async () => {
try {
const res = await getMemberInfo()
const data = res.data || {}
avatar.value = data.avatar || ''
phone.value = data.phone || ''
+ // 实名认证徽标据后端 authStatus 映射回显(审核通过/驳回后再次进入本页即刷新)
+ realNameStatus.value = AUTH_STATUS_CODE_MAP[data.authStatus] || 'none'
+ // 企业/个体户认证徽标据后端 companyAuthStatus 映射回显,与实名认证相互独立
+ companyStatus.value = AUTH_STATUS_CODE_MAP[data.companyAuthStatus] || 'none'
} catch (error) {
console.error('[PROFILE_EDIT] 拉取会员信息失败', error)
avatar.value = ''
phone.value = ''
+ realNameStatus.value = 'none'
+ companyStatus.value = 'none'
}
}
diff --git a/hugray-user-miniapp/pages/profile/index.vue b/hugray-user-miniapp/pages/profile/index.vue
index 38ce0af..0e26faf 100644
--- a/hugray-user-miniapp/pages/profile/index.vue
+++ b/hugray-user-miniapp/pages/profile/index.vue
@@ -164,6 +164,16 @@
uni.navigateTo({ url: '/pages/address/list' })
return
}
+ // 我的邀请:跳转「我的邀请」页(邀请码 / 我邀请的 / 邀请我的)
+ if (menu.key === 'invite') {
+ uni.navigateTo({ url: '/pages/profile/invite' })
+ return
+ }
+ // 会员中心:跳转会员中心页(销售统计 / 我的仓库 / 进货订单)
+ if (menu.key === 'member') {
+ uni.navigateTo({ url: '/pages/member/index' })
+ return
+ }
uni.showToast({ title: '敬请期待', icon: 'none' })
}
diff --git a/hugray-user-miniapp/pages/profile/invite.vue b/hugray-user-miniapp/pages/profile/invite.vue
new file mode 100644
index 0000000..5459889
--- /dev/null
+++ b/hugray-user-miniapp/pages/profile/invite.vue
@@ -0,0 +1,781 @@
+<template>
+ <view class="page">
+ <!-- 自定义导航栏:状态栏留白由行内 paddingTop 撑开 -->
+ <view class="nav" :style="{ paddingTop: statusBarHeight + 'px' }">
+ <view class="nav-bar">
+ <view class="nav-back" @tap="handleBack">
+ <view class="icon-back"></view>
+ </view>
+ <text class="nav-title">我的邀请</text>
+ </view>
+ </view>
+
+ <!-- 三段式 Tab:邀请码 / 我邀请的 / 邀请我的 -->
+ <view class="tabs">
+ <view
+ v-for="tab in TAB_LIST"
+ :key="tab.key"
+ class="tab-item"
+ @tap="handleTabChange(tab.key)"
+ >
+ <text class="tab-text" :class="{ 'tab-text--active': activeTab === tab.key }">
+ {{ tab.label }}
+ </text>
+ <!-- 选中态底部指示条 -->
+ <view v-if="activeTab === tab.key" class="tab-indicator"></view>
+ </view>
+ </view>
+
+ <view class="body">
+ <!-- ==================== Tab1:邀请码 ==================== -->
+ <view v-if="activeTab === 'code'" class="code-panel">
+ <view class="qrcode-card">
+ <!-- 真实小程序码(base64 DataURI),扫码直达注册页并自动填入邀请码 -->
+ <image v-if="qrcode" class="qrcode-img" :src="qrcode" mode="aspectFit" />
+ <view v-else class="qrcode-placeholder">
+ <text class="qrcode-placeholder-text">二维码生成中…</text>
+ </view>
+ <text class="qrcode-caption">我的邀请码</text>
+ <!-- 点击复制邀请码 -->
+ <view class="code-value" @tap="handleCopyCode">
+ <text class="code-value-text">{{ inviteCode || '—' }}</text>
+ </view>
+ </view>
+
+ <!-- 分享按钮:open-type=share 触发 onShareAppMessage -->
+ <button class="btn-share" open-type="share">
+ <text class="btn-share-text">分享邀请码</text>
+ </button>
+ </view>
+
+ <!-- ==================== Tab2:我邀请的 ==================== -->
+ <view v-else-if="activeTab === 'invitees'" class="invitee-panel">
+ <view v-if="invitees.length > 0" class="invitee-list">
+ <view v-for="item in invitees" :key="item.userId" class="invitee-row">
+ <!-- 头像:为空回退默认用户图标 -->
+ <view class="invitee-avatar">
+ <image
+ v-if="item.avatar"
+ class="invitee-avatar-img"
+ :src="item.avatar"
+ mode="aspectFill"
+ />
+ <view v-else class="icon icon-user"></view>
+ </view>
+ <!-- 中部:昵称 + 等级徽标 / 脱敏手机号 -->
+ <view class="invitee-main">
+ <view class="invitee-name-line">
+ <text class="invitee-name">{{ displayName(item) }}</text>
+ <view class="level-badge">
+ <text class="level-badge-text">{{ item.systemLevel || 'C' }}</text>
+ </view>
+ </view>
+ <text class="invitee-phone">{{ item.phone || '—' }}</text>
+ </view>
+ <!-- 右侧:合计消费 / 绑定日 -->
+ <view class="invitee-side">
+ <text class="invitee-amount">合计消费:¥{{ formatYuanAmount(item.buyAmountTotal) }}</text>
+ <text v-if="item.bindDate" class="invitee-date">绑定日:{{ item.bindDate }}</text>
+ </view>
+ </view>
+ </view>
+
+ <!-- 空态:无邀请记录 -->
+ <view v-else-if="!listLoading" class="empty">
+ <view class="empty-icon-wrap">
+ <view class="icon icon-empty-invite"></view>
+ </view>
+ <text class="empty-title">还没有邀请好友</text>
+ <text class="empty-desc">分享邀请码,邀请好友加入</text>
+ </view>
+
+ <!-- 加载更多提示 -->
+ <view v-if="invitees.length > 0" class="list-foot">
+ <text class="list-foot-text">{{ hasMore ? '加载中…' : '没有更多了' }}</text>
+ </view>
+ </view>
+
+ <!-- ==================== Tab3:邀请我的 ==================== -->
+ <view v-else class="referrer-panel">
+ <view v-if="referrer && referrer.userId" class="referrer-card">
+ <view class="referrer-avatar">
+ <image
+ v-if="referrer.avatar"
+ class="referrer-avatar-img"
+ :src="referrer.avatar"
+ mode="aspectFill"
+ />
+ <view v-else class="icon icon-user-lg"></view>
+ </view>
+ <view class="referrer-name-line">
+ <text class="referrer-name">{{ displayName(referrer) }}</text>
+ <view class="level-badge">
+ <text class="level-badge-text">{{ referrer.systemLevel || 'C' }}</text>
+ </view>
+ </view>
+ <text class="referrer-phone">{{ referrer.phone || '—' }}</text>
+ <text v-if="referrer.bindDate" class="referrer-date">绑定日:{{ referrer.bindDate }}</text>
+ </view>
+
+ <!-- 空态:无上级信息 -->
+ <view v-else-if="!referrerLoading" class="empty">
+ <view class="empty-icon-wrap">
+ <view class="icon icon-empty-referrer"></view>
+ </view>
+ <text class="empty-title">暂无上级信息</text>
+ <text class="empty-desc">绑定上级后这里会展示ta的信息</text>
+ </view>
+ </view>
+ </view>
+ </view>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { onLoad, onReachBottom, onShareAppMessage } from '@dcloudio/uni-app'
+import { ensureLogin } from '@/utils/request.js'
+import { getInviteCode, getInviteQrcode, getInvitees, getReferrer } from '@/api/index.js'
+
+/** 状态栏高度:自定义导航栏下用于顶部留白 */
+const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
+
+/** 每页条数:与后端 TableSupport 默认值保持一致 */
+const PAGE_SIZE = 10
+
+/** 三 Tab 配置:key 与后端无关,仅前端切换用 */
+const TAB_LIST = [
+ { key: 'code', label: '邀请码' },
+ { key: 'invitees', label: '我邀请的' },
+ { key: 'referrer', label: '邀请我的' }
+]
+
+/** 当前 Tab,默认「邀请码」 */
+const activeTab = ref('code')
+
+/** 本人邀请码原值(8 位 A-Z0-9) */
+const inviteCode = ref('')
+
+/** 本人邀请码小程序码(PNG base64 DataURI),直接作为 image src */
+const qrcode = ref('')
+
+/** 我邀请的 列表数据 */
+const invitees = ref([])
+/** 我邀请的 当前页码(下一页待请求页码) */
+const pageNum = ref(1)
+/** 我邀请的 是否还有下一页 */
+const hasMore = ref(true)
+/** 我邀请的 加载中标志(防止重复请求) */
+const listLoading = ref(false)
+
+/** 邀请我的 上级信息,无上级时为 null */
+const referrer = ref(null)
+/** 邀请我的 加载中标志 */
+const referrerLoading = ref(false)
+/** 邀请我的 是否已加载过(懒加载,避免重复请求) */
+const referrerLoaded = ref(false)
+
+/**
+ * 展示名:昵称优先,为空时回退脱敏手机号,均无则兜底「会员」
+ * @param {Object} user 含 nickname / phone 的用户对象
+ */
+const displayName = (user) => (user && (user.nickname || user.phone)) || '会员'
+
+/**
+ * 合计消费格式化:后端 buyAmountTotal 单位为「元」,保留两位小数
+ * 注意:不可复用 formatYuan(后者按「分」处理)
+ * @param {Number|String} val 金额(元)
+ */
+const formatYuanAmount = (val) => {
+ const num = Number(val)
+ return Number.isFinite(num) ? num.toFixed(2) : '0.00'
+}
+
+/** 拉取本人邀请码,成功后继续拉取小程序码 */
+const loadInviteInfo = async () => {
+ try {
+ const res = await getInviteCode()
+ inviteCode.value = (res.data && res.data.referralCode) || ''
+ if (inviteCode.value) {
+ loadQrcode(inviteCode.value)
+ }
+ } catch (e) {
+ console.error('[INVITE] 获取邀请码失败', e)
+ }
+}
+
+/**
+ * 拉取小程序码(以邀请码为 scene)
+ * @param {String} code 邀请码
+ */
+const loadQrcode = async (code) => {
+ try {
+ const res = await getInviteQrcode(code)
+ qrcode.value = (res.data && res.data.qrcode) || ''
+ } catch (e) {
+ console.error('[INVITE] 获取邀请二维码失败', e)
+ uni.showToast({ title: '二维码生成失败,请重试', icon: 'none' })
+ }
+}
+
+/**
+ * 拉取我邀请的列表(分页)
+ * @param {Boolean} reset 是否重置为第一页(首次进入 / 下拉刷新)
+ */
+const loadInvitees = async (reset = false) => {
+ if (listLoading.value) return
+ // 非重置且已无更多数据时直接返回,避免无效请求
+ if (!reset && !hasMore.value) return
+ if (reset) {
+ pageNum.value = 1
+ hasMore.value = true
+ }
+ listLoading.value = true
+ try {
+ const res = await getInvitees({ pageNum: pageNum.value, pageSize: PAGE_SIZE })
+ // 分页接口返回 TableDataInfo:数据在顶层 rows(非 data 包裹)
+ const rows = res.rows || []
+ invitees.value = reset ? rows : invitees.value.concat(rows)
+ const total = res.total || 0
+ // 已加载条数达到总数或本页返回空,视为无更多
+ hasMore.value = rows.length > 0 && invitees.value.length < total
+ if (hasMore.value) pageNum.value += 1
+ } catch (e) {
+ console.error('[INVITE] 获取邀请列表失败', e)
+ } finally {
+ listLoading.value = false
+ }
+}
+
+/** 拉取邀请我的(上级)信息 */
+const loadReferrer = async () => {
+ referrerLoading.value = true
+ try {
+ const res = await getReferrer()
+ referrer.value = res.data || null
+ } catch (e) {
+ console.error('[INVITE] 获取上级信息失败', e)
+ referrer.value = null
+ } finally {
+ referrerLoading.value = false
+ referrerLoaded.value = true
+ }
+}
+
+/** Tab 切换:进入「我邀请的」「邀请我的」时按需懒加载 */
+const handleTabChange = (key) => {
+ if (activeTab.value === key) return
+ activeTab.value = key
+ if (key === 'invitees' && invitees.value.length === 0) {
+ loadInvitees(true)
+ }
+ if (key === 'referrer' && !referrerLoaded.value) {
+ loadReferrer()
+ }
+}
+
+/** 点击邀请码:复制到剪贴板 */
+const handleCopyCode = () => {
+ if (!inviteCode.value) return
+ uni.setClipboardData({
+ data: inviteCode.value,
+ success: () => uni.showToast({ title: '邀请码已复制', icon: 'none' })
+ })
+}
+
+/** 返回:优先返回上一页,无上一页时回个人中心(Tab 页) */
+const handleBack = () => {
+ const pages = getCurrentPages()
+ if (pages.length > 1) {
+ uni.navigateBack()
+ return
+ }
+ uni.switchTab({ url: '/pages/profile/index' })
+}
+
+/** 页面加载:校验登录态并拉取邀请码与小程序码 */
+onLoad(() => {
+ if (!ensureLogin()) return
+ loadInviteInfo()
+})
+
+/** 触底加载更多(仅「我邀请的」Tab 生效) */
+onReachBottom(() => {
+ if (activeTab.value === 'invitees') {
+ loadInvitees(false)
+ }
+})
+
+/** 分享:携带邀请码直达注册页,扫码/点开注册即自动绑定 */
+onShareAppMessage(() => {
+ const path = inviteCode.value
+ ? `/pages/login/register?referralCode=${inviteCode.value}`
+ : '/pages/login/register'
+ return {
+ title: '邀请你加入代谢300,注册自动绑定邀请码',
+ path
+ }
+})
+</script>
+
+<style scoped lang="scss">
+.page {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ background-color: #f8fafc;
+}
+
+/* ==================== 自定义导航栏 ==================== */
+
+.nav {
+ flex-shrink: 0;
+ background-color: #ffffff;
+}
+
+.nav-bar {
+ position: relative;
+ height: 88rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.nav-back {
+ position: absolute;
+ left: 16rpx;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 64rpx;
+ height: 64rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* 纯 CSS 左向箭头:左 + 下边框旋转 45° */
+.icon-back {
+ width: 22rpx;
+ height: 22rpx;
+ border-left: 4rpx solid #0f172a;
+ border-bottom: 4rpx solid #0f172a;
+ transform: rotate(45deg);
+}
+
+.nav-title {
+ font-size: 34rpx;
+ font-weight: 700;
+ line-height: 1;
+ letter-spacing: 1rpx;
+ color: #0f172a;
+}
+
+/* ==================== 三段式 Tab ==================== */
+
+.tabs {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ background-color: #ffffff;
+ border-bottom: 2rpx solid #e2e8f0;
+}
+
+.tab-item {
+ flex: 1;
+ height: 88rpx;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.tab-text {
+ font-size: 30rpx;
+ line-height: 1;
+ color: #64748b;
+}
+
+.tab-text--active {
+ font-weight: 600;
+ color: #10b981;
+}
+
+/* 选中态底部指示条 */
+.tab-indicator {
+ position: absolute;
+ bottom: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 96rpx;
+ height: 4rpx;
+ border-radius: 4rpx;
+ background-color: #10b981;
+}
+
+.body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ==================== Tab1:邀请码 ==================== */
+
+.code-panel {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ padding: 40rpx 40rpx 60rpx;
+}
+
+.qrcode-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 60rpx 32rpx 56rpx;
+ border-radius: 16rpx;
+ background-color: #ffffff;
+ border: 2rpx solid #e2e8f0;
+ box-shadow: 0 6rpx 16rpx rgba(15, 23, 42, 0.06);
+}
+
+.qrcode-img {
+ width: 400rpx;
+ height: 400rpx;
+}
+
+.qrcode-placeholder {
+ width: 400rpx;
+ height: 400rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 12rpx;
+ background-color: #f1f5f9;
+}
+
+.qrcode-placeholder-text {
+ font-size: 26rpx;
+ line-height: 1;
+ color: #94a3b8;
+}
+
+.qrcode-caption {
+ margin-top: 40rpx;
+ font-size: 30rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #0f172a;
+}
+
+.code-value {
+ margin-top: 20rpx;
+ padding: 12rpx 32rpx;
+ border-radius: 12rpx;
+ background-color: #ecfdf5;
+}
+
+.code-value-text {
+ font-size: 32rpx;
+ font-weight: 700;
+ letter-spacing: 6rpx;
+ line-height: 1;
+ color: #10b981;
+}
+
+/* 分享按钮:整宽绿色胶囊 */
+.btn-share {
+ margin-top: auto;
+ width: 100%;
+ height: 96rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ border: none;
+ border-radius: 48rpx;
+ background: linear-gradient(90deg, #34d399 0%, #10b981 100%);
+ box-shadow: 0 12rpx 32rpx rgba(16, 185, 129, 0.28);
+}
+
+/* 覆盖小程序 button 默认伪元素边框 */
+.btn-share::after {
+ border: none;
+}
+
+.btn-share-text {
+ font-size: 32rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #ffffff;
+}
+
+/* ==================== Tab2:我邀请的 ==================== */
+
+.invitee-panel {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ padding: 24rpx 32rpx 0;
+}
+
+.invitee-list {
+ border-radius: 16rpx;
+ overflow: hidden;
+ background-color: #ffffff;
+ border: 2rpx solid #e2e8f0;
+ box-shadow: 0 6rpx 16rpx rgba(15, 23, 42, 0.06);
+}
+
+.invitee-row {
+ display: flex;
+ align-items: center;
+ padding: 28rpx 28rpx;
+ border-bottom: 2rpx solid #f1f5f9;
+}
+
+/* 末行无分隔线 */
+.invitee-row:last-child {
+ border-bottom: none;
+}
+
+.invitee-avatar {
+ flex-shrink: 0;
+ width: 88rpx;
+ height: 88rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ overflow: hidden;
+ background-color: #f1f5f9;
+}
+
+.invitee-avatar-img {
+ width: 100%;
+ height: 100%;
+}
+
+.invitee-main {
+ flex: 1;
+ min-width: 0;
+ margin-left: 24rpx;
+ display: flex;
+ flex-direction: column;
+ gap: 12rpx;
+}
+
+.invitee-name-line {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+}
+
+.invitee-name {
+ max-width: 240rpx;
+ font-size: 30rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #0f172a;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.invitee-phone {
+ font-size: 26rpx;
+ line-height: 1;
+ color: #64748b;
+}
+
+.invitee-side {
+ flex-shrink: 0;
+ margin-left: 16rpx;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 12rpx;
+}
+
+.invitee-amount {
+ font-size: 28rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #0f172a;
+}
+
+.invitee-date {
+ font-size: 24rpx;
+ line-height: 1;
+ color: #94a3b8;
+}
+
+/* ==================== Tab3:邀请我的 ==================== */
+
+.referrer-panel {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 80rpx 40rpx 0;
+}
+
+.referrer-card {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 64rpx 32rpx;
+ border-radius: 16rpx;
+ background-color: #ffffff;
+ border: 2rpx solid #e2e8f0;
+ box-shadow: 0 6rpx 16rpx rgba(15, 23, 42, 0.06);
+}
+
+.referrer-avatar {
+ width: 160rpx;
+ height: 160rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ overflow: hidden;
+ background-color: #f1f5f9;
+}
+
+.referrer-avatar-img {
+ width: 100%;
+ height: 100%;
+}
+
+.referrer-name-line {
+ margin-top: 32rpx;
+ display: flex;
+ align-items: center;
+ gap: 16rpx;
+}
+
+.referrer-name {
+ font-size: 36rpx;
+ font-weight: 700;
+ line-height: 1;
+ color: #0f172a;
+}
+
+.referrer-phone {
+ margin-top: 24rpx;
+ font-size: 30rpx;
+ line-height: 1;
+ letter-spacing: 1rpx;
+ color: #334155;
+}
+
+.referrer-date {
+ margin-top: 20rpx;
+ font-size: 26rpx;
+ line-height: 1;
+ color: #94a3b8;
+}
+
+/* ==================== 等级徽标(实心绿胶囊) ==================== */
+
+.level-badge {
+ display: flex;
+ align-items: center;
+ padding: 4rpx 14rpx;
+ border-radius: 6rpx;
+ background-color: #10b981;
+}
+
+.level-badge-text {
+ font-size: 22rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #ffffff;
+}
+
+/* ==================== 空态 / 加载更多 ==================== */
+
+.empty {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 140rpx 0 0;
+}
+
+/* 圆形底衬 + 空态图标 */
+.empty-icon-wrap {
+ width: 200rpx;
+ height: 200rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ background-color: #f1f5f9;
+}
+
+.empty-title {
+ margin-top: 36rpx;
+ font-size: 30rpx;
+ font-weight: 600;
+ line-height: 1;
+ color: #475569;
+}
+
+.empty-desc {
+ margin-top: 16rpx;
+ font-size: 26rpx;
+ line-height: 1;
+ color: #94a3b8;
+}
+
+.list-foot {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 32rpx 0;
+}
+
+.list-foot-text {
+ font-size: 24rpx;
+ line-height: 1;
+ color: #cbd5e1;
+}
+
+/* ==================== 图标(内联 base64 SVG) ==================== */
+
+.icon {
+ flex-shrink: 0;
+ background-repeat: no-repeat;
+ background-position: center center;
+ background-size: 100% 100%;
+}
+
+/* 默认用户占位(#CBD5E1) */
+.icon-user {
+ width: 68rpx;
+ height: 68rpx;
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjQ0JENUUxIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTE5IDIxdi0yYTQgNCAwIDAgMC00LTRIOWE0IDQgMCAwIDAtNCA0djIiLz48Y2lyY2xlIGN4PSIxMiIgY3k9IjciIHI9IjQiLz48L3N2Zz4=');
+}
+
+/* 上级大头像占位(#CBD5E1) */
+.icon-user-lg {
+ width: 120rpx;
+ height: 120rpx;
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjQ0JENUUxIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTE5IDIxdi0yYTQgNCAwIDAgMC00LTRIOWE0IDQgMCAwIDAtNCA0djIiLz48Y2lyY2xlIGN4PSIxMiIgY3k9IjciIHI9IjQiLz48L3N2Zz4=');
+}
+
+/* 空态图标:邀请好友(#CBD5E1) */
+.icon-empty-invite {
+ width: 96rpx;
+ height: 96rpx;
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjY2JkNWUxIiBzdHJva2Utd2lkdGg9IjEuNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTUgMjF2LTJhNCA0IDAgMCAwLTQtNEg2YTQgNCAwIDAgMC00IDR2MiIvPjxjaXJjbGUgY3g9IjguNSIgY3k9IjciIHI9IjQiLz48cGF0aCBkPSJNMTkgOHY2Ii8+PHBhdGggZD0iTTIyIDExaC02Ii8+PC9zdmc+');
+}
+
+/* 空态图标:暂无上级(#CBD5E1) */
+.icon-empty-referrer {
+ width: 96rpx;
+ height: 96rpx;
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjY2JkNWUxIiBzdHJva2Utd2lkdGg9IjEuNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTkgMjF2LTJhNCA0IDAgMCAwLTQtNEg5YTQgNCAwIDAgMC00IDR2MiIvPjxjaXJjbGUgY3g9IjEyIiBjeT0iNyIgcj0iNCIvPjwvc3ZnPg==');
+}
+</style>
diff --git a/hugray-user-miniapp/utils/request.js b/hugray-user-miniapp/utils/request.js
index 6e51d28..9bfa7a8 100644
--- a/hugray-user-miniapp/utils/request.js
+++ b/hugray-user-miniapp/utils/request.js
@@ -136,6 +136,100 @@
}, 1500)
}
+// ======================== 文件上传方法 ========================
+
+/**
+ * 统一文件上传方法(基于 uni.uploadFile)
+ * 与 request 保持一致的令牌注入、响应解包与登录失效处理,供图片/文件上传使用
+ * @param {String} url 上传接口路径(不含根地址;传完整 http 地址则直接使用)
+ * @param {String} filePath 本地文件路径(如 chooseImage 返回的 tempFilePath)
+ * @param {Object} [options] 可选配置
+ * @param {String} [options.name] 表单文件字段名,默认 file
+ * @param {Object} [options.formData] 附加表单参数
+ * @param {Object} [options.header] 自定义请求头
+ * @param {Boolean} [options.auth] 是否需要携带 token,默认 true
+ * @param {Boolean} [options.loading] 是否显示上传中提示,默认 true
+ * @param {String} [options.loadingText] 上传提示文案
+ * @returns {Promise<Object>} 解析后的响应体 { code, msg, data }
+ */
+export function uploadFile(url = '', filePath = '', options = {}) {
+ const {
+ name = 'file',
+ formData = {},
+ header = {},
+ auth = true,
+ loading = true,
+ loadingText = '上传中...'
+ } = options
+
+ // 拼接完整请求地址
+ const fullUrl = /^https?:\/\//i.test(url) ? url : config.baseUrl + url
+
+ // 组装请求头并注入令牌(multipart 边界由底层自动生成,此处不设 Content-Type)
+ const finalHeader = { ...header }
+ const token = getToken()
+ if (auth && token) {
+ finalHeader.Authorization = 'Bearer ' + token
+ }
+
+ if (loading) {
+ uni.showLoading({ title: loadingText, mask: true })
+ }
+
+ return new Promise((resolve, reject) => {
+ uni.uploadFile({
+ url: fullUrl,
+ filePath,
+ name,
+ formData,
+ header: finalHeader,
+ timeout: config.timeout,
+ success: (res) => {
+ const { statusCode } = res
+
+ // 非 HTTP 200 视为网络异常
+ if (statusCode !== 200) {
+ reject(new Error('网络请求异常:' + statusCode))
+ return
+ }
+
+ // uploadFile 返回的 data 为字符串,需解析为 JSON
+ let body
+ try {
+ body = JSON.parse(res.data)
+ } catch (e) {
+ reject(new Error('上传响应解析失败'))
+ return
+ }
+
+ // 登录失效:清理登录态并跳转登录页
+ if (body && body.code === config.unauthorizedCode) {
+ _handleUnauthorized()
+ reject(new Error(body.msg || '登录状态已过期'))
+ return
+ }
+
+ // 业务成功:返回完整响应体,调用方按需取 data
+ if (body && body.code === config.successCode) {
+ resolve(body)
+ return
+ }
+
+ // 业务失败:统一抛出错误信息
+ reject(new Error((body && body.msg) || '上传失败'))
+ },
+ fail: (err) => {
+ reject(new Error((err && err.errMsg) || '上传失败'))
+ },
+ complete: () => {
+ if (loading) {
+ uni.hideLoading()
+ }
+ }
+ })
+ })
+}
+
// ======================== 快捷方法 ========================
/** GET 请求 */
--
Gitblit v1.7.1