feat(admin): 用户预种数量展示 & 授权申请照片查看

## 功能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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-02 03:09:17 -08:00
parent d5d61f4f68
commit ac15d6682a
33 changed files with 918 additions and 12 deletions

View File

@ -796,6 +796,9 @@ model AuthorizationRoleQueryView {
lastAssessmentMonth String? @map("last_assessment_month") lastAssessmentMonth String? @map("last_assessment_month")
monthlyTreesAdded Int @default(0) @map("monthly_trees_added") monthlyTreesAdded Int @default(0) @map("monthly_trees_added")
// 申请时提供的办公室照片MinIO URL
officePhotoUrls String[] @default([]) @map("office_photo_urls")
// 时间戳 // 时间戳
createdAt DateTime @map("created_at") createdAt DateTime @map("created_at")
syncedAt DateTime @default(now()) @map("synced_at") syncedAt DateTime @default(now()) @map("synced_at")

View File

@ -26,6 +26,7 @@ import {
IUserDetailQueryRepository, IUserDetailQueryRepository,
USER_DETAIL_QUERY_REPOSITORY, USER_DETAIL_QUERY_REPOSITORY,
} from '../../domain/repositories/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, private readonly userQueryRepository: IUserQueryRepository,
@Inject(USER_DETAIL_QUERY_REPOSITORY) @Inject(USER_DETAIL_QUERY_REPOSITORY)
private readonly userDetailRepository: IUserDetailQueryRepository, 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.getReferralInfo(accountSequence),
this.userDetailRepository.getPersonalAdoptionCount(accountSequence), this.userDetailRepository.getPersonalAdoptionCount(accountSequence),
this.userDetailRepository.getTeamStats(accountSequence), this.userDetailRepository.getTeamStats(accountSequence),
this.userDetailRepository.getDirectReferralCount(accountSequence), this.userDetailRepository.getDirectReferralCount(accountSequence),
this.referralProxyService.getPrePlantingStats(accountSequence),
]); ]);
// 获取推荐人昵称 // 获取推荐人昵称
@ -87,6 +90,8 @@ export class UserDetailController {
registeredAt: user.registeredAt.toISOString(), registeredAt: user.registeredAt.toISOString(),
lastActiveAt: user.lastActiveAt?.toISOString() || null, lastActiveAt: user.lastActiveAt?.toISOString() || null,
personalAdoptions: personalAdoptions, personalAdoptions: personalAdoptions,
selfPrePlantingPortions: prePlantingStats.selfPrePlantingPortions,
teamPrePlantingPortions: prePlantingStats.teamPrePlantingPortions,
teamAddresses: teamStats.teamAddressCount, teamAddresses: teamStats.teamAddressCount,
teamAdoptions: teamStats.teamAdoptionCount, teamAdoptions: teamStats.teamAdoptionCount,
provincialAdoptions: { provincialAdoptions: {
@ -371,6 +376,7 @@ export class UserDetailController {
monthlyTargetType: role.monthlyTargetType, monthlyTargetType: role.monthlyTargetType,
lastAssessmentMonth: role.lastAssessmentMonth, lastAssessmentMonth: role.lastAssessmentMonth,
monthlyTreesAdded: role.monthlyTreesAdded, monthlyTreesAdded: role.monthlyTreesAdded,
officePhotoUrls: role.officePhotoUrls,
createdAt: role.createdAt.toISOString(), createdAt: role.createdAt.toISOString(),
})), })),
assessments: assessments.map((assessment) => ({ assessments: assessments.map((assessment) => ({

View File

@ -21,6 +21,7 @@ import {
IUserDetailQueryRepository, IUserDetailQueryRepository,
USER_DETAIL_QUERY_REPOSITORY, USER_DETAIL_QUERY_REPOSITORY,
} from '../../domain/repositories/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, private readonly userQueryRepository: IUserQueryRepository,
@Inject(USER_DETAIL_QUERY_REPOSITORY) @Inject(USER_DETAIL_QUERY_REPOSITORY)
private readonly userDetailRepository: IUserDetailQueryRepository, private readonly userDetailRepository: IUserDetailQueryRepository,
private readonly referralProxyService: ReferralProxyService,
) {} ) {}
/** /**
@ -70,7 +72,10 @@ export class UserController {
// 批量获取实时统计数据 // 批量获取实时统计数据
const accountSequences = result.items.map(item => item.accountSequence); 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; let totalTeamAdoptions = 0;
@ -79,7 +84,7 @@ export class UserController {
} }
return { 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, total: result.total,
page: result.page, page: result.page,
pageSize: result.pageSize, pageSize: result.pageSize,
@ -157,6 +162,10 @@ export class UserController {
provinceAdoptionCount: number; provinceAdoptionCount: number;
cityAdoptionCount: number; cityAdoptionCount: number;
}, },
prePlantingStats?: {
selfPrePlantingPortions: number;
teamPrePlantingPortions: number;
},
): UserListItemDto { ): UserListItemDto {
// 使用实时统计数据(如果有),否则使用预计算数据 // 使用实时统计数据(如果有),否则使用预计算数据
const personalAdoptions = realTimeStats?.personalAdoptionCount ?? item.personalAdoptionCount; const personalAdoptions = realTimeStats?.personalAdoptionCount ?? item.personalAdoptionCount;
@ -181,6 +190,8 @@ export class UserController {
nickname: item.nickname, nickname: item.nickname,
phoneNumberMasked: item.phoneNumberMasked, phoneNumberMasked: item.phoneNumberMasked,
personalAdoptions, personalAdoptions,
selfPrePlantingPortions: prePlantingStats?.selfPrePlantingPortions ?? 0,
teamPrePlantingPortions: prePlantingStats?.teamPrePlantingPortions ?? 0,
teamAddresses, teamAddresses,
teamAdoptions, teamAdoptions,
provincialAdoptions: { provincialAdoptions: {

View File

@ -49,6 +49,10 @@ export class UserFullDetailDto {
percentage: number; percentage: number;
}; };
// 预种统计
selfPrePlantingPortions!: number;
teamPrePlantingPortions!: number;
// 排名 // 排名
ranking!: number | null; ranking!: number | null;
@ -215,6 +219,7 @@ export class AuthorizationRoleDto {
monthlyTargetType!: string; monthlyTargetType!: string;
lastAssessmentMonth!: string | null; lastAssessmentMonth!: string | null;
monthlyTreesAdded!: number; monthlyTreesAdded!: number;
officePhotoUrls!: string[];
createdAt!: string; createdAt!: string;
} }

View File

@ -8,6 +8,8 @@ export class UserListItemDto {
nickname!: string | null; nickname!: string | null;
phoneNumberMasked!: string | null; phoneNumberMasked!: string | null;
personalAdoptions!: number; personalAdoptions!: number;
selfPrePlantingPortions!: number;
teamPrePlantingPortions!: number;
teamAddresses!: number; teamAddresses!: number;
teamAdoptions!: number; teamAdoptions!: number;
provincialAdoptions!: { provincialAdoptions!: {

View File

@ -89,6 +89,8 @@ import { PrePlantingConfigController, PublicPrePlantingConfigController } from '
import { PrePlantingConfigService } from './pre-planting/pre-planting-config.service'; import { PrePlantingConfigService } from './pre-planting/pre-planting-config.service';
// [2026-02-27] 新增预种计划数据代理admin-service → planting-service 内部 HTTP // [2026-02-27] 新增预种计划数据代理admin-service → planting-service 内部 HTTP
import { PrePlantingProxyService } from './pre-planting/pre-planting-proxy.service'; 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] 新增:认种树定价配置(总部运营成本压力涨价) // [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价)
import { AdminTreePricingController, PublicTreePricingController } from './pricing/tree-pricing.controller'; import { AdminTreePricingController, PublicTreePricingController } from './pricing/tree-pricing.controller';
import { TreePricingService } from './pricing/tree-pricing.service'; import { TreePricingService } from './pricing/tree-pricing.service';
@ -235,6 +237,8 @@ import { AutoPriceIncreaseJob } from './infrastructure/jobs/auto-price-increase.
PrePlantingConfigService, PrePlantingConfigService,
// [2026-02-27] 新增:预种计划数据代理 // [2026-02-27] 新增:预种计划数据代理
PrePlantingProxyService, PrePlantingProxyService,
// [2026-03-02] 新增:推荐链预种统计代理
ReferralProxyService,
// [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价) // [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价)
TreePricingService, TreePricingService,
AutoPriceIncreaseJob, AutoPriceIncreaseJob,

View File

@ -138,6 +138,7 @@ export interface AuthorizationRole {
monthlyTargetType: string; monthlyTargetType: string;
lastAssessmentMonth: string | null; lastAssessmentMonth: string | null;
monthlyTreesAdded: number; monthlyTreesAdded: number;
officePhotoUrls: string[];
createdAt: Date; createdAt: Date;
} }

View File

@ -448,6 +448,7 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository
monthlyTargetType: role.monthlyTargetType, monthlyTargetType: role.monthlyTargetType,
lastAssessmentMonth: role.lastAssessmentMonth, lastAssessmentMonth: role.lastAssessmentMonth,
monthlyTreesAdded: role.monthlyTreesAdded, monthlyTreesAdded: role.monthlyTreesAdded,
officePhotoUrls: role.officePhotoUrls ?? [],
createdAt: role.createdAt, createdAt: role.createdAt,
})); }));
} }

View File

@ -7,11 +7,13 @@ import {
Query, Query,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString } from 'class-validator'; import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { PrePlantingConfigService } from './pre-planting-config.service'; import { PrePlantingConfigService } from './pre-planting-config.service';
import { PrePlantingProxyService } from './pre-planting-proxy.service'; import { PrePlantingProxyService } from './pre-planting-proxy.service';
import { PrismaService } from '../infrastructure/persistence/prisma/prisma.service';
class UpdatePrePlantingConfigDto { class UpdatePrePlantingConfigDto {
@IsBoolean() @IsBoolean()
@ -35,11 +37,44 @@ class UpdatePrePlantingAgreementDto {
@ApiTags('预种计划配置') @ApiTags('预种计划配置')
@Controller('admin/pre-planting') @Controller('admin/pre-planting')
export class PrePlantingConfigController { export class PrePlantingConfigController {
private readonly logger = new Logger(PrePlantingConfigController.name);
constructor( constructor(
private readonly configService: PrePlantingConfigService, private readonly configService: PrePlantingConfigService,
private readonly proxyService: PrePlantingProxyService, private readonly proxyService: PrePlantingProxyService,
private readonly prisma: PrismaService,
) {} ) {}
/**
* accountSequence accountSequence
* userId ancestorPath userId
*
*/
private async resolveTeamAccountSequences(teamOfAccountSeq: string): Promise<string[]> {
// 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') @Get('config')
@ApiOperation({ summary: '获取预种计划开关状态(含协议文本)' }) @ApiOperation({ summary: '获取预种计划开关状态(含协议文本)' })
@ApiResponse({ status: HttpStatus.OK, description: '开关状态' }) @ApiResponse({ status: HttpStatus.OK, description: '开关状态' })
@ -75,17 +110,28 @@ export class PrePlantingConfigController {
@ApiQuery({ name: 'pageSize', required: false }) @ApiQuery({ name: 'pageSize', required: false })
@ApiQuery({ name: 'keyword', required: false }) @ApiQuery({ name: 'keyword', required: false })
@ApiQuery({ name: 'status', required: false }) @ApiQuery({ name: 'status', required: false })
@ApiQuery({ name: 'teamOf', required: false, description: '团队筛选:指定用户 accountSequence只显示其团队成员的订单' })
async getOrders( async getOrders(
@Query('page') page?: string, @Query('page') page?: string,
@Query('pageSize') pageSize?: string, @Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string, @Query('keyword') keyword?: string,
@Query('status') status?: 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({ return this.proxyService.getOrders({
page: page ? parseInt(page, 10) : undefined, page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined, pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
keyword: keyword || undefined, keyword: keyword || undefined,
status: status || undefined, status: status || undefined,
accountSequences,
}); });
} }
@ -94,15 +140,26 @@ export class PrePlantingConfigController {
@ApiQuery({ name: 'page', required: false }) @ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false }) @ApiQuery({ name: 'pageSize', required: false })
@ApiQuery({ name: 'keyword', required: false }) @ApiQuery({ name: 'keyword', required: false })
@ApiQuery({ name: 'teamOf', required: false, description: '团队筛选:指定用户 accountSequence只显示其团队成员的持仓' })
async getPositions( async getPositions(
@Query('page') page?: string, @Query('page') page?: string,
@Query('pageSize') pageSize?: string, @Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: 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({ return this.proxyService.getPositions({
page: page ? parseInt(page, 10) : undefined, page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined, pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
keyword: keyword || undefined, keyword: keyword || undefined,
accountSequences,
}); });
} }

View File

@ -37,6 +37,7 @@ export class PrePlantingProxyService {
pageSize?: number; pageSize?: number;
keyword?: string; keyword?: string;
status?: string; status?: string;
accountSequences?: string[];
}) { }) {
try { try {
const qp = new URLSearchParams(); const qp = new URLSearchParams();
@ -44,6 +45,7 @@ export class PrePlantingProxyService {
if (params.pageSize) qp.append('pageSize', params.pageSize.toString()); if (params.pageSize) qp.append('pageSize', params.pageSize.toString());
if (params.keyword) qp.append('keyword', params.keyword); if (params.keyword) qp.append('keyword', params.keyword);
if (params.status) qp.append('status', params.status); 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()}`; const url = `/api/v1/internal/pre-planting/admin/orders?${qp.toString()}`;
this.logger.debug(`[getOrders] 请求: ${url}`); this.logger.debug(`[getOrders] 请求: ${url}`);
@ -59,12 +61,14 @@ export class PrePlantingProxyService {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
keyword?: string; keyword?: string;
accountSequences?: string[];
}) { }) {
try { try {
const qp = new URLSearchParams(); const qp = new URLSearchParams();
if (params.page) qp.append('page', params.page.toString()); if (params.page) qp.append('page', params.page.toString());
if (params.pageSize) qp.append('pageSize', params.pageSize.toString()); if (params.pageSize) qp.append('pageSize', params.pageSize.toString());
if (params.keyword) qp.append('keyword', params.keyword); 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()}`; const url = `/api/v1/internal/pre-planting/admin/positions?${qp.toString()}`;
this.logger.debug(`[getPositions] 请求: ${url}`); this.logger.debug(`[getPositions] 请求: ${url}`);

View File

@ -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<string>(
'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<PrePlantingStats> {
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<Record<string, PrePlantingStats>> {
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<string, PrePlantingStats> = {};
for (const seq of accountSequences) {
defaults[seq] = { selfPrePlantingPortions: 0, teamPrePlantingPortions: 0 };
}
return defaults;
}
}
}

View File

@ -53,6 +53,9 @@ model AuthorizationRole {
// 当前考核月份索引 // 当前考核月份索引
currentMonthIndex Int @default(0) @map("current_month_index") currentMonthIndex Int @default(0) @map("current_month_index")
// 申请时提供的办公室照片MinIO URL
officePhotoUrls String[] @default([]) @map("office_photo_urls")
// 时间戳 // 时间戳
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")

View File

@ -53,6 +53,7 @@ export class AdminAuthorizationController {
authorizedAt: Date | null authorizedAt: Date | null
revokedAt: Date | null revokedAt: Date | null
revokeReason: string | null revokeReason: string | null
officePhotoUrls: string[]
}> }>
total: number total: number
page: number page: number

View File

@ -3404,6 +3404,7 @@ export class AuthorizationApplicationService {
const authorization = AuthorizationRole.createSelfAppliedCommunity({ const authorization = AuthorizationRole.createSelfAppliedCommunity({
userId, userId,
communityName: command.communityName!, communityName: command.communityName!,
officePhotoUrls: command.officePhotoUrls,
}) })
// 检查初始考核 // 检查初始考核
@ -3465,6 +3466,7 @@ export class AuthorizationApplicationService {
userId, userId,
cityCode: command.cityCode!, cityCode: command.cityCode!,
cityName: command.cityName!, cityName: command.cityName!,
officePhotoUrls: command.officePhotoUrls,
}) })
// 检查初始考核500棵 // 检查初始考核500棵
@ -3526,6 +3528,7 @@ export class AuthorizationApplicationService {
userId, userId,
provinceCode: command.provinceCode!, provinceCode: command.provinceCode!,
provinceName: command.provinceName!, provinceName: command.provinceName!,
officePhotoUrls: command.officePhotoUrls,
}) })
// 检查初始考核500棵 // 检查初始考核500棵
@ -3941,6 +3944,7 @@ export class AuthorizationApplicationService {
authorizedAt: auth.authorizedAt, authorizedAt: auth.authorizedAt,
revokedAt: auth.revokedAt, revokedAt: auth.revokedAt,
revokeReason: auth.revokeReason, revokeReason: auth.revokeReason,
officePhotoUrls: auth.officePhotoUrls,
} }
}) })

View File

@ -49,6 +49,7 @@ export interface AuthorizationRoleProps {
lastMonthTreesAdded: number // 上月新增树数(考核用存档) lastMonthTreesAdded: number // 上月新增树数(考核用存档)
currentMonthIndex: number currentMonthIndex: number
deletedAt: Date | null // 软删除时间 (大厂通用做法) deletedAt: Date | null // 软删除时间 (大厂通用做法)
officePhotoUrls: string[] // 办公室照片 URL 列表
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
} }
@ -93,6 +94,9 @@ export class AuthorizationRole extends AggregateRoot {
// 软删除 (大厂通用做法) // 软删除 (大厂通用做法)
private _deletedAt: Date | null private _deletedAt: Date | null
// 办公室照片
private _officePhotoUrls: string[]
private _createdAt: Date private _createdAt: Date
private _updatedAt: Date private _updatedAt: Date
@ -175,6 +179,9 @@ export class AuthorizationRole extends AggregateRoot {
get deletedAt(): Date | null { get deletedAt(): Date | null {
return this._deletedAt return this._deletedAt
} }
get officePhotoUrls(): string[] {
return this._officePhotoUrls
}
get isActive(): boolean { get isActive(): boolean {
return this._status === AuthorizationStatus.AUTHORIZED return this._status === AuthorizationStatus.AUTHORIZED
} }
@ -206,6 +213,7 @@ export class AuthorizationRole extends AggregateRoot {
this._lastMonthTreesAdded = props.lastMonthTreesAdded this._lastMonthTreesAdded = props.lastMonthTreesAdded
this._currentMonthIndex = props.currentMonthIndex this._currentMonthIndex = props.currentMonthIndex
this._deletedAt = props.deletedAt this._deletedAt = props.deletedAt
this._officePhotoUrls = props.officePhotoUrls
this._createdAt = props.createdAt this._createdAt = props.createdAt
this._updatedAt = props.updatedAt this._updatedAt = props.updatedAt
} }
@ -268,6 +276,7 @@ export class AuthorizationRole extends AggregateRoot {
lastMonthTreesAdded: 0, lastMonthTreesAdded: 0,
currentMonthIndex: 0, currentMonthIndex: 0,
deletedAt: null, deletedAt: null,
officePhotoUrls: [],
createdAt: new Date(), createdAt: new Date(),
updatedAt: 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 now = new Date()
const auth = new AuthorizationRole({ const auth = new AuthorizationRole({
authorizationId: AuthorizationId.generate(), authorizationId: AuthorizationId.generate(),
@ -311,6 +320,7 @@ export class AuthorizationRole extends AggregateRoot {
lastMonthTreesAdded: 0, lastMonthTreesAdded: 0,
currentMonthIndex: 0, currentMonthIndex: 0,
deletedAt: null, deletedAt: null,
officePhotoUrls: params.officePhotoUrls ?? [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
@ -361,6 +371,7 @@ export class AuthorizationRole extends AggregateRoot {
lastMonthTreesAdded: 0, lastMonthTreesAdded: 0,
currentMonthIndex: skipAssessment ? 1 : 0, currentMonthIndex: skipAssessment ? 1 : 0,
deletedAt: null, deletedAt: null,
officePhotoUrls: [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
@ -409,6 +420,7 @@ export class AuthorizationRole extends AggregateRoot {
lastMonthTreesAdded: 0, lastMonthTreesAdded: 0,
currentMonthIndex: 0, currentMonthIndex: 0,
deletedAt: null, deletedAt: null,
officePhotoUrls: [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
@ -430,6 +442,7 @@ export class AuthorizationRole extends AggregateRoot {
userId: UserId userId: UserId
provinceCode: string provinceCode: string
provinceName: string provinceName: string
officePhotoUrls?: string[]
}): AuthorizationRole { }): AuthorizationRole {
const now = new Date() const now = new Date()
const auth = new AuthorizationRole({ const auth = new AuthorizationRole({
@ -457,6 +470,7 @@ export class AuthorizationRole extends AggregateRoot {
lastMonthTreesAdded: 0, lastMonthTreesAdded: 0,
currentMonthIndex: 0, currentMonthIndex: 0,
deletedAt: null, deletedAt: null,
officePhotoUrls: params.officePhotoUrls ?? [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
@ -509,6 +523,7 @@ export class AuthorizationRole extends AggregateRoot {
lastMonthTreesAdded: 0, lastMonthTreesAdded: 0,
currentMonthIndex: 1, // 从第1个月开始考核 currentMonthIndex: 1, // 从第1个月开始考核
deletedAt: null, deletedAt: null,
officePhotoUrls: [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
@ -558,6 +573,7 @@ export class AuthorizationRole extends AggregateRoot {
lastMonthTreesAdded: 0, lastMonthTreesAdded: 0,
currentMonthIndex: 0, currentMonthIndex: 0,
deletedAt: null, deletedAt: null,
officePhotoUrls: [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
@ -579,6 +595,7 @@ export class AuthorizationRole extends AggregateRoot {
userId: UserId userId: UserId
cityCode: string cityCode: string
cityName: string cityName: string
officePhotoUrls?: string[]
}): AuthorizationRole { }): AuthorizationRole {
const now = new Date() const now = new Date()
const auth = new AuthorizationRole({ const auth = new AuthorizationRole({
@ -606,6 +623,7 @@ export class AuthorizationRole extends AggregateRoot {
lastMonthTreesAdded: 0, lastMonthTreesAdded: 0,
currentMonthIndex: 0, currentMonthIndex: 0,
deletedAt: null, deletedAt: null,
officePhotoUrls: params.officePhotoUrls ?? [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
@ -658,6 +676,7 @@ export class AuthorizationRole extends AggregateRoot {
lastMonthTreesAdded: 0, lastMonthTreesAdded: 0,
currentMonthIndex: 1, // 从第1个月开始考核 currentMonthIndex: 1, // 从第1个月开始考核
deletedAt: null, deletedAt: null,
officePhotoUrls: [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
@ -708,6 +727,7 @@ export class AuthorizationRole extends AggregateRoot {
lastMonthTreesAdded: 0, lastMonthTreesAdded: 0,
currentMonthIndex: 1, // 从第1个月开始考核与手动授权一致 currentMonthIndex: 1, // 从第1个月开始考核与手动授权一致
deletedAt: null, deletedAt: null,
officePhotoUrls: [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
@ -758,6 +778,7 @@ export class AuthorizationRole extends AggregateRoot {
lastMonthTreesAdded: 0, lastMonthTreesAdded: 0,
currentMonthIndex: 1, // 从第1个月开始考核与手动授权一致 currentMonthIndex: 1, // 从第1个月开始考核与手动授权一致
deletedAt: null, deletedAt: null,
officePhotoUrls: [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
@ -810,6 +831,7 @@ export class AuthorizationRole extends AggregateRoot {
lastMonthTreesAdded: 0, lastMonthTreesAdded: 0,
currentMonthIndex: skipAssessment ? 1 : 0, currentMonthIndex: skipAssessment ? 1 : 0,
deletedAt: null, deletedAt: null,
officePhotoUrls: [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
@ -862,6 +884,7 @@ export class AuthorizationRole extends AggregateRoot {
lastMonthTreesAdded: 0, lastMonthTreesAdded: 0,
currentMonthIndex: skipAssessment ? 1 : 0, currentMonthIndex: skipAssessment ? 1 : 0,
deletedAt: null, deletedAt: null,
officePhotoUrls: [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
@ -1170,6 +1193,7 @@ export class AuthorizationRole extends AggregateRoot {
monthlyTreesAdded: this._monthlyTreesAdded, monthlyTreesAdded: this._monthlyTreesAdded,
lastMonthTreesAdded: this._lastMonthTreesAdded, lastMonthTreesAdded: this._lastMonthTreesAdded,
currentMonthIndex: this._currentMonthIndex, currentMonthIndex: this._currentMonthIndex,
officePhotoUrls: this._officePhotoUrls,
deletedAt: this._deletedAt, deletedAt: this._deletedAt,
createdAt: this._createdAt, createdAt: this._createdAt,
updatedAt: this._updatedAt, updatedAt: this._updatedAt,

View File

@ -52,6 +52,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
monthlyTreesAdded: data.monthlyTreesAdded, monthlyTreesAdded: data.monthlyTreesAdded,
lastMonthTreesAdded: data.lastMonthTreesAdded, lastMonthTreesAdded: data.lastMonthTreesAdded,
currentMonthIndex: data.currentMonthIndex, currentMonthIndex: data.currentMonthIndex,
officePhotoUrls: data.officePhotoUrls,
deletedAt: data.deletedAt, deletedAt: data.deletedAt,
}, },
update: { update: {
@ -72,6 +73,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
monthlyTreesAdded: data.monthlyTreesAdded, monthlyTreesAdded: data.monthlyTreesAdded,
lastMonthTreesAdded: data.lastMonthTreesAdded, lastMonthTreesAdded: data.lastMonthTreesAdded,
currentMonthIndex: data.currentMonthIndex, currentMonthIndex: data.currentMonthIndex,
officePhotoUrls: data.officePhotoUrls,
deletedAt: data.deletedAt, deletedAt: data.deletedAt,
}, },
}) })
@ -518,6 +520,7 @@ export class AuthorizationRoleRepositoryImpl implements IAuthorizationRoleReposi
monthlyTreesAdded: record.monthlyTreesAdded ?? 0, monthlyTreesAdded: record.monthlyTreesAdded ?? 0,
lastMonthTreesAdded: record.lastMonthTreesAdded ?? 0, lastMonthTreesAdded: record.lastMonthTreesAdded ?? 0,
currentMonthIndex: record.currentMonthIndex, currentMonthIndex: record.currentMonthIndex,
officePhotoUrls: record.officePhotoUrls ?? [],
deletedAt: record.deletedAt, deletedAt: record.deletedAt,
createdAt: record.createdAt, createdAt: record.createdAt,
updatedAt: record.updatedAt, updatedAt: record.updatedAt,

View File

@ -47,23 +47,38 @@ export class InternalPrePlantingController {
@ApiQuery({ name: 'pageSize', required: false }) @ApiQuery({ name: 'pageSize', required: false })
@ApiQuery({ name: 'keyword', required: false }) @ApiQuery({ name: 'keyword', required: false })
@ApiQuery({ name: 'status', required: false }) @ApiQuery({ name: 'status', required: false })
@ApiQuery({ name: 'accountSequences', required: false, description: '逗号分隔的 accountSequence 列表,用于团队筛选' })
async getAdminOrders( async getAdminOrders(
@Query('page') page?: string, @Query('page') page?: string,
@Query('pageSize') pageSize?: string, @Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string, @Query('keyword') keyword?: string,
@Query('status') status?: string, @Query('status') status?: string,
@Query('accountSequences') accountSequences?: string,
) { ) {
const p = Math.max(1, parseInt(page || '1', 10)); const p = Math.max(1, parseInt(page || '1', 10));
const ps = Math.min(100, Math.max(1, parseInt(pageSize || '20', 10))); const ps = Math.min(100, Math.max(1, parseInt(pageSize || '20', 10)));
const where: any = {}; const where: any = {};
if (status) where.status = status; 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) { if (keyword) {
// 当同时存在 accountSequences 时keyword 只搜 orderNoaccountSequence 已被限定)
if (accountSequences) {
where.OR = [
{ orderNo: { contains: keyword, mode: 'insensitive' } },
];
} else {
where.OR = [ where.OR = [
{ orderNo: { contains: keyword, mode: 'insensitive' } }, { orderNo: { contains: keyword, mode: 'insensitive' } },
{ accountSequence: { contains: keyword, mode: 'insensitive' } }, { accountSequence: { contains: keyword, mode: 'insensitive' } },
]; ];
} }
}
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.prePlantingOrder.findMany({ this.prisma.prePlantingOrder.findMany({
@ -103,18 +118,35 @@ export class InternalPrePlantingController {
@ApiQuery({ name: 'page', required: false }) @ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false }) @ApiQuery({ name: 'pageSize', required: false })
@ApiQuery({ name: 'keyword', required: false }) @ApiQuery({ name: 'keyword', required: false })
@ApiQuery({ name: 'accountSequences', required: false, description: '逗号分隔的 accountSequence 列表,用于团队筛选' })
async getAdminPositions( async getAdminPositions(
@Query('page') page?: string, @Query('page') page?: string,
@Query('pageSize') pageSize?: string, @Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string, @Query('keyword') keyword?: string,
@Query('accountSequences') accountSequences?: string,
) { ) {
const p = Math.max(1, parseInt(page || '1', 10)); const p = Math.max(1, parseInt(page || '1', 10));
const ps = Math.min(100, Math.max(1, parseInt(pageSize || '20', 10))); const ps = Math.min(100, Math.max(1, parseInt(pageSize || '20', 10)));
const where: any = {}; 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) { if (keyword) {
// 当同时存在 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' }; where.accountSequence = { contains: keyword, mode: 'insensitive' };
} }
}
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.prePlantingPosition.findMany({ this.prisma.prePlantingPosition.findMany({

View File

@ -2,4 +2,5 @@ export * from './referral.controller';
export * from './team-statistics.controller'; export * from './team-statistics.controller';
export * from './internal-team-statistics.controller'; export * from './internal-team-statistics.controller';
export * from './internal-referral-chain.controller'; export * from './internal-referral-chain.controller';
export * from './internal-pre-planting-stats.controller';
export * from './health.controller'; export * from './health.controller';

View File

@ -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<string, bigint>();
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<bigint, { selfPrePlantingPortions: number; teamPrePlantingPortions: number }>();
for (const s of allStats) {
userIdToStats.set(s.userId, {
selfPrePlantingPortions: s.selfPrePlantingPortions,
teamPrePlantingPortions: s.teamPrePlantingPortions,
});
}
// 组装结果
const result: Record<string, { selfPrePlantingPortions: number; teamPrePlantingPortions: number }> = {};
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;
}
}

View File

@ -7,6 +7,7 @@ import {
TeamStatisticsController, TeamStatisticsController,
InternalTeamStatisticsController, InternalTeamStatisticsController,
InternalReferralChainController, InternalReferralChainController,
InternalPrePlantingStatsController,
HealthController, HealthController,
} from '../api'; } from '../api';
import { InternalReferralController } from '../api/controllers/referral.controller'; import { InternalReferralController } from '../api/controllers/referral.controller';
@ -18,6 +19,7 @@ import { InternalReferralController } from '../api/controllers/referral.controll
TeamStatisticsController, TeamStatisticsController,
InternalTeamStatisticsController, InternalTeamStatisticsController,
InternalReferralChainController, InternalReferralChainController,
InternalPrePlantingStatsController,
HealthController, HealthController,
InternalReferralController, InternalReferralController,
], ],

View File

@ -1001,6 +1001,96 @@
cursor: help; 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 { .authorization__pagination {
align-self: stretch; align-self: stretch;

View File

@ -29,6 +29,11 @@ export default function AuthorizationPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const limit = 20; const limit = 20;
// 照片查看状态
const [showPhotoModal, setShowPhotoModal] = useState(false);
const [selectedPhotos, setSelectedPhotos] = useState<string[]>([]);
const [lightboxPhoto, setLightboxPhoto] = useState<string | null>(null);
// 创建授权对话框状态 // 创建授权对话框状态
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [createForm, setCreateForm] = useState({ const [createForm, setCreateForm] = useState({
@ -186,6 +191,12 @@ export default function AuthorizationPage() {
} }
}; };
// 打开照片查看对话框
const openPhotoModal = (photos: string[]) => {
setSelectedPhotos(photos);
setShowPhotoModal(true);
};
// 打开取消授权对话框 // 打开取消授权对话框
const openRevokeModal = (item: Authorization) => { const openRevokeModal = (item: Authorization) => {
setRevokeTarget(item); setRevokeTarget(item);
@ -392,6 +403,15 @@ export default function AuthorizationPage() {
> >
</div> </div>
<div
className={cn(
styles.authorization__tableCell,
styles['authorization__tableCell--header'],
styles['authorization__tableCell--photos']
)}
>
</div>
<div <div
className={cn( className={cn(
styles.authorization__tableCell, styles.authorization__tableCell,
@ -474,6 +494,23 @@ export default function AuthorizationPage() {
{item.status === 'AUTHORIZED' ? '有效' : '已撤销'} {item.status === 'AUTHORIZED' ? '有效' : '已撤销'}
</span> </span>
</div> </div>
<div
className={cn(
styles.authorization__tableCell,
styles['authorization__tableCell--photos']
)}
>
{item.officePhotoUrls && item.officePhotoUrls.length > 0 ? (
<button
className={styles.authorization__photoBtn}
onClick={() => openPhotoModal(item.officePhotoUrls)}
>
{item.officePhotoUrls.length}
</button>
) : (
<span style={{ color: '#9ca3af', fontSize: '12px' }}>-</span>
)}
</div>
<div <div
className={cn( className={cn(
styles.authorization__tableCell, styles.authorization__tableCell,
@ -654,6 +691,53 @@ export default function AuthorizationPage() {
</div> </div>
</div> </div>
)} )}
{/* 照片查看对话框 */}
{showPhotoModal && selectedPhotos.length > 0 && (
<div className={styles.modal__overlay} onClick={() => setShowPhotoModal(false)}>
<div
className={cn(styles.modal__content, styles['modal__content--wide'])}
onClick={(e) => e.stopPropagation()}
>
<h3 className={styles.modal__title}> ({selectedPhotos.length} )</h3>
<div className={styles.photoGrid}>
{selectedPhotos.map((url, index) => (
<div key={index} className={styles.photoGrid__item}>
<img
src={url}
alt={`申请照片 ${index + 1}`}
className={styles.photoGrid__img}
onClick={() => setLightboxPhoto(url)}
/>
</div>
))}
</div>
<div className={styles.modal__footer}>
<button
className={styles.modal__cancelBtn}
onClick={() => setShowPhotoModal(false)}
>
</button>
</div>
</div>
</div>
)}
{/* 照片全屏查看 (Lightbox) */}
{lightboxPhoto && (
<div
className={styles.lightbox}
onClick={() => setLightboxPhoto(null)}
>
<img
src={lightboxPhoto}
alt="照片预览"
className={styles.lightbox__img}
/>
<span className={styles.lightbox__close}></span>
</div>
)}
</PageContainer> </PageContainer>
); );
} }

View File

@ -21,6 +21,8 @@
*/ */
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/common'; import { Button } from '@/components/common';
import { PageContainer } from '@/components/layout'; import { PageContainer } from '@/components/layout';
import { cn } from '@/utils/helpers'; import { cn } from '@/utils/helpers';
@ -62,6 +64,10 @@ const CONTRACT_STATUS_MAP: Record<string, { label: string; style: string }> = {
* *
*/ */
export default function PrePlantingPage() { export default function PrePlantingPage() {
// === URL 参数:团队过滤 ===
const searchParams = useSearchParams();
const teamOf = searchParams.get('teamOf') || undefined;
// === Tab 与分页状态 === // === Tab 与分页状态 ===
const [activeTab, setActiveTab] = useState<TabKey>('orders'); const [activeTab, setActiveTab] = useState<TabKey>('orders');
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
@ -77,16 +83,19 @@ export default function PrePlantingPage() {
page, page,
pageSize, pageSize,
keyword: activeTab === 'orders' ? keyword : undefined, keyword: activeTab === 'orders' ? keyword : undefined,
teamOf,
}); });
const positionsQuery = usePrePlantingPositions({ const positionsQuery = usePrePlantingPositions({
page, page,
pageSize, pageSize,
keyword: activeTab === 'positions' ? keyword : undefined, keyword: activeTab === 'positions' ? keyword : undefined,
teamOf,
}); });
const mergesQuery = usePrePlantingMerges({ const mergesQuery = usePrePlantingMerges({
page, page,
pageSize, pageSize,
keyword: activeTab === 'merges' ? keyword : undefined, keyword: activeTab === 'merges' ? keyword : undefined,
teamOf,
}); });
// === 协议文本管理 === // === 协议文本管理 ===
@ -141,9 +150,23 @@ export default function PrePlantingPage() {
<div className={styles.prePlanting}> <div className={styles.prePlanting}>
{/* 页面标题 */} {/* 页面标题 */}
<div className={styles.prePlanting__header}> <div className={styles.prePlanting__header}>
<h1 className={styles.prePlanting__title}></h1> <h1 className={styles.prePlanting__title}>
{teamOf ? `${teamOf} 的团队预种数据` : '预种计划管理'}
</h1>
</div> </div>
{/* 团队过滤提示 */}
{teamOf && (
<div className={styles.prePlanting__teamBanner}>
<span>
<strong>{teamOf}</strong>
</span>
<Link href={`/users/${teamOf}`} className={styles.prePlanting__teamBannerLink}>
</Link>
</div>
)}
{/* 预种开关卡片 */} {/* 预种开关卡片 */}
<div <div
className={cn( className={cn(

View File

@ -25,6 +25,35 @@
color: $text-primary; color: $text-primary;
} }
// 团队过滤提示横幅
&__teamBanner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: linear-gradient(135deg, #e8f4fd 0%, #d6eaf8 100%);
border: 1px solid #90caf9;
border-radius: 8px;
font-size: 14px;
color: #1565c0;
}
&__teamBannerLink {
font-size: 14px;
font-weight: 500;
color: #1565c0;
text-decoration: none;
padding: 4px 12px;
border-radius: 6px;
background: rgba(21, 101, 192, 0.08);
transition: background 0.2s;
&:hover {
background: rgba(21, 101, 192, 0.16);
text-decoration: underline;
}
}
// 开关状态卡片 // 开关状态卡片
&__switchCard { &__switchCard {
background: linear-gradient(135deg, #fff9e6 0%, #fff3cc 100%); background: linear-gradient(135deg, #fff9e6 0%, #fff3cc 100%);

View File

@ -184,6 +184,11 @@ export default function UserDetailPage() {
const [contractsData, setContractsData] = useState<ContractsListResponse | null>(null); const [contractsData, setContractsData] = useState<ContractsListResponse | null>(null);
const [contractsLoading, setContractsLoading] = useState(false); const [contractsLoading, setContractsLoading] = useState(false);
// 授权照片查看状态
const [showAuthPhotoModal, setShowAuthPhotoModal] = useState(false);
const [authSelectedPhotos, setAuthSelectedPhotos] = useState<string[]>([]);
const [authLightboxPhoto, setAuthLightboxPhoto] = useState<string | null>(null);
// 获取用户完整信息 // 获取用户完整信息
const { data: userDetail, isLoading: detailLoading, error: detailError } = useUserFullDetail(accountSequence); const { data: userDetail, isLoading: detailLoading, error: detailError } = useUserFullDetail(accountSequence);
@ -372,6 +377,19 @@ export default function UserDetailPage() {
<span className={styles.userDetail__statLabel}></span> <span className={styles.userDetail__statLabel}></span>
<span className={styles.userDetail__statValue}>{formatNumber(userDetail.teamAdoptions)}</span> <span className={styles.userDetail__statValue}>{formatNumber(userDetail.teamAdoptions)}</span>
</div> </div>
<div className={styles.userDetail__statCard}>
<span className={styles.userDetail__statLabel}></span>
<span className={styles.userDetail__statValue}>{formatNumber(userDetail.selfPrePlantingPortions)} </span>
</div>
<div className={styles.userDetail__statCard}>
<span className={styles.userDetail__statLabel}></span>
<Link
href={`/pre-planting?teamOf=${userDetail.accountSequence}`}
className={styles.userDetail__statValueLink}
>
{formatNumber(userDetail.teamPrePlantingPortions)}
</Link>
</div>
<div className={styles.userDetail__statCard}> <div className={styles.userDetail__statCard}>
<span className={styles.userDetail__statLabel}></span> <span className={styles.userDetail__statLabel}></span>
<span className={styles.userDetail__statValue}>{formatNumber(userDetail.teamAddresses)}</span> <span className={styles.userDetail__statValue}>{formatNumber(userDetail.teamAddresses)}</span>
@ -775,6 +793,20 @@ export default function UserDetailPage() {
<p><strong>:</strong> {formatNumber(role.initialTargetTreeCount)} </p> <p><strong>:</strong> {formatNumber(role.initialTargetTreeCount)} </p>
<p><strong>:</strong> {role.monthlyTargetType}</p> <p><strong>:</strong> {role.monthlyTargetType}</p>
<p><strong>:</strong> {formatDate(role.authorizedAt)}</p> <p><strong>:</strong> {formatDate(role.authorizedAt)}</p>
{role.officePhotoUrls && role.officePhotoUrls.length > 0 && (
<div className={styles.authTab__rolePhotos}>
<strong>:</strong>
<button
className={styles.authTab__photoBtn}
onClick={() => {
setAuthSelectedPhotos(role.officePhotoUrls);
setShowAuthPhotoModal(true);
}}
>
{role.officePhotoUrls.length}
</button>
</div>
)}
</div> </div>
</div> </div>
))} ))}
@ -1069,6 +1101,56 @@ export default function UserDetailPage() {
)} )}
</div> </div>
</div> </div>
{/* 授权照片查看对话框 */}
{showAuthPhotoModal && authSelectedPhotos.length > 0 && (
<div
className={styles.authPhotoModal__overlay}
onClick={() => setShowAuthPhotoModal(false)}
>
<div
className={styles.authPhotoModal__content}
onClick={(e) => e.stopPropagation()}
>
<h3 className={styles.authPhotoModal__title}> ({authSelectedPhotos.length} )</h3>
<div className={styles.authPhotoModal__grid}>
{authSelectedPhotos.map((url, index) => (
<div key={index} className={styles.authPhotoModal__item}>
<img
src={url}
alt={`申请照片 ${index + 1}`}
className={styles.authPhotoModal__img}
onClick={() => setAuthLightboxPhoto(url)}
/>
</div>
))}
</div>
<div className={styles.authPhotoModal__footer}>
<button
className={styles.authPhotoModal__closeBtn}
onClick={() => setShowAuthPhotoModal(false)}
>
</button>
</div>
</div>
</div>
)}
{/* 授权照片全屏查看 (Lightbox) */}
{authLightboxPhoto && (
<div
className={styles.authLightbox}
onClick={() => setAuthLightboxPhoto(null)}
>
<img
src={authLightboxPhoto}
alt="照片预览"
className={styles.authLightbox__img}
/>
<span className={styles.authLightbox__close}></span>
</div>
)}
</PageContainer> </PageContainer>
); );
} }

View File

@ -161,7 +161,7 @@
.userDetail__statsGrid { .userDetail__statsGrid {
display: grid; display: grid;
grid-template-columns: repeat(6, 1fr); grid-template-columns: repeat(7, 1fr);
gap: $spacing-base; gap: $spacing-base;
margin-bottom: $spacing-xl; 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; 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;
}
// ============================================================================ // ============================================================================
// 通用分类账表格 // 通用分类账表格
// ============================================================================ // ============================================================================

View File

@ -14,7 +14,7 @@ import styles from './users.module.scss';
// 骨架屏组件 // 骨架屏组件
const TableRowSkeleton = () => ( const TableRowSkeleton = () => (
<div className={styles.users__tableRow}> <div className={styles.users__tableRow}>
{Array.from({ length: 13 }).map((_, i) => ( {Array.from({ length: 15 }).map((_, i) => (
<div key={i} className={styles.users__tableCellSkeleton}> <div key={i} className={styles.users__tableCellSkeleton}>
<div className={styles.users__skeleton} /> <div className={styles.users__skeleton} />
</div> </div>
@ -361,6 +361,12 @@ export default function UsersPage() {
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--teamTotal'])}> <div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--teamTotal'])}>
<b></b> <b></b>
</div> </div>
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--prePlanting'])}>
<b>()</b>
</div>
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--prePlanting'])}>
<b>()</b>
</div>
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--province'])}> <div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--province'])}>
<b></b> <b></b>
</div> </div>
@ -455,6 +461,16 @@ export default function UsersPage() {
{formatNumber(user.teamAdoptions)} {formatNumber(user.teamAdoptions)}
</div> </div>
{/* 个人预种(份) */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--prePlanting'])}>
{formatNumber(user.selfPrePlantingPortions)}
</div>
{/* 团队预种(份) */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--prePlanting'])}>
{formatNumber(user.teamPrePlantingPortions)}
</div>
{/* 团队本省认种量及占比 */} {/* 团队本省认种量及占比 */}
<div className={cn(styles.users__tableCell, styles['users__tableCell--province'])}> <div className={cn(styles.users__tableCell, styles['users__tableCell--province'])}>
<span>{formatNumber(user.provincialAdoptions.count)}</span> <span>{formatNumber(user.provincialAdoptions.count)}</span>

View File

@ -273,6 +273,11 @@
flex-shrink: 0; flex-shrink: 0;
} }
&--prePlanting {
width: 80px;
flex-shrink: 0;
}
&--province { &--province {
width: 100px; width: 100px;
flex-shrink: 0; flex-shrink: 0;
@ -385,6 +390,11 @@
flex-shrink: 0; flex-shrink: 0;
} }
&--prePlanting {
width: 80px;
flex-shrink: 0;
}
&--province { &--province {
width: 100px; width: 100px;
flex-shrink: 0; flex-shrink: 0;

View File

@ -99,6 +99,7 @@ export interface PrePlantingListParams {
status?: string; status?: string;
accountSequence?: string; accountSequence?: string;
keyword?: string; keyword?: string;
teamOf?: string; // 按团队过滤:传入 accountSequence仅返回该用户团队成员的数据
} }
/** 分页列表响应 */ /** 分页列表响应 */

View File

@ -26,6 +26,8 @@ export interface UserListItem {
}; };
referrerId: string | null; referrerId: string | null;
ranking: number | null; ranking: number | null;
selfPrePlantingPortions: number;
teamPrePlantingPortions: number;
status: 'active' | 'frozen' | 'deactivated'; status: 'active' | 'frozen' | 'deactivated';
isOnline: boolean; isOnline: boolean;
} }

View File

@ -27,6 +27,7 @@ export interface Authorization {
regionName: string; regionName: string;
status: AuthorizationStatus; status: AuthorizationStatus;
benefitActive: boolean; benefitActive: boolean;
officePhotoUrls: string[];
createdAt: string; createdAt: string;
authorizedAt: string | null; authorizedAt: string | null;
revokedAt: string | null; revokedAt: string | null;

View File

@ -31,6 +31,8 @@ export interface UserListItem {
}; };
referrerId: string; referrerId: string;
ranking: number | null; ranking: number | null;
selfPrePlantingPortions: number;
teamPrePlantingPortions: number;
status: 'active' | 'inactive'; status: 'active' | 'inactive';
isOnline: boolean; isOnline: boolean;
} }

View File

@ -32,6 +32,10 @@ export interface UserFullDetail {
percentage: number; percentage: number;
}; };
// 预种统计
selfPrePlantingPortions: number;
teamPrePlantingPortions: number;
// 排名 // 排名
ranking: number | null; ranking: number | null;
@ -192,6 +196,7 @@ export interface AuthorizationRole {
monthlyTargetType: string; monthlyTargetType: string;
lastAssessmentMonth: string | null; lastAssessmentMonth: string | null;
monthlyTreesAdded: number; monthlyTreesAdded: number;
officePhotoUrls: string[];
createdAt: string; createdAt: string;
} }