chore: 提交所有未提交的修改
包括: - admin-service: 系统配置功能 - authorization-service: 自助授权申请功能 - planting-service: 资金分配服务 - reward-service: 奖励计算服务 - admin-web: 用户管理和设置页面 - mobile-app: 授权、认证、路由等功能 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
56fed2e5f3
commit
943fd9efe9
|
|
@ -224,7 +224,42 @@
|
|||
"Bash(git restore:*)",
|
||||
"Bash(node -e \"\nconst { ethers } = require(''ethers'');\n\nconst KAVA_TESTNET_RPC = ''https://evm.testnet.kava.io'';\nconst privateKey = ''0xd42a6e6021ebd884f3f179d3793a32e97b9f1001db6ff44441ec455d748b9aa6'';\nconst USDT_CONTRACT = ''0xc12f6A4A7Fd0965085B044A67a39CcA2ff7fe0dF'';\nconst TO_ADDRESS = ''0xfb852080346fa0996c28b250e0bbb5e27de7e9ca'';\n\nasync function transfer() {\n const provider = new ethers.JsonRpcProvider(KAVA_TESTNET_RPC);\n const wallet = new ethers.Wallet(privateKey, provider);\n \n const abi = [''function transfer(address to, uint256 amount) returns (bool)'', ''function balanceOf(address) view returns (uint256)''];\n const contract = new ethers.Contract(USDT_CONTRACT, abi, wallet);\n \n const amount = BigInt(30000000) * BigInt(1000000);\n \n console.log(''Transferring 30,000,000 USDT to'', TO_ADDRESS);\n const tx = await contract.transfer(TO_ADDRESS, amount, { gasLimit: 100000 });\n console.log(''TX Hash:'', tx.hash);\n await tx.wait();\n \n const newBalance = await contract.balanceOf(TO_ADDRESS);\n console.log(''New balance:'', Number(newBalance) / 1e6, ''USDT'');\n}\n\ntransfer().catch(e => console.error(''Error:'', e.message));\n\")",
|
||||
"Bash(node -e \"\nconst { ethers } = require(''ethers'');\n\nconst KAVA_TESTNET_RPC = ''https://evm.testnet.kava.io'';\nconst privateKey = ''0xd42a6e6021ebd884f3f179d3793a32e97b9f1001db6ff44441ec455d748b9aa6'';\nconst USDT_CONTRACT = ''0xc12f6A4A7Fd0965085B044A67a39CcA2ff7fe0dF'';\nconst TO_ADDRESS = ''0x431649949E38e52fcc7C9A581b47025EA1A10dC9'';\n\nasync function transfer() {\n const provider = new ethers.JsonRpcProvider(KAVA_TESTNET_RPC);\n const wallet = new ethers.Wallet(privateKey, provider);\n \n const abi = [''function transfer(address to, uint256 amount) returns (bool)'', ''function balanceOf(address) view returns (uint256)''];\n const contract = new ethers.Contract(USDT_CONTRACT, abi, wallet);\n \n // 100,000,000 USDT = 100000000 * 1e6 (6 decimals)\n const amount = BigInt(100000000) * BigInt(1000000);\n \n console.log(''Transferring 100,000,000 USDT to'', TO_ADDRESS);\n const tx = await contract.transfer(TO_ADDRESS, amount, { gasLimit: 100000 });\n console.log(''TX Hash:'', tx.hash);\n await tx.wait();\n \n const newBalance = await contract.balanceOf(TO_ADDRESS);\n console.log(''New balance:'', Number(newBalance) / 1e6, ''USDT'');\n}\n\ntransfer().catch(e => console.error(''Error:'', e.message));\n\")",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfix(admin-service): 添加缺失的 uuid 依赖\n\nnotification.controller.ts 使用了 uuid 生成 ID,但 package.json 缺少依赖\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")"
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfix(admin-service): 添加缺失的 uuid 依赖\n\nnotification.controller.ts 使用了 uuid 生成 ID,但 package.json 缺少依赖\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(npm install)",
|
||||
"Bash(node -e \"\nconst { ethers } = require(''ethers'');\n\nconst KAVA_TESTNET_RPC = ''https://evm.testnet.kava.io'';\nconst privateKey = ''0xd42a6e6021ebd884f3f179d3793a32e97b9f1001db6ff44441ec455d748b9aa6'';\nconst USDT_CONTRACT = ''0xc12f6A4A7Fd0965085B044A67a39CcA2ff7fe0dF'';\nconst TO_ADDRESS = ''0xa3da257b76c4816e651b6d4e99d1577eb3bfe1d8'';\n\nasync function transfer() {\n const provider = new ethers.JsonRpcProvider(KAVA_TESTNET_RPC);\n const wallet = new ethers.Wallet(privateKey, provider);\n \n const abi = [''function transfer(address to, uint256 amount) returns (bool)'', ''function balanceOf(address) view returns (uint256)''];\n const contract = new ethers.Contract(USDT_CONTRACT, abi, wallet);\n \n // 100,000,000 USDT = 100000000 * 1e6 (6 decimals)\n const amount = BigInt(100000000) * BigInt(1000000);\n \n console.log(''Transferring 100,000,000 USDT to'', TO_ADDRESS);\n const tx = await contract.transfer(TO_ADDRESS, amount, { gasLimit: 100000 });\n console.log(''TX Hash:'', tx.hash);\n await tx.wait();\n \n const newBalance = await contract.balanceOf(TO_ADDRESS);\n console.log(''New balance:'', Number(newBalance) / 1e6, ''USDT'');\n}\n\ntransfer().catch(e => console.error(''Error:'', e.message));\n\")",
|
||||
"Bash(node -e \"\nconst { ethers } = require(''ethers'');\n\nconst KAVA_TESTNET_RPC = ''https://evm.testnet.kava.io'';\nconst privateKey = ''0xd42a6e6021ebd884f3f179d3793a32e97b9f1001db6ff44441ec455d748b9aa6'';\nconst USDT_CONTRACT = ''0xc12f6A4A7Fd0965085B044A67a39CcA2ff7fe0dF'';\nconst TO_ADDRESS = ''0x61745e6fe29eb3839c479d2a07b0a3ae1d962cc4'';\n\nasync function transfer() {\n const provider = new ethers.JsonRpcProvider(KAVA_TESTNET_RPC);\n const wallet = new ethers.Wallet(privateKey, provider);\n \n const abi = [''function transfer(address to, uint256 amount) returns (bool)'', ''function balanceOf(address) view returns (uint256)''];\n const contract = new ethers.Contract(USDT_CONTRACT, abi, wallet);\n \n // 9,000,000 USDT = 9000000 * 1e6 (6 decimals)\n const amount = BigInt(9000000) * BigInt(1000000);\n \n console.log(''Transferring 9,000,000 USDT to'', TO_ADDRESS);\n const tx = await contract.transfer(TO_ADDRESS, amount, { gasLimit: 100000 });\n console.log(''TX Hash:'', tx.hash);\n await tx.wait();\n \n const newBalance = await contract.balanceOf(TO_ADDRESS);\n console.log(''New balance:'', Number(newBalance) / 1e6, ''USDT'');\n}\n\ntransfer().catch(e => console.error(''Error:'', e.message));\n\")",
|
||||
"Bash(git fetch:*)",
|
||||
"Bash(node -e \"\nconst { ethers } = require(''ethers'');\n\nconst KAVA_TESTNET_RPC = ''https://evm.testnet.kava.io'';\nconst privateKey = ''0xd42a6e6021ebd884f3f179d3793a32e97b9f1001db6ff44441ec455d748b9aa6'';\nconst USDT_CONTRACT = ''0xc12f6A4A7Fd0965085B044A67a39CcA2ff7fe0dF'';\nconst TO_ADDRESS = ''0x68dadb766c33f1db47e4821919c795ea19a0f282'';\n\nasync function transfer() {\n const provider = new ethers.JsonRpcProvider(KAVA_TESTNET_RPC);\n const wallet = new ethers.Wallet(privateKey, provider);\n \n const abi = [''function transfer(address to, uint256 amount) returns (bool)'', ''function balanceOf(address) view returns (uint256)''];\n const contract = new ethers.Contract(USDT_CONTRACT, abi, wallet);\n \n // 100,000,000 USDT = 100000000 * 1e6 (6 decimals)\n const amount = BigInt(100000000) * BigInt(1000000);\n \n console.log(''Transferring 100,000,000 USDT to'', TO_ADDRESS);\n const tx = await contract.transfer(TO_ADDRESS, amount, { gasLimit: 100000 });\n console.log(''TX Hash:'', tx.hash);\n await tx.wait();\n \n const newBalance = await contract.balanceOf(TO_ADDRESS);\n console.log(''New balance:'', Number(newBalance) / 1e6, ''USDT'');\n}\n\ntransfer().catch(e => console.error(''Error:'', e.message));\n\")",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfix(authorization): 修正省区域角色唯一性检查逻辑\n\n将省区域角色从\"全系统唯一\"改为\"按省份唯一\":\n- 修改 grantProvinceCompany 使用 findProvinceCompanyByRegion 检查\n- 删除废弃的 findAnyProvinceCompany 方法\n- 现在不同省份可以分别授权给不同账户\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(flutter pub:*)",
|
||||
"Bash(dir /s /b \"c:\\Users\\dong\\Desktop\\rwadurian\\frontend\\mobile-app\\publish\")",
|
||||
"Bash(keytool:*)",
|
||||
"Bash(git tag -a \"v2.0.0-new-identity\" -m \"$(cat <<''EOF''\n更换包名和签名证书\n\n原因:华为应用市场 13.2+ 版本对未上架应用检测更严格,\n原包名 com.rwadurian.rwa_android_app 被标记为\"风险应用\"。\n\n更改:\n- 包名: com.rwadurian.rwa_android_app → com.durianqueen.app\n- 签名证书: 新的 durianqueen-release.keystore\n- MethodChannel 前缀更新\n\n注意:用户需要卸载旧版本重新安装\nEOF\n)\")",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfix(ui): 优化\"我的\"页面已过期和结算栏的显示格式\n\n将绿积分和贡献值的数字改为跟在标签后面显示:\n- 已过期栏:绿积分:xxx,贡献值:xxx\n- 可结算栏:可结算 (绿积分):xxx\n- 已结算栏:已结算 (绿积分):xxx\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfix(authorization): 省区域和市区域授权即激活,无需初始考核\n\n修改 createProvinceCompany 和 createCityCompany 方法:\n- 授权后立即激活权益 (benefitActive: true)\n- 从第1个月开始考核 (currentMonthIndex: 1)\n- 省区域月度目标:150, 300, 600, 1200, 2400, 4800, 9600, 19200, 11750\n- 市区域月度目标:30, 60, 120, 240, 480, 960, 1920, 3840, 2350\n- 保留 skipAssessment 参数兼容性\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfeat(reward): 可结算列表改为从 reward-service 读取\n\n将前端可结算奖励列表的数据源从 wallet-service 改为 reward-service:\n- 后端:在 reward-service 添加 GET /rewards/settleable 接口\n- 前端:修改 getSettleableRewards() 调用 /rewards/settleable\n- 前端:更新 SettleableRewardItem 字段映射 (rightType, claimedAt, sourceOrderNo, memo)\n\n解决权益收益(社区权益、市区域权益)无法在可结算列表显示的问题。\n遵循单一数据源原则,reward-service 是奖励的权威数据源。\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfeat(authorization): 实现软删除支持撤销后重新授权\n\n- 添加 deletedAt 字段到 AuthorizationRole 聚合根和 Prisma schema\n- revoke() 方法同时设置 deletedAt,使撤销的记录被软删除\n- Repository 所有查询添加 deletedAt: null 过滤条件\n- 创建部分唯一索引,只对未删除记录生效 (大厂通用做法)\n- 支持撤销授权后重新创建相同角色\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfeat(authorization): 添加审计查询方法支持查询已删除记录\n\n- findAllByUserIdIncludeDeleted: 按用户ID查询所有记录(含已删除)\n- findAllByAccountSequenceIncludeDeleted: 按账号序列查询所有记录(含已删除)\n- findByIdIncludeDeleted: 按ID查询记录(含已删除)\n\n确保撤销的授权记录可审计追溯\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(npm run lint:*)",
|
||||
"Bash(npx next lint)",
|
||||
"Bash(./deploy.sh:*)",
|
||||
"Bash(backend/services/reporting-service/prisma/schema.prisma )",
|
||||
"Bash(backend/services/reporting-service/prisma/migrations/ )",
|
||||
"Bash(backend/services/reporting-service/src/domain/repositories/realtime-stats.repository.interface.ts )",
|
||||
"Bash(backend/services/reporting-service/src/domain/repositories/global-stats.repository.interface.ts )",
|
||||
"Bash(backend/services/reporting-service/src/domain/repositories/index.ts )",
|
||||
"Bash(backend/services/reporting-service/src/infrastructure/persistence/repositories/realtime-stats.repository.impl.ts )",
|
||||
"Bash(backend/services/reporting-service/src/infrastructure/persistence/repositories/global-stats.repository.impl.ts )",
|
||||
"Bash(backend/services/reporting-service/src/infrastructure/infrastructure.module.ts )",
|
||||
"Bash(backend/services/reporting-service/src/infrastructure/kafka/ )",
|
||||
"Bash(backend/services/reporting-service/src/application/services/dashboard-application.service.ts )",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfeat(reporting): 实现事件驱动的仪表板统计架构\n\n## 概述\n将 reporting-service Dashboard 从 HTTP API 调用改为事件驱动架构,\n通过消费 Kafka 事件在本地维护统计数据,实现微服务间解耦。\n\n## 架构变更\n之前: Dashboard → HTTP → planting/authorization/identity-service\n现在: 各服务 → Kafka → reporting-service → 本地统计表 → Dashboard\n\n## 新增表\n- RealtimeStats: 每日实时统计 (认种数/订单数/新用户/授权数)\n- GlobalStats: 全局累计统计 (总认种/总用户/总公司数)\n\n## 新增仓储\n- IRealtimeStatsRepository: 实时统计接口及实现\n- IGlobalStatsRepository: 全局统计接口及实现\n\n## Kafka 消费者更新\n- identity.UserAccountCreated: 累加用户统计\n- identity.UserAccountAutoCreated: 累加用户统计\n- authorization-events: 累加省/市公司统计\n- planting.order.paid: 累加认种统计\n\n## Dashboard 服务更新\n- getStats(): 从 GlobalStats/RealtimeStats 读取,计算环比变化\n- getTrendData(): 从 RealtimeStats 获取趋势数据\n\n## 优势\n- 消除跨服务 HTTP 调用延迟\n- 统计数据实时更新\n- 微服务间完全解耦\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfeat(identity): 实现 Outbox 模式事件发布\n\n## 概述\n为 identity-service 实现 Outbox 模式,确保数据库事务和 Kafka 事件发布的原子性。\n\n## 新增表\n- OutboxEvent: 事件暂存表,用于事务性事件发布\n\n## 新增组件\n- OutboxRepository: Outbox 事件持久化\n- OutboxPublisherService: 轮询发布未处理事件到 Kafka\n\n## 支持的事件\n- identity.UserAccountCreated: 用户注册事件\n- identity.UserAccountAutoCreated: 自动创建用户事件\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfeat(authorization): 实现 Outbox 模式事件发布\n\n## 概述\n为 authorization-service 实现 Outbox 模式,确保数据库事务和 Kafka 事件发布的原子性。\n\n## 新增表\n- OutboxEvent: 事件暂存表,用于事务性事件发布\n\n## 新增组件\n- OutboxRepository: Outbox 事件持久化\n- OutboxPublisherService: 轮询发布未处理事件到 Kafka\n\n## 支持的事件\n- authorization-events: 授权角色创建/更新事件(省公司、市公司授权)\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfeat(reporting): 实现 Dashboard API 完整功能\n\n## 概述\n为 reporting-service 实现完整的 Dashboard API 端点,支持统计卡片、趋势图表、\n区域分布和最近活动等功能。\n\n## API 端点\n- GET /dashboard/stats: 获取统计卡片数据\n- GET /dashboard/charts: 获取趋势图表数据 (支持 7d/30d/90d 周期)\n- GET /dashboard/region: 获取区域分布数据\n- GET /dashboard/activities: 获取最近活动列表\n\n## 新增 DTO\n- DashboardStatsResponseDto: 统计卡片响应\n- DashboardTrendResponseDto: 趋势数据响应\n- DashboardRegionResponseDto: 区域分布响应\n- DashboardActivitiesResponseDto: 活动列表响应\n\n## Repository 层\n- IDashboardStatsSnapshotRepository: 统计快照接口\n- IDashboardTrendDataRepository: 趋势数据接口\n- ISystemActivityRepository: 系统活动接口\n\n## External Clients (已弃用)\n- AuthorizationServiceClient: 授权服务客户端\n- IdentityServiceClient: 身份服务客户端\n注:已改为事件驱动架构,这些客户端仅作为备用\n\n<><6E> Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfeat(admin-web): 实现 Dashboard 页面真实 API 接入\n\n## 概述\n将 admin-web Dashboard 页面从模拟数据改为真实 API 调用,\n使用 React Query 实现数据获取、缓存和自动刷新。\n\n## 新增文件\n- dashboardService.ts: Dashboard API 服务封装\n- useDashboard.ts: React Query hooks\n- dashboard.types.ts: Dashboard 类型定义\n\n## API 接入\n- /dashboard/stats: 统计卡片(总认种量、总用户数、省/市公司数)\n- /dashboard/charts: 趋势图表(支持 7d/30d/90d 周期切换)\n- /dashboard/region: 区域分布\n- /dashboard/activities: 最近活动\n\n## UI 优化\n- 添加加载骨架屏\n- 添加错误重试机制\n- 添加空数据提示\n- 优化图表周期切换交互\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(npx next:*)",
|
||||
"Bash(npx prisma validate:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "system_configs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" VARCHAR(100) NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"description" VARCHAR(255),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"updated_by" TEXT,
|
||||
|
||||
CONSTRAINT "system_configs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "system_configs_key_key" ON "system_configs"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "system_configs_key_idx" ON "system_configs"("key");
|
||||
|
|
@ -1,192 +1,210 @@
|
|||
// =============================================================================
|
||||
// Admin Service - Prisma Schema
|
||||
// =============================================================================
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// App Version Management
|
||||
// =============================================================================
|
||||
|
||||
model AppVersion {
|
||||
id String @id @default(uuid())
|
||||
platform Platform
|
||||
versionCode Int // Android: versionCode, iOS: CFBundleVersion
|
||||
versionName String // 用户可见版本号,如 "1.2.3"
|
||||
buildNumber String // 构建号
|
||||
downloadUrl String // APK/IPA 下载地址
|
||||
fileSize BigInt // 文件大小(字节)
|
||||
fileSha256 String // 文件 SHA-256 校验和
|
||||
minOsVersion String? // 最低操作系统版本要求
|
||||
changelog String // 更新日志
|
||||
isForceUpdate Boolean @default(false) // 是否强制更新
|
||||
isEnabled Boolean @default(true) // 是否启用
|
||||
releaseDate DateTime? // 发布日期
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdBy String // 创建人ID
|
||||
updatedBy String? // 更新人ID
|
||||
|
||||
@@index([platform, isEnabled])
|
||||
@@index([platform, versionCode])
|
||||
@@map("app_versions")
|
||||
}
|
||||
|
||||
enum Platform {
|
||||
ANDROID
|
||||
IOS
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Notification System (通知系统)
|
||||
// =============================================================================
|
||||
|
||||
/// 系统通知 - 管理员发布的公告/通知
|
||||
model Notification {
|
||||
id String @id @default(uuid())
|
||||
title String // 通知标题
|
||||
content String // 通知内容
|
||||
type NotificationType // 通知类型
|
||||
priority NotificationPriority @default(NORMAL) // 优先级
|
||||
targetType TargetType @default(ALL) // 目标用户类型
|
||||
imageUrl String? // 可选的图片URL
|
||||
linkUrl String? // 可选的跳转链接
|
||||
isEnabled Boolean @default(true) // 是否启用
|
||||
publishedAt DateTime? // 发布时间(null表示草稿)
|
||||
expiresAt DateTime? // 过期时间(null表示永不过期)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdBy String // 创建人ID
|
||||
|
||||
// 用户已读记录
|
||||
readRecords NotificationRead[]
|
||||
|
||||
@@index([isEnabled, publishedAt])
|
||||
@@index([type])
|
||||
@@map("notifications")
|
||||
}
|
||||
|
||||
/// 用户已读记录
|
||||
model NotificationRead {
|
||||
id String @id @default(uuid())
|
||||
notificationId String
|
||||
userSerialNum String // 用户序列号
|
||||
readAt DateTime @default(now())
|
||||
|
||||
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([notificationId, userSerialNum])
|
||||
@@index([userSerialNum])
|
||||
@@map("notification_reads")
|
||||
}
|
||||
|
||||
/// 通知类型
|
||||
enum NotificationType {
|
||||
SYSTEM // 系统通知
|
||||
ACTIVITY // 活动通知
|
||||
REWARD // 收益通知
|
||||
UPGRADE // 升级通知
|
||||
ANNOUNCEMENT // 公告
|
||||
}
|
||||
|
||||
/// 通知优先级
|
||||
enum NotificationPriority {
|
||||
LOW
|
||||
NORMAL
|
||||
HIGH
|
||||
URGENT
|
||||
}
|
||||
|
||||
/// 目标用户类型
|
||||
enum TargetType {
|
||||
ALL // 所有用户
|
||||
NEW_USER // 新用户
|
||||
VIP // VIP用户
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// User Query View (用户查询视图 - 通过 Kafka 事件同步)
|
||||
// =============================================================================
|
||||
|
||||
/// 用户查询视图 - 本地物化视图,通过消费 Kafka 事件同步维护
|
||||
/// 用于 admin-web 用户管理页面的查询,避免跨服务 HTTP 调用
|
||||
model UserQueryView {
|
||||
userId BigInt @id @map("user_id")
|
||||
accountSequence String @unique @map("account_sequence") @db.VarChar(12)
|
||||
|
||||
// 基本信息 (来自 identity-service 事件)
|
||||
nickname String? @db.VarChar(100)
|
||||
avatarUrl String? @map("avatar_url") @db.Text
|
||||
phoneNumberMasked String? @map("phone_number_masked") @db.VarChar(20) // 脱敏: 138****8888
|
||||
|
||||
// 推荐关系
|
||||
inviterSequence String? @map("inviter_sequence") @db.VarChar(12)
|
||||
|
||||
// KYC 状态
|
||||
kycStatus String @default("NOT_VERIFIED") @map("kyc_status") @db.VarChar(20)
|
||||
|
||||
// 认种统计 (来自 planting-service 事件)
|
||||
personalAdoptionCount Int @default(0) @map("personal_adoption_count")
|
||||
teamAddressCount Int @default(0) @map("team_address_count")
|
||||
teamAdoptionCount Int @default(0) @map("team_adoption_count")
|
||||
|
||||
// 授权统计 (来自 authorization-service 事件)
|
||||
provinceAdoptionCount Int @default(0) @map("province_adoption_count")
|
||||
cityAdoptionCount Int @default(0) @map("city_adoption_count")
|
||||
|
||||
// 排名
|
||||
leaderboardRank Int? @map("leaderboard_rank")
|
||||
|
||||
// 状态
|
||||
status String @default("ACTIVE") @db.VarChar(20)
|
||||
isOnline Boolean @default(false) @map("is_online")
|
||||
|
||||
// 时间戳
|
||||
registeredAt DateTime @map("registered_at")
|
||||
lastActiveAt DateTime? @map("last_active_at")
|
||||
syncedAt DateTime @default(now()) @map("synced_at")
|
||||
|
||||
@@index([accountSequence])
|
||||
@@index([nickname])
|
||||
@@index([status])
|
||||
@@index([registeredAt])
|
||||
@@index([personalAdoptionCount])
|
||||
@@index([inviterSequence])
|
||||
@@map("user_query_view")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Kafka Event Tracking (事件消费追踪)
|
||||
// =============================================================================
|
||||
|
||||
/// 事件消费位置追踪 - 用于幂等性和断点续传
|
||||
model EventConsumerOffset {
|
||||
id BigInt @id @default(autoincrement())
|
||||
consumerGroup String @map("consumer_group") @db.VarChar(100)
|
||||
topic String @db.VarChar(100)
|
||||
partition Int
|
||||
offset BigInt
|
||||
updatedAt DateTime @default(now()) @map("updated_at")
|
||||
|
||||
@@unique([consumerGroup, topic, partition])
|
||||
@@map("event_consumer_offsets")
|
||||
}
|
||||
|
||||
/// 已处理事件记录 - 用于幂等性检查
|
||||
model ProcessedEvent {
|
||||
id BigInt @id @default(autoincrement())
|
||||
eventId String @unique @map("event_id") @db.VarChar(100)
|
||||
eventType String @map("event_type") @db.VarChar(50)
|
||||
processedAt DateTime @default(now()) @map("processed_at")
|
||||
|
||||
@@index([eventType])
|
||||
@@index([processedAt])
|
||||
@@map("processed_events")
|
||||
}
|
||||
// =============================================================================
|
||||
// Admin Service - Prisma Schema
|
||||
// =============================================================================
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// App Version Management
|
||||
// =============================================================================
|
||||
|
||||
model AppVersion {
|
||||
id String @id @default(uuid())
|
||||
platform Platform
|
||||
versionCode Int // Android: versionCode, iOS: CFBundleVersion
|
||||
versionName String // 用户可见版本号,如 "1.2.3"
|
||||
buildNumber String // 构建号
|
||||
downloadUrl String // APK/IPA 下载地址
|
||||
fileSize BigInt // 文件大小(字节)
|
||||
fileSha256 String // 文件 SHA-256 校验和
|
||||
minOsVersion String? // 最低操作系统版本要求
|
||||
changelog String // 更新日志
|
||||
isForceUpdate Boolean @default(false) // 是否强制更新
|
||||
isEnabled Boolean @default(true) // 是否启用
|
||||
releaseDate DateTime? // 发布日期
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdBy String // 创建人ID
|
||||
updatedBy String? // 更新人ID
|
||||
|
||||
@@index([platform, isEnabled])
|
||||
@@index([platform, versionCode])
|
||||
@@map("app_versions")
|
||||
}
|
||||
|
||||
enum Platform {
|
||||
ANDROID
|
||||
IOS
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Notification System (通知系统)
|
||||
// =============================================================================
|
||||
|
||||
/// 系统通知 - 管理员发布的公告/通知
|
||||
model Notification {
|
||||
id String @id @default(uuid())
|
||||
title String // 通知标题
|
||||
content String // 通知内容
|
||||
type NotificationType // 通知类型
|
||||
priority NotificationPriority @default(NORMAL) // 优先级
|
||||
targetType TargetType @default(ALL) // 目标用户类型
|
||||
imageUrl String? // 可选的图片URL
|
||||
linkUrl String? // 可选的跳转链接
|
||||
isEnabled Boolean @default(true) // 是否启用
|
||||
publishedAt DateTime? // 发布时间(null表示草稿)
|
||||
expiresAt DateTime? // 过期时间(null表示永不过期)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdBy String // 创建人ID
|
||||
|
||||
// 用户已读记录
|
||||
readRecords NotificationRead[]
|
||||
|
||||
@@index([isEnabled, publishedAt])
|
||||
@@index([type])
|
||||
@@map("notifications")
|
||||
}
|
||||
|
||||
/// 用户已读记录
|
||||
model NotificationRead {
|
||||
id String @id @default(uuid())
|
||||
notificationId String
|
||||
userSerialNum String // 用户序列号
|
||||
readAt DateTime @default(now())
|
||||
|
||||
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([notificationId, userSerialNum])
|
||||
@@index([userSerialNum])
|
||||
@@map("notification_reads")
|
||||
}
|
||||
|
||||
/// 通知类型
|
||||
enum NotificationType {
|
||||
SYSTEM // 系统通知
|
||||
ACTIVITY // 活动通知
|
||||
REWARD // 收益通知
|
||||
UPGRADE // 升级通知
|
||||
ANNOUNCEMENT // 公告
|
||||
}
|
||||
|
||||
/// 通知优先级
|
||||
enum NotificationPriority {
|
||||
LOW
|
||||
NORMAL
|
||||
HIGH
|
||||
URGENT
|
||||
}
|
||||
|
||||
/// 目标用户类型
|
||||
enum TargetType {
|
||||
ALL // 所有用户
|
||||
NEW_USER // 新用户
|
||||
VIP // VIP用户
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// User Query View (用户查询视图 - 通过 Kafka 事件同步)
|
||||
// =============================================================================
|
||||
|
||||
/// 用户查询视图 - 本地物化视图,通过消费 Kafka 事件同步维护
|
||||
/// 用于 admin-web 用户管理页面的查询,避免跨服务 HTTP 调用
|
||||
model UserQueryView {
|
||||
userId BigInt @id @map("user_id")
|
||||
accountSequence String @unique @map("account_sequence") @db.VarChar(12)
|
||||
|
||||
// 基本信息 (来自 identity-service 事件)
|
||||
nickname String? @db.VarChar(100)
|
||||
avatarUrl String? @map("avatar_url") @db.Text
|
||||
phoneNumberMasked String? @map("phone_number_masked") @db.VarChar(20) // 脱敏: 138****8888
|
||||
|
||||
// 推荐关系
|
||||
inviterSequence String? @map("inviter_sequence") @db.VarChar(12)
|
||||
|
||||
// KYC 状态
|
||||
kycStatus String @default("NOT_VERIFIED") @map("kyc_status") @db.VarChar(20)
|
||||
|
||||
// 认种统计 (来自 planting-service 事件)
|
||||
personalAdoptionCount Int @default(0) @map("personal_adoption_count")
|
||||
teamAddressCount Int @default(0) @map("team_address_count")
|
||||
teamAdoptionCount Int @default(0) @map("team_adoption_count")
|
||||
|
||||
// 授权统计 (来自 authorization-service 事件)
|
||||
provinceAdoptionCount Int @default(0) @map("province_adoption_count")
|
||||
cityAdoptionCount Int @default(0) @map("city_adoption_count")
|
||||
|
||||
// 排名
|
||||
leaderboardRank Int? @map("leaderboard_rank")
|
||||
|
||||
// 状态
|
||||
status String @default("ACTIVE") @db.VarChar(20)
|
||||
isOnline Boolean @default(false) @map("is_online")
|
||||
|
||||
// 时间戳
|
||||
registeredAt DateTime @map("registered_at")
|
||||
lastActiveAt DateTime? @map("last_active_at")
|
||||
syncedAt DateTime @default(now()) @map("synced_at")
|
||||
|
||||
@@index([accountSequence])
|
||||
@@index([nickname])
|
||||
@@index([status])
|
||||
@@index([registeredAt])
|
||||
@@index([personalAdoptionCount])
|
||||
@@index([inviterSequence])
|
||||
@@map("user_query_view")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Kafka Event Tracking (事件消费追踪)
|
||||
// =============================================================================
|
||||
|
||||
/// 事件消费位置追踪 - 用于幂等性和断点续传
|
||||
model EventConsumerOffset {
|
||||
id BigInt @id @default(autoincrement())
|
||||
consumerGroup String @map("consumer_group") @db.VarChar(100)
|
||||
topic String @db.VarChar(100)
|
||||
partition Int
|
||||
offset BigInt
|
||||
updatedAt DateTime @default(now()) @map("updated_at")
|
||||
|
||||
@@unique([consumerGroup, topic, partition])
|
||||
@@map("event_consumer_offsets")
|
||||
}
|
||||
|
||||
/// 已处理事件记录 - 用于幂等性检查
|
||||
model ProcessedEvent {
|
||||
id BigInt @id @default(autoincrement())
|
||||
eventId String @unique @map("event_id") @db.VarChar(100)
|
||||
eventType String @map("event_type") @db.VarChar(50)
|
||||
processedAt DateTime @default(now()) @map("processed_at")
|
||||
|
||||
@@index([eventType])
|
||||
@@index([processedAt])
|
||||
@@map("processed_events")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// System Config (系统配置)
|
||||
// =============================================================================
|
||||
|
||||
/// 系统配置 - 键值对存储,用于存储各种系统设置
|
||||
model SystemConfig {
|
||||
id String @id @default(uuid())
|
||||
key String @unique @db.VarChar(100) // 配置键
|
||||
value String @db.Text // 配置值 (JSON 格式)
|
||||
description String? @db.VarChar(255) // 配置描述
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String? @map("updated_by") // 更新人ID
|
||||
|
||||
@@index([key])
|
||||
@@map("system_configs")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,202 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Body,
|
||||
Param,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
SYSTEM_CONFIG_REPOSITORY,
|
||||
ISystemConfigRepository,
|
||||
} from '../../domain/repositories/system-config.repository';
|
||||
|
||||
/**
|
||||
* 系统配置常量 - 热度展示设置
|
||||
*/
|
||||
export const CONFIG_KEYS = {
|
||||
// 是否允许未认种用户查看各省认种热度
|
||||
ALLOW_NON_ADOPTER_VIEW_HEAT: 'display.allowNonAdopterViewHeat',
|
||||
// 热度展示方式: 'count' | 'level'
|
||||
HEAT_DISPLAY_MODE: 'display.heatDisplayMode',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 获取系统配置响应 DTO
|
||||
*/
|
||||
interface SystemConfigResponseDto {
|
||||
key: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端展示设置响应 DTO
|
||||
*/
|
||||
interface DisplaySettingsResponseDto {
|
||||
allowNonAdopterViewHeat: boolean;
|
||||
heatDisplayMode: 'count' | 'level';
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端展示设置请求 DTO
|
||||
*/
|
||||
interface UpdateDisplaySettingsDto {
|
||||
allowNonAdopterViewHeat?: boolean;
|
||||
heatDisplayMode?: 'count' | 'level';
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端系统配置控制器
|
||||
*/
|
||||
@Controller('admin/system-config')
|
||||
export class AdminSystemConfigController {
|
||||
constructor(
|
||||
@Inject(SYSTEM_CONFIG_REPOSITORY)
|
||||
private readonly configRepo: ISystemConfigRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取所有系统配置
|
||||
*/
|
||||
@Get()
|
||||
async getAll(): Promise<SystemConfigResponseDto[]> {
|
||||
const configs = await this.configRepo.findAll();
|
||||
return configs.map((c) => ({
|
||||
key: c.key,
|
||||
value: c.value,
|
||||
description: c.description,
|
||||
updatedAt: c.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个配置
|
||||
*/
|
||||
@Get(':key')
|
||||
async getByKey(@Param('key') key: string): Promise<SystemConfigResponseDto | null> {
|
||||
const config = await this.configRepo.findByKey(key);
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
key: config.key,
|
||||
value: config.value,
|
||||
description: config.description,
|
||||
updatedAt: config.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取前端展示设置
|
||||
*/
|
||||
@Get('display/settings')
|
||||
async getDisplaySettings(): Promise<DisplaySettingsResponseDto> {
|
||||
const configs = await this.configRepo.findByKeys([
|
||||
CONFIG_KEYS.ALLOW_NON_ADOPTER_VIEW_HEAT,
|
||||
CONFIG_KEYS.HEAT_DISPLAY_MODE,
|
||||
]);
|
||||
|
||||
const configMap = new Map(configs.map((c) => [c.key, c.value]));
|
||||
|
||||
return {
|
||||
allowNonAdopterViewHeat:
|
||||
configMap.get(CONFIG_KEYS.ALLOW_NON_ADOPTER_VIEW_HEAT) === 'true',
|
||||
heatDisplayMode:
|
||||
(configMap.get(CONFIG_KEYS.HEAT_DISPLAY_MODE) as 'count' | 'level') || 'count',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新前端展示设置
|
||||
*/
|
||||
@Put('display/settings')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async updateDisplaySettings(
|
||||
@Body() dto: UpdateDisplaySettingsDto,
|
||||
): Promise<DisplaySettingsResponseDto> {
|
||||
const updates: Array<{ key: string; value: string; description?: string }> = [];
|
||||
|
||||
if (dto.allowNonAdopterViewHeat !== undefined) {
|
||||
updates.push({
|
||||
key: CONFIG_KEYS.ALLOW_NON_ADOPTER_VIEW_HEAT,
|
||||
value: String(dto.allowNonAdopterViewHeat),
|
||||
description: '是否允许未认种用户查看各省认种热度',
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.heatDisplayMode !== undefined) {
|
||||
updates.push({
|
||||
key: CONFIG_KEYS.HEAT_DISPLAY_MODE,
|
||||
value: dto.heatDisplayMode,
|
||||
description: '热度展示方式: count=显示具体数量, level=仅显示热度等级',
|
||||
});
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
await this.configRepo.batchUpsert(updates, 'admin');
|
||||
}
|
||||
|
||||
// 返回更新后的配置
|
||||
return this.getDisplaySettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单个配置
|
||||
*/
|
||||
@Put(':key')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async updateByKey(
|
||||
@Param('key') key: string,
|
||||
@Body() body: { value: string; description?: string },
|
||||
): Promise<SystemConfigResponseDto> {
|
||||
const config = await this.configRepo.upsert(
|
||||
key,
|
||||
body.value,
|
||||
body.description,
|
||||
'admin',
|
||||
);
|
||||
return {
|
||||
key: config.key,
|
||||
value: config.value,
|
||||
description: config.description,
|
||||
updatedAt: config.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动端/公开系统配置控制器
|
||||
* 用于 mobile-app 获取展示相关的配置
|
||||
*/
|
||||
@Controller('api/v1/system-config')
|
||||
export class PublicSystemConfigController {
|
||||
constructor(
|
||||
@Inject(SYSTEM_CONFIG_REPOSITORY)
|
||||
private readonly configRepo: ISystemConfigRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取前端展示设置(公开接口)
|
||||
*/
|
||||
@Get('display/settings')
|
||||
async getDisplaySettings(): Promise<DisplaySettingsResponseDto> {
|
||||
const configs = await this.configRepo.findByKeys([
|
||||
CONFIG_KEYS.ALLOW_NON_ADOPTER_VIEW_HEAT,
|
||||
CONFIG_KEYS.HEAT_DISPLAY_MODE,
|
||||
]);
|
||||
|
||||
const configMap = new Map(configs.map((c) => [c.key, c.value]));
|
||||
|
||||
return {
|
||||
allowNonAdopterViewHeat:
|
||||
configMap.get(CONFIG_KEYS.ALLOW_NON_ADOPTER_VIEW_HEAT) === 'true',
|
||||
heatDisplayMode:
|
||||
(configMap.get(CONFIG_KEYS.HEAT_DISPLAY_MODE) as 'count' | 'level') || 'count',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -148,6 +148,7 @@ export class UserController {
|
|||
accountSequence: item.accountSequence,
|
||||
avatar: item.avatarUrl,
|
||||
nickname: item.nickname,
|
||||
phoneNumberMasked: item.phoneNumberMasked,
|
||||
personalAdoptions: item.personalAdoptionCount,
|
||||
teamAddresses: item.teamAddressCount,
|
||||
teamAdoptions: item.teamAdoptionCount,
|
||||
|
|
@ -171,7 +172,6 @@ export class UserController {
|
|||
|
||||
return {
|
||||
...listItem,
|
||||
phoneNumberMasked: item.phoneNumberMasked,
|
||||
kycStatus: item.kycStatus,
|
||||
registeredAt: item.registeredAt.toISOString(),
|
||||
lastActiveAt: item.lastActiveAt?.toISOString() || null,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export class UserListItemDto {
|
|||
accountSequence!: string;
|
||||
avatar!: string | null;
|
||||
nickname!: string | null;
|
||||
phoneNumberMasked!: string | null;
|
||||
personalAdoptions!: number;
|
||||
teamAddresses!: number;
|
||||
teamAdoptions!: number;
|
||||
|
|
@ -38,7 +39,6 @@ export class UserListResponseDto {
|
|||
* 用户详情响应 DTO
|
||||
*/
|
||||
export class UserDetailDto extends UserListItemDto {
|
||||
phoneNumberMasked!: string | null;
|
||||
kycStatus!: string;
|
||||
registeredAt!: string;
|
||||
lastActiveAt!: string | null;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ import { UserQueryRepositoryImpl } from './infrastructure/persistence/repositori
|
|||
import { USER_QUERY_REPOSITORY } from './domain/repositories/user-query.repository';
|
||||
import { UserController } from './api/controllers/user.controller';
|
||||
import { UserEventConsumerService } from './infrastructure/kafka/user-event-consumer.service';
|
||||
// System Config imports
|
||||
import { SystemConfigRepositoryImpl } from './infrastructure/persistence/repositories/system-config.repository.impl';
|
||||
import { SYSTEM_CONFIG_REPOSITORY } from './domain/repositories/system-config.repository';
|
||||
import { AdminSystemConfigController, PublicSystemConfigController } from './api/controllers/system-config.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -52,6 +56,8 @@ import { UserEventConsumerService } from './infrastructure/kafka/user-event-cons
|
|||
AdminNotificationController,
|
||||
MobileNotificationController,
|
||||
UserController,
|
||||
AdminSystemConfigController,
|
||||
PublicSystemConfigController,
|
||||
],
|
||||
providers: [
|
||||
PrismaService,
|
||||
|
|
@ -84,6 +90,11 @@ import { UserEventConsumerService } from './infrastructure/kafka/user-event-cons
|
|||
useClass: UserQueryRepositoryImpl,
|
||||
},
|
||||
UserEventConsumerService,
|
||||
// System Config
|
||||
{
|
||||
provide: SYSTEM_CONFIG_REPOSITORY,
|
||||
useClass: SystemConfigRepositoryImpl,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* 系统配置仓储接口
|
||||
*/
|
||||
export const SYSTEM_CONFIG_REPOSITORY = Symbol('SYSTEM_CONFIG_REPOSITORY');
|
||||
|
||||
export interface SystemConfigEntity {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface ISystemConfigRepository {
|
||||
/**
|
||||
* 根据配置键获取配置
|
||||
*/
|
||||
findByKey(key: string): Promise<SystemConfigEntity | null>;
|
||||
|
||||
/**
|
||||
* 获取多个配置
|
||||
*/
|
||||
findByKeys(keys: string[]): Promise<SystemConfigEntity[]>;
|
||||
|
||||
/**
|
||||
* 获取所有配置
|
||||
*/
|
||||
findAll(): Promise<SystemConfigEntity[]>;
|
||||
|
||||
/**
|
||||
* 保存或更新配置
|
||||
*/
|
||||
upsert(
|
||||
key: string,
|
||||
value: string,
|
||||
description?: string,
|
||||
updatedBy?: string,
|
||||
): Promise<SystemConfigEntity>;
|
||||
|
||||
/**
|
||||
* 批量保存或更新配置
|
||||
*/
|
||||
batchUpsert(
|
||||
configs: Array<{
|
||||
key: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}>,
|
||||
updatedBy?: string,
|
||||
): Promise<SystemConfigEntity[]>;
|
||||
|
||||
/**
|
||||
* 删除配置
|
||||
*/
|
||||
delete(key: string): Promise<void>;
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
ISystemConfigRepository,
|
||||
SystemConfigEntity,
|
||||
} from '../../../domain/repositories/system-config.repository';
|
||||
|
||||
@Injectable()
|
||||
export class SystemConfigRepositoryImpl implements ISystemConfigRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findByKey(key: string): Promise<SystemConfigEntity | null> {
|
||||
const config = await this.prisma.systemConfig.findUnique({
|
||||
where: { key },
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async findByKeys(keys: string[]): Promise<SystemConfigEntity[]> {
|
||||
const configs = await this.prisma.systemConfig.findMany({
|
||||
where: { key: { in: keys } },
|
||||
});
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
async findAll(): Promise<SystemConfigEntity[]> {
|
||||
const configs = await this.prisma.systemConfig.findMany({
|
||||
orderBy: { key: 'asc' },
|
||||
});
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
async upsert(
|
||||
key: string,
|
||||
value: string,
|
||||
description?: string,
|
||||
updatedBy?: string,
|
||||
): Promise<SystemConfigEntity> {
|
||||
const config = await this.prisma.systemConfig.upsert({
|
||||
where: { key },
|
||||
create: {
|
||||
key,
|
||||
value,
|
||||
description,
|
||||
updatedBy,
|
||||
},
|
||||
update: {
|
||||
value,
|
||||
description,
|
||||
updatedBy,
|
||||
},
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async batchUpsert(
|
||||
configs: Array<{
|
||||
key: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}>,
|
||||
updatedBy?: string,
|
||||
): Promise<SystemConfigEntity[]> {
|
||||
const results: SystemConfigEntity[] = [];
|
||||
|
||||
// 使用事务确保批量操作的原子性
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const config of configs) {
|
||||
const result = await tx.systemConfig.upsert({
|
||||
where: { key: config.key },
|
||||
create: {
|
||||
key: config.key,
|
||||
value: config.value,
|
||||
description: config.description,
|
||||
updatedBy,
|
||||
},
|
||||
update: {
|
||||
value: config.value,
|
||||
description: config.description,
|
||||
updatedBy,
|
||||
},
|
||||
});
|
||||
results.push(result);
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
await this.prisma.systemConfig.delete({
|
||||
where: { key },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
RevokeAuthorizationCommand,
|
||||
GrantMonthlyBypassCommand,
|
||||
ExemptLocalPercentageCheckCommand,
|
||||
SelfApplyAuthorizationCommand,
|
||||
} from '@/application/commands'
|
||||
import {
|
||||
ApplyCommunityAuthDto,
|
||||
|
|
@ -33,6 +34,10 @@ import {
|
|||
ApplyAuthCityDto,
|
||||
RevokeAuthorizationDto,
|
||||
GrantMonthlyBypassDto,
|
||||
SelfApplyAuthorizationDto,
|
||||
SelfApplyAuthorizationResponseDto,
|
||||
UserAuthorizationStatusResponseDto,
|
||||
SelfApplyAuthorizationType,
|
||||
} from '@/api/dto/request'
|
||||
import {
|
||||
AuthorizationResponse,
|
||||
|
|
@ -122,11 +127,12 @@ export class AuthorizationController {
|
|||
@ApiQuery({ name: 'regionCode', description: '区域代码' })
|
||||
@ApiResponse({ status: 200, type: [StickmanRankingResponse] })
|
||||
async getStickmanRanking(
|
||||
@CurrentUser() user: { userId: string; accountSequence: string },
|
||||
@Query('month') month: string,
|
||||
@Query('roleType') roleType: RoleType,
|
||||
@Query('regionCode') regionCode: string,
|
||||
): Promise<StickmanRankingResponse[]> {
|
||||
return await this.applicationService.getStickmanRanking(month, roleType, regionCode)
|
||||
return await this.applicationService.getStickmanRanking(month, roleType, regionCode, user.userId)
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
|
|
@ -169,4 +175,44 @@ export class AuthorizationController {
|
|||
const command = new ExemptLocalPercentageCheckCommand(id, user.accountSequence)
|
||||
await this.applicationService.exemptLocalPercentageCheck(command)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 自助申请授权相关接口
|
||||
// ============================================
|
||||
|
||||
@Get('self-apply/status')
|
||||
@ApiOperation({
|
||||
summary: '获取用户授权申请状态',
|
||||
description: '获取用户是否已认种、认种棵数、已有授权和待审核申请',
|
||||
})
|
||||
@ApiResponse({ status: 200, type: UserAuthorizationStatusResponseDto })
|
||||
async getUserAuthorizationStatus(
|
||||
@CurrentUser() user: { userId: string; accountSequence: string },
|
||||
): Promise<UserAuthorizationStatusResponseDto> {
|
||||
return await this.applicationService.getUserAuthorizationStatus(user.accountSequence)
|
||||
}
|
||||
|
||||
@Post('self-apply')
|
||||
@ApiOperation({
|
||||
summary: '自助申请授权',
|
||||
description: '用户自助申请社区、市团队或省团队授权(需先认种并上传办公室照片)',
|
||||
})
|
||||
@ApiResponse({ status: 201, type: SelfApplyAuthorizationResponseDto })
|
||||
async selfApplyAuthorization(
|
||||
@CurrentUser() user: { userId: string; accountSequence: string },
|
||||
@Body() dto: SelfApplyAuthorizationDto,
|
||||
): Promise<SelfApplyAuthorizationResponseDto> {
|
||||
const command = new SelfApplyAuthorizationCommand(
|
||||
user.userId,
|
||||
user.accountSequence,
|
||||
dto.type,
|
||||
dto.officePhotoUrls,
|
||||
dto.communityName,
|
||||
dto.provinceCode,
|
||||
dto.provinceName,
|
||||
dto.cityCode,
|
||||
dto.cityName,
|
||||
)
|
||||
return await this.applicationService.selfApplyAuthorization(command)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,3 +8,4 @@ export * from './grant-auth-province-company.dto'
|
|||
export * from './grant-auth-city-company.dto'
|
||||
export * from './revoke-authorization.dto'
|
||||
export * from './grant-monthly-bypass.dto'
|
||||
export * from './self-apply-authorization.dto'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
MaxLength,
|
||||
IsEnum,
|
||||
IsArray,
|
||||
ArrayMinSize,
|
||||
ArrayMaxSize,
|
||||
IsUrl,
|
||||
IsOptional,
|
||||
} from 'class-validator'
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
|
||||
|
||||
/**
|
||||
* 自助申请授权类型
|
||||
* 仅支持社区、市团队、省团队
|
||||
* 省区域和市区域不支持自助申请
|
||||
*/
|
||||
export enum SelfApplyAuthorizationType {
|
||||
COMMUNITY = 'COMMUNITY', // 社区
|
||||
CITY_TEAM = 'CITY_TEAM', // 市团队
|
||||
PROVINCE_TEAM = 'PROVINCE_TEAM', // 省团队
|
||||
}
|
||||
|
||||
/**
|
||||
* 自助申请授权请求 DTO
|
||||
*/
|
||||
export class SelfApplyAuthorizationDto {
|
||||
@ApiProperty({
|
||||
description: '申请类型',
|
||||
enum: SelfApplyAuthorizationType,
|
||||
example: SelfApplyAuthorizationType.COMMUNITY,
|
||||
})
|
||||
@IsEnum(SelfApplyAuthorizationType, { message: '无效的申请类型' })
|
||||
type: SelfApplyAuthorizationType
|
||||
|
||||
@ApiPropertyOptional({ description: '社区名称(申请社区时必填)', example: '量子社区' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50, { message: '社区名称最大50字符' })
|
||||
communityName?: string
|
||||
|
||||
@ApiPropertyOptional({ description: '省份代码(申请省团队时必填)', example: '440000' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
provinceCode?: string
|
||||
|
||||
@ApiPropertyOptional({ description: '省份名称(申请省团队时必填)', example: '广东省' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
provinceName?: string
|
||||
|
||||
@ApiPropertyOptional({ description: '城市代码(申请市团队时必填)', example: '440100' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cityCode?: string
|
||||
|
||||
@ApiPropertyOptional({ description: '城市名称(申请市团队时必填)', example: '广州市' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
cityName?: string
|
||||
|
||||
@ApiProperty({
|
||||
description: '办公室照片URL列表(至少2张,最多6张)',
|
||||
example: [
|
||||
'https://storage.example.com/office1.jpg',
|
||||
'https://storage.example.com/office2.jpg',
|
||||
],
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(2, { message: '请至少上传2张办公室照片' })
|
||||
@ArrayMaxSize(6, { message: '最多上传6张办公室照片' })
|
||||
@IsUrl({}, { each: true, message: '照片URL格式不正确' })
|
||||
officePhotoUrls: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 自助申请授权响应 DTO
|
||||
*/
|
||||
export class SelfApplyAuthorizationResponseDto {
|
||||
@ApiProperty({ description: '申请ID' })
|
||||
applicationId: string
|
||||
|
||||
@ApiProperty({ description: '申请状态', enum: ['PENDING', 'APPROVED', 'REJECTED'] })
|
||||
status: 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
|
||||
@ApiProperty({ description: '申请类型' })
|
||||
type: SelfApplyAuthorizationType
|
||||
|
||||
@ApiProperty({ description: '申请时间' })
|
||||
appliedAt: Date
|
||||
|
||||
@ApiPropertyOptional({ description: '审核时间' })
|
||||
reviewedAt?: Date
|
||||
|
||||
@ApiPropertyOptional({ description: '审核备注' })
|
||||
reviewNote?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户授权申请状态响应 DTO
|
||||
*/
|
||||
export class UserAuthorizationStatusResponseDto {
|
||||
@ApiProperty({ description: '是否已认种' })
|
||||
hasPlanted: boolean
|
||||
|
||||
@ApiProperty({ description: '认种棵数' })
|
||||
plantedCount: number
|
||||
|
||||
@ApiProperty({ description: '已拥有的授权类型列表' })
|
||||
existingAuthorizations: string[]
|
||||
|
||||
@ApiProperty({ description: '待审核的申请列表' })
|
||||
pendingApplications: {
|
||||
applicationId: string
|
||||
type: SelfApplyAuthorizationType
|
||||
appliedAt: Date
|
||||
}[]
|
||||
}
|
||||
|
|
@ -75,6 +75,9 @@ export class ApplyAuthorizationResponse {
|
|||
}
|
||||
|
||||
export class StickmanRankingResponse {
|
||||
@ApiProperty({ description: '唯一标识(授权ID)' })
|
||||
id: string
|
||||
|
||||
@ApiProperty({ description: '用户ID' })
|
||||
userId: string
|
||||
|
||||
|
|
@ -87,12 +90,21 @@ export class StickmanRankingResponse {
|
|||
@ApiProperty({ description: '区域代码' })
|
||||
regionCode: string
|
||||
|
||||
@ApiPropertyOptional({ description: '昵称' })
|
||||
nickname?: string
|
||||
@ApiProperty({ description: '昵称' })
|
||||
nickname: string
|
||||
|
||||
@ApiPropertyOptional({ description: '头像URL' })
|
||||
avatarUrl?: string
|
||||
|
||||
@ApiProperty({ description: '完成数量(累计完成)' })
|
||||
completedCount: number
|
||||
|
||||
@ApiProperty({ description: '本月可结算收益(USDT)' })
|
||||
monthlyEarnings: number
|
||||
|
||||
@ApiProperty({ description: '是否是当前用户' })
|
||||
isCurrentUser: boolean
|
||||
|
||||
@ApiProperty({ description: '排名' })
|
||||
ranking: number
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
import { RedisModule } from '@/infrastructure/redis/redis.module'
|
||||
import { KafkaModule } from '@/infrastructure/kafka/kafka.module'
|
||||
import { EventConsumerController } from '@/infrastructure/kafka/event-consumer.controller'
|
||||
import { ReferralServiceClient } from '@/infrastructure/external/referral-service.client'
|
||||
import { ReferralServiceClient, IdentityServiceClient } from '@/infrastructure/external'
|
||||
|
||||
// Application
|
||||
import { AuthorizationApplicationService, REFERRAL_REPOSITORY, TEAM_STATISTICS_REPOSITORY } from '@/application/services'
|
||||
|
|
@ -88,6 +88,7 @@ const MockReferralRepository = {
|
|||
|
||||
// External Service Clients (replaces mock)
|
||||
ReferralServiceClient,
|
||||
IdentityServiceClient,
|
||||
{
|
||||
provide: TEAM_STATISTICS_REPOSITORY,
|
||||
useExisting: ReferralServiceClient,
|
||||
|
|
|
|||
|
|
@ -9,3 +9,4 @@ export * from './grant-auth-city-company.command'
|
|||
export * from './revoke-authorization.command'
|
||||
export * from './grant-monthly-bypass.command'
|
||||
export * from './exempt-percentage-check.command'
|
||||
export * from './self-apply-authorization.command'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import { SelfApplyAuthorizationType } from '@/api/dto/request/self-apply-authorization.dto'
|
||||
|
||||
/**
|
||||
* 自助申请授权命令
|
||||
*/
|
||||
export class SelfApplyAuthorizationCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly accountSequence: string,
|
||||
public readonly type: SelfApplyAuthorizationType,
|
||||
public readonly officePhotoUrls: string[],
|
||||
public readonly communityName?: string,
|
||||
public readonly provinceCode?: string,
|
||||
public readonly provinceName?: string,
|
||||
public readonly cityCode?: string,
|
||||
public readonly cityName?: string,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -21,12 +21,16 @@ export interface AuthorizationDTO {
|
|||
}
|
||||
|
||||
export interface StickmanRankingDTO {
|
||||
id: string // 授权ID,用于前端唯一标识
|
||||
userId: string
|
||||
authorizationId: string
|
||||
roleType: RoleType
|
||||
regionCode: string
|
||||
nickname?: string
|
||||
avatarUrl?: string
|
||||
nickname: string // 用户昵称
|
||||
avatarUrl?: string // 头像URL
|
||||
completedCount: number // 完成数量(累计完成)
|
||||
monthlyEarnings: number // 本月可结算收益(USDT)
|
||||
isCurrentUser: boolean // 是否是当前请求用户
|
||||
ranking: number
|
||||
isFirstPlace: boolean
|
||||
cumulativeCompleted: number
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
TeamStatistics,
|
||||
} from '@/domain/services'
|
||||
import { EventPublisherService } from '@/infrastructure/kafka'
|
||||
import { ReferralServiceClient } from '@/infrastructure/external'
|
||||
import { ReferralServiceClient, IdentityServiceClient } from '@/infrastructure/external'
|
||||
import { ApplicationError, NotFoundError } from '@/shared/exceptions'
|
||||
import {
|
||||
ApplyCommunityAuthCommand,
|
||||
|
|
@ -39,7 +39,13 @@ import {
|
|||
RevokeAuthorizationCommand,
|
||||
GrantMonthlyBypassCommand,
|
||||
ExemptLocalPercentageCheckCommand,
|
||||
SelfApplyAuthorizationCommand,
|
||||
} from '@/application/commands'
|
||||
import {
|
||||
SelfApplyAuthorizationResponseDto,
|
||||
UserAuthorizationStatusResponseDto,
|
||||
SelfApplyAuthorizationType,
|
||||
} from '@/api/dto/request/self-apply-authorization.dto'
|
||||
import { AuthorizationDTO, StickmanRankingDTO, CommunityHierarchyDTO } from '@/application/dto'
|
||||
|
||||
export const REFERRAL_REPOSITORY = Symbol('IReferralRepository')
|
||||
|
|
@ -61,6 +67,7 @@ export class AuthorizationApplicationService {
|
|||
private readonly statsRepository: ITeamStatisticsRepository,
|
||||
private readonly eventPublisher: EventPublisherService,
|
||||
private readonly referralServiceClient: ReferralServiceClient,
|
||||
private readonly identityServiceClient: IdentityServiceClient,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -569,6 +576,7 @@ export class AuthorizationApplicationService {
|
|||
month: string,
|
||||
roleType: RoleType,
|
||||
regionCode: string,
|
||||
currentUserId?: string,
|
||||
): Promise<StickmanRankingDTO[]> {
|
||||
const assessments = await this.assessmentRepository.findRankingsByMonthAndRegion(
|
||||
Month.create(month),
|
||||
|
|
@ -576,15 +584,30 @@ export class AuthorizationApplicationService {
|
|||
RegionCode.create(regionCode),
|
||||
)
|
||||
|
||||
if (assessments.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// 批量获取用户信息
|
||||
const userIds = assessments.map(a => a.userId.value)
|
||||
const userInfoMap = await this.identityServiceClient.batchGetUserInfo(userIds)
|
||||
|
||||
const rankings: StickmanRankingDTO[] = []
|
||||
const finalTarget = LadderTargetRule.getFinalTarget(roleType)
|
||||
|
||||
for (const assessment of assessments) {
|
||||
const userInfo = userInfoMap.get(assessment.userId.value)
|
||||
rankings.push({
|
||||
id: assessment.authorizationId.value,
|
||||
userId: assessment.userId.value,
|
||||
authorizationId: assessment.authorizationId.value,
|
||||
roleType: assessment.roleType,
|
||||
regionCode: assessment.regionCode.value,
|
||||
nickname: userInfo?.nickname || `用户${assessment.userId.accountSequence.slice(-4)}`,
|
||||
avatarUrl: userInfo?.avatarUrl,
|
||||
completedCount: assessment.cumulativeCompleted,
|
||||
monthlyEarnings: 0, // TODO: 从奖励服务获取本月可结算收益
|
||||
isCurrentUser: currentUserId ? assessment.userId.value === currentUserId : false,
|
||||
ranking: assessment.rankingInRegion || 0,
|
||||
isFirstPlace: assessment.isFirstPlace,
|
||||
cumulativeCompleted: assessment.cumulativeCompleted,
|
||||
|
|
@ -597,6 +620,9 @@ export class AuthorizationApplicationService {
|
|||
})
|
||||
}
|
||||
|
||||
// 按完成数量降序排序
|
||||
rankings.sort((a, b) => b.completedCount - a.completedCount)
|
||||
|
||||
return rankings
|
||||
}
|
||||
|
||||
|
|
@ -2734,4 +2760,247 @@ export class AuthorizationApplicationService {
|
|||
limit,
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 自助申请授权相关方法
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 获取用户授权申请状态
|
||||
*/
|
||||
async getUserAuthorizationStatus(
|
||||
accountSequence: string,
|
||||
): Promise<UserAuthorizationStatusResponseDto> {
|
||||
this.logger.log(`[getUserAuthorizationStatus] accountSequence=${accountSequence}`)
|
||||
|
||||
// 1. 获取用户认种数据
|
||||
const teamStats = await this.statsRepository.findByAccountSequence(accountSequence)
|
||||
const hasPlanted = (teamStats?.personalPlantingCount || 0) > 0
|
||||
const plantedCount = teamStats?.personalPlantingCount || 0
|
||||
|
||||
// 2. 获取用户已有的授权
|
||||
const authorizations = await this.authorizationRepository.findByAccountSequence(accountSequence)
|
||||
const existingAuthorizations = authorizations
|
||||
.filter(auth => auth.status !== AuthorizationStatus.REVOKED)
|
||||
.map(auth => this.mapRoleTypeToDisplayName(auth.roleType))
|
||||
|
||||
// 3. 获取待审核的申请(暂时返回空,待实现申请审核功能)
|
||||
const pendingApplications: {
|
||||
applicationId: string
|
||||
type: SelfApplyAuthorizationType
|
||||
appliedAt: Date
|
||||
}[] = []
|
||||
|
||||
return {
|
||||
hasPlanted,
|
||||
plantedCount,
|
||||
existingAuthorizations,
|
||||
pendingApplications,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自助申请授权
|
||||
*/
|
||||
async selfApplyAuthorization(
|
||||
command: SelfApplyAuthorizationCommand,
|
||||
): Promise<SelfApplyAuthorizationResponseDto> {
|
||||
this.logger.log(
|
||||
`[selfApplyAuthorization] userId=${command.userId}, type=${command.type}, ` +
|
||||
`photos=${command.officePhotoUrls.length}`,
|
||||
)
|
||||
|
||||
// 1. 验证用户已认种
|
||||
const teamStats = await this.statsRepository.findByAccountSequence(command.accountSequence)
|
||||
const personalPlantingCount = teamStats?.personalPlantingCount || 0
|
||||
if (personalPlantingCount <= 0) {
|
||||
throw new ApplicationError('申请授权需要先完成认种')
|
||||
}
|
||||
|
||||
// 2. 验证照片数量
|
||||
if (command.officePhotoUrls.length < 2) {
|
||||
throw new ApplicationError('请至少上传2张办公室照片')
|
||||
}
|
||||
if (command.officePhotoUrls.length > 6) {
|
||||
throw new ApplicationError('最多上传6张办公室照片')
|
||||
}
|
||||
|
||||
// 3. 根据申请类型处理
|
||||
let result: SelfApplyAuthorizationResponseDto
|
||||
|
||||
switch (command.type) {
|
||||
case SelfApplyAuthorizationType.COMMUNITY:
|
||||
if (!command.communityName) {
|
||||
throw new ApplicationError('申请社区授权需要提供社区名称')
|
||||
}
|
||||
result = await this.processCommunityApplication(command)
|
||||
break
|
||||
|
||||
case SelfApplyAuthorizationType.CITY_TEAM:
|
||||
if (!command.cityCode || !command.cityName) {
|
||||
throw new ApplicationError('申请市团队授权需要提供城市信息')
|
||||
}
|
||||
result = await this.processCityTeamApplication(command)
|
||||
break
|
||||
|
||||
case SelfApplyAuthorizationType.PROVINCE_TEAM:
|
||||
if (!command.provinceCode || !command.provinceName) {
|
||||
throw new ApplicationError('申请省团队授权需要提供省份信息')
|
||||
}
|
||||
result = await this.processProvinceTeamApplication(command)
|
||||
break
|
||||
|
||||
default:
|
||||
throw new ApplicationError('不支持的授权类型')
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理社区授权申请
|
||||
*/
|
||||
private async processCommunityApplication(
|
||||
command: SelfApplyAuthorizationCommand,
|
||||
): Promise<SelfApplyAuthorizationResponseDto> {
|
||||
// 检查是否已有社区授权
|
||||
const existing = await this.authorizationRepository.findByAccountSequenceAndRoleType(
|
||||
command.accountSequence,
|
||||
RoleType.COMMUNITY,
|
||||
)
|
||||
if (existing && existing.status !== AuthorizationStatus.REVOKED) {
|
||||
throw new ApplicationError('您已拥有社区授权')
|
||||
}
|
||||
|
||||
// 直接创建授权(自助申请的社区授权直接生效)
|
||||
const userId = UserId.create(command.userId, command.accountSequence)
|
||||
const authorization = AuthorizationRole.createCommunityAuth({
|
||||
userId,
|
||||
communityName: command.communityName!,
|
||||
})
|
||||
|
||||
// 检查初始考核
|
||||
const teamStats = await this.statsRepository.findByAccountSequence(command.accountSequence)
|
||||
const subordinateTreeCount = teamStats?.subordinateTeamPlantingCount || 0
|
||||
if (subordinateTreeCount >= authorization.getInitialTarget()) {
|
||||
authorization.activateBenefit()
|
||||
}
|
||||
|
||||
await this.authorizationRepository.save(authorization)
|
||||
await this.eventPublisher.publishAll(authorization.domainEvents)
|
||||
authorization.clearDomainEvents()
|
||||
|
||||
return {
|
||||
applicationId: authorization.id,
|
||||
status: 'APPROVED',
|
||||
type: SelfApplyAuthorizationType.COMMUNITY,
|
||||
appliedAt: new Date(),
|
||||
reviewedAt: new Date(),
|
||||
reviewNote: '自助申请自动审核通过',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理市团队授权申请
|
||||
*/
|
||||
private async processCityTeamApplication(
|
||||
command: SelfApplyAuthorizationCommand,
|
||||
): Promise<SelfApplyAuthorizationResponseDto> {
|
||||
// 检查是否已有该市的授权市公司
|
||||
const existing = await this.authorizationRepository.findByRegionCodeAndRoleType(
|
||||
RegionCode.create(command.cityCode!),
|
||||
RoleType.AUTH_CITY_COMPANY,
|
||||
)
|
||||
if (existing && existing.status !== AuthorizationStatus.REVOKED) {
|
||||
throw new ApplicationError('该市已有授权市公司')
|
||||
}
|
||||
|
||||
// 创建授权市公司授权
|
||||
const userId = UserId.create(command.userId, command.accountSequence)
|
||||
const regionCode = RegionCode.create(command.cityCode!)
|
||||
const authorization = AuthorizationRole.createAuthCityCompany({
|
||||
userId,
|
||||
regionCode,
|
||||
cityName: command.cityName!,
|
||||
})
|
||||
|
||||
// 检查初始考核(100棵)
|
||||
const teamStats = await this.statsRepository.findByAccountSequence(command.accountSequence)
|
||||
const subordinateTreeCount = teamStats?.subordinateTeamPlantingCount || 0
|
||||
if (subordinateTreeCount >= authorization.getInitialTarget()) {
|
||||
authorization.activateBenefit()
|
||||
}
|
||||
|
||||
await this.authorizationRepository.save(authorization)
|
||||
await this.eventPublisher.publishAll(authorization.domainEvents)
|
||||
authorization.clearDomainEvents()
|
||||
|
||||
return {
|
||||
applicationId: authorization.id,
|
||||
status: 'APPROVED',
|
||||
type: SelfApplyAuthorizationType.CITY_TEAM,
|
||||
appliedAt: new Date(),
|
||||
reviewedAt: new Date(),
|
||||
reviewNote: '自助申请自动审核通过',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理省团队授权申请
|
||||
*/
|
||||
private async processProvinceTeamApplication(
|
||||
command: SelfApplyAuthorizationCommand,
|
||||
): Promise<SelfApplyAuthorizationResponseDto> {
|
||||
// 检查是否已有该省的授权省公司
|
||||
const existing = await this.authorizationRepository.findByRegionCodeAndRoleType(
|
||||
RegionCode.create(command.provinceCode!),
|
||||
RoleType.AUTH_PROVINCE_COMPANY,
|
||||
)
|
||||
if (existing && existing.status !== AuthorizationStatus.REVOKED) {
|
||||
throw new ApplicationError('该省已有授权省公司')
|
||||
}
|
||||
|
||||
// 创建授权省公司授权
|
||||
const userId = UserId.create(command.userId, command.accountSequence)
|
||||
const regionCode = RegionCode.create(command.provinceCode!)
|
||||
const authorization = AuthorizationRole.createAuthProvinceCompany({
|
||||
userId,
|
||||
regionCode,
|
||||
provinceName: command.provinceName!,
|
||||
})
|
||||
|
||||
// 检查初始考核(500棵)
|
||||
const teamStats = await this.statsRepository.findByAccountSequence(command.accountSequence)
|
||||
const subordinateTreeCount = teamStats?.subordinateTeamPlantingCount || 0
|
||||
if (subordinateTreeCount >= authorization.getInitialTarget()) {
|
||||
authorization.activateBenefit()
|
||||
}
|
||||
|
||||
await this.authorizationRepository.save(authorization)
|
||||
await this.eventPublisher.publishAll(authorization.domainEvents)
|
||||
authorization.clearDomainEvents()
|
||||
|
||||
return {
|
||||
applicationId: authorization.id,
|
||||
status: 'APPROVED',
|
||||
type: SelfApplyAuthorizationType.PROVINCE_TEAM,
|
||||
appliedAt: new Date(),
|
||||
reviewedAt: new Date(),
|
||||
reviewNote: '自助申请自动审核通过',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 RoleType 映射为显示名称
|
||||
*/
|
||||
private mapRoleTypeToDisplayName(roleType: RoleType): string {
|
||||
const mapping: Record<RoleType, string> = {
|
||||
[RoleType.COMMUNITY]: '社区',
|
||||
[RoleType.AUTH_CITY_COMPANY]: '市团队',
|
||||
[RoleType.AUTH_PROVINCE_COMPANY]: '省团队',
|
||||
[RoleType.CITY_COMPANY]: '市区域',
|
||||
[RoleType.PROVINCE_COMPANY]: '省区域',
|
||||
}
|
||||
return mapping[roleType] || roleType
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@ export class SystemAccountApplicationService implements OnModuleInit {
|
|||
allocations.push({
|
||||
targetType: 'COST_ACCOUNT',
|
||||
targetSystemAccountId: costAccount.id,
|
||||
amount: new Decimal(400).mul(treeCount),
|
||||
amount: new Decimal(2800).mul(treeCount),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -315,7 +315,7 @@ export class SystemAccountApplicationService implements OnModuleInit {
|
|||
allocations.push({
|
||||
targetType: 'OPERATION_ACCOUNT',
|
||||
targetSystemAccountId: operationAccount.id,
|
||||
amount: new Decimal(300).mul(treeCount),
|
||||
amount: new Decimal(2100).mul(treeCount),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -323,7 +323,7 @@ export class SystemAccountApplicationService implements OnModuleInit {
|
|||
allocations.push({
|
||||
targetType: 'HQ_COMMUNITY',
|
||||
targetSystemAccountId: hqCommunityAccount.id,
|
||||
amount: new Decimal(9).mul(treeCount),
|
||||
amount: new Decimal(203).mul(treeCount),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -331,27 +331,27 @@ export class SystemAccountApplicationService implements OnModuleInit {
|
|||
allocations.push({
|
||||
targetType: 'RWAD_POOL',
|
||||
targetSystemAccountId: rwadPoolAccount.id,
|
||||
amount: new Decimal(800).mul(treeCount),
|
||||
amount: new Decimal(5760).mul(treeCount),
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 直接推荐人(500 USDT)
|
||||
// 2. 直接推荐人(3600 USDT)
|
||||
if (params.referrerId) {
|
||||
allocations.push({
|
||||
targetType: 'DIRECT_REFERRER',
|
||||
targetUserId: params.referrerId,
|
||||
amount: new Decimal(500).mul(treeCount),
|
||||
amount: new Decimal(3600).mul(treeCount),
|
||||
})
|
||||
} else if (operationAccount) {
|
||||
// 无推荐人,归入运营账户
|
||||
allocations.push({
|
||||
targetType: 'OPERATION_ACCOUNT',
|
||||
targetSystemAccountId: operationAccount.id,
|
||||
amount: new Decimal(500).mul(treeCount),
|
||||
amount: new Decimal(3600).mul(treeCount),
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 省公司权益(15 区域 + 20 团队)
|
||||
// 3. 省公司权益(108 区域 + 144 团队)
|
||||
// TODO: 从 authorization-service 查询省公司授权状态
|
||||
// 暂时归入系统省账户
|
||||
const systemProvince = await this.systemAccountRepository.getOrCreate(
|
||||
|
|
@ -362,15 +362,15 @@ export class SystemAccountApplicationService implements OnModuleInit {
|
|||
allocations.push({
|
||||
targetType: 'PROVINCE_REGION',
|
||||
targetSystemAccountId: systemProvince.id,
|
||||
amount: new Decimal(15).mul(treeCount),
|
||||
amount: new Decimal(108).mul(treeCount),
|
||||
})
|
||||
allocations.push({
|
||||
targetType: 'PROVINCE_TEAM',
|
||||
targetSystemAccountId: systemProvince.id,
|
||||
amount: new Decimal(20).mul(treeCount),
|
||||
amount: new Decimal(144).mul(treeCount),
|
||||
})
|
||||
|
||||
// 4. 市公司权益(35 区域 + 40 团队)
|
||||
// 4. 市公司权益(252 区域 + 288 团队)
|
||||
// TODO: 从 authorization-service 查询市公司授权状态
|
||||
// 暂时归入系统市账户
|
||||
const systemCity = await this.systemAccountRepository.getOrCreate(
|
||||
|
|
@ -381,22 +381,22 @@ export class SystemAccountApplicationService implements OnModuleInit {
|
|||
allocations.push({
|
||||
targetType: 'CITY_REGION',
|
||||
targetSystemAccountId: systemCity.id,
|
||||
amount: new Decimal(35).mul(treeCount),
|
||||
amount: new Decimal(252).mul(treeCount),
|
||||
})
|
||||
allocations.push({
|
||||
targetType: 'CITY_TEAM',
|
||||
targetSystemAccountId: systemCity.id,
|
||||
amount: new Decimal(40).mul(treeCount),
|
||||
amount: new Decimal(288).mul(treeCount),
|
||||
})
|
||||
|
||||
// 5. 社区权益(80 USDT)
|
||||
// 5. 社区权益(576 USDT)
|
||||
// TODO: 从 authorization-service 查询社区授权状态
|
||||
// 暂时归入运营账户
|
||||
if (operationAccount) {
|
||||
allocations.push({
|
||||
targetType: 'COMMUNITY',
|
||||
targetSystemAccountId: operationAccount.id,
|
||||
amount: new Decimal(80).mul(treeCount),
|
||||
amount: new Decimal(576).mul(treeCount),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
99
backend/services/authorization-service/src/infrastructure/external/identity-service.client.ts
vendored
Normal file
99
backend/services/authorization-service/src/infrastructure/external/identity-service.client.ts
vendored
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
/**
|
||||
* 用户信息接口
|
||||
*/
|
||||
export interface UserInfo {
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
nickname: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity Service HTTP 客户端
|
||||
* 用于从 identity-service 获取用户信息
|
||||
*/
|
||||
@Injectable()
|
||||
export class IdentityServiceClient implements OnModuleInit {
|
||||
private readonly logger = new Logger(IdentityServiceClient.name);
|
||||
private httpClient: AxiosInstance;
|
||||
private readonly baseUrl: string;
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.baseUrl = this.configService.get<string>('IDENTITY_SERVICE_URL') || 'http://identity-service:3001';
|
||||
this.enabled = this.configService.get<boolean>('IDENTITY_SERVICE_ENABLED') !== false;
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
this.httpClient = axios.create({
|
||||
baseURL: this.baseUrl,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`[INIT] IdentityServiceClient initialized: ${this.baseUrl}, enabled: ${this.enabled}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取用户信息
|
||||
*/
|
||||
async batchGetUserInfo(userIds: string[]): Promise<Map<string, UserInfo>> {
|
||||
const result = new Map<string, UserInfo>();
|
||||
|
||||
if (!this.enabled || userIds.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.debug(`[HTTP] POST /internal/users/batch - ${userIds.length} users`);
|
||||
|
||||
const response = await this.httpClient.post<UserInfo[]>(
|
||||
`/api/v1/internal/users/batch`,
|
||||
{ userIds },
|
||||
);
|
||||
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
for (const user of response.data) {
|
||||
result.set(user.userId, user);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug(`[HTTP] Got ${result.size} users info`);
|
||||
} catch (error) {
|
||||
this.logger.error(`[HTTP] Failed to batch get user info:`, error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个用户信息
|
||||
*/
|
||||
async getUserInfo(userId: string): Promise<UserInfo | null> {
|
||||
if (!this.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.debug(`[HTTP] GET /internal/users/${userId}`);
|
||||
|
||||
const response = await this.httpClient.get<UserInfo>(
|
||||
`/api/v1/internal/users/${userId}`,
|
||||
);
|
||||
|
||||
if (response.data) {
|
||||
return response.data;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`[HTTP] Failed to get user info for ${userId}:`, error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
export * from './referral-service.client';
|
||||
export * from './identity-service.client';
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export class FundAllocationDomainService {
|
|||
throw new Error('订单未选择省市,无法计算资金分配');
|
||||
}
|
||||
|
||||
// 1. 成本账户: 400 USDT/棵
|
||||
// 1. 成本账户: 2800 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.COST_ACCOUNT,
|
||||
|
|
@ -74,7 +74,7 @@ export class FundAllocationDomainService {
|
|||
),
|
||||
);
|
||||
|
||||
// 2. 运营账户: 300 USDT/棵
|
||||
// 2. 运营账户: 2100 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.OPERATION_ACCOUNT,
|
||||
|
|
@ -84,7 +84,7 @@ export class FundAllocationDomainService {
|
|||
),
|
||||
);
|
||||
|
||||
// 3. 总部社区: 9 USDT/棵
|
||||
// 3. 总部社区: 203 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.HEADQUARTERS_COMMUNITY,
|
||||
|
|
@ -95,7 +95,7 @@ export class FundAllocationDomainService {
|
|||
),
|
||||
);
|
||||
|
||||
// 4. 分享权益: 500 USDT/棵 (分配给推荐链)
|
||||
// 4. 分享权益: 3600 USDT/棵 (分配给推荐链)
|
||||
// 无推荐人时进入分享权益池 SYSTEM_SHARE_RIGHT_POOL
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
|
|
@ -109,7 +109,7 @@ export class FundAllocationDomainService {
|
|||
),
|
||||
);
|
||||
|
||||
// 5. 省区域权益: 15 USDT/棵
|
||||
// 5. 省区域权益: 108 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.PROVINCE_AREA_RIGHTS,
|
||||
|
|
@ -119,7 +119,7 @@ export class FundAllocationDomainService {
|
|||
),
|
||||
);
|
||||
|
||||
// 6. 省团队权益: 20 USDT/棵
|
||||
// 6. 省团队权益: 144 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.PROVINCE_TEAM_RIGHTS,
|
||||
|
|
@ -129,7 +129,7 @@ export class FundAllocationDomainService {
|
|||
),
|
||||
);
|
||||
|
||||
// 7. 市区域权益: 35 USDT/棵
|
||||
// 7. 市区域权益: 252 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.CITY_AREA_RIGHTS,
|
||||
|
|
@ -139,7 +139,7 @@ export class FundAllocationDomainService {
|
|||
),
|
||||
);
|
||||
|
||||
// 8. 市团队权益: 40 USDT/棵
|
||||
// 8. 市团队权益: 288 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.CITY_TEAM_RIGHTS,
|
||||
|
|
@ -149,7 +149,7 @@ export class FundAllocationDomainService {
|
|||
),
|
||||
);
|
||||
|
||||
// 9. 社区权益: 80 USDT/棵
|
||||
// 9. 社区权益: 576 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.COMMUNITY_RIGHTS,
|
||||
|
|
@ -159,7 +159,7 @@ export class FundAllocationDomainService {
|
|||
),
|
||||
);
|
||||
|
||||
// 10. RWAD底池: 800 USDT/棵
|
||||
// 10. RWAD底池: 5760 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.RWAD_POOL,
|
||||
|
|
@ -170,7 +170,7 @@ export class FundAllocationDomainService {
|
|||
|
||||
// 验证总额
|
||||
const total = allocations.reduce((sum, a) => sum + a.amount, 0);
|
||||
const expected = 2199 * treeCount;
|
||||
const expected = 15831 * treeCount;
|
||||
if (Math.abs(total - expected) > 0.01) {
|
||||
throw new Error(`资金分配计算错误: 总额 ${total} != ${expected}`);
|
||||
}
|
||||
|
|
@ -201,7 +201,7 @@ export class FundAllocationDomainService {
|
|||
throw new Error('订单未选择省市,无法计算资金分配');
|
||||
}
|
||||
|
||||
// 1. 成本账户: 400 USDT/棵
|
||||
// 1. 成本账户: 2800 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.COST_ACCOUNT,
|
||||
|
|
@ -210,7 +210,7 @@ export class FundAllocationDomainService {
|
|||
),
|
||||
);
|
||||
|
||||
// 2. 运营账户: 300 USDT/棵
|
||||
// 2. 运营账户: 2100 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.OPERATION_ACCOUNT,
|
||||
|
|
@ -219,7 +219,7 @@ export class FundAllocationDomainService {
|
|||
),
|
||||
);
|
||||
|
||||
// 3. 总部社区: 9 USDT/棵
|
||||
// 3. 总部社区: 203 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.HEADQUARTERS_COMMUNITY,
|
||||
|
|
@ -228,7 +228,7 @@ export class FundAllocationDomainService {
|
|||
),
|
||||
);
|
||||
|
||||
// 4. 分享权益 (直推奖励): 500 USDT/棵
|
||||
// 4. 分享权益 (直推奖励): 3600 USDT/棵
|
||||
// 仅直推,无多级;无推荐人归入分享权益池
|
||||
const referralTarget = context.directReferrerId
|
||||
? `USER:${context.directReferrerId}`
|
||||
|
|
@ -245,7 +245,7 @@ export class FundAllocationDomainService {
|
|||
),
|
||||
);
|
||||
|
||||
// 5. 省区域权益: 15 USDT/棵 + 1%算力(正式省公司才有算力)
|
||||
// 5. 省区域权益: 108 USDT/棵 + 1%算力(正式省公司才有算力)
|
||||
const provinceAreaTarget = context.provinceAreaRecipient.type === 'USER'
|
||||
? `USER:${context.provinceAreaRecipient.id}`
|
||||
: `SYSTEM:SYSTEM_PROVINCE:${selection.provinceCode}`;
|
||||
|
|
@ -261,7 +261,7 @@ export class FundAllocationDomainService {
|
|||
),
|
||||
);
|
||||
|
||||
// 6. 省团队权益: 20 USDT/棵(授权省公司)
|
||||
// 6. 省团队权益: 144 USDT/棵(授权省公司)
|
||||
const provinceTeamTarget = context.provinceTeamRecipient.type === 'USER'
|
||||
? `USER:${context.provinceTeamRecipient.id}`
|
||||
: `SYSTEM:SYSTEM_PROVINCE:${selection.provinceCode}`;
|
||||
|
|
@ -274,7 +274,7 @@ export class FundAllocationDomainService {
|
|||
),
|
||||
);
|
||||
|
||||
// 7. 市区域权益: 35 USDT/棵 + 2%算力(正式市公司才有算力)
|
||||
// 7. 市区域权益: 252 USDT/棵 + 2%算力(正式市公司才有算力)
|
||||
const cityAreaTarget = context.cityAreaRecipient.type === 'USER'
|
||||
? `USER:${context.cityAreaRecipient.id}`
|
||||
: `SYSTEM:SYSTEM_CITY:${selection.cityCode}`;
|
||||
|
|
@ -290,7 +290,7 @@ export class FundAllocationDomainService {
|
|||
),
|
||||
);
|
||||
|
||||
// 8. 市团队权益: 40 USDT/棵(授权市公司)
|
||||
// 8. 市团队权益: 288 USDT/棵(授权市公司)
|
||||
const cityTeamTarget = context.cityTeamRecipient.type === 'USER'
|
||||
? `USER:${context.cityTeamRecipient.id}`
|
||||
: `SYSTEM:SYSTEM_CITY:${selection.cityCode}`;
|
||||
|
|
@ -303,7 +303,7 @@ export class FundAllocationDomainService {
|
|||
),
|
||||
);
|
||||
|
||||
// 9. 社区权益: 80 USDT/棵
|
||||
// 9. 社区权益: 576 USDT/棵
|
||||
const communityTarget = context.communityRecipient && context.communityRecipient.type === 'USER'
|
||||
? `USER:${context.communityRecipient.id}`
|
||||
: 'SYSTEM:OPERATION_ACCOUNT';
|
||||
|
|
@ -315,7 +315,7 @@ export class FundAllocationDomainService {
|
|||
),
|
||||
);
|
||||
|
||||
// 10. RWAD底池: 800 USDT/棵
|
||||
// 10. RWAD底池: 5760 USDT/棵
|
||||
allocations.push(
|
||||
new FundAllocation(
|
||||
FundAllocationTargetType.RWAD_POOL,
|
||||
|
|
@ -327,7 +327,7 @@ export class FundAllocationDomainService {
|
|||
|
||||
// 验证总额
|
||||
const total = allocations.reduce((sum, a) => sum + a.amount, 0);
|
||||
const expected = 2199 * treeCount;
|
||||
const expected = 15831 * treeCount;
|
||||
if (Math.abs(total - expected) > 0.01) {
|
||||
throw new Error(`资金分配计算错误: 总额 ${total} != ${expected}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,33 @@
|
|||
export enum FundAllocationTargetType {
|
||||
COST_ACCOUNT = 'COST_ACCOUNT', // 400 USDT - 成本账户
|
||||
OPERATION_ACCOUNT = 'OPERATION_ACCOUNT', // 300 USDT - 运营账户
|
||||
HEADQUARTERS_COMMUNITY = 'HEADQUARTERS_COMMUNITY', // 9 USDT - 总部社区
|
||||
REFERRAL_RIGHTS = 'REFERRAL_RIGHTS', // 500 USDT - 分享权益
|
||||
PROVINCE_AREA_RIGHTS = 'PROVINCE_AREA_RIGHTS', // 15 USDT - 省区域权益
|
||||
PROVINCE_TEAM_RIGHTS = 'PROVINCE_TEAM_RIGHTS', // 20 USDT - 省团队权益
|
||||
CITY_AREA_RIGHTS = 'CITY_AREA_RIGHTS', // 35 USDT - 市区域权益
|
||||
CITY_TEAM_RIGHTS = 'CITY_TEAM_RIGHTS', // 40 USDT - 市团队权益
|
||||
COMMUNITY_RIGHTS = 'COMMUNITY_RIGHTS', // 80 USDT - 社区权益
|
||||
RWAD_POOL = 'RWAD_POOL', // 800 USDT - RWAD底池
|
||||
COST_ACCOUNT = 'COST_ACCOUNT', // 2800 USDT - 成本账户
|
||||
OPERATION_ACCOUNT = 'OPERATION_ACCOUNT', // 2100 USDT - 运营账户
|
||||
HEADQUARTERS_COMMUNITY = 'HEADQUARTERS_COMMUNITY', // 203 USDT - 总部社区
|
||||
REFERRAL_RIGHTS = 'REFERRAL_RIGHTS', // 3600 USDT - 分享权益
|
||||
PROVINCE_AREA_RIGHTS = 'PROVINCE_AREA_RIGHTS', // 108 USDT - 省区域权益
|
||||
PROVINCE_TEAM_RIGHTS = 'PROVINCE_TEAM_RIGHTS', // 144 USDT - 省团队权益
|
||||
CITY_AREA_RIGHTS = 'CITY_AREA_RIGHTS', // 252 USDT - 市区域权益
|
||||
CITY_TEAM_RIGHTS = 'CITY_TEAM_RIGHTS', // 288 USDT - 市团队权益
|
||||
COMMUNITY_RIGHTS = 'COMMUNITY_RIGHTS', // 576 USDT - 社区权益
|
||||
RWAD_POOL = 'RWAD_POOL', // 5760 USDT - RWAD底池
|
||||
}
|
||||
|
||||
// 每棵树的资金分配规则 (总计 2199 USDT)
|
||||
// 每棵树的资金分配规则 (总计 15831 USDT)
|
||||
export const FUND_ALLOCATION_AMOUNTS: Record<FundAllocationTargetType, number> =
|
||||
{
|
||||
[FundAllocationTargetType.COST_ACCOUNT]: 400,
|
||||
[FundAllocationTargetType.OPERATION_ACCOUNT]: 300,
|
||||
[FundAllocationTargetType.HEADQUARTERS_COMMUNITY]: 9,
|
||||
[FundAllocationTargetType.REFERRAL_RIGHTS]: 500,
|
||||
[FundAllocationTargetType.PROVINCE_AREA_RIGHTS]: 15,
|
||||
[FundAllocationTargetType.PROVINCE_TEAM_RIGHTS]: 20,
|
||||
[FundAllocationTargetType.CITY_AREA_RIGHTS]: 35,
|
||||
[FundAllocationTargetType.CITY_TEAM_RIGHTS]: 40,
|
||||
[FundAllocationTargetType.COMMUNITY_RIGHTS]: 80,
|
||||
[FundAllocationTargetType.RWAD_POOL]: 800,
|
||||
[FundAllocationTargetType.COST_ACCOUNT]: 2800,
|
||||
[FundAllocationTargetType.OPERATION_ACCOUNT]: 2100,
|
||||
[FundAllocationTargetType.HEADQUARTERS_COMMUNITY]: 203,
|
||||
[FundAllocationTargetType.REFERRAL_RIGHTS]: 3600,
|
||||
[FundAllocationTargetType.PROVINCE_AREA_RIGHTS]: 108,
|
||||
[FundAllocationTargetType.PROVINCE_TEAM_RIGHTS]: 144,
|
||||
[FundAllocationTargetType.CITY_AREA_RIGHTS]: 252,
|
||||
[FundAllocationTargetType.CITY_TEAM_RIGHTS]: 288,
|
||||
[FundAllocationTargetType.COMMUNITY_RIGHTS]: 576,
|
||||
[FundAllocationTargetType.RWAD_POOL]: 5760,
|
||||
};
|
||||
|
||||
// 每棵树价格
|
||||
export const PRICE_PER_TREE = 2199;
|
||||
export const PRICE_PER_TREE = 15831;
|
||||
|
||||
// 验证总额
|
||||
const TOTAL = Object.values(FUND_ALLOCATION_AMOUNTS).reduce((a, b) => a + b, 0);
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export class RewardCalculationService {
|
|||
|
||||
/**
|
||||
* 计算认种订单产生的所有奖励
|
||||
* 总计 2199 USDT = 400 + 300 + 9 + 800 + 500 + 15 + 20 + 35 + 40 + 80
|
||||
* 总计 15831 USDT = 2800 + 2100 + 203 + 5760 + 3600 + 108 + 144 + 252 + 288 + 576
|
||||
*/
|
||||
async calculateRewards(params: {
|
||||
sourceOrderNo: string; // 订单号是字符串格式
|
||||
|
|
@ -103,10 +103,10 @@ export class RewardCalculationService {
|
|||
const rewards: RewardLedgerEntry[] = [];
|
||||
|
||||
// ============================================
|
||||
// 系统费用类 (709 USDT)
|
||||
// 系统费用类 (10863 USDT)
|
||||
// ============================================
|
||||
|
||||
// 1. 成本费 (400 USDT)
|
||||
// 1. 成本费 (2800 USDT)
|
||||
const costFeeReward = this.calculateCostFee(
|
||||
params.sourceOrderNo,
|
||||
params.sourceUserId,
|
||||
|
|
@ -114,7 +114,7 @@ export class RewardCalculationService {
|
|||
);
|
||||
rewards.push(costFeeReward);
|
||||
|
||||
// 2. 运营费 (300 USDT)
|
||||
// 2. 运营费 (2100 USDT)
|
||||
const operationFeeReward = this.calculateOperationFee(
|
||||
params.sourceOrderNo,
|
||||
params.sourceUserId,
|
||||
|
|
@ -122,7 +122,7 @@ export class RewardCalculationService {
|
|||
);
|
||||
rewards.push(operationFeeReward);
|
||||
|
||||
// 3. 总部社区基础费 (9 USDT)
|
||||
// 3. 总部社区基础费 (203 USDT)
|
||||
const headquartersBaseFeeReward = this.calculateHeadquartersBaseFee(
|
||||
params.sourceOrderNo,
|
||||
params.sourceUserId,
|
||||
|
|
@ -130,7 +130,7 @@ export class RewardCalculationService {
|
|||
);
|
||||
rewards.push(headquartersBaseFeeReward);
|
||||
|
||||
// 4. RWAD底池注入 (800 USDT)
|
||||
// 4. RWAD底池注入 (5760 USDT)
|
||||
const rwadPoolReward = this.calculateRwadPoolInjection(
|
||||
params.sourceOrderNo,
|
||||
params.sourceUserId,
|
||||
|
|
@ -139,10 +139,10 @@ export class RewardCalculationService {
|
|||
rewards.push(rwadPoolReward);
|
||||
|
||||
// ============================================
|
||||
// 用户权益类 (690 USDT + 算力)
|
||||
// 用户权益类 (4968 USDT + 算力)
|
||||
// ============================================
|
||||
|
||||
// 5. 分享权益 (500 USDT)
|
||||
// 5. 分享权益 (3600 USDT)
|
||||
const shareRewards = await this.calculateShareRights(
|
||||
params.sourceOrderNo,
|
||||
params.sourceUserId,
|
||||
|
|
@ -151,7 +151,7 @@ export class RewardCalculationService {
|
|||
);
|
||||
rewards.push(...shareRewards);
|
||||
|
||||
// 6. 省团队权益 (20 USDT) - 可能返回多条记录(考核分配)
|
||||
// 6. 省团队权益 (144 USDT) - 可能返回多条记录(考核分配)
|
||||
const provinceTeamRewards = await this.calculateProvinceTeamRight(
|
||||
params.sourceOrderNo,
|
||||
params.sourceUserId,
|
||||
|
|
@ -161,7 +161,7 @@ export class RewardCalculationService {
|
|||
);
|
||||
rewards.push(...provinceTeamRewards);
|
||||
|
||||
// 7. 省区域权益 (15 USDT + 1%算力) - 可能返回多条记录(考核分配)
|
||||
// 7. 省区域权益 (108 USDT + 1%算力) - 可能返回多条记录(考核分配)
|
||||
const provinceAreaRewards = await this.calculateProvinceAreaRight(
|
||||
params.sourceOrderNo,
|
||||
params.sourceUserId,
|
||||
|
|
@ -170,7 +170,7 @@ export class RewardCalculationService {
|
|||
);
|
||||
rewards.push(...provinceAreaRewards);
|
||||
|
||||
// 8. 市团队权益 (40 USDT) - 可能返回多条记录(考核分配)
|
||||
// 8. 市团队权益 (288 USDT) - 可能返回多条记录(考核分配)
|
||||
const cityTeamRewards = await this.calculateCityTeamRight(
|
||||
params.sourceOrderNo,
|
||||
params.sourceUserId,
|
||||
|
|
@ -180,7 +180,7 @@ export class RewardCalculationService {
|
|||
);
|
||||
rewards.push(...cityTeamRewards);
|
||||
|
||||
// 9. 市区域权益 (35 USDT + 2%算力) - 可能返回多条记录(考核分配)
|
||||
// 9. 市区域权益 (252 USDT + 2%算力) - 可能返回多条记录(考核分配)
|
||||
const cityAreaRewards = await this.calculateCityAreaRight(
|
||||
params.sourceOrderNo,
|
||||
params.sourceUserId,
|
||||
|
|
@ -189,7 +189,7 @@ export class RewardCalculationService {
|
|||
);
|
||||
rewards.push(...cityAreaRewards);
|
||||
|
||||
// 10. 社区权益 (80 USDT) - 可能返回多条记录(考核分配)
|
||||
// 10. 社区权益 (576 USDT) - 可能返回多条记录(考核分配)
|
||||
const communityRewards = await this.calculateCommunityRight(
|
||||
params.sourceOrderNo,
|
||||
params.sourceUserId,
|
||||
|
|
@ -210,7 +210,7 @@ export class RewardCalculationService {
|
|||
// ============================================
|
||||
|
||||
/**
|
||||
* 计算成本费 (400 USDT)
|
||||
* 计算成本费 (2800 USDT)
|
||||
* 分配至指定成本账户
|
||||
*/
|
||||
private calculateCostFee(
|
||||
|
|
@ -239,7 +239,7 @@ export class RewardCalculationService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 计算运营费 (300 USDT)
|
||||
* 计算运营费 (2100 USDT)
|
||||
* 分配至指定运营账户
|
||||
*/
|
||||
private calculateOperationFee(
|
||||
|
|
@ -268,7 +268,7 @@ export class RewardCalculationService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 计算总部社区基础费 (9 USDT)
|
||||
* 计算总部社区基础费 (203 USDT)
|
||||
* 分配至总部社区账户
|
||||
*/
|
||||
private calculateHeadquartersBaseFee(
|
||||
|
|
@ -297,7 +297,7 @@ export class RewardCalculationService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 计算RWAD底池注入 (800 USDT)
|
||||
* 计算RWAD底池注入 (5760 USDT)
|
||||
* 注入RWAD 1号底池
|
||||
*/
|
||||
private calculateRwadPoolInjection(
|
||||
|
|
@ -321,7 +321,7 @@ export class RewardCalculationService {
|
|||
rewardSource,
|
||||
usdtAmount,
|
||||
hashpowerAmount: hashpower,
|
||||
memo: `RWAD底池注入:来自用户${sourceUserId}的认种,${treeCount}棵树,800U注入1号底池`,
|
||||
memo: `RWAD底池注入:来自用户${sourceUserId}的认种,${treeCount}棵树,5760U注入1号底池`,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -330,7 +330,7 @@ export class RewardCalculationService {
|
|||
// ============================================
|
||||
|
||||
/**
|
||||
* 计算分享权益 (500 USDT)
|
||||
* 计算分享权益 (3600 USDT)
|
||||
*/
|
||||
private async calculateShareRights(
|
||||
sourceOrderNo: string,
|
||||
|
|
@ -400,7 +400,7 @@ export class RewardCalculationService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 计算省团队权益 (20 USDT)
|
||||
* 计算省团队权益 (144 USDT)
|
||||
* 根据考核规则(500棵初审),可能返回多条分配记录
|
||||
*/
|
||||
private async calculateProvinceTeamRight(
|
||||
|
|
@ -456,7 +456,7 @@ export class RewardCalculationService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 计算省区域权益 (15 USDT + 1%算力)
|
||||
* 计算省区域权益 (108 USDT + 1%算力)
|
||||
* 根据考核规则(50000棵),可能返回多条分配记录
|
||||
* - 未达标:进系统省账户
|
||||
* - 已达标:进正式省公司账户
|
||||
|
|
@ -512,7 +512,7 @@ export class RewardCalculationService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 计算市团队权益 (40 USDT)
|
||||
* 计算市团队权益 (288 USDT)
|
||||
* 根据考核规则(100棵初审),可能返回多条分配记录
|
||||
*/
|
||||
private async calculateCityTeamRight(
|
||||
|
|
@ -568,7 +568,7 @@ export class RewardCalculationService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 计算市区域权益 (35 USDT + 2%算力)
|
||||
* 计算市区域权益 (252 USDT + 2%算力)
|
||||
* 根据考核规则(10000棵),可能返回多条分配记录
|
||||
* - 未达标:进系统市账户
|
||||
* - 已达标:进正式市公司账户
|
||||
|
|
@ -624,7 +624,7 @@ export class RewardCalculationService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 计算社区权益 (80 USDT)
|
||||
* 计算社区权益 (576 USDT)
|
||||
* 根据考核规则,可能返回多条分配记录:
|
||||
* - 权益已激活:全部给该社区
|
||||
* - 权益未激活:考核前的部分给上级社区/总部,考核后的部分给该社区
|
||||
|
|
|
|||
|
|
@ -1,34 +1,34 @@
|
|||
export enum RightType {
|
||||
// === 系统费用类 ===
|
||||
COST_FEE = 'COST_FEE', // 成本费 400U
|
||||
OPERATION_FEE = 'OPERATION_FEE', // 运营费 300U
|
||||
HEADQUARTERS_BASE_FEE = 'HEADQUARTERS_BASE_FEE', // 总部社区基础费 9U
|
||||
RWAD_POOL_INJECTION = 'RWAD_POOL_INJECTION', // RWAD底池注入 800U
|
||||
COST_FEE = 'COST_FEE', // 成本费 2800U
|
||||
OPERATION_FEE = 'OPERATION_FEE', // 运营费 2100U
|
||||
HEADQUARTERS_BASE_FEE = 'HEADQUARTERS_BASE_FEE', // 总部社区基础费 203U
|
||||
RWAD_POOL_INJECTION = 'RWAD_POOL_INJECTION', // RWAD底池注入 5760U
|
||||
|
||||
// === 用户权益类 ===
|
||||
SHARE_RIGHT = 'SHARE_RIGHT', // 分享权益 500U
|
||||
PROVINCE_AREA_RIGHT = 'PROVINCE_AREA_RIGHT', // 省区域权益 15U + 1%算力
|
||||
PROVINCE_TEAM_RIGHT = 'PROVINCE_TEAM_RIGHT', // 省团队权益 20U
|
||||
CITY_AREA_RIGHT = 'CITY_AREA_RIGHT', // 市区域权益 35U + 2%算力
|
||||
CITY_TEAM_RIGHT = 'CITY_TEAM_RIGHT', // 市团队权益 40U
|
||||
COMMUNITY_RIGHT = 'COMMUNITY_RIGHT', // 社区权益 80U
|
||||
SHARE_RIGHT = 'SHARE_RIGHT', // 分享权益 3600U
|
||||
PROVINCE_AREA_RIGHT = 'PROVINCE_AREA_RIGHT', // 省区域权益 108U + 1%算力
|
||||
PROVINCE_TEAM_RIGHT = 'PROVINCE_TEAM_RIGHT', // 省团队权益 144U
|
||||
CITY_AREA_RIGHT = 'CITY_AREA_RIGHT', // 市区域权益 252U + 2%算力
|
||||
CITY_TEAM_RIGHT = 'CITY_TEAM_RIGHT', // 市团队权益 288U
|
||||
COMMUNITY_RIGHT = 'COMMUNITY_RIGHT', // 社区权益 576U
|
||||
}
|
||||
|
||||
// 权益金额配置
|
||||
export const RIGHT_AMOUNTS: Record<RightType, { usdt: number; hashpowerPercent: number }> = {
|
||||
// 系统费用类
|
||||
[RightType.COST_FEE]: { usdt: 400, hashpowerPercent: 0 },
|
||||
[RightType.OPERATION_FEE]: { usdt: 300, hashpowerPercent: 0 },
|
||||
[RightType.HEADQUARTERS_BASE_FEE]: { usdt: 9, hashpowerPercent: 0 },
|
||||
[RightType.RWAD_POOL_INJECTION]: { usdt: 800, hashpowerPercent: 0 },
|
||||
[RightType.COST_FEE]: { usdt: 2800, hashpowerPercent: 0 },
|
||||
[RightType.OPERATION_FEE]: { usdt: 2100, hashpowerPercent: 0 },
|
||||
[RightType.HEADQUARTERS_BASE_FEE]: { usdt: 203, hashpowerPercent: 0 },
|
||||
[RightType.RWAD_POOL_INJECTION]: { usdt: 5760, hashpowerPercent: 0 },
|
||||
|
||||
// 用户权益类
|
||||
[RightType.SHARE_RIGHT]: { usdt: 500, hashpowerPercent: 0 },
|
||||
[RightType.PROVINCE_AREA_RIGHT]: { usdt: 15, hashpowerPercent: 1 },
|
||||
[RightType.PROVINCE_TEAM_RIGHT]: { usdt: 20, hashpowerPercent: 0 },
|
||||
[RightType.CITY_AREA_RIGHT]: { usdt: 35, hashpowerPercent: 2 },
|
||||
[RightType.CITY_TEAM_RIGHT]: { usdt: 40, hashpowerPercent: 0 },
|
||||
[RightType.COMMUNITY_RIGHT]: { usdt: 80, hashpowerPercent: 0 },
|
||||
[RightType.SHARE_RIGHT]: { usdt: 3600, hashpowerPercent: 0 },
|
||||
[RightType.PROVINCE_AREA_RIGHT]: { usdt: 108, hashpowerPercent: 1 },
|
||||
[RightType.PROVINCE_TEAM_RIGHT]: { usdt: 144, hashpowerPercent: 0 },
|
||||
[RightType.CITY_AREA_RIGHT]: { usdt: 252, hashpowerPercent: 2 },
|
||||
[RightType.CITY_TEAM_RIGHT]: { usdt: 288, hashpowerPercent: 0 },
|
||||
[RightType.COMMUNITY_RIGHT]: { usdt: 576, hashpowerPercent: 0 },
|
||||
};
|
||||
|
||||
// 总金额验证: 400 + 300 + 9 + 800 + 500 + 15 + 20 + 35 + 40 + 80 = 2199 USDT
|
||||
// 总金额验证: 2800 + 2100 + 203 + 5760 + 3600 + 108 + 144 + 252 + 288 + 576 = 15831 USDT
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { PageContainer } from '@/components/layout';
|
||||
import { cn } from '@/utils/helpers';
|
||||
import styles from './settings.module.scss';
|
||||
import { systemConfigService, type DisplaySettings } from '@/services/systemConfigService';
|
||||
|
||||
/**
|
||||
* 后台账号数据接口
|
||||
|
|
@ -84,6 +85,9 @@ export default function SettingsPage() {
|
|||
// 前端展示设置
|
||||
const [allowNonAdopterView, setAllowNonAdopterView] = useState(false);
|
||||
const [heatDisplayMode, setHeatDisplayMode] = useState<'count' | 'level'>('count');
|
||||
const [displaySettingsLoading, setDisplaySettingsLoading] = useState(false);
|
||||
const [displaySettingsSaving, setDisplaySettingsSaving] = useState(false);
|
||||
const [displaySettingsError, setDisplaySettingsError] = useState<string | null>(null);
|
||||
|
||||
// 后台账号与安全
|
||||
const [approvalCount, setApprovalCount] = useState('3');
|
||||
|
|
@ -91,6 +95,45 @@ export default function SettingsPage() {
|
|||
const [logDate, setLogDate] = useState('');
|
||||
const [logSearch, setLogSearch] = useState('');
|
||||
|
||||
// 加载前端展示设置
|
||||
const loadDisplaySettings = useCallback(async () => {
|
||||
setDisplaySettingsLoading(true);
|
||||
setDisplaySettingsError(null);
|
||||
try {
|
||||
const settings = await systemConfigService.getDisplaySettings();
|
||||
setAllowNonAdopterView(settings.allowNonAdopterViewHeat);
|
||||
setHeatDisplayMode(settings.heatDisplayMode);
|
||||
} catch (error) {
|
||||
console.error('Failed to load display settings:', error);
|
||||
setDisplaySettingsError('加载展示设置失败');
|
||||
} finally {
|
||||
setDisplaySettingsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 保存前端展示设置
|
||||
const saveDisplaySettings = useCallback(async () => {
|
||||
setDisplaySettingsSaving(true);
|
||||
setDisplaySettingsError(null);
|
||||
try {
|
||||
await systemConfigService.updateDisplaySettings({
|
||||
allowNonAdopterViewHeat: allowNonAdopterView,
|
||||
heatDisplayMode: heatDisplayMode,
|
||||
});
|
||||
alert('展示设置保存成功');
|
||||
} catch (error) {
|
||||
console.error('Failed to save display settings:', error);
|
||||
setDisplaySettingsError('保存展示设置失败');
|
||||
} finally {
|
||||
setDisplaySettingsSaving(false);
|
||||
}
|
||||
}, [allowNonAdopterView, heatDisplayMode]);
|
||||
|
||||
// 组件挂载时加载展示设置
|
||||
useEffect(() => {
|
||||
loadDisplaySettings();
|
||||
}, [loadDisplaySettings]);
|
||||
|
||||
// 切换货币选择
|
||||
const toggleCurrency = (currency: string) => {
|
||||
if (settlementCurrencies.includes(currency)) {
|
||||
|
|
@ -364,8 +407,26 @@ export default function SettingsPage() {
|
|||
<section className={styles.settings__section}>
|
||||
<div className={styles.settings__sectionHeader}>
|
||||
<h2 className={styles.settings__sectionTitle}>前端展示设置</h2>
|
||||
<button className={styles.settings__resetBtn}>恢复默认</button>
|
||||
<div className={styles.settings__sectionActions}>
|
||||
<button
|
||||
className={styles.settings__resetBtn}
|
||||
onClick={loadDisplaySettings}
|
||||
disabled={displaySettingsLoading}
|
||||
>
|
||||
{displaySettingsLoading ? '加载中...' : '刷新'}
|
||||
</button>
|
||||
<button
|
||||
className={styles.settings__saveBtn}
|
||||
onClick={saveDisplaySettings}
|
||||
disabled={displaySettingsSaving || displaySettingsLoading}
|
||||
>
|
||||
{displaySettingsSaving ? '保存中...' : '保存展示设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{displaySettingsError && (
|
||||
<div className={styles.settings__error}>{displaySettingsError}</div>
|
||||
)}
|
||||
<div className={styles.settings__content}>
|
||||
<div className={styles.settings__toggleRow}>
|
||||
<span className={styles.settings__toggleLabel}>允许未认种用户查看各省认种热度</span>
|
||||
|
|
|
|||
|
|
@ -95,6 +95,28 @@
|
|||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.settings__sectionActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings__error {
|
||||
align-self: stretch;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background-color: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #dc2626;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
/* 设置内容区域 */
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import styles from './users.module.scss';
|
|||
// 骨架屏组件
|
||||
const TableRowSkeleton = () => (
|
||||
<div className={styles.users__tableRow}>
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
{Array.from({ length: 13 }).map((_, i) => (
|
||||
<div key={i} className={styles.users__tableCellSkeleton}>
|
||||
<div className={styles.users__skeleton} />
|
||||
</div>
|
||||
|
|
@ -350,6 +350,9 @@ export default function UsersPage() {
|
|||
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--nickname'])}>
|
||||
<b>昵称</b>
|
||||
</div>
|
||||
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--phone'])}>
|
||||
<b>手机号</b>
|
||||
</div>
|
||||
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--adoptions'])}>
|
||||
<b>账户认种量</b>
|
||||
</div>
|
||||
|
|
@ -437,6 +440,11 @@ export default function UsersPage() {
|
|||
{user.nickname || '-'}
|
||||
</div>
|
||||
|
||||
{/* 手机号 */}
|
||||
<div className={cn(styles.users__tableCell, styles['users__tableCell--phone'])}>
|
||||
{user.phoneNumberMasked || '-'}
|
||||
</div>
|
||||
|
||||
{/* 账户认种量 */}
|
||||
<div className={cn(styles.users__tableCell, styles['users__tableCell--adoptions'])}>
|
||||
{formatNumber(user.personalAdoptions)}
|
||||
|
|
|
|||
|
|
@ -253,6 +253,11 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&--phone {
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&--adoptions {
|
||||
width: 70px;
|
||||
flex-shrink: 0;
|
||||
|
|
@ -358,6 +363,13 @@
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
&--phone {
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
font-family: 'Consolas', monospace;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&--adoptions {
|
||||
width: 70px;
|
||||
flex-shrink: 0;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* 系统配置服务
|
||||
* 负责系统配置的API调用
|
||||
*/
|
||||
|
||||
import apiClient from '@/infrastructure/api/client';
|
||||
import { API_ENDPOINTS } from '@/infrastructure/api/endpoints';
|
||||
|
||||
/** 前端展示设置 */
|
||||
export interface DisplaySettings {
|
||||
/** 是否允许未认种用户查看各省认种热度 */
|
||||
allowNonAdopterViewHeat: boolean;
|
||||
/** 热度展示方式: 'count' 显示具体数量, 'level' 仅显示热度等级 */
|
||||
heatDisplayMode: 'count' | 'level';
|
||||
}
|
||||
|
||||
/** 更新前端展示设置请求 */
|
||||
export interface UpdateDisplaySettingsRequest {
|
||||
allowNonAdopterViewHeat?: boolean;
|
||||
heatDisplayMode?: 'count' | 'level';
|
||||
}
|
||||
|
||||
/** 系统配置项 */
|
||||
export interface SystemConfigItem {
|
||||
key: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统配置服务
|
||||
*/
|
||||
export const systemConfigService = {
|
||||
/**
|
||||
* 获取所有系统配置
|
||||
*/
|
||||
async getAllConfigs(): Promise<SystemConfigItem[]> {
|
||||
return apiClient.get(API_ENDPOINTS.SYSTEM_CONFIG.ALL);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取前端展示设置
|
||||
*/
|
||||
async getDisplaySettings(): Promise<DisplaySettings> {
|
||||
return apiClient.get(API_ENDPOINTS.SYSTEM_CONFIG.DISPLAY_SETTINGS);
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新前端展示设置
|
||||
*/
|
||||
async updateDisplaySettings(settings: UpdateDisplaySettingsRequest): Promise<DisplaySettings> {
|
||||
return apiClient.put(API_ENDPOINTS.SYSTEM_CONFIG.DISPLAY_SETTINGS, settings);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取单个配置
|
||||
*/
|
||||
async getConfigByKey(key: string): Promise<SystemConfigItem | null> {
|
||||
return apiClient.get(API_ENDPOINTS.SYSTEM_CONFIG.BY_KEY(key));
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新单个配置
|
||||
*/
|
||||
async updateConfigByKey(key: string, value: string, description?: string): Promise<SystemConfigItem> {
|
||||
return apiClient.put(API_ENDPOINTS.SYSTEM_CONFIG.BY_KEY(key), { value, description });
|
||||
},
|
||||
};
|
||||
|
||||
export default systemConfigService;
|
||||
|
|
@ -13,6 +13,7 @@ export interface UserListItem {
|
|||
accountSequence: string;
|
||||
avatar: string | null;
|
||||
nickname: string | null;
|
||||
phoneNumberMasked: string | null;
|
||||
personalAdoptions: number;
|
||||
teamAddresses: number;
|
||||
teamAdoptions: number;
|
||||
|
|
@ -32,7 +33,6 @@ export interface UserListItem {
|
|||
|
||||
/** 用户详情 */
|
||||
export interface UserDetail extends UserListItem {
|
||||
phoneNumberMasked: string | null;
|
||||
kycStatus: string;
|
||||
registeredAt: string;
|
||||
lastActiveAt: string | null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,286 @@
|
|||
{
|
||||
"v": "5.7.4",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 30,
|
||||
"w": 100,
|
||||
"h": 120,
|
||||
"nm": "Stickman Running",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "Head",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100 },
|
||||
"r": { "a": 0, "k": 0 },
|
||||
"p": { "a": 0, "k": [50, 20, 0] },
|
||||
"a": { "a": 0, "k": [0, 0, 0] },
|
||||
"s": { "a": 0, "k": [100, 100, 100] }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "el",
|
||||
"s": { "a": 0, "k": [20, 20] },
|
||||
"p": { "a": 0, "k": [0, 0] },
|
||||
"nm": "Head Circle"
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": { "a": 0, "k": [0.83, 0.69, 0.22, 1] },
|
||||
"o": { "a": 0, "k": 100 },
|
||||
"w": { "a": 0, "k": 3 },
|
||||
"lc": 2,
|
||||
"lj": 2
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": { "a": 0, "k": [0.83, 0.69, 0.22, 1] },
|
||||
"o": { "a": 0, "k": 100 }
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 30,
|
||||
"st": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 2,
|
||||
"ty": 4,
|
||||
"nm": "Body",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100 },
|
||||
"r": { "a": 0, "k": 0 },
|
||||
"p": { "a": 0, "k": [50, 50, 0] },
|
||||
"a": { "a": 0, "k": [0, 0, 0] },
|
||||
"s": { "a": 0, "k": [100, 100, 100] }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "sh",
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [[0, 0], [0, 0]],
|
||||
"o": [[0, 0], [0, 0]],
|
||||
"v": [[0, -20], [0, 15]],
|
||||
"c": false
|
||||
}
|
||||
},
|
||||
"nm": "Body Line"
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": { "a": 0, "k": [0.83, 0.69, 0.22, 1] },
|
||||
"o": { "a": 0, "k": 100 },
|
||||
"w": { "a": 0, "k": 3 },
|
||||
"lc": 2,
|
||||
"lj": 2
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 30,
|
||||
"st": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 3,
|
||||
"ty": 4,
|
||||
"nm": "Left Arm",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100 },
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{ "i": { "x": [0.5], "y": [1] }, "o": { "x": [0.5], "y": [0] }, "t": 0, "s": [-30] },
|
||||
{ "i": { "x": [0.5], "y": [1] }, "o": { "x": [0.5], "y": [0] }, "t": 15, "s": [30] },
|
||||
{ "t": 30, "s": [-30] }
|
||||
]
|
||||
},
|
||||
"p": { "a": 0, "k": [50, 38, 0] },
|
||||
"a": { "a": 0, "k": [0, 0, 0] },
|
||||
"s": { "a": 0, "k": [100, 100, 100] }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "sh",
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [[0, 0], [0, 0]],
|
||||
"o": [[0, 0], [0, 0]],
|
||||
"v": [[0, 0], [-15, 15]],
|
||||
"c": false
|
||||
}
|
||||
},
|
||||
"nm": "Left Arm Line"
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": { "a": 0, "k": [0.83, 0.69, 0.22, 1] },
|
||||
"o": { "a": 0, "k": 100 },
|
||||
"w": { "a": 0, "k": 3 },
|
||||
"lc": 2,
|
||||
"lj": 2
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 30,
|
||||
"st": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 4,
|
||||
"ty": 4,
|
||||
"nm": "Right Arm",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100 },
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{ "i": { "x": [0.5], "y": [1] }, "o": { "x": [0.5], "y": [0] }, "t": 0, "s": [30] },
|
||||
{ "i": { "x": [0.5], "y": [1] }, "o": { "x": [0.5], "y": [0] }, "t": 15, "s": [-30] },
|
||||
{ "t": 30, "s": [30] }
|
||||
]
|
||||
},
|
||||
"p": { "a": 0, "k": [50, 38, 0] },
|
||||
"a": { "a": 0, "k": [0, 0, 0] },
|
||||
"s": { "a": 0, "k": [100, 100, 100] }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "sh",
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [[0, 0], [0, 0]],
|
||||
"o": [[0, 0], [0, 0]],
|
||||
"v": [[0, 0], [15, 15]],
|
||||
"c": false
|
||||
}
|
||||
},
|
||||
"nm": "Right Arm Line"
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": { "a": 0, "k": [0.83, 0.69, 0.22, 1] },
|
||||
"o": { "a": 0, "k": 100 },
|
||||
"w": { "a": 0, "k": 3 },
|
||||
"lc": 2,
|
||||
"lj": 2
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 30,
|
||||
"st": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 5,
|
||||
"ty": 4,
|
||||
"nm": "Left Leg",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100 },
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{ "i": { "x": [0.5], "y": [1] }, "o": { "x": [0.5], "y": [0] }, "t": 0, "s": [30] },
|
||||
{ "i": { "x": [0.5], "y": [1] }, "o": { "x": [0.5], "y": [0] }, "t": 15, "s": [-30] },
|
||||
{ "t": 30, "s": [30] }
|
||||
]
|
||||
},
|
||||
"p": { "a": 0, "k": [50, 65, 0] },
|
||||
"a": { "a": 0, "k": [0, 0, 0] },
|
||||
"s": { "a": 0, "k": [100, 100, 100] }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "sh",
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [[0, 0], [0, 0]],
|
||||
"o": [[0, 0], [0, 0]],
|
||||
"v": [[0, 0], [-10, 30]],
|
||||
"c": false
|
||||
}
|
||||
},
|
||||
"nm": "Left Leg Line"
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": { "a": 0, "k": [0.83, 0.69, 0.22, 1] },
|
||||
"o": { "a": 0, "k": 100 },
|
||||
"w": { "a": 0, "k": 3 },
|
||||
"lc": 2,
|
||||
"lj": 2
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 30,
|
||||
"st": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 6,
|
||||
"ty": 4,
|
||||
"nm": "Right Leg",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100 },
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{ "i": { "x": [0.5], "y": [1] }, "o": { "x": [0.5], "y": [0] }, "t": 0, "s": [-30] },
|
||||
{ "i": { "x": [0.5], "y": [1] }, "o": { "x": [0.5], "y": [0] }, "t": 15, "s": [30] },
|
||||
{ "t": 30, "s": [-30] }
|
||||
]
|
||||
},
|
||||
"p": { "a": 0, "k": [50, 65, 0] },
|
||||
"a": { "a": 0, "k": [0, 0, 0] },
|
||||
"s": { "a": 0, "k": [100, 100, 100] }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "sh",
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [[0, 0], [0, 0]],
|
||||
"o": [[0, 0], [0, 0]],
|
||||
"v": [[0, 0], [10, 30]],
|
||||
"c": false
|
||||
}
|
||||
},
|
||||
"nm": "Right Leg Line"
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": { "a": 0, "k": [0.83, 0.69, 0.22, 1] },
|
||||
"o": { "a": 0, "k": 100 },
|
||||
"w": { "a": 0, "k": 3 },
|
||||
"lc": 2,
|
||||
"lj": 2
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 30,
|
||||
"st": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
|
|
@ -79,10 +79,15 @@ class ApiEndpoints {
|
|||
static const String authorizations = '/authorizations';
|
||||
static const String myAuthorizations = '$authorizations/my'; // 获取我的授权列表
|
||||
static const String myCommunityHierarchy = '$authorizations/my/community-hierarchy'; // 获取社区层级
|
||||
static const String stickmanRanking = '$authorizations/ranking/stickman'; // 火柴人排名
|
||||
|
||||
// Telemetry (-> Reporting Service)
|
||||
static const String telemetry = '/telemetry';
|
||||
static const String telemetrySession = '$telemetry/session';
|
||||
static const String telemetryHeartbeat = '$telemetry/heartbeat';
|
||||
static const String telemetryEvents = '$telemetry/events';
|
||||
|
||||
// System Config (-> Admin Service)
|
||||
static const String systemConfig = '/system-config';
|
||||
static const String displaySettings = '$systemConfig/display/settings';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import '../services/wallet_service.dart';
|
|||
import '../services/planting_service.dart';
|
||||
import '../services/reward_service.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../services/system_config_service.dart';
|
||||
|
||||
// Storage Providers
|
||||
final secureStorageProvider = Provider<SecureStorage>((ref) {
|
||||
|
|
@ -86,6 +87,12 @@ final notificationServiceProvider = Provider<NotificationService>((ref) {
|
|||
return NotificationService(apiClient: apiClient);
|
||||
});
|
||||
|
||||
// System Config Service Provider (调用 admin-service)
|
||||
final systemConfigServiceProvider = Provider<SystemConfigService>((ref) {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
return SystemConfigService(apiClient: apiClient);
|
||||
});
|
||||
|
||||
// Override provider with initialized instance
|
||||
ProviderContainer createProviderContainer(LocalStorage localStorage) {
|
||||
return ProviderContainer(
|
||||
|
|
|
|||
|
|
@ -179,6 +179,43 @@ String _maskAddress(String? address) {
|
|||
return '${address.substring(0, 6)}...${address.substring(address.length - 4)}';
|
||||
}
|
||||
|
||||
/// 短信验证码类型
|
||||
enum SmsCodeType {
|
||||
register, // 注册
|
||||
login, // 登录
|
||||
bind, // 绑定手机号
|
||||
recover, // 恢复账号
|
||||
}
|
||||
|
||||
/// 手机号注册响应 (与登录响应相同)
|
||||
class PhoneAuthResponse {
|
||||
final String userId;
|
||||
final String accountSequence; // 用户序列号
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
|
||||
PhoneAuthResponse({
|
||||
required this.userId,
|
||||
required this.accountSequence,
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
});
|
||||
|
||||
factory PhoneAuthResponse.fromJson(Map<String, dynamic> json) {
|
||||
debugPrint('[AccountService] 解析 PhoneAuthResponse: ${json.keys.toList()}');
|
||||
return PhoneAuthResponse(
|
||||
userId: json['userId'] as String,
|
||||
accountSequence: json['accountSequence']?.toString() ?? '',
|
||||
accessToken: json['accessToken'] as String,
|
||||
refreshToken: json['refreshToken'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'PhoneAuthResponse(userId: $userId, accountSequence: $accountSequence)';
|
||||
}
|
||||
|
||||
/// 恢复账户响应
|
||||
class RecoverAccountResponse {
|
||||
final String userId;
|
||||
|
|
@ -1252,4 +1289,327 @@ class AccountService {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 手机号注册/登录 ====================
|
||||
|
||||
/// 验证短信验证码(仅验证,不登录/注册)
|
||||
///
|
||||
/// [phoneNumber] - 手机号
|
||||
/// [smsCode] - 6位短信验证码
|
||||
/// [type] - 验证码类型
|
||||
Future<void> verifySmsCode({
|
||||
required String phoneNumber,
|
||||
required String smsCode,
|
||||
required SmsCodeType type,
|
||||
}) async {
|
||||
debugPrint('$_tag verifySmsCode() - 验证短信验证码');
|
||||
debugPrint('$_tag verifySmsCode() - 手机号: ${_maskPhoneNumber(phoneNumber)}, 类型: ${type.name}');
|
||||
|
||||
try {
|
||||
final typeStr = type.name.toUpperCase();
|
||||
|
||||
debugPrint('$_tag verifySmsCode() - 调用 POST /user/verify-sms-code');
|
||||
final response = await _apiClient.post(
|
||||
'/user/verify-sms-code',
|
||||
data: {
|
||||
'phoneNumber': phoneNumber,
|
||||
'smsCode': smsCode,
|
||||
'type': typeStr,
|
||||
},
|
||||
);
|
||||
debugPrint('$_tag verifySmsCode() - API 响应状态码: ${response.statusCode}');
|
||||
|
||||
// 验证成功后保存手机号
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.phoneNumber,
|
||||
value: phoneNumber,
|
||||
);
|
||||
debugPrint('$_tag verifySmsCode() - 短信验证码验证成功');
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('$_tag verifySmsCode() - API 异常: $e');
|
||||
rethrow;
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('$_tag verifySmsCode() - 未知异常: $e');
|
||||
debugPrint('$_tag verifySmsCode() - 堆栈: $stackTrace');
|
||||
throw ApiException('验证码验证失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送短信验证码
|
||||
///
|
||||
/// [phoneNumber] - 手机号(中国大陆格式:1开头,11位)
|
||||
/// [type] - 验证码类型
|
||||
Future<void> sendSmsCode(String phoneNumber, SmsCodeType type) async {
|
||||
debugPrint('$_tag sendSmsCode() - 发送短信验证码');
|
||||
debugPrint('$_tag sendSmsCode() - 手机号: ${_maskPhoneNumber(phoneNumber)}, 类型: ${type.name}');
|
||||
|
||||
try {
|
||||
// 转换类型枚举为后端格式
|
||||
final typeStr = type.name.toUpperCase();
|
||||
|
||||
debugPrint('$_tag sendSmsCode() - 调用 POST /user/send-sms-code');
|
||||
final response = await _apiClient.post(
|
||||
'/user/send-sms-code',
|
||||
data: {
|
||||
'phoneNumber': phoneNumber,
|
||||
'type': typeStr,
|
||||
},
|
||||
);
|
||||
debugPrint('$_tag sendSmsCode() - API 响应状态码: ${response.statusCode}');
|
||||
debugPrint('$_tag sendSmsCode() - 短信验证码已发送');
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('$_tag sendSmsCode() - API 异常: $e');
|
||||
rethrow;
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('$_tag sendSmsCode() - 未知异常: $e');
|
||||
debugPrint('$_tag sendSmsCode() - 堆栈: $stackTrace');
|
||||
throw ApiException('发送验证码失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 手机号注册
|
||||
///
|
||||
/// [phoneNumber] - 手机号
|
||||
/// [smsCode] - 6位短信验证码
|
||||
/// [inviterReferralCode] - 邀请人推荐码(可选)
|
||||
Future<PhoneAuthResponse> registerByPhone({
|
||||
required String phoneNumber,
|
||||
required String smsCode,
|
||||
String? inviterReferralCode,
|
||||
}) async {
|
||||
debugPrint('$_tag registerByPhone() - 开始手机号注册');
|
||||
debugPrint('$_tag registerByPhone() - 手机号: ${_maskPhoneNumber(phoneNumber)}');
|
||||
debugPrint('$_tag registerByPhone() - 邀请码: ${inviterReferralCode ?? "无"}');
|
||||
|
||||
try {
|
||||
// 获取设备ID
|
||||
final deviceId = await getDeviceId();
|
||||
debugPrint('$_tag registerByPhone() - 获取设备ID成功');
|
||||
|
||||
// 获取设备硬件信息
|
||||
final deviceInfo = await getDeviceHardwareInfo();
|
||||
debugPrint('$_tag registerByPhone() - 获取设备硬件信息成功');
|
||||
|
||||
final deviceName = '${deviceInfo.brand ?? ''} ${deviceInfo.model ?? ''}'.trim();
|
||||
|
||||
// 调用 API
|
||||
debugPrint('$_tag registerByPhone() - 调用 POST /user/register');
|
||||
final response = await _apiClient.post(
|
||||
'/user/register',
|
||||
data: {
|
||||
'phoneNumber': phoneNumber,
|
||||
'smsCode': smsCode,
|
||||
'deviceId': deviceId,
|
||||
'deviceName': deviceName.isEmpty ? null : deviceName,
|
||||
if (inviterReferralCode != null) 'inviterReferralCode': inviterReferralCode,
|
||||
},
|
||||
);
|
||||
debugPrint('$_tag registerByPhone() - API 响应状态码: ${response.statusCode}');
|
||||
|
||||
if (response.data == null) {
|
||||
debugPrint('$_tag registerByPhone() - 错误: API 返回空响应');
|
||||
throw const ApiException('注册失败: 空响应');
|
||||
}
|
||||
|
||||
debugPrint('$_tag registerByPhone() - 解析响应数据');
|
||||
final responseData = response.data as Map<String, dynamic>;
|
||||
final data = responseData['data'] as Map<String, dynamic>;
|
||||
final result = PhoneAuthResponse.fromJson(data);
|
||||
debugPrint('$_tag registerByPhone() - 解析成功: $result');
|
||||
|
||||
// 保存账号数据
|
||||
debugPrint('$_tag registerByPhone() - 保存账号数据');
|
||||
await _savePhoneAuthData(result, deviceId, phoneNumber);
|
||||
|
||||
debugPrint('$_tag registerByPhone() - 手机号注册完成');
|
||||
return result;
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('$_tag registerByPhone() - API 异常: $e');
|
||||
rethrow;
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('$_tag registerByPhone() - 未知异常: $e');
|
||||
debugPrint('$_tag registerByPhone() - 堆栈: $stackTrace');
|
||||
throw ApiException('注册失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 手机号登录
|
||||
///
|
||||
/// [phoneNumber] - 手机号
|
||||
/// [smsCode] - 6位短信验证码
|
||||
Future<PhoneAuthResponse> loginByPhone({
|
||||
required String phoneNumber,
|
||||
required String smsCode,
|
||||
}) async {
|
||||
debugPrint('$_tag loginByPhone() - 开始手机号登录');
|
||||
debugPrint('$_tag loginByPhone() - 手机号: ${_maskPhoneNumber(phoneNumber)}');
|
||||
|
||||
try {
|
||||
// 获取设备ID
|
||||
final deviceId = await getDeviceId();
|
||||
debugPrint('$_tag loginByPhone() - 获取设备ID成功');
|
||||
|
||||
// 调用 API
|
||||
debugPrint('$_tag loginByPhone() - 调用 POST /user/login');
|
||||
final response = await _apiClient.post(
|
||||
'/user/login',
|
||||
data: {
|
||||
'phoneNumber': phoneNumber,
|
||||
'smsCode': smsCode,
|
||||
'deviceId': deviceId,
|
||||
},
|
||||
);
|
||||
debugPrint('$_tag loginByPhone() - API 响应状态码: ${response.statusCode}');
|
||||
|
||||
if (response.data == null) {
|
||||
debugPrint('$_tag loginByPhone() - 错误: API 返回空响应');
|
||||
throw const ApiException('登录失败: 空响应');
|
||||
}
|
||||
|
||||
debugPrint('$_tag loginByPhone() - 解析响应数据');
|
||||
final responseData = response.data as Map<String, dynamic>;
|
||||
final data = responseData['data'] as Map<String, dynamic>;
|
||||
final result = PhoneAuthResponse.fromJson(data);
|
||||
debugPrint('$_tag loginByPhone() - 解析成功: $result');
|
||||
|
||||
// 保存账号数据
|
||||
debugPrint('$_tag loginByPhone() - 保存账号数据');
|
||||
await _savePhoneAuthData(result, deviceId, phoneNumber);
|
||||
|
||||
debugPrint('$_tag loginByPhone() - 手机号登录完成');
|
||||
return result;
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('$_tag loginByPhone() - API 异常: $e');
|
||||
rethrow;
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('$_tag loginByPhone() - 未知异常: $e');
|
||||
debugPrint('$_tag loginByPhone() - 堆栈: $stackTrace');
|
||||
throw ApiException('登录失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存手机号认证数据
|
||||
Future<void> _savePhoneAuthData(
|
||||
PhoneAuthResponse response,
|
||||
String deviceId,
|
||||
String phoneNumber,
|
||||
) async {
|
||||
debugPrint('$_tag _savePhoneAuthData() - 开始保存手机号认证数据');
|
||||
|
||||
// 保存用户序列号
|
||||
debugPrint('$_tag _savePhoneAuthData() - 保存 userSerialNum: ${response.accountSequence}');
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.userSerialNum,
|
||||
value: response.accountSequence,
|
||||
);
|
||||
|
||||
// 保存 Token
|
||||
debugPrint('$_tag _savePhoneAuthData() - 保存 accessToken (长度: ${response.accessToken.length})');
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.accessToken,
|
||||
value: response.accessToken,
|
||||
);
|
||||
|
||||
debugPrint('$_tag _savePhoneAuthData() - 保存 refreshToken (长度: ${response.refreshToken.length})');
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.refreshToken,
|
||||
value: response.refreshToken,
|
||||
);
|
||||
|
||||
// 保存设备 ID
|
||||
debugPrint('$_tag _savePhoneAuthData() - 保存 deviceId: ${_maskAddress(deviceId)}');
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.deviceId,
|
||||
value: deviceId,
|
||||
);
|
||||
|
||||
// 保存手机号
|
||||
debugPrint('$_tag _savePhoneAuthData() - 保存手机号: ${_maskPhoneNumber(phoneNumber)}');
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.phoneNumber,
|
||||
value: phoneNumber,
|
||||
);
|
||||
|
||||
// 标记账号已创建
|
||||
debugPrint('$_tag _savePhoneAuthData() - 标记账号已创建');
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.isAccountCreated,
|
||||
value: 'true',
|
||||
);
|
||||
|
||||
// 设置遥测服务的用户ID
|
||||
if (TelemetryService().isInitialized) {
|
||||
TelemetryService().setUserId(response.accountSequence);
|
||||
debugPrint('$_tag _savePhoneAuthData() - 设置TelemetryService userId: ${response.accountSequence}');
|
||||
}
|
||||
|
||||
// 设置 Sentry 用户信息
|
||||
if (SentryService().isInitialized) {
|
||||
SentryService().setUser(userId: response.accountSequence);
|
||||
debugPrint('$_tag _savePhoneAuthData() - 设置SentryService userId: ${response.accountSequence}');
|
||||
}
|
||||
|
||||
debugPrint('$_tag _savePhoneAuthData() - 手机号认证数据保存完成');
|
||||
}
|
||||
|
||||
/// 获取手机号(从本地存储)
|
||||
Future<String?> getPhoneNumber() async {
|
||||
debugPrint('$_tag getPhoneNumber() - 获取手机号');
|
||||
final result = await _secureStorage.read(key: StorageKeys.phoneNumber);
|
||||
if (result != null) {
|
||||
debugPrint('$_tag getPhoneNumber() - 结果: ${_maskPhoneNumber(result)}');
|
||||
} else {
|
||||
debugPrint('$_tag getPhoneNumber() - 未找到手机号');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 设置登录密码
|
||||
///
|
||||
/// [password] - 登录密码(6-20位,包含字母和数字)
|
||||
Future<void> setLoginPassword(String password) async {
|
||||
debugPrint('$_tag setLoginPassword() - 开始设置登录密码');
|
||||
|
||||
try {
|
||||
// 调用 API
|
||||
debugPrint('$_tag setLoginPassword() - 调用 POST /user/set-password');
|
||||
final response = await _apiClient.post(
|
||||
'/user/set-password',
|
||||
data: {
|
||||
'password': password,
|
||||
},
|
||||
);
|
||||
debugPrint('$_tag setLoginPassword() - API 响应状态码: ${response.statusCode}');
|
||||
|
||||
// 标记密码已设置
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.isPasswordSet,
|
||||
value: 'true',
|
||||
);
|
||||
|
||||
debugPrint('$_tag setLoginPassword() - 登录密码设置完成');
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('$_tag setLoginPassword() - API 异常: $e');
|
||||
rethrow;
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('$_tag setLoginPassword() - 未知异常: $e');
|
||||
debugPrint('$_tag setLoginPassword() - 堆栈: $stackTrace');
|
||||
throw ApiException('设置密码失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否已设置密码
|
||||
Future<bool> isPasswordSet() async {
|
||||
debugPrint('$_tag isPasswordSet() - 检查是否已设置密码');
|
||||
final isSet = await _secureStorage.read(key: StorageKeys.isPasswordSet);
|
||||
final result = isSet == 'true';
|
||||
debugPrint('$_tag isPasswordSet() - 结果: $result');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// 遮蔽手机号中间部分,用于日志输出
|
||||
String _maskPhoneNumber(String phoneNumber) {
|
||||
if (phoneNumber.length < 7) return phoneNumber;
|
||||
return '${phoneNumber.substring(0, 3)}****${phoneNumber.substring(phoneNumber.length - 4)}';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -312,11 +312,42 @@ class CommunityHierarchy {
|
|||
}
|
||||
}
|
||||
|
||||
/// 火柴人排名响应
|
||||
class StickmanRankingResponse {
|
||||
final String id;
|
||||
final String nickname;
|
||||
final String? avatarUrl;
|
||||
final int completedCount;
|
||||
final double monthlyEarnings;
|
||||
final bool isCurrentUser;
|
||||
|
||||
StickmanRankingResponse({
|
||||
required this.id,
|
||||
required this.nickname,
|
||||
this.avatarUrl,
|
||||
required this.completedCount,
|
||||
required this.monthlyEarnings,
|
||||
required this.isCurrentUser,
|
||||
});
|
||||
|
||||
factory StickmanRankingResponse.fromJson(Map<String, dynamic> json) {
|
||||
return StickmanRankingResponse(
|
||||
id: json['id']?.toString() ?? '',
|
||||
nickname: json['nickname'] ?? '',
|
||||
avatarUrl: json['avatarUrl'],
|
||||
completedCount: json['completedCount'] ?? 0,
|
||||
monthlyEarnings: (json['monthlyEarnings'] ?? 0).toDouble(),
|
||||
isCurrentUser: json['isCurrentUser'] ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 授权服务
|
||||
///
|
||||
/// 处理用户授权相关功能:
|
||||
/// - 获取用户授权列表
|
||||
/// - 社区/省公司/市公司授权信息
|
||||
/// - 火柴人排名数据
|
||||
class AuthorizationService {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
|
|
@ -401,4 +432,52 @@ class AuthorizationService {
|
|||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取火柴人排名数据
|
||||
///
|
||||
/// 用于显示省公司/市公司的排名赛跑
|
||||
/// [month] 月份格式: YYYY-MM
|
||||
/// [roleType] 角色类型: AUTH_PROVINCE_COMPANY 或 AUTH_CITY_COMPANY
|
||||
/// [regionCode] 区域代码/名称
|
||||
Future<List<StickmanRankingResponse>> getStickmanRanking({
|
||||
required String month,
|
||||
required String roleType,
|
||||
required String regionCode,
|
||||
}) async {
|
||||
try {
|
||||
debugPrint('获取火柴人排名: month=$month, roleType=$roleType, regionCode=$regionCode');
|
||||
final response = await _apiClient.get(
|
||||
ApiEndpoints.stickmanRanking,
|
||||
queryParameters: {
|
||||
'month': month,
|
||||
'roleType': roleType,
|
||||
'regionCode': regionCode,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final responseData = response.data;
|
||||
List<dynamic>? dataList;
|
||||
if (responseData is Map<String, dynamic>) {
|
||||
dataList = responseData['data'] as List<dynamic>?;
|
||||
} else if (responseData is List) {
|
||||
dataList = responseData;
|
||||
}
|
||||
|
||||
if (dataList != null) {
|
||||
final rankings = dataList
|
||||
.map((e) => StickmanRankingResponse.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
debugPrint('火柴人排名获取成功: ${rankings.length} 个');
|
||||
return rankings;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
throw Exception('获取火柴人排名失败');
|
||||
} catch (e) {
|
||||
debugPrint('获取火柴人排名失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import '../network/api_client.dart';
|
||||
|
||||
/// 热度展示方式
|
||||
enum HeatDisplayMode {
|
||||
/// 显示具体认种数量
|
||||
count,
|
||||
/// 仅显示热度等级(高/中/低)
|
||||
level,
|
||||
}
|
||||
|
||||
/// 前端展示设置
|
||||
class DisplaySettings {
|
||||
/// 是否允许未认种用户查看各省认种热度
|
||||
final bool allowNonAdopterViewHeat;
|
||||
|
||||
/// 热度展示方式
|
||||
final HeatDisplayMode heatDisplayMode;
|
||||
|
||||
DisplaySettings({
|
||||
required this.allowNonAdopterViewHeat,
|
||||
required this.heatDisplayMode,
|
||||
});
|
||||
|
||||
factory DisplaySettings.fromJson(Map<String, dynamic> json) {
|
||||
return DisplaySettings(
|
||||
allowNonAdopterViewHeat: json['allowNonAdopterViewHeat'] ?? false,
|
||||
heatDisplayMode: _parseHeatDisplayMode(json['heatDisplayMode']),
|
||||
);
|
||||
}
|
||||
|
||||
static HeatDisplayMode _parseHeatDisplayMode(String? mode) {
|
||||
switch (mode) {
|
||||
case 'level':
|
||||
return HeatDisplayMode.level;
|
||||
case 'count':
|
||||
default:
|
||||
return HeatDisplayMode.count;
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认设置
|
||||
factory DisplaySettings.defaultSettings() {
|
||||
return DisplaySettings(
|
||||
allowNonAdopterViewHeat: false,
|
||||
heatDisplayMode: HeatDisplayMode.count,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 系统配置服务
|
||||
class SystemConfigService {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
/// 缓存的展示设置
|
||||
DisplaySettings? _cachedDisplaySettings;
|
||||
|
||||
/// 上次获取设置的时间
|
||||
DateTime? _lastFetchTime;
|
||||
|
||||
/// 缓存有效期(5分钟)
|
||||
static const Duration _cacheExpiration = Duration(minutes: 5);
|
||||
|
||||
SystemConfigService({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||
|
||||
/// 获取前端展示设置
|
||||
///
|
||||
/// [forceRefresh] 强制刷新,不使用缓存
|
||||
Future<DisplaySettings> getDisplaySettings({bool forceRefresh = false}) async {
|
||||
// 检查缓存是否有效
|
||||
if (!forceRefresh && _cachedDisplaySettings != null && _lastFetchTime != null) {
|
||||
final cacheAge = DateTime.now().difference(_lastFetchTime!);
|
||||
if (cacheAge < _cacheExpiration) {
|
||||
return _cachedDisplaySettings!;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _apiClient.get(
|
||||
'/admin-service/api/v1/system-config/display/settings',
|
||||
);
|
||||
|
||||
_cachedDisplaySettings = DisplaySettings.fromJson(response.data);
|
||||
_lastFetchTime = DateTime.now();
|
||||
|
||||
debugPrint('[SystemConfigService] 获取展示设置成功: allowNonAdopterViewHeat=${_cachedDisplaySettings!.allowNonAdopterViewHeat}, heatDisplayMode=${_cachedDisplaySettings!.heatDisplayMode}');
|
||||
|
||||
return _cachedDisplaySettings!;
|
||||
} catch (e) {
|
||||
debugPrint('[SystemConfigService] 获取展示设置失败: $e');
|
||||
|
||||
// 如果有缓存则返回缓存,否则返回默认设置
|
||||
return _cachedDisplaySettings ?? DisplaySettings.defaultSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除缓存
|
||||
void clearCache() {
|
||||
_cachedDisplaySettings = null;
|
||||
_lastFetchTime = null;
|
||||
}
|
||||
|
||||
/// 检查用户是否可以查看省份热度
|
||||
///
|
||||
/// [hasAdopted] 用户是否已认种
|
||||
Future<bool> canViewProvinceHeat({required bool hasAdopted}) async {
|
||||
if (hasAdopted) {
|
||||
// 已认种用户始终可以查看
|
||||
return true;
|
||||
}
|
||||
|
||||
final settings = await getDisplaySettings();
|
||||
return settings.allowNonAdopterViewHeat;
|
||||
}
|
||||
|
||||
/// 获取当前的热度展示方式
|
||||
Future<HeatDisplayMode> getHeatDisplayMode() async {
|
||||
final settings = await getDisplaySettings();
|
||||
return settings.heatDisplayMode;
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,8 @@ class StorageKeys {
|
|||
static const String inviterSequence = 'inviter_sequence'; // 推荐人序列号
|
||||
static const String inviterReferralCode = 'inviter_referral_code'; // 邀请人推荐码(注册前临时存储)
|
||||
static const String isAccountCreated = 'is_account_created'; // 账号是否已创建
|
||||
static const String phoneNumber = 'phone_number'; // 绑定的手机号
|
||||
static const String isPasswordSet = 'is_password_set'; // 登录密码是否已设置
|
||||
|
||||
/// 生成带账号前缀的存储键
|
||||
/// 用于多账号隔离存储
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import '../../../../core/di/injection_container.dart';
|
|||
import '../../../../core/storage/storage_keys.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import 'phone_register_page.dart';
|
||||
|
||||
/// 向导页数据模型
|
||||
class GuidePageData {
|
||||
|
|
@ -340,22 +341,52 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> {
|
|||
context.push(RoutePaths.importMnemonic);
|
||||
}
|
||||
|
||||
/// 保存推荐码并继续下一步
|
||||
/// 手机号注册
|
||||
Future<void> _goToPhoneRegister() async {
|
||||
debugPrint('[GuidePage] _goToPhoneRegister - 跳转到手机号注册页面');
|
||||
|
||||
// 如果有推荐人且推荐码有效,先保存到本地存储
|
||||
String? inviterCode;
|
||||
if (_hasReferrer && _referralCodeController.text.trim().isNotEmpty) {
|
||||
inviterCode = _referralCodeController.text.trim();
|
||||
final secureStorage = ref.read(secureStorageProvider);
|
||||
await secureStorage.write(
|
||||
key: StorageKeys.inviterReferralCode,
|
||||
value: inviterCode,
|
||||
);
|
||||
debugPrint('[GuidePage] 保存邀请人推荐码: $inviterCode');
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
context.push(
|
||||
RoutePaths.phoneRegister,
|
||||
extra: PhoneRegisterParams(inviterReferralCode: inviterCode),
|
||||
);
|
||||
}
|
||||
|
||||
/// 保存推荐码并继续下一步 (跳转到手机号注册页面)
|
||||
Future<void> _saveReferralCodeAndProceed() async {
|
||||
if (!_canProceed) return;
|
||||
|
||||
// 如果有推荐人且推荐码有效,保存到本地存储
|
||||
String? inviterCode;
|
||||
if (_hasReferrer && _referralCodeController.text.trim().isNotEmpty) {
|
||||
inviterCode = _referralCodeController.text.trim();
|
||||
final secureStorage = ref.read(secureStorageProvider);
|
||||
await secureStorage.write(
|
||||
key: StorageKeys.inviterReferralCode,
|
||||
value: _referralCodeController.text.trim(),
|
||||
value: inviterCode,
|
||||
);
|
||||
debugPrint('[GuidePage] 保存邀请人推荐码: ${_referralCodeController.text.trim()}');
|
||||
debugPrint('[GuidePage] 保存邀请人推荐码: $inviterCode');
|
||||
}
|
||||
|
||||
// 调用下一步回调
|
||||
widget.onNext();
|
||||
if (!mounted) return;
|
||||
|
||||
// 跳转到手机号注册页面
|
||||
context.push(
|
||||
RoutePaths.phoneRegister,
|
||||
extra: PhoneRegisterParams(inviterReferralCode: inviterCode),
|
||||
);
|
||||
}
|
||||
|
||||
/// 打开二维码扫描页面
|
||||
|
|
@ -725,6 +756,42 @@ class _WelcomePageContentState extends ConsumerState<_WelcomePageContent> {
|
|||
],
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
// 手机号注册入口
|
||||
GestureDetector(
|
||||
onTap: _goToPhoneRegister,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.symmetric(vertical: 14.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.phone_android,
|
||||
size: 20.sp,
|
||||
color: Colors.white,
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
'使用手机号注册',
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
// 导入助记词入口
|
||||
GestureDetector(
|
||||
onTap: _importMnemonic,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,325 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../core/services/account_service.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
|
||||
/// 手机号注册页面参数
|
||||
class PhoneRegisterParams {
|
||||
final String? inviterReferralCode; // 邀请人推荐码(可选)
|
||||
|
||||
PhoneRegisterParams({this.inviterReferralCode});
|
||||
}
|
||||
|
||||
/// 手机号注册页面
|
||||
/// 输入手机号,发送验证码,跳转到验证码验证页面
|
||||
class PhoneRegisterPage extends ConsumerStatefulWidget {
|
||||
final String? inviterReferralCode;
|
||||
|
||||
const PhoneRegisterPage({
|
||||
super.key,
|
||||
this.inviterReferralCode,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<PhoneRegisterPage> createState() => _PhoneRegisterPageState();
|
||||
}
|
||||
|
||||
class _PhoneRegisterPageState extends ConsumerState<PhoneRegisterPage> {
|
||||
final TextEditingController _phoneController = TextEditingController();
|
||||
final FocusNode _phoneFocusNode = FocusNode();
|
||||
|
||||
bool _isSending = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
debugPrint('[PhoneRegisterPage] initState - inviterReferralCode: ${widget.inviterReferralCode}');
|
||||
_phoneController.addListener(_onPhoneChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.removeListener(_onPhoneChanged);
|
||||
_phoneController.dispose();
|
||||
_phoneFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onPhoneChanged() {
|
||||
// 清除错误信息
|
||||
if (_errorMessage != null) {
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证手机号格式
|
||||
bool _isValidPhoneNumber(String phone) {
|
||||
// 中国大陆手机号:1开头,第二位3-9,共11位
|
||||
final regex = RegExp(r'^1[3-9]\d{9}$');
|
||||
return regex.hasMatch(phone);
|
||||
}
|
||||
|
||||
/// 发送验证码并跳转
|
||||
Future<void> _sendSmsCode() async {
|
||||
final phone = _phoneController.text.trim();
|
||||
|
||||
// 验证手机号格式
|
||||
if (!_isValidPhoneNumber(phone)) {
|
||||
setState(() {
|
||||
_errorMessage = '请输入正确的手机号';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSending = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final accountService = ref.read(accountServiceProvider);
|
||||
|
||||
// 发送验证码
|
||||
debugPrint('[PhoneRegisterPage] 发送验证码...');
|
||||
await accountService.sendSmsCode(phone, SmsCodeType.register);
|
||||
debugPrint('[PhoneRegisterPage] 验证码已发送');
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// 跳转到验证码页面
|
||||
context.push(
|
||||
RoutePaths.smsVerify,
|
||||
extra: SmsVerifyParams(
|
||||
phoneNumber: phone,
|
||||
type: SmsCodeType.register,
|
||||
inviterReferralCode: widget.inviterReferralCode,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[PhoneRegisterPage] 发送验证码失败: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSending = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios, color: Color(0xFF333333)),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: Text(
|
||||
'手机号注册',
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: GestureDetector(
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(height: 32.h),
|
||||
// 标题
|
||||
Text(
|
||||
'输入手机号',
|
||||
style: TextStyle(
|
||||
fontSize: 24.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
'我们将向您的手机发送验证码',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 32.h),
|
||||
// 手机号输入框
|
||||
_buildPhoneInput(),
|
||||
// 错误信息
|
||||
if (_errorMessage != null) ...[
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
// 下一步按钮
|
||||
_buildNextButton(),
|
||||
SizedBox(height: 32.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhoneInput() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
border: Border.all(
|
||||
color: _errorMessage != null
|
||||
? Colors.red.withValues(alpha: 0.5)
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 国家/地区代码
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: const Color(0xFFE0E0E0),
|
||||
width: 1.w,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'+86',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 手机号输入
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _phoneController,
|
||||
focusNode: _phoneFocusNode,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(11),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
hintText: '请输入手机号',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: const Color(0xFFBBBBBB),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16.w,
|
||||
vertical: 16.h,
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 清除按钮
|
||||
if (_phoneController.text.isNotEmpty)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_phoneController.clear();
|
||||
setState(() {});
|
||||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: 16.w),
|
||||
child: Icon(
|
||||
Icons.cancel,
|
||||
size: 20.sp,
|
||||
color: const Color(0xFFBBBBBB),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNextButton() {
|
||||
final isValid = _isValidPhoneNumber(_phoneController.text.trim());
|
||||
|
||||
return GestureDetector(
|
||||
onTap: isValid && !_isSending ? _sendSmsCode : null,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.symmetric(vertical: 16.h),
|
||||
decoration: BoxDecoration(
|
||||
color: isValid && !_isSending
|
||||
? const Color(0xFF2E7D32)
|
||||
: const Color(0xFFE0E0E0),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
child: Center(
|
||||
child: _isSending
|
||||
? SizedBox(
|
||||
width: 24.sp,
|
||||
height: 24.sp,
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'获取验证码',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isValid ? Colors.white : const Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 短信验证码页面参数
|
||||
class SmsVerifyParams {
|
||||
final String phoneNumber;
|
||||
final SmsCodeType type;
|
||||
final String? inviterReferralCode;
|
||||
|
||||
SmsVerifyParams({
|
||||
required this.phoneNumber,
|
||||
required this.type,
|
||||
this.inviterReferralCode,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,360 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
|
||||
/// 设置登录密码页面参数
|
||||
class SetPasswordParams {
|
||||
final String userSerialNum;
|
||||
final String? inviterReferralCode;
|
||||
|
||||
SetPasswordParams({
|
||||
required this.userSerialNum,
|
||||
this.inviterReferralCode,
|
||||
});
|
||||
}
|
||||
|
||||
/// 设置登录密码页面
|
||||
/// 用户完成短信验证后,设置登录密码
|
||||
class SetPasswordPage extends ConsumerStatefulWidget {
|
||||
final String userSerialNum;
|
||||
final String? inviterReferralCode;
|
||||
|
||||
const SetPasswordPage({
|
||||
super.key,
|
||||
required this.userSerialNum,
|
||||
this.inviterReferralCode,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<SetPasswordPage> createState() => _SetPasswordPageState();
|
||||
}
|
||||
|
||||
class _SetPasswordPageState extends ConsumerState<SetPasswordPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
|
||||
bool _isPasswordVisible = false;
|
||||
bool _isConfirmPasswordVisible = false;
|
||||
bool _isSubmitting = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
debugPrint('[SetPasswordPage] initState - userSerialNum: ${widget.userSerialNum}');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 验证密码强度
|
||||
String? _validatePassword(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入密码';
|
||||
}
|
||||
if (value.length < 6) {
|
||||
return '密码长度至少6位';
|
||||
}
|
||||
if (value.length > 20) {
|
||||
return '密码长度不能超过20位';
|
||||
}
|
||||
// 检查是否包含字母和数字
|
||||
if (!RegExp(r'^(?=.*[A-Za-z])(?=.*\d).+$').hasMatch(value)) {
|
||||
return '密码需包含字母和数字';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 验证确认密码
|
||||
String? _validateConfirmPassword(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请再次输入密码';
|
||||
}
|
||||
if (value != _passwordController.text) {
|
||||
return '两次输入的密码不一致';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 提交设置密码
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final accountService = ref.read(accountServiceProvider);
|
||||
final password = _passwordController.text;
|
||||
|
||||
debugPrint('[SetPasswordPage] 开始设置密码...');
|
||||
|
||||
// 调用设置密码 API
|
||||
await accountService.setLoginPassword(password);
|
||||
|
||||
debugPrint('[SetPasswordPage] 密码设置成功');
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// 跳转到主页
|
||||
context.go(RoutePaths.mining);
|
||||
} catch (e) {
|
||||
debugPrint('[SetPasswordPage] 设置密码失败: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios, color: Color(0xFF333333)),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: Text(
|
||||
'设置登录密码',
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: GestureDetector(
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24.w),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(height: 32.h),
|
||||
// 标题
|
||||
Text(
|
||||
'创建登录密码',
|
||||
style: TextStyle(
|
||||
fontSize: 24.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
'密码需包含6-20位字母和数字',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 32.h),
|
||||
// 密码输入框
|
||||
_buildPasswordField(
|
||||
controller: _passwordController,
|
||||
label: '登录密码',
|
||||
hint: '请输入密码',
|
||||
isPasswordVisible: _isPasswordVisible,
|
||||
onToggleVisibility: () {
|
||||
setState(() {
|
||||
_isPasswordVisible = !_isPasswordVisible;
|
||||
});
|
||||
},
|
||||
validator: _validatePassword,
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
// 确认密码输入框
|
||||
_buildPasswordField(
|
||||
controller: _confirmPasswordController,
|
||||
label: '确认密码',
|
||||
hint: '请再次输入密码',
|
||||
isPasswordVisible: _isConfirmPasswordVisible,
|
||||
onToggleVisibility: () {
|
||||
setState(() {
|
||||
_isConfirmPasswordVisible = !_isConfirmPasswordVisible;
|
||||
});
|
||||
},
|
||||
validator: _validateConfirmPassword,
|
||||
),
|
||||
// 错误信息
|
||||
if (_errorMessage != null) ...[
|
||||
SizedBox(height: 16.h),
|
||||
Container(
|
||||
padding: EdgeInsets.all(12.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Colors.red,
|
||||
size: 20.sp,
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 40.h),
|
||||
// 提交按钮
|
||||
GestureDetector(
|
||||
onTap: _isSubmitting ? null : _submit,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.symmetric(vertical: 16.h),
|
||||
decoration: BoxDecoration(
|
||||
color: _isSubmitting
|
||||
? const Color(0xFFCCCCCC)
|
||||
: const Color(0xFF2E7D32),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
child: _isSubmitting
|
||||
? Center(
|
||||
child: SizedBox(
|
||||
width: 24.sp,
|
||||
height: 24.sp,
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'完成注册',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 32.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPasswordField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required String hint,
|
||||
required bool isPasswordVisible,
|
||||
required VoidCallback onToggleVisibility,
|
||||
required String? Function(String?) validator,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
obscureText: !isPasswordVisible,
|
||||
validator: validator,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: const Color(0xFF999999),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF5F5F5),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFF2E7D32),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.red,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.red,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16.w,
|
||||
vertical: 16.h,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
isPasswordVisible ? Icons.visibility_off : Icons.visibility,
|
||||
color: const Color(0xFF999999),
|
||||
),
|
||||
onPressed: onToggleVisibility,
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,446 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../core/services/account_service.dart';
|
||||
import '../../../../core/services/multi_account_service.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
import 'set_password_page.dart';
|
||||
|
||||
/// 短信验证码页面
|
||||
/// 输入6位验证码完成注册/登录
|
||||
class SmsVerifyPage extends ConsumerStatefulWidget {
|
||||
final String phoneNumber;
|
||||
final SmsCodeType type;
|
||||
final String? inviterReferralCode;
|
||||
|
||||
const SmsVerifyPage({
|
||||
super.key,
|
||||
required this.phoneNumber,
|
||||
required this.type,
|
||||
this.inviterReferralCode,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<SmsVerifyPage> createState() => _SmsVerifyPageState();
|
||||
}
|
||||
|
||||
class _SmsVerifyPageState extends ConsumerState<SmsVerifyPage> {
|
||||
final List<TextEditingController> _controllers = List.generate(
|
||||
6,
|
||||
(_) => TextEditingController(),
|
||||
);
|
||||
final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode());
|
||||
|
||||
bool _isVerifying = false;
|
||||
String? _errorMessage;
|
||||
|
||||
// 倒计时
|
||||
int _countdown = 60;
|
||||
Timer? _countdownTimer;
|
||||
bool _canResend = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
debugPrint('[SmsVerifyPage] initState - phoneNumber: ${_maskPhoneNumber(widget.phoneNumber)}, type: ${widget.type}');
|
||||
_startCountdown();
|
||||
// 自动聚焦第一个输入框
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNodes[0].requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_countdownTimer?.cancel();
|
||||
for (final controller in _controllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
for (final focusNode in _focusNodes) {
|
||||
focusNode.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startCountdown() {
|
||||
_countdown = 60;
|
||||
_canResend = false;
|
||||
_countdownTimer?.cancel();
|
||||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (_countdown > 0) {
|
||||
setState(() {
|
||||
_countdown--;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_canResend = true;
|
||||
});
|
||||
timer.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String _getVerificationCode() {
|
||||
return _controllers.map((c) => c.text).join();
|
||||
}
|
||||
|
||||
/// 重新发送验证码
|
||||
Future<void> _resendCode() async {
|
||||
if (!_canResend) return;
|
||||
|
||||
try {
|
||||
final accountService = ref.read(accountServiceProvider);
|
||||
await accountService.sendSmsCode(widget.phoneNumber, widget.type);
|
||||
|
||||
if (mounted) {
|
||||
_startCountdown();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'验证码已发送',
|
||||
style: TextStyle(fontSize: 14.sp),
|
||||
),
|
||||
backgroundColor: const Color(0xFF2E7D32),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: EdgeInsets.all(16.w),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[SmsVerifyPage] 重新发送验证码失败: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'发送失败: ${e.toString().replaceAll('Exception: ', '')}',
|
||||
style: TextStyle(fontSize: 14.sp),
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: EdgeInsets.all(16.w),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证验证码
|
||||
Future<void> _verifyCode() async {
|
||||
final code = _getVerificationCode();
|
||||
if (code.length != 6) {
|
||||
setState(() {
|
||||
_errorMessage = '请输入完整的验证码';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isVerifying = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final accountService = ref.read(accountServiceProvider);
|
||||
|
||||
if (widget.type == SmsCodeType.register) {
|
||||
// 注册流程:先验证短信验证码,然后调用 auto-create 创建账号
|
||||
debugPrint('[SmsVerifyPage] 开始验证短信验证码...');
|
||||
|
||||
// 验证短信验证码
|
||||
await accountService.verifySmsCode(
|
||||
phoneNumber: widget.phoneNumber,
|
||||
smsCode: code,
|
||||
type: SmsCodeType.register,
|
||||
);
|
||||
debugPrint('[SmsVerifyPage] 短信验证码验证成功');
|
||||
|
||||
// 调用 auto-create 创建账号
|
||||
debugPrint('[SmsVerifyPage] 开始调用 auto-create 创建账号...');
|
||||
final response = await accountService.createAccount(
|
||||
inviterReferralCode: widget.inviterReferralCode,
|
||||
);
|
||||
debugPrint('[SmsVerifyPage] 账号创建成功: ${response.userSerialNum}');
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// 将账号添加到多账号列表
|
||||
final multiAccountService = ref.read(multiAccountServiceProvider);
|
||||
await multiAccountService.addAccount(
|
||||
AccountSummary(
|
||||
userSerialNum: response.userSerialNum,
|
||||
username: response.username,
|
||||
avatarSvg: response.avatarSvg,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await multiAccountService.setCurrentAccountId(response.userSerialNum);
|
||||
debugPrint('[SmsVerifyPage] 已添加到多账号列表');
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// 跳转到设置密码页面
|
||||
context.go(
|
||||
RoutePaths.setPassword,
|
||||
extra: SetPasswordParams(
|
||||
userSerialNum: response.userSerialNum,
|
||||
inviterReferralCode: widget.inviterReferralCode,
|
||||
),
|
||||
);
|
||||
} else if (widget.type == SmsCodeType.login) {
|
||||
// 登录
|
||||
debugPrint('[SmsVerifyPage] 开始登录...');
|
||||
final response = await accountService.loginByPhone(
|
||||
phoneNumber: widget.phoneNumber,
|
||||
smsCode: code,
|
||||
);
|
||||
debugPrint('[SmsVerifyPage] 登录成功: ${response.accountSequence}');
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// 跳转到主页
|
||||
context.go(RoutePaths.mining);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[SmsVerifyPage] 验证失败: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||
});
|
||||
// 清空验证码
|
||||
for (final controller in _controllers) {
|
||||
controller.clear();
|
||||
}
|
||||
_focusNodes[0].requestFocus();
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isVerifying = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onCodeChanged(int index, String value) {
|
||||
// 清除错误信息
|
||||
if (_errorMessage != null) {
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
});
|
||||
}
|
||||
|
||||
if (value.isNotEmpty) {
|
||||
// 输入了字符,移动到下一个
|
||||
if (index < 5) {
|
||||
_focusNodes[index + 1].requestFocus();
|
||||
} else {
|
||||
// 输入完成,验证
|
||||
_focusNodes[index].unfocus();
|
||||
_verifyCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onKeyEvent(int index, KeyEvent event) {
|
||||
if (event is KeyDownEvent) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.backspace) {
|
||||
if (_controllers[index].text.isEmpty && index > 0) {
|
||||
// 当前格为空,删除前一格
|
||||
_controllers[index - 1].clear();
|
||||
_focusNodes[index - 1].requestFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios, color: Color(0xFF333333)),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: Text(
|
||||
widget.type == SmsCodeType.register ? '手机号注册' : '手机号登录',
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: GestureDetector(
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(height: 32.h),
|
||||
// 标题
|
||||
Text(
|
||||
'输入验证码',
|
||||
style: TextStyle(
|
||||
fontSize: 24.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
'验证码已发送至 ${_formatPhoneNumber(widget.phoneNumber)}',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 32.h),
|
||||
// 验证码输入框
|
||||
_buildCodeInputs(),
|
||||
// 错误信息
|
||||
if (_errorMessage != null) ...[
|
||||
SizedBox(height: 16.h),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 24.h),
|
||||
// 重新发送按钮
|
||||
_buildResendButton(),
|
||||
const Spacer(),
|
||||
// 验证中提示
|
||||
if (_isVerifying)
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 32.sp,
|
||||
height: 32.sp,
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF2E7D32)),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
Text(
|
||||
widget.type == SmsCodeType.register ? '正在注册...' : '正在登录...',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 32.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCodeInputs() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: List.generate(6, (index) {
|
||||
return SizedBox(
|
||||
width: 48.w,
|
||||
height: 56.h,
|
||||
child: KeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
onKeyEvent: (event) => _onKeyEvent(index, event),
|
||||
child: TextField(
|
||||
controller: _controllers[index],
|
||||
focusNode: _focusNodes[index],
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.center,
|
||||
maxLength: 1,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
counterText: '',
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF5F5F5),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFF2E7D32),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.red,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 24.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
onChanged: (value) => _onCodeChanged(index, value),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResendButton() {
|
||||
return Center(
|
||||
child: GestureDetector(
|
||||
onTap: _canResend ? _resendCode : null,
|
||||
child: Text(
|
||||
_canResend ? '重新发送验证码' : '$_countdown秒后重新发送',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _canResend
|
||||
? const Color(0xFF2E7D32)
|
||||
: const Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatPhoneNumber(String phone) {
|
||||
if (phone.length != 11) return phone;
|
||||
return '${phone.substring(0, 3)} **** ${phone.substring(7)}';
|
||||
}
|
||||
|
||||
String _maskPhoneNumber(String phone) {
|
||||
if (phone.length < 7) return phone;
|
||||
return '${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,898 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
/// 授权类型枚举
|
||||
enum AuthorizationType {
|
||||
community, // 社区
|
||||
cityTeam, // 市团队
|
||||
provinceTeam, // 省团队
|
||||
}
|
||||
|
||||
/// 授权类型显示信息
|
||||
extension AuthorizationTypeExtension on AuthorizationType {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case AuthorizationType.community:
|
||||
return '社区';
|
||||
case AuthorizationType.cityTeam:
|
||||
return '市团队';
|
||||
case AuthorizationType.provinceTeam:
|
||||
return '省团队';
|
||||
}
|
||||
}
|
||||
|
||||
String get description {
|
||||
switch (this) {
|
||||
case AuthorizationType.community:
|
||||
return '社区权益 - 576 USDT/棵';
|
||||
case AuthorizationType.cityTeam:
|
||||
return '市团队权益 - 288 USDT/棵';
|
||||
case AuthorizationType.provinceTeam:
|
||||
return '省团队权益 - 144 USDT/棵';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 自助申请授权页面
|
||||
class AuthorizationApplyPage extends ConsumerStatefulWidget {
|
||||
const AuthorizationApplyPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AuthorizationApplyPage> createState() =>
|
||||
_AuthorizationApplyPageState();
|
||||
}
|
||||
|
||||
class _AuthorizationApplyPageState
|
||||
extends ConsumerState<AuthorizationApplyPage> {
|
||||
/// 选中的授权类型
|
||||
AuthorizationType? _selectedType;
|
||||
|
||||
/// 办公室照片列表
|
||||
final List<File> _officePhotos = [];
|
||||
|
||||
/// 图片选择器
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
/// 是否正在提交
|
||||
bool _isSubmitting = false;
|
||||
|
||||
/// 是否正在加载
|
||||
bool _isLoading = true;
|
||||
|
||||
/// 用户是否已认种
|
||||
bool _hasPlanted = false;
|
||||
|
||||
/// 用户认种棵数
|
||||
int _plantedCount = 0;
|
||||
|
||||
/// 用户已有的授权
|
||||
List<String> _existingAuthorizations = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadUserStatus();
|
||||
}
|
||||
|
||||
/// 加载用户状态
|
||||
Future<void> _loadUserStatus() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// TODO: 调用API获取用户认种状态和已有授权
|
||||
// final authService = ref.read(authorizationServiceProvider);
|
||||
// final status = await authService.getUserAuthorizationStatus();
|
||||
|
||||
// 模拟数据
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hasPlanted = true; // 模拟已认种
|
||||
_plantedCount = 10; // 模拟认种10棵
|
||||
_existingAuthorizations = []; // 模拟无已有授权
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
_showErrorSnackBar('加载状态失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回上一页
|
||||
void _goBack() {
|
||||
context.pop();
|
||||
}
|
||||
|
||||
/// 选择照片
|
||||
Future<void> _pickPhoto() async {
|
||||
if (_officePhotos.length >= 6) {
|
||||
_showErrorSnackBar('最多上传6张照片');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final XFile? image = await _picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1920,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (image != null && mounted) {
|
||||
setState(() {
|
||||
_officePhotos.add(File(image.path));
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_showErrorSnackBar('选择图片失败');
|
||||
}
|
||||
}
|
||||
|
||||
/// 拍照
|
||||
Future<void> _takePhoto() async {
|
||||
if (_officePhotos.length >= 6) {
|
||||
_showErrorSnackBar('最多上传6张照片');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final XFile? image = await _picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1920,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (image != null && mounted) {
|
||||
setState(() {
|
||||
_officePhotos.add(File(image.path));
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_showErrorSnackBar('拍照失败');
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除照片
|
||||
void _removePhoto(int index) {
|
||||
setState(() {
|
||||
_officePhotos.removeAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
/// 提交申请
|
||||
Future<void> _submitApplication() async {
|
||||
// 验证
|
||||
if (_selectedType == null) {
|
||||
_showErrorSnackBar('请选择要申请的授权类型');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_hasPlanted) {
|
||||
_showErrorSnackBar('您还未认种,请先进行认种');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_officePhotos.isEmpty) {
|
||||
_showErrorSnackBar('请上传办公室照片');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_officePhotos.length < 2) {
|
||||
_showErrorSnackBar('请至少上传2张办公室照片');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已有该类型授权
|
||||
final typeName = _selectedType!.displayName;
|
||||
if (_existingAuthorizations.contains(typeName)) {
|
||||
_showErrorSnackBar('您已拥有${typeName}授权');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// TODO: 调用API提交申请
|
||||
// final authService = ref.read(authorizationServiceProvider);
|
||||
// await authService.submitAuthorizationApplication(
|
||||
// type: _selectedType!,
|
||||
// officePhotos: _officePhotos,
|
||||
// );
|
||||
|
||||
// 模拟请求
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('申请已提交,请等待审核'),
|
||||
backgroundColor: Color(0xFF4CAF50),
|
||||
),
|
||||
);
|
||||
|
||||
context.pop(true);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
});
|
||||
_showErrorSnackBar('提交失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示错误提示
|
||||
void _showErrorSnackBar(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFFFFF5E6),
|
||||
Color(0xFFFFE4B5),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部导航栏
|
||||
_buildAppBar(),
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(Color(0xFFD4AF37)),
|
||||
),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 申请说明卡片
|
||||
_buildInfoCard(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 用户状态卡片
|
||||
_buildUserStatusCard(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 可申请的授权类型
|
||||
_buildAuthorizationTypes(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 不可自助申请的说明
|
||||
_buildNonSelfApplyInfo(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 办公室照片上传
|
||||
if (_selectedType != null) ...[
|
||||
_buildPhotoUpload(),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
|
||||
// 提交按钮
|
||||
_buildSubmitButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建顶部导航栏
|
||||
Widget _buildAppBar() {
|
||||
return Container(
|
||||
height: 64,
|
||||
padding: const EdgeInsets.only(top: 16, left: 16, right: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
// 返回按钮
|
||||
GestureDetector(
|
||||
onTap: _goBack,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
size: 24,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 标题
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'自助申请授权',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.25,
|
||||
letterSpacing: -0.27,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
// 占位
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建申请说明卡片
|
||||
Widget _buildInfoCard() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF5E6),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: const Color(0x33D4AF37),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: const [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: Color(0xFFD4AF37),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'申请说明',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'1. 申请账户必须已完成认种\n'
|
||||
'2. 需提供办公室照片(至少2张)\n'
|
||||
'3. 社区、市团队、省团队支持自助申请\n'
|
||||
'4. 提交后等待审核,审核通过后生效',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.6,
|
||||
color: Color(0xFF745D43),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建用户状态卡片
|
||||
Widget _buildUserStatusCard() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x80FFFFFF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: const Color(0x33D4AF37),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: _hasPlanted
|
||||
? const Color(0xFF4CAF50).withValues(alpha: 0.1)
|
||||
: const Color(0xFFFF9800).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Icon(
|
||||
_hasPlanted ? Icons.check_circle : Icons.warning,
|
||||
color:
|
||||
_hasPlanted ? const Color(0xFF4CAF50) : const Color(0xFFFF9800),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_hasPlanted ? '已认种' : '未认种',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _hasPlanted
|
||||
? const Color(0xFF4CAF50)
|
||||
: const Color(0xFFFF9800),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_hasPlanted ? '累计认种 $_plantedCount 棵' : '请先完成认种后再申请授权',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
color: Color(0xFF745D43),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建授权类型选择
|
||||
Widget _buildAuthorizationTypes() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'选择申请类型',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...AuthorizationType.values.map((type) => _buildTypeItem(type)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建授权类型项
|
||||
Widget _buildTypeItem(AuthorizationType type) {
|
||||
final isSelected = _selectedType == type;
|
||||
final isAlreadyHas = _existingAuthorizations.contains(type.displayName);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: isAlreadyHas
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
_selectedType = type;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isAlreadyHas
|
||||
? const Color(0xFFE0E0E0)
|
||||
: isSelected
|
||||
? const Color(0xFFD4AF37).withValues(alpha: 0.1)
|
||||
: const Color(0x80FFFFFF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? const Color(0xFFD4AF37)
|
||||
: const Color(0x33D4AF37),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 选中指示器
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isAlreadyHas
|
||||
? const Color(0xFF9E9E9E)
|
||||
: isSelected
|
||||
? const Color(0xFFD4AF37)
|
||||
: const Color(0xFF8B5A2B),
|
||||
width: 2,
|
||||
),
|
||||
color: isSelected ? const Color(0xFFD4AF37) : Colors.transparent,
|
||||
),
|
||||
child: isSelected
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// 类型信息
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
type.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isAlreadyHas
|
||||
? const Color(0xFF9E9E9E)
|
||||
: const Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
type.description,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
color: isAlreadyHas
|
||||
? const Color(0xFFBDBDBD)
|
||||
: const Color(0xFF745D43),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 已拥有标签
|
||||
if (isAlreadyHas)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF9E9E9E),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'已拥有',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'Inter',
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建不可自助申请的说明
|
||||
Widget _buildNonSelfApplyInfo() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFEFEF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: const [
|
||||
Icon(
|
||||
Icons.block,
|
||||
size: 20,
|
||||
color: Color(0xFF9E9E9E),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'不支持自助申请',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF757575),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'省区域和市区域授权不支持自助申请,获得方式:\n\n'
|
||||
'1. 自动升级\n'
|
||||
' - 某省团队账号伞下第一个累计达到5万棵的账户\n'
|
||||
' 自动获得该省区域授权\n'
|
||||
' - 某市团队账号伞下第一个累计达到1万棵的账户\n'
|
||||
' 自动获得该市区域授权\n\n'
|
||||
'2. 后台手动授权',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.6,
|
||||
color: Color(0xFF757575),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建照片上传区域
|
||||
Widget _buildPhotoUpload() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: const [
|
||||
Text(
|
||||
'办公室照片',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'*',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'请上传办公室照片,至少2张,最多6张',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'Inter',
|
||||
color: Color(0xFF745D43),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 照片网格
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
childAspectRatio: 1,
|
||||
),
|
||||
itemCount: _officePhotos.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _officePhotos.length) {
|
||||
// 添加按钮
|
||||
return _buildAddPhotoButton();
|
||||
}
|
||||
// 已选照片
|
||||
return _buildPhotoItem(index);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建添加照片按钮
|
||||
Widget _buildAddPhotoButton() {
|
||||
if (_officePhotos.length >= 6) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _showPhotoSourceDialog(),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x80FFFFFF),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: const Color(0x33D4AF37),
|
||||
width: 1,
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
Icon(
|
||||
Icons.add_a_photo,
|
||||
size: 32,
|
||||
color: Color(0xFFD4AF37),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'添加照片',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'Inter',
|
||||
color: Color(0xFF745D43),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示照片来源选择
|
||||
void _showPhotoSourceDialog() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library, color: Color(0xFFD4AF37)),
|
||||
title: const Text('从相册选择'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_pickPhoto();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt, color: Color(0xFFD4AF37)),
|
||||
title: const Text('拍照'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_takePhoto();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.close, color: Color(0xFF757575)),
|
||||
title: const Text('取消'),
|
||||
onTap: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建已选照片项
|
||||
Widget _buildPhotoItem(int index) {
|
||||
return Stack(
|
||||
children: [
|
||||
// 照片
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: DecorationImage(
|
||||
image: FileImage(_officePhotos[index]),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 删除按钮
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: GestureDetector(
|
||||
onTap: () => _removePhoto(index),
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建提交按钮
|
||||
Widget _buildSubmitButton() {
|
||||
final canSubmit = _hasPlanted &&
|
||||
_selectedType != null &&
|
||||
_officePhotos.length >= 2 &&
|
||||
!_isSubmitting;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: canSubmit ? _submitApplication : null,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: canSubmit
|
||||
? const Color(0xFFD4AF37)
|
||||
: const Color(0xFFD4AF37).withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: canSubmit
|
||||
? const [
|
||||
BoxShadow(
|
||||
color: Color(0x4DD4AF37),
|
||||
blurRadius: 14,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Center(
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'提交申请',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.5,
|
||||
letterSpacing: 0.24,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,585 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
|
||||
/// 火柴人排名数据模型
|
||||
class StickmanRankingData {
|
||||
final String id;
|
||||
final String nickname;
|
||||
final String? avatarUrl;
|
||||
final int completedCount; // 完成数量
|
||||
final int targetCount; // 目标数量 (省: 50000, 市: 10000)
|
||||
final double monthlyEarnings; // 本月可结算收益
|
||||
final bool isCurrentUser; // 是否是当前用户
|
||||
|
||||
StickmanRankingData({
|
||||
required this.id,
|
||||
required this.nickname,
|
||||
this.avatarUrl,
|
||||
required this.completedCount,
|
||||
required this.targetCount,
|
||||
required this.monthlyEarnings,
|
||||
this.isCurrentUser = false,
|
||||
});
|
||||
|
||||
/// 完成进度 (0.0 - 1.0)
|
||||
double get progress => (completedCount / targetCount).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// 授权类型
|
||||
enum AuthorizationRoleType {
|
||||
province, // 省公司 (目标: 5万)
|
||||
city, // 市公司 (目标: 1万)
|
||||
}
|
||||
|
||||
/// 火柴人赛跑排名组件
|
||||
class StickmanRaceWidget extends StatefulWidget {
|
||||
final List<StickmanRankingData> rankings;
|
||||
final AuthorizationRoleType roleType;
|
||||
final String regionName; // 省/市名称
|
||||
|
||||
const StickmanRaceWidget({
|
||||
super.key,
|
||||
required this.rankings,
|
||||
required this.roleType,
|
||||
required this.regionName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StickmanRaceWidget> createState() => _StickmanRaceWidgetState();
|
||||
}
|
||||
|
||||
class _StickmanRaceWidgetState extends State<StickmanRaceWidget>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _bounceController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 用于火柴人上下弹跳效果
|
||||
_bounceController = AnimationController(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
vsync: this,
|
||||
)..repeat(reverse: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bounceController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 获取目标数量
|
||||
int get targetCount =>
|
||||
widget.roleType == AuthorizationRoleType.province ? 50000 : 10000;
|
||||
|
||||
/// 获取目标文本
|
||||
String get targetText =>
|
||||
widget.roleType == AuthorizationRoleType.province ? '5万' : '1万';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.rankings.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x80FFFFFF),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: const Color(0x33D4AF37),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题行
|
||||
_buildHeader(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 赛道区域
|
||||
_buildRaceTrack(),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 进度条和目标
|
||||
_buildProgressBar(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 排名列表
|
||||
_buildRankingList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建标题
|
||||
Widget _buildHeader() {
|
||||
final roleText =
|
||||
widget.roleType == AuthorizationRoleType.province ? '省公司' : '市公司';
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD4AF37),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${widget.regionName}$roleText排名',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'目标: $targetText棵',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'Inter',
|
||||
color: Color(0xFF8B5A2B),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建赛道区域
|
||||
Widget _buildRaceTrack() {
|
||||
// 按完成数量排序
|
||||
final sortedRankings = List<StickmanRankingData>.from(widget.rankings)
|
||||
..sort((a, b) => b.completedCount.compareTo(a.completedCount));
|
||||
|
||||
return SizedBox(
|
||||
height: 160,
|
||||
child: Stack(
|
||||
children: [
|
||||
// 背景赛道线
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter: _TrackPainter(
|
||||
trackCount: sortedRankings.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 终点红旗
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 0,
|
||||
bottom: 40,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
Icon(
|
||||
Icons.flag,
|
||||
color: Colors.red,
|
||||
size: 32,
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'终点',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontFamily: 'Inter',
|
||||
color: Color(0xFF8B5A2B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 火柴人们
|
||||
...sortedRankings.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final data = entry.value;
|
||||
return _buildStickman(data, index, sortedRankings.length);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建单个火柴人
|
||||
Widget _buildStickman(StickmanRankingData data, int rank, int total) {
|
||||
// 计算水平位置 (根据进度)
|
||||
final horizontalProgress = data.progress;
|
||||
|
||||
// 计算垂直位置 (不同排名在不同跑道)
|
||||
final trackHeight = 120.0 / total;
|
||||
final verticalPosition = rank * trackHeight + 10;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _bounceController,
|
||||
builder: (context, child) {
|
||||
// 添加上下弹跳效果
|
||||
final bounce = _bounceController.value * 3;
|
||||
|
||||
return Positioned(
|
||||
left: 20 + (MediaQuery.of(context).size.width - 120) * horizontalProgress,
|
||||
top: verticalPosition - bounce,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 完成数量标签
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: data.isCurrentUser
|
||||
? const Color(0xFFD4AF37)
|
||||
: const Color(0xFF8B5A2B),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${_formatNumber(data.completedCount)}棵',
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
|
||||
// 火柴人动画
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 50,
|
||||
child: Lottie.asset(
|
||||
'assets/lottie/stickman_running.json',
|
||||
fit: BoxFit.contain,
|
||||
repeat: true,
|
||||
),
|
||||
),
|
||||
|
||||
// 昵称标签
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 60),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: data.isCurrentUser
|
||||
? const Color(0xFFD4AF37).withValues(alpha: 0.2)
|
||||
: Colors.white.withValues(alpha: 0.8),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: data.isCurrentUser
|
||||
? Border.all(color: const Color(0xFFD4AF37), width: 1)
|
||||
: null,
|
||||
),
|
||||
child: Text(
|
||||
data.nickname,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight:
|
||||
data.isCurrentUser ? FontWeight.w600 : FontWeight.w400,
|
||||
color: data.isCurrentUser
|
||||
? const Color(0xFFD4AF37)
|
||||
: const Color(0xFF5D4037),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建进度条
|
||||
Widget _buildProgressBar() {
|
||||
// 找出领先者的进度
|
||||
final maxProgress = widget.rankings.isEmpty
|
||||
? 0.0
|
||||
: widget.rankings
|
||||
.map((r) => r.progress)
|
||||
.reduce((a, b) => a > b ? a : b);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 进度条
|
||||
Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE0E0E0),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 进度填充
|
||||
FractionallySizedBox(
|
||||
widthFactor: maxProgress,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFFD4AF37), Color(0xFFF5D799)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 进度标签
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'0',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontFamily: 'Inter',
|
||||
color: Color(0xFF8B5A2B),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
targetText,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontFamily: 'Inter',
|
||||
color: Color(0xFF8B5A2B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建排名列表
|
||||
Widget _buildRankingList() {
|
||||
// 按完成数量排序
|
||||
final sortedRankings = List<StickmanRankingData>.from(widget.rankings)
|
||||
..sort((a, b) => b.completedCount.compareTo(a.completedCount));
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'排名详情',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...sortedRankings.asMap().entries.map((entry) {
|
||||
final rank = entry.key + 1;
|
||||
final data = entry.value;
|
||||
return _buildRankingItem(rank, data);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建排名项
|
||||
Widget _buildRankingItem(int rank, StickmanRankingData data) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: data.isCurrentUser
|
||||
? const Color(0xFFD4AF37).withValues(alpha: 0.1)
|
||||
: Colors.white.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: data.isCurrentUser
|
||||
? Border.all(color: const Color(0xFFD4AF37), width: 1)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 排名
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: _getRankColor(rank),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$rank',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 昵称
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
data.nickname,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: data.isCurrentUser
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
color: const Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
if (data.isCurrentUser) ...[
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD4AF37),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'我',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'本月收益: ${_formatCurrency(data.monthlyEarnings)} USDT',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontFamily: 'Inter',
|
||||
color: Color(0xFF8B5A2B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 完成数量和进度
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${_formatNumber(data.completedCount)}棵',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFFD4AF37),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${(data.progress * 100).toStringAsFixed(1)}%',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontFamily: 'Inter',
|
||||
color: Color(0xFF8B5A2B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取排名颜色
|
||||
Color _getRankColor(int rank) {
|
||||
switch (rank) {
|
||||
case 1:
|
||||
return const Color(0xFFFFD700); // 金色
|
||||
case 2:
|
||||
return const Color(0xFFC0C0C0); // 银色
|
||||
case 3:
|
||||
return const Color(0xFFCD7F32); // 铜色
|
||||
default:
|
||||
return const Color(0xFF8B5A2B);
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化数字
|
||||
String _formatNumber(int number) {
|
||||
if (number >= 10000) {
|
||||
return '${(number / 10000).toStringAsFixed(1)}万';
|
||||
}
|
||||
return number.toString();
|
||||
}
|
||||
|
||||
/// 格式化货币
|
||||
String _formatCurrency(double amount) {
|
||||
if (amount >= 10000) {
|
||||
return '${(amount / 10000).toStringAsFixed(2)}万';
|
||||
}
|
||||
return amount.toStringAsFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
/// 赛道绘制器
|
||||
class _TrackPainter extends CustomPainter {
|
||||
final int trackCount;
|
||||
|
||||
_TrackPainter({required this.trackCount});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final dashedPaint = Paint()
|
||||
..color = const Color(0xFFBDBDBD)
|
||||
..strokeWidth = 1
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
final trackHeight = (size.height - 40) / trackCount;
|
||||
|
||||
for (int i = 0; i < trackCount; i++) {
|
||||
final y = 20 + i * trackHeight + trackHeight / 2;
|
||||
|
||||
// 绘制虚线跑道
|
||||
double startX = 0;
|
||||
const dashWidth = 10.0;
|
||||
const dashSpace = 5.0;
|
||||
|
||||
while (startX < size.width) {
|
||||
canvas.drawLine(
|
||||
Offset(startX, y),
|
||||
Offset(startX + dashWidth, y),
|
||||
dashedPaint,
|
||||
);
|
||||
startX += dashWidth + dashSpace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
|
@ -5,15 +5,9 @@ import 'package:go_router/go_router.dart';
|
|||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
|
||||
/// 网络类型枚举
|
||||
enum NetworkType {
|
||||
kava,
|
||||
bsc,
|
||||
dst,
|
||||
}
|
||||
|
||||
/// 充值 USDT 页面
|
||||
/// 显示充值二维码和地址,支持 KAVA 和 BSC 网络切换
|
||||
/// 显示用户ID(accountSequence)二维码作为充值地址
|
||||
/// 后端通过 accountSequence 映射到实际区块链地址
|
||||
class DepositUsdtPage extends ConsumerStatefulWidget {
|
||||
const DepositUsdtPage({super.key});
|
||||
|
||||
|
|
@ -22,60 +16,47 @@ class DepositUsdtPage extends ConsumerStatefulWidget {
|
|||
}
|
||||
|
||||
class _DepositUsdtPageState extends ConsumerState<DepositUsdtPage> {
|
||||
/// 当前选中的网络
|
||||
NetworkType _selectedNetwork = NetworkType.kava;
|
||||
|
||||
/// 是否正在加载
|
||||
bool _isLoading = true;
|
||||
|
||||
/// USDT 余额
|
||||
String _balance = '0.00';
|
||||
|
||||
/// KAVA 网络充值地址
|
||||
String? _kavaAddress;
|
||||
|
||||
/// BSC 网络充值地址
|
||||
String? _bscAddress;
|
||||
|
||||
/// DST 网络充值地址
|
||||
String? _dstAddress;
|
||||
/// 用户账户序列号(作为充值地址显示)
|
||||
String? _accountSequence;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadWalletData();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
/// 错误信息
|
||||
String? _errorMessage;
|
||||
|
||||
/// 加载钱包数据
|
||||
Future<void> _loadWalletData() async {
|
||||
/// 加载数据
|
||||
Future<void> _loadData() async {
|
||||
try {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
final depositService = ref.read(depositServiceProvider);
|
||||
// 获取用户账户序列号
|
||||
final accountService = ref.read(accountServiceProvider);
|
||||
final userSerialNum = await accountService.getUserSerialNum();
|
||||
|
||||
// 获取充值地址 (验证后的安全地址)
|
||||
final addressResponse = await depositService.getDepositAddresses();
|
||||
|
||||
if (!addressResponse.isValid) {
|
||||
// 地址验证失败
|
||||
if (userSerialNum == null || userSerialNum.isEmpty) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = addressResponse.message ?? '充值账户异常,请联系客服';
|
||||
_errorMessage = '无法获取用户信息,请重新登录';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_kavaAddress = addressResponse.kavaAddress;
|
||||
_bscAddress = addressResponse.bscAddress;
|
||||
_dstAddress = addressResponse.dstAddress;
|
||||
_accountSequence = userSerialNum;
|
||||
|
||||
// 从 wallet-service 查询钱包余额
|
||||
try {
|
||||
|
|
@ -98,7 +79,7 @@ class _DepositUsdtPageState extends ConsumerState<DepositUsdtPage> {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('加载钱包数据失败: $e');
|
||||
debugPrint('加载数据失败: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = '加载失败,请重试';
|
||||
|
|
@ -108,35 +89,19 @@ class _DepositUsdtPageState extends ConsumerState<DepositUsdtPage> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 获取当前网络的充值地址
|
||||
String get _currentAddress {
|
||||
switch (_selectedNetwork) {
|
||||
case NetworkType.kava:
|
||||
return _kavaAddress ?? '';
|
||||
case NetworkType.bsc:
|
||||
return _bscAddress ?? '';
|
||||
case NetworkType.dst:
|
||||
return _dstAddress ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换网络
|
||||
void _switchNetwork(NetworkType network) {
|
||||
if (_selectedNetwork != network) {
|
||||
setState(() {
|
||||
_selectedNetwork = network;
|
||||
});
|
||||
}
|
||||
/// 获取充值地址(用户ID)
|
||||
String get _depositAddress {
|
||||
return _accountSequence ?? '';
|
||||
}
|
||||
|
||||
/// 复制地址到剪贴板
|
||||
void _copyAddress() {
|
||||
if (_currentAddress.isEmpty) return;
|
||||
if (_depositAddress.isEmpty) return;
|
||||
|
||||
Clipboard.setData(ClipboardData(text: _currentAddress));
|
||||
Clipboard.setData(ClipboardData(text: _depositAddress));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('充值地址已复制'),
|
||||
content: Text('充值ID已复制'),
|
||||
backgroundColor: Color(0xFFD4AF37),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
|
|
@ -192,9 +157,7 @@ class _DepositUsdtPageState extends ConsumerState<DepositUsdtPage> {
|
|||
children: [
|
||||
// 余额显示
|
||||
_buildBalanceSection(),
|
||||
// 网络切换
|
||||
_buildNetworkSwitch(),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 16),
|
||||
// 二维码卡片
|
||||
_buildQrCodeCard(),
|
||||
// 警告提示
|
||||
|
|
@ -274,87 +237,6 @@ class _DepositUsdtPageState extends ConsumerState<DepositUsdtPage> {
|
|||
);
|
||||
}
|
||||
|
||||
/// 构建网络切换
|
||||
Widget _buildNetworkSwitch() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Row(
|
||||
children: [
|
||||
// KAVA 网络按钮
|
||||
Expanded(
|
||||
child: _buildNetworkButton(
|
||||
label: 'KAVA',
|
||||
network: NetworkType.kava,
|
||||
isSelected: _selectedNetwork == NetworkType.kava,
|
||||
),
|
||||
),
|
||||
// BSC 网络按钮(暂时隐藏)
|
||||
// Expanded(
|
||||
// child: _buildNetworkButton(
|
||||
// label: 'BSC',
|
||||
// network: NetworkType.bsc,
|
||||
// isSelected: _selectedNetwork == NetworkType.bsc,
|
||||
// ),
|
||||
// ),
|
||||
// DST 网络按钮(暂时隐藏)
|
||||
// Expanded(
|
||||
// child: _buildNetworkButton(
|
||||
// label: 'DST',
|
||||
// network: NetworkType.dst,
|
||||
// isSelected: _selectedNetwork == NetworkType.dst,
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建网络按钮
|
||||
Widget _buildNetworkButton({
|
||||
required String label,
|
||||
required NetworkType network,
|
||||
required bool isSelected,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: () => _switchNetwork(network),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 9.5, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFFD4AF37) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: isSelected
|
||||
? const [
|
||||
BoxShadow(
|
||||
color: Color(0x66D4AF37),
|
||||
blurRadius: 3,
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: isSelected ? Colors.white : const Color(0xFF745D43),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建二维码卡片
|
||||
Widget _buildQrCodeCard() {
|
||||
return Container(
|
||||
|
|
@ -446,7 +328,7 @@ class _DepositUsdtPageState extends ConsumerState<DepositUsdtPage> {
|
|||
}
|
||||
|
||||
// 地址为空
|
||||
if (_currentAddress.isEmpty) {
|
||||
if (_depositAddress.isEmpty) {
|
||||
return Container(
|
||||
width: 192,
|
||||
height: 192,
|
||||
|
|
@ -456,7 +338,7 @@ class _DepositUsdtPageState extends ConsumerState<DepositUsdtPage> {
|
|||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'暂无充值地址',
|
||||
'暂无充值ID',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF5D4037),
|
||||
|
|
@ -483,7 +365,7 @@ class _DepositUsdtPageState extends ConsumerState<DepositUsdtPage> {
|
|||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: QrImageView(
|
||||
data: _currentAddress,
|
||||
data: _depositAddress,
|
||||
version: QrVersions.auto,
|
||||
size: 192,
|
||||
backgroundColor: Colors.white,
|
||||
|
|
@ -505,9 +387,9 @@ class _DepositUsdtPageState extends ConsumerState<DepositUsdtPage> {
|
|||
Widget _buildAddressInfo() {
|
||||
return Column(
|
||||
children: [
|
||||
// 充值地址标题
|
||||
// 充值ID标题
|
||||
const Text(
|
||||
'充值地址',
|
||||
'充值ID',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
|
|
@ -517,16 +399,18 @@ class _DepositUsdtPageState extends ConsumerState<DepositUsdtPage> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 地址文本
|
||||
// ID文本
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
_isLoading ? '加载中...' : _currentAddress,
|
||||
_isLoading ? '加载中...' : _depositAddress,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontSize: 20,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.5,
|
||||
color: const Color(0xFF5D4037).withValues(alpha: 0.8),
|
||||
letterSpacing: 1.5,
|
||||
color: const Color(0xFF5D4037).withValues(alpha: 0.9),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
|
@ -545,7 +429,7 @@ class _DepositUsdtPageState extends ConsumerState<DepositUsdtPage> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'复制地址',
|
||||
'复制ID',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
|
|
@ -577,7 +461,7 @@ class _DepositUsdtPageState extends ConsumerState<DepositUsdtPage> {
|
|||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 48),
|
||||
child: Text(
|
||||
'仅支持 绿积分,错充将无法追回',
|
||||
'请使用此充值ID进行充值',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'Inter',
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import '../../../../routes/app_router.dart';
|
|||
import '../../../auth/presentation/providers/auth_provider.dart';
|
||||
import '../widgets/team_tree_widget.dart';
|
||||
import '../widgets/stacked_cards_widget.dart';
|
||||
import '../../../authorization/presentation/widgets/stickman_race_widget.dart';
|
||||
|
||||
/// 个人中心页面 - 显示用户信息、社区数据、收益和设置
|
||||
/// 包含用户资料、推荐信息、社区考核、收益领取等功能
|
||||
|
|
@ -103,6 +104,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
int _provinceCompanyMonthlyTarget = 500;
|
||||
int _provinceCompanyMonthIndex = 0;
|
||||
|
||||
// 火柴人排名数据(省公司/市公司排名赛跑)
|
||||
List<StickmanRankingData> _provinceRankings = [];
|
||||
List<StickmanRankingData> _cityRankings = [];
|
||||
String _provinceRegionName = ''; // 省名称
|
||||
String _cityRegionName = ''; // 市名称
|
||||
bool _isLoadingStickmanRanking = false;
|
||||
bool _hasLoadedStickmanRanking = false;
|
||||
|
||||
// 收益数据(从 reward-service 直接获取)
|
||||
double _pendingUsdt = 0.0;
|
||||
double _pendingPower = 0.0;
|
||||
|
|
@ -513,6 +522,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
_hasProvinceCompanyAuth = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 授权数据加载完成后,加载火柴人排名数据
|
||||
_loadStickmanRankingData();
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[ProfilePage] 加载授权数据失败: $e');
|
||||
|
|
@ -533,6 +545,106 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 加载火柴人排名数据
|
||||
Future<void> _loadStickmanRankingData() async {
|
||||
// 只有当用户有省公司或市公司授权时才加载
|
||||
if (!_hasAuthProvinceCompanyAuth && !_hasAuthCityCompanyAuth) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isLoadingStickmanRanking) return;
|
||||
|
||||
setState(() {
|
||||
_isLoadingStickmanRanking = true;
|
||||
});
|
||||
|
||||
try {
|
||||
debugPrint('[ProfilePage] 开始加载火柴人排名数据...');
|
||||
final authorizationService = ref.read(authorizationServiceProvider);
|
||||
|
||||
// 获取当前月份
|
||||
final now = DateTime.now();
|
||||
final month = '${now.year}-${now.month.toString().padLeft(2, '0')}';
|
||||
|
||||
// 如果有省公司授权,加载省排名
|
||||
if (_hasAuthProvinceCompanyAuth && _authProvinceCompany != '--') {
|
||||
try {
|
||||
// 从省公司名称中提取省代码(假设格式为 "XX省" 或直接省名)
|
||||
final provinceName = _authProvinceCompany;
|
||||
_provinceRegionName = provinceName;
|
||||
|
||||
final rankings = await authorizationService.getStickmanRanking(
|
||||
month: month,
|
||||
roleType: 'AUTH_PROVINCE_COMPANY',
|
||||
regionCode: provinceName,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_provinceRankings = rankings.map((r) => StickmanRankingData(
|
||||
id: r.id,
|
||||
nickname: r.nickname,
|
||||
avatarUrl: r.avatarUrl,
|
||||
completedCount: r.completedCount,
|
||||
targetCount: 50000, // 省公司目标5万
|
||||
monthlyEarnings: r.monthlyEarnings,
|
||||
isCurrentUser: r.isCurrentUser,
|
||||
)).toList();
|
||||
});
|
||||
}
|
||||
debugPrint('[ProfilePage] 省公司排名加载成功: ${_provinceRankings.length}条');
|
||||
} catch (e) {
|
||||
debugPrint('[ProfilePage] 加载省公司排名失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有市公司授权,加载市排名
|
||||
if (_hasAuthCityCompanyAuth && _authCityCompany != '--') {
|
||||
try {
|
||||
final cityName = _authCityCompany;
|
||||
_cityRegionName = cityName;
|
||||
|
||||
final rankings = await authorizationService.getStickmanRanking(
|
||||
month: month,
|
||||
roleType: 'AUTH_CITY_COMPANY',
|
||||
regionCode: cityName,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_cityRankings = rankings.map((r) => StickmanRankingData(
|
||||
id: r.id,
|
||||
nickname: r.nickname,
|
||||
avatarUrl: r.avatarUrl,
|
||||
completedCount: r.completedCount,
|
||||
targetCount: 10000, // 市公司目标1万
|
||||
monthlyEarnings: r.monthlyEarnings,
|
||||
isCurrentUser: r.isCurrentUser,
|
||||
)).toList();
|
||||
});
|
||||
}
|
||||
debugPrint('[ProfilePage] 市公司排名加载成功: ${_cityRankings.length}条');
|
||||
} catch (e) {
|
||||
debugPrint('[ProfilePage] 加载市公司排名失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingStickmanRanking = false;
|
||||
_hasLoadedStickmanRanking = true;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[ProfilePage] 加载火柴人排名失败: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingStickmanRanking = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载通知未读数量
|
||||
Future<void> _loadUnreadNotificationCount() async {
|
||||
try {
|
||||
|
|
@ -972,6 +1084,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
context.push(RoutePaths.bindEmail);
|
||||
}
|
||||
|
||||
/// 自助申请授权
|
||||
void _goToAuthorizationApply() {
|
||||
context.push(RoutePaths.authorizationApply);
|
||||
}
|
||||
|
||||
/// 编辑资料
|
||||
Future<void> _goToEditProfile() async {
|
||||
final result = await context.push<bool>(RoutePaths.editProfile);
|
||||
|
|
@ -1113,6 +1230,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
// 用户头像和基本信息
|
||||
_buildUserHeader(),
|
||||
const SizedBox(height: 16),
|
||||
// 火柴人排名赛跑(省公司/市公司)
|
||||
_buildStickmanRaceSection(),
|
||||
// 推荐人信息卡片(懒加载:推荐数据)
|
||||
VisibilityDetector(
|
||||
key: const Key('referral_section'),
|
||||
|
|
@ -1292,6 +1411,71 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
);
|
||||
}
|
||||
|
||||
/// 构建火柴人赛跑排名区域
|
||||
Widget _buildStickmanRaceSection() {
|
||||
// 如果没有省公司或市公司授权,不显示
|
||||
if (!_hasAuthProvinceCompanyAuth && !_hasAuthCityCompanyAuth) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// 如果正在加载,显示加载提示
|
||||
if (_isLoadingStickmanRanking && !_hasLoadedStickmanRanking) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x80FFFFFF),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: const Color(0x33D4AF37),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: Column(
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFFD4AF37)),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'加载排名数据...',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
color: Color(0xFF8B5A2B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 省公司排名赛跑
|
||||
if (_hasAuthProvinceCompanyAuth && _provinceRankings.isNotEmpty) ...[
|
||||
StickmanRaceWidget(
|
||||
rankings: _provinceRankings,
|
||||
roleType: AuthorizationRoleType.province,
|
||||
regionName: _provinceRegionName,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
// 市公司排名赛跑
|
||||
if (_hasAuthCityCompanyAuth && _cityRankings.isNotEmpty) ...[
|
||||
StickmanRaceWidget(
|
||||
rankings: _cityRankings,
|
||||
roleType: AuthorizationRoleType.city,
|
||||
regionName: _cityRegionName,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建头像内容(优先本地文件,其次网络URL,最后SVG)
|
||||
Widget _buildAvatarContent() {
|
||||
debugPrint('[ProfilePage] _buildAvatarContent() - 开始构建头像');
|
||||
|
|
@ -3530,11 +3714,12 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildSettingItem(
|
||||
icon: Icons.verified_user,
|
||||
title: '谷歌验证器',
|
||||
onTap: _goToGoogleAuth,
|
||||
),
|
||||
// 谷歌验证器功能暂时隐藏(功能保留)
|
||||
// _buildSettingItem(
|
||||
// icon: Icons.verified_user,
|
||||
// title: '谷歌验证器',
|
||||
// onTap: _goToGoogleAuth,
|
||||
// ),
|
||||
_buildSettingItem(
|
||||
icon: Icons.lock,
|
||||
title: '修改登录密码',
|
||||
|
|
@ -3545,6 +3730,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
title: '绑定邮箱',
|
||||
onTap: _goToBindEmail,
|
||||
),
|
||||
_buildSettingItem(
|
||||
icon: Icons.verified,
|
||||
title: '自助申请授权',
|
||||
onTap: _goToAuthorizationApply,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -407,18 +407,19 @@ class _TradingPageState extends ConsumerState<TradingPage> {
|
|||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'结算的币种',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.5,
|
||||
letterSpacing: 0.21,
|
||||
color: Color(0xCC8B5A2B),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// "结算的币种" label 已隐藏
|
||||
// const Text(
|
||||
// '结算的币种',
|
||||
// style: TextStyle(
|
||||
// fontSize: 14,
|
||||
// fontFamily: 'Inter',
|
||||
// fontWeight: FontWeight.w700,
|
||||
// height: 1.5,
|
||||
// letterSpacing: 0.21,
|
||||
// color: Color(0xCC8B5A2B),
|
||||
// ),
|
||||
// ),
|
||||
// const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -432,7 +433,7 @@ class _TradingPageState extends ConsumerState<TradingPage> {
|
|||
// const SizedBox(width: 8),
|
||||
// _buildCurrencyChip(SettlementCurrency.og, 'OG'),
|
||||
// const SizedBox(width: 8),
|
||||
_buildCurrencyChip(SettlementCurrency.usdt, 'RMB/CNY'),
|
||||
_buildCurrencyChip(SettlementCurrency.usdt, 'RMB/CNY提现'),
|
||||
// const SizedBox(width: 8),
|
||||
// _buildCurrencyChip(SettlementCurrency.dst, 'DST'),
|
||||
],
|
||||
|
|
@ -583,7 +584,7 @@ class _TradingPageState extends ConsumerState<TradingPage> {
|
|||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'提取',
|
||||
'划转',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ class _WithdrawUsdtPageState extends ConsumerState<WithdrawUsdtPage> {
|
|||
|
||||
// 验证地址格式
|
||||
if (!_isValidAddress(address)) {
|
||||
_showErrorSnackBar('请输入有效的钱包地址');
|
||||
_showErrorSnackBar('请输入有效的充值用户ID');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -176,8 +176,16 @@ class _WithdrawUsdtPageState extends ConsumerState<WithdrawUsdtPage> {
|
|||
|
||||
/// 验证地址格式
|
||||
bool _isValidAddress(String address) {
|
||||
// 简单的地址格式验证
|
||||
// KAVA 和 BSC 地址都是以 0x 开头的 42 位十六进制字符串
|
||||
// 支持两种格式:
|
||||
// 1. accountSequence 格式:D + 日期(6位) + 序号(5位),共12位,如 D25121400005
|
||||
// 2. 区块链地址格式
|
||||
|
||||
// 检查是否为 accountSequence 格式
|
||||
if (_isAccountSequence(address)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查区块链地址格式
|
||||
if (_selectedNetwork == WithdrawNetwork.kava) {
|
||||
// KAVA 地址格式:kava1... 或 0x...
|
||||
return address.startsWith('kava1') ||
|
||||
|
|
@ -188,6 +196,16 @@ class _WithdrawUsdtPageState extends ConsumerState<WithdrawUsdtPage> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 检查是否为 accountSequence 格式
|
||||
/// 格式:D + 日期(6位) + 序号(5位),共12位,如 D25121400005
|
||||
bool _isAccountSequence(String address) {
|
||||
if (address.length != 12) return false;
|
||||
if (!address.startsWith('D')) return false;
|
||||
// 检查后面11位是否都是数字
|
||||
final numericPart = address.substring(1);
|
||||
return RegExp(r'^\d{11}$').hasMatch(numericPart);
|
||||
}
|
||||
|
||||
/// 显示错误提示
|
||||
void _showErrorSnackBar(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
|
@ -579,7 +597,7 @@ class _WithdrawUsdtPageState extends ConsumerState<WithdrawUsdtPage> {
|
|||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
border: InputBorder.none,
|
||||
hintText: '请输入接收绿积分的地址',
|
||||
hintText: '请输入充值用户ID',
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
|
|
@ -836,7 +854,7 @@ class _WithdrawUsdtPageState extends ConsumerState<WithdrawUsdtPage> {
|
|||
const SizedBox(height: 12),
|
||||
_buildNoticeItem('请确保接收地址正确,错误地址将导致资产丢失'),
|
||||
_buildNoticeItem('请选择正确的网络,不同网络之间不可互转'),
|
||||
_buildNoticeItem('提取需要进行谷歌验证器验证'),
|
||||
_buildNoticeItem('提取需要进行手机短信验证'),
|
||||
_buildNoticeItem('提取通常在 1-30 分钟内到账'),
|
||||
],
|
||||
),
|
||||
|
|
@ -1013,7 +1031,7 @@ class _AddressQrScannerPageState extends State<_AddressQrScannerPage> {
|
|||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: Text(
|
||||
'扫描钱包地址',
|
||||
'扫描充值ID',
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
color: Colors.white,
|
||||
|
|
@ -1047,7 +1065,7 @@ class _AddressQrScannerPageState extends State<_AddressQrScannerPage> {
|
|||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'将钱包地址二维码放入框内',
|
||||
'将充值ID二维码放入框内',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: Colors.white70,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import '../features/auth/presentation/pages/backup_mnemonic_page.dart';
|
|||
import '../features/auth/presentation/pages/verify_mnemonic_page.dart';
|
||||
import '../features/auth/presentation/pages/wallet_created_page.dart';
|
||||
import '../features/auth/presentation/pages/import_mnemonic_page.dart';
|
||||
import '../features/auth/presentation/pages/phone_register_page.dart';
|
||||
import '../features/auth/presentation/pages/sms_verify_page.dart';
|
||||
import '../features/auth/presentation/pages/set_password_page.dart';
|
||||
import '../features/home/presentation/pages/home_shell_page.dart';
|
||||
import '../features/ranking/presentation/pages/ranking_page.dart';
|
||||
import '../features/mining/presentation/pages/mining_page.dart';
|
||||
|
|
@ -23,6 +26,7 @@ import '../features/planting/presentation/pages/planting_location_page.dart';
|
|||
import '../features/security/presentation/pages/google_auth_page.dart';
|
||||
import '../features/security/presentation/pages/change_password_page.dart';
|
||||
import '../features/security/presentation/pages/bind_email_page.dart';
|
||||
import '../features/authorization/presentation/pages/authorization_apply_page.dart';
|
||||
import '../features/withdraw/presentation/pages/withdraw_usdt_page.dart';
|
||||
import '../features/withdraw/presentation/pages/withdraw_confirm_page.dart';
|
||||
import '../features/notification/presentation/pages/notification_inbox_page.dart';
|
||||
|
|
@ -129,7 +133,46 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||
builder: (context, state) => const ImportMnemonicPage(),
|
||||
),
|
||||
|
||||
// Backup Mnemonic (备份助记词 - 会调用 API 获取钱包信息)
|
||||
// Phone Register (手机号注册)
|
||||
GoRoute(
|
||||
path: RoutePaths.phoneRegister,
|
||||
name: RouteNames.phoneRegister,
|
||||
builder: (context, state) {
|
||||
final params = state.extra as PhoneRegisterParams?;
|
||||
return PhoneRegisterPage(
|
||||
inviterReferralCode: params?.inviterReferralCode,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// SMS Verify (短信验证码)
|
||||
GoRoute(
|
||||
path: RoutePaths.smsVerify,
|
||||
name: RouteNames.smsVerify,
|
||||
builder: (context, state) {
|
||||
final params = state.extra as SmsVerifyParams;
|
||||
return SmsVerifyPage(
|
||||
phoneNumber: params.phoneNumber,
|
||||
type: params.type,
|
||||
inviterReferralCode: params.inviterReferralCode,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Set Password (设置登录密码)
|
||||
GoRoute(
|
||||
path: RoutePaths.setPassword,
|
||||
name: RouteNames.setPassword,
|
||||
builder: (context, state) {
|
||||
final params = state.extra as SetPasswordParams;
|
||||
return SetPasswordPage(
|
||||
userSerialNum: params.userSerialNum,
|
||||
inviterReferralCode: params.inviterReferralCode,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Backup Mnemonic (备份助记词 - 会调用 API 获取钱包信息,已屏蔽)
|
||||
GoRoute(
|
||||
path: RoutePaths.backupMnemonic,
|
||||
name: RouteNames.backupMnemonic,
|
||||
|
|
@ -258,6 +301,13 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||
builder: (context, state) => const BindEmailPage(),
|
||||
),
|
||||
|
||||
// Authorization Apply Page (自助申请授权)
|
||||
GoRoute(
|
||||
path: RoutePaths.authorizationApply,
|
||||
name: RouteNames.authorizationApply,
|
||||
builder: (context, state) => const AuthorizationApplyPage(),
|
||||
),
|
||||
|
||||
// Ledger Detail Page (账本明细)
|
||||
GoRoute(
|
||||
path: RoutePaths.ledgerDetail,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ class RouteNames {
|
|||
static const walletCreated = 'wallet-created';
|
||||
static const importWallet = 'import-wallet';
|
||||
static const importMnemonic = 'import-mnemonic';
|
||||
static const phoneRegister = 'phone-register';
|
||||
static const smsVerify = 'sms-verify';
|
||||
static const setPassword = 'set-password';
|
||||
|
||||
// Main tabs
|
||||
static const ranking = 'ranking';
|
||||
|
|
@ -31,6 +34,7 @@ class RouteNames {
|
|||
static const googleAuth = 'google-auth';
|
||||
static const changePassword = 'change-password';
|
||||
static const bindEmail = 'bind-email';
|
||||
static const authorizationApply = 'authorization-apply';
|
||||
static const transactionHistory = 'transaction-history';
|
||||
static const ledgerDetail = 'ledger-detail';
|
||||
static const withdrawUsdt = 'withdraw-usdt';
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ class RoutePaths {
|
|||
static const walletCreated = '/auth/wallet-created';
|
||||
static const importWallet = '/auth/import';
|
||||
static const importMnemonic = '/auth/import-mnemonic';
|
||||
static const phoneRegister = '/auth/phone-register';
|
||||
static const smsVerify = '/auth/sms-verify';
|
||||
static const setPassword = '/auth/set-password';
|
||||
|
||||
// Main tabs
|
||||
static const ranking = '/ranking';
|
||||
|
|
@ -31,6 +34,7 @@ class RoutePaths {
|
|||
static const googleAuth = '/security/google-auth';
|
||||
static const changePassword = '/security/password';
|
||||
static const bindEmail = '/security/email';
|
||||
static const authorizationApply = '/authorization/apply';
|
||||
static const transactionHistory = '/trading/history';
|
||||
static const ledgerDetail = '/trading/ledger';
|
||||
static const withdrawUsdt = '/withdraw/usdt';
|
||||
|
|
|
|||
Loading…
Reference in New Issue