Files
AI-Health/health_app/lib/widgets/authenticated_network_image.dart
MingNian 3b5cec10a6 feat: AI 提示词模块化 + AI 同意门控 + AI 草稿存储 + 意图路由 + 医疗引用知识库 + 多页面 UI 优化
- AI 提示词拆分为 markdown 模块(Prompts/global + modules + rag + router)
- 新增 AI 同意门控(用户需同意后才能使用 AI 功能)
- 新增 AI 录入草稿存储(AiEntryDraftContracts/EfAiEntryDraftStore/AiEntryDraftRecord)
- 新增 AI 意图路由(ai_intent_router)
- 新增医疗引用知识库(MedicalCitationKnowledge)
- 重构 prompt_manager 和 ai_chat_endpoints
- 优化聊天/趋势/档案/抽屉/医生端等多个页面 UI
- 新增 agent 插画和趋势指标图标资源
- 删除 HANDOFF-2026-07-17.md
2026-07-27 16:53:20 +08:00

160 lines
4.4 KiB
Dart

import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api_client.dart' show baseUrl;
import '../providers/auth_provider.dart';
const int _maxProtectedImageCacheEntries = 32;
final Map<String, Uint8List> _protectedImageBytesCache = {};
String protectedMediaUrl(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return trimmed;
final absolute = Uri.tryParse(trimmed);
final path = absolute?.path ?? trimmed.split('?').first;
final legacyIndex = path.toLowerCase().indexOf('/uploads/users/');
if (legacyIndex >= 0) {
final fileName = Uri.decodeComponent(path.split('/').last);
return '$baseUrl/api/files/content/${Uri.encodeComponent(fileName)}';
}
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return trimmed;
}
if (trimmed.startsWith('/')) return '$baseUrl$trimmed';
return '$baseUrl/$trimmed';
}
bool mediaRequiresAuthentication(String url) {
final normalizedBase = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/';
return url == baseUrl || url.startsWith(normalizedBase);
}
class AuthenticatedNetworkImage extends ConsumerStatefulWidget {
final String imageUrl;
final BoxFit? fit;
final double? width;
final double? height;
final ImageLoadingBuilder? loadingBuilder;
final ImageErrorWidgetBuilder? errorBuilder;
const AuthenticatedNetworkImage({
super.key,
required this.imageUrl,
this.fit,
this.width,
this.height,
this.loadingBuilder,
this.errorBuilder,
});
@override
ConsumerState<AuthenticatedNetworkImage> createState() =>
_AuthenticatedNetworkImageState();
}
class _AuthenticatedNetworkImageState
extends ConsumerState<AuthenticatedNetworkImage> {
late String _resolvedUrl;
Future<Uint8List>? _bytes;
Uint8List? _cachedBytes;
@override
void initState() {
super.initState();
_configure();
}
@override
void didUpdateWidget(covariant AuthenticatedNetworkImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.imageUrl != widget.imageUrl) _configure();
}
void _configure() {
_resolvedUrl = protectedMediaUrl(widget.imageUrl);
_cachedBytes = _protectedImageBytesCache[_resolvedUrl];
_bytes = mediaRequiresAuthentication(_resolvedUrl) && _cachedBytes == null
? _loadAndCacheProtectedBytes(_resolvedUrl)
: null;
}
Future<Uint8List> _loadAndCacheProtectedBytes(String url) async {
final data = await _loadProtectedBytes(url);
if (_protectedImageBytesCache.length >= _maxProtectedImageCacheEntries) {
_protectedImageBytesCache.remove(_protectedImageBytesCache.keys.first);
}
_protectedImageBytesCache[url] = data;
return data;
}
Future<Uint8List> _loadProtectedBytes(String url) async {
final response = await ref
.read(apiClientProvider)
.dio
.get<List<int>>(
url,
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data ?? const []);
}
@override
Widget build(BuildContext context) {
final cachedBytes = _cachedBytes;
if (cachedBytes != null) {
return Image.memory(
cachedBytes,
fit: widget.fit,
width: widget.width,
height: widget.height,
gaplessPlayback: true,
errorBuilder: widget.errorBuilder,
);
}
final bytes = _bytes;
if (bytes == null) {
return Image.network(
_resolvedUrl,
fit: widget.fit,
width: widget.width,
height: widget.height,
gaplessPlayback: true,
loadingBuilder: widget.loadingBuilder,
errorBuilder: widget.errorBuilder,
);
}
return FutureBuilder<Uint8List>(
future: bytes,
builder: (context, snapshot) {
if (snapshot.hasError) {
return widget.errorBuilder?.call(
context,
snapshot.error!,
snapshot.stackTrace,
) ??
const Icon(Icons.broken_image_outlined);
}
final data = snapshot.data;
if (data == null) {
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
}
return Image.memory(
data,
fit: widget.fit,
width: widget.width,
height: widget.height,
gaplessPlayback: true,
errorBuilder: widget.errorBuilder,
);
},
);
}
}