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")
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")

View File

@ -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) => ({

View File

@ -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: {

View File

@ -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;
}

View File

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

View File

@ -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,

View File

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

View File

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

View File

@ -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<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')
@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,
});
}

View File

@ -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}`);

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")
// 申请时提供的办公室照片MinIO URL
officePhotoUrls String[] @default([]) @map("office_photo_urls")
// 时间戳
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

View File

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

View File

@ -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,
}
})

View File

@ -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,

View File

@ -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,

View File

@ -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 只搜 orderNoaccountSequence 已被限定)
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([

View File

@ -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';

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,
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,
],

View File

@ -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;

View File

@ -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<string[]>([]);
const [lightboxPhoto, setLightboxPhoto] = useState<string | null>(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() {
>
</div>
<div
className={cn(
styles.authorization__tableCell,
styles['authorization__tableCell--header'],
styles['authorization__tableCell--photos']
)}
>
</div>
<div
className={cn(
styles.authorization__tableCell,
@ -474,6 +494,23 @@ export default function AuthorizationPage() {
{item.status === 'AUTHORIZED' ? '有效' : '已撤销'}
</span>
</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
className={cn(
styles.authorization__tableCell,
@ -654,6 +691,53 @@ export default function AuthorizationPage() {
</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>
);
}

View File

@ -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<string, { label: string; style: string }> = {
*
*/
export default function PrePlantingPage() {
// === URL 参数:团队过滤 ===
const searchParams = useSearchParams();
const teamOf = searchParams.get('teamOf') || undefined;
// === Tab 与分页状态 ===
const [activeTab, setActiveTab] = useState<TabKey>('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() {
<div className={styles.prePlanting}>
{/* 页面标题 */}
<div className={styles.prePlanting__header}>
<h1 className={styles.prePlanting__title}></h1>
<h1 className={styles.prePlanting__title}>
{teamOf ? `${teamOf} 的团队预种数据` : '预种计划管理'}
</h1>
</div>
{/* 团队过滤提示 */}
{teamOf && (
<div className={styles.prePlanting__teamBanner}>
<span>
<strong>{teamOf}</strong>
</span>
<Link href={`/users/${teamOf}`} className={styles.prePlanting__teamBannerLink}>
</Link>
</div>
)}
{/* 预种开关卡片 */}
<div
className={cn(

View File

@ -25,6 +25,35 @@
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 {
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 [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);
@ -372,6 +377,19 @@ export default function UserDetailPage() {
<span className={styles.userDetail__statLabel}></span>
<span className={styles.userDetail__statValue}>{formatNumber(userDetail.teamAdoptions)}</span>
</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}>
<span className={styles.userDetail__statLabel}></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> {role.monthlyTargetType}</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>
))}
@ -1069,6 +1101,56 @@ export default function UserDetailPage() {
)}
</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>
);
}

View File

@ -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;
}
// ============================================================================
// 通用分类账表格
// ============================================================================

View File

@ -14,7 +14,7 @@ import styles from './users.module.scss';
// 骨架屏组件
const TableRowSkeleton = () => (
<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 className={styles.users__skeleton} />
</div>
@ -361,6 +361,12 @@ export default function UsersPage() {
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--teamTotal'])}>
<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--prePlanting'])}>
<b>()</b>
</div>
<div className={cn(styles.users__tableHeaderCell, styles['users__tableHeaderCell--province'])}>
<b></b>
</div>
@ -455,6 +461,16 @@ export default function UsersPage() {
{formatNumber(user.teamAdoptions)}
</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'])}>
<span>{formatNumber(user.provincialAdoptions.count)}</span>

View File

@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
}