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])
|
||||
@@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 { 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,
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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/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);
|
||||
|
|
|
|||
|
|
@ -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 cachedRankingData = 'cached_ranking_data';
|
||||
static const String cachedMiningStatus = 'cached_mining_status';
|
||||
static const String cachedAppAssets = 'cached_app_assets';
|
||||
|
||||
// ===== 已废弃 (保留用于迁移旧数据) =====
|
||||
@Deprecated('Use userSerialNum instead')
|
||||
|
|
|
|||
|
|
@ -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页为欢迎加入页)
|
||||
// 支持 png、jpg、webp 等格式
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue