From ac15d6682ad0320442442366905854bf590a0600 Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 2 Mar 2026 03:09:17 -0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=94=A8=E6=88=B7=E9=A2=84?= =?UTF-8?q?=E7=A7=8D=E6=95=B0=E9=87=8F=E5=B1=95=E7=A4=BA=20&=20=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E7=94=B3=E8=AF=B7=E7=85=A7=E7=89=87=E6=9F=A5=E7=9C=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 功能7:用户管理展示预种数量 ### 需求 在用户管理(列表+详情页)展示个人及团队预种份数, 并支持跳转预种管理页查看团队中哪些ID购买了预种。 ### 后端变更 referral-service — 新增内部 API: - GET /internal/referral/pre-planting-stats/:accountSequence - POST /internal/referral/pre-planting-stats/batch 从 TeamStatistics 表查询 selfPrePlantingPortions/teamPrePlantingPortions admin-service: - 新建 ReferralProxyService (src/referral/) 代理 referral-service 内部 API - UserFullDetailDto 新增 selfPrePlantingPortions, teamPrePlantingPortions - user-detail.controller 并行调用获取预种统计 - user.controller 批量获取用户列表预种统计 - UserListItemDto 新增 selfPrePlantingPortions, teamPrePlantingPortions - pre-planting-config.controller 新增 teamOf 查询参数 → 先从 referralQueryView 获取团队成员列表 → 将 accountSequences 传递给 planting-service 过滤 planting-service: - internal-pre-planting.controller 的 admin orders/positions 端点 新增 accountSequences 查询参数,支持按用户列表过滤 ### 前端变更 admin-web: - 用户列表页表格新增"个人预种(份)"、"团队预种(份)"两列 - 用户详情页新增预种统计卡片(个人/团队预种份数) - 团队预种数字可点击,跳转 /pre-planting?teamOf={accountSequence} - 预种管理页支持 teamOf URL 参数,团队过滤模式下显示提示条+返回链接 --- ## 功能8:授权管理查看申请照片 ### 需求 管理后台无法查看社区/市公司/省公司申请时提供的办公室照片。 原因:authorization-service 收到 officePhotoUrls 后只验证未持久化。 照片文件本身已存储在 MinIO,只需持久化 URL 到数据库。 ### 后端变更 authorization-service: - schema.prisma: AuthorizationRole 新增 officePhotoUrls String[] 字段 - authorization-role.aggregate: props/字段/getter/构造/toPersistence/所有工厂方法 - repository: save() create/update 块 + toDomain() 映射 - application service: 3个 self-apply 方法传递 officePhotoUrls 到 aggregate - admin controller: queryAuthorizations 响应包含 officePhotoUrls admin-service: - schema.prisma: AuthorizationRoleQueryView 新增 officePhotoUrls 字段 - user-detail-query.repository 接口 + impl: 包含 officePhotoUrls - user-detail.dto: AuthorizationRoleDto 新增 officePhotoUrls ### 前端变更 admin-web: - 授权管理页表格新增"申请照片"列,点击弹出照片网格 Modal - 照片支持点击放大(全屏 lightbox 覆盖层) - 用户详情页授权信息 Tab 同样支持查看照片 - authorization.types.ts / userDetail.types.ts 类型更新 --- ## 部署注意事项 1. authorization-service 需运行: npx prisma migrate dev --name add-office-photo-urls 2. admin-service 需运行: npx prisma migrate dev --name add-auth-role-office-photos 3. 需部署: authorization-service, admin-service, referral-service, planting-service, admin-web Co-Authored-By: Claude Opus 4.6 --- .../admin-service/prisma/schema.prisma | 3 + .../api/controllers/user-detail.controller.ts | 8 +- .../src/api/controllers/user.controller.ts | 15 +- .../src/api/dto/response/user-detail.dto.ts | 5 + .../src/api/dto/response/user.dto.ts | 2 + .../services/admin-service/src/app.module.ts | 4 + .../user-detail-query.repository.ts | 1 + .../user-detail-query.repository.impl.ts | 1 + .../pre-planting-config.controller.ts | 57 ++++++ .../pre-planting-proxy.service.ts | 4 + .../src/referral/referral-proxy.service.ts | 83 +++++++++ .../prisma/schema.prisma | 3 + .../admin-authorization.controller.ts | 1 + .../authorization-application.service.ts | 4 + .../authorization-role.aggregate.ts | 26 ++- .../authorization-role.repository.impl.ts | 3 + .../internal-pre-planting.controller.ts | 42 ++++- .../src/api/controllers/index.ts | 1 + .../internal-pre-planting-stats.controller.ts | 148 +++++++++++++++ .../src/modules/api.module.ts | 2 + .../authorization/authorization.module.scss | 90 ++++++++++ .../app/(dashboard)/authorization/page.tsx | 84 +++++++++ .../src/app/(dashboard)/pre-planting/page.tsx | 25 ++- .../pre-planting/pre-planting.module.scss | 29 +++ .../src/app/(dashboard)/users/[id]/page.tsx | 82 +++++++++ .../users/[id]/user-detail.module.scss | 168 +++++++++++++++++- .../src/app/(dashboard)/users/page.tsx | 18 +- .../app/(dashboard)/users/users.module.scss | 10 ++ .../src/services/prePlantingService.ts | 1 + .../admin-web/src/services/userService.ts | 2 + .../src/types/authorization.types.ts | 1 + frontend/admin-web/src/types/user.types.ts | 2 + .../admin-web/src/types/userDetail.types.ts | 5 + 33 files changed, 918 insertions(+), 12 deletions(-) create mode 100644 backend/services/admin-service/src/referral/referral-proxy.service.ts create mode 100644 backend/services/referral-service/src/api/controllers/internal-pre-planting-stats.controller.ts diff --git a/backend/services/admin-service/prisma/schema.prisma b/backend/services/admin-service/prisma/schema.prisma index d3f43fc0..09753890 100644 --- a/backend/services/admin-service/prisma/schema.prisma +++ b/backend/services/admin-service/prisma/schema.prisma @@ -796,6 +796,9 @@ model AuthorizationRoleQueryView { lastAssessmentMonth String? @map("last_assessment_month") monthlyTreesAdded Int @default(0) @map("monthly_trees_added") + // 申请时提供的办公室照片(MinIO URL) + officePhotoUrls String[] @default([]) @map("office_photo_urls") + // 时间戳 createdAt DateTime @map("created_at") syncedAt DateTime @default(now()) @map("synced_at") diff --git a/backend/services/admin-service/src/api/controllers/user-detail.controller.ts b/backend/services/admin-service/src/api/controllers/user-detail.controller.ts index ae3710d2..5f778432 100644 --- a/backend/services/admin-service/src/api/controllers/user-detail.controller.ts +++ b/backend/services/admin-service/src/api/controllers/user-detail.controller.ts @@ -26,6 +26,7 @@ import { IUserDetailQueryRepository, USER_DETAIL_QUERY_REPOSITORY, } from '../../domain/repositories/user-detail-query.repository'; +import { ReferralProxyService } from '../../referral/referral-proxy.service'; /** * 用户详情控制器 @@ -40,6 +41,7 @@ export class UserDetailController { private readonly userQueryRepository: IUserQueryRepository, @Inject(USER_DETAIL_QUERY_REPOSITORY) private readonly userDetailRepository: IUserDetailQueryRepository, + private readonly referralProxyService: ReferralProxyService, ) {} /** @@ -58,11 +60,12 @@ export class UserDetailController { } // 并行获取所有相关数据 - const [referralInfo, personalAdoptions, teamStats, directReferralCount] = await Promise.all([ + const [referralInfo, personalAdoptions, teamStats, directReferralCount, prePlantingStats] = await Promise.all([ this.userDetailRepository.getReferralInfo(accountSequence), this.userDetailRepository.getPersonalAdoptionCount(accountSequence), this.userDetailRepository.getTeamStats(accountSequence), this.userDetailRepository.getDirectReferralCount(accountSequence), + this.referralProxyService.getPrePlantingStats(accountSequence), ]); // 获取推荐人昵称 @@ -87,6 +90,8 @@ export class UserDetailController { registeredAt: user.registeredAt.toISOString(), lastActiveAt: user.lastActiveAt?.toISOString() || null, personalAdoptions: personalAdoptions, + selfPrePlantingPortions: prePlantingStats.selfPrePlantingPortions, + teamPrePlantingPortions: prePlantingStats.teamPrePlantingPortions, teamAddresses: teamStats.teamAddressCount, teamAdoptions: teamStats.teamAdoptionCount, provincialAdoptions: { @@ -371,6 +376,7 @@ export class UserDetailController { monthlyTargetType: role.monthlyTargetType, lastAssessmentMonth: role.lastAssessmentMonth, monthlyTreesAdded: role.monthlyTreesAdded, + officePhotoUrls: role.officePhotoUrls, createdAt: role.createdAt.toISOString(), })), assessments: assessments.map((assessment) => ({ diff --git a/backend/services/admin-service/src/api/controllers/user.controller.ts b/backend/services/admin-service/src/api/controllers/user.controller.ts index 6f3d49ee..cd4c1384 100644 --- a/backend/services/admin-service/src/api/controllers/user.controller.ts +++ b/backend/services/admin-service/src/api/controllers/user.controller.ts @@ -21,6 +21,7 @@ import { IUserDetailQueryRepository, USER_DETAIL_QUERY_REPOSITORY, } from '../../domain/repositories/user-detail-query.repository'; +import { ReferralProxyService } from '../../referral/referral-proxy.service'; /** * 用户管理控制器 @@ -34,6 +35,7 @@ export class UserController { private readonly userQueryRepository: IUserQueryRepository, @Inject(USER_DETAIL_QUERY_REPOSITORY) private readonly userDetailRepository: IUserDetailQueryRepository, + private readonly referralProxyService: ReferralProxyService, ) {} /** @@ -70,7 +72,10 @@ export class UserController { // 批量获取实时统计数据 const accountSequences = result.items.map(item => item.accountSequence); - const statsMap = await this.userDetailRepository.getBatchUserStats(accountSequences); + const [statsMap, prePlantingStatsMap] = await Promise.all([ + this.userDetailRepository.getBatchUserStats(accountSequences), + this.referralProxyService.batchGetPrePlantingStats(accountSequences), + ]); // 获取所有用户的团队总认种数用于计算百分比(使用实时数据) let totalTeamAdoptions = 0; @@ -79,7 +84,7 @@ export class UserController { } return { - items: result.items.map((item) => this.mapToListItem(item, totalTeamAdoptions, statsMap.get(item.accountSequence))), + items: result.items.map((item) => this.mapToListItem(item, totalTeamAdoptions, statsMap.get(item.accountSequence), prePlantingStatsMap[item.accountSequence])), total: result.total, page: result.page, pageSize: result.pageSize, @@ -157,6 +162,10 @@ export class UserController { provinceAdoptionCount: number; cityAdoptionCount: number; }, + prePlantingStats?: { + selfPrePlantingPortions: number; + teamPrePlantingPortions: number; + }, ): UserListItemDto { // 使用实时统计数据(如果有),否则使用预计算数据 const personalAdoptions = realTimeStats?.personalAdoptionCount ?? item.personalAdoptionCount; @@ -181,6 +190,8 @@ export class UserController { nickname: item.nickname, phoneNumberMasked: item.phoneNumberMasked, personalAdoptions, + selfPrePlantingPortions: prePlantingStats?.selfPrePlantingPortions ?? 0, + teamPrePlantingPortions: prePlantingStats?.teamPrePlantingPortions ?? 0, teamAddresses, teamAdoptions, provincialAdoptions: { diff --git a/backend/services/admin-service/src/api/dto/response/user-detail.dto.ts b/backend/services/admin-service/src/api/dto/response/user-detail.dto.ts index 30b3602a..37ea0707 100644 --- a/backend/services/admin-service/src/api/dto/response/user-detail.dto.ts +++ b/backend/services/admin-service/src/api/dto/response/user-detail.dto.ts @@ -49,6 +49,10 @@ export class UserFullDetailDto { percentage: number; }; + // 预种统计 + selfPrePlantingPortions!: number; + teamPrePlantingPortions!: number; + // 排名 ranking!: number | null; @@ -215,6 +219,7 @@ export class AuthorizationRoleDto { monthlyTargetType!: string; lastAssessmentMonth!: string | null; monthlyTreesAdded!: number; + officePhotoUrls!: string[]; createdAt!: string; } diff --git a/backend/services/admin-service/src/api/dto/response/user.dto.ts b/backend/services/admin-service/src/api/dto/response/user.dto.ts index 1e822a82..9ecf7251 100644 --- a/backend/services/admin-service/src/api/dto/response/user.dto.ts +++ b/backend/services/admin-service/src/api/dto/response/user.dto.ts @@ -8,6 +8,8 @@ export class UserListItemDto { nickname!: string | null; phoneNumberMasked!: string | null; personalAdoptions!: number; + selfPrePlantingPortions!: number; + teamPrePlantingPortions!: number; teamAddresses!: number; teamAdoptions!: number; provincialAdoptions!: { diff --git a/backend/services/admin-service/src/app.module.ts b/backend/services/admin-service/src/app.module.ts index 9b2cd443..8ac94956 100644 --- a/backend/services/admin-service/src/app.module.ts +++ b/backend/services/admin-service/src/app.module.ts @@ -89,6 +89,8 @@ import { PrePlantingConfigController, PublicPrePlantingConfigController } from ' import { PrePlantingConfigService } from './pre-planting/pre-planting-config.service'; // [2026-02-27] 新增:预种计划数据代理(admin-service → planting-service 内部 HTTP) import { PrePlantingProxyService } from './pre-planting/pre-planting-proxy.service'; +// [2026-03-02] 新增:推荐链预种统计代理(admin-service → referral-service 内部 HTTP) +import { ReferralProxyService } from './referral/referral-proxy.service'; // [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价) import { AdminTreePricingController, PublicTreePricingController } from './pricing/tree-pricing.controller'; import { TreePricingService } from './pricing/tree-pricing.service'; @@ -235,6 +237,8 @@ import { AutoPriceIncreaseJob } from './infrastructure/jobs/auto-price-increase. PrePlantingConfigService, // [2026-02-27] 新增:预种计划数据代理 PrePlantingProxyService, + // [2026-03-02] 新增:推荐链预种统计代理 + ReferralProxyService, // [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价) TreePricingService, AutoPriceIncreaseJob, diff --git a/backend/services/admin-service/src/domain/repositories/user-detail-query.repository.ts b/backend/services/admin-service/src/domain/repositories/user-detail-query.repository.ts index d4d8d011..6fdf61f0 100644 --- a/backend/services/admin-service/src/domain/repositories/user-detail-query.repository.ts +++ b/backend/services/admin-service/src/domain/repositories/user-detail-query.repository.ts @@ -138,6 +138,7 @@ export interface AuthorizationRole { monthlyTargetType: string; lastAssessmentMonth: string | null; monthlyTreesAdded: number; + officePhotoUrls: string[]; createdAt: Date; } diff --git a/backend/services/admin-service/src/infrastructure/persistence/repositories/user-detail-query.repository.impl.ts b/backend/services/admin-service/src/infrastructure/persistence/repositories/user-detail-query.repository.impl.ts index 16a3f90f..17e757fd 100644 --- a/backend/services/admin-service/src/infrastructure/persistence/repositories/user-detail-query.repository.impl.ts +++ b/backend/services/admin-service/src/infrastructure/persistence/repositories/user-detail-query.repository.impl.ts @@ -448,6 +448,7 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository monthlyTargetType: role.monthlyTargetType, lastAssessmentMonth: role.lastAssessmentMonth, monthlyTreesAdded: role.monthlyTreesAdded, + officePhotoUrls: role.officePhotoUrls ?? [], createdAt: role.createdAt, })); } diff --git a/backend/services/admin-service/src/pre-planting/pre-planting-config.controller.ts b/backend/services/admin-service/src/pre-planting/pre-planting-config.controller.ts index 82a89026..6c3860e7 100644 --- a/backend/services/admin-service/src/pre-planting/pre-planting-config.controller.ts +++ b/backend/services/admin-service/src/pre-planting/pre-planting-config.controller.ts @@ -7,11 +7,13 @@ import { Query, HttpCode, HttpStatus, + Logger, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { IsBoolean, IsOptional, IsString } from 'class-validator'; import { PrePlantingConfigService } from './pre-planting-config.service'; import { PrePlantingProxyService } from './pre-planting-proxy.service'; +import { PrismaService } from '../infrastructure/persistence/prisma/prisma.service'; class UpdatePrePlantingConfigDto { @IsBoolean() @@ -35,11 +37,44 @@ class UpdatePrePlantingAgreementDto { @ApiTags('预种计划配置') @Controller('admin/pre-planting') export class PrePlantingConfigController { + private readonly logger = new Logger(PrePlantingConfigController.name); + constructor( private readonly configService: PrePlantingConfigService, private readonly proxyService: PrePlantingProxyService, + private readonly prisma: PrismaService, ) {} + /** + * 根据 accountSequence 查找该用户的团队成员 accountSequence 列表 + * 逻辑:找到该用户的 userId,然后查找 ancestorPath 包含该 userId 的所有用户 + * 返回的列表包含该用户本人 + */ + private async resolveTeamAccountSequences(teamOfAccountSeq: string): Promise { + // 1. 找到 teamOf 用户的 userId + const leader = await this.prisma.referralQueryView.findUnique({ + where: { accountSequence: teamOfAccountSeq }, + select: { userId: true, accountSequence: true }, + }); + if (!leader) { + this.logger.warn(`[resolveTeamAccountSequences] 未找到用户: ${teamOfAccountSeq}`); + return []; + } + + // 2. 查找 ancestorPath 包含该 userId 的所有下级用户(PostgreSQL array contains) + const teamMembers = await this.prisma.referralQueryView.findMany({ + where: { + ancestorPath: { has: leader.userId }, + }, + select: { accountSequence: true }, + }); + + // 3. 包含团队领导本人 + const sequences = [leader.accountSequence, ...teamMembers.map((m) => m.accountSequence)]; + this.logger.debug(`[resolveTeamAccountSequences] ${teamOfAccountSeq} 团队成员数: ${sequences.length}`); + return sequences; + } + @Get('config') @ApiOperation({ summary: '获取预种计划开关状态(含协议文本)' }) @ApiResponse({ status: HttpStatus.OK, description: '开关状态' }) @@ -75,17 +110,28 @@ export class PrePlantingConfigController { @ApiQuery({ name: 'pageSize', required: false }) @ApiQuery({ name: 'keyword', required: false }) @ApiQuery({ name: 'status', required: false }) + @ApiQuery({ name: 'teamOf', required: false, description: '团队筛选:指定用户 accountSequence,只显示其团队成员的订单' }) async getOrders( @Query('page') page?: string, @Query('pageSize') pageSize?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, + @Query('teamOf') teamOf?: string, ) { + let accountSequences: string[] | undefined; + if (teamOf) { + accountSequences = await this.resolveTeamAccountSequences(teamOf); + if (accountSequences.length === 0) { + return { items: [], total: 0, page: page ? parseInt(page, 10) : 1, pageSize: pageSize ? parseInt(pageSize, 10) : 20 }; + } + } + return this.proxyService.getOrders({ page: page ? parseInt(page, 10) : undefined, pageSize: pageSize ? parseInt(pageSize, 10) : undefined, keyword: keyword || undefined, status: status || undefined, + accountSequences, }); } @@ -94,15 +140,26 @@ export class PrePlantingConfigController { @ApiQuery({ name: 'page', required: false }) @ApiQuery({ name: 'pageSize', required: false }) @ApiQuery({ name: 'keyword', required: false }) + @ApiQuery({ name: 'teamOf', required: false, description: '团队筛选:指定用户 accountSequence,只显示其团队成员的持仓' }) async getPositions( @Query('page') page?: string, @Query('pageSize') pageSize?: string, @Query('keyword') keyword?: string, + @Query('teamOf') teamOf?: string, ) { + let accountSequences: string[] | undefined; + if (teamOf) { + accountSequences = await this.resolveTeamAccountSequences(teamOf); + if (accountSequences.length === 0) { + return { items: [], total: 0, page: page ? parseInt(page, 10) : 1, pageSize: pageSize ? parseInt(pageSize, 10) : 20 }; + } + } + return this.proxyService.getPositions({ page: page ? parseInt(page, 10) : undefined, pageSize: pageSize ? parseInt(pageSize, 10) : undefined, keyword: keyword || undefined, + accountSequences, }); } diff --git a/backend/services/admin-service/src/pre-planting/pre-planting-proxy.service.ts b/backend/services/admin-service/src/pre-planting/pre-planting-proxy.service.ts index 8e7105b6..e305300e 100644 --- a/backend/services/admin-service/src/pre-planting/pre-planting-proxy.service.ts +++ b/backend/services/admin-service/src/pre-planting/pre-planting-proxy.service.ts @@ -37,6 +37,7 @@ export class PrePlantingProxyService { pageSize?: number; keyword?: string; status?: string; + accountSequences?: string[]; }) { try { const qp = new URLSearchParams(); @@ -44,6 +45,7 @@ export class PrePlantingProxyService { if (params.pageSize) qp.append('pageSize', params.pageSize.toString()); if (params.keyword) qp.append('keyword', params.keyword); if (params.status) qp.append('status', params.status); + if (params.accountSequences?.length) qp.append('accountSequences', params.accountSequences.join(',')); const url = `/api/v1/internal/pre-planting/admin/orders?${qp.toString()}`; this.logger.debug(`[getOrders] 请求: ${url}`); @@ -59,12 +61,14 @@ export class PrePlantingProxyService { page?: number; pageSize?: number; keyword?: string; + accountSequences?: string[]; }) { try { const qp = new URLSearchParams(); if (params.page) qp.append('page', params.page.toString()); if (params.pageSize) qp.append('pageSize', params.pageSize.toString()); if (params.keyword) qp.append('keyword', params.keyword); + if (params.accountSequences?.length) qp.append('accountSequences', params.accountSequences.join(',')); const url = `/api/v1/internal/pre-planting/admin/positions?${qp.toString()}`; this.logger.debug(`[getPositions] 请求: ${url}`); diff --git a/backend/services/admin-service/src/referral/referral-proxy.service.ts b/backend/services/admin-service/src/referral/referral-proxy.service.ts new file mode 100644 index 00000000..d971d6dc --- /dev/null +++ b/backend/services/admin-service/src/referral/referral-proxy.service.ts @@ -0,0 +1,83 @@ +/** + * 推荐链预种统计代理服务 + * [2026-03-02] 新增:通过内部 HTTP 调用 referral-service 获取预种统计数据 + * + * === 架构 === + * admin-web → admin-service (本服务) → referral-service /internal/referral/pre-planting-stats/* + * 复用现有 PrePlantingProxyService 的 axios 代理模式 + */ + +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios, { AxiosInstance } from 'axios'; + +export interface PrePlantingStats { + selfPrePlantingPortions: number; + teamPrePlantingPortions: number; +} + +@Injectable() +export class ReferralProxyService { + private readonly logger = new Logger(ReferralProxyService.name); + private readonly httpClient: AxiosInstance; + + constructor(private readonly configService: ConfigService) { + const referralServiceUrl = this.configService.get( + 'REFERRAL_SERVICE_URL', + 'http://rwa-referral-service:3004', + ); + + this.httpClient = axios.create({ + baseURL: referralServiceUrl, + timeout: 30000, + }); + + this.logger.log( + `ReferralProxyService initialized, referral-service URL: ${referralServiceUrl}`, + ); + } + + /** + * 获取单个用户的预种统计(个人 + 团队预种份数) + */ + async getPrePlantingStats(accountSequence: string): Promise { + try { + const url = `/api/v1/internal/referral/pre-planting-stats/${accountSequence}`; + this.logger.debug(`[getPrePlantingStats] 请求: ${url}`); + const response = await this.httpClient.get(url); + return { + selfPrePlantingPortions: response.data?.selfPrePlantingPortions ?? 0, + teamPrePlantingPortions: response.data?.teamPrePlantingPortions ?? 0, + }; + } catch (error) { + this.logger.error(`[getPrePlantingStats] 失败 (${accountSequence}): ${error.message}`); + return { selfPrePlantingPortions: 0, teamPrePlantingPortions: 0 }; + } + } + + /** + * 批量获取多个用户的预种统计 + */ + async batchGetPrePlantingStats( + accountSequences: string[], + ): Promise> { + if (accountSequences.length === 0) { + return {}; + } + + try { + const url = '/api/v1/internal/referral/pre-planting-stats/batch'; + this.logger.debug(`[batchGetPrePlantingStats] 请求: ${url}, 数量: ${accountSequences.length}`); + const response = await this.httpClient.post(url, { accountSequences }); + return response.data ?? {}; + } catch (error) { + this.logger.error(`[batchGetPrePlantingStats] 失败: ${error.message}`); + // 返回所有用户的零值默认 + const defaults: Record = {}; + for (const seq of accountSequences) { + defaults[seq] = { selfPrePlantingPortions: 0, teamPrePlantingPortions: 0 }; + } + return defaults; + } + } +} diff --git a/backend/services/authorization-service/prisma/schema.prisma b/backend/services/authorization-service/prisma/schema.prisma index 9573d802..f86a0431 100644 --- a/backend/services/authorization-service/prisma/schema.prisma +++ b/backend/services/authorization-service/prisma/schema.prisma @@ -53,6 +53,9 @@ model AuthorizationRole { // 当前考核月份索引 currentMonthIndex Int @default(0) @map("current_month_index") + // 申请时提供的办公室照片(MinIO URL) + officePhotoUrls String[] @default([]) @map("office_photo_urls") + // 时间戳 createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/backend/services/authorization-service/src/api/controllers/admin-authorization.controller.ts b/backend/services/authorization-service/src/api/controllers/admin-authorization.controller.ts index 9bdd902c..1ed2ca28 100644 --- a/backend/services/authorization-service/src/api/controllers/admin-authorization.controller.ts +++ b/backend/services/authorization-service/src/api/controllers/admin-authorization.controller.ts @@ -53,6 +53,7 @@ export class AdminAuthorizationController { authorizedAt: Date | null revokedAt: Date | null revokeReason: string | null + officePhotoUrls: string[] }> total: number page: number diff --git a/backend/services/authorization-service/src/application/services/authorization-application.service.ts b/backend/services/authorization-service/src/application/services/authorization-application.service.ts index 7b4f4e09..9611a3a1 100644 --- a/backend/services/authorization-service/src/application/services/authorization-application.service.ts +++ b/backend/services/authorization-service/src/application/services/authorization-application.service.ts @@ -3404,6 +3404,7 @@ export class AuthorizationApplicationService { const authorization = AuthorizationRole.createSelfAppliedCommunity({ userId, communityName: command.communityName!, + officePhotoUrls: command.officePhotoUrls, }) // 检查初始考核 @@ -3465,6 +3466,7 @@ export class AuthorizationApplicationService { userId, cityCode: command.cityCode!, cityName: command.cityName!, + officePhotoUrls: command.officePhotoUrls, }) // 检查初始考核(500棵) @@ -3526,6 +3528,7 @@ export class AuthorizationApplicationService { userId, provinceCode: command.provinceCode!, provinceName: command.provinceName!, + officePhotoUrls: command.officePhotoUrls, }) // 检查初始考核(500棵) @@ -3941,6 +3944,7 @@ export class AuthorizationApplicationService { authorizedAt: auth.authorizedAt, revokedAt: auth.revokedAt, revokeReason: auth.revokeReason, + officePhotoUrls: auth.officePhotoUrls, } }) diff --git a/backend/services/authorization-service/src/domain/aggregates/authorization-role.aggregate.ts b/backend/services/authorization-service/src/domain/aggregates/authorization-role.aggregate.ts index ac2c9aa6..f3e4972f 100644 --- a/backend/services/authorization-service/src/domain/aggregates/authorization-role.aggregate.ts +++ b/backend/services/authorization-service/src/domain/aggregates/authorization-role.aggregate.ts @@ -49,6 +49,7 @@ export interface AuthorizationRoleProps { lastMonthTreesAdded: number // 上月新增树数(考核用存档) currentMonthIndex: number deletedAt: Date | null // 软删除时间 (大厂通用做法) + officePhotoUrls: string[] // 办公室照片 URL 列表 createdAt: Date updatedAt: Date } @@ -93,6 +94,9 @@ export class AuthorizationRole extends AggregateRoot { // 软删除 (大厂通用做法) private _deletedAt: Date | null + // 办公室照片 + private _officePhotoUrls: string[] + private _createdAt: Date private _updatedAt: Date @@ -175,6 +179,9 @@ export class AuthorizationRole extends AggregateRoot { get deletedAt(): Date | null { return this._deletedAt } + get officePhotoUrls(): string[] { + return this._officePhotoUrls + } get isActive(): boolean { return this._status === AuthorizationStatus.AUTHORIZED } @@ -206,6 +213,7 @@ export class AuthorizationRole extends AggregateRoot { this._lastMonthTreesAdded = props.lastMonthTreesAdded this._currentMonthIndex = props.currentMonthIndex this._deletedAt = props.deletedAt + this._officePhotoUrls = props.officePhotoUrls this._createdAt = props.createdAt this._updatedAt = props.updatedAt } @@ -268,6 +276,7 @@ export class AuthorizationRole extends AggregateRoot { lastMonthTreesAdded: 0, currentMonthIndex: 0, deletedAt: null, + officePhotoUrls: [], createdAt: new Date(), updatedAt: new Date(), }) @@ -284,7 +293,7 @@ export class AuthorizationRole extends AggregateRoot { } // 工厂方法 - 自助申请社区授权(直接生效,无需审核) - static createSelfAppliedCommunity(params: { userId: UserId; communityName: string }): AuthorizationRole { + static createSelfAppliedCommunity(params: { userId: UserId; communityName: string; officePhotoUrls?: string[] }): AuthorizationRole { const now = new Date() const auth = new AuthorizationRole({ authorizationId: AuthorizationId.generate(), @@ -311,6 +320,7 @@ export class AuthorizationRole extends AggregateRoot { lastMonthTreesAdded: 0, currentMonthIndex: 0, deletedAt: null, + officePhotoUrls: params.officePhotoUrls ?? [], createdAt: now, updatedAt: now, }) @@ -361,6 +371,7 @@ export class AuthorizationRole extends AggregateRoot { lastMonthTreesAdded: 0, currentMonthIndex: skipAssessment ? 1 : 0, deletedAt: null, + officePhotoUrls: [], createdAt: now, updatedAt: now, }) @@ -409,6 +420,7 @@ export class AuthorizationRole extends AggregateRoot { lastMonthTreesAdded: 0, currentMonthIndex: 0, deletedAt: null, + officePhotoUrls: [], createdAt: now, updatedAt: now, }) @@ -430,6 +442,7 @@ export class AuthorizationRole extends AggregateRoot { userId: UserId provinceCode: string provinceName: string + officePhotoUrls?: string[] }): AuthorizationRole { const now = new Date() const auth = new AuthorizationRole({ @@ -457,6 +470,7 @@ export class AuthorizationRole extends AggregateRoot { lastMonthTreesAdded: 0, currentMonthIndex: 0, deletedAt: null, + officePhotoUrls: params.officePhotoUrls ?? [], createdAt: now, updatedAt: now, }) @@ -509,6 +523,7 @@ export class AuthorizationRole extends AggregateRoot { lastMonthTreesAdded: 0, currentMonthIndex: 1, // 从第1个月开始考核 deletedAt: null, + officePhotoUrls: [], createdAt: now, updatedAt: now, }) @@ -558,6 +573,7 @@ export class AuthorizationRole extends AggregateRoot { lastMonthTreesAdded: 0, currentMonthIndex: 0, deletedAt: null, + officePhotoUrls: [], createdAt: now, updatedAt: now, }) @@ -579,6 +595,7 @@ export class AuthorizationRole extends AggregateRoot { userId: UserId cityCode: string cityName: string + officePhotoUrls?: string[] }): AuthorizationRole { const now = new Date() const auth = new AuthorizationRole({ @@ -606,6 +623,7 @@ export class AuthorizationRole extends AggregateRoot { lastMonthTreesAdded: 0, currentMonthIndex: 0, deletedAt: null, + officePhotoUrls: params.officePhotoUrls ?? [], createdAt: now, updatedAt: now, }) @@ -658,6 +676,7 @@ export class AuthorizationRole extends AggregateRoot { lastMonthTreesAdded: 0, currentMonthIndex: 1, // 从第1个月开始考核 deletedAt: null, + officePhotoUrls: [], createdAt: now, updatedAt: now, }) @@ -708,6 +727,7 @@ export class AuthorizationRole extends AggregateRoot { lastMonthTreesAdded: 0, currentMonthIndex: 1, // 从第1个月开始考核(与手动授权一致) deletedAt: null, + officePhotoUrls: [], createdAt: now, updatedAt: now, }) @@ -758,6 +778,7 @@ export class AuthorizationRole extends AggregateRoot { lastMonthTreesAdded: 0, currentMonthIndex: 1, // 从第1个月开始考核(与手动授权一致) deletedAt: null, + officePhotoUrls: [], createdAt: now, updatedAt: now, }) @@ -810,6 +831,7 @@ export class AuthorizationRole extends AggregateRoot { lastMonthTreesAdded: 0, currentMonthIndex: skipAssessment ? 1 : 0, deletedAt: null, + officePhotoUrls: [], createdAt: now, updatedAt: now, }) @@ -862,6 +884,7 @@ export class AuthorizationRole extends AggregateRoot { lastMonthTreesAdded: 0, currentMonthIndex: skipAssessment ? 1 : 0, deletedAt: null, + officePhotoUrls: [], createdAt: now, updatedAt: now, }) @@ -1170,6 +1193,7 @@ export class AuthorizationRole extends AggregateRoot { monthlyTreesAdded: this._monthlyTreesAdded, lastMonthTreesAdded: this._lastMonthTreesAdded, currentMonthIndex: this._currentMonthIndex, + officePhotoUrls: this._officePhotoUrls, deletedAt: this._deletedAt, createdAt: this._createdAt, updatedAt: this._updatedAt, diff --git a/backend/services/authorization-service/src/infrastructure/persistence/repositories/authorization-role.repository.impl.ts b/backend/services/authorization-service/src/infrastructure/persistence/repositories/authorization-role.repository.impl.ts index 9c6c018d..653ac007 100644 --- a/backend/services/authorization-service/src/infrastructure/persistence/repositories/authorization-role.repository.impl.ts +++ b/backend/services/authorization-service/src/infrastructure/persistence/repositories/authorization-role.repository.impl.ts @@ -52,6 +52,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi monthlyTreesAdded: data.monthlyTreesAdded, lastMonthTreesAdded: data.lastMonthTreesAdded, currentMonthIndex: data.currentMonthIndex, + officePhotoUrls: data.officePhotoUrls, deletedAt: data.deletedAt, }, update: { @@ -72,6 +73,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi monthlyTreesAdded: data.monthlyTreesAdded, lastMonthTreesAdded: data.lastMonthTreesAdded, currentMonthIndex: data.currentMonthIndex, + officePhotoUrls: data.officePhotoUrls, deletedAt: data.deletedAt, }, }) @@ -518,6 +520,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi monthlyTreesAdded: record.monthlyTreesAdded ?? 0, lastMonthTreesAdded: record.lastMonthTreesAdded ?? 0, currentMonthIndex: record.currentMonthIndex, + officePhotoUrls: record.officePhotoUrls ?? [], deletedAt: record.deletedAt, createdAt: record.createdAt, updatedAt: record.updatedAt, diff --git a/backend/services/planting-service/src/pre-planting/api/controllers/internal-pre-planting.controller.ts b/backend/services/planting-service/src/pre-planting/api/controllers/internal-pre-planting.controller.ts index 977959db..f28fdc4e 100644 --- a/backend/services/planting-service/src/pre-planting/api/controllers/internal-pre-planting.controller.ts +++ b/backend/services/planting-service/src/pre-planting/api/controllers/internal-pre-planting.controller.ts @@ -47,22 +47,37 @@ export class InternalPrePlantingController { @ApiQuery({ name: 'pageSize', required: false }) @ApiQuery({ name: 'keyword', required: false }) @ApiQuery({ name: 'status', required: false }) + @ApiQuery({ name: 'accountSequences', required: false, description: '逗号分隔的 accountSequence 列表,用于团队筛选' }) async getAdminOrders( @Query('page') page?: string, @Query('pageSize') pageSize?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, + @Query('accountSequences') accountSequences?: string, ) { const p = Math.max(1, parseInt(page || '1', 10)); const ps = Math.min(100, Math.max(1, parseInt(pageSize || '20', 10))); const where: any = {}; if (status) where.status = status; + if (accountSequences) { + const seqList = accountSequences.split(',').map((s) => s.trim()).filter(Boolean); + if (seqList.length > 0) { + where.accountSequence = { in: seqList }; + } + } if (keyword) { - where.OR = [ - { orderNo: { contains: keyword, mode: 'insensitive' } }, - { accountSequence: { contains: keyword, mode: 'insensitive' } }, - ]; + // 当同时存在 accountSequences 时,keyword 只搜 orderNo(accountSequence 已被限定) + if (accountSequences) { + where.OR = [ + { orderNo: { contains: keyword, mode: 'insensitive' } }, + ]; + } else { + where.OR = [ + { orderNo: { contains: keyword, mode: 'insensitive' } }, + { accountSequence: { contains: keyword, mode: 'insensitive' } }, + ]; + } } const [items, total] = await Promise.all([ @@ -103,17 +118,34 @@ export class InternalPrePlantingController { @ApiQuery({ name: 'page', required: false }) @ApiQuery({ name: 'pageSize', required: false }) @ApiQuery({ name: 'keyword', required: false }) + @ApiQuery({ name: 'accountSequences', required: false, description: '逗号分隔的 accountSequence 列表,用于团队筛选' }) async getAdminPositions( @Query('page') page?: string, @Query('pageSize') pageSize?: string, @Query('keyword') keyword?: string, + @Query('accountSequences') accountSequences?: string, ) { const p = Math.max(1, parseInt(page || '1', 10)); const ps = Math.min(100, Math.max(1, parseInt(pageSize || '20', 10))); const where: any = {}; + if (accountSequences) { + const seqList = accountSequences.split(',').map((s) => s.trim()).filter(Boolean); + if (seqList.length > 0) { + where.accountSequence = { in: seqList }; + } + } if (keyword) { - where.accountSequence = { contains: keyword, mode: 'insensitive' }; + // 当同时存在 accountSequences 时,用 AND 组合两个条件 + if (accountSequences && where.accountSequence) { + where.AND = [ + { accountSequence: where.accountSequence }, + { accountSequence: { contains: keyword, mode: 'insensitive' } }, + ]; + delete where.accountSequence; + } else { + where.accountSequence = { contains: keyword, mode: 'insensitive' }; + } } const [items, total] = await Promise.all([ diff --git a/backend/services/referral-service/src/api/controllers/index.ts b/backend/services/referral-service/src/api/controllers/index.ts index 1f005e58..246b61d7 100644 --- a/backend/services/referral-service/src/api/controllers/index.ts +++ b/backend/services/referral-service/src/api/controllers/index.ts @@ -2,4 +2,5 @@ export * from './referral.controller'; export * from './team-statistics.controller'; export * from './internal-team-statistics.controller'; export * from './internal-referral-chain.controller'; +export * from './internal-pre-planting-stats.controller'; export * from './health.controller'; diff --git a/backend/services/referral-service/src/api/controllers/internal-pre-planting-stats.controller.ts b/backend/services/referral-service/src/api/controllers/internal-pre-planting-stats.controller.ts new file mode 100644 index 00000000..b8a5868f --- /dev/null +++ b/backend/services/referral-service/src/api/controllers/internal-pre-planting-stats.controller.ts @@ -0,0 +1,148 @@ +import { + Controller, + Get, + Post, + Param, + Body, + Logger, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +/** + * 内部预种统计API - 供其他微服务调用 + * 无需JWT认证(服务间内部通信) + */ +@ApiTags('Internal Pre-Planting Stats API') +@Controller('internal/referral/pre-planting-stats') +export class InternalPrePlantingStatsController { + private readonly logger = new Logger(InternalPrePlantingStatsController.name); + + constructor(private readonly prisma: PrismaService) {} + + /** + * 根据 accountSequence 获取单个用户的预种统计 + */ + @Get(':accountSequence') + @ApiOperation({ summary: '获取用户预种统计(内部API)' }) + @ApiParam({ name: 'accountSequence', description: '账户序列号 (格式: D + YYMMDD + 5位序号)' }) + @ApiResponse({ + status: 200, + description: '预种统计数据', + schema: { + type: 'object', + properties: { + selfPrePlantingPortions: { type: 'number', description: '个人预种份数' }, + teamPrePlantingPortions: { type: 'number', description: '团队预种份数(含自己和所有下级)' }, + }, + }, + }) + async getPrePlantingStats(@Param('accountSequence') accountSequence: string) { + this.logger.debug(`[INTERNAL] getPrePlantingStats: ${accountSequence}`); + + const relationship = await this.prisma.referralRelationship.findUnique({ + where: { accountSequence }, + select: { userId: true }, + }); + + if (!relationship) { + this.logger.debug(`[INTERNAL] No referral found for accountSequence: ${accountSequence}`); + return { + selfPrePlantingPortions: 0, + teamPrePlantingPortions: 0, + }; + } + + const stats = await this.prisma.teamStatistics.findUnique({ + where: { userId: relationship.userId }, + select: { + selfPrePlantingPortions: true, + teamPrePlantingPortions: true, + }, + }); + + if (!stats) { + this.logger.debug(`[INTERNAL] No team statistics found for userId: ${relationship.userId}`); + return { + selfPrePlantingPortions: 0, + teamPrePlantingPortions: 0, + }; + } + + return { + selfPrePlantingPortions: stats.selfPrePlantingPortions, + teamPrePlantingPortions: stats.teamPrePlantingPortions, + }; + } + + /** + * 批量获取多个用户的预种统计 + */ + @Post('batch') + @ApiOperation({ summary: '批量获取用户预种统计(内部API)' }) + @ApiResponse({ + status: 200, + description: '批量预种统计数据 (Map: accountSequence → stats)', + schema: { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + selfPrePlantingPortions: { type: 'number' }, + teamPrePlantingPortions: { type: 'number' }, + }, + }, + }, + }) + async getBatchPrePlantingStats( + @Body() body: { accountSequences: string[] }, + ) { + const { accountSequences } = body; + this.logger.debug(`[INTERNAL] getBatchPrePlantingStats: ${accountSequences.length} accounts`); + + // 批量查询所有 referral relationships + const relationships = await this.prisma.referralRelationship.findMany({ + where: { accountSequence: { in: accountSequences } }, + select: { accountSequence: true, userId: true }, + }); + + const accountToUserId = new Map(); + for (const rel of relationships) { + accountToUserId.set(rel.accountSequence, rel.userId); + } + + // 批量查询所有 team statistics + const userIds = relationships.map((r) => r.userId); + const allStats = await this.prisma.teamStatistics.findMany({ + where: { userId: { in: userIds } }, + select: { + userId: true, + selfPrePlantingPortions: true, + teamPrePlantingPortions: true, + }, + }); + + const userIdToStats = new Map(); + for (const s of allStats) { + userIdToStats.set(s.userId, { + selfPrePlantingPortions: s.selfPrePlantingPortions, + teamPrePlantingPortions: s.teamPrePlantingPortions, + }); + } + + // 组装结果 + const result: Record = {}; + + for (const seq of accountSequences) { + const userId = accountToUserId.get(seq); + if (userId) { + const stats = userIdToStats.get(userId); + result[seq] = stats ?? { selfPrePlantingPortions: 0, teamPrePlantingPortions: 0 }; + } else { + result[seq] = { selfPrePlantingPortions: 0, teamPrePlantingPortions: 0 }; + } + } + + return result; + } +} diff --git a/backend/services/referral-service/src/modules/api.module.ts b/backend/services/referral-service/src/modules/api.module.ts index 4b06477a..612c917d 100644 --- a/backend/services/referral-service/src/modules/api.module.ts +++ b/backend/services/referral-service/src/modules/api.module.ts @@ -7,6 +7,7 @@ import { TeamStatisticsController, InternalTeamStatisticsController, InternalReferralChainController, + InternalPrePlantingStatsController, HealthController, } from '../api'; import { InternalReferralController } from '../api/controllers/referral.controller'; @@ -18,6 +19,7 @@ import { InternalReferralController } from '../api/controllers/referral.controll TeamStatisticsController, InternalTeamStatisticsController, InternalReferralChainController, + InternalPrePlantingStatsController, HealthController, InternalReferralController, ], diff --git a/frontend/admin-web/src/app/(dashboard)/authorization/authorization.module.scss b/frontend/admin-web/src/app/(dashboard)/authorization/authorization.module.scss index 937293b9..80628830 100644 --- a/frontend/admin-web/src/app/(dashboard)/authorization/authorization.module.scss +++ b/frontend/admin-web/src/app/(dashboard)/authorization/authorization.module.scss @@ -1001,6 +1001,96 @@ cursor: help; } +/* 照片列 */ +.authorization__tableCell--photos { + width: 120px; + flex-shrink: 0; +} + +/* 查看照片按钮 */ +.authorization__photoBtn { + cursor: pointer; + border: 1px solid #3b82f6; + border-radius: 4px; + background-color: rgba(59, 130, 246, 0.08); + padding: 3px 8px; + font-size: 12px; + line-height: 16px; + font-weight: 500; + color: #2563eb; + font-family: inherit; + white-space: nowrap; + @include transition-fast; + + &:hover { + background-color: rgba(59, 130, 246, 0.16); + } +} + +/* 宽模态框(用于照片查看) */ +.modal__content--wide { + max-width: 680px; +} + +/* 照片网格 */ +.photoGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + max-height: 60vh; + overflow-y: auto; + padding: 4px; +} + +.photoGrid__item { + border-radius: 6px; + overflow: hidden; + border: 1px solid #e5e7eb; + cursor: pointer; + @include transition-fast; + + &:hover { + border-color: #3b82f6; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + } +} + +.photoGrid__img { + display: block; + width: 100%; + height: auto; + object-fit: cover; +} + +/* 全屏灯箱 */ +.lightbox { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.85); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 2000; + cursor: pointer; +} + +.lightbox__img { + max-width: 90vw; + max-height: 85vh; + object-fit: contain; + border-radius: 4px; +} + +.lightbox__close { + margin-top: 16px; + color: rgba(255, 255, 255, 0.7); + font-size: 14px; +} + /* 分页 */ .authorization__pagination { align-self: stretch; diff --git a/frontend/admin-web/src/app/(dashboard)/authorization/page.tsx b/frontend/admin-web/src/app/(dashboard)/authorization/page.tsx index 5069c3e0..22af991d 100644 --- a/frontend/admin-web/src/app/(dashboard)/authorization/page.tsx +++ b/frontend/admin-web/src/app/(dashboard)/authorization/page.tsx @@ -29,6 +29,11 @@ export default function AuthorizationPage() { const [page, setPage] = useState(1); const limit = 20; + // 照片查看状态 + const [showPhotoModal, setShowPhotoModal] = useState(false); + const [selectedPhotos, setSelectedPhotos] = useState([]); + const [lightboxPhoto, setLightboxPhoto] = useState(null); + // 创建授权对话框状态 const [showCreateModal, setShowCreateModal] = useState(false); const [createForm, setCreateForm] = useState({ @@ -186,6 +191,12 @@ export default function AuthorizationPage() { } }; + // 打开照片查看对话框 + const openPhotoModal = (photos: string[]) => { + setSelectedPhotos(photos); + setShowPhotoModal(true); + }; + // 打开取消授权对话框 const openRevokeModal = (item: Authorization) => { setRevokeTarget(item); @@ -392,6 +403,15 @@ export default function AuthorizationPage() { > 状态 +
+ 申请照片 +
+
+ {item.officePhotoUrls && item.officePhotoUrls.length > 0 ? ( + + ) : ( + - + )} +
)} + + {/* 照片查看对话框 */} + {showPhotoModal && selectedPhotos.length > 0 && ( +
setShowPhotoModal(false)}> +
e.stopPropagation()} + > +

申请照片 ({selectedPhotos.length} 张)

+
+ {selectedPhotos.map((url, index) => ( +
+ {`申请照片 setLightboxPhoto(url)} + /> +
+ ))} +
+
+ +
+
+
+ )} + + {/* 照片全屏查看 (Lightbox) */} + {lightboxPhoto && ( +
setLightboxPhoto(null)} + > + 照片预览 + 点击任意处关闭 +
+ )} ); } diff --git a/frontend/admin-web/src/app/(dashboard)/pre-planting/page.tsx b/frontend/admin-web/src/app/(dashboard)/pre-planting/page.tsx index 4d20e1db..4f32c089 100644 --- a/frontend/admin-web/src/app/(dashboard)/pre-planting/page.tsx +++ b/frontend/admin-web/src/app/(dashboard)/pre-planting/page.tsx @@ -21,6 +21,8 @@ */ import { useState, useEffect, useCallback } from 'react'; +import { useSearchParams } from 'next/navigation'; +import Link from 'next/link'; import { Button } from '@/components/common'; import { PageContainer } from '@/components/layout'; import { cn } from '@/utils/helpers'; @@ -62,6 +64,10 @@ const CONTRACT_STATUS_MAP: Record = { * 预种计划管理页面 */ export default function PrePlantingPage() { + // === URL 参数:团队过滤 === + const searchParams = useSearchParams(); + const teamOf = searchParams.get('teamOf') || undefined; + // === Tab 与分页状态 === const [activeTab, setActiveTab] = useState('orders'); const [keyword, setKeyword] = useState(''); @@ -77,16 +83,19 @@ export default function PrePlantingPage() { page, pageSize, keyword: activeTab === 'orders' ? keyword : undefined, + teamOf, }); const positionsQuery = usePrePlantingPositions({ page, pageSize, keyword: activeTab === 'positions' ? keyword : undefined, + teamOf, }); const mergesQuery = usePrePlantingMerges({ page, pageSize, keyword: activeTab === 'merges' ? keyword : undefined, + teamOf, }); // === 协议文本管理 === @@ -141,9 +150,23 @@ export default function PrePlantingPage() {
{/* 页面标题 */}
-

预种计划管理

+

+ {teamOf ? `${teamOf} 的团队预种数据` : '预种计划管理'} +

+ {/* 团队过滤提示 */} + {teamOf && ( +
+ + 正在查看 {teamOf} 的团队预种数据 + + + 返回用户详情 + +
+ )} + {/* 预种开关卡片 */}
(null); const [contractsLoading, setContractsLoading] = useState(false); + // 授权照片查看状态 + const [showAuthPhotoModal, setShowAuthPhotoModal] = useState(false); + const [authSelectedPhotos, setAuthSelectedPhotos] = useState([]); + const [authLightboxPhoto, setAuthLightboxPhoto] = useState(null); + // 获取用户完整信息 const { data: userDetail, isLoading: detailLoading, error: detailError } = useUserFullDetail(accountSequence); @@ -372,6 +377,19 @@ export default function UserDetailPage() { 团队认种量 {formatNumber(userDetail.teamAdoptions)}
+
+ 个人预种 + {formatNumber(userDetail.selfPrePlantingPortions)} 份 +
+
+ 团队预种 + + {formatNumber(userDetail.teamPrePlantingPortions)} 份 + +
团队地址数 {formatNumber(userDetail.teamAddresses)} @@ -775,6 +793,20 @@ export default function UserDetailPage() {

初始目标: {formatNumber(role.initialTargetTreeCount)} 棵

月度目标类型: {role.monthlyTargetType}

授权时间: {formatDate(role.authorizedAt)}

+ {role.officePhotoUrls && role.officePhotoUrls.length > 0 && ( +
+ 申请照片: + +
+ )}
))} @@ -1069,6 +1101,56 @@ export default function UserDetailPage() { )} + + {/* 授权照片查看对话框 */} + {showAuthPhotoModal && authSelectedPhotos.length > 0 && ( +
setShowAuthPhotoModal(false)} + > +
e.stopPropagation()} + > +

申请照片 ({authSelectedPhotos.length} 张)

+
+ {authSelectedPhotos.map((url, index) => ( +
+ {`申请照片 setAuthLightboxPhoto(url)} + /> +
+ ))} +
+
+ +
+
+
+ )} + + {/* 授权照片全屏查看 (Lightbox) */} + {authLightboxPhoto && ( +
setAuthLightboxPhoto(null)} + > + 照片预览 + 点击任意处关闭 +
+ )} ); } diff --git a/frontend/admin-web/src/app/(dashboard)/users/[id]/user-detail.module.scss b/frontend/admin-web/src/app/(dashboard)/users/[id]/user-detail.module.scss index 1dc8af24..8d114725 100644 --- a/frontend/admin-web/src/app/(dashboard)/users/[id]/user-detail.module.scss +++ b/frontend/admin-web/src/app/(dashboard)/users/[id]/user-detail.module.scss @@ -161,7 +161,7 @@ .userDetail__statsGrid { display: grid; - grid-template-columns: repeat(6, 1fr); + grid-template-columns: repeat(7, 1fr); gap: $spacing-base; margin-bottom: $spacing-xl; @@ -201,6 +201,21 @@ } } +.userDetail__statValueLink { + font-family: $font-family-number; + font-size: $font-size-xl; + font-weight: $font-weight-bold; + color: $primary-color; + text-decoration: none; + cursor: pointer; + @include transition-fast; + + &:hover { + text-decoration: underline; + opacity: 0.85; + } +} + // ============================================================================ // 推荐人信息 // ============================================================================ @@ -734,6 +749,157 @@ color: $text-disabled; } +// 角色卡片中的照片区域 +.authTab__rolePhotos { + display: flex; + align-items: center; + gap: $spacing-sm; + margin-top: $spacing-xs; + font-size: $font-size-sm; + color: $text-secondary; + + strong { + color: $text-primary; + margin-right: $spacing-xs; + } +} + +.authTab__photoBtn { + cursor: pointer; + border: 1px solid #3b82f6; + border-radius: 4px; + background-color: rgba(59, 130, 246, 0.08); + padding: 2px 8px; + font-size: $font-size-xs; + font-weight: $font-weight-medium; + color: #2563eb; + font-family: inherit; + white-space: nowrap; + @include transition-fast; + + &:hover { + background-color: rgba(59, 130, 246, 0.16); + } +} + +// ============================================================================ +// 授权照片模态框 & 灯箱 +// ============================================================================ + +.authPhotoModal__overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.authPhotoModal__content { + background-color: $card-background; + border-radius: $border-radius-lg; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 680px; + max-height: 90vh; + overflow-y: auto; + padding: $spacing-xl; +} + +.authPhotoModal__title { + margin: 0 0 $spacing-lg 0; + font-size: $font-size-lg; + font-weight: $font-weight-bold; + color: $text-primary; +} + +.authPhotoModal__grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: $spacing-base; + max-height: 60vh; + overflow-y: auto; + padding: $spacing-xs; +} + +.authPhotoModal__item { + border-radius: $border-radius-base; + overflow: hidden; + border: 1px solid $border-color; + cursor: pointer; + @include transition-fast; + + &:hover { + border-color: #3b82f6; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + } +} + +.authPhotoModal__img { + display: block; + width: 100%; + height: auto; + object-fit: cover; +} + +.authPhotoModal__footer { + display: flex; + align-items: center; + justify-content: flex-end; + margin-top: $spacing-lg; + padding-top: $spacing-base; + border-top: 1px solid $border-color; +} + +.authPhotoModal__closeBtn { + cursor: pointer; + border: 1px solid $border-color; + border-radius: $border-radius-base; + background-color: $card-background; + padding: $spacing-sm $spacing-lg; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-secondary; + font-family: inherit; + @include transition-fast; + + &:hover { + background-color: $background-color; + } +} + +.authLightbox { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.85); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 2000; + cursor: pointer; +} + +.authLightbox__img { + max-width: 90vw; + max-height: 85vh; + object-fit: contain; + border-radius: $border-radius-sm; +} + +.authLightbox__close { + margin-top: $spacing-base; + color: rgba(255, 255, 255, 0.7); + font-size: $font-size-sm; +} + // ============================================================================ // 通用分类账表格 // ============================================================================ diff --git a/frontend/admin-web/src/app/(dashboard)/users/page.tsx b/frontend/admin-web/src/app/(dashboard)/users/page.tsx index fbbd33b9..1f9f8944 100644 --- a/frontend/admin-web/src/app/(dashboard)/users/page.tsx +++ b/frontend/admin-web/src/app/(dashboard)/users/page.tsx @@ -14,7 +14,7 @@ import styles from './users.module.scss'; // 骨架屏组件 const TableRowSkeleton = () => (
- {Array.from({ length: 13 }).map((_, i) => ( + {Array.from({ length: 15 }).map((_, i) => (
@@ -361,6 +361,12 @@ export default function UsersPage() {
团队总认种量
+
+ 个人预种(份) +
+
+ 团队预种(份) +
团队本省认种量及占比
@@ -455,6 +461,16 @@ export default function UsersPage() { {formatNumber(user.teamAdoptions)}
+ {/* 个人预种(份) */} +
+ {formatNumber(user.selfPrePlantingPortions)} +
+ + {/* 团队预种(份) */} +
+ {formatNumber(user.teamPrePlantingPortions)} +
+ {/* 团队本省认种量及占比 */}
{formatNumber(user.provincialAdoptions.count)} diff --git a/frontend/admin-web/src/app/(dashboard)/users/users.module.scss b/frontend/admin-web/src/app/(dashboard)/users/users.module.scss index 6a069626..6980e2cf 100644 --- a/frontend/admin-web/src/app/(dashboard)/users/users.module.scss +++ b/frontend/admin-web/src/app/(dashboard)/users/users.module.scss @@ -273,6 +273,11 @@ flex-shrink: 0; } + &--prePlanting { + width: 80px; + flex-shrink: 0; + } + &--province { width: 100px; flex-shrink: 0; @@ -385,6 +390,11 @@ flex-shrink: 0; } + &--prePlanting { + width: 80px; + flex-shrink: 0; + } + &--province { width: 100px; flex-shrink: 0; diff --git a/frontend/admin-web/src/services/prePlantingService.ts b/frontend/admin-web/src/services/prePlantingService.ts index 167a48a8..77dcaf03 100644 --- a/frontend/admin-web/src/services/prePlantingService.ts +++ b/frontend/admin-web/src/services/prePlantingService.ts @@ -99,6 +99,7 @@ export interface PrePlantingListParams { status?: string; accountSequence?: string; keyword?: string; + teamOf?: string; // 按团队过滤:传入 accountSequence,仅返回该用户团队成员的数据 } /** 分页列表响应 */ diff --git a/frontend/admin-web/src/services/userService.ts b/frontend/admin-web/src/services/userService.ts index 08595e96..424e4ddf 100644 --- a/frontend/admin-web/src/services/userService.ts +++ b/frontend/admin-web/src/services/userService.ts @@ -26,6 +26,8 @@ export interface UserListItem { }; referrerId: string | null; ranking: number | null; + selfPrePlantingPortions: number; + teamPrePlantingPortions: number; status: 'active' | 'frozen' | 'deactivated'; isOnline: boolean; } diff --git a/frontend/admin-web/src/types/authorization.types.ts b/frontend/admin-web/src/types/authorization.types.ts index fc1eff4f..64d2f942 100644 --- a/frontend/admin-web/src/types/authorization.types.ts +++ b/frontend/admin-web/src/types/authorization.types.ts @@ -27,6 +27,7 @@ export interface Authorization { regionName: string; status: AuthorizationStatus; benefitActive: boolean; + officePhotoUrls: string[]; createdAt: string; authorizedAt: string | null; revokedAt: string | null; diff --git a/frontend/admin-web/src/types/user.types.ts b/frontend/admin-web/src/types/user.types.ts index 15566f6d..039e6672 100644 --- a/frontend/admin-web/src/types/user.types.ts +++ b/frontend/admin-web/src/types/user.types.ts @@ -31,6 +31,8 @@ export interface UserListItem { }; referrerId: string; ranking: number | null; + selfPrePlantingPortions: number; + teamPrePlantingPortions: number; status: 'active' | 'inactive'; isOnline: boolean; } diff --git a/frontend/admin-web/src/types/userDetail.types.ts b/frontend/admin-web/src/types/userDetail.types.ts index daa0991d..97427761 100644 --- a/frontend/admin-web/src/types/userDetail.types.ts +++ b/frontend/admin-web/src/types/userDetail.types.ts @@ -32,6 +32,10 @@ export interface UserFullDetail { percentage: number; }; + // 预种统计 + selfPrePlantingPortions: number; + teamPrePlantingPortions: number; + // 排名 ranking: number | null; @@ -192,6 +196,7 @@ export interface AuthorizationRole { monthlyTargetType: string; lastAssessmentMonth: string | null; monthlyTreesAdded: number; + officePhotoUrls: string[]; createdAt: string; }