feat: integrate real API for authorization apply page
Changes: - Add generic image upload API endpoint (POST /user/upload-image) - Add uploadImage method in StorageService for backend - Add uploadImage method in AccountService for frontend - Add selfApplyStatus and selfApplyAuthorization methods in AuthorizationService - Replace mock data with real API calls in authorization apply page - Add API endpoints for self-apply status and self-apply authorization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5904f2f84d
commit
ecf3755227
|
|
@ -5,6 +5,7 @@ import {
|
|||
Put,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Headers,
|
||||
UseInterceptors,
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
ApiResponse,
|
||||
ApiConsumes,
|
||||
ApiBody,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { UserApplicationService } from '@/application/services/user-application.service';
|
||||
import { StorageService } from '@/infrastructure/external/storage/storage.service';
|
||||
|
|
@ -597,4 +599,70 @@ export class UserAccountController {
|
|||
avatarUrl: result.url,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('upload-image')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '上传通用图片' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiQuery({
|
||||
name: 'category',
|
||||
description: '图片分类 (如 office-photos)',
|
||||
example: 'office-photos',
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: {
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
description: '图片文件 (支持 jpg, png, gif, webp, 最大10MB)',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '上传成功,返回图片URL' })
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadImage(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('category') category: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
// 验证分类
|
||||
if (!category || category.trim() === '') {
|
||||
throw new BadRequestException('请指定图片分类');
|
||||
}
|
||||
|
||||
// 验证文件是否存在
|
||||
if (!file) {
|
||||
throw new BadRequestException('请选择要上传的图片');
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
if (!this.storageService.isValidImageType(file.mimetype)) {
|
||||
throw new BadRequestException(
|
||||
'不支持的图片格式,请使用 jpg, png, gif 或 webp',
|
||||
);
|
||||
}
|
||||
|
||||
// 验证文件大小
|
||||
if (file.size > this.storageService.maxImageSize) {
|
||||
throw new BadRequestException('图片大小不能超过 10MB');
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
const result = await this.storageService.uploadImage(
|
||||
user.userId,
|
||||
category.trim(),
|
||||
file.buffer,
|
||||
file.mimetype,
|
||||
);
|
||||
|
||||
return {
|
||||
message: '上传成功',
|
||||
imageUrl: result.url,
|
||||
key: result.key,
|
||||
size: result.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,4 +218,58 @@ export class StorageService implements OnModuleInit {
|
|||
get maxAvatarSize(): number {
|
||||
return 5 * 1024 * 1024;
|
||||
}
|
||||
|
||||
/**
|
||||
* 最大通用图片文件大小 (10MB)
|
||||
*/
|
||||
get maxImageSize(): number {
|
||||
return 10 * 1024 * 1024;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传通用图片
|
||||
*
|
||||
* 用于办公室照片等通用图片上传
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param category 分类 (如 'office-photos')
|
||||
* @param buffer 文件内容
|
||||
* @param contentType MIME类型 (image/jpeg, image/png, etc.)
|
||||
* @returns 上传结果
|
||||
*/
|
||||
async uploadImage(
|
||||
userId: string,
|
||||
category: string,
|
||||
buffer: Buffer,
|
||||
contentType: string,
|
||||
): Promise<UploadResult> {
|
||||
// 使用 avatars bucket 存储所有用户图片
|
||||
// 路径格式: userId/category/uuid.ext
|
||||
const extension = this.getExtensionFromContentType(contentType);
|
||||
const key = `${userId}/${category}/${uuidv4()}${extension}`;
|
||||
|
||||
try {
|
||||
await this.client.putObject(
|
||||
this.bucketAvatars,
|
||||
key,
|
||||
buffer,
|
||||
buffer.length,
|
||||
{ 'Content-Type': contentType },
|
||||
);
|
||||
|
||||
const url = `${this.publicUrl}/${this.bucketAvatars}/${key}`;
|
||||
|
||||
this.logger.log(`Image uploaded: ${key} (${buffer.length} bytes)`);
|
||||
|
||||
return {
|
||||
key,
|
||||
url,
|
||||
size: buffer.length,
|
||||
contentType,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to upload image: ${error.message}`);
|
||||
throw new Error(`文件上传失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,8 @@ class ApiEndpoints {
|
|||
static const String myAuthorizations = '$authorizations/my'; // 获取我的授权列表
|
||||
static const String myCommunityHierarchy = '$authorizations/my/community-hierarchy'; // 获取社区层级
|
||||
static const String stickmanRanking = '$authorizations/ranking/stickman'; // 火柴人排名
|
||||
static const String selfApplyStatus = '$authorizations/self-apply/status'; // 获取自助申请状态
|
||||
static const String selfApply = '$authorizations/self-apply'; // 自助申请授权
|
||||
|
||||
// Telemetry (-> Reporting Service)
|
||||
static const String telemetry = '/telemetry';
|
||||
|
|
|
|||
|
|
@ -1214,6 +1214,89 @@ class AccountService {
|
|||
}
|
||||
}
|
||||
|
||||
/// 上传通用图片
|
||||
///
|
||||
/// [imageFile] - 图片文件
|
||||
/// [category] - 图片分类 (如 'office-photos')
|
||||
/// 返回图片URL
|
||||
Future<String> uploadImage(File imageFile, String category) async {
|
||||
debugPrint('$_tag uploadImage() - 开始上传图片');
|
||||
debugPrint('$_tag uploadImage() - 文件路径: ${imageFile.path}, 分类: $category');
|
||||
|
||||
try {
|
||||
// 获取文件名和MIME类型
|
||||
final fileName = imageFile.path.split('/').last;
|
||||
final extension = fileName.split('.').last.toLowerCase();
|
||||
|
||||
String mimeType;
|
||||
switch (extension) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
mimeType = 'image/jpeg';
|
||||
break;
|
||||
case 'png':
|
||||
mimeType = 'image/png';
|
||||
break;
|
||||
case 'gif':
|
||||
mimeType = 'image/gif';
|
||||
break;
|
||||
case 'webp':
|
||||
mimeType = 'image/webp';
|
||||
break;
|
||||
default:
|
||||
mimeType = 'image/jpeg';
|
||||
}
|
||||
|
||||
debugPrint('$_tag uploadImage() - 文件名: $fileName, MIME: $mimeType');
|
||||
|
||||
// 创建 FormData
|
||||
final formData = FormData.fromMap({
|
||||
'file': await MultipartFile.fromFile(
|
||||
imageFile.path,
|
||||
filename: fileName,
|
||||
contentType: DioMediaType.parse(mimeType),
|
||||
),
|
||||
});
|
||||
|
||||
// 调用 API
|
||||
debugPrint('$_tag uploadImage() - 调用 POST /user/upload-image?category=$category');
|
||||
final response = await _apiClient.post(
|
||||
'/user/upload-image',
|
||||
data: formData,
|
||||
queryParameters: {'category': category},
|
||||
);
|
||||
debugPrint('$_tag uploadImage() - API 响应状态码: ${response.statusCode}');
|
||||
|
||||
if (response.data == null) {
|
||||
throw const ApiException('上传图片失败: 空响应');
|
||||
}
|
||||
|
||||
final responseData = response.data as Map<String, dynamic>;
|
||||
debugPrint('$_tag uploadImage() - responseData: $responseData');
|
||||
|
||||
final data = responseData['data'] as Map<String, dynamic>?;
|
||||
if (data == null) {
|
||||
throw const ApiException('上传图片失败: 响应数据格式错误');
|
||||
}
|
||||
|
||||
final imageUrl = data['imageUrl'] as String?;
|
||||
debugPrint('$_tag uploadImage() - 图片URL: $imageUrl');
|
||||
|
||||
if (imageUrl == null || imageUrl.isEmpty) {
|
||||
throw const ApiException('上传图片失败: 未返回图片URL');
|
||||
}
|
||||
|
||||
return imageUrl;
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('$_tag uploadImage() - API 异常: $e');
|
||||
rethrow;
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('$_tag uploadImage() - 未知异常: $e');
|
||||
debugPrint('$_tag uploadImage() - 堆栈: $stackTrace');
|
||||
throw ApiException('上传图片失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取我的资料(从服务器)
|
||||
Future<Map<String, dynamic>> getMyProfile() async {
|
||||
debugPrint('$_tag getMyProfile() - 获取我的资料');
|
||||
|
|
|
|||
|
|
@ -312,6 +312,122 @@ class CommunityHierarchy {
|
|||
}
|
||||
}
|
||||
|
||||
/// 自助申请授权类型
|
||||
enum SelfApplyAuthorizationType {
|
||||
community,
|
||||
cityTeam,
|
||||
provinceTeam,
|
||||
}
|
||||
|
||||
extension SelfApplyAuthorizationTypeExtension on SelfApplyAuthorizationType {
|
||||
String get value {
|
||||
switch (this) {
|
||||
case SelfApplyAuthorizationType.community:
|
||||
return 'COMMUNITY';
|
||||
case SelfApplyAuthorizationType.cityTeam:
|
||||
return 'CITY_TEAM';
|
||||
case SelfApplyAuthorizationType.provinceTeam:
|
||||
return 'PROVINCE_TEAM';
|
||||
}
|
||||
}
|
||||
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case SelfApplyAuthorizationType.community:
|
||||
return '社区';
|
||||
case SelfApplyAuthorizationType.cityTeam:
|
||||
return '市团队';
|
||||
case SelfApplyAuthorizationType.provinceTeam:
|
||||
return '省团队';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 待审核申请信息
|
||||
class PendingApplication {
|
||||
final String applicationId;
|
||||
final String type;
|
||||
final DateTime appliedAt;
|
||||
|
||||
PendingApplication({
|
||||
required this.applicationId,
|
||||
required this.type,
|
||||
required this.appliedAt,
|
||||
});
|
||||
|
||||
factory PendingApplication.fromJson(Map<String, dynamic> json) {
|
||||
return PendingApplication(
|
||||
applicationId: json['applicationId'] ?? '',
|
||||
type: json['type'] ?? '',
|
||||
appliedAt: json['appliedAt'] != null
|
||||
? DateTime.parse(json['appliedAt'])
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户授权申请状态响应
|
||||
class UserAuthorizationStatusResponse {
|
||||
final bool hasPlanted;
|
||||
final int plantedCount;
|
||||
final List<String> existingAuthorizations;
|
||||
final List<PendingApplication> pendingApplications;
|
||||
|
||||
UserAuthorizationStatusResponse({
|
||||
required this.hasPlanted,
|
||||
required this.plantedCount,
|
||||
required this.existingAuthorizations,
|
||||
required this.pendingApplications,
|
||||
});
|
||||
|
||||
factory UserAuthorizationStatusResponse.fromJson(Map<String, dynamic> json) {
|
||||
return UserAuthorizationStatusResponse(
|
||||
hasPlanted: json['hasPlanted'] ?? false,
|
||||
plantedCount: json['plantedCount'] ?? 0,
|
||||
existingAuthorizations: (json['existingAuthorizations'] as List<dynamic>?)
|
||||
?.map((e) => e.toString())
|
||||
.toList() ?? [],
|
||||
pendingApplications: (json['pendingApplications'] as List<dynamic>?)
|
||||
?.map((e) => PendingApplication.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 自助申请授权响应
|
||||
class SelfApplyAuthorizationResponse {
|
||||
final String applicationId;
|
||||
final String status;
|
||||
final String type;
|
||||
final DateTime appliedAt;
|
||||
final DateTime? reviewedAt;
|
||||
final String? reviewNote;
|
||||
|
||||
SelfApplyAuthorizationResponse({
|
||||
required this.applicationId,
|
||||
required this.status,
|
||||
required this.type,
|
||||
required this.appliedAt,
|
||||
this.reviewedAt,
|
||||
this.reviewNote,
|
||||
});
|
||||
|
||||
factory SelfApplyAuthorizationResponse.fromJson(Map<String, dynamic> json) {
|
||||
return SelfApplyAuthorizationResponse(
|
||||
applicationId: json['applicationId'] ?? '',
|
||||
status: json['status'] ?? 'PENDING',
|
||||
type: json['type'] ?? '',
|
||||
appliedAt: json['appliedAt'] != null
|
||||
? DateTime.parse(json['appliedAt'])
|
||||
: DateTime.now(),
|
||||
reviewedAt: json['reviewedAt'] != null
|
||||
? DateTime.parse(json['reviewedAt'])
|
||||
: null,
|
||||
reviewNote: json['reviewNote'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 火柴人排名响应
|
||||
class StickmanRankingResponse {
|
||||
final String id;
|
||||
|
|
@ -480,4 +596,109 @@ class AuthorizationService {
|
|||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取用户授权申请状态
|
||||
///
|
||||
/// 返回用户是否已认种、认种棵数、已有授权和待审核申请
|
||||
/// 调用 GET /authorizations/self-apply/status
|
||||
Future<UserAuthorizationStatusResponse> getSelfApplyStatus() async {
|
||||
try {
|
||||
debugPrint('获取授权申请状态...');
|
||||
final response = await _apiClient.get(ApiEndpoints.selfApplyStatus);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final responseData = response.data;
|
||||
Map<String, dynamic>? data;
|
||||
if (responseData is Map<String, dynamic>) {
|
||||
if (responseData.containsKey('data')) {
|
||||
data = responseData['data'] as Map<String, dynamic>?;
|
||||
} else {
|
||||
data = responseData;
|
||||
}
|
||||
}
|
||||
|
||||
if (data != null) {
|
||||
final status = UserAuthorizationStatusResponse.fromJson(data);
|
||||
debugPrint('授权申请状态获取成功: hasPlanted=${status.hasPlanted}, plantedCount=${status.plantedCount}');
|
||||
return status;
|
||||
}
|
||||
throw Exception('授权申请状态数据格式错误');
|
||||
}
|
||||
|
||||
throw Exception('获取授权申请状态失败');
|
||||
} catch (e) {
|
||||
debugPrint('获取授权申请状态失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 自助申请授权
|
||||
///
|
||||
/// [type] 申请类型(社区、市团队、省团队)
|
||||
/// [officePhotoUrls] 办公室照片URL列表(至少2张,最多6张)
|
||||
/// [communityName] 社区名称(申请社区时必填)
|
||||
/// [provinceCode] 省份代码(申请省团队时必填)
|
||||
/// [provinceName] 省份名称(申请省团队时必填)
|
||||
/// [cityCode] 城市代码(申请市团队时必填)
|
||||
/// [cityName] 城市名称(申请市团队时必填)
|
||||
Future<SelfApplyAuthorizationResponse> selfApplyAuthorization({
|
||||
required SelfApplyAuthorizationType type,
|
||||
required List<String> officePhotoUrls,
|
||||
String? communityName,
|
||||
String? provinceCode,
|
||||
String? provinceName,
|
||||
String? cityCode,
|
||||
String? cityName,
|
||||
}) async {
|
||||
try {
|
||||
debugPrint('自助申请授权: type=${type.value}');
|
||||
|
||||
final body = <String, dynamic>{
|
||||
'type': type.value,
|
||||
'officePhotoUrls': officePhotoUrls,
|
||||
};
|
||||
|
||||
// 根据申请类型添加必填字段
|
||||
if (type == SelfApplyAuthorizationType.community && communityName != null) {
|
||||
body['communityName'] = communityName;
|
||||
}
|
||||
if (type == SelfApplyAuthorizationType.provinceTeam) {
|
||||
if (provinceCode != null) body['provinceCode'] = provinceCode;
|
||||
if (provinceName != null) body['provinceName'] = provinceName;
|
||||
}
|
||||
if (type == SelfApplyAuthorizationType.cityTeam) {
|
||||
if (cityCode != null) body['cityCode'] = cityCode;
|
||||
if (cityName != null) body['cityName'] = cityName;
|
||||
}
|
||||
|
||||
final response = await _apiClient.post(
|
||||
ApiEndpoints.selfApply,
|
||||
data: body,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
final responseData = response.data;
|
||||
Map<String, dynamic>? data;
|
||||
if (responseData is Map<String, dynamic>) {
|
||||
if (responseData.containsKey('data')) {
|
||||
data = responseData['data'] as Map<String, dynamic>?;
|
||||
} else {
|
||||
data = responseData;
|
||||
}
|
||||
}
|
||||
|
||||
if (data != null) {
|
||||
final result = SelfApplyAuthorizationResponse.fromJson(data);
|
||||
debugPrint('自助申请授权成功: applicationId=${result.applicationId}');
|
||||
return result;
|
||||
}
|
||||
throw Exception('申请响应数据格式错误');
|
||||
}
|
||||
|
||||
throw Exception('自助申请授权失败');
|
||||
} catch (e) {
|
||||
debugPrint('自助申请授权失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../core/services/authorization_service.dart';
|
||||
|
||||
/// 授权类型枚举
|
||||
enum AuthorizationType {
|
||||
|
|
@ -84,18 +86,15 @@ class _AuthorizationApplyPageState
|
|||
});
|
||||
|
||||
try {
|
||||
// TODO: 调用API获取用户认种状态和已有授权
|
||||
// final authService = ref.read(authorizationServiceProvider);
|
||||
// final status = await authService.getUserAuthorizationStatus();
|
||||
|
||||
// 模拟数据
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
// 调用API获取用户认种状态和已有授权
|
||||
final authService = ref.read(authorizationServiceProvider);
|
||||
final status = await authService.getSelfApplyStatus();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hasPlanted = true; // 模拟已认种
|
||||
_plantedCount = 10; // 模拟认种10棵
|
||||
_existingAuthorizations = []; // 模拟无已有授权
|
||||
_hasPlanted = status.hasPlanted;
|
||||
_plantedCount = status.plantedCount;
|
||||
_existingAuthorizations = status.existingAuthorizations;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
|
@ -206,15 +205,38 @@ class _AuthorizationApplyPageState
|
|||
});
|
||||
|
||||
try {
|
||||
// TODO: 调用API提交申请
|
||||
// final authService = ref.read(authorizationServiceProvider);
|
||||
// await authService.submitAuthorizationApplication(
|
||||
// type: _selectedType!,
|
||||
// officePhotos: _officePhotos,
|
||||
// );
|
||||
final accountService = ref.read(accountServiceProvider);
|
||||
final authService = ref.read(authorizationServiceProvider);
|
||||
|
||||
// 模拟请求
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
// 1. 上传所有照片获取URL列表
|
||||
final photoUrls = <String>[];
|
||||
for (int i = 0; i < _officePhotos.length; i++) {
|
||||
final url = await accountService.uploadImage(
|
||||
_officePhotos[i],
|
||||
'office-photos',
|
||||
);
|
||||
photoUrls.add(url);
|
||||
}
|
||||
|
||||
// 2. 将 AuthorizationType 转换为 SelfApplyAuthorizationType
|
||||
SelfApplyAuthorizationType selfApplyType;
|
||||
switch (_selectedType!) {
|
||||
case AuthorizationType.community:
|
||||
selfApplyType = SelfApplyAuthorizationType.community;
|
||||
break;
|
||||
case AuthorizationType.cityTeam:
|
||||
selfApplyType = SelfApplyAuthorizationType.cityTeam;
|
||||
break;
|
||||
case AuthorizationType.provinceTeam:
|
||||
selfApplyType = SelfApplyAuthorizationType.provinceTeam;
|
||||
break;
|
||||
}
|
||||
|
||||
// 3. 提交授权申请
|
||||
await authService.selfApplyAuthorization(
|
||||
type: selfApplyType,
|
||||
officePhotoUrls: photoUrls,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue