feat(admin): 功能8修正 — 自助申请照片独立展示页(纯新增方案)

用户在 App 自助申请社区/市团队/省团队授权时上传的办公室照片,
之前错误放在授权管理页且因 CDC 未同步导致全显示"-"。
本次采用纯新增方案:绕过 CDC,通过内部 HTTP API 直连
authorization-service 源头数据库读取照片,保证数据 100% 准确。

=== 数据流 ===
admin-web 新页面 → admin-service 新 Controller
  → authorization-service 新 Internal API
  → authorization_roles 表 (源头, officePhotoUrls 字段)

=== 后端 — authorization-service ===
- 新建 internal-self-apply-photos.controller.ts
  GET /authorization/self-apply-photos?page=1&limit=20&roleType=COMMUNITY
  使用 $queryRaw 查询 office_photo_urls != '{}' 的记录
  支持 roleType 筛选 + 分页
- index.ts 新增 export, app.module.ts 注册 controller

=== 后端 — admin-service ===
- 新建 authorization/authorization-proxy.service.ts
  axios 代理调用 authorization-service 内部 API
  批量查 user_query_view 补充 nickname + avatarUrl
- 新建 api/controllers/authorization-photos.controller.ts
  GET /admin/authorization-photos (admin-web 调用)
- app.module.ts 注册 controller + provider
- docker-compose.yml 追加 AUTHORIZATION_SERVICE_URL 环境变量

=== 前端 — admin-web ===
- 新建 authorization-photos/ 页面 (page.tsx + SCSS)
  表格展示:头像、昵称、账户序列号、授权类型、地区、照片数、申请时间
  点击照片弹出 Modal 网格 → 点击单张弹出全屏 Lightbox
  支持 roleType 筛选 + 分页
- Sidebar.tsx 追加"申请照片"菜单项 (紧随"授权管理"之后)
- endpoints.ts 追加 SELF_APPLY_PHOTOS 端点
- authorization/page.tsx 移除"申请照片"列、photo modal、lightbox
- authorization.module.scss 清理照片相关样式

=== 风险 ===
- CDC 链路: 零修改
- 现有 API: 零冲突 (新 controller 独立文件)
- 2.0 系统: 零影响
- 所有操作均为只读查询

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-02 08:08:32 -08:00
parent 41818eb8e2
commit 59f7bdc137
13 changed files with 988 additions and 173 deletions

View File

@ -0,0 +1,42 @@
/**
*
* [2026-03-02]
*
* === ===
* admin-web AuthorizationProxyService authorization-service API
* CDC officePhotoUrls 100%
*/
import { Controller, Get, Query, Logger, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthorizationProxyService, SelfApplyPhotosResponse } from '../../authorization/authorization-proxy.service';
@Controller('admin/authorization-photos')
export class AuthorizationPhotosController {
private readonly logger = new Logger(AuthorizationPhotosController.name);
constructor(
private readonly authorizationProxyService: AuthorizationProxyService,
) {}
/**
*
* GET /admin/authorization-photos?page=1&limit=20&roleType=COMMUNITY
*/
@Get()
@HttpCode(HttpStatus.OK)
async getSelfApplyPhotos(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('roleType') roleType?: string,
): Promise<SelfApplyPhotosResponse> {
this.logger.debug(
`[getSelfApplyPhotos] page=${page}, limit=${limit}, roleType=${roleType || 'ALL'}`,
);
return this.authorizationProxyService.getSelfApplyPhotos({
page: Number(page) || 1,
limit: Number(limit) || 20,
roleType: roleType || undefined,
});
}
}

View File

@ -91,6 +91,9 @@ import { PrePlantingConfigService } from './pre-planting/pre-planting-config.ser
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-03-02] 纯新增授权自助申请照片代理admin-service → authorization-service 内部 HTTP
import { AuthorizationProxyService } from './authorization/authorization-proxy.service';
import { AuthorizationPhotosController } from './api/controllers/authorization-photos.controller';
// [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价)
import { AdminTreePricingController, PublicTreePricingController } from './pricing/tree-pricing.controller';
import { TreePricingService } from './pricing/tree-pricing.service';
@ -144,6 +147,8 @@ import { AutoPriceIncreaseJob } from './infrastructure/jobs/auto-price-increase.
// [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价)
AdminTreePricingController,
PublicTreePricingController,
// [2026-03-02] 纯新增:自助申请照片管理
AuthorizationPhotosController,
],
providers: [
PrismaService,
@ -239,6 +244,8 @@ import { AutoPriceIncreaseJob } from './infrastructure/jobs/auto-price-increase.
PrePlantingProxyService,
// [2026-03-02] 新增:推荐链预种统计代理
ReferralProxyService,
// [2026-03-02] 纯新增:授权自助申请照片代理
AuthorizationProxyService,
// [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价)
TreePricingService,
AutoPriceIncreaseJob,

View File

@ -0,0 +1,119 @@
/**
*
* [2026-03-02] HTTP authorization-service
*
* === ===
* admin-web admin-service () authorization-service /authorization/self-apply-photos
* ReferralProxyService axios
*/
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
import { PrismaService } from '../infrastructure/persistence/prisma/prisma.service';
export interface SelfApplyPhotoItem {
id: string;
accountSequence: string;
nickname: string;
avatar: string | null;
roleType: string;
regionName: string;
status: string;
officePhotoUrls: string[];
createdAt: string;
}
export interface SelfApplyPhotosResponse {
items: SelfApplyPhotoItem[];
total: number;
page: number;
limit: number;
}
@Injectable()
export class AuthorizationProxyService {
private readonly logger = new Logger(AuthorizationProxyService.name);
private readonly httpClient: AxiosInstance;
constructor(
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {
const authorizationServiceUrl = this.configService.get<string>(
'AUTHORIZATION_SERVICE_URL',
'http://rwa-authorization-service:3009',
);
this.httpClient = axios.create({
baseURL: authorizationServiceUrl,
timeout: 30000,
});
this.logger.log(
`AuthorizationProxyService initialized, authorization-service URL: ${authorizationServiceUrl}`,
);
}
/**
* /
*/
async getSelfApplyPhotos(params: {
page?: number;
limit?: number;
roleType?: string;
}): Promise<SelfApplyPhotosResponse> {
const { page = 1, limit = 20, roleType } = params;
try {
// 1. 从 authorization-service 获取有照片的授权记录
const queryParams = new URLSearchParams();
queryParams.set('page', String(page));
queryParams.set('limit', String(limit));
if (roleType) {
queryParams.set('roleType', roleType);
}
const url = `/api/v1/authorization/self-apply-photos?${queryParams.toString()}`;
this.logger.debug(`[getSelfApplyPhotos] 请求: ${url}`);
const response = await this.httpClient.get(url);
const data = response.data;
if (!data?.items?.length) {
return { items: [], total: data?.total ?? 0, page, limit };
}
// 2. 批量查 user_query_view 补充 nickname + avatarUrl
const accountSequences = data.items.map((item: any) => item.accountSequence);
const users = await this.prisma.userQueryView.findMany({
where: { accountSequence: { in: accountSequences } },
select: { accountSequence: true, nickname: true, avatarUrl: true },
});
const userMap = new Map(
users.map((u) => [u.accountSequence, { nickname: u.nickname, avatar: u.avatarUrl }]),
);
// 3. 合并数据
const items: SelfApplyPhotoItem[] = data.items.map((item: any) => {
const user = userMap.get(item.accountSequence);
return {
id: item.id,
accountSequence: item.accountSequence,
nickname: user?.nickname ?? item.accountSequence,
avatar: user?.avatar ?? null,
roleType: item.roleType,
regionName: item.regionName,
status: item.status,
officePhotoUrls: item.officePhotoUrls ?? [],
createdAt: item.createdAt,
};
});
return { items, total: data.total, page, limit };
} catch (error) {
this.logger.error(`[getSelfApplyPhotos] 失败: ${error.message}`);
return { items: [], total: 0, page, limit };
}
}
}

View File

@ -2,3 +2,5 @@ export * from './authorization.controller'
export * from './admin-authorization.controller'
export * from './health.controller'
export * from './internal-authorization.controller'
// [2026-03-02] 纯新增:自助申请照片内部 API
export * from './internal-self-apply-photos.controller'

View File

@ -0,0 +1,94 @@
/**
* API
* [2026-03-02] admin-service
*
* === ===
* admin-web admin-service authorization_roles
* CDC officePhotoUrls 100%
*/
import { Controller, Get, Query, Logger } from '@nestjs/common'
import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service'
import { Prisma } from '@prisma/client'
interface SelfApplyPhotoRow {
id: string
account_sequence: string
role_type: string
region_name: string
status: string
office_photo_urls: string[]
created_at: Date
}
@ApiTags('Internal Self-Apply Photos')
@Controller('authorization')
export class InternalSelfApplyPhotosController {
private readonly logger = new Logger(InternalSelfApplyPhotosController.name)
constructor(private readonly prisma: PrismaService) {}
/**
*
* officePhotoUrls
*/
@Get('self-apply-photos')
@ApiOperation({ summary: '获取自助申请照片列表(内部 API' })
@ApiQuery({ name: 'page', required: false, type: Number, description: '页码,默认 1' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: '每页数量,默认 20' })
@ApiQuery({ name: 'roleType', required: false, type: String, description: '授权类型筛选' })
@ApiResponse({ status: 200, description: '自助申请照片列表' })
async getSelfApplyPhotos(
@Query('page') pageStr?: string,
@Query('limit') limitStr?: string,
@Query('roleType') roleType?: string,
) {
const page = Math.max(1, Number(pageStr) || 1)
const limit = Math.min(100, Math.max(1, Number(limitStr) || 20))
const offset = (page - 1) * limit
this.logger.debug(
`[INTERNAL] getSelfApplyPhotos: page=${page}, limit=${limit}, roleType=${roleType || 'ALL'}`,
)
// 使用 raw SQL 查询,避免本地 Prisma client 类型不同步问题
const roleTypeCondition = roleType
? Prisma.sql`AND role_type = ${roleType}`
: Prisma.empty
const [items, countResult] = await Promise.all([
this.prisma.$queryRaw<SelfApplyPhotoRow[]>`
SELECT id, account_sequence, role_type, region_name, status, office_photo_urls, created_at
FROM authorization_roles
WHERE office_photo_urls != '{}' AND deleted_at IS NULL
${roleTypeCondition}
ORDER BY created_at DESC
LIMIT ${limit} OFFSET ${offset}
`,
this.prisma.$queryRaw<[{ count: bigint }]>`
SELECT COUNT(*)::bigint as count
FROM authorization_roles
WHERE office_photo_urls != '{}' AND deleted_at IS NULL
${roleTypeCondition}
`,
])
const total = Number(countResult[0]?.count ?? 0)
return {
items: items.map((item) => ({
id: item.id,
accountSequence: item.account_sequence,
roleType: item.role_type,
regionName: item.region_name,
status: item.status,
officePhotoUrls: item.office_photo_urls,
createdAt: item.created_at instanceof Date ? item.created_at.toISOString() : String(item.created_at),
})),
total,
page,
limit,
}
}
}

View File

@ -48,6 +48,7 @@ import {
AdminAuthorizationController,
HealthController,
InternalAuthorizationController,
InternalSelfApplyPhotosController,
} from '@/api/controllers'
// Shared
@ -89,6 +90,8 @@ const MockReferralRepository = {
AdminAuthorizationController,
HealthController,
InternalAuthorizationController,
// [2026-03-02] 纯新增:自助申请照片内部 API
InternalSelfApplyPhotosController,
EventConsumerController,
],
providers: [

View File

@ -668,6 +668,8 @@ services:
- KAFKA_CDC_CONSUMER_GROUP=admin-service-cdc
# File Storage
- UPLOAD_DIR=/app/uploads
# [2026-03-02] 纯新增:授权自助申请照片代理
- AUTHORIZATION_SERVICE_URL=http://rwa-authorization-service:3009
volumes:
- admin_uploads_data:/app/uploads
depends_on:

View File

@ -0,0 +1,427 @@
/* 自助申请照片页面样式 */
/* [2026-03-02] 纯新增 */
@use '@/styles/variables' as *;
@use '@/styles/mixins' as *;
.photos {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
text-align: left;
font-family: 'Noto Sans SC', $font-family-base;
}
/* 卡片 */
.photos__card {
align-self: stretch;
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.05);
border-radius: 8px;
background-color: #fff;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 24px;
gap: 15px;
}
.photos__cardHeader {
align-self: stretch;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.photos__cardTitle {
margin: 0;
font-size: 18px;
line-height: 28px;
font-weight: 700;
color: #1e293b;
}
.photos__count {
font-size: 14px;
color: #6b7280;
}
/* 筛选 */
.photos__filters {
align-self: stretch;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.photos__select {
min-width: 160px;
height: 38px;
border-radius: 6px;
background-color: #f3f4f6;
border: 1px solid #e5e7eb;
padding: 9px 13px;
font-size: 14px;
line-height: 20px;
color: #1e293b;
cursor: pointer;
appearance: none;
font-family: inherit;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 8px center;
background-repeat: no-repeat;
background-size: 16px;
}
/* 表格 */
.photos__table {
align-self: stretch;
overflow-x: auto;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.photos__tableHeader {
align-self: stretch;
min-width: 750px;
background-color: #f3f4f6;
display: flex;
align-items: center;
}
.photos__tableRow {
align-self: stretch;
min-width: 750px;
display: flex;
align-items: center;
border-bottom: 1px solid #e5e7eb;
@include transition-fast;
&:hover {
background-color: #f9fafb;
}
}
.photos__cell {
display: flex;
align-items: center;
padding: 12px 16px;
font-size: 14px;
line-height: 20px;
color: #1e293b;
&--header {
font-size: 12px;
font-weight: 700;
color: #6b7280;
text-transform: uppercase;
}
&--avatar {
width: 64px;
flex-shrink: 0;
justify-content: center;
}
&--nickname {
width: 120px;
flex-shrink: 0;
font-weight: 500;
}
&--accountId {
width: 140px;
flex-shrink: 0;
font-size: 13px;
}
&--type {
width: 100px;
flex-shrink: 0;
}
&--region {
width: 140px;
flex-shrink: 0;
}
&--photoCount {
width: 100px;
flex-shrink: 0;
}
&--date {
flex: 1;
min-width: 90px;
font-size: 12px;
color: #6b7280;
}
}
/* 头像 */
.photos__avatar {
width: 36px;
height: 36px;
border-radius: 9999px;
background-size: cover;
background-position: center;
background-color: #e5e7eb;
}
/* 类型徽章 */
.photos__typeBadge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
background-color: #e0e7ff;
color: #3730a3;
}
/* 查看照片按钮 */
.photos__viewBtn {
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);
}
}
/* 空数据 */
.photos__emptyRow {
align-self: stretch;
padding: 40px 24px;
text-align: center;
color: #6b7280;
font-size: 14px;
}
/* 帮助文本 */
.photos__help {
align-self: stretch;
font-size: 12px;
line-height: 16px;
color: #6b7280;
padding-top: 4px;
}
/* 加载/错误 */
.photos__loading {
align-self: stretch;
padding: 40px 24px;
text-align: center;
color: #6b7280;
font-size: 14px;
}
.photos__error {
align-self: stretch;
padding: 24px;
background-color: #fef2f2;
border-radius: 6px;
color: #991b1b;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.photos__retryBtn {
cursor: pointer;
border: 1px solid #991b1b;
border-radius: 4px;
background-color: transparent;
padding: 4px 12px;
font-size: 12px;
color: #991b1b;
font-family: inherit;
@include transition-fast;
&:hover {
background-color: #991b1b;
color: #fff;
}
}
/* 分页 */
.photos__pagination {
align-self: stretch;
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
padding: 16px 0;
border-top: 1px solid #e5e7eb;
margin-top: 8px;
}
.photos__pageBtn {
cursor: pointer;
border: 1px solid #e5e7eb;
border-radius: 6px;
background-color: #fff;
padding: 6px 12px;
font-size: 14px;
color: #1e293b;
font-family: inherit;
@include transition-fast;
&:hover:not(:disabled) {
background-color: #f3f4f6;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.photos__pageInfo {
font-size: 14px;
color: #6b7280;
}
/* 模态框 */
.modal__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;
}
.modal__content {
background-color: #fff;
border-radius: 12px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 680px;
max-height: 90vh;
overflow-y: auto;
padding: 24px;
}
.modal__title {
margin: 0 0 12px 0;
font-size: 18px;
font-weight: 700;
color: #1e293b;
}
.modal__info {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 16px;
font-size: 13px;
color: #6b7280;
}
.modal__footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #e5e7eb;
}
.modal__closeBtn {
cursor: pointer;
border: 1px solid #e5e7eb;
border-radius: 6px;
background-color: #fff;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: #6b7280;
font-family: inherit;
@include transition-fast;
&:hover {
background-color: #f3f4f6;
}
}
/* 照片网格 */
.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;
}

View File

@ -0,0 +1,288 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { PageContainer } from '@/components/layout';
import { cn } from '@/utils/helpers';
import apiClient from '@/infrastructure/api/client';
import { API_ENDPOINTS } from '@/infrastructure/api/endpoints';
import { ROLE_TYPE_LABELS } from '@/types/authorization.types';
import type { RoleType } from '@/types/authorization.types';
import styles from './authorization-photos.module.scss';
/** 自助申请照片记录 */
interface SelfApplyPhotoItem {
id: string;
accountSequence: string;
nickname: string;
avatar: string | null;
roleType: RoleType;
regionName: string;
status: string;
officePhotoUrls: string[];
createdAt: string;
}
interface SelfApplyPhotosResponse {
items: SelfApplyPhotoItem[];
total: number;
page: number;
limit: number;
}
/**
*
* [2026-03-02]
* authorization-service CDC
*/
export default function AuthorizationPhotosPage() {
const [data, setData] = useState<SelfApplyPhotosResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 筛选
const [filterType, setFilterType] = useState<RoleType | ''>('');
const [page, setPage] = useState(1);
const limit = 20;
// 照片查看
const [showPhotoModal, setShowPhotoModal] = useState(false);
const [selectedItem, setSelectedItem] = useState<SelfApplyPhotoItem | null>(null);
const [lightboxPhoto, setLightboxPhoto] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const params: Record<string, string | number> = { page, limit };
if (filterType) {
params.roleType = filterType;
}
const response = await apiClient.get(API_ENDPOINTS.AUTHORIZATION.SELF_APPLY_PHOTOS, {
params,
});
setData((response as any)?.data ?? { items: [], total: 0, page: 1, limit: 20 });
} catch (err) {
console.error('获取自助申请照片失败:', err);
setError(err instanceof Error ? err.message : '获取数据失败');
} finally {
setIsLoading(false);
}
}, [page, limit, filterType]);
useEffect(() => {
fetchData();
}, [fetchData]);
const openPhotoModal = (item: SelfApplyPhotoItem) => {
setSelectedItem(item);
setShowPhotoModal(true);
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('zh-CN');
};
const totalPages = data ? Math.ceil(data.total / limit) : 0;
return (
<PageContainer title="自助申请照片">
<div className={styles.photos}>
<section className={styles.photos__card}>
<div className={styles.photos__cardHeader}>
<h3 className={styles.photos__cardTitle}></h3>
<span className={styles.photos__count}>
{data?.total ?? 0}
</span>
</div>
{/* 筛选 */}
<div className={styles.photos__filters}>
<select
className={styles.photos__select}
value={filterType}
onChange={(e) => {
setFilterType(e.target.value as RoleType | '');
setPage(1);
}}
aria-label="授权类型"
>
<option value=""></option>
{Object.entries(ROLE_TYPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
{/* 加载状态 */}
{isLoading && (
<div className={styles.photos__loading}>...</div>
)}
{/* 错误状态 */}
{error && (
<div className={styles.photos__error}>
: {error}
<button onClick={fetchData} className={styles.photos__retryBtn}>
</button>
</div>
)}
{/* 表格 */}
{!isLoading && !error && (
<>
<div className={styles.photos__table}>
<div className={styles.photos__tableHeader}>
<div className={cn(styles.photos__cell, styles['photos__cell--header'], styles['photos__cell--avatar'])}>
</div>
<div className={cn(styles.photos__cell, styles['photos__cell--header'], styles['photos__cell--nickname'])}>
</div>
<div className={cn(styles.photos__cell, styles['photos__cell--header'], styles['photos__cell--accountId'])}>
</div>
<div className={cn(styles.photos__cell, styles['photos__cell--header'], styles['photos__cell--type'])}>
</div>
<div className={cn(styles.photos__cell, styles['photos__cell--header'], styles['photos__cell--region'])}>
</div>
<div className={cn(styles.photos__cell, styles['photos__cell--header'], styles['photos__cell--photoCount'])}>
</div>
<div className={cn(styles.photos__cell, styles['photos__cell--header'], styles['photos__cell--date'])}>
</div>
</div>
{data?.items.map((item) => (
<div key={item.id} className={styles.photos__tableRow}>
<div className={cn(styles.photos__cell, styles['photos__cell--avatar'])}>
<div
className={styles.photos__avatar}
style={item.avatar ? { backgroundImage: `url(${item.avatar})` } : undefined}
/>
</div>
<div className={cn(styles.photos__cell, styles['photos__cell--nickname'])}>
{item.nickname || '-'}
</div>
<div className={cn(styles.photos__cell, styles['photos__cell--accountId'])}>
{item.accountSequence}
</div>
<div className={cn(styles.photos__cell, styles['photos__cell--type'])}>
<span className={styles.photos__typeBadge}>
{ROLE_TYPE_LABELS[item.roleType] || item.roleType}
</span>
</div>
<div className={cn(styles.photos__cell, styles['photos__cell--region'])}>
{item.regionName || '-'}
</div>
<div className={cn(styles.photos__cell, styles['photos__cell--photoCount'])}>
<button
className={styles.photos__viewBtn}
onClick={() => openPhotoModal(item)}
>
{item.officePhotoUrls.length}
</button>
</div>
<div className={cn(styles.photos__cell, styles['photos__cell--date'])}>
{formatDate(item.createdAt)}
</div>
</div>
))}
{(!data?.items || data.items.length === 0) && (
<div className={styles.photos__emptyRow}></div>
)}
</div>
{/* 分页 */}
{totalPages > 1 && (
<div className={styles.photos__pagination}>
<button
className={styles.photos__pageBtn}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
</button>
<span className={styles.photos__pageInfo}>
{page} / {totalPages} ( {data?.total ?? 0} )
</span>
<button
className={styles.photos__pageBtn}
onClick={() => setPage((p) => p + 1)}
disabled={page >= totalPages}
>
</button>
</div>
)}
</>
)}
<p className={styles.photos__help}>
App
</p>
</section>
</div>
{/* 照片查看对话框 */}
{showPhotoModal && selectedItem && (
<div className={styles.modal__overlay} onClick={() => setShowPhotoModal(false)}>
<div
className={styles.modal__content}
onClick={(e) => e.stopPropagation()}
>
<h3 className={styles.modal__title}>
{selectedItem.nickname || selectedItem.accountSequence} - ({selectedItem.officePhotoUrls.length} )
</h3>
<div className={styles.modal__info}>
<span>: {selectedItem.accountSequence}</span>
<span>: {ROLE_TYPE_LABELS[selectedItem.roleType] || selectedItem.roleType}</span>
<span>: {selectedItem.regionName}</span>
</div>
<div className={styles.photoGrid}>
{selectedItem.officePhotoUrls.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__closeBtn}
onClick={() => setShowPhotoModal(false)}
>
</button>
</div>
</div>
</div>
)}
{/* 全屏灯箱 */}
{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

@ -1001,96 +1001,6 @@
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,11 +29,6 @@ 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({
@ -191,12 +186,6 @@ export default function AuthorizationPage() {
}
};
// 打开照片查看对话框
const openPhotoModal = (photos: string[]) => {
setSelectedPhotos(photos);
setShowPhotoModal(true);
};
// 打开取消授权对话框
const openRevokeModal = (item: Authorization) => {
setRevokeTarget(item);
@ -403,15 +392,6 @@ 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,
@ -494,23 +474,6 @@ 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,
@ -692,52 +655,6 @@ export default function AuthorizationPage() {
</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

@ -29,6 +29,8 @@ const topMenuItems: MenuItem[] = [
{ key: 'contracts', icon: '/images/Container2.svg', label: '合同管理', path: '/contracts' },
{ key: 'leaderboard', icon: '/images/Container3.svg', label: '龙虎榜', path: '/leaderboard' },
{ key: 'authorization', icon: '/images/Container4.svg', label: '授权管理', path: '/authorization' },
// [2026-03-02] 纯新增:自助申请照片独立页面
{ key: 'authorization-photos', icon: '/images/Container4.svg', label: '申请照片', path: '/authorization-photos' },
{ key: 'co-managed-wallet', icon: '/images/Container4.svg', label: '共管钱包', path: '/co-managed-wallet' },
{ key: 'notifications', icon: '/images/Container3.svg', label: '通知管理', path: '/notifications' },
{ key: 'pending-actions', icon: '/images/Container3.svg', label: '待办操作', path: '/pending-actions' },

View File

@ -55,6 +55,8 @@ export const API_ENDPOINTS = {
ADMIN_GRANT_CITY_COMPANY: '/v1/admin/authorizations/city-company',
ADMIN_GRANT_AUTH_PROVINCE_COMPANY: '/v1/admin/authorizations/auth-province-company',
ADMIN_GRANT_AUTH_CITY_COMPANY: '/v1/admin/authorizations/auth-city-company',
// [2026-03-02] 纯新增:自助申请照片(绕过 CDC读源头数据
SELF_APPLY_PHOTOS: '/v1/admin/authorization-photos',
// 其他授权端点
PROVINCE_COMPANIES: '/v1/authorizations/province-companies',
CITY_COMPANIES: '/v1/authorizations/city-companies',