feat: 完成智能电网电工端 Flutter 应用初始化

创建完整的 Flutter 项目结构,包含 Android 和 iOS 平台配置、核心网络层、路由、状态管理及业务模块(订单、收益、认证),适配原 uni-app 项目功能逻辑
This commit is contained in:
andyesfly committed 2026-10-06 11:54:10 +08:00
commit 56830e94ec
108 files changed
+9272

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
import 'package:flutter/material.dart';
/// 全局 Key:供非 Widget 层(拦截器、工具函数)访问 Navigator / ScaffoldMessenger。
abstract final class AppKeys {
static final rootNavigator = GlobalKey<NavigatorState>();
static final scaffoldMessenger = GlobalKey<ScaffoldMessengerState>();
}
+35
View File
@@ -0,0 +1,35 @@
/// 运行期配置。
///
/// 与 uni-app 版 `utils/request.ts` 的地址策略一致:App 端直连后端。
/// 真机联调时用 `--dart-define=API_BASE_URL=http://<局域网IP>:7001/api` 覆盖默认值。
///
/// 登录页「服务端设置」(仅调试版可见)保存的 IP+端口优先级更高,
/// 解析顺序见 [resolveBaseUrl]。
abstract final class Env {
/// 编译期默认地址,作为兜底方案。
static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://192.168.3.29:7001/api',
);
static final Uri _default = Uri.parse(apiBaseUrl);
/// 默认主机,取自 [apiBaseUrl]。
static String get defaultHost => _default.host;
/// 默认端口,取自 [apiBaseUrl],未显式指定时回落 7007。
static int get defaultPort => _default.hasPort ? _default.port : 7001;
/// 由 IP + 端口拼接 Base URL;协议与路径沿用 [apiBaseUrl],
/// 这样 `--dart-define` 传入自定义路径时也能保持一致。
static String buildBaseUrl(String host, String port) {
final h = host.trim();
final p = port.trim();
final resolvedPort = p.isEmpty ? '$defaultPort' : p;
return '${_default.scheme}://$h:$resolvedPort${_default.path}';
}
/// 当前生效的 Base URL:登录页保存值优先,其次编译期默认值。
static String resolveBaseUrl({String host = '', String port = ''}) =>
host.trim().isEmpty ? apiBaseUrl : buildBaseUrl(host, port);
}
+133
View File
@@ -0,0 +1,133 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import '../storage/token_storage.dart';
import 'api_response.dart';
/// 统一请求封装,等价于 uni-app 版 `utils/request.ts`:
/// - 自动注入 `Authorization: Bearer <token>`
/// - 解析 `{ code, message, data }`,非 200 也照常返回给调用方判断
/// - 401 直接判定会话失效并广播给会话层,由会话层清空本地登录态回到登录页。
///
/// 注意:电工端后端未提供 refresh 接口(与管理端 `auth.controller` 不同),
/// 因此这里不做静默续期,过期即重新登录。
class ApiClient {
ApiClient({required TokenStorage storage, required String baseUrl, Dio? dio})
: _storage = storage,
_dio =
dio ??
Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
// 自行判断业务 code,不因 HTTP 状态码抛异常
validateStatus: (_) => true,
),
);
final TokenStorage _storage;
final Dio _dio;
final _unauthorizedController = StreamController<void>.broadcast();
/// 会话失效事件:401 时触发,由会话层订阅后跳转登录页。
Stream<void> get onUnauthorized => _unauthorizedController.stream;
Future<ApiResponse<T>> get<T>(
String url, {
Map<String, dynamic>? query,
T Function(dynamic data)? parse,
bool needAuth = true,
}) => _send<T>(
'GET',
url,
query: _compact(query),
parse: parse,
needAuth: needAuth,
);
Future<ApiResponse<T>> post<T>(
String url, {
Object? body,
T Function(dynamic data)? parse,
bool needAuth = true,
}) => _send<T>('POST', url, body: body, parse: parse, needAuth: needAuth);
Future<ApiResponse<T>> put<T>(
String url, {
Object? body,
T Function(dynamic data)? parse,
bool needAuth = true,
}) => _send<T>('PUT', url, body: body, parse: parse, needAuth: needAuth);
void dispose() => _unauthorizedController.close();
// ---- 内部实现 ----
Future<ApiResponse<T>> _send<T>(
String method,
String url, {
Map<String, dynamic>? query,
Object? body,
T Function(dynamic data)? parse,
required bool needAuth,
}) async {
late Response<dynamic> res;
try {
res = await _dio.request<dynamic>(
url,
data: body,
queryParameters: query,
options: Options(
method: method,
headers: needAuth ? _authHeader() : null,
),
);
} on DioException catch (e) {
throw ApiException(e.message ?? '网络请求失败');
}
final response = ApiResponse<T>.fromJson(_asMap(res.data), parse);
if (response.code == 401) {
_unauthorizedController.add(null);
throw const ApiException('登录已失效,请重新登录', code: 401);
}
return response;
}
Map<String, dynamic> _authHeader() {
final token = _storage.accessToken;
return token.isEmpty ? const {} : {'Authorization': 'Bearer $token'};
}
static Map<String, dynamic> _asMap(dynamic data) {
if (data is Map) return data.cast<String, dynamic>();
if (data is String && data.isNotEmpty) {
try {
final decoded = jsonDecode(data);
if (decoded is Map) return decoded.cast<String, dynamic>();
} catch (_) {}
}
return const {'code': -1, 'message': '响应解析失败'};
}
/// 去掉 null / 空串查询条件。
/// uni-app 会把 undefined 序列化成空串(如 `status=`),后端对空串查询条件会 500,
/// 因此这里同样只在有值时才拼接参数。
static Map<String, dynamic>? _compact(Map<String, dynamic>? src) {
if (src == null || src.isEmpty) return null;
final out = <String, dynamic>{};
src.forEach((k, v) {
if (v == null) return;
if (v is String && v.isEmpty) return;
out[k] = v;
});
return out.isEmpty ? null : out;
}
}
+33
View File
@@ -0,0 +1,33 @@
/// 后端统一响应体:`{ code, message, data }`。
class ApiResponse<T> {
const ApiResponse({required this.code, required this.message, this.data});
final int code;
final String message;
final T? data;
bool get isOk => code == 200;
factory ApiResponse.fromJson(
Map<String, dynamic> json,
T Function(dynamic data)? parse,
) {
final raw = json['data'];
return ApiResponse<T>(
code: (json['code'] as num?)?.toInt() ?? -1,
message: (json['message'] as String?) ?? '',
data: (raw == null || parse == null) ? null : parse(raw),
);
}
}
/// 网络/协议层异常。调用方捕获后用 `message` 提示用户。
class ApiException implements Exception {
const ApiException(this.message, {this.code});
final String message;
final int? code;
@override
String toString() => message;
}
+110
View File
@@ -0,0 +1,110 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/auth/application/session_controller.dart';
import '../features/notice/data/notice_api.dart';
import 'env.dart';
import 'network/api_client.dart';
import 'network/api_response.dart';
import 'storage/token_storage.dart';
/// 在 `main()` 中读取 SharedPreferences 后通过 override 注入。
final tokenStorageProvider = Provider<TokenStorage>(
(ref) => throw UnimplementedError('tokenStorageProvider 必须在 ProviderScope 中 override'),
);
/// 登录页「服务端设置」保存的主机与端口;空串表示未覆盖,走编译期默认值。
typedef ServerAddress = ({String host, String port});
/// 服务端地址状态。登录页保存/重置后通知 [apiClientProvider] 重建客户端,
/// 因此无需重启 App 即可切换后端。
class ServerAddressNotifier extends Notifier<ServerAddress> {
@override
ServerAddress build() {
final storage = ref.watch(tokenStorageProvider);
return (host: storage.serverHost, port: storage.serverPort);
}
Future<void> save(String host, String port) async {
await ref.read(tokenStorageProvider).setServerAddress(host, port);
state = (host: host, port: port);
}
Future<void> reset() async {
await ref.read(tokenStorageProvider).clearServerAddress();
state = (host: '', port: '');
}
}
final serverAddressProvider =
NotifierProvider<ServerAddressNotifier, ServerAddress>(
ServerAddressNotifier.new,
);
/// 当前生效的 API Base URL。
final apiBaseUrlProvider = Provider<String>(
(ref) => Env.resolveBaseUrl(
host: ref.watch(serverAddressProvider).host,
port: ref.watch(serverAddressProvider).port,
),
);
final apiClientProvider = Provider<ApiClient>((ref) {
final client = ApiClient(
storage: ref.watch(tokenStorageProvider),
baseUrl: ref.watch(apiBaseUrlProvider),
);
ref.onDispose(client.dispose);
return client;
});
/// 站内消息中心接口。
final noticeApiProvider = Provider<NoticeApi>(
(ref) => NoticeApi(ref.watch(apiClientProvider)),
);
/// 未读消息数。登录态下每 60s 轮询一次,登出立即停止并归零;
/// 三处顶栏铃铛与消息中心页共用,标记已读后调用 [noticeUnreadCountProvider]
/// 的 `refresh()` 立即刷新。
final noticeUnreadCountProvider =
NotifierProvider<NoticeUnreadCountNotifier, int>(
NoticeUnreadCountNotifier.new,
);
class NoticeUnreadCountNotifier extends Notifier<int> {
static const _interval = Duration(seconds: 60);
Timer? _timer;
@override
int build() {
// 登录态变化时重建:先停掉上一轮轮询,避免重复计时器。
_stop();
final loggedIn = ref.watch(sessionProvider.select((s) => s.isLoggedIn));
if (!loggedIn) return 0;
ref.onDispose(_stop);
_timer = Timer.periodic(_interval, (_) => unawaited(refresh()));
unawaited(refresh());
return 0;
}
/// 立即拉取一次未读数(进入消息中心 / 单条或全部已读后调用)。
Future<void> refresh() async {
try {
final res = await ref.read(noticeApiProvider).unreadCount();
// 请求期间若已登出,丢弃结果,避免残留红点。
if (!ref.read(sessionProvider).isLoggedIn) return;
if (res.isOk && res.data != null) state = res.data!;
} on ApiException {
// 轮询失败保持上一次的值,不打断 UI。
}
}
void _stop() {
_timer?.cancel();
_timer = null;
}
}
+59
View File
@@ -0,0 +1,59 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
/// 本地持久化。键名与 uni-app 版 `uni.getStorageSync` 使用的键保持一致,
/// 便于两端在真机上迁移/对拍时理解同一份数据。
class TokenStorage {
TokenStorage(this._prefs);
final SharedPreferences _prefs;
static const _kAccessToken = 'accessToken';
static const _kUserInfo = 'userInfo';
static const _kServerHost = 'serverHost';
static const _kServerPort = 'serverPort';
static Future<TokenStorage> open() async =>
TokenStorage(await SharedPreferences.getInstance());
String get accessToken => _prefs.getString(_kAccessToken) ?? '';
Map<String, dynamic>? get userInfo {
final raw = _prefs.getString(_kUserInfo);
if (raw == null || raw.isEmpty) return null;
try {
return jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return null;
}
}
Future<void> setAccessToken(String token) =>
_prefs.setString(_kAccessToken, token);
Future<void> setUserInfo(Map<String, dynamic> info) =>
_prefs.setString(_kUserInfo, jsonEncode(info));
/// 登录页「服务端设置」保存的 IP 与端口,空串表示未覆盖、走编译期默认值。
String get serverHost => _prefs.getString(_kServerHost) ?? '';
String get serverPort => _prefs.getString(_kServerPort) ?? '';
Future<void> setServerAddress(String host, String port) async {
await _prefs.setString(_kServerHost, host);
await _prefs.setString(_kServerPort, port);
}
Future<void> clearServerAddress() async {
await _prefs.remove(_kServerHost);
await _prefs.remove(_kServerPort);
}
Future<void> clearSession() async {
await _prefs.remove(_kAccessToken);
await _prefs.remove(_kUserInfo);
}
bool get hasSession => accessToken.isNotEmpty;
}
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
/// 设计令牌 - 色彩。
/// 逐项对应 uni-app 版 `src/uni.scss` 中的 `$color-*` 变量。
abstract final class AppColors {
// Primary
/// 品牌主色(源 `$primary-container`)。源页面绝大多数蓝色元素用它。
static const primary = Color(0xFF0052FF);
/// 深一档的品牌色(源 `$primary`),用于余额卡渐变起始色。
static const primaryDeep = Color(0xFF003EC7);
static const onPrimary = Color(0xFFFFFFFF);
static const onPrimaryContainer = Color(0xFFDFE3FF);
static const primaryFixed = Color(0xFFDDE1FF);
static const primaryFixedDim = Color(0xFFB7C4FF);
static const onPrimaryFixed = Color(0xFF001452);
static const onPrimaryFixedVariant = Color(0xFF0038B6);
// Secondary
static const secondary = Color(0xFF4C4ACA);
static const secondaryContainer = Color(0xFF6664E4);
static const onSecondary = Color(0xFFFFFFFF);
static const onSecondaryContainer = Color(0xFFFFFBFF);
static const secondaryFixed = Color(0xFFE2DFFF);
static const secondaryFixedDim = Color(0xFFC2C1FF);
static const onSecondaryFixed = Color(0xFF0C006A);
static const onSecondaryFixedVariant = Color(0xFF3631B4);
// Tertiary
static const tertiary = Color(0xFF952200);
static const tertiaryContainer = Color(0xFFBF3003);
static const onTertiary = Color(0xFFFFFFFF);
static const onTertiaryContainer = Color(0xFFFFDDD5);
static const tertiaryFixed = Color(0xFFFFDBD2);
static const tertiaryFixedDim = Color(0xFFFFB4A1);
static const onTertiaryFixed = Color(0xFF3C0800);
static const onTertiaryFixedVariant = Color(0xFF891E00);
// Error
static const error = Color(0xFFBA1A1A);
static const errorContainer = Color(0xFFFFDAD6);
static const onError = Color(0xFFFFFFFF);
static const onErrorContainer = Color(0xFF93000A);
// Surface
static const surface = Color(0xFFFAF9FE);
static const surfaceBright = Color(0xFFFAF9FE);
static const surfaceDim = Color(0xFFDAD9DF);
static const surfaceContainerLowest = Color(0xFFFFFFFF);
static const surfaceContainerLow = Color(0xFFF4F3F8);
static const surfaceContainer = Color(0xFFEEEDF3);
static const surfaceContainerHigh = Color(0xFFE9E7ED);
static const surfaceContainerHighest = Color(0xFFE3E2E7);
static const surfaceVariant = Color(0xFFE3E2E7);
// Background
static const bgPage = Color(0xFFFAF9FE);
static const background = Color(0xFFFAF9FE);
// Text
static const onSurface = Color(0xFF1A1B1F);
static const onSurfaceVariant = Color(0xFF434656);
static const textMuted = Color(0xFF737688);
static const textPlaceholder = Color(0xFFC3C5D9);
// Outline
static const outline = Color(0xFF737688);
static const outlineVariant = Color(0xFFC3C5D9);
static const borderRule = Color(0xFFC3C5D9);
/// `rgba($outline-variant, 0.2)` —— 各页面 `.ios-card` / 分隔线用的浅描边。
static const borderSoft = Color(0x33C3C5D9);
/// `.ios-blur` / `.custom-tab-bar` 的毛玻璃底色 rgba(250,249,254,0.85)
static const headerGlass = Color(0xD9FAF9FE);
// Inverse
static const inverseSurface = Color(0xFF2F3034);
static const inverseOnSurface = Color(0xFFF1F0F5);
static const inversePrimary = Color(0xFFB7C4FF);
// Accent
static const accentSubtle = Color(0x140052FF);
// Status
static const success = Color(0xFF34C759);
static const star = Color(0xFFFF9500);
static const statusWarn = Color(0xFFE0852B);
static const statusError = Color(0xFFDC2626);
// 底部导航毛玻璃底:rgba(250,249,254,0.95)
static const navGlass = Color(0xF2FAF9FE);
}
/// chip 语义样式:背景色 + 前景色,对应各页面 `.chip-*` / 徽章配色。
enum ChipTone { primary, secondary, urgent, warning, success, muted }
extension ChipToneStyle on ChipTone {
Color get foreground => switch (this) {
ChipTone.primary => AppColors.primary,
ChipTone.secondary => AppColors.secondary,
ChipTone.urgent => AppColors.tertiaryContainer,
ChipTone.warning => AppColors.statusWarn,
ChipTone.success => AppColors.success,
ChipTone.muted => AppColors.onSurfaceVariant,
};
Color get background => switch (this) {
ChipTone.primary => AppColors.accentSubtle,
ChipTone.secondary => const Color(0x144C4ACA),
ChipTone.urgent => const Color(0x14BF3003),
ChipTone.warning => const Color(0x14E0852B),
ChipTone.success => const Color(0x1434C759),
ChipTone.muted => AppColors.surfaceContainer,
};
}
+66
View File
@@ -0,0 +1,66 @@
import 'package:flutter/painting.dart';
/// 设计令牌 - 间距与圆角。对应 `uni.scss` 的 `$spacing-*` / `$rounded-*`。
abstract final class AppSpacing {
static const double base = 4;
static const double xs = 8;
static const double sm = 12;
static const double md = 16;
static const double lg = 24;
static const double xl = 40;
/// `$spacing-margin-page` / `$spacing-gutter-grid` = 1rem
static const double gutter = 16;
}
abstract final class AppRadius {
/// `$rounded-sm` = 0.25rem
static const double sm = 4;
/// `$rounded-md` = 0.5rem
static const double regular = 8;
/// `$rounded-lg` = 0.75rem
static const double lg = 12;
/// `$rounded-xl` = 1rem
static const double xl = 16;
/// 订单区 / 底部弹窗的 24px 大圆角
static const double xxl = 24;
/// `$rounded-full`
static const double full = 9999;
}
abstract final class AppShadows {
/// 全局 `.ios-card`:0 1px 3px rgba(0,0,0,0.04)
static const card = [
BoxShadow(color: Color(0x0A000000), blurRadius: 3, offset: Offset(0, 1)),
];
/// 订单卡片:0 2px 8px rgba(0,0,0,0.04)
static const orderCard = [
BoxShadow(color: Color(0x0A000000), blurRadius: 8, offset: Offset(0, 2)),
];
/// 顶栏下边框阴影:0 -1px 3px rgba(0,0,0,0.05)
static const topBar = [
BoxShadow(color: Color(0x0D000000), blurRadius: 3, offset: Offset(0, 1)),
];
/// `@mixin ios-shadow`:0 2px 8px rgba(0,0,0,0.04)
static const soft = [
BoxShadow(color: Color(0x0A000000), blurRadius: 8, offset: Offset(0, 2)),
];
/// 底部导航条:向上投影
static const bottomNav = [
BoxShadow(color: Color(0x14000000), blurRadius: 24, offset: Offset(0, -8)),
];
/// 品牌主按钮发光:0 8px 20px rgba(0,82,255,0.28)
static const primaryButton = [
BoxShadow(color: Color(0x470052FF), blurRadius: 20, offset: Offset(0, 8)),
];
}
+97
View File
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'app_colors.dart';
abstract final class AppTheme {
/// 全平台统一使用 iOS 风格「新页面从右侧滑入」转场,与 uni-app 版
/// `components/PageTransition.vue` 的表现对齐。
static const _transitions = PageTransitionsTheme(
builders: {
TargetPlatform.android: CupertinoPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.windows: CupertinoPageTransitionsBuilder(),
TargetPlatform.linux: CupertinoPageTransitionsBuilder(),
TargetPlatform.fuchsia: CupertinoPageTransitionsBuilder(),
},
);
static ThemeData build() {
final base = ThemeData.light(useMaterial3: true);
return base.copyWith(
scaffoldBackgroundColor: AppColors.bgPage,
pageTransitionsTheme: _transitions,
colorScheme: base.colorScheme.copyWith(
primary: AppColors.primary,
onPrimary: AppColors.onPrimary,
secondary: AppColors.secondary,
onSecondary: AppColors.onSecondary,
tertiary: AppColors.tertiary,
onTertiary: AppColors.onTertiary,
error: AppColors.error,
onError: AppColors.onError,
surface: AppColors.surfaceContainerLowest,
onSurface: AppColors.onSurface,
outline: AppColors.outline,
outlineVariant: AppColors.outlineVariant,
),
textTheme: base.textTheme.apply(
bodyColor: AppColors.onSurface,
displayColor: AppColors.onSurface,
),
dividerTheme: const DividerThemeData(
color: AppColors.borderRule,
thickness: 0.5,
space: 1,
),
appBarTheme: const AppBarTheme(
backgroundColor: AppColors.surface,
foregroundColor: AppColors.onSurface,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
systemOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
),
titleTextStyle: TextStyle(
fontSize: 17,
height: 24 / 17,
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.surfaceContainerLow,
hintStyle: const TextStyle(color: AppColors.textPlaceholder, fontSize: 17),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.error),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.error, width: 1.5),
),
),
splashFactory: InkSparkle.splashFactory,
tooltipTheme: const TooltipThemeData(waitDuration: Duration(milliseconds: 400)),
);
}
}
+114
View File
@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
/// 设计令牌 - 字号 / 行高 / 字重。
/// 对应 `uni.scss` 的 `$font-*` 与 `global.scss` 的 `.font-*` 工具类,
/// 行高统一换算成 Flutter 的 `height`(行高 ÷ 字号)。
abstract final class AppTypography {
static const _sansFallback = <String>[
'Inter',
'PingFang SC',
'Microsoft YaHei',
'Noto Sans CJK SC',
];
static const _monoFallback = <String>[
'JetBrains Mono',
'SF Mono',
'Menlo',
'Consolas',
];
/// 12 / 16 / w500 / ls 0.02em — `.font-label-caps`
static const labelCaps = TextStyle(
fontSize: 12,
height: 16 / 12,
fontWeight: FontWeight.w500,
letterSpacing: 0.24,
fontFamilyFallback: _sansFallback,
);
/// 13 / 18 / w500 — 卡片内的次要信息行
static const bodySm = TextStyle(
fontSize: 13,
height: 18 / 13,
fontWeight: FontWeight.w500,
fontFamilyFallback: _sansFallback,
);
/// 15 / 20 / w400 — `.font-body-sm`
static const bodyMd = TextStyle(
fontSize: 15,
height: 20 / 15,
fontWeight: FontWeight.w400,
fontFamilyFallback: _sansFallback,
);
/// 17 / 22 / w400 / ls -0.01em — `.font-body-lg`
static const bodyLg = TextStyle(
fontSize: 17,
height: 22 / 17,
fontWeight: FontWeight.w400,
letterSpacing: -0.17,
fontFamilyFallback: _sansFallback,
);
/// 20 / 25 / w600 / ls -0.01em — `.font-headline-md`
static const headlineMd = TextStyle(
fontSize: 20,
height: 25 / 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
fontFamilyFallback: _sansFallback,
);
/// 22 / 28 / w700 — 「我的」页用户名
static const titleLg = TextStyle(
fontSize: 22,
height: 28 / 22,
fontWeight: FontWeight.w700,
fontFamilyFallback: _sansFallback,
);
/// 28 / 34 / w700 / ls -0.02em — `.font-nav-large-title-mobile`
static const navLargeTitle = TextStyle(
fontSize: 28,
height: 34 / 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.56,
fontFamilyFallback: _sansFallback,
);
/// 32 / 40 / w700 — 登录页品牌字
static const displayMd = TextStyle(
fontSize: 32,
height: 40 / 32,
fontWeight: FontWeight.w700,
fontFamilyFallback: _sansFallback,
);
/// 40 / 48 / w700 — 收益页总余额
static const displayLg = TextStyle(
fontSize: 40,
height: 48 / 40,
fontWeight: FontWeight.w700,
fontFamilyFallback: _sansFallback,
);
/// 金额 / 编号等需要等宽对齐的场景
static const dataMono = TextStyle(
fontSize: 14,
height: 18 / 14,
fontWeight: FontWeight.w600,
fontFamilyFallback: _monoFallback,
);
}
/// 全局默认文字样式,对应 `global.scss` 里 `page` 上的字体设置。
TextStyle get appDefaultTextStyle => const TextStyle(
fontSize: 15,
height: 1.4,
color: AppColors.onSurface,
fontFamilyFallback: AppTypography._sansFallback,
);
+45
View File
@@ -0,0 +1,45 @@
/// 日期格式化工具。
String _pad(int n) => n.toString().padLeft(2, '0');
/// 统一转 DateTime:兼容 `YYYY-MM-DD`、ISO 字符串与时间戳。
DateTime? parseDate(Object? v) {
if (v == null) return null;
if (v is DateTime) return v;
if (v is num) return DateTime.fromMillisecondsSinceEpoch(v.toInt());
final s = v.toString();
if (s.isEmpty) return null;
return DateTime.tryParse(s) ?? DateTime.tryParse(s.replaceAll('-', '/'));
}
/// `YYYY-MM-DD`
String formatDate(Object? v) {
final d = parseDate(v);
if (d == null) return '-';
return '${d.year}-${_pad(d.month)}-${_pad(d.day)}';
}
/// `YYYY-MM-DD HH:mm`
String formatDateTime(Object? v) {
final d = parseDate(v);
if (d == null) return '-';
return '${formatDate(d)} ${_pad(d.hour)}:${_pad(d.minute)}';
}
/// 今天的 `YYYY-MM-DD`
String todayStr() => formatDate(DateTime.now());
/// 金额格式化:千分位 + 两位小数,对应源页面 `formatNumber`。
String formatMoney(num? value) {
final v = (value ?? 0).toDouble();
final fixed = v.toStringAsFixed(2);
final parts = fixed.split('.');
final intPart = parts[0];
final negative = intPart.startsWith('-');
final digits = negative ? intPart.substring(1) : intPart;
final buf = StringBuffer();
for (var i = 0; i < digits.length; i++) {
if (i > 0 && (digits.length - i) % 3 == 0) buf.write(',');
buf.write(digits[i]);
}
return '${negative ? '-' : ''}$buf.${parts[1]}';
}
+22
View File
@@ -0,0 +1,22 @@
/// 后端主键为 BIGINT,经 JSON 序列化后是字符串(如 `"1"`)。
/// 统一归一化为 int,避免与表单/路由里的 int 型 id 做比较时失效。
Object? toNumId(Object? v) {
if (v == null || v == '') return v;
if (v is int) return v;
if (v is num) return v.toInt();
final n = int.tryParse(v.toString());
return n ?? v;
}
int? toIntOrNull(Object? v) {
final n = toNumId(v);
return n is int ? n : null;
}
int toIntOr(Object? v, int fallback) => toIntOrNull(v) ?? fallback;
double toDoubleOr(Object? v, double fallback) {
if (v == null) return fallback;
if (v is num) return v.toDouble();
return double.tryParse(v.toString()) ?? fallback;
}