feat(app-assets): 应用图片管理 — 开屏图/引导图可从 admin-web 配置

新增从 admin-web 后台管理开屏图(2张)和引导图(5张+标题/副标题)的完整功能链路。
移动端优先使用后台配置的远程图片,无配置或加载失败时自动回退到本地 asset。

### Backend (admin-service)
- Prisma schema 新增 AppAssetType 枚举 + AppAsset 模型 (type/sortOrder 唯一约束)
- 新增 AdminAppAssetController: 图片上传(multipart)、列表查询、元数据更新、删除
- 新增 PublicAppAssetController: 公开查询接口供移动端消费 (仅返回 isEnabled=true)
- 新增数据库 migration: 20260204100000_add_app_assets

### Admin-web
- endpoints.ts 新增 APP_ASSETS 端点组
- 新增 appAssetService.ts: list/upload/update/delete 方法
- Settings 页新增"应用图片管理"区块: 开屏图 2 卡槽 + 引导图 5 卡槽
- 每个卡槽支持: 图片上传预览、启用/禁用开关、删除、引导图额外支持标题和副标题编辑

### Mobile-app (Flutter)
- 新增 AppAssetService: 3 级缓存策略 (内存 5min TTL → SharedPreferences → 后台静默刷新)
- splash_page.dart: 支持远程开屏图 (CachedNetworkImage),fallback 到本地 asset
- guide_page.dart: 支持远程引导图+标题/副标题覆盖,fallback 到本地 asset
- 替换 2 张开屏图为新版 (1280x1826/1834, ~245KB)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-04 11:20:26 -08:00
parent c7978f6fb5
commit d075853a7f
15 changed files with 1007 additions and 8 deletions

View File

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

View File

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

View File

@ -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<AppAssetType, number> = {
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<AppAssetResponseDto[]> {
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<AppAssetResponseDto> {
// 校验 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<AppAssetResponseDto> {
const existing = await this.prisma.appAsset.findUnique({ where: { id } })
if (!existing) {
throw new NotFoundException('App asset not found')
}
const data: Record<string, unknown> = {}
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<void> {
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<AppAssetResponseDto[]> {
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,
}))
}
}

View File

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

View File

@ -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<string | null>(null);
// 应用图片管理
const [splashAssets, setSplashAssets] = useState<AppAsset[]>([]);
const [guideAssets, setGuideAssets] = useState<AppAsset[]>([]);
const [assetsLoading, setAssetsLoading] = useState(false);
const [uploadingSlot, setUploadingSlot] = useState<string | null>(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() {
</div>
</section>
{/* 应用图片管理 */}
<section className={styles.settings__section}>
<div className={styles.settings__sectionHeader}>
<h2 className={styles.settings__sectionTitle}></h2>
<button
className={styles.settings__resetBtn}
onClick={loadAppAssets}
disabled={assetsLoading}
>
{assetsLoading ? '加载中...' : '刷新'}
</button>
</div>
<div className={styles.settings__content}>
{/* 开屏图 */}
<div className={styles.settings__fieldGroup}>
<label className={styles.settings__label}>2</label>
<div className={styles.settings__imageGrid}>
{[1, 2].map((sortOrder) => {
const asset = splashAssets.find(a => a.sortOrder === sortOrder);
const slotKey = `SPLASH-${sortOrder}`;
const isUploading = uploadingSlot === slotKey;
return (
<div key={slotKey} className={styles.settings__imageSlot}>
<div className={styles.settings__imageSlotLabel}> {sortOrder}</div>
<div className={styles.settings__imagePreview}>
{asset ? (
<img src={asset.imageUrl} alt={`开屏图 ${sortOrder}`} />
) : (
<span style={{ color: '#9ca3af', fontSize: 13 }}></span>
)}
</div>
<div className={styles.settings__imageSlotActions}>
<label className={styles.settings__uploadLabel}>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
style={{ display: 'none' }}
disabled={isUploading}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleImageUpload('SPLASH', sortOrder, file);
e.target.value = '';
}}
/>
{isUploading ? '上传中...' : (asset ? '替换' : '上传')}
</label>
{asset && (
<>
<Toggle
checked={asset.isEnabled}
onChange={(v) => handleAssetToggle(asset.id, v)}
size="small"
/>
<button
className={cn(styles.settings__actionLink, styles['settings__actionLink--danger'])}
onClick={() => handleAssetDelete(asset.id)}
>
</button>
</>
)}
</div>
</div>
);
})}
</div>
</div>
{/* 引导图 */}
<div className={styles.settings__fieldGroup}>
<label className={styles.settings__label}>5</label>
<div className={styles.settings__imageGrid}>
{[1, 2, 3, 4, 5].map((sortOrder) => {
const asset = guideAssets.find(a => a.sortOrder === sortOrder);
const slotKey = `GUIDE-${sortOrder}`;
const isUploading = uploadingSlot === slotKey;
return (
<div key={slotKey} className={styles.settings__imageSlot}>
<div className={styles.settings__imageSlotLabel}> {sortOrder}</div>
<div className={styles.settings__imagePreview}>
{asset ? (
<img src={asset.imageUrl} alt={`引导页 ${sortOrder}`} />
) : (
<span style={{ color: '#9ca3af', fontSize: 13 }}></span>
)}
</div>
{asset && (
<div className={styles.settings__guideTextFields}>
<input
type="text"
className={styles.settings__input}
placeholder="标题"
defaultValue={asset.title || ''}
onBlur={(e) => {
if (e.target.value !== (asset.title || '')) {
handleGuideTextUpdate(asset.id, e.target.value, asset.subtitle || '');
}
}}
/>
<input
type="text"
className={styles.settings__input}
placeholder="副标题"
defaultValue={asset.subtitle || ''}
onBlur={(e) => {
if (e.target.value !== (asset.subtitle || '')) {
handleGuideTextUpdate(asset.id, asset.title || '', e.target.value);
}
}}
/>
</div>
)}
<div className={styles.settings__imageSlotActions}>
<label className={styles.settings__uploadLabel}>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
style={{ display: 'none' }}
disabled={isUploading}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleImageUpload('GUIDE', sortOrder, file);
e.target.value = '';
}}
/>
{isUploading ? '上传中...' : (asset ? '替换' : '上传')}
</label>
{asset && (
<>
<Toggle
checked={asset.isEnabled}
onChange={(v) => handleAssetToggle(asset.id, v)}
size="small"
/>
<button
className={cn(styles.settings__actionLink, styles['settings__actionLink--danger'])}
onClick={() => handleAssetDelete(asset.id)}
>
</button>
</>
)}
</div>
</div>
);
})}
</div>
</div>
<span className={styles.settings__hint}>
使
</span>
</div>
</section>
{/* 后台账号与安全 */}
<section className={styles.settings__section}>
<div className={styles.settings__sectionHeader}>

View File

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

View File

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

View File

@ -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<AppAsset[]> {
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<AppAsset> {
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<AppAsset> {
return apiClient.put(API_ENDPOINTS.APP_ASSETS.UPDATE(id), data);
},
/** 删除资源 */
async delete(id: string): Promise<void> {
return apiClient.delete(API_ENDPOINTS.APP_ASSETS.DELETE(id));
},
};
export default appAssetService;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 244 KiB

View File

@ -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<SystemConfigService>((ref) {
return SystemConfigService(apiClient: apiClient);
});
// App Asset Service Provider ( admin-service)
final appAssetServiceProvider = Provider<AppAssetService>((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<LeaderboardService>((ref) {
final apiClient = ref.watch(apiClientProvider);

View File

@ -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<String, dynamic> 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<String, dynamic> toJson() => {
'id': id,
'type': type,
'sortOrder': sortOrder,
'imageUrl': imageUrl,
'title': title,
'subtitle': subtitle,
};
}
///
class AppAssetConfig {
final List<AppAssetData> splashImages;
final List<AppAssetData> guideImages;
const AppAssetConfig({
required this.splashImages,
required this.guideImages,
});
/// API
factory AppAssetConfig.fromList(List<dynamic> list) {
final assets = list.map((e) => AppAssetData.fromJson(e as Map<String, dynamic>)).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<dynamic>;
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<AppAssetConfig?> 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<AppAssetConfig?> _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;
}
}
}

View File

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

View File

@ -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<GuidePage> {
// 使
_currentPage = widget.initialPage.clamp(0, 4);
_pageController = PageController(initialPage: _currentPage);
//
_loadRemoteGuideConfig();
// build
WidgetsBinding.instance.addPostFrameCallback((_) {
_logScreenInfo();
@ -71,9 +78,37 @@ class _GuidePageState extends ConsumerState<GuidePage> {
debugPrint('[GuidePage] ================================');
}
///
/// sortOrder
Future<void> _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)
// pngjpgwebp
final List<GuidePageData> _guidePages = const [
//
final List<GuidePageData> _guidePages = [
GuidePageData(
imagePath: 'assets/images/guide_1.jpg',
title: '认种一棵榴莲树\n拥有真实RWA资产',
@ -151,8 +186,28 @@ class _GuidePageState extends ConsumerState<GuidePage> {
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,

View File

@ -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<SplashPage> {
// ========== ==========
/// URL ()
String? _remoteSplashUrl;
///
bool _showSkipButton = false;
@ -65,6 +69,8 @@ class _SplashPageState extends ConsumerState<SplashPage> {
if (_useStaticImage) {
_initializeStaticImage();
//
_loadRemoteSplashConfig();
} else {
_initializeFrames();
}
@ -166,6 +172,23 @@ class _SplashPageState extends ConsumerState<SplashPage> {
}
}
///
/// SharedPreferences
Future<void> _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<SplashPage> {
}
///
/// 使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,
),
);
}