feat: 完成 Flutter 版智能电网 IoT 管理应用初始化搭建

This commit is contained in:
andyesfly committed 2026-10-02 17:21:53 +08:00
commit f61b7bf90b
120 files changed
+15229

No files matched your search

@@ -0,0 +1,338 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/format_util.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_card.dart';
import '../../../shared/widgets/x_chip.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../../customer/data/customer_api.dart';
import '../data/project_api.dart';
import '../data/project_models.dart';
/// 项目详情页。
///
/// 源 `pages/project/detail.vue` 基于 mock 数据(团队 / 里程碑 / 设备概览等),
/// 这里按用户确认改为直连后端 `/api/project-plans/:id`,保留信息卡 + 分组 + 明细列表的视觉语言。
class ProjectDetailPage extends ConsumerStatefulWidget {
const ProjectDetailPage({super.key, required this.id});
final int id;
@override
ConsumerState<ProjectDetailPage> createState() => _ProjectDetailPageState();
}
class _ProjectDetailPageState extends ConsumerState<ProjectDetailPage> {
ProjectPlan? _plan;
String? _customerName;
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final res = await ref.read(projectApiProvider).detail(widget.id);
if (!mounted) return;
if (res.isOk && res.data != null) {
setState(() => _plan = res.data);
await _loadCustomer(res.data!.customerId);
} else {
showAppToast(res.message.isEmpty ? '方案不存在' : res.message);
}
} catch (_) {
if (mounted) showAppToast('加载失败');
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _loadCustomer(int customerId) async {
try {
final res = await ref.read(customerApiProvider).detail(customerId);
if (!mounted) return;
if (res.isOk && res.data != null) {
setState(() => _customerName = res.data!.customerName);
}
} catch (_) {
// 客户名称获取失败时降级为「客户 #id」
}
}
String _customerLabel(int customerId) {
final name = _customerName;
return (name == null || name.isEmpty) ? '客户 #$customerId' : name;
}
String _textOrDash(String? value) =>
(value == null || value.isEmpty) ? '-' : value;
String _formatQty(num quantity) => quantity == quantity.roundToDouble()
? quantity.toInt().toString()
: quantity.toString();
@override
Widget build(BuildContext context) {
final planNo = _plan?.planNo;
return XPageShell(
topBar: XTopBar.nav(
title: (planNo == null || planNo.isEmpty) ? '方案详情' : planNo,
onBack: () => context.pop(),
),
child: _buildBody(),
);
}
Widget _buildBody() {
final p = _plan;
if (p == null) {
return XPlaceholder(text: _loading ? '加载中...' : '方案不存在');
}
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeaderCard(p),
_buildInfoCard(p),
_buildDetailSection(p),
],
),
);
}
Widget _buildHeaderCard(ProjectPlan p) {
return XCard(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 6,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
_customerLabel(p.customerId),
style: AppTypography.headlineMdMobile.copyWith(
color: AppColors.onSurface,
),
),
XChip(
projectStatusText(p.status),
tone: projectStatusTone(p.status),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Text(
'方案单号',
style: AppTypography.bodyMd.copyWith(
fontSize: 12,
color: AppColors.textMuted,
),
),
const SizedBox(width: 8),
Text(
p.displayTitle,
style: AppTypography.dataMono.copyWith(
color: AppColors.onSurface,
),
),
],
),
],
),
);
}
Widget _buildInfoCard(ProjectPlan p) {
final items = <Widget>[
_infoItem('person', '登记人', _textOrDash(p.registrar)),
_infoItem('calendar', '登记时间', formatDate(p.registerTime)),
_infoItem('clock', '创建时间', formatDateTime(p.createdAt)),
_infoItem('category', '方案说明', _textOrDash(p.planDesc)),
];
return XCard(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Column(
children: [
for (var i = 0; i < items.length; i++) ...[
if (i > 0) Container(height: 1, color: AppColors.borderRule),
items[i],
],
if (p.planFileUrl != null && p.planFileUrl!.isNotEmpty) ...[
Container(height: 1, color: AppColors.borderRule),
_buildFileRow(p.planFileUrl!),
],
],
),
);
}
Widget _infoItem(String icon, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
children: [
XIcon(icon, size: 18, color: AppColors.textMuted),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: AppTypography.bodyMd.copyWith(
fontSize: 12,
color: AppColors.textMuted,
),
),
Text(
value,
style: AppTypography.bodyMd.copyWith(
color: AppColors.onSurface,
),
),
],
),
),
],
),
);
}
/// 方案文件:源页面用 uni 下载打开,Flutter 未引入 url_launcher,这里仅展示链接文本。
Widget _buildFileRow(String url) {
return GestureDetector(
onTap: () => showAppToast(url),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
children: [
const XIcon('domain', size: 18, color: AppColors.textMuted),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'方案文件',
style: AppTypography.bodyMd.copyWith(
fontSize: 12,
color: AppColors.textMuted,
),
),
Text(
url,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.bodyMd.copyWith(
color: AppColors.primary,
),
),
],
),
),
const SizedBox(width: 6),
const XIcon('forward', size: 16, color: AppColors.textPlaceholder),
],
),
),
);
}
Widget _buildDetailSection(ProjectPlan p) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(top: 22, bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const XSectionTitle('设备明细', icon: 'category', fontSize: 16),
Text(
'合计 ${_formatQty(p.detailQuantityTotal)}',
style: AppTypography.dataMono.copyWith(
color: AppColors.primary,
),
),
],
),
),
if (p.details.isEmpty)
const XPlaceholder(text: '暂无设备明细', dashed: true)
else
_buildDetailCard(p.details),
],
);
}
Widget _buildDetailCard(List<ProjectPlanDetail> details) {
final items = <Widget>[];
for (var i = 0; i < details.length; i++) {
if (i > 0) items.add(Container(height: 1, color: AppColors.borderRule));
items.add(_detailItem(details[i]));
}
return XCard(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Column(children: items),
);
}
Widget _detailItem(ProjectPlanDetail d) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
d.equipName.isEmpty ? '未命名设备' : d.equipName,
style: AppTypography.bodyMd.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
if (d.remark != null && d.remark!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
d.remark!,
style: AppTypography.bodyMd.copyWith(
fontSize: 12,
color: AppColors.textMuted,
),
),
],
],
),
),
const SizedBox(width: 12),
Text(
'×${_formatQty(d.quantity)}',
style: AppTypography.dataMono.copyWith(color: AppColors.primary),
),
],
),
);
}
}
@@ -0,0 +1,330 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/tab_index.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/format_util.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_card.dart';
import '../../../shared/widgets/x_chip.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../../customer/data/customer_api.dart';
import '../data/project_api.dart';
import '../data/project_models.dart';
/// 项目 Tab。
///
/// 源 `components/tabs/ProjectTab.vue` 使用本地 mock(字段为 projectName / progress / stage 等),
/// 这里按用户确认改为直连后端 `/api/project-plans`,视觉沿用原卡片式语言但字段换成后端真实字段。
class ProjectTab extends ConsumerStatefulWidget {
const ProjectTab({super.key});
@override
ConsumerState<ProjectTab> createState() => _ProjectTabState();
}
class _ProjectTabState extends ConsumerState<ProjectTab> {
static const _statusOptions = <({String label, int? value})>[
(label: '全部', value: null),
(label: '草稿', value: ProjectPlanStatus.draft),
(label: '已确认', value: ProjectPlanStatus.confirmed),
];
final _searchController = TextEditingController();
Timer? _searchTimer;
List<ProjectPlan> _list = const [];
Map<int, String> _customerNames = const {};
bool _loading = false;
int? _filterStatus;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadCustomers();
_load();
});
}
@override
void dispose() {
_searchTimer?.cancel();
_searchController.dispose();
super.dispose();
}
/// `/project-plans` 只返回 customerId,需要先建立 id → 客户名称 映射用于展示。
Future<void> _loadCustomers() async {
try {
final res = await ref
.read(customerApiProvider)
.list(page: 1, pageSize: 200);
if (!mounted) return;
if (res.isOk && res.data != null) {
setState(() {
_customerNames = {
for (final c in res.data!.list) c.id: c.customerName,
};
});
}
} catch (_) {
// 客户名称映射失败时降级为「客户 #id」,不影响方案列表展示
}
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final keyword = _searchController.text.trim();
final res = await ref
.read(projectApiProvider)
.list(
page: 1,
pageSize: 100,
keyword: keyword.isEmpty ? null : keyword,
status: _filterStatus,
);
if (!mounted) return;
if (res.isOk && res.data != null) {
setState(() => _list = res.data!.list);
}
} catch (_) {
showAppToast('加载方案失败');
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _onSearchChanged(String _) {
_searchTimer?.cancel();
_searchTimer = Timer(const Duration(milliseconds: 350), _load);
}
void _onStatusChip(int? value) {
if (_filterStatus == value) return;
setState(() => _filterStatus = value);
_load();
}
Future<void> _goDetail(ProjectPlan p) async {
await context.push('/project/detail/${p.id}');
if (mounted) _load();
}
String _customerLabel(int customerId) {
final name = _customerNames[customerId];
return (name == null || name.isEmpty) ? '客户 #$customerId' : name;
}
@override
Widget build(BuildContext context) {
ref.listen(tabIndexProvider, (previous, next) {
if (next == AppTab.project) _load();
});
return XPageShell(
topBar: XTopBar.brand(
title: '鑫潼智电',
trailingColor: AppColors.primary,
),
child: Column(
children: [
_buildPageHead(),
_buildSearchBar(),
_buildFilters(),
Expanded(child: _buildList()),
],
),
);
}
Widget _buildPageHead() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('项目', style: AppTypography.headlineMd),
Padding(
padding: const EdgeInsets.only(top: 2),
child: const Text(
'管理与跟踪智电改造方案',
style: TextStyle(fontSize: 14, color: AppColors.textMuted),
),
),
],
),
);
}
Widget _buildSearchBar() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
child: XSearchField(
controller: _searchController,
hintText: '按方案单号或说明搜索...',
onChanged: _onSearchChanged,
),
);
}
Widget _buildFilters() {
return Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 4, bottom: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final option in _statusOptions)
Padding(
padding: const EdgeInsets.only(right: 8),
child: XFilterChip(
label: option.label,
selected: _filterStatus == option.value,
onTap: () => _onStatusChip(option.value),
),
),
],
),
),
);
}
Widget _buildList() {
if (_loading && _list.isEmpty) {
return const XPlaceholder(text: '加载中...');
}
if (_list.isEmpty) {
return const XPlaceholder(text: '暂无方案');
}
return RefreshIndicator(
color: AppColors.primary,
onRefresh: _load,
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 120),
itemCount: _list.length,
itemBuilder: (context, index) => _planCard(_list[index]),
),
);
}
Widget _planCard(ProjectPlan p) {
final registrar = p.registrar;
return XCard(
padding: EdgeInsets.zero,
margin: const EdgeInsets.only(bottom: 12),
onTap: () => _goDetail(p),
child: Stack(
children: [
// 左侧状态色条贴边,圆角按卡片圆角裁出
Positioned(
left: 0,
top: 0,
bottom: 0,
width: 4,
child: DecoratedBox(
decoration: BoxDecoration(
color: projectStatusBarColor(p.status),
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(AppRadius.lg),
),
),
),
),
Padding(
padding: const EdgeInsets.only(left: 4),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
_customerLabel(p.customerId),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
),
const SizedBox(width: 8),
XChip(
projectStatusText(p.status),
tone: projectStatusTone(p.status),
),
],
),
const SizedBox(height: 6),
Text(
p.displayTitle,
style: AppTypography.dataMono.copyWith(
color: AppColors.textMuted,
),
),
const SizedBox(height: 10),
Row(
children: [
const XIcon(
'person',
size: 15,
color: AppColors.textMuted,
),
const SizedBox(width: 4),
Expanded(
child: Text(
(registrar == null || registrar.isEmpty)
? '未填写登记人'
: registrar,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
color: AppColors.textMuted,
),
),
),
const SizedBox(width: 12),
const XIcon('clock', size: 15, color: AppColors.textMuted),
const SizedBox(width: 4),
Text(
formatDate(p.registerTime),
style: AppTypography.dataMono.copyWith(
color: AppColors.textMuted,
),
),
],
),
if (p.planDesc != null && p.planDesc!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
p.planDesc!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
color: AppColors.onSurfaceVariant,
),
),
],
],
),
),
),
],
),
);
}
}