- 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
160 lines
4.4 KiB
Dart
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,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|