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:
parent
c7978f6fb5
commit
d075853a7f
|
|
@ -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");
|
||||||
|
|
@ -1150,3 +1150,30 @@ model CoManagedWallet {
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("co_managed_wallets")
|
@@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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -76,6 +76,8 @@ import { SYSTEM_MAINTENANCE_REPOSITORY } from './domain/repositories/system-main
|
||||||
import { SystemMaintenanceRepositoryImpl } from './infrastructure/persistence/repositories/system-maintenance.repository.impl';
|
import { SystemMaintenanceRepositoryImpl } from './infrastructure/persistence/repositories/system-maintenance.repository.impl';
|
||||||
import { AdminMaintenanceController, MobileMaintenanceController } from './api/controllers/system-maintenance.controller';
|
import { AdminMaintenanceController, MobileMaintenanceController } from './api/controllers/system-maintenance.controller';
|
||||||
import { MaintenanceInterceptor } from './api/interceptors/maintenance.interceptor';
|
import { MaintenanceInterceptor } from './api/interceptors/maintenance.interceptor';
|
||||||
|
// App Asset imports
|
||||||
|
import { AdminAppAssetController, PublicAppAssetController } from './api/controllers/app-asset.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -111,6 +113,9 @@ import { MaintenanceInterceptor } from './api/interceptors/maintenance.intercept
|
||||||
// System Maintenance Controllers
|
// System Maintenance Controllers
|
||||||
AdminMaintenanceController,
|
AdminMaintenanceController,
|
||||||
MobileMaintenanceController,
|
MobileMaintenanceController,
|
||||||
|
// App Asset Controllers
|
||||||
|
AdminAppAssetController,
|
||||||
|
PublicAppAssetController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
PrismaService,
|
PrismaService,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { PageContainer } from '@/components/layout';
|
||||||
import { cn } from '@/utils/helpers';
|
import { cn } from '@/utils/helpers';
|
||||||
import styles from './settings.module.scss';
|
import styles from './settings.module.scss';
|
||||||
import { systemConfigService, type DisplaySettings } from '@/services/systemConfigService';
|
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 [displaySettingsSaving, setDisplaySettingsSaving] = useState(false);
|
||||||
const [displaySettingsError, setDisplaySettingsError] = useState<string | null>(null);
|
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 [approvalCount, setApprovalCount] = useState('3');
|
||||||
const [sensitiveOperations, setSensitiveOperations] = useState(['修改结算参数', '删除用户']);
|
const [sensitiveOperations, setSensitiveOperations] = useState(['修改结算参数', '删除用户']);
|
||||||
|
|
@ -129,10 +136,80 @@ export default function SettingsPage() {
|
||||||
}
|
}
|
||||||
}, [allowNonAdopterView, heatDisplayMode]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
loadDisplaySettings();
|
loadDisplaySettings();
|
||||||
}, [loadDisplaySettings]);
|
loadAppAssets();
|
||||||
|
}, [loadDisplaySettings, loadAppAssets]);
|
||||||
|
|
||||||
// 切换货币选择
|
// 切换货币选择
|
||||||
const toggleCurrency = (currency: string) => {
|
const toggleCurrency = (currency: string) => {
|
||||||
|
|
@ -461,6 +538,160 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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}>
|
<section className={styles.settings__section}>
|
||||||
<div className={styles.settings__sectionHeader}>
|
<div className={styles.settings__sectionHeader}>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
.settings__footer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
||||||
|
|
@ -253,4 +253,12 @@ export const API_ENDPOINTS = {
|
||||||
// [2026-01-07] 新增:过期收益明细列表
|
// [2026-01-07] 新增:过期收益明细列表
|
||||||
EXPIRED_REWARDS_ENTRIES: '/v1/system-account-reports/expired-rewards-entries',
|
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;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -12,6 +12,7 @@ import '../services/planting_service.dart';
|
||||||
import '../services/reward_service.dart';
|
import '../services/reward_service.dart';
|
||||||
import '../services/notification_service.dart';
|
import '../services/notification_service.dart';
|
||||||
import '../services/system_config_service.dart';
|
import '../services/system_config_service.dart';
|
||||||
|
import '../services/app_asset_service.dart';
|
||||||
import '../services/leaderboard_service.dart';
|
import '../services/leaderboard_service.dart';
|
||||||
import '../services/contract_signing_service.dart';
|
import '../services/contract_signing_service.dart';
|
||||||
import '../services/contract_check_service.dart';
|
import '../services/contract_check_service.dart';
|
||||||
|
|
@ -111,6 +112,13 @@ final systemConfigServiceProvider = Provider<SystemConfigService>((ref) {
|
||||||
return SystemConfigService(apiClient: apiClient);
|
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)
|
// Leaderboard Service Provider (调用 leaderboard-service)
|
||||||
final leaderboardServiceProvider = Provider<LeaderboardService>((ref) {
|
final leaderboardServiceProvider = Provider<LeaderboardService>((ref) {
|
||||||
final apiClient = ref.watch(apiClientProvider);
|
final apiClient = ref.watch(apiClientProvider);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,7 @@ class StorageKeys {
|
||||||
static const String lastSyncTime = 'last_sync_time';
|
static const String lastSyncTime = 'last_sync_time';
|
||||||
static const String cachedRankingData = 'cached_ranking_data';
|
static const String cachedRankingData = 'cached_ranking_data';
|
||||||
static const String cachedMiningStatus = 'cached_mining_status';
|
static const String cachedMiningStatus = 'cached_mining_status';
|
||||||
|
static const String cachedAppAssets = 'cached_app_assets';
|
||||||
|
|
||||||
// ===== 已废弃 (保留用于迁移旧数据) =====
|
// ===== 已废弃 (保留用于迁移旧数据) =====
|
||||||
@Deprecated('Use userSerialNum instead')
|
@Deprecated('Use userSerialNum instead')
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:mobile_scanner/mobile_scanner.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/di/injection_container.dart';
|
||||||
import '../../../../core/storage/storage_keys.dart';
|
import '../../../../core/storage/storage_keys.dart';
|
||||||
import '../../../../core/providers/maintenance_provider.dart';
|
import '../../../../core/providers/maintenance_provider.dart';
|
||||||
|
|
@ -14,13 +15,15 @@ import 'phone_register_page.dart';
|
||||||
|
|
||||||
/// 向导页数据模型
|
/// 向导页数据模型
|
||||||
class GuidePageData {
|
class GuidePageData {
|
||||||
final String? imagePath;
|
final String? imagePath; // 本地 asset 路径 (兜底)
|
||||||
|
final String? remoteImageUrl; // 远程图片 URL (优先)
|
||||||
final String title;
|
final String title;
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
final Widget? customContent;
|
final Widget? customContent;
|
||||||
|
|
||||||
const GuidePageData({
|
const GuidePageData({
|
||||||
this.imagePath,
|
this.imagePath,
|
||||||
|
this.remoteImageUrl,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.subtitle,
|
required this.subtitle,
|
||||||
this.customContent,
|
this.customContent,
|
||||||
|
|
@ -49,6 +52,10 @@ class _GuidePageState extends ConsumerState<GuidePage> {
|
||||||
// 使用传入的初始页面索引
|
// 使用传入的初始页面索引
|
||||||
_currentPage = widget.initialPage.clamp(0, 4);
|
_currentPage = widget.initialPage.clamp(0, 4);
|
||||||
_pageController = PageController(initialPage: _currentPage);
|
_pageController = PageController(initialPage: _currentPage);
|
||||||
|
|
||||||
|
// 尝试加载远程引导图配置(非阻塞)
|
||||||
|
_loadRemoteGuideConfig();
|
||||||
|
|
||||||
// 延迟到 build 后获取屏幕信息
|
// 延迟到 build 后获取屏幕信息
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_logScreenInfo();
|
_logScreenInfo();
|
||||||
|
|
@ -71,9 +78,37 @@ class _GuidePageState extends ConsumerState<GuidePage> {
|
||||||
debugPrint('[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页为欢迎加入页)
|
// 向导页1-5的数据 (第5页为欢迎加入页)
|
||||||
// 支持 png、jpg、webp 等格式
|
// 支持 png、jpg、webp 等格式
|
||||||
final List<GuidePageData> _guidePages = const [
|
// 远程配置加载后会动态覆盖图片和文案
|
||||||
|
final List<GuidePageData> _guidePages = [
|
||||||
GuidePageData(
|
GuidePageData(
|
||||||
imagePath: 'assets/images/guide_1.jpg',
|
imagePath: 'assets/images/guide_1.jpg',
|
||||||
title: '认种一棵榴莲树\n拥有真实RWA资产',
|
title: '认种一棵榴莲树\n拥有真实RWA资产',
|
||||||
|
|
@ -151,8 +186,28 @@ class _GuidePageState extends ConsumerState<GuidePage> {
|
||||||
return Stack(
|
return Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// 全屏背景图片 - 使用 cover 填满屏幕
|
// 全屏背景图片 - 优先远程图片,兜底本地 asset
|
||||||
if (data.imagePath != null)
|
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(
|
Image.asset(
|
||||||
data.imagePath!,
|
data.imagePath!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@ import 'dart:math';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../../../../routes/route_paths.dart';
|
import '../../../../routes/route_paths.dart';
|
||||||
import '../../../../bootstrap.dart';
|
import '../../../../bootstrap.dart';
|
||||||
import '../../../../core/providers/maintenance_provider.dart';
|
import '../../../../core/providers/maintenance_provider.dart';
|
||||||
import '../../../../core/di/injection_container.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 '../../../../routes/app_router.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
|
|
||||||
|
|
@ -50,6 +51,9 @@ class _SplashPageState extends ConsumerState<SplashPage> {
|
||||||
|
|
||||||
// ========== 通用状态 ==========
|
// ========== 通用状态 ==========
|
||||||
|
|
||||||
|
/// 远程开屏图 URL (从缓存获取,不阻塞启动)
|
||||||
|
String? _remoteSplashUrl;
|
||||||
|
|
||||||
/// 是否显示跳过按钮
|
/// 是否显示跳过按钮
|
||||||
bool _showSkipButton = false;
|
bool _showSkipButton = false;
|
||||||
|
|
||||||
|
|
@ -65,6 +69,8 @@ class _SplashPageState extends ConsumerState<SplashPage> {
|
||||||
|
|
||||||
if (_useStaticImage) {
|
if (_useStaticImage) {
|
||||||
_initializeStaticImage();
|
_initializeStaticImage();
|
||||||
|
// 尝试加载远程开屏图配置(非阻塞)
|
||||||
|
_loadRemoteSplashConfig();
|
||||||
} else {
|
} else {
|
||||||
_initializeFrames();
|
_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() {
|
void _skipAnimation() {
|
||||||
_isPlaying = false;
|
_isPlaying = false;
|
||||||
|
|
@ -294,11 +317,34 @@ class _SplashPageState extends ConsumerState<SplashPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 构建静态图片视图
|
/// 构建静态图片视图
|
||||||
|
/// 优先使用远程图片(CachedNetworkImage),失败时回退到本地 asset。
|
||||||
Widget _buildStaticImage() {
|
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(
|
return SizedBox.expand(
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/images/splash_static/splash_$_staticImageIndex.jpg',
|
localAssetPath,
|
||||||
fit: BoxFit.contain, // 不拉伸,保持原比例,黑边填充
|
fit: BoxFit.contain,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue