已完成用户APP首期功能开发

交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
This commit is contained in:
czl231
2026-09-13 00:57:32 +08:00
parent a0a4bb1218
commit 3ef33b531d
793 changed files with 40959 additions and 1204 deletions

View File

@@ -0,0 +1,72 @@
// 功能:首页设备能力未接入时保留设计结构,禁止展示虚构遥测;版本:1.0.0。
import 'package:flutter/material.dart';
/// 仅作为未开放状态;接入真实设备接口后替换,不构造模拟在线或安全结论。
class DeviceStatusPlaceholder extends StatelessWidget {
const DeviceStatusPlaceholder({super.key});
@override
Widget build(BuildContext context) => Column(
children: [
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.settings_input_component,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('设备服务暂未开放', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
SizedBox(height: 2),
Text('设备状态与遥测暂不可查询', style: TextStyle(fontSize: 12, color: Color(0xFF626977))),
],
),
),
],
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Divider(),
),
LayoutBuilder(
builder: (context, constraints) {
final columns =
constraints.maxWidth < 330 || MediaQuery.textScalerOf(context).scale(12) > 14 ? 2 : 4;
final width = constraints.maxWidth / columns;
return Wrap(
children: [
for (final label in ['环境压力', '温度', '可燃气体', '最后更新时间'])
SizedBox(
width: width,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Column(
children: [
Text(
label,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 12, color: Color(0xFF727987)),
),
const SizedBox(height: 2),
const Text('暂不可查询', style: TextStyle(fontSize: 11)),
],
),
),
),
],
);
},
),
],
);
}

View File

@@ -1,105 +1,331 @@
// 功能描述:展示用户端首页的安全内容、服务归属与公告列表。
// 版本:1.1.0
// 功能描述:首页服务归属、内容和保留入口;未开放设备不展示虚构遥测。
// 版本:2.0.0
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../data/repositories/client_repository.dart';
import '../../../data/services/api_client.dart';
import '../../../domain/models/client_models.dart';
import '../../../data/repositories/primary_repository.dart';
import '../../../domain/models/primary_models.dart';
import '../../core/async_content.dart';
import '../../core/feature_entry.dart';
import '../../core/widgets.dart';
import 'service_relation_card.dart';
import 'device_status_placeholder.dart';
/// 用户端首页。
class HomePage extends StatefulWidget {
const HomePage({required this.repository, super.key});
/// 首页;游客可浏览内容,个人归属仅登录后读取。
class HomePage extends StatelessWidget {
const HomePage({required this.repository, this.authenticated = true, super.key});
final ClientRepository repository;
final bool authenticated;
@override
State<HomePage> createState() => _HomePageState();
}
/// 管理首页远端数据加载与下拉刷新状态。
class _HomePageState extends State<HomePage> {
late Future<(List<ClientRecord>, Map<String, Object?>?)> _future;
@override
void initState() {
super.initState();
_future = _load();
/// 并行获取独立数据;所有异常由公共异步容器处理。
Future<(List<PublishedContent>, ServiceRelation?)> _load() async {
final source = PrimaryRepository(repository);
final results = await Future.wait<Object?>([
source.contents(),
authenticated ? source.relation() : Future.value(null),
]);
return (results[0] as List<PublishedContent>, results[1] as ServiceRelation?);
}
/// 并行语义上聚合公告和当前服务归属数据。
Future<(List<ClientRecord>, Map<String, Object?>?)> _load() async =>
(await widget.repository.contents(), await widget.repository.serviceRelation());
/// 重新加载首页数据并等待刷新完成。
Future<void> _refresh() async {
setState(() => _future = _load());
await _future;
/// 登录入口保留当前目标页,避免认证后丢失用户意图。
void _openProtected(BuildContext context, String location) {
if (authenticated) {
context.push(location);
return;
}
context.push('/login?redirect=${Uri.encodeComponent(location)}');
}
@override
Widget build(BuildContext context) => Scaffold(
body: SafeArea(
child: FutureBuilder<(List<ClientRecord>, Map<String, Object?>?)>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
if (snapshot.error is SessionExpiredException) {
return const SizedBox.shrink();
}
return EmptyState(
title: '首页加载失败',
description: snapshot.error.toString(),
onRetry: _refresh,
);
}
final (contents, relation) = snapshot.data ?? (const <ClientRecord>[], null);
return RefreshIndicator(
onRefresh: _refresh,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 24),
children: [
const PageIntro(
eyebrow: '安全生活',
title: '今天也要安心用气',
description: '设备控制能力尚未开放,本页只展示真实服务与安全内容。',
),
AppGutter(
child: ServiceRelationCard(relation: relation),
),
const SizedBox(height: 32),
const AppGutter(
child: SectionHeader(title: '安全公告', description: '来自平台的最新安全提醒与服务信息'),
),
const SizedBox(height: 12),
if (contents.isEmpty)
const AppGutter(
child: SurfaceSection(child: Text('暂无已发布内容')),
)
else
AppGutter(
child: SurfaceSection(
padding: EdgeInsets.zero,
child: Column(
children: [
for (var index = 0; index < contents.take(6).length; index++) ...[
RecordCard(record: contents[index]),
if (index < contents.take(6).length - 1)
const Divider(indent: 16, endIndent: 16),
],
],
child: AsyncContent<(List<PublishedContent>, ServiceRelation?)>(
key: ValueKey(authenticated),
load: _load,
builder: (context, data) => ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(18, 16, 18, 20),
children: [
Text(
'安全生活',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text('今天也要安心用气', style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 6),
const Text(
'设备控制能力尚未开放,本页只展示真实服务与安全内容。',
style: TextStyle(fontSize: 12),
),
const SizedBox(height: 16),
if (authenticated)
ServiceRelationCard.typed(data.$2)
else
ServiceRelationCard(
relation: null,
emptyMessage: '登录后查看所属气站与配送点',
onTap: () => _openProtected(context, '/home'),
),
const SizedBox(height: 10),
SurfaceSection(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Expanded(
child: Text(
'我的智能设备',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
InkWell(
onTap: () => _openProtected(context, '/devices'),
borderRadius: BorderRadius.circular(8),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 4, vertical: 5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('查看详情', style: TextStyle(color: Color(0xFF2563EB))),
SizedBox(width: 2),
Icon(Icons.chevron_right, size: 18, color: Color(0xFF2563EB)),
],
),
),
),
],
),
const SizedBox(height: 4),
const DeviceStatusPlaceholder(),
],
),
),
const SizedBox(height: 10),
SurfaceSection(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: IntrinsicHeight(
child: Row(
children: [
const Expanded(
child: FeatureEntry(
icon: Icons.qr_code_scanner,
label: '扫码添加',
compact: true,
showStatus: false,
),
),
const VerticalDivider(indent: 10, endIndent: 10),
const Expanded(
child: FeatureEntry(
icon: Icons.bluetooth,
label: '蓝牙连接',
compact: true,
showStatus: false,
),
),
const VerticalDivider(indent: 10, endIndent: 10),
Expanded(
child: FeatureEntry(
icon: Icons.grid_view_outlined,
label: '分组控制',
compact: true,
showStatus: false,
onTap: () => _openProtected(context, '/device-groups'),
),
),
const VerticalDivider(indent: 10, endIndent: 10),
Expanded(
child: FeatureEntry(
icon: Icons.build_outlined,
label: '一键报修',
compact: true,
showStatus: false,
onTap: () => _openProtected(context, '/repair'),
),
),
],
),
),
),
const SizedBox(height: 10),
SurfaceSection(
padding: EdgeInsets.zero,
child: Column(
children: [
_entry(
context,
Icons.shield_outlined,
'安全记录 / 最近告警',
'查看设备状态与告警记录',
),
const Divider(indent: 12, endIndent: 12),
_entry(
context,
Icons.propane_tank_outlined,
'气瓶下单',
'选择本人合同气瓶并预约配送',
onTap: () => _openProtected(context, '/gas/order'),
),
],
),
),
const SizedBox(height: 16),
Row(
children: [
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'安全公告',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
SizedBox(height: 2),
Text(
'来自平台的最新安全提醒与服务信息',
style: TextStyle(fontSize: 11, color: Color(0xFF626977)),
),
],
),
),
InkWell(
onTap: () => context.push('/contents'),
borderRadius: BorderRadius.circular(8),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('查看全部', style: TextStyle(color: Color(0xFF2563EB))),
SizedBox(width: 2),
Icon(Icons.chevron_right, size: 18, color: Color(0xFF2563EB)),
],
),
),
),
],
),
);
},
const SizedBox(height: 4),
if (data.$1.where((c) => c.type == 'notice').isEmpty)
const SurfaceSection(child: Text('暂无已发布内容')),
if (data.$1.where((c) => c.type == 'notice').isNotEmpty)
SurfaceSection(
padding: EdgeInsets.zero,
child: Column(
children: [
for (final (index, content)
in data.$1.where((c) => c.type == 'notice').take(2).indexed) ...[
_AnnouncementRow(
content: content,
onTap: () => context.push(
'/contents/${Uri.encodeComponent(content.identity)}',
),
),
if (index == 0 && data.$1.where((c) => c.type == 'notice').length > 1)
const Divider(indent: 12, endIndent: 12),
],
],
),
),
],
),
),
),
);
/// 保留安全与气瓶入口,点击解释当前可用性。
Widget _entry(
BuildContext context,
IconData icon,
String text,
String description, {
VoidCallback? onTap,
}) => InkWell(
onTap: onTap ?? () => showUnavailableFeature(context, text),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
child: Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 21, color: Theme.of(context).colorScheme.primary),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(text, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
Text(
description,
style: const TextStyle(fontSize: 12, color: Color(0xFF626977)),
),
],
),
),
const Icon(Icons.chevron_right, color: Color(0xFF626977)),
],
),
),
);
}
/// 公告行只显示服务端真实标题、摘要与日期,缺失字段保持空白。
class _AnnouncementRow extends StatelessWidget {
const _AnnouncementRow({required this.content, required this.onTap});
final PublishedContent content;
final VoidCallback onTap;
String get _date {
final value = content.publishedAt?.toLocal();
return value == null ? '' : '${value.year}/${value.month}/${value.day}';
}
@override
Widget build(BuildContext context) => InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
content.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
const SizedBox(height: 1),
Text(
content.body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, color: Color(0xFF626977)),
),
],
),
),
if (_date.isNotEmpty) ...[
const SizedBox(width: 8),
Text(_date, style: const TextStyle(fontSize: 11, color: Color(0xFF777F8D))),
],
const SizedBox(width: 4),
const Icon(Icons.chevron_right, size: 20, color: Color(0xFF626977)),
],
),
),
);

View File

@@ -0,0 +1,181 @@
// 功能:安全内容中心的真实公告搜索、分类入口与正文阅读;版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../data/repositories/client_repository.dart';
import '../../../data/repositories/primary_repository.dart';
import '../../../data/services/api_client.dart';
import '../../../domain/models/primary_models.dart';
import '../../core/async_content.dart';
import '../../core/feature_entry.dart';
/// 图文内容使用后台发布接口;安全视频尚缺媒体能力,保留入口但不伪造播放。
class SafetyContentsPage extends StatefulWidget {
const SafetyContentsPage({required this.repository, this.identity, super.key});
final ClientRepository repository;
final String? identity;
@override
State<SafetyContentsPage> createState() => _SafetyContentsPageState();
}
class _SafetyContentsPageState extends State<SafetyContentsPage> {
String _query = '';
int _selected = 0;
final _searchFocus = FocusNode();
/// 列表保留兼容接口,正文不再重复下载全部文章。
Future<List<PublishedContent>> _load() async {
if (widget.identity == null) return PrimaryRepository(widget.repository).contents();
try {
final item = await widget.repository.safetyContent(widget.identity!);
return [
PublishedContent(
identity: item.identity,
title: item.title,
body: item.raw['body'] as String? ?? '',
type: item.raw['content_type'] as String? ?? '',
version: item.raw['version_no'] as int? ?? 1,
),
];
} on ApiException catch (error) {
if (error.code == 1112) return [];
rethrow;
}
}
@override
void dispose() {
_searchFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(widget.identity == null ? '安全内容' : '内容详情'),
leading: IconButton(
tooltip: '返回',
icon: const Icon(Icons.arrow_back),
onPressed: () => context.canPop() ? context.pop() : context.go('/home'),
),
actions: [
if (widget.identity == null)
IconButton(
tooltip: '搜索公告',
onPressed: _searchFocus.requestFocus,
icon: const Icon(Icons.search),
),
],
),
body: AsyncContent<List<PublishedContent>>(
key: ValueKey(widget.identity),
load: _load,
builder: (context, contents) {
final notices = contents
.where((item) => ['notice', 'safety_article', 'law'].contains(item.type))
.toList();
if (widget.identity != null) {
final matches = notices.where((item) => item.identity == widget.identity);
if (matches.isEmpty) return const Center(child: Text('内容不存在或已下架'));
final item = matches.first;
return ListView(
padding: const EdgeInsets.all(20),
children: [
SelectableText(
item.title,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
const Text('平台发布', style: TextStyle(color: Color(0xFF727987))),
const Divider(height: 32),
SelectableText(
item.body.isEmpty ? '暂无正文' : item.body,
style: const TextStyle(fontSize: 16, height: 1.7),
),
],
);
}
final type = {1: 'safety_article', 3: 'law', 4: 'notice'}[_selected];
final shown = notices
.where(
(item) =>
(type == null || item.type == type) &&
item.title.toLowerCase().contains(_query.toLowerCase()),
)
.toList();
return ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final (index, label) in ['全部', '安全宣传', '安全视频', '法律法规', '平台公告'].indexed)
TextButton(
onPressed: () {
if (index == 2) {
showUnavailableFeature(context, label);
return;
}
setState(() => _selected = index);
},
child: Column(
children: [
Text(
label,
style: TextStyle(
color: _selected == index
? const Color(0xFF0064FF)
: const Color(0xFF616979),
),
),
if (index == 2)
const Text(
'暂未开放',
style: TextStyle(fontSize: 10, color: Color(0xFF727987)),
),
if (_selected == index)
const SizedBox(
width: 24,
child: Divider(color: Color(0xFF0064FF), thickness: 2),
),
],
),
),
],
),
),
const SizedBox(height: 12),
TextField(
focusNode: _searchFocus,
onChanged: (value) => setState(() => _query = value.trim()),
decoration: InputDecoration(
hintText: _selected == 4 ? '搜索公告标题' : '搜索内容标题',
suffixIcon: const Icon(Icons.search),
),
),
const SizedBox(height: 20),
if (shown.isEmpty)
const Padding(
padding: EdgeInsets.all(28),
child: Center(child: Text('暂无符合条件的内容')),
),
for (final item in shown) ...[
ListTile(
contentPadding: const EdgeInsets.symmetric(vertical: 8),
leading: const Icon(Icons.campaign_outlined, size: 32, color: Color(0xFF0064FF)),
title: Text(
item.title,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
subtitle: const Padding(padding: EdgeInsets.only(top: 8), child: Text('平台发布')),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/contents/${Uri.encodeComponent(item.identity)}'),
),
const Divider(height: 1),
],
],
);
},
),
);
}

View File

@@ -2,13 +2,28 @@
// 版本:1.0.0
import 'package:flutter/material.dart';
import 'package:heqi_design_system/heqi_design_system.dart';
import '../../../domain/models/primary_models.dart';
/// 服务归属信息卡片,负责清晰区分气站主体与配送履约网点。
class ServiceRelationCard extends StatelessWidget {
const ServiceRelationCard({required this.relation, super.key});
const ServiceRelationCard({
required this.relation,
this.emptyMessage = '尚未建立服务关系',
this.onTap,
super.key,
});
/// 以强类型归属创建卡片,保留旧构造函数供未迁移页面使用。
factory ServiceRelationCard.typed(ServiceRelation? value) => ServiceRelationCard(
relation: value == null
? null
: {'gas_name': value.gasName, 'delivery_name': value.deliveryName},
);
/// 服务端返回的当前有效服务关系;为空表示尚未建立归属。
final Map<String, Object?>? relation;
final String emptyMessage;
final VoidCallback? onTap;
/// 提取并清理指定关系字段,空字符串按无值处理。
String? _relationValue(String key) {
@@ -23,53 +38,60 @@ class ServiceRelationCard extends StatelessWidget {
final hasRelation = relation != null && gasName != null;
return HeqiSurfaceSection(
padding: const EdgeInsets.all(HeqiSpacing.x4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(HeqiRadius.extraLarge),
),
child: Icon(
Icons.store_mall_directory_outlined,
size: HeqiSize.iconLarge,
color: Theme.of(context).colorScheme.primary,
semanticLabel: '服务归属',
),
padding: EdgeInsets.zero,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.all(HeqiSpacing.x3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(HeqiRadius.extraLarge),
),
child: Icon(
Icons.store_mall_directory_outlined,
size: 30,
color: Theme.of(context).colorScheme.primary,
semanticLabel: '服务归属',
),
),
const SizedBox(width: HeqiSpacing.x3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('当前服务归属', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: HeqiSpacing.x1),
if (!hasRelation)
Text(
emptyMessage,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
else ...[
_RelationLine(label: '所属气站', value: gasName),
const Padding(
padding: EdgeInsets.symmetric(vertical: HeqiSpacing.x1),
child: Divider(height: 1),
),
_RelationLine(
label: deliveryName == null ? '服务方式' : '服务配送点',
value: deliveryName ?? '气站直接服务',
),
],
],
),
),
],
),
const SizedBox(width: HeqiSpacing.x4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('当前服务归属', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: HeqiSpacing.x3),
if (!hasRelation)
Text(
'尚未建立服务关系',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
else ...[
_RelationLine(label: '所属气站', value: gasName),
const Padding(
padding: EdgeInsets.symmetric(vertical: HeqiSpacing.x3),
child: Divider(height: 1),
),
_RelationLine(
label: deliveryName == null ? '服务方式' : '服务配送点',
value: deliveryName ?? '气站直接服务',
),
],
],
),
),
],
),
),
);
}