diff --git a/backend/services/admin-service/prisma/migrations/20260204100000_add_app_assets/migration.sql b/backend/services/admin-service/prisma/migrations/20260204100000_add_app_assets/migration.sql new file mode 100644 index 00000000..14e345e4 --- /dev/null +++ b/backend/services/admin-service/prisma/migrations/20260204100000_add_app_assets/migration.sql @@ -0,0 +1,34 @@ +-- ============================================================================= +-- App Assets Migration +-- 应用资源管理 (开屏图/引导图) +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 1. 应用资源类型枚举 +-- ----------------------------------------------------------------------------- + +CREATE TYPE "AppAssetType" AS ENUM ('SPLASH', 'GUIDE'); + +-- ----------------------------------------------------------------------------- +-- 2. 应用资源表 +-- ----------------------------------------------------------------------------- + +CREATE TABLE "app_assets" ( + "id" TEXT NOT NULL, + "type" "AppAssetType" NOT NULL, + "sort_order" INTEGER NOT NULL, + "image_url" TEXT NOT NULL, + "title" VARCHAR(100), + "subtitle" VARCHAR(200), + "is_enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "app_assets_pkey" PRIMARY KEY ("id") +); + +-- Unique constraint: 同类型同排序位置只能有一条记录 +CREATE UNIQUE INDEX "app_assets_type_sort_order_key" ON "app_assets"("type", "sort_order"); + +-- 按类型和启用状态查询索引 +CREATE INDEX "app_assets_type_is_enabled_idx" ON "app_assets"("type", "is_enabled"); diff --git a/backend/services/admin-service/prisma/schema.prisma b/backend/services/admin-service/prisma/schema.prisma index 949c6ec5..245f4f7a 100644 --- a/backend/services/admin-service/prisma/schema.prisma +++ b/backend/services/admin-service/prisma/schema.prisma @@ -1150,3 +1150,30 @@ model CoManagedWallet { @@index([createdAt]) @@map("co_managed_wallets") } + +// ============================================================================= +// App Assets (应用资源 - 开屏图/引导图) +// ============================================================================= + +/// 应用资源类型 +enum AppAssetType { + SPLASH // 开屏图 + GUIDE // 引导图 +} + +/// 应用资源 - 管理开屏图和引导图 +model AppAsset { + id String @id @default(uuid()) + type AppAssetType + sortOrder Int @map("sort_order") + imageUrl String @map("image_url") @db.Text + title String? @db.VarChar(100) + subtitle String? @db.VarChar(200) + isEnabled Boolean @default(true) @map("is_enabled") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([type, sortOrder]) + @@index([type, isEnabled]) + @@map("app_assets") +} diff --git a/backend/services/admin-service/src/api/controllers/app-asset.controller.ts b/backend/services/admin-service/src/api/controllers/app-asset.controller.ts new file mode 100644 index 00000000..c51e64ae --- /dev/null +++ b/backend/services/admin-service/src/api/controllers/app-asset.controller.ts @@ -0,0 +1,262 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UploadedFile, + HttpCode, + HttpStatus, + UseInterceptors, + ParseFilePipe, + MaxFileSizeValidator, + FileTypeValidator, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common' +import { FileInterceptor } from '@nestjs/platform-express' +import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes, ApiBody, ApiQuery } from '@nestjs/swagger' +import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service' +import { FileStorageService } from '../../infrastructure/storage/file-storage.service' +import { AppAssetType } from '@prisma/client' + +// 图片最大 10MB +const MAX_IMAGE_SIZE = 10 * 1024 * 1024 + +// 每种类型的数量上限 +const ASSET_LIMITS: Record = { + SPLASH: 10, + GUIDE: 10, +} + +// ===== Response DTO ===== + +interface AppAssetResponseDto { + id: string + type: AppAssetType + sortOrder: number + imageUrl: string + title: string | null + subtitle: string | null + isEnabled: boolean + createdAt: Date + updatedAt: Date +} + +// ============================================================================= +// Admin Controller (需要认证) +// ============================================================================= + +@ApiTags('App Asset Management') +@Controller('admin/app-assets') +export class AdminAppAssetController { + private readonly logger = new Logger(AdminAppAssetController.name) + + constructor( + private readonly prisma: PrismaService, + private readonly fileStorage: FileStorageService, + ) {} + + @Get() + @ApiBearerAuth() + @ApiOperation({ summary: '查询应用资源列表' }) + @ApiQuery({ name: 'type', required: false, enum: AppAssetType }) + async list(@Query('type') type?: AppAssetType): Promise { + const where = type ? { type } : {} + const assets = await this.prisma.appAsset.findMany({ + where, + orderBy: [{ type: 'asc' }, { sortOrder: 'asc' }], + }) + return assets.map(this.toDto) + } + + @Post('upload') + @ApiBearerAuth() + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: '上传图片并创建/替换资源' }) + @ApiBody({ + schema: { + type: 'object', + required: ['file', 'type', 'sortOrder'], + properties: { + file: { type: 'string', format: 'binary' }, + type: { type: 'string', enum: ['SPLASH', 'GUIDE'] }, + sortOrder: { type: 'integer', minimum: 1 }, + title: { type: 'string' }, + subtitle: { type: 'string' }, + }, + }, + }) + async upload( + @UploadedFile( + new ParseFilePipe({ + validators: [ + new MaxFileSizeValidator({ maxSize: MAX_IMAGE_SIZE }), + new FileTypeValidator({ fileType: /^image\/(jpeg|jpg|png|webp)$/ }), + ], + fileIsRequired: true, + }), + ) + file: Express.Multer.File, + @Body('type') type: string, + @Body('sortOrder') sortOrderStr: string, + @Body('title') title?: string, + @Body('subtitle') subtitle?: string, + ): Promise { + // 校验 type + const assetType = type as AppAssetType + if (!Object.values(AppAssetType).includes(assetType)) { + throw new BadRequestException(`Invalid type: ${type}. Must be SPLASH or GUIDE`) + } + + // 校验 sortOrder + const sortOrder = parseInt(sortOrderStr, 10) + if (isNaN(sortOrder) || sortOrder < 1) { + throw new BadRequestException('sortOrder must be a positive integer') + } + + const limit = ASSET_LIMITS[assetType] + if (sortOrder > limit) { + throw new BadRequestException(`sortOrder for ${assetType} must be between 1 and ${limit}`) + } + + // 保存文件 + const uploadResult = await this.fileStorage.saveFile( + file.buffer, + file.originalname, + 'app-assets', + `${assetType.toLowerCase()}-${sortOrder}`, + ) + + this.logger.log( + `Uploaded app asset: type=${assetType}, sortOrder=${sortOrder}, url=${uploadResult.url}`, + ) + + // Upsert: 同 type+sortOrder 自动替换 + const asset = await this.prisma.appAsset.upsert({ + where: { + type_sortOrder: { type: assetType, sortOrder }, + }, + create: { + type: assetType, + sortOrder, + imageUrl: uploadResult.url, + title: title || null, + subtitle: subtitle || null, + isEnabled: true, + }, + update: { + imageUrl: uploadResult.url, + title: title !== undefined ? (title || null) : undefined, + subtitle: subtitle !== undefined ? (subtitle || null) : undefined, + }, + }) + + return this.toDto(asset) + } + + @Put(':id') + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '更新资源元数据 (标题/副标题/启停)' }) + async update( + @Param('id') id: string, + @Body() body: { title?: string; subtitle?: string; isEnabled?: boolean }, + ): Promise { + const existing = await this.prisma.appAsset.findUnique({ where: { id } }) + if (!existing) { + throw new NotFoundException('App asset not found') + } + + const data: Record = {} + if (body.title !== undefined) data.title = body.title || null + if (body.subtitle !== undefined) data.subtitle = body.subtitle || null + if (body.isEnabled !== undefined) data.isEnabled = body.isEnabled + + const updated = await this.prisma.appAsset.update({ + where: { id }, + data, + }) + + return this.toDto(updated) + } + + @Delete(':id') + @ApiBearerAuth() + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: '删除资源' }) + async delete(@Param('id') id: string): Promise { + const existing = await this.prisma.appAsset.findUnique({ where: { id } }) + if (!existing) { + throw new NotFoundException('App asset not found') + } + await this.prisma.appAsset.delete({ where: { id } }) + this.logger.log(`Deleted app asset: id=${id}, type=${existing.type}, sortOrder=${existing.sortOrder}`) + } + + private toDto(asset: { + id: string + type: AppAssetType + sortOrder: number + imageUrl: string + title: string | null + subtitle: string | null + isEnabled: boolean + createdAt: Date + updatedAt: Date + }): AppAssetResponseDto { + return { + id: asset.id, + type: asset.type, + sortOrder: asset.sortOrder, + imageUrl: asset.imageUrl, + title: asset.title, + subtitle: asset.subtitle, + isEnabled: asset.isEnabled, + createdAt: asset.createdAt, + updatedAt: asset.updatedAt, + } + } +} + +// ============================================================================= +// Public Controller (移动端调用,无需认证) +// ============================================================================= + +@ApiTags('App Assets (Public)') +@Controller('api/v1/app-assets') +export class PublicAppAssetController { + constructor(private readonly prisma: PrismaService) {} + + @Get() + @ApiOperation({ summary: '获取已启用的应用资源 (移动端)' }) + @ApiQuery({ name: 'type', required: false, enum: AppAssetType }) + async list(@Query('type') type?: AppAssetType): Promise { + const where: { isEnabled: boolean; type?: AppAssetType } = { isEnabled: true } + if (type && Object.values(AppAssetType).includes(type)) { + where.type = type + } + + const assets = await this.prisma.appAsset.findMany({ + where, + orderBy: [{ type: 'asc' }, { sortOrder: 'asc' }], + }) + + return assets.map((asset) => ({ + id: asset.id, + type: asset.type, + sortOrder: asset.sortOrder, + imageUrl: asset.imageUrl, + title: asset.title, + subtitle: asset.subtitle, + isEnabled: asset.isEnabled, + createdAt: asset.createdAt, + updatedAt: asset.updatedAt, + })) + } +} diff --git a/backend/services/admin-service/src/app.module.ts b/backend/services/admin-service/src/app.module.ts index cb7d227e..e918c3c2 100644 --- a/backend/services/admin-service/src/app.module.ts +++ b/backend/services/admin-service/src/app.module.ts @@ -76,6 +76,8 @@ import { SYSTEM_MAINTENANCE_REPOSITORY } from './domain/repositories/system-main import { SystemMaintenanceRepositoryImpl } from './infrastructure/persistence/repositories/system-maintenance.repository.impl'; import { AdminMaintenanceController, MobileMaintenanceController } from './api/controllers/system-maintenance.controller'; import { MaintenanceInterceptor } from './api/interceptors/maintenance.interceptor'; +// App Asset imports +import { AdminAppAssetController, PublicAppAssetController } from './api/controllers/app-asset.controller'; @Module({ imports: [ @@ -111,6 +113,9 @@ import { MaintenanceInterceptor } from './api/interceptors/maintenance.intercept // System Maintenance Controllers AdminMaintenanceController, MobileMaintenanceController, + // App Asset Controllers + AdminAppAssetController, + PublicAppAssetController, ], providers: [ PrismaService, diff --git a/frontend/admin-web/src/app/(dashboard)/settings/page.tsx b/frontend/admin-web/src/app/(dashboard)/settings/page.tsx index 5ee2167c..e6f736aa 100644 --- a/frontend/admin-web/src/app/(dashboard)/settings/page.tsx +++ b/frontend/admin-web/src/app/(dashboard)/settings/page.tsx @@ -5,6 +5,7 @@ import { PageContainer } from '@/components/layout'; import { cn } from '@/utils/helpers'; import styles from './settings.module.scss'; import { systemConfigService, type DisplaySettings } from '@/services/systemConfigService'; +import { appAssetService, type AppAsset, type AppAssetType } from '@/services/appAssetService'; /** * 后台账号数据接口 @@ -89,6 +90,12 @@ export default function SettingsPage() { const [displaySettingsSaving, setDisplaySettingsSaving] = useState(false); const [displaySettingsError, setDisplaySettingsError] = useState(null); + // 应用图片管理 + const [splashAssets, setSplashAssets] = useState([]); + const [guideAssets, setGuideAssets] = useState([]); + const [assetsLoading, setAssetsLoading] = useState(false); + const [uploadingSlot, setUploadingSlot] = useState(null); + // 后台账号与安全 const [approvalCount, setApprovalCount] = useState('3'); const [sensitiveOperations, setSensitiveOperations] = useState(['修改结算参数', '删除用户']); @@ -129,10 +136,80 @@ export default function SettingsPage() { } }, [allowNonAdopterView, heatDisplayMode]); + // 加载应用图片资源 + const loadAppAssets = useCallback(async () => { + setAssetsLoading(true); + try { + const assets = await appAssetService.list(); + setSplashAssets(assets.filter(a => a.type === 'SPLASH').sort((a, b) => a.sortOrder - b.sortOrder)); + setGuideAssets(assets.filter(a => a.type === 'GUIDE').sort((a, b) => a.sortOrder - b.sortOrder)); + } catch (error) { + console.error('Failed to load app assets:', error); + } finally { + setAssetsLoading(false); + } + }, []); + + // 上传图片 + const handleImageUpload = useCallback(async ( + type: AppAssetType, + sortOrder: number, + file: File, + title?: string, + subtitle?: string, + ) => { + const slotKey = `${type}-${sortOrder}`; + setUploadingSlot(slotKey); + try { + await appAssetService.upload(file, type, sortOrder, title, subtitle); + await loadAppAssets(); + alert(`${type === 'SPLASH' ? '开屏图' : '引导图'} ${sortOrder} 上传成功`); + } catch (error) { + console.error('Upload failed:', error); + alert('上传失败,请重试'); + } finally { + setUploadingSlot(null); + } + }, [loadAppAssets]); + + // 切换启停 + const handleAssetToggle = useCallback(async (id: string, isEnabled: boolean) => { + try { + await appAssetService.update(id, { isEnabled }); + await loadAppAssets(); + } catch (error) { + console.error('Toggle failed:', error); + } + }, [loadAppAssets]); + + // 删除资源 + const handleAssetDelete = useCallback(async (id: string) => { + if (!confirm('确定要删除此图片吗?')) return; + try { + await appAssetService.delete(id); + await loadAppAssets(); + } catch (error) { + console.error('Delete failed:', error); + } + }, [loadAppAssets]); + + // 更新引导图文案 + const handleGuideTextUpdate = useCallback(async (id: string, title: string, subtitle: string) => { + try { + await appAssetService.update(id, { title, subtitle }); + await loadAppAssets(); + alert('文案更新成功'); + } catch (error) { + console.error('Update failed:', error); + alert('更新失败,请重试'); + } + }, [loadAppAssets]); + // 组件挂载时加载展示设置 useEffect(() => { loadDisplaySettings(); - }, [loadDisplaySettings]); + loadAppAssets(); + }, [loadDisplaySettings, loadAppAssets]); // 切换货币选择 const toggleCurrency = (currency: string) => { @@ -461,6 +538,160 @@ export default function SettingsPage() { + {/* 应用图片管理 */} +
+
+

应用图片管理

+ +
+
+ {/* 开屏图 */} +
+ +
+ {[1, 2].map((sortOrder) => { + const asset = splashAssets.find(a => a.sortOrder === sortOrder); + const slotKey = `SPLASH-${sortOrder}`; + const isUploading = uploadingSlot === slotKey; + return ( +
+
开屏图 {sortOrder}
+
+ {asset ? ( + {`开屏图 + ) : ( + 未上传 + )} +
+
+ + {asset && ( + <> + handleAssetToggle(asset.id, v)} + size="small" + /> + + + )} +
+
+ ); + })} +
+
+ + {/* 引导图 */} +
+ +
+ {[1, 2, 3, 4, 5].map((sortOrder) => { + const asset = guideAssets.find(a => a.sortOrder === sortOrder); + const slotKey = `GUIDE-${sortOrder}`; + const isUploading = uploadingSlot === slotKey; + return ( +
+
引导页 {sortOrder}
+
+ {asset ? ( + {`引导页 + ) : ( + 未上传 + )} +
+ {asset && ( +
+ { + if (e.target.value !== (asset.title || '')) { + handleGuideTextUpdate(asset.id, e.target.value, asset.subtitle || ''); + } + }} + /> + { + if (e.target.value !== (asset.subtitle || '')) { + handleGuideTextUpdate(asset.id, asset.title || '', e.target.value); + } + }} + /> +
+ )} +
+ + {asset && ( + <> + handleAssetToggle(asset.id, v)} + size="small" + /> + + + )} +
+
+ ); + })} +
+
+ + 提示:上传新图片会替换同位置的旧图片。图片修改后用户下次启动应用时生效。未上传的位置将使用应用内置的默认图片。 + +
+
+ {/* 后台账号与安全 */}
diff --git a/frontend/admin-web/src/app/(dashboard)/settings/settings.module.scss b/frontend/admin-web/src/app/(dashboard)/settings/settings.module.scss index 545408f7..d575731a 100644 --- a/frontend/admin-web/src/app/(dashboard)/settings/settings.module.scss +++ b/frontend/admin-web/src/app/(dashboard)/settings/settings.module.scss @@ -1150,6 +1150,72 @@ } } +/* 应用图片管理 */ +.settings__imageGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; + width: 100%; +} + +.settings__imageSlot { + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + background: #fafafa; +} + +.settings__imageSlotLabel { + font-size: 13px; + font-weight: 600; + color: #374151; +} + +.settings__imagePreview { + width: 100%; + aspect-ratio: 9 / 16; + border-radius: 4px; + overflow: hidden; + background: #f3f4f6; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.settings__imageSlotActions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.settings__uploadLabel { + cursor: pointer; + font-size: 12px; + line-height: 16px; + color: #0061a8; + font-weight: 500; + + &:hover { + text-decoration: underline; + } +} + +.settings__guideTextFields { + display: flex; + flex-direction: column; + gap: 6px; +} + /* 页面底部操作区 */ .settings__footer { width: 100%; diff --git a/frontend/admin-web/src/infrastructure/api/endpoints.ts b/frontend/admin-web/src/infrastructure/api/endpoints.ts index fb7ee61d..744123c6 100644 --- a/frontend/admin-web/src/infrastructure/api/endpoints.ts +++ b/frontend/admin-web/src/infrastructure/api/endpoints.ts @@ -253,4 +253,12 @@ export const API_ENDPOINTS = { // [2026-01-07] 新增:过期收益明细列表 EXPIRED_REWARDS_ENTRIES: '/v1/system-account-reports/expired-rewards-entries', }, + + // 应用图片资源管理 (admin-service) + APP_ASSETS: { + LIST: '/v1/admin/app-assets', + UPLOAD: '/v1/admin/app-assets/upload', + UPDATE: (id: string) => `/v1/admin/app-assets/${id}`, + DELETE: (id: string) => `/v1/admin/app-assets/${id}`, + }, } as const; diff --git a/frontend/admin-web/src/services/appAssetService.ts b/frontend/admin-web/src/services/appAssetService.ts new file mode 100644 index 00000000..481ff30f --- /dev/null +++ b/frontend/admin-web/src/services/appAssetService.ts @@ -0,0 +1,59 @@ +import apiClient from '@/infrastructure/api/client'; +import { API_ENDPOINTS } from '@/infrastructure/api/endpoints'; + +export type AppAssetType = 'SPLASH' | 'GUIDE'; + +export interface AppAsset { + id: string; + type: AppAssetType; + sortOrder: number; + imageUrl: string; + title: string | null; + subtitle: string | null; + isEnabled: boolean; + createdAt: string; + updatedAt: string; +} + +export const appAssetService = { + /** 查询资源列表 */ + async list(type?: AppAssetType): Promise { + const params = type ? { type } : {}; + return apiClient.get(API_ENDPOINTS.APP_ASSETS.LIST, { params }); + }, + + /** 上传图片并创建/替换资源 */ + async upload( + file: File, + type: AppAssetType, + sortOrder: number, + title?: string, + subtitle?: string, + ): Promise { + const formData = new FormData(); + formData.append('file', file); + formData.append('type', type); + formData.append('sortOrder', String(sortOrder)); + if (title) formData.append('title', title); + if (subtitle) formData.append('subtitle', subtitle); + + return apiClient.post(API_ENDPOINTS.APP_ASSETS.UPLOAD, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + }, + + /** 更新资源元数据 */ + async update( + id: string, + data: { title?: string; subtitle?: string; isEnabled?: boolean }, + ): Promise { + return apiClient.put(API_ENDPOINTS.APP_ASSETS.UPDATE(id), data); + }, + + /** 删除资源 */ + async delete(id: string): Promise { + return apiClient.delete(API_ENDPOINTS.APP_ASSETS.DELETE(id)); + }, +}; + +export default appAssetService; diff --git a/frontend/mobile-app/assets/images/splash_static/splash_1.jpg b/frontend/mobile-app/assets/images/splash_static/splash_1.jpg index 529d4f12..d127d866 100644 Binary files a/frontend/mobile-app/assets/images/splash_static/splash_1.jpg and b/frontend/mobile-app/assets/images/splash_static/splash_1.jpg differ diff --git a/frontend/mobile-app/assets/images/splash_static/splash_2.jpg b/frontend/mobile-app/assets/images/splash_static/splash_2.jpg index 71635354..ed650833 100644 Binary files a/frontend/mobile-app/assets/images/splash_static/splash_2.jpg and b/frontend/mobile-app/assets/images/splash_static/splash_2.jpg differ diff --git a/frontend/mobile-app/lib/core/di/injection_container.dart b/frontend/mobile-app/lib/core/di/injection_container.dart index d3c23c4c..00eac821 100644 --- a/frontend/mobile-app/lib/core/di/injection_container.dart +++ b/frontend/mobile-app/lib/core/di/injection_container.dart @@ -12,6 +12,7 @@ import '../services/planting_service.dart'; import '../services/reward_service.dart'; import '../services/notification_service.dart'; import '../services/system_config_service.dart'; +import '../services/app_asset_service.dart'; import '../services/leaderboard_service.dart'; import '../services/contract_signing_service.dart'; import '../services/contract_check_service.dart'; @@ -111,6 +112,13 @@ final systemConfigServiceProvider = Provider((ref) { return SystemConfigService(apiClient: apiClient); }); +// App Asset Service Provider (调用 admin-service) +final appAssetServiceProvider = Provider((ref) { + final apiClient = ref.watch(apiClientProvider); + final localStorage = ref.watch(localStorageProvider); + return AppAssetService(apiClient: apiClient, localStorage: localStorage); +}); + // Leaderboard Service Provider (调用 leaderboard-service) final leaderboardServiceProvider = Provider((ref) { final apiClient = ref.watch(apiClientProvider); diff --git a/frontend/mobile-app/lib/core/services/app_asset_service.dart b/frontend/mobile-app/lib/core/services/app_asset_service.dart new file mode 100644 index 00000000..6dd1b96c --- /dev/null +++ b/frontend/mobile-app/lib/core/services/app_asset_service.dart @@ -0,0 +1,197 @@ +import 'dart:convert'; +import 'dart:math'; +import 'package:flutter/foundation.dart'; +import '../network/api_client.dart'; +import '../storage/local_storage.dart'; +import '../storage/storage_keys.dart'; + +/// 应用资源数据模型 +class AppAssetData { + final String id; + final String type; // 'SPLASH' | 'GUIDE' + final int sortOrder; + final String imageUrl; + final String? title; + final String? subtitle; + + const AppAssetData({ + required this.id, + required this.type, + required this.sortOrder, + required this.imageUrl, + this.title, + this.subtitle, + }); + + factory AppAssetData.fromJson(Map json) { + return AppAssetData( + id: json['id'] as String, + type: json['type'] as String, + sortOrder: json['sortOrder'] as int, + imageUrl: json['imageUrl'] as String, + title: json['title'] as String?, + subtitle: json['subtitle'] as String?, + ); + } + + Map toJson() => { + 'id': id, + 'type': type, + 'sortOrder': sortOrder, + 'imageUrl': imageUrl, + 'title': title, + 'subtitle': subtitle, + }; +} + +/// 应用资源配置(包含开屏图和引导图) +class AppAssetConfig { + final List splashImages; + final List guideImages; + + const AppAssetConfig({ + required this.splashImages, + required this.guideImages, + }); + + /// 从 API 响应列表解析 + factory AppAssetConfig.fromList(List list) { + final assets = list.map((e) => AppAssetData.fromJson(e as Map)).toList(); + return AppAssetConfig( + splashImages: assets.where((a) => a.type == 'SPLASH').toList() + ..sort((a, b) => a.sortOrder.compareTo(b.sortOrder)), + guideImages: assets.where((a) => a.type == 'GUIDE').toList() + ..sort((a, b) => a.sortOrder.compareTo(b.sortOrder)), + ); + } + + bool get hasSplashImages => splashImages.isNotEmpty; + bool get hasGuideImages => guideImages.isNotEmpty; + + /// 随机获取一个开屏图 URL + String? get randomSplashImageUrl { + if (splashImages.isEmpty) return null; + return splashImages[Random().nextInt(splashImages.length)].imageUrl; + } + + /// 序列化为 JSON 字符串 + String toJsonString() { + final list = [...splashImages, ...guideImages].map((e) => e.toJson()).toList(); + return jsonEncode(list); + } + + /// 从 JSON 字符串反序列化 + static AppAssetConfig? fromJsonString(String? jsonStr) { + if (jsonStr == null || jsonStr.isEmpty) return null; + try { + final list = jsonDecode(jsonStr) as List; + return AppAssetConfig.fromList(list); + } catch (e) { + debugPrint('[AppAssetService] 反序列化缓存失败: $e'); + return null; + } + } +} + +/// 应用资源服务 +/// +/// 负责从后端获取开屏图和引导图配置。 +/// 策略: +/// 1. 优先使用 SharedPreferences 中的持久化缓存 +/// 2. 后台静默刷新,成功后更新缓存 +/// 3. 缓存为空时调用方回退到本地 asset +/// 4. 图片变更在用户下次启动应用时生效 +class AppAssetService { + final ApiClient _apiClient; + final LocalStorage _localStorage; + + /// 内存缓存 + AppAssetConfig? _memoryCache; + + /// 缓存有效期 + static const Duration _cacheExpiration = Duration(minutes: 5); + + /// 上次网络请求时间 + DateTime? _lastFetchTime; + + AppAssetService({ + required ApiClient apiClient, + required LocalStorage localStorage, + }) : _apiClient = apiClient, + _localStorage = localStorage; + + /// 获取应用资源配置 + /// + /// 返回顺序:内存缓存 → 持久化缓存 → null(调用方回退到本地 asset) + /// 同时在后台静默刷新数据。 + Future getConfig({bool forceRefresh = false}) async { + // 1. 检查内存缓存 + if (!forceRefresh && _memoryCache != null && _lastFetchTime != null) { + final cacheAge = DateTime.now().difference(_lastFetchTime!); + if (cacheAge < _cacheExpiration) { + return _memoryCache; + } + } + + // 2. 从 SharedPreferences 加载 + if (_memoryCache == null) { + try { + final cachedJson = _localStorage.getString(StorageKeys.cachedAppAssets); + final cached = AppAssetConfig.fromJsonString(cachedJson); + if (cached != null) { + _memoryCache = cached; + debugPrint('[AppAssetService] 从持久化缓存加载: ' + '${cached.splashImages.length} splash, ' + '${cached.guideImages.length} guide'); + } + } catch (e) { + debugPrint('[AppAssetService] 读取持久化缓存失败: $e'); + } + } + + // 3. 后台静默刷新 + _refreshInBackground(); + + return _memoryCache; + } + + /// 后台静默刷新 + void _refreshInBackground() { + _fetchFromApi().then((config) { + if (config != null) { + _memoryCache = config; + _lastFetchTime = DateTime.now(); + // 持久化 + _localStorage.setString( + StorageKeys.cachedAppAssets, + config.toJsonString(), + ); + debugPrint('[AppAssetService] 后台刷新成功: ' + '${config.splashImages.length} splash, ' + '${config.guideImages.length} guide'); + } + }).catchError((e) { + debugPrint('[AppAssetService] 后台刷新失败: $e'); + }); + } + + /// 从 API 获取数据 + Future _fetchFromApi() async { + try { + final response = await _apiClient.get( + '/admin-service/api/v1/app-assets', + ); + if (response.data is List) { + return AppAssetConfig.fromList(response.data as List); + } + // 如果返回的是 Map 且有 data 字段 + if (response.data is Map && response.data['data'] is List) { + return AppAssetConfig.fromList(response.data['data'] as List); + } + return null; + } catch (e) { + debugPrint('[AppAssetService] API 请求失败: $e'); + return null; + } + } +} diff --git a/frontend/mobile-app/lib/core/storage/storage_keys.dart b/frontend/mobile-app/lib/core/storage/storage_keys.dart index 4d8771e1..36add575 100644 --- a/frontend/mobile-app/lib/core/storage/storage_keys.dart +++ b/frontend/mobile-app/lib/core/storage/storage_keys.dart @@ -49,6 +49,7 @@ class StorageKeys { static const String lastSyncTime = 'last_sync_time'; static const String cachedRankingData = 'cached_ranking_data'; static const String cachedMiningStatus = 'cached_mining_status'; + static const String cachedAppAssets = 'cached_app_assets'; // ===== 已废弃 (保留用于迁移旧数据) ===== @Deprecated('Use userSerialNum instead') diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/guide_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/guide_page.dart index 5cbae11e..37646d67 100644 --- a/frontend/mobile-app/lib/features/auth/presentation/pages/guide_page.dart +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/guide_page.dart @@ -5,6 +5,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import '../../../../core/di/injection_container.dart'; import '../../../../core/storage/storage_keys.dart'; import '../../../../core/providers/maintenance_provider.dart'; @@ -14,13 +15,15 @@ import 'phone_register_page.dart'; /// 向导页数据模型 class GuidePageData { - final String? imagePath; + final String? imagePath; // 本地 asset 路径 (兜底) + final String? remoteImageUrl; // 远程图片 URL (优先) final String title; final String subtitle; final Widget? customContent; const GuidePageData({ this.imagePath, + this.remoteImageUrl, required this.title, required this.subtitle, this.customContent, @@ -49,6 +52,10 @@ class _GuidePageState extends ConsumerState { // 使用传入的初始页面索引 _currentPage = widget.initialPage.clamp(0, 4); _pageController = PageController(initialPage: _currentPage); + + // 尝试加载远程引导图配置(非阻塞) + _loadRemoteGuideConfig(); + // 延迟到 build 后获取屏幕信息 WidgetsBinding.instance.addPostFrameCallback((_) { _logScreenInfo(); @@ -71,9 +78,37 @@ class _GuidePageState extends ConsumerState { debugPrint('[GuidePage] ================================'); } + /// 加载远程引导图配置 + /// 从缓存中读取远程配置,按 sortOrder 覆盖对应页的图片和文案。 + Future _loadRemoteGuideConfig() async { + try { + final appAssetService = ref.read(appAssetServiceProvider); + final config = await appAssetService.getConfig(); + if (config != null && config.hasGuideImages && mounted) { + setState(() { + for (final remote in config.guideImages) { + final index = remote.sortOrder - 1; // sortOrder 从 1 开始 + if (index >= 0 && index < _guidePages.length) { + _guidePages[index] = GuidePageData( + imagePath: _guidePages[index].imagePath, // 保留本地路径作为兜底 + remoteImageUrl: remote.imageUrl, + title: remote.title ?? _guidePages[index].title, + subtitle: remote.subtitle ?? _guidePages[index].subtitle, + ); + } + } + }); + debugPrint('[GuidePage] 远程引导图加载成功: ${config.guideImages.length} 张'); + } + } catch (e) { + debugPrint('[GuidePage] 加载远程引导图失败: $e'); + } + } + // 向导页1-5的数据 (第5页为欢迎加入页) // 支持 png、jpg、webp 等格式 - final List _guidePages = const [ + // 远程配置加载后会动态覆盖图片和文案 + final List _guidePages = [ GuidePageData( imagePath: 'assets/images/guide_1.jpg', title: '认种一棵榴莲树\n拥有真实RWA资产', @@ -151,8 +186,28 @@ class _GuidePageState extends ConsumerState { return Stack( fit: StackFit.expand, children: [ - // 全屏背景图片 - 使用 cover 填满屏幕 - if (data.imagePath != null) + // 全屏背景图片 - 优先远程图片,兜底本地 asset + if (data.remoteImageUrl != null) + CachedNetworkImage( + imageUrl: data.remoteImageUrl!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + placeholder: (context, url) => data.imagePath != null + ? Image.asset(data.imagePath!, fit: BoxFit.cover, + width: double.infinity, height: double.infinity) + : Container(color: const Color(0xFFFFF8E7), + child: _buildPlaceholderImage(index)), + errorWidget: (context, url, error) { + debugPrint('[GuidePage] 页面 ${index + 1} 远程图片加载失败: $error'); + return data.imagePath != null + ? Image.asset(data.imagePath!, fit: BoxFit.cover, + width: double.infinity, height: double.infinity) + : Container(color: const Color(0xFFFFF8E7), + child: _buildPlaceholderImage(index)); + }, + ) + else if (data.imagePath != null) Image.asset( data.imagePath!, fit: BoxFit.cover, diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/splash_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/splash_page.dart index 2dac2866..668e1de0 100644 --- a/frontend/mobile-app/lib/features/auth/presentation/pages/splash_page.dart +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/splash_page.dart @@ -2,11 +2,12 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import '../../../../routes/route_paths.dart'; import '../../../../bootstrap.dart'; import '../../../../core/providers/maintenance_provider.dart'; import '../../../../core/di/injection_container.dart'; -import '../../../../core/services/pending_action_polling_service.dart'; +import '../../../../core/services/pending_action_polling_service.dart'; // ignore: unused_import import '../../../../routes/app_router.dart'; import '../providers/auth_provider.dart'; @@ -50,6 +51,9 @@ class _SplashPageState extends ConsumerState { // ========== 通用状态 ========== + /// 远程开屏图 URL (从缓存获取,不阻塞启动) + String? _remoteSplashUrl; + /// 是否显示跳过按钮 bool _showSkipButton = false; @@ -65,6 +69,8 @@ class _SplashPageState extends ConsumerState { if (_useStaticImage) { _initializeStaticImage(); + // 尝试加载远程开屏图配置(非阻塞) + _loadRemoteSplashConfig(); } else { _initializeFrames(); } @@ -166,6 +172,23 @@ class _SplashPageState extends ConsumerState { } } + /// 加载远程开屏图配置 + /// 优先从 SharedPreferences 持久化缓存读取,不阻塞启动流程。 + Future _loadRemoteSplashConfig() async { + try { + final appAssetService = ref.read(appAssetServiceProvider); + final config = await appAssetService.getConfig(); + if (config != null && config.hasSplashImages && mounted) { + setState(() { + _remoteSplashUrl = config.randomSplashImageUrl; + }); + debugPrint('[SplashPage] 远程开屏图: $_remoteSplashUrl'); + } + } catch (e) { + debugPrint('[SplashPage] 加载远程开屏图失败: $e'); + } + } + /// 跳过动画/等待 void _skipAnimation() { _isPlaying = false; @@ -294,11 +317,34 @@ class _SplashPageState extends ConsumerState { } /// 构建静态图片视图 + /// 优先使用远程图片(CachedNetworkImage),失败时回退到本地 asset。 Widget _buildStaticImage() { + final localAssetPath = 'assets/images/splash_static/splash_$_staticImageIndex.jpg'; + + if (_remoteSplashUrl != null) { + return SizedBox.expand( + child: CachedNetworkImage( + imageUrl: _remoteSplashUrl!, + fit: BoxFit.contain, + placeholder: (context, url) => Image.asset( + localAssetPath, + fit: BoxFit.contain, + ), + errorWidget: (context, url, error) { + debugPrint('[SplashPage] 远程图片加载失败: $error,回退到本地'); + return Image.asset( + localAssetPath, + fit: BoxFit.contain, + ); + }, + ), + ); + } + return SizedBox.expand( child: Image.asset( - 'assets/images/splash_static/splash_$_staticImageIndex.jpg', - fit: BoxFit.contain, // 不拉伸,保持原比例,黑边填充 + localAssetPath, + fit: BoxFit.contain, ), ); }